Public: anyone can read this; the crew can change it.

Feed model and ranker: a per-viewer read model of forge events, ranked, each item with a one-sentence reason #364

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The feed model, ranker and API are in: a per-viewer read of forge events, asks and decision outcomes, ranked, each item with a one-sentence reason. `tsc` is clean and the new tests pass. I also ran the looks, asks, notify, event-audience, map and migrate suites alongside them, and they pass. I did not run the full `forge.json` check or the whole test suite; `test/feed.test.ts` is added to the chec…

shipped production is serving it deploy 1f3280d

opened this

The feed model, ranker and API are in: a per-viewer read of forge events, asks and decision outcomes, ranked, each item with a one-sentence reason. tsc is clean and the new tests pass. I also ran the looks, asks, notify, event-audience, map and migrate suites alongside them, and they pass. I did not run the full forge.json check or the whole test suite; test/feed.test.ts is added to the check list.

What changed

  • GET /api/v1/feed?limit=&cursor= returns {items, next_cursor} for the signed-in viewer only. An item is {id, kind, app, actor, at, link, reason, score}, and kind is one of run, ask, pull, decision, map or cost. The limit and cursor are validated.
  • Where items come from (src/feed/items.ts):

- Events go through listEventsVisibleTo and then audienceFilter. - Asks come from askLinesFor, so only what the viewer may decide. - Decision outcomes appear only for repos the viewer can write. - Cost items need owner-facts access.

  • POST /api/v1/feed/interactions records opened, dwelled, steered, told, swiped, shipped, shared, dismissed or muted in a new interaction table (migration 35). Browser sessions are same-origin checked and tokens need rw. An event: item takes its repo and actor from the event; any other item names its app as owner/name. Anything the viewer can't read returns 404, so nothing leaks.
  • Events: runs already emit run.* events. Looks emitted nothing, so I added look.taken, sent when a full set of pictures is taken. askLinesFor now also returns kind, repoId and number.
  • Affinity (src/feed/affinity.ts) is a decayed count per app and per actor with a 7-day half-life. It is seeded from ownership, pins, crew, the viewer's own merges, proposals and tells, and ledger outcomes, then moved by interactions.
  • Ranking (src/feed/rank.ts): score = urgency + 1.5·affinity + recency − repeats.

- Each item already placed from the same app costs 1.2, and from the same actor 0.6. - A dismissal is −4 and a mute is −8. - The reason is the sentence for the term that led, for example because you shipped “Fix the nav” yesterday.

  • Docs and tests: docs/FEED.md describes the feed and the CLAUDE.md map has a line for src/feed/. The tests cover ranking order, reason text, repeat decay, affinity decay, dismiss, mute, no leak across viewers (private repos, webhook events), cursor paging, input validation and a cross-origin refusal.

Choices to check

  • Muting vs asks: mute hides an app's items except asks, because something waiting on a person should not be muted away. Dismissing hides a single item.
  • Cursor: the feed is recomputed on every request. The cursor is the last item's score|id and resumes by id, or by score if that item is gone. Placed scores never rise, so paging is stable.
  • No new feed table: there is no stored affinity table. Affinity is derived at read time from the last 1000 interactions plus the seeds.

Not done

  • Left out as asked: UI, share and offline.
  • "Following": there is no social-follow concept in the forge, so it isn't a seed.
  • Mute scope: mute is per app. There is no unmute and no per-actor mute.
  • Dependencies: none added.

To try it: bun test test/feed.test.ts, or call GET /api/v1/feed with a token.

🤖 Generated with Claude Code

Closes #360

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs