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.
38 files+174−30590/38 viewed
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 5 | 5 | ||
| 6 | 6 | const ana: Person = { user_id: "u1", owner: false, teams: new Set(["web"]) }; | |
| 7 | 7 | const bo: Person = { user_id: "u2", owner: false, teams: new Set() }; | |
| ⋯ | |||
| 41 | 41 | assert.equal(roleOf(secret, ana), "manage"); | |
| 42 | 42 | }); | |
| 43 | 43 | ||
| 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", () => { | |
| 48 | 45 | assert.equal(readableByWorkspace(workspace), true); | |
| 49 | 46 | assert.equal(readableByWorkspace(team), false); | |
| 50 | 47 | 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 }); | |
| 59 | 48 | }); | |
| 60 | 49 | ||
| 61 | 50 | test("roles order view < comment < edit < manage", () => { | |
| 1 | 1 | /** | |
| 2 | 2 | * 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. | |
| 5 | 4 | * | |
| 6 | 5 | * - `workspace` spaces give every member `default_role`; `team` spaces give | |
| 7 | 6 | * the team's members `default_role`; `private` spaces give nobody | |
| 8 | 7 | * 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: | |
| 10 | 9 | * a person's role is the highest that applies to them. | |
| 11 | 10 | * - Workspace owners manage every workspace and team space. A private | |
| 12 | 11 | * 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. | |
| 16 | 12 | */ | |
| 17 | − | import type { DocAgentAbilities, DocAgentMode, DocRole, DocSpaceKind } from "@g1t/contracts"; | |
| 13 | + | import type { DocRole, DocSpaceKind } from "@g1t/contracts"; | |
| 18 | 14 | ||
| 19 | 15 | export const RANK: Record<DocRole, number> = { view: 1, comment: 2, edit: 3, manage: 4 }; | |
| 20 | 16 | ||
| ⋯ | |||
| 65 | 61 | } | |
| 66 | 62 | if (person.owner && space.kind !== "private") role = "manage"; | |
| 67 | 63 | 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")); | |
| 73 | 64 | } | |
| 74 | 65 | ||
| 75 | 66 | /** | |
| 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. | |
| 78 | 69 | */ | |
| 79 | 70 | export function readableByWorkspace(space: SpaceRules): boolean { | |
| 80 | 71 | 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 | − | }; | |
| 96 | 72 | } | |
| 97 | 73 | ||
| 98 | 74 | /** Whether `role` holders may change who is in a space and its settings. */ | |
| ⋯ | |||
| 135 | 111 | // role, `link` gives it to members who opened the link (a visit). | |
| 136 | 112 | // | |
| 137 | 113 | // 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. | |
| 141 | 115 | ||
| 142 | 116 | /** One folio as access needs it. */ | |
| 143 | 117 | export type FolioAclNode = { | |
| ⋯ | |||
| 257 | 231 | } | |
| 258 | 232 | ||
| 259 | 233 | /** | |
| 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 | |
| 262 | 235 | * role) or general access `workspace`; never a link, grants or Private. | |
| 263 | 236 | */ | |
| 264 | 237 | export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean { | |
| ⋯ | |||
| 266 | 239 | if (!root) return false; | |
| 267 | 240 | if (root.general_access === "workspace") return true; | |
| 268 | 241 | 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")); | |
| 280 | 242 | } | |
| 281 | 243 | ||
| 282 | 244 | /** | |
| ⋯ | |||
| 313 | 275 | return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit")); | |
| 314 | 276 | } | |
| 315 | 277 | ||
| 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 | − | } | |
| 4 | 4 | import { | |
| 5 | 5 | aclChain, | |
| 6 | 6 | aclRootOf, | |
| 7 | − | agentAbilities, | |
| 8 | − | agentFolioRole, | |
| 9 | 7 | canShare, | |
| 10 | 8 | effectiveRole, | |
| 11 | 9 | explicitAccess, | |
| 12 | 10 | folioPathOf, | |
| 13 | − | folioReadableByAll, | |
| 14 | 11 | folioReadableByWorkspace, | |
| 15 | 12 | folioScope, | |
| 16 | 13 | generalRoleCap, | |
| ⋯ | |||
| 214 | 211 | // Restricted inside an open space: not the workspace's. | |
| 215 | 212 | const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]); | |
| 216 | 213 | 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); | |
| 245 | 214 | }); | |
| 246 | 215 | ||
| 247 | 216 | test("who may share", () => { | |
| 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 | − | }); |
| 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 | − | } |
| 60 | 60 | NOTIFY?: ServiceBinding; | |
| 61 | 61 | EVENTS?: ServiceBinding; | |
| 62 | 62 | IDENTITY: ServiceBinding; | |
| 63 | − | AGENTS: ServiceBinding; | |
| 64 | 63 | REPOS?: ServiceBinding; | |
| 65 | 64 | /** The rooms themselves, as the Worker binds them (mentions are checked through the service). */ | |
| 66 | 65 | FOLIOS: DurableObjectNamespace<FolioRoom>; | |
| ⋯ | |||
| 318 | 317 | ||
| 319 | 318 | async threads(): Promise<ReturnType<typeof listThreads>> { | |
| 320 | 319 | 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)); | |
| 342 | 320 | } | |
| 343 | 321 | ||
| 344 | 322 | /** Tells everyone with the folio open. */ | |
| 8 | 8 | function contractMethods(): string[] { | |
| 9 | 9 | const source = readFileSync(new URL("../../../../packages/contracts/src/folios.ts", import.meta.url), "utf8"); | |
| 10 | 10 | 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)); | |
| 12 | 13 | } | |
| 13 | 14 | ||
| 14 | 15 | test("every folio RPC method in the contract is answered, and nothing else", () => { | |
| ⋯ | |||
| 19 | 20 | }); | |
| 20 | 21 | ||
| 21 | 22 | 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); | |
| 23 | 24 | }); | |
| 12 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| 13 | 13 | type Handler = (service: Folios, args: any) => Promise<unknown>; | |
| 14 | 14 | ||
| 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> = { | |
| 16 | 22 | folio_list: (s, a) => s.list(a), | |
| 17 | 23 | folio_sidebar: (s, a) => s.sidebar(a), | |
| 18 | 24 | folio: (s, a) => s.folio(a), | |
| ⋯ | |||
| 52 | 58 | folio_threads: (s, a) => s.threads(a), | |
| 53 | 59 | query_tile: (s, a) => s.queryTile(a), | |
| 54 | 60 | 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), | |
| 64 | 61 | mark_folio_current: (s, a) => s.markCurrent(a), | |
| 65 | 62 | reindex_folios: (s, a) => s.reindex(a), | |
| 66 | 63 | }; | |
| 67 | 64 | ||
| 68 | 65 | 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; | |
| 70 | 67 | } | |
| 7 | 7 | * the folio's chain, after the list SQL's coarse filter (`folio_access`, | |
| 8 | 8 | * readable spaces, general access, link visits). Everything that changes | |
| 9 | 9 | * 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. | |
| 13 | 11 | */ | |
| 14 | 12 | import { | |
| 15 | 13 | DOCS_VIEWER_HEADER, | |
| ⋯ | |||
| 29 | 27 | notifyClient, | |
| 30 | 28 | ok, | |
| 31 | 29 | parsePrincipalKey, | |
| 32 | − | principalKey, | |
| 33 | 30 | reposClient, | |
| 34 | 31 | type DocAgentMode, | |
| 35 | − | type DocAudience, | |
| 36 | 32 | type DocCitation, | |
| 37 | 33 | type DocEditTarget, | |
| 38 | 34 | type DocRepoSpace, | |
| ⋯ | |||
| 54 | 50 | type FolioListQuery, | |
| 55 | 51 | type FolioMove, | |
| 56 | 52 | type FolioPage, | |
| 57 | − | type FolioPassage, | |
| 58 | 53 | type FolioProposal, | |
| 59 | 54 | type FolioRef, | |
| 60 | 55 | type FolioSearchHit, | |
| ⋯ | |||
| 73 | 68 | type User, | |
| 74 | 69 | type Viewer, | |
| 75 | 70 | type Workspace, | |
| 76 | − | type WorkspaceAgent, | |
| 77 | 71 | } from "@g1t/contracts"; | |
| 78 | 72 | ||
| 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"; | |
| 80 | 74 | import { diffLines } from "../diff.ts"; | |
| 81 | 75 | 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"; | |
| 83 | 77 | import { kindModel } from "../kinds/index.ts"; | |
| 84 | 78 | import type { FolioOrigin } from "../kinds/types.ts"; | |
| 85 | 79 | 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"; | |
| 87 | 81 | import type { RepoSpaceRow } from "../repo-spaces.ts"; | |
| 88 | 82 | import { ROOM_MEMBER_HEADER } from "../room.ts"; | |
| 89 | − | import { ftsAnyQuery, ftsQuery, projectRef } from "../search.ts"; | |
| 83 | + | import { ftsQuery, projectRef } from "../search.ts"; | |
| 90 | 84 | import type { ThreadResult } from "../threads.ts"; | |
| 91 | 85 | 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"; | |
| 93 | 87 | import { | |
| 94 | 88 | FOLIO_COLUMNS, | |
| 95 | 89 | aclNode, | |
| ⋯ | |||
| 108 | 102 | type FolioRow, | |
| 109 | 103 | type ReaderContext, | |
| 110 | 104 | } from "./access-store.ts"; | |
| 111 | − | import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts"; | |
| 112 | 105 | import { publishFolioEvent, publishSpaceDeleted } from "./events.ts"; | |
| 113 | 106 | import { MAX_DEPTH, MAX_PAGE_STATE, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, pageState, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts"; | |
| 114 | 107 | import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts"; | |
| ⋯ | |||
| 136 | 129 | }; | |
| 137 | 130 | ||
| 138 | 131 | type Args = { workspace: string; viewer: Viewer }; | |
| 139 | − | type AgentArgs = Args & { agent_id: string; audience?: DocAudience | null }; | |
| 140 | 132 | ||
| 141 | 133 | /** The viewer in their workspace, with their spaces. */ | |
| 142 | 134 | 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[] }; | |
| 146 | 135 | ||
| 147 | 136 | type SuggestionRow = { | |
| 148 | 137 | id: string; | |
| ⋯ | |||
| 662 | 651 | return ok({ text, spec, title, icon }); | |
| 663 | 652 | } | |
| 664 | 653 | ||
| 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. */ | |
| 666 | 655 | private async principalExists(ctx: Ctx, principal: string): Promise<boolean> { | |
| 667 | 656 | const p = parsePrincipalKey(principal); | |
| 668 | 657 | if (principal.startsWith("team:")) { | |
| 669 | 658 | const slug = principal.slice(5).toLowerCase(); | |
| 670 | 659 | return [...(await this.who.teamsOf(ctx.workspace)).values()].some((set) => set.has(slug)); | |
| 671 | 660 | } | |
| 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; | |
| 677 | 662 | await this.who.nameUsers([p.id]); | |
| 678 | 663 | const username = this.who.usernames.get(p.id); | |
| 679 | 664 | return !!username && (await this.who.members(ctx.workspace)).has(username.toLowerCase()); | |
| ⋯ | |||
| 766 | 751 | return (await foliosById(this.db, [id])).get(id) ?? row; | |
| 767 | 752 | } | |
| 768 | 753 | ||
| 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. */ | |
| 770 | 755 | private async cleanShares(ctx: Ctx, share: { principal: string; role: DocRole }[] | null | undefined): Promise<Result<{ principal: string; role: DocRole }[]>> { | |
| 771 | 756 | const out: { principal: string; role: DocRole }[] = []; | |
| 772 | 757 | for (const s of (share ?? []).slice(0, FOLIO_MAX_SHARE)) { | |
| 773 | 758 | 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.`); | |
| 775 | 760 | out.push({ principal, role: s.role }); | |
| 776 | 761 | } | |
| 777 | 762 | return ok(out); | |
| ⋯ | |||
| 1220 | 1205 | return ok({ mode: "applied", version_id: result.version_id, folio: ref, summary: result.summary }); | |
| 1221 | 1206 | } | |
| 1222 | 1207 | 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 }); | |
| 1224 | 1209 | return suggestion.ok ? ok({ mode: "suggested", suggestion: suggestion.value, folio: ref }) : suggestion; | |
| 1225 | 1210 | } | |
| 1226 | 1211 | ||
| ⋯ | |||
| 1316 | 1301 | await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run(); | |
| 1317 | 1302 | return ok(await this.afterShare(ctx, row)); | |
| 1318 | 1303 | } | |
| 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."); | |
| 1320 | 1305 | await this.db | |
| 1321 | 1306 | .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") | |
| 1322 | 1307 | .bind(row.id, principal, change.role, ctx.key, now()) | |
| ⋯ | |||
| 1812 | 1797 | ); | |
| 1813 | 1798 | } | |
| 1814 | 1799 | ||
| 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. */ | |
| 1816 | 1801 | private async fileSuggestion( | |
| 1817 | 1802 | ctx: Ctx, | |
| 1818 | 1803 | row: FolioRow, | |
| 1819 | − | by: { author: string; asked_by: string | null; agentName: string | null }, | |
| 1804 | + | by: { author: string; asked_by: string | null }, | |
| 1820 | 1805 | edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }, | |
| 1821 | 1806 | ): Promise<Result<FolioSuggestion>> { | |
| 1822 | 1807 | const room = await this.ready(ctx.workspace, row); | |
| ⋯ | |||
| 1843 | 1828 | .run(); | |
| 1844 | 1829 | const [suggestion] = await this.toSuggestions(ctx.workspace, [s], [current.block_ids]); | |
| 1845 | 1830 | this.tell(row.id, { type: "suggestion.created", suggestion: suggestion! }); | |
| 1846 | − | if (by.agentName) this.defer(room.announce(by.author, by.agentName).catch(() => undefined)); | |
| 1847 | 1831 | this.defer(this.notifyOwnerOfSuggestion(ctx, row, suggestion!)); | |
| 1848 | 1832 | return ok(suggestion!); | |
| 1849 | 1833 | } | |
| ⋯ | |||
| 2030 | 2014 | ||
| 2031 | 2015 | async queryDataset(a: Args & { query: unknown }): Promise<Result<never>> { | |
| 2032 | 2016 | 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); | |
| 2039 | 2017 | if (!found.ok) return found; | |
| 2040 | 2018 | return fail("invalid", "Dashboards aren't here yet."); | |
| 2041 | 2019 | } | |
| ⋯ | |||
| 2063 | 2041 | if (!found.ok) return found; | |
| 2064 | 2042 | if (!found.value.owner) return fail("forbidden", "Only an owner can index the workspace's artifacts again."); | |
| 2065 | 2043 | 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); | |
| 2280 | 2044 | } | |
| 2281 | 2045 | ||
| 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 | − | ||
| 2470 | 2046 | // ── Projects' docs ────────────────────────────────────────────────────── | |
| 2471 | 2047 | ||
| 2472 | 2048 | private async readableRepoSpaces(workspace: Workspace, viewer: User): Promise<{ row: RepoSpaceRow; repo: Repo }[]> { | |
| ⋯ | |||
| 2507 | 2083 | .map((f) => ({ path: f.path, title: f.title })), | |
| 2508 | 2084 | can_remove: row.added_by === ctx.key || ctx.owner, | |
| 2509 | 2085 | })); | |
| 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)); | |
| 2532 | 2086 | } | |
| 2533 | 2087 | ||
| 2534 | 2088 | // ── Sockets and files ─────────────────────────────────────────────────── | |
| 1 | 1 | /** | |
| 2 | 2 | * 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 | |
| 4 | 4 | * (docs.g1t.sh/guides/artifacts/, "Docs"). | |
| 5 | 5 | * | |
| 6 | 6 | * Reached through service bindings: `POST /rpc/<method>` with snake_case | |
| ⋯ | |||
| 32 | 32 | parsePrincipalKey, | |
| 33 | 33 | principalKey, | |
| 34 | 34 | reposClient, | |
| 35 | − | workspaceAgentsClient, | |
| 36 | − | type DocAgentAbilities, | |
| 37 | 35 | type DocCitation, | |
| 38 | − | type DocDescribes, | |
| 39 | 36 | type DocRepoPage, | |
| 40 | 37 | type DocRepoSpace, | |
| 41 | 38 | type DocStaleChange, | |
| 42 | − | type DocStalePage, | |
| 43 | 39 | type DocStaleness, | |
| 44 | 40 | type G1tEvent, | |
| 45 | 41 | type Repo, | |
| 46 | − | type DocAgentEditResult, | |
| 47 | 42 | type DocAgentMode, | |
| 48 | − | type DocAgentPage, | |
| 49 | − | type DocAudience, | |
| 50 | 43 | type DocEditTarget, | |
| 51 | 44 | type DocFile, | |
| 52 | 45 | type DocMove, | |
| ⋯ | |||
| 54 | 47 | type DocPageChange, | |
| 55 | 48 | type DocPageDetail, | |
| 56 | 49 | type DocPageRef, | |
| 57 | − | type DocPassage, | |
| 58 | 50 | type DocRole, | |
| 59 | 51 | type DocSearchHit, | |
| 60 | 52 | type DocSearchQuery, | |
| ⋯ | |||
| 82 | 74 | type User, | |
| 83 | 75 | type Viewer, | |
| 84 | 76 | type Workspace, | |
| 85 | − | type WorkspaceAgent, | |
| 86 | 77 | } from "@g1t/contracts"; | |
| 87 | 78 | ||
| 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"; | |
| 89 | 80 | import { diffLines } from "./diff.ts"; | |
| 90 | 81 | import { cleanDescribes } from "./citations.ts"; | |
| 91 | 82 | import { publishDocEvent } from "./events.ts"; | |
| 92 | 83 | import { fileStore, safeName, servedType, type FileStoreEnv } from "./files.ts"; | |
| 93 | 84 | 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"; | |
| 95 | 86 | 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"; | |
| 97 | 88 | import { ROOM_MEMBER_HEADER, type Origin, type PageRoom, type RoomMember } from "./room.ts"; | |
| 98 | 89 | 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"; | |
| 100 | 91 | import { freeSlug, pageSlug, validSpaceSlug } from "./slugs.ts"; | |
| 101 | 92 | import { BUILTIN_TEMPLATES, builtinTemplate } from "./templates.ts"; | |
| 102 | 93 | import type { ThreadResult } from "./threads.ts"; | |
| ⋯ | |||
| 112 | 103 | type Env = FileStoreEnv & { | |
| 113 | 104 | DB: D1Database; | |
| 114 | 105 | IDENTITY: ServiceBinding; | |
| 115 | − | AGENTS: ServiceBinding; | |
| 116 | 106 | NOTIFY?: ServiceBinding; | |
| 117 | 107 | /** Repositories: who may read one, what a change touched, a project's docs (src/staleness.ts, src/repo-spaces.ts). */ | |
| 118 | 108 | REPOS?: ServiceBinding; | |
| ⋯ | |||
| 229 | 219 | ||
| 230 | 220 | const MAX_TITLE = 200; | |
| 231 | 221 | const MAX_MARKDOWN = 512 * 1024; | |
| 232 | − | const MAX_NOTE = 500; | |
| 233 | 222 | ||
| 234 | 223 | const now = () => new Date().toISOString(); | |
| 235 | 224 | ||
| ⋯ | |||
| 268 | 257 | return [...new Set(list.map((p) => projectRef(String(p))).filter((p): p is string => !!p))].slice(0, 20); | |
| 269 | 258 | } | |
| 270 | 259 | ||
| 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 | − | ||
| 287 | 260 | class Docs { | |
| 288 | 261 | private readonly workspaces = new Map<string, Promise<Workspace | null>>(); | |
| 289 | 262 | private readonly people = new Map<string, Promise<Map<string, Member>>>(); | |
| 290 | 263 | private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>(); | |
| 291 | 264 | private readonly usernames = new Map<string, string>(); | |
| 292 | − | private readonly agents = new Map<string, WorkspaceAgent | null>(); | |
| 293 | 265 | ||
| 294 | 266 | constructor( | |
| 295 | 267 | private readonly env: Env, | |
| ⋯ | |||
| 352 | 324 | for (const [id, username] of Object.entries(named)) this.usernames.set(id, username); | |
| 353 | 325 | } | |
| 354 | 326 | ||
| 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 | − | ||
| 369 | 327 | /** How member keys show. Teams show by slug. */ | |
| 370 | 328 | private async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> { | |
| 371 | 329 | const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p); | |
| 372 | 330 | 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>()]); | |
| 375 | 332 | const out = new Map<string, MemberProfile>(); | |
| 376 | 333 | for (const p of principals) { | |
| 377 | 334 | if (p.kind === "user") { | |
| ⋯ | |||
| 387 | 344 | avatar_seed: null, | |
| 388 | 345 | }); | |
| 389 | 346 | } 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 }); | |
| 401 | 349 | } | |
| 402 | 350 | } | |
| 403 | 351 | // Anything else (system, ghost keys): a plain name. | |
| ⋯ | |||
| 980 | 928 | if (!space?.role) return fail("not_found", "No such space."); | |
| 981 | 929 | if (!atLeast(space.role, "manage")) return fail("forbidden", "Only people with full access can change who is in a space."); | |
| 982 | 930 | 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."); | |
| 984 | 932 | const role = a.role === null ? null : isRole(a.role) ? a.role : null; | |
| 985 | 933 | if (a.role !== null && !role) return fail("invalid", "Choose a role."); | |
| 986 | 934 | // They must belong to the workspace. | |
| ⋯ | |||
| 989 | 937 | const username = this.usernames.get(key.id); | |
| 990 | 938 | if (!username || !(await this.members(workspace)).has(username.toLowerCase())) return fail("invalid", "Only members of the workspace can be added."); | |
| 991 | 939 | } 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."); | |
| 994 | 942 | } else { | |
| 995 | 943 | const teams = await this.teamsOf(workspace); | |
| 996 | 944 | if (![...teams.values()].some((set) => set.has(key.id.toLowerCase()))) return fail("invalid", "No such team in this workspace."); | |
| ⋯ | |||
| 1176 | 1124 | if (!found.ok) return found; | |
| 1177 | 1125 | await this.clearStale(found.value.page.id, this.userKey(a.viewer!)); | |
| 1178 | 1126 | 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); | |
| 1221 | 1127 | } | |
| 1222 | 1128 | ||
| 1223 | 1129 | /** Where a new page in `space` from `input` starts: its Markdown and title. */ | |
| ⋯ | |||
| 1289 | 1195 | ]); | |
| 1290 | 1196 | await this.room(id).ensure({ page_id: id, workspace_slug: workspace.slug, markdown: input.markdown, state: input.state ?? null }); | |
| 1291 | 1197 | 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. | |
| 1293 | 1199 | if (input.markdown.trim()) this.defer(indexPage(this.env, id)); | |
| 1294 | 1200 | return row; | |
| 1295 | 1201 | } | |
| ⋯ | |||
| 1361 | 1267 | } | |
| 1362 | 1268 | } | |
| 1363 | 1269 | 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); | |
| 1365 | 1271 | statements.push(this.db.prepare("DELETE FROM page_owners WHERE page_id = ?").bind(page.id)); | |
| 1366 | 1272 | for (const o of owners) statements.push(this.db.prepare("INSERT INTO page_owners (page_id, principal) VALUES (?, ?)").bind(page.id, o)); | |
| 1367 | 1273 | } | |
| ⋯ | |||
| 1438 | 1344 | const at = now(); | |
| 1439 | 1345 | 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))); | |
| 1440 | 1346 | 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. | |
| 1442 | 1348 | this.defer(forgetDocs(this.env, { page_ids: ids })); | |
| 1443 | 1349 | this.defer(publishDocEvent(this.env.EVENTS, "doc.page.archived", this.eventData(workspace, space.row, page), this.userKey(a.viewer!))); | |
| 1444 | 1350 | const after = await this.db.prepare("SELECT * FROM pages WHERE id = ?").bind(page.id).first<PageRow>(); | |
| ⋯ | |||
| 2079 | 1985 | if (!found.ok) return found; | |
| 2080 | 1986 | return ok(await this.resolveThreads(found.value.workspace, await this.room(a.page_id).threads())); | |
| 2081 | 1987 | } | |
| 2082 | − | ||
| 2083 | − | // ── Agents ────────────────────────────────────────────────────────────── | |
| 2084 | 1988 | ||
| 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 | − | ||
| 2171 | 1989 | // ── Recall: the semantic index ────────────────────────────────────────── | |
| 2172 | 1990 | ||
| 2173 | 1991 | /** Pages indexed again one after another (moved, restored). */ | |
| ⋯ | |||
| 2205 | 2023 | } | |
| 2206 | 2024 | } | |
| 2207 | 2025 | ||
| 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 | − | ||
| 2227 | 2026 | /** | |
| 2228 | 2027 | * Passages by id as they read now, with their page or file: only those | |
| 2229 | 2028 | * whose page is still out of the trash and whose file is still there. | |
| ⋯ | |||
| 2252 | 2051 | return out; | |
| 2253 | 2052 | } | |
| 2254 | 2053 | ||
| 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 | − | ||
| 2383 | 2054 | /** Indexes the workspace's pages and projects' docs again, on the queue. Owners only. */ | |
| 2384 | 2055 | async reindexDocs(a: { workspace: string; viewer: Viewer }): Promise<Result<boolean>> { | |
| 2385 | 2056 | const found = await this.viewerWorkspace(a.workspace, a.viewer); | |
| 2386 | 2057 | if (!found.ok) return found; | |
| 2387 | 2058 | if (!this.viewerOwner(a.viewer!, a.workspace)) return fail("forbidden", "Only an owner can index the workspace's docs again."); | |
| 2388 | 2059 | 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)); | |
| 2537 | 2060 | } | |
| 2538 | 2061 | ||
| 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 | − | ||
| 2545 | 2062 | // ── Sockets and files ─────────────────────────────────────────────────── | |
| 2546 | 2063 | ||
| 2547 | 2064 | private viewerFrom(request: Request): Viewer { | |
| ⋯ | |||
| 2689 | 2206 | return Response.json(await service.thread(args)); | |
| 2690 | 2207 | case "threads": | |
| 2691 | 2208 | 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)); | |
| 2708 | 2209 | case "mark_current": | |
| 2709 | 2210 | return Response.json(await service.markCurrent(args)); | |
| 2710 | 2211 | case "stale_pages": | |
| ⋯ | |||
| 2715 | 2216 | return Response.json(await service.removeRepoSpace(args)); | |
| 2716 | 2217 | case "repo_page": | |
| 2717 | 2218 | return Response.json(await service.repoPage(args)); | |
| 2718 | − | case "recall_for_agent": | |
| 2719 | − | return Response.json(await service.recallForAgent(args)); | |
| 2720 | 2219 | case "reindex_docs": | |
| 2721 | 2220 | return Response.json(await service.reindexDocs(args)); | |
| 2722 | 2221 | default: | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | 343 | /** | |
| 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 | − | /** | |
| 358 | 344 | * One batch of a workspace's backfill: pages by id, then projects' docs | |
| 359 | 345 | * files by space and path, from the run's cursor. Hands on to the next | |
| 360 | 346 | * batch (or waits out the hour's cap) through the queue. |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 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"; | |
| 8 | 5 | ||
| 9 | 6 | test("the index is filtered by the allowed spaces, or by workspace and after when there are too many", () => { | |
| 10 | 7 | assert.equal(vectorQueryPlan("w1", []), null); | |
| ⋯ | |||
| 13 | 10 | assert.deepEqual(vectorQueryPlan("w1", many), { topK: TOP_K_UNFILTERED, filter: { workspace_id: "w1" }, filterAfter: true }); | |
| 14 | 11 | }); | |
| 15 | 12 | ||
| 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 | − | ||
| 70 | 13 | test("hybrid search fuses ranks: found both ways first", () => { | |
| 71 | 14 | assert.deepEqual(fuseRanks(["a", "b", "c"], ["c", "d"]), ["c", "a", "b", "d"]); | |
| 72 | 15 | assert.deepEqual(fuseRanks([], ["x", "y"]), ["x", "y"]); | |
| ⋯ | |||
| 83 | 26 | assert.equal(cache.get("a", 3), null); | |
| 84 | 27 | assert.deepEqual(cache.get("c", 3), [3]); | |
| 85 | 28 | 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"'); | |
| 92 | 29 | }); | |
| 1 | 1 | /** | |
| 2 | 2 | * Recall's choices, apart from where the passages come from: how the | |
| 3 | 3 | * 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. | |
| 7 | 6 | */ | |
| 8 | 7 | ||
| 9 | 8 | /** | |
| ⋯ | |||
| 17 | 16 | * keeps recall quiet when the docs say nothing about the question. | |
| 18 | 17 | */ | |
| 19 | 18 | 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; | |
| 22 | 19 | /** Nearest passages asked of the index. */ | |
| 23 | 20 | export const TOP_K = 24; | |
| 24 | 21 | /** Asked when the index can't filter by space and recall filters after: more, so enough are left. */ | |
| 25 | 22 | export const TOP_K_UNFILTERED = 50; | |
| 26 | 23 | /** 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). */ | |
| 27 | 24 | 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; | |
| 32 | 25 | ||
| 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 | − | ||
| 39 | 26 | /** | |
| 40 | 27 | * How to ask the index: by the allowed spaces when there are few enough | |
| 41 | 28 | * to name, otherwise by workspace alone, more of them, filtered after. | |
| ⋯ | |||
| 46 | 33 | if (!ids.length) return null; | |
| 47 | 34 | if (ids.length <= MAX_IN_FILTER) return { topK: TOP_K, filter: { workspace_id: workspaceId, space_ids: ids }, filterAfter: false }; | |
| 48 | 35 | 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)); | |
| 59 | 36 | } | |
| 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 | − | }; | |
| 71 | 37 | ||
| 72 | 38 | /** | |
| 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 | − | /** | |
| 106 | 39 | * Hybrid search's order for people: each document's place in the word | |
| 107 | 40 | * results and in the meaning results, fused by reciprocal rank (k = 60), | |
| 108 | 41 | * so a page both find comes first and either alone still counts. | |
| ⋯ | |||
| 119 | 52 | } | |
| 120 | 53 | ||
| 121 | 54 | /** | |
| 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. | |
| 124 | 57 | */ | |
| 125 | 58 | export class QueryCache { | |
| 126 | 59 | private readonly entries = new Map<string, { at: number; vector: number[] }>(); | |
| 309 | 309 | return listThreads(this.load()); | |
| 310 | 310 | } | |
| 311 | 311 | ||
| 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 | − | ||
| 337 | 312 | /** Tells everyone with the page open. */ | |
| 338 | 313 | async notice(event: DocsLiveEvent): Promise<void> { | |
| 339 | 314 | this.send(JSON.stringify(event)); |
| 40 | 40 | return /^[a-z0-9][a-z0-9._-]{0,99}\/[a-z0-9._-]{1,100}$/.test(s) ? s : null; | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 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 | − | } |
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…