Skip to content

Commit

Chat, artifacts and notifications no longer serve g1t's own agents. Direct messages and conversation invites take people only, and a space or folio is shared with people and teams. Chat stops waking agents, drops the RPCs agents posted, read, searched and handed off through, and stops answering buttons on agent cards; old cards keep their links. An agent's old messages still show, under its saved name or as "A deleted agent". Artifacts drops the folio and docs RPCs agents read and edited pages with, and an old agent member of a space can still be removed. Notifications drop "an agent is waiting on you". A deployment no longer trusts a pull request with secrets just because g1t's agent opened it, and the sign-up card no longer promises a team of agents.

syntaqxcommitted Parent7a99bc7Browse files
38 files+174−30590/38 viewed
+2−13
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { agentAbilities, atLeast, leavesNoManager, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
4+import { atLeast, leavesNoManager, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
55
66 const ana: Person = { user_id: "u1", owner: false, teams: new Set(["web"]) };
77 const bo: Person = { user_id: "u2", owner: false, teams: new Set() };
4141 assert.equal(roleOf(secret, ana), "manage");
4242 });
4343
44−test("an audience reads a space only if every person in it can", () => {
45− assert.equal(readableByAll(team, [ana]), true);
46− assert.equal(readableByAll(team, [ana, bo]), false);
47− assert.equal(readableByAll(workspace, [ana, bo]), true);
44+test("a space is readable by the whole workspace only when it is open with a base role", () => {
4845 assert.equal(readableByWorkspace(workspace), true);
4946 assert.equal(readableByWorkspace(team), false);
5047 assert.equal(readableByWorkspace({ ...workspace, default_role: null }), false);
51−});
52−
53−test("an agent is capped by the person it acts for and by the space's agent mode", () => {
54− assert.deepEqual(agentAbilities("edit", "suggest"), { read: true, suggest: true, edit: false, attach: true }, "a file can be attached where the person could edit, whatever the agent mode");
55− assert.deepEqual(agentAbilities("edit", "edit"), { read: true, suggest: true, edit: true, attach: true });
56− assert.deepEqual(agentAbilities("view", "edit"), { read: true, suggest: false, edit: false, attach: false });
57− assert.deepEqual(agentAbilities("comment", "edit"), { read: true, suggest: true, edit: false, attach: false });
58− assert.deepEqual(agentAbilities(null, "edit"), { read: false, suggest: false, edit: false, attach: false });
5948 });
6049
6150 test("roles order view < comment < edit < manage", () => {
+7−53
11 /**
22 * Who may do what in a space: pure, so the rules are tested apart from the
3− * service (docs.g1t.sh/guides/agent-access/, "Rule one: the asker's access
4− * caps the agent").
3+ * service.
54 *
65 * - `workspace` spaces give every member `default_role`; `team` spaces give
76 * the team's members `default_role`; `private` spaces give nobody
87 * anything by default.
9− * - Listed members (`user:<id>`, `agent:<id>`, `team:<slug>`) add to that:
8+ * - Listed members (`user:<id>`, `team:<slug>`) add to that:
109 * a person's role is the highest that applies to them.
1110 * - Workspace owners manage every workspace and team space. A private
1211 * space is its members' alone, owners included.
13− * - An agent acts for a person: it reads what they read, suggests where
14− * they can comment, and edits directly only where they can edit and the
15− * space lets agents edit.
1612 */
17−import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts";
13+import type { DocRole, DocSpaceKind } from "@g1t/contracts";
1814
1915 export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 };
2016
6561 }
6662 if (person.owner && space.kind !== "private") role = "manage";
6763 return role;
68−}
69−
70−/** Whether every one of `people` can read the space; for an agent answering to an audience. */
71−export function readableByAll(space: SpaceRules, people: Person[]): boolean {
72− return people.every((person) => atLeast(roleOf(space, person), "view"));
7364 }
7465
7566 /**
76− * Whether a space is readable by "everyone in the workspace": a public
77− * channel's audience. Only a workspace space with a base role is.
67+ * Whether a space is readable by "everyone in the workspace". Only a
68+ * workspace space with a base role is.
7869 */
7970 export function readableByWorkspace(space: SpaceRules): boolean {
8071 return space.kind === "workspace" && atLeast(space.default_role, "view");
81−}
82−
83−/**
84− * What an agent may do for a person whose role is `asker`, in a space whose
85− * agents `mode`. Editing the body follows the mode; attaching a file it
86− * made only needs the person to be able to edit, since the body stays as
87− * it is.
88− */
89−export function agentAbilities(asker: DocRole | null, mode: DocAgentMode): DocAgentAbilities {
90− return {
91− read: atLeast(asker, "view"),
92− suggest: atLeast(asker, "comment"),
93− edit: atLeast(asker, "edit") && mode === "edit",
94− attach: atLeast(asker, "edit"),
95− };
9672 }
9773
9874 /** Whether `role` holders may change who is in a space and its settings. */
135111 // role, `link` gives it to members who opened the link (a visit).
136112 //
137113 // A restricted folio (`inherit` false) is its own access root: grants
138−// above it, its space and its parent's general access stop there. An
139−// agent never has more than the person it acts for, narrowed to what
140−// everyone it is talking to can read.
114+// above it, its space and its parent's general access stop there.
141115
142116 /** One folio as access needs it. */
143117 export type FolioAclNode = {
257231 }
258232
259233 /**
260− * Whether "everyone in the workspace" can read a folio: a public
261− * channel's audience. Only through an open space it inherits (with a base
234+ * Whether "everyone in the workspace" can read a folio. Only through an open space it inherits (with a base
262235 * role) or general access `workspace`; never a link, grants or Private.
263236 */
264237 export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
266239 if (!root) return false;
267240 if (root.general_access === "workspace") return true;
268241 return inheritsSpace(chain) && !!space && readableByWorkspace(space);
269−}
270−
271−/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
272−export function folioReadableByAll(
273− chain: readonly FolioAclNode[],
274− grants: FolioGrants,
275− space: SpaceRules | null,
276− people: readonly Person[],
277− visited: (person: Person) => boolean = () => false,
278−): boolean {
279− return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
280242 }
281243
282244 /**
313275 return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
314276 }
315277
316−/**
317− * An agent's role on a folio: never more than its asker's, and nothing
318− * when someone it is talking to can't read the folio. The agent's own
319− * grants never widen this; they make it a participant, nothing more.
320− */
321−export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
322− return audienceCanRead ? askerRole : null;
323−}
+0−31
44 import {
55 aclChain,
66 aclRootOf,
7− agentAbilities,
8− agentFolioRole,
97 canShare,
108 effectiveRole,
119 explicitAccess,
1210 folioPathOf,
13− folioReadableByAll,
1411 folioReadableByWorkspace,
1512 folioScope,
1613 generalRoleCap,
214211 // Restricted inside an open space: not the workspace's.
215212 const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]);
216213 assert.equal(folioReadableByWorkspace(aclChain("b", restricted), open), false);
217−});
218−
219−test("readable by an audience only when every person in it can read", () => {
220− const nodes = new Map([["f", node("f", { owner: "user:ana" })]]);
221− const chain = aclChain("f", nodes);
222− const grants = new Map([["f", [{ principal: "user:bo", role: "view" as const }]]]);
223− assert.equal(folioReadableByAll(chain, grants, null, [ana, bo]), true);
224− assert.equal(folioReadableByAll(chain, grants, null, [ana, bo, cy]), false);
225− const link = new Map([["l", node("l", { general_access: "link", general_role: "view" })]]);
226− assert.equal(folioReadableByAll(aclChain("l", link), new Map(), null, [ana, bo]), false);
227− assert.equal(
228− folioReadableByAll(aclChain("l", link), new Map(), null, [ana, bo], (p) => p.user_id === "bo"),
229− true,
230− );
231−});
232−
233−test("an agent is capped by its asker and narrowed by its audience", () => {
234− // The agent holds an edit grant, its asker only view: it may only read.
235− const nodes = [node("f", { owner: "user:cy" })];
236− const grants = { f: [{ principal: "agent:ag1", role: "edit" as const }, { principal: "user:bo", role: "view" as const }] };
237− const asker = roleIn(nodes, grants, "f", bo);
238− assert.equal(asker, "view");
239− assert.equal(agentFolioRole(asker, true), "view");
240− assert.deepEqual(agentAbilities(agentFolioRole(asker, true), "edit"), { read: true, suggest: false, edit: false, attach: false });
241− // Someone in the conversation can't read it: the agent can't either.
242− assert.equal(agentFolioRole("manage", false), null);
243− // Someone who can't read it gets nothing from the agent's grant.
244− assert.equal(agentFolioRole(roleIn(nodes, grants, "f", ana), true), null);
245214 });
246215
247216 test("who may share", () => {
+0−96
1−import assert from "node:assert/strict";
2−import { test } from "node:test";
3−
4−import { aclChain, type FolioAclNode, type FolioGrant, type Person, type SpaceRules } from "../access.ts";
5−import { agentMayFind, agentReach, audienceRule, type AudienceRule } from "./agents.ts";
6−
7−// The leak rules (docs.g1t.sh/guides/agent-access/).
8−
9−const ana: Person = { user_id: "ana", owner: false, teams: new Set(["web"]) };
10−const bo: Person = { user_id: "bo", owner: false, teams: new Set() };
11−const cy: Person = { user_id: "cy", owner: false, teams: new Set(["web"]) };
12−
13−const open: SpaceRules = { kind: "workspace", team: null, default_role: "edit", members: [] };
14−const team: SpaceRules = { kind: "team", team: "web", default_role: "edit", members: [] };
15−
16−function node(id: string, over: Partial<FolioAclNode> = {}): FolioAclNode {
17− return { id, owner: "user:ana", parent_id: null, space_id: null, inherit: true, general_access: "none", general_role: null, ...over };
18−}
19−
20−function reach(n: FolioAclNode, grants: Record<string, FolioGrant[]>, space: SpaceRules | null, rule: AudienceRule, people: Person[] = [], visits: string[] = [], asker = ana) {
21− return agentReach({
22− chain: aclChain(n.id, new Map([[n.id, n]])),
23− grants: new Map(Object.entries(grants)),
24− space,
25− asker,
26− askerVisited: visits.includes(asker.user_id),
27− rule,
28− people,
29− visited: (p) => visits.includes(p.user_id),
30− agent_mode: "edit",
31− });
32−}
33−
34−test("audience rules: none or only the asker is the asker; more than 20 people is the workspace", () => {
35− assert.deepEqual(audienceRule(null, "ana"), { kind: "asker" });
36− assert.deepEqual(audienceRule({ kind: "people", user_ids: ["ana"] }, "ana"), { kind: "asker" });
37− assert.deepEqual(audienceRule({ kind: "people", user_ids: ["ana", "bo", "bo"] }, "ana"), { kind: "people", user_ids: ["bo"] });
38− assert.deepEqual(audienceRule({ kind: "workspace" }, "ana"), { kind: "workspace" });
39− const crowd = Array.from({ length: 21 }, (_, i) => `u${i}`);
40− assert.deepEqual(audienceRule({ kind: "people", user_ids: crowd }, "ana"), { kind: "workspace" });
41− const twenty = Array.from({ length: 19 }, (_, i) => `u${i}`);
42− assert.equal(audienceRule({ kind: "people", user_ids: twenty }, "ana").kind, "people");
43−});
44−
45−test("rule 1: a public channel finds only open-space and workspace-wide folios", () => {
46− const ws: AudienceRule = { kind: "workspace" };
47− assert.equal(agentMayFind(reach(node("a", { space_id: "open" }), {}, open, ws)), true);
48− assert.equal(agentMayFind(reach(node("a", { general_access: "workspace", general_role: "view" }), {}, null, ws)), true);
49− // Private, shared, team, link: never in a public channel.
50− assert.equal(agentMayFind(reach(node("a"), {}, null, ws)), false);
51− assert.equal(agentMayFind(reach(node("a"), { a: [{ principal: "user:bo", role: "view" }] }, null, ws)), false);
52− assert.equal(agentMayFind(reach(node("a", { space_id: "team" }), {}, team, ws)), false);
53− assert.equal(agentMayFind(reach(node("a", { general_access: "link", general_role: "view" }), {}, null, ws, [], ["ana"])), false);
54−});
55−
56−test("rule 1: a conversation finds only what everyone in it can read", () => {
57− const withBo: AudienceRule = { kind: "people", user_ids: ["bo"] };
58− const shared = node("a");
59− assert.equal(agentMayFind(reach(shared, { a: [{ principal: "user:bo", role: "view" }] }, null, withBo, [bo])), true);
60− assert.equal(agentMayFind(reach(shared, {}, null, withBo, [bo])), false);
61− // A team space: Cy is in the team, Bo isn't.
62− const t = node("t", { space_id: "team" });
63− assert.equal(agentMayFind(reach(t, {}, team, { kind: "people", user_ids: ["cy"] }, [cy])), true);
64− assert.equal(agentMayFind(reach(t, {}, team, withBo, [bo])), false);
65− // A link folio: only when everyone opened it.
66− const link = node("l", { general_access: "link", general_role: "view" });
67− assert.equal(agentMayFind(reach(link, {}, null, withBo, [bo], ["ana"])), false);
68− assert.equal(agentMayFind(reach(link, {}, null, withBo, [bo], ["ana", "bo"])), true);
69−});
70−
71−test("rule 1: the asker's own Private is theirs alone", () => {
72− assert.equal(agentMayFind(reach(node("p"), {}, null, { kind: "asker" })), true);
73− assert.equal(agentMayFind(reach(node("p"), {}, null, { kind: "people", user_ids: ["bo"] }, [bo])), false);
74− // Someone else's Private: not even for its asker.
75− assert.equal(agentMayFind(reach(node("p", { owner: "user:bo" }), {}, null, { kind: "asker" })), false);
76−});
77−
78−test("rule 2: reading what the audience can't all read says so", () => {
79− const r = reach(node("p"), {}, null, { kind: "workspace" });
80− assert.equal(r.asker_role, "manage");
81− assert.equal(r.audience_can_read, false);
82− const fine = reach(node("o", { space_id: "open" }), {}, open, { kind: "workspace" });
83− assert.equal(fine.audience_can_read, true);
84−});
85−
86−test("an agent's grant never widens what it can do for its asker", () => {
87− const n = node("f", { owner: "user:cy" });
88− const grants = { f: [{ principal: "agent:ag1", role: "manage" as const }, { principal: "user:bo", role: "comment" as const }] };
89− const r = reach(n, grants, null, { kind: "asker" }, [], [], bo);
90− assert.equal(r.asker_role, "comment");
91− assert.deepEqual(r.can, { read: true, suggest: true, edit: false, attach: false }, "a commenter can't have a file attached for them either");
92− const none = reach(n, grants, null, { kind: "asker" }, [], [], ana);
93− assert.equal(none.asker_role, null);
94− assert.deepEqual(none.can, { read: false, suggest: false, edit: false, attach: false });
95− assert.equal(agentMayFind(none), false);
96−});
+0−83
1−/**
2− * What an agent may reach in folios for the person it acts for (its
3− * asker), and who else will see its answer (the audience): the leak rules
4− * (docs.g1t.sh/guides/agent-access/), apart from where rows come from.
5− * Pure.
6− *
7− * - The agent never has more than its asker (`agentFolioRole`).
8− * - Finding things (lists, search, recall, stale) is narrowed to folios
9− * every person in the audience can read, so nothing turns up in a
10− * conversation that someone in it can't open.
11− * - A public channel's audience is "the workspace": only folios readable
12− * through an open space or general access `workspace`. Never a link
13− * folio, a share, or Private. More than 20 people reads the same way.
14− * - Reading one folio the asker named (a link they gave) works whenever
15− * the asker can read it; the result says `audience_can_read: false`
16− * when someone else in the conversation can't, so the agent says it
17− * sent the link to the asker instead of quoting it.
18− */
19−import type { DocAgentAbilities, DocAgentMode, DocAudience, DocRole } from "@g1t/contracts";
20−
21−import { agentAbilities, agentFolioRole, effectiveRole, folioReadableByWorkspace, roleOf, type FolioAclNode, type FolioGrants, type Person, type SpaceRules } from "../access.ts";
22−
23−/** More people than this in a conversation read as the whole workspace. */
24−export const AUDIENCE_AS_WORKSPACE = 20;
25−
26−/** Who an answer reaches, as access checks it. */
27−export type AudienceRule =
28− /** The asker alone (no audience, or only them). */
29− | { kind: "asker" }
30− /** These other people besides the asker, by user id. */
31− | { kind: "people"; user_ids: string[] }
32− /** Everyone in the workspace. */
33− | { kind: "workspace" };
34−
35−export function audienceRule(audience: DocAudience | null | undefined, askerId: string): AudienceRule {
36− if (!audience) return { kind: "asker" };
37− if (audience.kind === "workspace") return { kind: "workspace" };
38− if (audience.kind !== "people" || !Array.isArray(audience.user_ids)) return { kind: "asker" };
39− const others = [...new Set(audience.user_ids.map(String))].filter((id) => id && id !== askerId);
40− if (!others.length) return { kind: "asker" };
41− if (others.length + 1 > AUDIENCE_AS_WORKSPACE) return { kind: "workspace" };
42− return { kind: "people", user_ids: others };
43−}
44−
45−/** One folio as an agent sees it. */
46−export type AgentReach = {
47− /** The asker's role, or null when they can't read it. */
48− asker_role: DocRole | null;
49− /** Whether everyone in the audience can read it too. */
50− audience_can_read: boolean;
51− /** What the agent may do: the asker's role, nothing more, by the folio's agent mode. */
52− can: DocAgentAbilities;
53−};
54−
55−export type ReachInput = {
56− chain: readonly FolioAclNode[];
57− grants: FolioGrants;
58− /** The folio's space's rules, when its chain inherits one (else null). */
59− space: SpaceRules | null;
60− asker: Person;
61− askerVisited: boolean;
62− rule: AudienceRule;
63− /** The audience's people (for a `people` rule). */
64− people: readonly Person[];
65− /** Whether a person opened the folio's link. */
66− visited: (person: Person) => boolean;
67− agent_mode: DocAgentMode;
68−};
69−
70−export function agentReach(input: ReachInput): AgentReach {
71− const spaceRole = (p: Person) => (input.space ? roleOf(input.space, p) : null);
72− const asker = effectiveRole(input.chain, input.grants, spaceRole(input.asker), input.asker, { visited: input.askerVisited });
73− let audience = true;
74− if (input.rule.kind === "workspace") audience = folioReadableByWorkspace(input.chain, input.space);
75− else if (input.rule.kind === "people") audience = input.people.every((p) => !!effectiveRole(input.chain, input.grants, spaceRole(p), p, { visited: input.visited(p) }));
76− const role = agentFolioRole(asker, true);
77− return { asker_role: role, audience_can_read: !!asker && audience, can: agentAbilities(role, input.agent_mode) };
78−}
79−
80−/** Whether an agent may find a folio (lists, search, recall): its asker and every person in the audience can read it. */
81−export function agentMayFind(reach: AgentReach): boolean {
82− return !!reach.asker_role && reach.audience_can_read;
83−}
+0−22
6060 NOTIFY?: ServiceBinding;
6161 EVENTS?: ServiceBinding;
6262 IDENTITY: ServiceBinding;
63− AGENTS: ServiceBinding;
6463 REPOS?: ServiceBinding;
6564 /** The rooms themselves, as the Worker binds them (mentions are checked through the service). */
6665 FOLIOS: DurableObjectNamespace<FolioRoom>;
318317
319318 async threads(): Promise<ReturnType<typeof listThreads>> {
320319 return listThreads(this.load());
321− }
322−
323− /** Shows an agent in everyone's presence row for a little while when it edits or suggests. */
324− async announce(key: string, name: string): Promise<void> {
325− let id = 0;
326− for (let i = 0; i < key.length; i++) id = (Math.imul(id, 31) + key.charCodeAt(i)) | 0;
327− const client = (id & 0x3fffffff) + 1;
328− const clock = Math.floor(Date.now() / 1000);
329− const state = JSON.stringify({ user: { name, color: "#b8a6ff", key, kind: "agent", avatar: "" } });
330− const update = encoding.createEncoder();
331− encoding.writeVarUint(update, 1);
332− encoding.writeVarUint(update, client);
333− encoding.writeVarUint(update, clock);
334− encoding.writeVarString(update, state);
335− const bytes = encoding.toUint8Array(update);
336− this.awareness.set(client, bytes);
337− this.clocks.set(client, clock);
338− const message = encoding.createEncoder();
339− encoding.writeVarUint(message, MESSAGE_AWARENESS);
340− encoding.writeVarUint8Array(message, bytes);
341− this.send(encoding.toUint8Array(message));
342320 }
343321
344322 /** Tells everyone with the folio open. */
+3−2
88 function contractMethods(): string[] {
99 const source = readFileSync(new URL("../../../../packages/contracts/src/folios.ts", import.meta.url), "utf8");
1010 const block = source.slice(source.indexOf("export const FOLIO_RPC_METHODS = ["), source.indexOf("] as const;"));
11− return [...block.matchAll(/^\s*"([a-z_]+)",/gm)].map((m) => m[1]!);
11+ // Leaving out the methods workspace agents called (`RetiredMethod` in rpc.ts).
12+ return [...block.matchAll(/^\s*"([a-z_]+)",/gm)].map((m) => m[1]!).filter((m) => !/_(for|as)_agent$/.test(m));
1213 }
1314
1415 test("every folio RPC method in the contract is answered, and nothing else", () => {
1920 });
2021
2122 test("Docs' own methods fall through to the legacy switch", () => {
22− for (const m of ["sidebar", "page", "create_page", "recall_for_agent", "toString", "__proto__", "constructor"]) assert.equal(folioHandler(m), null, m);
23+ for (const m of ["sidebar", "page", "create_page", "recall_for_agent", "folios_for_agent", "toString", "__proto__", "constructor"]) assert.equal(folioHandler(m), null, m);
2324 });
+8−11
1212 // eslint-disable-next-line @typescript-eslint/no-explicit-any
1313 type Handler = (service: Folios, args: any) => Promise<unknown>;
1414
15−export const FOLIO_RPC: Record<FolioRpcMethod, Handler> = {
15+/**
16+ * Methods workspace agents called, which g1t no longer has. The contract
17+ * may still list them until it is trimmed; nothing here answers them.
18+ */
19+type RetiredMethod = Extract<FolioRpcMethod, `${string}_for_agent` | `${string}_as_agent`>;
20+
21+export const FOLIO_RPC: Record<Exclude<FolioRpcMethod, RetiredMethod>, Handler> = {
1622 folio_list: (s, a) => s.list(a),
1723 folio_sidebar: (s, a) => s.sidebar(a),
1824 folio: (s, a) => s.folio(a),
5258 folio_threads: (s, a) => s.threads(a),
5359 query_tile: (s, a) => s.queryTile(a),
5460 query_dataset: (s, a) => s.queryDataset(a),
55− query_dataset_for_agent: (s, a) => s.queryDatasetForAgent(a),
56− folios_for_agent: (s, a) => s.foliosForAgent(a),
57− read_folio_for_agent: (s, a) => s.readForAgent(a),
58− create_folio_as_agent: (s, a) => s.createAsAgent(a),
59− edit_folio_as_agent: (s, a) => s.editAsAgent(a),
60− share_folio_as_agent: (s, a) => s.shareAsAgent(a),
61− attach_file_as_agent: (s, a) => s.attachAsAgent(a),
62− recall_folios_for_agent: (s, a) => s.recallForAgent(a),
63− stale_folios_for_agent: (s, a) => s.staleForAgent(a),
6461 mark_folio_current: (s, a) => s.markCurrent(a),
6562 reindex_folios: (s, a) => s.reindex(a),
6663 };
6764
6865 export function folioHandler(method: string): Handler | null {
69− return Object.prototype.hasOwnProperty.call(FOLIO_RPC, method) ? FOLIO_RPC[method as FolioRpcMethod] : null;
66+ return Object.prototype.hasOwnProperty.call(FOLIO_RPC, method) ? FOLIO_RPC[method as keyof typeof FOLIO_RPC] : null;
7067 }
+14−460
77 * the folio's chain, after the list SQL's coarse filter (`folio_access`,
88 * readable spaces, general access, link visits). Everything that changes
99 * a folio's content goes through its room (src/folios/room.ts); this
10− * class decides who may ask. Agents act for a person and never reach
11− * more than that person can, narrowed to their audience
12− * (src/folios/agents.ts).
10+ * class decides who may ask.
1311 */
1412 import {
1513 DOCS_VIEWER_HEADER,
2927 notifyClient,
3028 ok,
3129 parsePrincipalKey,
32− principalKey,
3330 reposClient,
3431 type DocAgentMode,
35− type DocAudience,
3632 type DocCitation,
3733 type DocEditTarget,
3834 type DocRepoSpace,
5450 type FolioListQuery,
5551 type FolioMove,
5652 type FolioPage,
57− type FolioPassage,
5853 type FolioProposal,
5954 type FolioRef,
6055 type FolioSearchHit,
7368 type User,
7469 type Viewer,
7570 type Workspace,
76− type WorkspaceAgent,
7771 } from "@g1t/contracts";
7872
79−import { RANK, aclChain, atLeast, canShare, explicitAccess, inheritsSpace, isPrivateFolio, isRole, personKeys, type Person, type SpaceRules } from "../access.ts";
73+import { RANK, aclChain, atLeast, canShare, explicitAccess, inheritsSpace, isPrivateFolio, isRole, personKeys, type Person } from "../access.ts";
8074 import { diffLines } from "../diff.ts";
8175 import { fileStore, safeName, servedType } from "../files.ts";
82−import { adapters, folioAdapters, forgetFolios, indexFolio, startBackfill, ensureIndexed, type DocsJob } from "../indexer.ts";
76+import { folioAdapters, forgetFolios, indexFolio, startBackfill, type DocsJob } from "../indexer.ts";
8377 import { kindModel } from "../kinds/index.ts";
8478 import type { FolioOrigin } from "../kinds/types.ts";
8579 import { excerpt, searchText } from "../markdown.ts";
86−import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "../recall.ts";
80+import { QueryCache, fuseRanks, queryKey, vectorQueryPlan, MEANING_FLOOR } from "../recall.ts";
8781 import type { RepoSpaceRow } from "../repo-spaces.ts";
8882 import { ROOM_MEMBER_HEADER } from "../room.ts";
89−import { ftsAnyQuery, ftsQuery, projectRef } from "../search.ts";
83+import { ftsQuery, projectRef } from "../search.ts";
9084 import type { ThreadResult } from "../threads.ts";
9185 import { placeBefore } from "../tree.ts";
92−import { Who, now, rulesOf, userKey, type Space, type WhoEnv } from "../who.ts";
86+import { Who, now, userKey, type Space, type WhoEnv } from "../who.ts";
9387 import {
9488 FOLIO_COLUMNS,
9589 aclNode,
108102 type FolioRow,
109103 type ReaderContext,
110104 } from "./access-store.ts";
111−import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts";
112105 import { publishFolioEvent, publishSpaceDeleted } from "./events.ts";
113106 import { MAX_DEPTH, MAX_PAGE_STATE, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
114107 import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts";
136129 };
137130
138131 type Args = { workspace: string; viewer: Viewer };
139−type AgentArgs = Args & { agent_id: string; audience?: DocAudience | null };
140132
141133 /** The viewer in their workspace, with their spaces. */
142134 type Ctx = { workspace: Workspace; viewer: User; key: string; person: Person; spaces: Space[]; spaceById: Map<string, Space>; owner: boolean };
143−
144−/** An agent's turn: its asker's context, the agent, and who will see the answer. */
145−type AgentCtx = Ctx & { agent: WorkspaceAgent; agentKey: string; rule: AudienceRule; people: Person[]; audienceIds: string[] };
146135
147136 type SuggestionRow = {
148137 id: string;
662651 return ok({ text, spec, title, icon });
663652 }
664653
665− /** Whether a member key may be shared with: a member, an agent or a team of this workspace. */
654+ /** Whether a member key may be shared with: a member or a team of this workspace. */
666655 private async principalExists(ctx: Ctx, principal: string): Promise<boolean> {
667656 const p = parsePrincipalKey(principal);
668657 if (principal.startsWith("team:")) {
669658 const slug = principal.slice(5).toLowerCase();
670659 return [...(await this.who.teamsOf(ctx.workspace)).values()].some((set) => set.has(slug));
671660 }
672− if (!p) return false;
673− if (p.kind === "agent") {
674− const agent = (await this.who.agentsById([p.id])).get(p.id);
675− return !!agent && agent.workspace_id === ctx.workspace.id && !agent.archived_at;
676− }
661+ if (!p || p.kind !== "user") return false;
677662 await this.who.nameUsers([p.id]);
678663 const username = this.who.usernames.get(p.id);
679664 return !!username && (await this.who.members(ctx.workspace)).has(username.toLowerCase());
766751 return (await foliosById(this.db, [id])).get(id) ?? row;
767752 }
768753
769− /** Grants asked for at creation: people, agents and teams of this workspace, never above `edit` for teams' sake of sense. */
754+ /** Grants asked for at creation: people and teams of this workspace, never above `edit` for teams' sake of sense. */
770755 private async cleanShares(ctx: Ctx, share: { principal: string; role: DocRole }[] | null | undefined): Promise<Result<{ principal: string; role: DocRole }[]>> {
771756 const out: { principal: string; role: DocRole }[] = [];
772757 for (const s of (share ?? []).slice(0, FOLIO_MAX_SHARE)) {
773758 const principal = s.principal.startsWith("team:") ? `team:${s.principal.slice(5).toLowerCase()}` : s.principal;
774− if (!(await this.principalExists(ctx, principal))) return fail("invalid", `${s.principal} isn't a member, agent or team of this workspace.`);
759+ if (!(await this.principalExists(ctx, principal))) return fail("invalid", `${s.principal} isn't a member or team of this workspace.`);
775760 out.push({ principal, role: s.role });
776761 }
777762 return ok(out);
12201205 return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary });
12211206 }
12221207 if (edit.kind !== "doc") return fail("forbidden", `Suggesting changes to ${kindLabel(row.kind)} comes with proposals, which aren't here yet.`);
1223− const suggestion = await this.fileSuggestion(ctx, row, { author: ctx.key, asked_by: null, agentName: null }, { target: cleanTarget(edit.target)!, markdown: edit.markdown, note: cleanNote(edit.note), marks_current: edit.marks_current === true });
1208+ const suggestion = await this.fileSuggestion(ctx, row, { author: ctx.key, asked_by: null }, { target: cleanTarget(edit.target)!, markdown: edit.markdown, note: cleanNote(edit.note), marks_current: edit.marks_current === true });
12241209 return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion;
12251210 }
12261211
13161301 await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run();
13171302 return ok(await this.afterShare(ctx, row));
13181303 }
1319− if (!(await this.principalExists(ctx, principal))) return fail("invalid", "Share with a member, an agent or a team of this workspace.");
1304+ if (!(await this.principalExists(ctx, principal))) return fail("invalid", "Share with a member or a team of this workspace.");
13201305 await this.db
13211306 .prepare("INSERT INTO folio_grants (folio_id, principal, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (folio_id, principal) DO UPDATE SET role = excluded.role")
13221307 .bind(row.id, principal, change.role, ctx.key, now())
18121797 );
18131798 }
18141799
1815− /** Files a doc suggestion: someone who can comment (a person, or an agent for one). */
1800+ /** Files a doc suggestion: someone who can comment. */
18161801 private async fileSuggestion(
18171802 ctx: Ctx,
18181803 row: FolioRow,
1819− by: { author: string; asked_by: string | null; agentName: string | null },
1804+ by: { author: string; asked_by: string | null },
18201805 edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
18211806 ): Promise<Result<FolioSuggestion>> {
18221807 const room = await this.ready(ctx.workspace, row);
18431828 .run();
18441829 const [suggestion] = await this.toSuggestions(ctx.workspace, [s], [current.block_ids]);
18451830 this.tell(row.id, { type: "suggestion.created", suggestion: suggestion! });
1846− if (by.agentName) this.defer(room.announce(by.author, by.agentName).catch(() => undefined));
18471831 this.defer(this.notifyOwnerOfSuggestion(ctx, row, suggestion!));
18481832 return ok(suggestion!);
18491833 }
20302014
20312015 async queryDataset(a: Args & { query: unknown }): Promise<Result<never>> {
20322016 const found = await this.ctx(a.workspace, a.viewer);
2033− if (!found.ok) return found;
2034− return fail("invalid", "Dashboards aren't here yet.");
2035− }
2036−
2037− async queryDatasetForAgent(a: AgentArgs): Promise<Result<never>> {
2038− const found = await this.agentCtx(a);
20392017 if (!found.ok) return found;
20402018 return fail("invalid", "Dashboards aren't here yet.");
20412019 }
20632041 if (!found.ok) return found;
20642042 if (!found.value.owner) return fail("forbidden", "Only an owner can index the workspace's artifacts again.");
20652043 return ok(await startBackfill(this.env, found.value.workspace.id, { force: true }));
2066− }
2067−
2068− // ── Agents ──────────────────────────────────────────────────────────────
2069−
2070− private async agentCtx(a: AgentArgs): Promise<Result<AgentCtx>> {
2071− const found = await this.ctx(a.workspace, a.viewer);
2072− if (!found.ok) return found;
2073− const ctx = found.value;
2074− const agentId = String(a.agent_id ?? "");
2075− const agent = (await this.who.agentsById([agentId])).get(agentId);
2076− if (!agent || agent.workspace_id !== ctx.workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
2077− const rule = audienceRule(a.audience ?? null, ctx.viewer.id);
2078− const people = rule.kind === "people" ? await this.who.peopleByIds(ctx.workspace, rule.user_ids) : [];
2079− return ok({ ...ctx, agent, agentKey: principalKey({ kind: "agent", id: agent.id }), rule, people, audienceIds: rule.kind === "people" ? rule.user_ids : [] });
2080− }
2081−
2082− /** What the agent may reach in each folio for its asker and audience. */
2083− private async reach(actx: AgentCtx, rows: FolioRow[]): Promise<Map<string, AgentReach>> {
2084− const out = new Map<string, AgentReach>();
2085− if (!rows.length) return out;
2086− const [found, asker] = await Promise.all([ancestry(this.db, rows), visitsOf(this.db, actx.viewer.id, rows)]);
2087− let audienceVisits = new Map<string, Set<string>>();
2088− if (actx.rule.kind === "people") {
2089− const ids = [...new Set(rows.flatMap((r) => [r.id, r.acl_root]))];
2090− const found2 = await this.db
2091− .prepare("SELECT folio_id, user_id FROM folio_visits WHERE user_id IN (SELECT value FROM json_each(?)) AND folio_id IN (SELECT value FROM json_each(?))")
2092− .bind(json(actx.audienceIds), json(ids))
2093− .all<{ folio_id: string; user_id: string }>();
2094− audienceVisits = new Map();
2095− for (const v of found2.results) audienceVisits.set(v.user_id, (audienceVisits.get(v.user_id) ?? new Set()).add(v.folio_id));
2096− }
2097− const allSpaces = new Map((await this.who.allSpaces(actx.workspace)).map((s) => [s.row.id, s]));
2098− for (const row of rows) {
2099− const chain = aclChain(row.id, found.nodes);
2100− const root = chain[chain.length - 1];
2101− const s = root?.space_id ? allSpaces.get(root.space_id) : undefined;
2102− const space: SpaceRules | null = s ? rulesOf(s) : null;
2103− const seen = (set: Set<string> | undefined) => !!set && (set.has(row.id) || (!!root && set.has(root.id)));
2104− out.set(
2105− row.id,
2106− agentReach({
2107− chain,
2108− grants: found.grants,
2109− space,
2110− asker: actx.person,
2111− askerVisited: seen(asker),
2112− rule: actx.rule,
2113− people: actx.people,
2114− visited: (p) => seen(audienceVisits.get(p.user_id)),
2115− agent_mode: this.agentMode(row, actx),
2116− }),
2117− );
2118− }
2119− return out;
2120− }
2121−
2122− /** A folio the agent may reach for its asker: not found when the asker can't read it. */
2123− private async agentOpen(actx: AgentCtx, folioId: unknown): Promise<Result<{ row: FolioRow; reach: AgentReach }>> {
2124− const row = await this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE id = ? AND workspace_id = ? AND trashed_at IS NULL`).bind(String(folioId ?? ""), actx.workspace.id).first<FolioRow>();
2125− if (!row) return fail("not_found", "No such artifact.");
2126− const reach = (await this.reach(actx, [row])).get(row.id)!;
2127− if (!reach.asker_role) return fail("not_found", "No such artifact.");
2128− return ok({ row, reach });
2129− }
2130−
2131− async foliosForAgent(a: AgentArgs & { query: FolioListQuery }): Promise<Result<FolioList>> {
2132− const found = await this.agentCtx(a);
2133− if (!found.ok) return found;
2134− const actx = found.value;
2135− const query = { ...(a.query ?? { tab: "all" as const }), tab: a.query?.tab ?? "all", limit: Math.min(listLimit(a.query?.limit), 50) };
2136− const invalid = folioListQueryError(query);
2137− if (invalid) return fail("invalid", invalid);
2138− const page = await this.listFor(actx, query);
2139− const rows = await foliosById(
2140− this.db,
2141− page.items.map((f) => f.id),
2142− );
2143− const reach = await this.reach(actx, [...rows.values()]);
2144− return ok({ items: page.items.filter((f) => agentMayFind(reach.get(f.id) ?? { asker_role: null, audience_can_read: false, can: { read: false, suggest: false, edit: false, attach: false } })), next_cursor: page.next_cursor });
2145− }
2146−
2147− async readForAgent(a: AgentArgs & { folio_id: string }): Promise<Result<FolioAgentRead>> {
2148− const found = await this.agentCtx(a);
2149− if (!found.ok) return found;
2150− const actx = found.value;
2151− const opened = await this.agentOpen(actx, a.folio_id);
2152− if (!opened.ok) return opened;
2153− const { row, reach } = opened.value;
2154− if (!kindModel(row.kind)) return fail("invalid", `${kindLabel(row.kind)} aren't here yet.`);
2155− const read = await (await this.ready(actx.workspace, row)).read();
2156− return ok({
2157− folio: { ...this.ref(actx.workspace.slug, row), edited_at: row.edited_at },
2158− space: this.spaceOf(actx, row),
2159− content: read.content,
2160− ...(read.blocks ? { blocks: read.blocks } : {}),
2161− can: reach.can,
2162− audience_can_read: reach.audience_can_read,
2163− });
2164− }
2165−
2166− async createAsAgent(
2167− a: AgentArgs & {
2168− input: { kind: FolioKind; title: string; content?: FolioContentInput | null; template_id?: string | null; where: { space_id: string } | "private" | { conversation: string[] }; parent_id?: string | null; source?: { title: string; href: string } | null };
2169− },
2170− ): Promise<Result<FolioRef>> {
2171− const found = await this.agentCtx({ ...a, audience: null });
2172− if (!found.ok) return found;
2173− const actx = found.value;
2174− const input = a.input ?? ({} as typeof a.input);
2175− const invalid = newFolioError({ kind: input.kind, title: input.title, content: input.content ?? null, template_id: input.template_id ?? null });
2176− if (invalid) return fail("invalid", invalid);
2177− if (!kindModel(input.kind)) return fail("invalid", `${kindLabel(input.kind)} aren't here yet.`);
2178− const title = cleanTitle(input.title);
2179− if (!title && !input.template_id) return fail("invalid", "Give it a title.");
2180− const where = input.where ?? "private";
2181− let place: Result<{ space_id: string | null; parent: FolioRow | null }>;
2182− let grants: { principal: string; role: DocRole }[] = [{ principal: actx.agentKey, role: "edit" }];
2183− if (input.parent_id) place = await this.placeFor(actx, { parent_id: input.parent_id });
2184− else if (typeof where === "object" && "space_id" in where) place = await this.placeFor(actx, { space_id: where.space_id });
2185− else place = ok({ space_id: null, parent: null });
2186− if (!place.ok) return place.error.code === "forbidden" ? fail("forbidden", `${actx.viewer.username} can't add there.`) : place;
2187− if (typeof where === "object" && "conversation" in where) {
2188− // Private, and the conversation's people may read it.
2189− const ids = [...new Set((Array.isArray(where.conversation) ? where.conversation : []).map(String))].filter((id) => id && id !== actx.viewer.id).slice(0, FOLIO_MAX_SHARE);
2190− const people = await this.who.peopleByIds(actx.workspace, ids);
2191− grants = [...grants, ...people.filter((p) => !p.user_id.startsWith("outside:")).map((p) => ({ principal: `user:${p.user_id}`, role: "view" as DocRole }))];
2192− }
2193− const start = await this.startingPoint(actx, input.kind, { title, template_id: input.template_id, content: input.content });
2194− if (!start.ok) return start;
2195− const row = await this.insertFolio(actx, {
2196− kind: input.kind,
2197− owner: actx.key,
2198− created_by: actx.agentKey,
2199− space_id: place.value.space_id,
2200− parent: place.value.parent,
2201− title: start.value.title,
2202− icon: start.value.icon,
2203− text: start.value.text,
2204− spec: start.value.spec,
2205− source: cleanSource(input.source),
2206− grants,
2207− });
2208− return ok(this.ref(actx.workspace.slug, row));
2209− }
2210−
2211− async editAsAgent(a: AgentArgs & { folio_id: string; edit: FolioAgentEdit }): Promise<Result<FolioAgentEditResult>> {
2212− const found = await this.agentCtx({ ...a, audience: null });
2213− if (!found.ok) return found;
2214− const actx = found.value;
2215− const opened = await this.agentOpen(actx, a.folio_id);
2216− if (!opened.ok) return opened;
2217− const { row, reach } = opened.value;
2218− const invalid = this.editError(row, a.edit);
2219− if (invalid) return fail("invalid", invalid);
2220− const edit = a.edit;
2221− const ref = this.ref(actx.workspace.slug, row);
2222− if (reach.can.edit && !edit.suggest_only) {
2223− const room = await this.ready(actx.workspace, row);
2224− const note = cleanNote(edit.note);
2225− const result = await room.edit(edit, {
2226− key: actx.agentKey,
2227− kind: "agent",
2228− note: note ? `@${actx.agent.handle} for @${actx.viewer.username}: ${note}` : `@${actx.agent.handle} for @${actx.viewer.username}`,
2229− authors: [actx.agentKey],
2230− });
2231− if (!result.applied) return fail("not_found", `${result.summary} Read it again and target what is there now.`);
2232− if (edit.marks_current) await this.clearStale(row.id, actx.agentKey);
2233− this.defer(room.announce(actx.agentKey, actx.agent.display_name).catch(() => undefined));
2234− return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary });
2235− }
2236− if (!reach.can.suggest) return fail("forbidden", `${actx.viewer.username} can only read this, so it can't be changed for them.`);
2237− if (edit.kind !== "doc") return fail("forbidden", `Changing ${kindLabel(row.kind)} without edit access comes with proposals, which aren't here yet.`);
2238− const suggestion = await this.fileSuggestion(actx, row, { author: actx.agentKey, asked_by: actx.key, agentName: actx.agent.display_name }, { target: cleanTarget(edit.target)!, markdown: edit.markdown, note: cleanNote(edit.note), marks_current: edit.marks_current === true });
2239− return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion;
2240− }
2241−
2242− async shareAsAgent(a: AgentArgs & { folio_id: string; user_ids: string[]; role: "view" | "comment" }): Promise<Result<FolioAccessList>> {
2243− if (a.role !== "view" && a.role !== "comment") return fail("invalid", "An agent shares to view or comment only. For more, post a card with a Share button for the person to press.");
2244− const found = await this.agentCtx(a);
2245− if (!found.ok) return found;
2246− const actx = found.value;
2247− if (actx.rule.kind !== "people") return fail("forbidden", "An agent shares only with people in a private conversation. Ask the person to use Share instead.");
2248− const opened = await this.agentOpen(actx, a.folio_id);
2249− if (!opened.ok) return opened;
2250− const { row, reach } = opened.value;
2251− if (!canShare(reach.asker_role)) return fail("forbidden", `${actx.viewer.username} doesn't have full access, so it can't be shared for them.`);
2252− const inConversation = new Set(actx.rule.user_ids);
2253− const ids = [...new Set((Array.isArray(a.user_ids) ? a.user_ids : []).map(String))];
2254− if (!ids.length) return fail("invalid", "Name who to share it with.");
2255− if (ids.some((id) => !inConversation.has(id))) return fail("forbidden", "An agent shares only with people already in the conversation.");
2256− const people = (await this.who.peopleByIds(actx.workspace, ids)).filter((p) => !p.user_id.startsWith("outside:"));
2257− const at = now();
2258− // Never lowers what someone already has.
2259− await runBatches(
2260− this.db,
2261− people.map((p) =>
2262− this.db
2263− .prepare(
2264− "INSERT INTO folio_grants (folio_id, principal, role, granted_by, granted_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (folio_id, principal) DO UPDATE SET role = CASE WHEN folio_grants.role IN ('edit', 'manage') OR (folio_grants.role = 'comment' AND excluded.role = 'view') THEN folio_grants.role ELSE excluded.role END",
2265− )
2266− .bind(row.id, `user:${p.user_id}`, a.role, actx.agentKey, at),
2267− ),
2268− );
2269− const list = await this.afterShare(actx, row);
2270− const open = await workspaceReadable(this.db, row.id).catch(() => false);
2271− this.defer(
2272− publishFolioEvent(
2273− this.env.EVENTS,
2274− "folio.shared",
2275− { workspace: actx.workspace.slug, workspaceId: actx.workspace.id, folioId: row.id, kind: row.kind, spaceId: row.space_id, title: open ? row.title : null, principals: people.map((p) => `user:${p.user_id}`), role: a.role },
2276− actx.agentKey,
2277− ),
2278− );
2279− return ok(list);
22802044 }
22812045
2282− /**
2283− * A file an agent made (`make_file` in services/agents), kept with a
2284− * folio as a person's upload is: only where the agent may edit for the
2285− * person it acts for, up to the same size, served the same way.
2286− */
2287− async attachAsAgent(
2288− a: AgentArgs & { folio_id: string; file: { name: string; content_type: string; data: string } },
2289− ): Promise<Result<{ id: string; url: string; name: string; content_type: string; bytes: number }>> {
2290− const found = await this.agentCtx({ ...a, audience: null });
2291− if (!found.ok) return found;
2292− const actx = found.value;
2293− const opened = await this.agentOpen(actx, a.folio_id);
2294− if (!opened.ok) return opened;
2295− const { row, reach } = opened.value;
2296− if (!reach.can.attach) return fail("forbidden", `${actx.viewer.username} can't edit this artifact, so nothing can be attached to it for them.`);
2297− let bytes: Uint8Array;
2298− try {
2299− bytes = Uint8Array.from(atob(String(a.file?.data ?? "")), (c) => c.charCodeAt(0));
2300− } catch {
2301− return fail("invalid", "The file isn't base64.");
2302− }
2303− if (!bytes.length) return fail("invalid", "The file is empty.");
2304− if (bytes.length > DOC_MAX_FILE_BYTES) return fail("invalid", `Files can be up to ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB.`);
2305− const name = safeName(a.file?.name ?? "file");
2306− const contentType = servedType(a.file?.content_type ?? "");
2307− const key = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
2308− const id = newId("fil");
2309− await fileStore(this.env).put(`docs/${key}`, bytes, contentType);
2310− await this.db
2311− .prepare("INSERT INTO folio_files (id, workspace_id, folio_id, key, name, content_type, bytes, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
2312− .bind(id, actx.workspace.id, row.id, key, name, contentType, bytes.length, actx.agentKey, now())
2313− .run();
2314− return ok({ id, url: `/docs-files/${key}`, name, content_type: contentType, bytes: bytes.length });
2315− }
2316−
2317− /**
2318− * What the workspace's artifacts (and projects' docs) say about a
2319− * query, for an agent about to answer: passages by meaning above the
2320− * floor, then by words, at most two per folio, only from folios its
2321− * asker and every person in the audience can read, each checked against
2322− * the folio itself. Projects' docs come from Docs' index (`g1t-docs`)
2323− * until Phase 7 moves them.
2324− */
2325− async recallForAgent(a: AgentArgs & { query: string; limit?: number | null; spaces?: string[] | null; kinds?: FolioKind[] | null }): Promise<Result<FolioPassage[]>> {
2326− const found = await this.agentCtx(a);
2327− if (!found.ok) return found;
2328− const actx = found.value;
2329− this.defer(ensureIndexed(this.env, actx.workspace.id).catch((error: unknown) => console.error("folios could not start indexing", actx.workspace.id, String(error))));
2330− const query = String(a.query ?? "").trim().slice(0, 2000);
2331− if (!query) return ok([]);
2332− const limit = recallLimit(a.limit);
2333− const kinds = (a.kinds ?? []).filter(isFolioKind);
2334− const { scopes } = await this.allowedScopes(actx);
2335− const required = new Set((Array.isArray(a.spaces) ? a.spaces : []).map((id) => `space:${id}`).filter((s) => scopes.includes(s)));
2336− const fts = ftsAnyQuery(query);
2337− const [meaning, words, repo] = await Promise.all([
2338− this.meaningPassages(actx, query, scopes, kinds).catch(() => []),
2339− fts
2340− ? this.db
2341− .prepare(
2342− `SELECT c.id, c.folio_id, c.scope, c.heading, c.text FROM folio_chunks_fts JOIN folio_chunks c ON c.id = folio_chunks_fts.chunk_id
2343− WHERE folio_chunks_fts MATCH ? AND c.workspace_id = ? ${scopes.length <= 80 ? "AND folio_chunks_fts.scope IN (SELECT value FROM json_each(?))" : ""} ${kinds.length ? "AND c.kind IN (SELECT value FROM json_each(?))" : ""}
2344− ORDER BY bm25(folio_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 30`,
2345− )
2346− .bind(fts, actx.workspace.id, ...(scopes.length <= 80 ? [json(scopes)] : []), ...(kinds.length ? [json(kinds)] : []))
2347− .all<{ id: string; folio_id: string; scope: string; heading: string | null; text: string }>()
2348− .then((r) => r.results)
2349− .catch((error: unknown) => {
2350− console.error("folios word recall failed", String(error));
2351− return [] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[];
2352− })
2353− : Promise.resolve([] as { id: string; folio_id: string; scope: string; heading: string | null; text: string }[]),
2354− kinds.length && !kinds.includes("doc") ? Promise.resolve([] as FolioPassage[]) : this.recallRepoDocs(actx, query, fts, limit).catch(() => [] as FolioPassage[]),
2355− ]);
2356− // Every folio a passage came from, checked as the agent's asker and audience.
2357− const folioIds = [...new Set([...meaning.map((m) => m.folio_id), ...words.map((w) => w.folio_id)])];
2358− const rows = [...(await foliosById(this.db, folioIds)).values()].filter((r) => r.workspace_id === actx.workspace.id && !r.trashed_at);
2359− const reach = await this.reach(actx, rows);
2360− const may = new Set(rows.filter((r) => agentMayFind(reach.get(r.id)!)).map((r) => r.id));
2361− const byFolio = new Map(rows.map((r) => [r.id, r]));
2362− type C = Candidate & { heading: string | null; text: string };
2363− const scopeOf = (folioId: string) => (required.size && byFolio.get(folioId)?.space_id && required.has(`space:${byFolio.get(folioId)!.space_id}`) ? "required" : "rest");
2364− const candidates: C[] = [
2365− ...meaning.filter((m) => may.has(m.folio_id)).map((m) => ({ id: m.id, doc_id: m.folio_id, space_id: scopeOf(m.folio_id), score: m.score, by: "meaning" as const, heading: m.heading, text: m.text })),
2366− ...words.filter((w) => may.has(w.folio_id)).map((w) => ({ id: w.id, doc_id: w.folio_id, space_id: scopeOf(w.folio_id), score: WORDS_SCORE, by: "words" as const, heading: w.heading, text: w.text })),
2367− ];
2368− const picked = pickPassages(candidates, { allowed: new Set(["required", "rest"]), required: required.size ? ["required"] : [], limit });
2369− const stale = await this.staleIds(picked.map((c) => c.doc_id));
2370− const passages: FolioPassage[] = picked.map((c) => {
2371− const row = byFolio.get(c.doc_id)!;
2372− const space = row.space_id ? actx.spaceById.get(row.space_id) : undefined;
2373− return {
2374− folio: this.ref(actx.workspace.slug, row),
2375− repo_file: null,
2376− space_name: space?.row.name ?? "Private",
2377− heading: c.heading,
2378− text: c.text,
2379− score: Math.round(c.score * 1000) / 1000,
2380− updated_at: row.edited_at,
2381− stale: stale.has(row.id),
2382− };
2383− });
2384− // Projects' docs fill what's left, best first.
2385− const out = [...passages, ...repo.sort((x, y) => y.score - x.score)].slice(0, limit);
2386− return ok(out.sort((x, y) => y.score - x.score));
2387− }
2388−
2389− /** Projects' docs the agent may recall from: repositories its asker and every person in the audience can read. */
2390− private async recallRepoDocs(actx: AgentCtx, query: string, fts: string | null, limit: number): Promise<FolioPassage[]> {
2391− const spaces = await this.repoSpacesForAudience(actx);
2392− if (!spaces.length) return [];
2393− const ids = spaces.map((s) => s.row.id);
2394− const { embedder, store } = adapters(this.env);
2395− const vector = store ? await this.queryVector(query, embedder) : null;
2396− const plan = vectorQueryPlan(actx.workspace.id, ids);
2397− const [meaning, words] = await Promise.all([
2398− vector && store && plan ? store.query(vector, { topK: plan.topK, filter: plan.filter }).catch(() => [] as { id: string; score: number }[]) : Promise.resolve([] as { id: string; score: number }[]),
2399− fts
2400− ? this.db
2401− .prepare(
2402− `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id
2403− WHERE doc_chunks_fts MATCH ? AND c.workspace_id = ? AND c.repo_file_id IS NOT NULL AND doc_chunks_fts.space_id IN (SELECT value FROM json_each(?))
2404− ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT 20`,
2405− )
2406− .bind(fts, actx.workspace.id, json(ids))
2407− .all<{ id: string }>()
2408− .then((r) => r.results.map((x) => x.id))
2409− .catch(() => [] as string[])
2410− : Promise.resolve([] as string[]),
2411− ]);
2412− const scores = new Map<string, number>();
2413− for (const m of meaning) if (m.score >= MEANING_FLOOR) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score));
2414− for (const id of words) if (!scores.has(id)) scores.set(id, WORDS_SCORE);
2415− if (!scores.size) return [];
2416− const rows = (
2417− await this.db
2418− .prepare(
2419− `SELECT c.id, c.space_id, c.repo_file_id, c.path, c.heading, c.text FROM doc_chunks c JOIN repo_files f ON f.space_id = c.space_id AND f.path = c.path
2420− WHERE c.workspace_id = ? AND c.repo_file_id IS NOT NULL AND c.id IN (SELECT value FROM json_each(?))`,
2421− )
2422− .bind(actx.workspace.id, json([...scores.keys()]))
2423− .all<{ id: string; space_id: string; repo_file_id: string; path: string; heading: string | null; text: string }>()
2424− ).results;
2425− const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
2426− const perFile = new Map<string, number>();
2427− const out: FolioPassage[] = [];
2428− for (const r of rows.sort((x, y) => (scores.get(y.id) ?? 0) - (scores.get(x.id) ?? 0))) {
2429− const s = bySpace.get(r.space_id);
2430− if (!s) continue;
2431− const n = perFile.get(r.repo_file_id) ?? 0;
2432− if (n >= 2) continue;
2433− perFile.set(r.repo_file_id, n + 1);
2434− const name = `${s.repo.namespace}/${s.repo.name}`;
2435− out.push({
2436− folio: null,
2437− repo_file: { repo: name, path: r.path, href: `/${actx.workspace.slug}/-/artifacts/repo/${name}/${r.path.split("/").map(encodeURIComponent).join("/")}` },
2438− space_name: name,
2439− heading: r.heading,
2440− text: r.text,
2441− score: Math.round((scores.get(r.id) ?? 0) * 1000) / 1000,
2442− updated_at: s.row.indexed_at ?? s.row.added_at,
2443− stale: false,
2444− });
2445− if (out.length >= limit) break;
2446− }
2447− return out;
2448− }
2449−
2450− async staleForAgent(a: AgentArgs & { repo?: string | null; since?: string | null }): Promise<Result<Folio[]>> {
2451− const found = await this.agentCtx(a);
2452− if (!found.ok) return found;
2453− const actx = found.value;
2454− const repo = a.repo ? projectRef(a.repo) : null;
2455− if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
2456− const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
2457− const rows = (
2458− await this.db
2459− .prepare(
2460− `SELECT ${folioColumns("f")} FROM folios f JOIN (SELECT folio_id, MAX(detected_at) AS flagged FROM folio_changes WHERE cleared_at IS NULL ${repo ? "AND repo = ?" : ""} GROUP BY folio_id) c ON c.folio_id = f.id
2461− WHERE f.workspace_id = ? AND f.trashed_at IS NULL ${since ? "AND c.flagged >= ?" : ""} ORDER BY c.flagged DESC LIMIT 200`,
2462− )
2463− .bind(...(repo ? [repo] : []), actx.workspace.id, ...(since ? [since] : []))
2464− .all<FolioRow>()
2465− ).results;
2466− const reach = await this.reach(actx, rows);
2467− return ok((await this.toFolios(actx, rows.filter((r) => agentMayFind(reach.get(r.id)!)))).slice(0, 50));
2468− }
2469−
24702046 // ── Projects' docs ──────────────────────────────────────────────────────
24712047
24722048 private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
25072083 .map((f) => ({ path: f.path, title: f.title })),
25082084 can_remove: row.added_by === ctx.key || ctx.owner,
25092085 }));
2510− }
2511−
2512− /** Projects' docs an agent may recall from: the asker's, narrowed to every person in the audience (public repositories only for a workspace audience). */
2513− private async repoSpacesForAudience(actx: AgentCtx): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2514− const mine = await this.readableRepoSpaces(actx.workspace, actx.viewer);
2515− if (!mine.length || actx.rule.kind === "asker") return mine;
2516− const publicOnly = () => mine.filter((s) => !s.repo.isPrivate);
2517− if (actx.rule.kind === "workspace" || !this.env.REPOS) return publicOnly();
2518− const others = await identityClient(this.env.IDENTITY)
2519− .usersForAudience(actx.rule.user_ids)
2520− .catch(() => [] as User[]);
2521− let keep = new Set(mine.map((s) => s.row.repo_id));
2522− // Someone who isn't a live account reads public repositories only.
2523− if (others.length < actx.rule.user_ids.length) keep = new Set(publicOnly().map((s) => s.row.repo_id));
2524− for (const person of others) {
2525− const readable = await reposClient(this.env.REPOS)
2526− .readable([...keep], person)
2527− .catch(() => [] as Repo[]);
2528− keep = new Set(readable.map((r) => r.id));
2529− if (!keep.size) break;
2530− }
2531− return mine.filter((s) => keep.has(s.row.repo_id));
25322086 }
25332087
25342088 // ── Sockets and files ───────────────────────────────────────────────────
+14−515
11 /**
22 * The artifacts service: a workspace's spaces and pages, their live documents,
3− * history, comments, agents' suggestions, templates and search
3+ * history, comments, suggestions, templates and search
44 * (docs.g1t.sh/guides/artifacts/, "Docs").
55 *
66 * Reached through service bindings: `POST /rpc/<method>` with snake_case
3232 parsePrincipalKey,
3333 principalKey,
3434 reposClient,
35− workspaceAgentsClient,
36− type DocAgentAbilities,
3735 type DocCitation,
38− type DocDescribes,
3936 type DocRepoPage,
4037 type DocRepoSpace,
4138 type DocStaleChange,
42− type DocStalePage,
4339 type DocStaleness,
4440 type G1tEvent,
4541 type Repo,
46− type DocAgentEditResult,
4742 type DocAgentMode,
48− type DocAgentPage,
49− type DocAudience,
5043 type DocEditTarget,
5144 type DocFile,
5245 type DocMove,
5447 type DocPageChange,
5548 type DocPageDetail,
5649 type DocPageRef,
57− type DocPassage,
5850 type DocRole,
5951 type DocSearchHit,
6052 type DocSearchQuery,
8274 type User,
8375 type Viewer,
8476 type Workspace,
85− type WorkspaceAgent,
8677 } from "@g1t/contracts";
8778
88−import { RANK, agentAbilities, atLeast, isRole, leavesNoManager, memberKey, readableByAll, readableByWorkspace, roleOf, type Person, type SpaceRules } from "./access.ts";
79+import { RANK, atLeast, isRole, leavesNoManager, memberKey, roleOf, type Person, type SpaceRules } from "./access.ts";
8980 import { diffLines } from "./diff.ts";
9081 import { cleanDescribes } from "./citations.ts";
9182 import { publishDocEvent } from "./events.ts";
9283 import { fileStore, safeName, servedType, type FileStoreEnv } from "./files.ts";
9384 import { repoFileId } from "./chunks.ts";
94−import { adapters, ensureIndexed, forgetDocs, indexPage, indexRepoFiles, runBackfill, startBackfill, type DocsJob } from "./indexer.ts";
85+import { adapters, forgetDocs, indexPage, indexRepoFiles, runBackfill, startBackfill, type DocsJob } from "./indexer.ts";
9586 import { excerpt, searchText } from "./markdown.ts";
96−import { QueryCache, fuseRanks, pickPassages, queryKey, recallLimit, requiredSpaces, vectorQueryPlan, MEANING_FLOOR, WORDS_SCORE, type Candidate } from "./recall.ts";
87+import { QueryCache, fuseRanks, queryKey, vectorQueryPlan, MEANING_FLOOR } from "./recall.ts";
9788 import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts";
9889 import { indexRepoSpace, reindexRepo, type RepoSpaceRow } from "./repo-spaces.ts";
99−import { ftsAnyQuery, ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
90+import { ftsQuery, inProject, projectRef, searchSpaces } from "./search.ts";
10091 import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts";
10192 import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts";
10293 import type { ThreadResult } from "./threads.ts";
112103 type Env = FileStoreEnv & {
113104 DB: D1Database;
114105 IDENTITY: ServiceBinding;
115− AGENTS: ServiceBinding;
116106 NOTIFY?: ServiceBinding;
117107 /** Repositories: who may read one, what a change touched, a project's docs (src/staleness.ts, src/repo-spaces.ts). */
118108 REPOS?: ServiceBinding;
229219
230220 const MAX_TITLE = 200;
231221 const MAX_MARKDOWN = 512 * 1024;
232−const MAX_NOTE = 500;
233222
234223 const now = () => new Date().toISOString();
235224
268257 return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20);
269258 }
270259
271−function cleanTarget(target: unknown): DocEditTarget | null {
272− const t = target as DocEditTarget | null;
273− if (!t || typeof t !== "object") return null;
274− switch (t.kind) {
275− case "append":
276− case "document":
277− return { kind: t.kind };
278− case "section":
279− return typeof t.heading === "string" && t.heading.trim() ? { kind: "section", heading: t.heading.trim().slice(0, 300) } : null;
280− case "blocks":
281− return typeof t.from_block === "string" && typeof t.to_block === "string" ? { kind: "blocks", from_block: t.from_block, to_block: t.to_block } : null;
282− default:
283− return null;
284− }
285−}
286−
287260 class Docs {
288261 private readonly workspaces = new Map<string, Promise<Workspace | null>>();
289262 private readonly people = new Map<string, Promise<Map<string, Member>>>();
290263 private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>();
291264 private readonly usernames = new Map<string, string>();
292− private readonly agents = new Map<string, WorkspaceAgent | null>();
293265
294266 constructor(
295267 private readonly env: Env,
352324 for (const [id, username] of Object.entries(named)) this.usernames.set(id, username);
353325 }
354326
355− private async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> {
356− const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id));
357− if (wanted.length) {
358− let found: WorkspaceAgent[] = [];
359− try {
360− found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted);
361− } catch (error) {
362− console.error("docs could not resolve agents", error);
363− }
364− for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null);
365− }
366− return new Map(ids.map((id) => [id, this.agents.get(id) ?? null]));
367− }
368−
369327 /** How member keys show. Teams show by slug. */
370328 private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> {
371329 const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p);
372330 const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id);
373− const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id);
374− const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]);
331+ const [, people] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>()]);
375332 const out = new Map<string, MemberProfile>();
376333 for (const p of principals) {
377334 if (p.kind === "user") {
387344 avatar_seed: null,
388345 });
389346 } else {
390− const agent = agents.get(p.id) ?? null;
391− out.set(principalKey(p), {
392− ...p,
393− name: agent?.handle ?? p.id,
394− display_name: agent?.display_name ?? "Former agent",
395− avatar: agent?.avatar ?? null,
396− role: agent?.role ?? null,
397− title: agent?.title || null,
398− avatar_seed: agent?.avatar_seed ?? null,
399− look: agent?.look ?? null,
400− });
347+ // Workspaces no longer have agents: what one wrote shows as a deleted agent's.
348+ out.set(principalKey(p), { ...p, name: p.id, display_name: "A deleted agent", avatar: null, role: null, title: null, avatar_seed: null, deleted: true });
401349 }
402350 }
403351 // Anything else (system, ghost keys): a plain name.
980928 if (!space?.role) return fail("not_found", "No such space.");
981929 if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space.");
982930 const key = memberKey(String(a.member ?? ""));
983− if (!key) return fail("invalid", "Choose a person, agent or team.");
931+ if (!key) return fail("invalid", "Choose a person or team.");
984932 const role = a.role === null ? null : isRole(a.role) ? a.role : null;
985933 if (a.role !== null && !role) return fail("invalid", "Choose a role.");
986934 // They must belong to the workspace.
989937 const username = this.usernames.get(key.id);
990938 if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added.");
991939 } else if (key.kind === "agent") {
992− const agent = (await this.agentsById([key.id])).get(key.id);
993− if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("invalid", "No such agent in this workspace.");
940+ // An agent left in a space from before can be taken out, never added.
941+ if (role) return fail("invalid", "Choose a person or team.");
994942 } else {
995943 const teams = await this.teamsOf(workspace);
996944 if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace.");
11761124 if (!found.ok) return found;
11771125 await this.clearStale(found.value.page.id, this.userKey(a.viewer!));
11781126 return ok(true);
1179− }
1180−
1181− async stalePagesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; repo?: string | null; since?: string | null; audience: DocAudience | null }): Promise<Result<DocStalePage[]>> {
1182− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
1183− if (!found.ok) return found;
1184− const { workspace, spaces } = found.value;
1185− const repo = a.repo ? projectRef(a.repo) : null;
1186− if (a.repo && !repo) return fail("invalid", "Name the repository as owner/name.");
1187− const since = a.since && !Number.isNaN(Date.parse(a.since)) ? new Date(a.since).toISOString() : null;
1188− const rows = await this.staleRows(
1189− spaces.map((s) => s.row.id),
1190− repo,
1191− 200,
1192− );
1193− const [changes, cited, pages] = await Promise.all([
1194− this.openChanges(rows.map((r) => r.id)),
1195− this.citationsOf(rows.map((r) => r.id)),
1196− this.toPages(workspace, new Map(spaces.map((s) => [s.row.id, s.row])), rows),
1197− ]);
1198− // The agent learns only of code its person can read.
1199− const readable = await this.readableRepos(a.viewer!, [...changes.map((c) => c.repo), ...[...cited.values()].flat().map((c) => c.repo)]);
1200− const bySpace = new Map(spaces.map((s) => [s.row.id, s]));
1201− const out: DocStalePage[] = [];
1202− for (const row of rows) {
1203− const mine = changes.filter((c) => c.page_id === row.id && readable.has(c.repo) && (!repo || c.repo === repo));
1204− if (!mine.length) continue;
1205− const newest = mine[0]!.detected_at;
1206− if (since && newest < since) continue;
1207− const space = bySpace.get(row.space_id)!;
1208− const page = pages.find((p) => p.id === row.id)!;
1209− out.push({
1210− page: { ...this.ref(workspace.slug, space.row, row), updated_at: row.updated_at },
1211− space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
1212− can: space.can,
1213− owners: page.owners,
1214− citations: (cited.get(row.id) ?? []).filter((c) => readable.has(c.repo)),
1215− changes: mine.slice(0, 20).map((c) => this.toChange(c, readable)),
1216− since: mine[mine.length - 1]!.detected_at,
1217− });
1218− if (out.length >= 50) break;
1219− }
1220− return ok(out);
12211127 }
12221128
12231129 /** Where a new page in `space` from `input` starts: its Markdown and title. */
12891195 ]);
12901196 await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null });
12911197 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.created", this.eventData(workspace, space, row), author));
1292− // Its passages, for agents' recall (src/indexer.ts); later edits are indexed by its room.
1198+ // Its passages, for search by meaning (src/indexer.ts); later edits are indexed by its room.
12931199 if (input.markdown.trim()) this.defer(indexPage(this.env, id));
12941200 return row;
12951201 }
13611267 }
13621268 }
13631269 if (c.owners !== undefined) {
1364− const owners = [...new Set((Array.isArray(c.owners) ? c.owners : []).map(String).filter((k) => memberKey(k)?.kind === "user" || memberKey(k)?.kind === "agent"))].slice(0, 20);
1270+ const owners = [...new Set((Array.isArray(c.owners) ? c.owners : []).map(String).filter((k) => memberKey(k)?.kind === "user"))].slice(0, 20);
13651271 statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id));
13661272 for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o));
13671273 }
14381344 const at = now();
14391345 await this.db.batch(ids.map((id) => this.db.prepare("UPDATE pages SET archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL").bind(at, this.userKey(a.viewer!), id)));
14401346 for (const id of ids) this.defer(this.room(id).closeAll("Moved to the trash").catch(() => undefined));
1441− // Out of agents' recall while in the trash; restoring indexes them again.
1347+ // Out of search while in the trash; restoring indexes them again.
14421348 this.defer(forgetDocs(this.env, { page_ids: ids }));
14431349 this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!)));
14441350 const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>();
20791985 if (!found.ok) return found;
20801986 return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads()));
20811987 }
2082−
2083− // ── Agents ──────────────────────────────────────────────────────────────
20841988
2085− /**
2086− * What an agent may read and do for `viewer`, space by space: the
2087− * viewer's own access, narrowed to what every person in the audience can
2088− * read. Never wider than the viewer.
2089− */
2090− private async agentSpaces(slug: string, agentId: string, viewer: Viewer, audience: DocAudience | null): Promise<Result<{ workspace: Workspace; agent: WorkspaceAgent; spaces: (Space & { can: DocAgentAbilities })[] }>> {
2091− const found = await this.viewerWorkspace(slug, viewer);
2092− if (!found.ok) return found;
2093− const workspace = found.value;
2094− const agent = (await this.agentsById([String(agentId ?? "")])).get(String(agentId ?? ""));
2095− if (!agent || agent.workspace_id !== workspace.id || agent.archived_at) return fail("not_found", "No such agent.");
2096− const spaces = await this.spacesFor(workspace, viewer!);
2097− let readable = (s: Space) => !!s.role;
2098− if (audience?.kind === "workspace") {
2099− readable = (s) => !!s.role && readableByWorkspace(rulesOf(s));
2100− } else if (audience?.kind === "people" && Array.isArray(audience.user_ids) && audience.user_ids.length) {
2101− const ids = [...new Set(audience.user_ids.map(String))].slice(0, 200);
2102− await this.nameUsers(ids);
2103− const [members, teams] = await Promise.all([this.members(workspace), this.needsTeams(spaces) ? this.teamsOf(workspace) : Promise.resolve(new Map<string, Set<string>>())]);
2104− const people: Person[] = ids.map((id) => {
2105− const username = this.usernames.get(id)?.toLowerCase() ?? "";
2106− const member = members.get(username);
2107− // Someone who is not a member reads nothing: a person with no teams who owns nothing.
2108− return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: teams.get(username) ?? new Set() };
2109− });
2110− readable = (s) => !!s.role && readableByAll(rulesOf(s), people);
2111− }
2112− return ok({
2113− workspace,
2114− agent,
2115− spaces: spaces.filter(readable).map((s) => ({ ...s, can: agentAbilities(s.role, s.row.agent_mode) })),
2116− });
2117− }
2118−
2119− async spacesForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; audience: DocAudience | null }) {
2120− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2121− if (!found.ok) return found;
2122− return ok(
2123− found.value.spaces.map((s) => ({
2124− id: s.row.id,
2125− slug: s.row.slug,
2126− name: s.row.name,
2127− description: s.row.description,
2128− kind: s.row.kind,
2129− agent_mode: s.row.agent_mode,
2130− projects: s.projects,
2131− can: s.can,
2132− })),
2133− );
2134− }
2135−
2136− /** A page an agent may read for the viewer and audience, or not found. */
2137− private async agentPage(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience?: DocAudience | null }) {
2138− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2139− if (!found.ok) return found;
2140− const page = await this.db
2141− .prepare("SELECT * FROM pages WHERE id = ? AND workspace_id = ? AND archived_at IS NULL")
2142− .bind(String(a.page_id ?? ""), found.value.workspace.id)
2143− .first<PageRow>();
2144− const space = page ? found.value.spaces.find((s) => s.row.id === page.space_id) : null;
2145− if (!page || !space) return fail("not_found", "No such page.");
2146− return ok({ ...found.value, page, space });
2147− }
2148−
2149− async pageMarkdown(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocAgentPage>> {
2150− const found = await this.agentPage(a);
2151− if (!found.ok) return found;
2152− const { workspace, page, space } = found.value;
2153− const room = this.room(page.id);
2154− await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2155− const read = await room.read();
2156− return ok({
2157− page: { ...this.ref(workspace.slug, space.row, page), updated_at: page.updated_at },
2158− space: { id: space.row.id, slug: space.row.slug, name: space.row.name, agent_mode: space.row.agent_mode },
2159− markdown: read.markdown,
2160− blocks: read.blocks,
2161− can: space.can,
2162− });
2163− }
2164−
2165− async searchForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; query: DocSearchQuery; audience: DocAudience | null }): Promise<Result<DocSearchHit[]>> {
2166− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience);
2167− if (!found.ok) return found;
2168− return ok(await this.searchIn(found.value.workspace, found.value.spaces, { ...(a.query ?? { query: "" }), limit: Math.min(Number(a.query?.limit) || 10, 20) }));
2169− }
2170−
21711989 // ── Recall: the semantic index ──────────────────────────────────────────
21721990
21731991 /** Pages indexed again one after another (moved, restored). */
22052023 }
22062024 }
22072025
2208− /** Passages by their words (any of them), best first, from `allowed` spaces. */
2209− private async wordMatches(workspaceId: string, allowed: string[], fts: string, limit: number): Promise<string[]> {
2210− if (!allowed.length) return [];
2211− const named = allowed.length <= 80;
2212− const rows = await this.db
2213− .prepare(
2214− `SELECT doc_chunks_fts.chunk_id AS id FROM doc_chunks_fts JOIN doc_chunks c ON c.id = doc_chunks_fts.chunk_id
2215− WHERE doc_chunks_fts MATCH ? AND c.workspace_id = ? ${named ? `AND doc_chunks_fts.space_id IN (${allowed.map(() => "?").join(",")})` : ""}
2216− ORDER BY bm25(doc_chunks_fts, 0, 0, 0, 4.0, 1.0) LIMIT ?`,
2217− )
2218− .bind(fts, workspaceId, ...(named ? allowed : []), limit)
2219− .all<{ id: string }>()
2220− .catch((error: unknown) => {
2221− console.error("docs word recall failed", String(error));
2222− return { results: [] as { id: string }[] };
2223− });
2224− return rows.results.map((r) => r.id);
2225− }
2226−
22272026 /**
22282027 * Passages by id as they read now, with their page or file: only those
22292028 * whose page is still out of the trash and whose file is still there.
22522051 return out;
22532052 }
22542053
2255− /**
2256− * Projects' docs an agent may recall from: those the viewer can read
2257− * and, with an audience, everyone in it. A workspace-wide audience, or
2258− * one too large to ask about person by person, gets public repositories
2259− * only. Never wider than the viewer.
2260− */
2261− private async repoSpacesForAudience(workspace: Workspace, viewer: User, audience: DocAudience | null): Promise<{ row: RepoSpaceRow; repo: Repo }[]> {
2262− const mine = await this.readableRepoSpaces(workspace, viewer);
2263− if (!mine.length || !audience) return mine;
2264− const publicOnly = () => mine.filter((s) => !s.repo.isPrivate);
2265− if (audience.kind === "workspace") return publicOnly();
2266− if (audience.kind !== "people" || !Array.isArray(audience.user_ids)) return mine;
2267− const others = [...new Set(audience.user_ids.map(String))].filter((id) => id !== viewer.id);
2268− if (!others.length) return mine;
2269− if (others.length > 20 || !this.env.REPOS) return publicOnly();
2270− await this.nameUsers(others);
2271− const members = await this.members(workspace);
2272− let keep = new Set(mine.map((s) => s.row.repo_id));
2273− for (const id of others) {
2274− const username = this.usernames.get(id)?.toLowerCase();
2275− if (!username) {
2276− const open = new Set(publicOnly().map((s) => s.row.repo_id));
2277− keep = new Set([...keep].filter((r) => open.has(r)));
2278− continue;
2279− }
2280− const member = members.get(username);
2281− // As repos sees them: their membership here and no direct grants, so never wider than they are.
2282− const person: User = { id, username, verified: true, workspaces: member ? [{ slug: workspace.slug, role: member.role }] : [] };
2283− const readable = await reposClient(this.env.REPOS)
2284− .readable([...keep], person)
2285− .catch(() => [] as Repo[]);
2286− keep = new Set(readable.map((r) => r.id));
2287− if (!keep.size) break;
2288− }
2289− return mine.filter((s) => keep.has(s.row.repo_id));
2290− }
2291−
2292− /**
2293− * What the workspace's Docs say about a query, for an agent about to
2294− * answer (DocsApi.recallForAgent): the closest passages by meaning above
2295− * MEANING_FLOOR, required spaces first, at most two per page, filled with
2296− * passages matching its words when meaning finds too few. Only from what
2297− * the viewer and audience can all read, by the same rules as every other
2298− * agent read (`agentSpaces`).
2299− */
2300− async recallForAgent(a: {
2301− workspace: string;
2302− agent_id: string;
2303− viewer: Viewer;
2304− query: string;
2305− limit?: number | null;
2306− spaces?: string[] | null;
2307− audience: DocAudience | null;
2308− }): Promise<Result<DocPassage[]>> {
2309− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, a.audience ?? null);
2310− if (!found.ok) return found;
2311− const { workspace, spaces } = found.value;
2312− this.defer(ensureIndexed(this.env, workspace.id).catch((error: unknown) => console.error("docs could not start indexing", workspace.id, String(error))));
2313− const query = String(a.query ?? "").trim().slice(0, 2000);
2314− if (!query) return ok([]);
2315− const limit = recallLimit(a.limit);
2316− const repoSpaces = await this.repoSpacesForAudience(workspace, a.viewer!, a.audience ?? null).catch((error: unknown) => {
2317− console.error("docs could not check projects' docs for recall", String(error));
2318− return [] as { row: RepoSpaceRow; repo: Repo }[];
2319− });
2320− const allowed = [...spaces.map((s) => s.row.id), ...repoSpaces.map((r) => r.row.id)];
2321− if (!allowed.length) return ok([]);
2322− const required = requiredSpaces(allowed, a.spaces);
2323− const fts = ftsAnyQuery(query);
2324− const vector = await this.queryVector(query);
2325− const [meaning, requiredMeaning, words] = await Promise.all([
2326− vector ? this.meaningMatches(workspace.id, allowed, vector) : Promise.resolve([]),
2327− // Required reading asked on its own too, so the rest of the workspace can't crowd it out.
2328− vector && required.length && required.length < allowed.length ? this.meaningMatches(workspace.id, required, vector) : Promise.resolve([]),
2329− fts ? this.wordMatches(workspace.id, allowed, fts, 30) : Promise.resolve([] as string[]),
2330− ]);
2331− const scores = new Map<string, number>();
2332− for (const m of [...meaning, ...requiredMeaning]) scores.set(m.id, Math.max(scores.get(m.id) ?? 0, m.score));
2333− const rows = await this.passages(workspace.id, [...scores.keys(), ...words]);
2334− const candidates: (Candidate & { row: PassageRow })[] = [];
2335− for (const [id, score] of scores) {
2336− const row = rows.get(id);
2337− if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score, by: "meaning", row });
2338− }
2339− for (const id of words) {
2340− const row = rows.get(id);
2341− if (row) candidates.push({ id, doc_id: row.page_id ?? row.repo_file_id!, space_id: row.space_id, score: WORDS_SCORE, by: "words", row });
2342− }
2343− const picked = pickPassages(candidates, { allowed: new Set(allowed), required, limit });
2344− const stale = await this.staleIds([...new Set(picked.map((c) => c.row.page_id).filter((id): id is string => !!id))]);
2345− const bySpace = new Map(spaces.map((s) => [s.row.id, s.row]));
2346− const byRepo = new Map(repoSpaces.map((r) => [r.row.id, r]));
2347− const out: DocPassage[] = [];
2348− for (const c of picked) {
2349− const row = c.row;
2350− const score = Math.round(c.score * 1000) / 1000;
2351− if (row.page_id) {
2352− const space = bySpace.get(row.space_id);
2353− if (!space) continue;
2354− out.push({
2355− page: this.ref(workspace.slug, space, { id: row.page_id, title: row.title ?? "", icon: row.icon }),
2356− repo_file: null,
2357− space_name: space.name,
2358− heading: row.heading,
2359− text: row.text,
2360− score,
2361− updated_at: row.page_updated_at ?? row.updated_at,
2362− stale: stale.has(row.page_id),
2363− });
2364− } else {
2365− const repo = byRepo.get(row.space_id);
2366− if (!repo || !row.path) continue;
2367− const name = `${repo.repo.namespace}/${repo.repo.name}`;
2368− out.push({
2369− page: null,
2370− repo_file: { repo: name, path: row.path, href: `/${workspace.slug}/-/docs/repo/${name}/${row.path.split("/").map(encodeURIComponent).join("/")}` },
2371− space_name: name,
2372− heading: row.heading,
2373− text: row.text,
2374− score,
2375− updated_at: repo.row.indexed_at ?? row.updated_at,
2376− stale: false,
2377− });
2378− }
2379− }
2380− return ok(out);
2381− }
2382−
23832054 /** Indexes the workspace's pages and projects' docs again, on the queue. Owners only. */
23842055 async reindexDocs(a: { workspace: string; viewer: Viewer }): Promise<Result<boolean>> {
23852056 const found = await this.viewerWorkspace(a.workspace, a.viewer);
23862057 if (!found.ok) return found;
23872058 if (!this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only an owner can index the workspace's docs again.");
23882059 return ok(await startBackfill(this.env, found.value.id, { force: true }));
2389− }
2390−
2391− private async fileSuggestion(
2392− workspace: Workspace,
2393− page: PageRow,
2394− space: SpaceRow,
2395− agent: WorkspaceAgent,
2396− viewer: User,
2397− edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean },
2398− ): Promise<Result<DocSuggestion>> {
2399− const room = this.room(page.id);
2400− await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2401− const current = await room.target(edit.target);
2402− if (!current) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
2403− const id = newId("sug");
2404− const row: SuggestionRow = {
2405− id,
2406− page_id: page.id,
2407− author: principalKey({ kind: "agent", id: agent.id }),
2408− asked_by: this.userKey(viewer),
2409− target: JSON.stringify(edit.target),
2410− before_markdown: current.markdown,
2411− after_markdown: edit.markdown,
2412− note: edit.note,
2413− status: "open",
2414− created_at: now(),
2415− decided_by: null,
2416− decided_at: null,
2417− marks_current: edit.marks_current ? 1 : 0,
2418− };
2419− await this.db
2420− .prepare("INSERT INTO suggestions (id, page_id, author, asked_by, target, before_markdown, after_markdown, note, status, created_at, marks_current) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)")
2421− .bind(row.id, row.page_id, row.author, row.asked_by, row.target, row.before_markdown, row.after_markdown, row.note, row.created_at, row.marks_current)
2422− .run();
2423− const [suggestion] = await this.toSuggestions(workspace, [row], [current.block_ids]);
2424− this.tell(page.id, { type: "suggestion.created", suggestion: suggestion! });
2425− this.defer(room.announce(row.author, agent.display_name).catch(() => undefined));
2426− this.defer(this.notifyOwners(workspace, page, space, suggestion!));
2427− return ok(suggestion!);
2428− }
2429−
2430− /** A page's owners (people) hear of a suggestion waiting for them. */
2431− private async notifyOwners(workspace: Workspace, page: PageRow, space: SpaceRow, suggestion: DocSuggestion): Promise<void> {
2432− if (!this.env.NOTIFY) return;
2433− const owners = (await this.db.prepare("SELECT principal FROM page_owners WHERE page_id = ?").bind(page.id).all<{ principal: string }>()).results
2434− .map((o) => o.principal)
2435− .filter((k) => k.startsWith("user:"))
2436− .map((k) => k.slice(5));
2437− if (!owners.length) return;
2438− const notify = notifyClient(this.env.NOTIFY);
2439− const href = this.ref(workspace.slug, space, page).path;
2440− await Promise.all(
2441− owners.map((id) =>
2442− notify
2443− .notify(
2444− { user_id: id },
2445− {
2446− id: `doc-suggestion:${suggestion.id}:${id}`,
2447− kind: "inbox",
2448− workspace: workspace.slug,
2449− title: `${suggestion.author.display_name} suggested a change to ${page.title || "Untitled"}`,
2450− body: suggestion.note ?? excerpt(suggestion.after_markdown, 140),
2451− href,
2452− actor: { kind: "agent", id: suggestion.author.id, name: suggestion.author.display_name, avatar: suggestion.author.avatar, avatar_seed: suggestion.author.avatar_seed ?? null, look: suggestion.author.look ?? null },
2453− created_at: suggestion.created_at,
2454− },
2455− )
2456− .catch(() => undefined),
2457− ),
2458− );
2459− }
2460−
2461− private cleanEdit(edit: unknown): Result<{ target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }> {
2462− const e = (edit ?? {}) as { target?: unknown; markdown?: unknown; note?: unknown; marks_current?: unknown };
2463− const target = cleanTarget(e.target);
2464− if (!target) return fail("invalid", "Say what to change: append, document, a section by its heading, or blocks by id.");
2465− const markdown = String(e.markdown ?? "");
2466− if (markdown.length > MAX_MARKDOWN) return fail("invalid", "That edit is too long.");
2467− if (target.kind === "append" && !markdown.trim()) return fail("invalid", "Nothing to add.");
2468− return ok({ target, markdown, note: e.note ? String(e.note).trim().slice(0, MAX_NOTE) || null : null, marks_current: e.marks_current === true });
2469− }
2470−
2471− async suggestEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocSuggestion>> {
2472− const edit = this.cleanEdit(a.edit);
2473− if (!edit.ok) return edit;
2474− const found = await this.agentPage(a);
2475− if (!found.ok) return found;
2476− const { workspace, agent, page, space } = found.value;
2477− if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so no change can be suggested for them.`);
2478− return this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2479− }
2480−
2481− async applyEdit(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; edit: unknown }): Promise<Result<DocAgentEditResult>> {
2482− const edit = this.cleanEdit(a.edit);
2483− if (!edit.ok) return edit;
2484− const found = await this.agentPage(a);
2485− if (!found.ok) return found;
2486− const { workspace, agent, page, space } = found.value;
2487− const ref = this.ref(workspace.slug, space.row, page);
2488− if (!space.can.edit) {
2489− if (!space.can.suggest) return fail("forbidden", `${a.viewer!.username} can only read this page, so it can't be changed for them.`);
2490− const suggestion = await this.fileSuggestion(workspace, page, space.row, agent, a.viewer!, edit.value);
2491− return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, page: ref }) : suggestion;
2492− }
2493− const room = this.room(page.id);
2494− await room.ensure({ page_id: page.id, workspace_slug: workspace.slug, markdown: page.markdown });
2495− const result = await room.edit(edit.value.target, edit.value.markdown, {
2496− key: principalKey({ kind: "agent", id: agent.id }),
2497− kind: "agent",
2498− note: edit.value.note ? `@${agent.handle} for @${a.viewer!.username}: ${edit.value.note}` : `@${agent.handle} for @${a.viewer!.username}`,
2499− authors: [principalKey({ kind: "agent", id: agent.id })],
2500− });
2501− if (!result.applied) return fail("not_found", "That part of the page isn't there. Read the page again and target what is there now.");
2502− if (edit.value.marks_current) await this.clearStale(page.id, principalKey({ kind: "agent", id: agent.id }));
2503− this.defer(room.announce(principalKey({ kind: "agent", id: agent.id }), agent.display_name).catch(() => undefined));
2504− return ok({ mode: "applied", version_id: result.version_id, page: ref });
2505− }
2506−
2507− async createPageAsAgent(a: {
2508− workspace: string;
2509− agent_id: string;
2510− viewer: Viewer;
2511− input: { space_id?: string | null; parent_id?: string | null; title: string; icon?: string | null; markdown: string; source?: { title: string; href: string } | null };
2512− }): Promise<Result<DocPageRef>> {
2513− const found = await this.agentSpaces(a.workspace, a.agent_id, a.viewer, null);
2514− if (!found.ok) return found;
2515− const { workspace, agent, spaces } = found.value;
2516− const input = a.input ?? ({} as typeof a.input);
2517− const space = input.space_id ? spaces.find((s) => s.row.id === input.space_id) : spaces.find((s) => s.row.is_default);
2518− if (!space) return fail("not_found", "No such space.");
2519− if (!atLeast(space.role, "edit")) return fail("forbidden", `${a.viewer!.username} can't add pages to ${space.row.name}.`);
2520− const title = cleanTitle(input.title);
2521− if (!title) return fail("invalid", "Give the page a title.");
2522− let markdown = String(input.markdown ?? "").slice(0, MAX_MARKDOWN);
2523− const source = input.source && typeof input.source.href === "string" && input.source.href.startsWith("/") ? input.source : null;
2524− if (source) markdown = `> [!NOTE]\n> Written up from [${String(source.title || "a conversation").replace(/[[\]]/g, "")}](${source.href}).\n\n${markdown}`;
2525− const parent = input.parent_id
2526− ? await this.db.prepare("SELECT id FROM pages WHERE id = ? AND space_id = ? AND archived_at IS NULL").bind(input.parent_id, space.row.id).first<{ id: string }>()
2527− : null;
2528− const agentKey = principalKey({ kind: "agent", id: agent.id });
2529− const row = await this.insertPage(workspace, space.row, agentKey, {
2530− parent_id: parent?.id ?? null,
2531− title,
2532− icon: cleanIcon(input.icon),
2533− markdown,
2534− owners: [this.userKey(a.viewer!)],
2535− });
2536− return ok(this.ref(workspace.slug, space.row, row));
25372060 }
25382061
2539− async threadsForAgent(a: { workspace: string; agent_id: string; viewer: Viewer; page_id: string; audience: DocAudience | null }): Promise<Result<DocThread[]>> {
2540− const found = await this.agentPage(a);
2541− if (!found.ok) return found;
2542− return ok(await this.resolveThreads(found.value.workspace, await this.room(found.value.page.id).threads()));
2543− }
2544−
25452062 // ── Sockets and files ───────────────────────────────────────────────────
25462063
25472064 private viewerFrom(request: Request): Viewer {
26892206 return Response.json(await service.thread(args));
26902207 case "threads":
26912208 return Response.json(await service.threads(args));
2692− case "spaces_for_agent":
2693− return Response.json(await service.spacesForAgent(args));
2694− case "page_markdown":
2695− return Response.json(await service.pageMarkdown(args));
2696− case "search_for_agent":
2697− return Response.json(await service.searchForAgent(args));
2698− case "suggest_edit":
2699− return Response.json(await service.suggestEdit(args));
2700− case "apply_edit":
2701− return Response.json(await service.applyEdit(args));
2702− case "create_page_as_agent":
2703− return Response.json(await service.createPageAsAgent(args));
2704− case "threads_for_agent":
2705− return Response.json(await service.threadsForAgent(args));
2706− case "stale_pages_for_agent":
2707− return Response.json(await service.stalePagesForAgent(args));
27082209 case "mark_current":
27092210 return Response.json(await service.markCurrent(args));
27102211 case "stale_pages":
27152216 return Response.json(await service.removeRepoSpace(args));
27162217 case "repo_page":
27172218 return Response.json(await service.repoPage(args));
2718− case "recall_for_agent":
2719− return Response.json(await service.recallForAgent(args));
27202219 case "reindex_docs":
27212220 return Response.json(await service.reindexDocs(args));
27222221 default:
+0−14
341341 }
342342
343343 /**
344− * The first time anyone recalls from a workspace's docs: when it has
345− * pages or projects' docs and has never been indexed, start the backfill.
346− */
347−export async function ensureIndexed(env: IndexEnv, workspaceId: string): Promise<void> {
348− const row = await env.DB.prepare(
349− "SELECT (SELECT 1 FROM doc_index_runs WHERE workspace_id = ?1) AS ran, (SELECT 1 FROM pages WHERE workspace_id = ?1 AND archived_at IS NULL LIMIT 1) AS page, (SELECT 1 FROM folios WHERE workspace_id = ?1 AND trashed_at IS NULL LIMIT 1) AS folio, (SELECT 1 FROM repo_spaces WHERE workspace_id = ?1 LIMIT 1) AS repo",
350− )
351− .bind(workspaceId)
352− .first<{ ran: number | null; page: number | null; folio: number | null; repo: number | null }>();
353− if (!row || row.ran || (!row.page && !row.folio && !row.repo)) return;
354− await startBackfill(env, workspaceId);
355−}
356−
357−/**
358344 * One batch of a workspace's backfill: pages by id, then projects' docs
359345 * files by space and path, from the run's cursor. Hands on to the next
360346 * batch (or waits out the hour's cap) through the queue.
+1−64
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { MAX_IN_FILTER, MEANING_FLOOR, QueryCache, TOP_K, TOP_K_UNFILTERED, fuseRanks, pickPassages, queryKey, recallLimit, requiredSpaces, vectorQueryPlan, type Candidate } from "./recall.ts";
5−import { ftsAnyQuery } from "./search.ts";
6−
7−const c = (id: string, space: string, score: number, by: Candidate["by"] = "meaning"): Candidate => ({ id, doc_id: id.split(":")[0]!, space_id: space, score, by });
4+import { MAX_IN_FILTER, QueryCache, TOP_K, TOP_K_UNFILTERED, fuseRanks, queryKey, vectorQueryPlan } from "./recall.ts";
85
96 test("the index is filtered by the allowed spaces, or by workspace and after when there are too many", () => {
107 assert.equal(vectorQueryPlan("w1", []), null);
1310 assert.deepEqual(vectorQueryPlan("w1", many), { topK: TOP_K_UNFILTERED, filter: { workspace_id: "w1" }, filterAfter: true });
1411 });
1512
16−test("required reading never widens what may be read", () => {
17− assert.deepEqual(requiredSpaces(["s1", "s2"], ["s2", "s3", "s2"]), ["s2"]);
18− assert.deepEqual(requiredSpaces(["s1"], null), []);
19− assert.deepEqual(requiredSpaces(["s1"], "s1"), []);
20−});
21−
22−test("limits: five by default, ten at most", () => {
23− assert.equal(recallLimit(undefined), 5);
24− assert.equal(recallLimit(0), 5);
25− assert.equal(recallLimit(3), 3);
26− assert.equal(recallLimit(50), 10);
27− assert.equal(recallLimit("x"), 5);
28−});
29−
30−test("only close enough, only allowed, at most two per page, best first", () => {
31− const picked = pickPassages(
32− [
33− c("p1:0", "s1", 0.81),
34− c("p1:1", "s1", 0.8),
35− c("p1:2", "s1", 0.79),
36− c("p2:0", "s1", 0.7),
37− c("p3:0", "secret", 0.95),
38− c("p4:0", "s1", MEANING_FLOOR - 0.01),
39− ],
40− { allowed: new Set(["s1"]), limit: 10 },
41− );
42− assert.deepEqual(
43− picked.map((p) => p.id),
44− ["p1:0", "p1:1", "p2:0"],
45− );
46−});
47−
48−test("required spaces come first, then the rest", () => {
49− const picked = pickPassages([c("a:0", "s1", 0.9), c("b:0", "s2", 0.75), c("c:0", "s1", 0.85)], { allowed: new Set(["s1", "s2"]), required: ["s2"], limit: 2 });
50− assert.deepEqual(
51− picked.map((p) => p.id),
52− ["b:0", "a:0"],
53− );
54−});
55−
56−test("words fill in only when meaning finds too few, and never twice", () => {
57− const candidates = [c("a:0", "s1", 0.9), c("a:0", "s1", 0.5, "words"), c("b:0", "s1", 0.5, "words"), c("c:0", "s1", 0.5, "words"), c("d:0", "nope", 0.5, "words")];
58− assert.deepEqual(
59− pickPassages(candidates, { allowed: new Set(["s1"]), limit: 3 }).map((p) => `${p.id}/${p.by}`),
60− ["a:0/meaning", "b:0/words", "c:0/words"],
61− );
62− assert.deepEqual(
63− pickPassages(candidates, { allowed: new Set(["s1"]), limit: 1 }).map((p) => p.id),
64− ["a:0"],
65− );
66− // Nothing close and no words: nothing.
67− assert.deepEqual(pickPassages([c("x:0", "s1", 0.3)], { allowed: new Set(["s1"]), limit: 5 }), []);
68−});
69−
7013 test("hybrid search fuses ranks: found both ways first", () => {
7114 assert.deepEqual(fuseRanks(["a", "b", "c"], ["c", "d"]), ["c", "a", "b", "d"]);
7215 assert.deepEqual(fuseRanks([], ["x", "y"]), ["x", "y"]);
8326 assert.equal(cache.get("a", 3), null);
8427 assert.deepEqual(cache.get("c", 3), [3]);
8528 assert.equal(queryKey(" How do we Deploy? "), "how do we deploy?");
86−});
87−
88−test("recall's word fallback matches any meaningful word", () => {
89− assert.equal(ftsAnyQuery("How do we roll back a deploy?"), '"roll" OR "back" OR "deploy"');
90− assert.equal(ftsAnyQuery("is it a"), null);
91− assert.equal(ftsAnyQuery('NEAR("x") deploy-key'), '"near" OR "deploy" OR "key"');
9229 });
+4−71
11 /**
22 * Recall's choices, apart from where the passages come from: how the
33 * vector query is filtered, which matches are close enough, and how
4− * passages are picked (an agent's required reading first, at most two per
5− * document, words only to fill). Pure; src/index.ts `recallForAgent` and
6− * hybrid search run it over Vectorize and D1.
4+ * results of words and meaning are fused. Pure; hybrid search runs it
5+ * over Vectorize and D1.
76 */
87
98 /**
1716 * keeps recall quiet when the docs say nothing about the question.
1817 */
1918 export const MEANING_FLOOR = 0.7;
20−/** The score a passage found only by its words carries: below the floor, so callers can tell. */
21−export const WORDS_SCORE = 0.5;
2219 /** Nearest passages asked of the index. */
2320 export const TOP_K = 24;
2421 /** Asked when the index can't filter by space and recall filters after: more, so enough are left. */
2522 export const TOP_K_UNFILTERED = 50;
2623 /** The most space ids put in one `$in` filter; past it, filter after the query (a Vectorize filter is at most 2 KB of JSON). */
2724 export const MAX_IN_FILTER = 40;
28−/** Passages from one page or file at most. */
29−export const PER_DOC = 2;
30−export const DEFAULT_LIMIT = 5;
31−export const MAX_LIMIT = 10;
3225
33−export function recallLimit(limit: unknown): number {
34− const n = Math.floor(Number(limit));
35− if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT;
36− return Math.min(n, MAX_LIMIT);
37−}
38−
3926 /**
4027 * How to ask the index: by the allowed spaces when there are few enough
4128 * to name, otherwise by workspace alone, more of them, filtered after.
4633 if (!ids.length) return null;
4734 if (ids.length <= MAX_IN_FILTER) return { topK: TOP_K, filter: { workspace_id: workspaceId, space_ids: ids }, filterAfter: false };
4835 return { topK: TOP_K_UNFILTERED, filter: { workspace_id: workspaceId }, filterAfter: true };
49−}
50−
51−/**
52− * The spaces recall looks in first: those asked for (an agent's required
53− * reading) that may be read. Never wider than what may be read.
54− */
55−export function requiredSpaces(allowed: string[], asked: unknown): string[] {
56− if (!Array.isArray(asked)) return [];
57− const may = new Set(allowed);
58− return [...new Set(asked.map(String))].filter((id) => may.has(id));
5936 }
60−
61−export type Candidate = {
62− /** The passage's id: `<doc>:<seq>`. */
63− id: string;
64− /** Its page's or file's id. */
65− doc_id: string;
66− space_id: string;
67− score: number;
68− /** Found by meaning (the index) or by its words (full text). */
69− by: "meaning" | "words";
70−};
7137
7238 /**
73− * The passages to hand over, best first: by meaning above the floor,
74− * required spaces first, then the rest; then, if that is fewer than
75− * `limit`, by words, required spaces first. At most PER_DOC from one
76− * document, each passage once, only from `allowed`.
77− */
78−export function pickPassages<T extends Candidate>(candidates: T[], options: { allowed: Set<string>; required?: string[]; limit: number; floor?: number }): T[] {
79− const floor = options.floor ?? MEANING_FLOOR;
80− const required = new Set(options.required ?? []);
81− const usable = candidates.filter((c) => options.allowed.has(c.space_id));
82− const meaning = usable.filter((c) => c.by === "meaning" && c.score >= floor).sort((a, b) => b.score - a.score);
83− const words = usable.filter((c) => c.by === "words");
84− const out: T[] = [];
85− const taken = new Set<string>();
86− const perDoc = new Map<string, number>();
87− const take = (list: T[]) => {
88− for (const c of list) {
89− if (out.length >= options.limit) return;
90− if (taken.has(c.id)) continue;
91− const n = perDoc.get(c.doc_id) ?? 0;
92− if (n >= PER_DOC) continue;
93− taken.add(c.id);
94− perDoc.set(c.doc_id, n + 1);
95− out.push(c);
96− }
97− };
98− take(meaning.filter((c) => required.has(c.space_id)));
99− take(meaning.filter((c) => !required.has(c.space_id)));
100− take(words.filter((c) => required.has(c.space_id)));
101− take(words.filter((c) => !required.has(c.space_id)));
102− return out;
103−}
104−
105−/**
10639 * Hybrid search's order for people: each document's place in the word
10740 * results and in the meaning results, fused by reciprocal rank (k = 60),
10841 * so a page both find comes first and either alone still counts.
11952 }
12053
12154 /**
122− * A query's embeddings, kept a minute per isolate: an agent asked the same
123− * thing again in a session, or a search page reloaded, embeds once.
55+ * A query's embeddings, kept a minute per isolate: a search page
56+ * reloaded embeds once.
12457 */
12558 export class QueryCache {
12659 private readonly entries = new Map<string, { at: number; vector: number[] }>();
+0−25
309309 return listThreads(this.load());
310310 }
311311
312− /**
313− * Shows an agent on the page for a little while (its face in everyone's
314− * presence row) when it edits or suggests: an awareness entry of its
315− * own, which editors drop after 30 s without renewal, as for anyone.
316− */
317− async announce(key: string, name: string): Promise<void> {
318− let id = 0;
319− for (let i = 0; i < key.length; i++) id = (Math.imul(id, 31) + key.charCodeAt(i)) | 0;
320− const client = (id & 0x3fffffff) + 1;
321− const clock = Math.floor(Date.now() / 1000);
322− const state = JSON.stringify({ user: { name, color: "#b8a6ff", key, kind: "agent", avatar: "" } });
323− const update = encoding.createEncoder();
324− encoding.writeVarUint(update, 1);
325− encoding.writeVarUint(update, client);
326− encoding.writeVarUint(update, clock);
327− encoding.writeVarString(update, state);
328− const bytes = encoding.toUint8Array(update);
329− this.awareness.set(client, bytes);
330− this.clocks.set(client, clock);
331− const message = encoding.createEncoder();
332− encoding.writeVarUint(message, MESSAGE_AWARENESS);
333− encoding.writeVarUint8Array(message, bytes);
334− this.send(encoding.toUint8Array(message));
335− }
336−
337312 /** Tells everyone with the page open. */
338313 async notice(event: DocsLiveEvent): Promise<void> {
339314 this.send(JSON.stringify(event));
+0−23
4040 return /^[a-z0-9][a-z0-9._-]{0,99}\/[a-z0-9._-]{1,100}$/.test(s) ? s : null;
4141 }
4242
43−/** Words too common to say what a question is about. */
44−const STOP_WORDS = new Set(
45− "a an and are as at be but by can could did do does for from had has have how i if in into is it its me my of on or our should so that the their them then there these they this to us was we were what when where which who why will with would you your about after before any been being both each few more most other over same some such than through too under until very".split(" "),
46−);
47−
48−/**
49− * A question as a loose FTS5 query: its meaningful words (quoted, longer
50− * than two letters, at most ten), any of them; bm25 ranks passages with
51− * more of them first. For recall's word fallback, where a whole sentence
52− * rarely matches every word. Null when no word is left.
53− */
54−export function ftsAnyQuery(text: string): string | null {
55− const words = [
56− ...new Set(
57− String(text ?? "")
58− .toLowerCase()
59− .split(/[^\p{L}\p{N}_]+/u)
60− .filter((w) => w.length > 2 && !STOP_WORDS.has(w)),
61− ),
62− ].slice(0, 10);
63− if (!words.length) return null;
64− return words.map((w) => `"${w.replace(/"/g, "")}"`).join(" OR ");
65−}
+9−33
Loading the diff…
+3−5
Loading the diff…
+0−45
Loading the diff…
+0−54
Loading the diff…
+14−66
Loading the diff…
+11−60
Loading the diff…
+0−161
Loading the diff…
+0−235
Loading the diff…
+41−679
Loading the diff…
+1−21
Loading the diff…
+1−29
Loading the diff…
+0−22
Loading the diff…
+1−21
Loading the diff…
+2−58
Loading the diff…
+8−58
Loading the diff…
+0−2
Loading the diff…
+5−3
Loading the diff…
+4−3
Loading the diff…
+8−8
Loading the diff…
+2−2
Loading the diff…
+7−5
Loading the diff…
+3−5
Loading the diff…
+1−1
Loading the diff…