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

MCP feed and asks take the filter the owner saved, and the manifest says so #1935

Merged opened this

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

Merged

scout/forge:scout/1599-mcp-feed-and-asks-take-the-filter-the-ow → main 9 commits fork 4 threads · 4 unresolved

  • Merged. scout/1599-mcp-feed-and-asks-take-the-filter-the-ow was merged into main as 4db2728.

Review

Sign in to review this change.
9 files changed +159 −17
README.md +1 −1
@@ -324,7 +324,7 @@ and `forge sync` below is what acts on it.
324324
325325 **A wave proposal is one card.** With "plans need a nod" on, a plan the planner writes is kept as a proposal and opens nothing. It is one row in the asks list (home, `/asks`, `/api/v1/asks`, MCP `asks`, the terminal's On you card) with its title, its why and its issue titles, all escaped, and it asks the app's owner only. Three taps, each one POST that leaves you on the page you were on: **Approve** opens the issues with their links, exactly as an unheld plan would; **Redirect** takes a text box and hands your words to the planner as a new run on the direction issue; **Later** hides it until you bring it back, from the "N waves put aside" line under the asks (each with a "Bring back" button). A proposal is decided once. The same four verbs (`approve`, `redirect`, `later`, `again`) are on every door, all calling `actions/proposals.ts`: the page (`/asks/proposal/<id>/<verb>`), the API (`POST /api/v1/asks/proposal/<id>/<verb>`, a `rw` token, `{"words": …}` for redirect), the MCP tool `asks_proposal` (`id`, `verb`, `words`), and in the terminal `y` approve, `R` redirect, `l` later and `B` bring every put-aside wave back, on a wave row of the On you card.
326326
327 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox. `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back.
327 **Chips on the feed**: kind chips switch a kind of post off: `reviews` (pulls), `decisions` (decisions and choose decks), `crew` (tells, replies, looks) and `releases`; a person chip switches one person or agent off. Saved per user, one filter (`chipHides`) behind the home feed, `/api/v1/feed`, the Asks list and the terminal's inbox, and the MCP `feed` and `asks` tools, which also take `hide_kinds` and `hide_people` to replace the saved filter for that one call (`[]` lifts it). `/live/apps` (your apps) is not feed items, so chips do not apply there. `GET /api/v1/feed/chips` answers `{kinds, people}`; `POST /api/v1/feed/chips` takes `{"kind": "kind"|"person", "value": "reviews"|"<username>", "hidden": true|false}` (scope `rw`, same-origin). In the terminal dashboard `1`–`4` switch the kind chips `h` on an On you row switches its author off (not your own) and the author's rows leave the card, and `P` brings every person back.
328328
329329 **On you, in the terminal**, answers to the same mutes and one-move verbs as
330330 the web card. Repositories you muted are hidden and counted ("2 repositories
src/core/feed_chips.ts +31
@@ -51,3 +51,34 @@ export function setChip(
5151 "DELETE FROM feed_chip WHERE viewer_id = ?1 AND kind = ?2 AND value = ?3",
5252 ).run(viewerId, chip.kind, chip.value);
5353 }
54
55 /**
56 * A filter given as arguments (`hide_kinds`, `hide_people`, the latter by
57 * username): the chips it names, or why it is not one. Absent is undefined —
58 * the viewer's saved chips stand; given, it replaces them for that call.
59 */
60 export function parseChipArgs(
61 args: { hide_kinds?: unknown; hide_people?: unknown },
62 userId: (name: string) => string | undefined,
63 ): Chips | undefined | { error: string } {
64 const { hide_kinds: k, hide_people: p } = args;
65 if (k === undefined && p === undefined) return undefined;
66 const kinds = new Set<KindChip>();
67 const people = new Set<string>();
68 const list = (v: unknown): unknown[] | undefined =>
69 v === undefined ? [] : Array.isArray(v) && v.length <= 50 ? v : undefined;
70 const ks = list(k);
71 const ps = list(p);
72 if (ks === undefined || ps === undefined)
73 return { error: "hide_kinds and hide_people are lists of at most 50" };
74 for (const v of ks) {
75 if (!isKindChip(v)) return { error: `hide_kinds are ${KIND_CHIPS.join(", ")}` };
76 kinds.add(v);
77 }
78 for (const v of ps) {
79 const id = typeof v === "string" ? userId(v) : undefined;
80 if (id === undefined) return { error: "hide_people are usernames of people or agents here" };
81 people.add(id);
82 }
83 return { kinds, people };
84 }
src/feed/feed.ts +4 −3
@@ -7,7 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { chipsOf } from "../core/feed_chips.ts";
10 import { chipsOf, type Chips } from "../core/feed_chips.ts";
1111 import { chipHides } from "./chips.ts";
1212 import { dismissedItems, mutedRepos } from "../core/interactions.ts";
1313 import { affinityFor } from "./affinity.ts";
@@ -39,6 +39,7 @@ export function readFeed(
3939 limit?: number;
4040 cursor?: { score: number; id: string };
4141 now?: number;
42 chips?: Chips;
4243 /** The home pins asks above the feed: they must not fill its page. */
4344 withoutAsks?: boolean;
4445 } = {},
@@ -47,14 +48,14 @@ export function readFeed(
4748 const limit = Math.min(options.limit ?? FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX);
4849 const muted = mutedRepos(db, viewer.id);
4950 const dismissed = dismissedItems(db, viewer.id);
50 const chips = chipsOf(db, viewer.id);
51 const chips = options.chips ?? chipsOf(db, viewer.id);
5152 drainQueue(db, viewer.id, now);
5253 const shared = sharedCandidates(db, viewer, now).filter(
5354 (c) => !dismissed.has(c.itemId),
5455 );
5556 const boosted = new Set(shared.map((c) => c.itemId));
5657 const candidates = [
57 ...candidatesFor(db, viewer, now).filter((c) => !boosted.has(c.id)),
58 ...candidatesFor(db, viewer, now, chips).filter((c) => !boosted.has(c.id)),
5859 ...shared,
5960 ]
6061 // The chips: the one filter. An ask was filtered by it in `askLinesFor`.
src/feed/items.ts +3 −1
@@ -7,6 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import type { Chips } from "../core/feed_chips.ts";
1011 import { listDecidedIn } from "../core/decisions.ts";
1112 import { audienceFilter } from "../core/event_audience.ts";
1213 import { listEventsVisibleTo, type ForgeEvent } from "../core/events.ts";
@@ -199,12 +200,13 @@ export function candidatesFor(
199200 db: Database,
200201 viewer: User,
201202 now: number,
203 chips?: Chips,
202204 ): Candidate[] {
203205 const events = listEventsVisibleTo(db, viewer, { limit: EVENT_WINDOW }).filter(
204206 audienceFilter(db, viewer),
205207 );
206208 const writable = listReposWritableTo(db, viewer, { limit: 200 });
207 const asks = askLinesFor(db, viewer);
209 const asks = askLinesFor(db, viewer, chips);
208210 const decided = listDecidedIn(
209211 db,
210212 writable.map((r) => r.id),
src/http/mcp.ts +36 −6
@@ -102,7 +102,7 @@ import {
102102 import { plannerDeclines } from "../crew/autonomy.ts";
103103 import { checkSummary, listActiveRuns } from "../core/runs.ts";
104104 import { scopeAllows } from "../core/tokens.ts";
105 import { getUsersByIds, type User } from "../core/users.ts";
105 import { getUserByName, getUsersByIds, type User } from "../core/users.ts";
106106 import { openapi } from "../contract/openapi.ts";
107107 import { audit } from "../contract/audit.ts";
108108 import { probeApp } from "../contract/probes.ts";
@@ -120,6 +120,7 @@ import {
120120 milestoneData,
121121 milestonesJson,
122122 } from "../web/pages/milestones.ts";
123 import { KIND_CHIPS, parseChipArgs } from "../core/feed_chips.ts";
123124 import { appsOf, asksAnswer } from "../web/studio/asks.ts";
124125 import { decideAsk, undoDecideAsk } from "../actions/asks_decide.ts";
125126 import { actOnProposal, isProposalVerb, PROPOSAL_VERBS } from "../actions/proposals.ts";
@@ -198,6 +199,29 @@ interface Tool {
198199 run(ctx: Ctx, user: User, args: Args): Promise<Answer> | Answer;
199200 }
200201
202 const CHIP_ARGS = {
203 hide_kinds: {
204 type: "array",
205 items: { type: "string", enum: [...KIND_CHIPS] },
206 maxItems: 50,
207 description: "Kinds to leave out (reviews, decisions, crew, releases). Given, it replaces your saved filter for this call.",
208 },
209 hide_people: {
210 type: "array",
211 items: { type: "string", maxLength: 39 },
212 maxItems: 50,
213 description: "Usernames of people or agents to leave out. Given, it replaces your saved filter for this call.",
214 },
215 } as const;
216
217 /** The filter a call names, or undefined for the viewer's saved one. */
218 function chipArgs(ctx: Ctx, args: Record<string, unknown>) {
outdated

Low, not blocking: this resolves any username. The distinct error for an unknown name tells the caller whether that account exists. That's fine if usernames are public on the forge. If any are not (for example a sealed or suspended user), resolve only people the viewer can already see.

219 return parseChipArgs(args, (name) => {

Good: organizations are refused and the name is trimmed. The schema's maxLength: 39 is advice to the client and isn't enforced here. That's harmless, because a parameterized lookup on a long string just misses.

220 const who = getUserByName(ctx.app.db, name.trim());
221 return who && who.kind !== "org" ? who.id : undefined;
222 });
223 }
224
201225 const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
202226
203227 const REPO_ARG = {
@@ -589,11 +613,13 @@ const TOOLS: readonly Tool[] = [
589613 {
590614 name: "asks",
591615 description:
592 "What waits on you, most consequential first: each row names the decision, its options and what happens if nobody acts, and why it ranks where it does (security, money, held-up work, then age). Rows of one kind in one app are collapsed with their item ids. Also the forge's own needs (for a site admin) and the last 7 days' asks raised, acted on, dismissed and gone by themselves.",
593 inputSchema: { type: "object", properties: {} },
616 "What waits on you, most consequential first: each row names the decision, its options and what happens if nobody acts, and why it ranks where it does (security, money, held-up work, then age). Rows of one kind in one app are collapsed with their item ids. Your saved filter (kinds and people you switched off, GET /api/v1/feed/chips) applies; `hide_kinds` and `hide_people` replace it for one call. Also the forge's own needs (for a site admin) and the last 7 days' asks raised, acted on, dismissed and gone by themselves.",
617 inputSchema: { type: "object", properties: { ...CHIP_ARGS } },
594618 writes: false,
595 run(ctx, user) {
596 return good(asksAnswer(ctx, user));
619 run(ctx, user, args) {
620 const chips = chipArgs(ctx, args);
621 if (chips && "error" in chips) return bad("invalid", chips.error);
622 return good(asksAnswer(ctx, user, chips));
597623 },
598624 },
599625 {
@@ -1337,16 +1363,19 @@ const TOOLS: readonly Tool[] = [
13371363 {
13381364 name: "feed",
13391365 description:
1340 "Your home timeline, as the forge's home shows it: tells, replies, runs, changes and looks across the apps you can see, ranked, each with who did it, what it says, why it is there and a link to its thing. Pass `cursor` (next_cursor from the last answer) for the next page. The same items as GET /api/v1/feed.",
1366 "Your home timeline, as the forge's home shows it: tells, replies, runs, changes and looks across the apps you can see, ranked, each with who did it, what it says, why it is there and a link to its thing. Pass `cursor` (next_cursor from the last answer) for the next page. The same items as GET /api/v1/feed. Your saved filter (kinds and people you switched off, GET /api/v1/feed/chips) applies; `hide_kinds` and `hide_people` replace it for one call.",
13411367 inputSchema: {
13421368 type: "object",
13431369 properties: {
13441370 limit: { type: "integer", minimum: 1, maximum: FEED_LIMIT_MAX },
13451371 cursor: { type: "string" },
1372 ...CHIP_ARGS,
13461373 },
13471374 },
13481375 writes: false,
13491376 run(ctx, user, args) {
1377 const chips = chipArgs(ctx, args);
1378 if (chips && "error" in chips) return bad("invalid", chips.error);
13501379 const raw = args["limit"];
13511380 const limit = raw === undefined ? FEED_LIMIT_DEFAULT : Number(raw);
13521381 if (!Number.isInteger(limit) || limit < 1 || limit > FEED_LIMIT_MAX)
@@ -1358,6 +1387,7 @@ const TOOLS: readonly Tool[] = [
13581387 return good(
13591388 readFeed(ctx.app.db, user, {
13601389 limit,
1390 ...(chips === undefined ? {} : { chips }),
13611391 ...(cursor === undefined ? {} : { cursor }),
13621392 }),
13631393 );
src/web/studio/asks.ts +9 −6
@@ -19,7 +19,7 @@ import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
1919 import { latestDecision, stopStands } from "../../core/decisions.ts";
2020 import { getHold, holdsFor } from "../../core/holds.ts";
2121 import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.ts";
22 import { chipsOf } from "../../core/feed_chips.ts";
22 import { chipsOf, type Chips } from "../../core/feed_chips.ts";
2323 import { chipHides } from "../../feed/chips.ts";
2424 import { askDismissals, mutedRepos } from "../../core/interactions.ts";
2525 import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts";
@@ -605,10 +605,11 @@ function notDismissed<T extends AskCandidate>(
605605 db: Database,
606606 user: User,
607607 asks: readonly T[],
608 given?: Chips,
608609 ): T[] {
609610 const muted = mutedRepos(db, user.id);
610611 const dismissed = askDismissals(db, user.id);
611 const chips = chipsOf(db, user.id);
612 const chips = given ?? chipsOf(db, user.id);
612613 // A change waiting on review is a `pull`; anything else is a decision.
613614 // The author is the actor the person chip names.
614615 const chipped = (a: T): boolean =>
@@ -798,9 +799,10 @@ function waitingOn(
798799 db: Database,
799800 user: User | undefined,
800801 repos: readonly Repo[],
802 chips?: Chips,
801803 ): Scored[] {
802804 if (!user) return [];
803 return notDismissed(db, user, scoredFor(db, user, repos));
805 return notDismissed(db, user, scoredFor(db, user, repos), chips);
804806 }
805807
806808 /** How an ask that is no longer asked ended, when nobody dismissed it. */
@@ -957,6 +959,7 @@ export const askAnchor = (id: string): string => id.replace(/[^a-z0-9-]/g, "-");
957959 export function askLinesFor(
958960 db: Database,
959961 user: User,
962 chips?: Chips,
960963 ): {
961964 id: string;
962965 line: string;
@@ -979,7 +982,7 @@ export function askLinesFor(
979982 waitingFor: WaitingFor;
980983 }[] {
981984 const repos = listReposVisibleTo(db, user, { limit: 200 });
982 return askRows(waitingOn(db, user, repos)).map((row) => {
985 return askRows(waitingOn(db, user, repos, chips)).map((row) => {
983986 const base = `/${row.repo.ownerName}/${row.repo.name}`;
984987 const about = {
985988 id: row.id,
@@ -1368,10 +1371,10 @@ export function askSentence(count: number): string {
13681371 * admin, and the week's measure of asks raised against acted on, dismissed
13691372 * and gone by themselves. `GET /api/v1/asks` and the MCP's `asks` read this.
13701373 */
1371 export function asksAnswer(ctx: Ctx, user: User) {
1374 export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) {
13721375 const since = new Date(Date.now() - 7 * 86_400_000).toISOString();
13731376 return {
1374 rows: askLinesFor(ctx.app.db, user),
1377 rows: askLinesFor(ctx.app.db, user, chips),
13751378 forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)),
13761379 measure: { days: 7, ...askMeasure(ctx.app.db, user.id, since) },
13771380 };
test/asks-mute.test.ts +37
@@ -23,6 +23,7 @@ import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2323 import { createToken } from "../src/core/tokens.ts";
2424 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2525 import { serve, type Servers } from "../src/http/server.ts";
26 import { mcpTool } from "./helpers/mcp.ts";
2627 import { askCountFor, askLinesFor, repoAsks } from "../src/web/studio/asks.ts";
2728 import { quietLogs } from "./helpers/globals.ts";
2829
@@ -218,4 +219,40 @@ describe("muting a repository's asks", () => {
218219 setChip(app.db, alice.id, { kind: "person", value: bot.id }, false);
219220 expect(lines()).toBe(1);
220221 });
222
223 test("MCP asks applies the saved filter, and takes the same filter as arguments", async () => {
224 const token = createToken(app.db, alice.id, "ro", "ro").token;
225
outdated

This line is indented 6 spaces inside a 4-space block. The blank line after test( in feed-chips.test.ts has the same problem. Also, this tool helper is a copy of the one at test/mcp.test.ts:60 and is pasted again in feed-chips.test.ts. Either indent it correctly here, or move it to a shared helper in test/ and import it in both files.

outdated

This helper is misindented and is a word-for-word copy of the one added in test/feed-chips.test.ts. Indent it like the code around it, and keep one copy in the shared test helpers.

226 const tool = (token: string, name: string, args: unknown = {}) =>
227 mcpTool(servers.url, token, name, args);
228 const bot = getUserByName(app.db, "bot")!;
229 const rows = async (args: unknown = {}) => ((await tool(token, "asks", args)) as any).value.rows.length;
230 expect(await rows()).toBe(1);
231 // Given, the filter replaces the saved one for that call only.
232 expect(await rows({ hide_kinds: ["reviews"] })).toBe(0);
233 expect(await rows({ hide_people: ["bot"] })).toBe(0);
234 expect(await rows({ hide_people: ["bob"], hide_kinds: ["crew"] })).toBe(1);
235 expect(await rows({ hide_kinds: [] })).toBe(1);
236 // Saved, it applies with no arguments; an empty filter given lifts it.
237 setChip(app.db, alice.id, { kind: "person", value: bot.id }, true);
238 expect(await rows()).toBe(0);
239 expect(await rows({ hide_kinds: [] })).toBe(1);
240 setChip(app.db, alice.id, { kind: "person", value: bot.id }, false);
241 // Bad filters are refused, not guessed at.
242 expect(((await tool(token, "asks", { hide_kinds: ["nope"] })) as any).error).toContain("hide_kinds");
243 expect(((await tool(token, "asks", { hide_people: ["nobody"] })) as any).error).toContain("hide_people");
244 expect(((await tool(token, "asks", { hide_kinds: "reviews" })) as any).error).toContain("lists");
245 // The manifest says so.
246 const res = await fetch(`${servers.url}/mcp`, {
247 method: "POST",
248 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
249 body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
250 });
251 const tools = ((await res.json()) as any).result.tools as any[];
252 for (const name of ["asks", "feed"]) {
253 const t = tools.find((x) => x.name === name);
254 expect(t.description).toContain("saved filter");
255 expect(Object.keys(t.inputSchema.properties)).toEqual(expect.arrayContaining(["hide_kinds", "hide_people"]));
256 }
257 });
221258 });
test/feed-chips.test.ts +18
@@ -14,6 +14,7 @@ import { chipsOf, KIND_CHIPS, noChips, setChip } from "../src/core/feed_chips.ts
1414 import type { Repo } from "../src/core/repos.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { serve, type Servers } from "../src/http/server.ts";
17 import { mcpTool } from "./helpers/mcp.ts";
1718 import { chipHides } from "../src/feed/chips.ts";
1819 import type { FeedKind } from "../src/feed/items.ts";
1920 import { readFeed } from "../src/feed/feed.ts";
@@ -173,4 +174,21 @@ describe("the chips of a forge", () => {
173174 expect(page).toMatch(/data-chip="person"\s+data-value="alice"/);
174175 setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false);
175176 });
177
178 test("MCP feed applies the saved filter and takes the same filter as arguments", async () => {
179 const tool = (token: string, name: string, args: unknown = {}) =>
outdated

This is the same pasted helper, after a stray blank line and misindented. Remove the duplication (see test/asks-mute.test.ts).

180 mcpTool(servers.url, token, name, args);
181 const kindsVia = async (args: unknown = {}) =>
182 (((await tool(bobToken, "feed", args)) as any).value.items as { kind: string }[]).map((i) => i.kind).sort();
183 expect(await kindsVia()).toEqual(["look", "pull", "release", "run"]);
184 expect(await kindsVia({ hide_kinds: ["releases"] })).toEqual(["look", "pull", "run"]);
185 expect(await kindsVia({ hide_people: ["carol"] })).toEqual(["look", "release", "run"]);
186 // Saved, it applies by default; given, the arguments replace it.
187 setChip(app.db, bob.id, { kind: "kind", value: "crew" }, true);
188 expect(await kindsVia()).toEqual(["pull", "release", "run"]);
189 expect(await kindsVia({ hide_kinds: [] })).toEqual(["look", "pull", "release", "run"]);
190 setChip(app.db, bob.id, { kind: "kind", value: "crew" }, false);
191 expect(((await tool(bobToken, "feed", { hide_kinds: ["x"] })) as any).error).toContain("hide_kinds");
192 expect(((await tool(bobToken, "feed", { hide_people: [5] })) as any).error).toContain("hide_people");
193 });
176194 });
test/helpers/mcp.ts added +20
@@ -0,0 +1,20 @@
1 // One MCP tools/call over HTTP, answered as { value } or { error }.
2 export const mcpTool = async (
3 base: string,
4 token: string,
5 name: string,
6 args: unknown = {},
7 ): Promise<{ value?: any; error?: string }> => {
8 const res = await fetch(`${base}/mcp`, {
9 method: "POST",
10 headers: {
11 "content-type": "application/json",
12 accept: "application/json, text/event-stream",
13 authorization: `Bearer ${token}`,
14 },
15 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
16 });
17 const body = (await res.json()) as any;
18 const text: string = body.result.content[0].text;
19 return body.result.isError === true ? { error: text } : { value: JSON.parse(text) };
20 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs