Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Docs: a workspace knowledge base people and agents write together | 1 | /** |
| 2 | * What a page's room saves to D1 after a burst of edits: the Markdown | |
| 3 | * rendition, the search index, backlinks, history, and telling people | |
| 4 | * newly mentioned in the page. Run by the room (src/room.ts), which owns | |
| 5 | * the live document; nothing here reads the document itself. | |
| 6 | */ | |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 7 | import { newId, notifyClient, type DocCitation, type DocVersionKind, type FeedNotification, type FolioKind, type FolioVersionKind, type ServiceBinding } from "@g1t/contracts"; |
| Docs: a workspace knowledge base people and agents write together | 8 | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 9 | import { publishDocEvent } from "./events.ts"; |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 10 | import { fileStore, type FileStoreEnv } from "./files.ts"; |
| 11 | import { workspaceReadable } from "./folios/access-store.ts"; | |
| 12 | import { publishFolioEvent } from "./folios/events.ts"; | |
| 13 | import type { Rendition } from "./kinds/types.ts"; | |
| Docs: a workspace knowledge base people and agents write together | 14 | import { excerpt, searchText } from "./markdown.ts"; |
| 15 | import { linkedPageIds, pageSlug } from "./slugs.ts"; | |
| 16 | ||
| 17 | /** How long edits gather into one version before the next starts. */ | |
| 18 | export const VERSION_EVERY_MS = 10 * 60 * 1000; | |
| 19 | /** The largest Yjs state a version keeps; past it, only its Markdown. */ | |
| 20 | const MAX_VERSION_STATE = 1_500_000; | |
| 21 | ||
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 22 | export type SaveEnv = { DB: D1Database; NOTIFY?: ServiceBinding; EVENTS?: ServiceBinding }; |
| Docs: a workspace knowledge base people and agents write together | 23 | |
| 24 | export type Save = { | |
| 25 | page_id: string; | |
| 26 | markdown: string; | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 27 | /** Code the document cites (src/citations.ts `bodyCitations`). */ |
| 28 | citations: DocCitation[]; | |
| Docs: a workspace knowledge base people and agents write together | 29 | /** The editors since the last save, member keys, last one last. */ |
| 30 | editors: string[]; | |
| 31 | /** People mentioned in the document now (usernames, lowercased). */ | |
| 32 | mentioned: string[]; | |
| 33 | /** The usernames of the people who made these edits, lowercased: nobody is told they mentioned themselves. */ | |
| 34 | editor_names: string[]; | |
| 35 | /** The whole document, for a version. */ | |
| 36 | state: Uint8Array; | |
| 37 | /** A version to record now, whatever the time since the last. */ | |
| 38 | version: { kind: DocVersionKind; note: string | null; authors: string[] } | null; | |
| 39 | /** Editors since the last version, for a timed one. */ | |
| 40 | pending_authors: string[]; | |
| 41 | /** When the last version was recorded (ms), or 0. */ | |
| 42 | last_version_at: number; | |
| 43 | /** The workspace's slug as the room last heard it, for links in notifications. */ | |
| 44 | workspace_slug: string | null; | |
| 45 | }; | |
| 46 | ||
| 47 | type PageRow = { id: string; workspace_id: string; space_id: string; title: string; mentioned: string; markdown: string; slug: string; archived_at: string | null }; | |
| 48 | ||
| Docs index by meaning: passages of every page and project doc, embedded on save and recalled for agents; hybrid search for people | 49 | /** Saves; returns whether a version was recorded, and its id, and whether the Markdown changed (so the room indexes it again, src/indexer.ts). */ |
| 50 | export async function save(env: SaveEnv, input: Save, now = new Date()): Promise<{ version_id: string | null; changed: boolean }> { | |
| Docs: a workspace knowledge base people and agents write together | 51 | const at = now.toISOString(); |
| 52 | const page = await env.DB.prepare( | |
| 53 | "SELECT p.id, p.workspace_id, p.space_id, p.title, p.mentioned, p.markdown, p.archived_at, s.slug AS slug FROM pages p JOIN spaces s ON s.id = p.space_id WHERE p.id = ?", | |
| 54 | ) | |
| 55 | .bind(input.page_id) | |
| 56 | .first<PageRow>(); | |
| Docs index by meaning: passages of every page and project doc, embedded on save and recalled for agents; hybrid search for people | 57 | if (!page) return { version_id: null, changed: false }; |
| Docs: a workspace knowledge base people and agents write together | 58 | const changed = page.markdown !== input.markdown; |
| 59 | const last = input.editors[input.editors.length - 1] ?? null; | |
| 60 | const statements: D1PreparedStatement[] = []; | |
| 61 | if (changed || last) { | |
| 62 | statements.push( | |
| 63 | env.DB.prepare("UPDATE pages SET markdown = ?, updated_at = ?, updated_by = COALESCE(?, updated_by) WHERE id = ?").bind(input.markdown, at, last, page.id), | |
| 64 | ); | |
| 65 | } | |
| 66 | if (changed) { | |
| 67 | statements.push(env.DB.prepare("DELETE FROM pages_fts WHERE page_id = ?").bind(page.id)); | |
| 68 | statements.push(env.DB.prepare("INSERT INTO pages_fts (page_id, title, body) VALUES (?, ?, ?)").bind(page.id, page.title, searchText(input.markdown))); | |
| 69 | statements.push(env.DB.prepare("DELETE FROM page_links WHERE from_page = ?").bind(page.id)); | |
| 70 | for (const to of linkedPageIds(input.markdown).filter((id) => id !== page.id).slice(0, 200)) { | |
| 71 | statements.push(env.DB.prepare("INSERT OR IGNORE INTO page_links (from_page, to_page) VALUES (?, ?)").bind(page.id, to)); | |
| 72 | } | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 73 | // What it cites, from its text; the header's own stay. |
| 74 | statements.push(env.DB.prepare("DELETE FROM citations WHERE page_id = ? AND source = 'body'").bind(page.id)); | |
| 75 | for (const c of input.citations) { | |
| 76 | statements.push( | |
| 77 | env.DB.prepare("INSERT OR IGNORE INTO citations (page_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, ?, ?, ?, 'body')").bind(page.id, c.repo, c.path, c.kind, c.label ?? "", c.ref), | |
| 78 | ); | |
| 79 | } | |
| Docs: a workspace knowledge base people and agents write together | 80 | } |
| 81 | // A version: asked for (an agent's edit, a suggestion, a restore), or | |
| 82 | // the first save after enough time since the last one. | |
| 83 | let versionId: string | null = null; | |
| 84 | const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS; | |
| 85 | if (input.version || (timed && changed)) { | |
| 86 | versionId = newId("ver", now.getTime()); | |
| 87 | const authors = input.version?.authors.length ? input.version.authors : input.pending_authors; | |
| 88 | const state = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null; | |
| 89 | statements.push( | |
| 90 | env.DB.prepare("INSERT INTO page_versions (id, page_id, created_at, kind, authors, note, markdown, state) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").bind( | |
| 91 | versionId, | |
| 92 | page.id, | |
| 93 | at, | |
| 94 | input.version?.kind ?? "edit", | |
| 95 | JSON.stringify([...new Set(authors)]), | |
| 96 | input.version?.note ?? null, | |
| 97 | input.markdown, | |
| 98 | state, | |
| 99 | ), | |
| 100 | ); | |
| 101 | } | |
| 102 | // People mentioned for the first time. | |
| 103 | let told: string[] = []; | |
| 104 | try { | |
| 105 | told = JSON.parse(page.mentioned) as string[]; | |
| 106 | } catch { | |
| 107 | told = []; | |
| 108 | } | |
| 109 | const editors = new Set(input.editor_names); | |
| 110 | const fresh = input.mentioned.filter((name) => !told.includes(name) && !editors.has(name)); | |
| 111 | if (fresh.length || input.mentioned.length !== told.length) { | |
| 112 | statements.push(env.DB.prepare("UPDATE pages SET mentioned = ? WHERE id = ?").bind(JSON.stringify([...new Set([...told.filter((id) => input.mentioned.includes(id)), ...fresh])]), page.id)); | |
| 113 | } | |
| 114 | if (statements.length) await env.DB.batch(statements); | |
| Docs know what code they describe; a project's docs folder in Docs; Docs events; files on any S3 store | 115 | // A version is what the rest of g1t hears of: at most every ten minutes of editing, and each agent edit, suggestion and restore. |
| 116 | const kind = input.version?.kind ?? "edit"; | |
| 117 | if (versionId && kind !== "created" && input.workspace_slug && !page.archived_at) { | |
| 118 | await publishDocEvent( | |
| 119 | env.EVENTS, | |
| 120 | "doc.page.updated", | |
| 121 | { | |
| 122 | workspace: input.workspace_slug, | |
| 123 | workspaceId: page.workspace_id, | |
| 124 | pageId: page.id, | |
| 125 | spaceId: page.space_id, | |
| 126 | title: page.title, | |
| 127 | path: `/${input.workspace_slug}/-/docs/${page.slug}/${pageSlug(page.title, page.id)}`, | |
| 128 | versionId, | |
| 129 | kind, | |
| 130 | authors: [...new Set(input.version?.authors.length ? input.version.authors : input.pending_authors)], | |
| 131 | }, | |
| 132 | last, | |
| 133 | ); | |
| 134 | } | |
| Docs: a workspace knowledge base people and agents write together | 135 | if (fresh.length && env.NOTIFY && !page.archived_at) { |
| 136 | const slug = input.workspace_slug; | |
| 137 | if (slug) { | |
| 138 | const href = `/${slug}/-/docs/${page.slug}/${pageSlug(page.title, page.id)}`; | |
| 139 | const notification = (username: string): FeedNotification => ({ | |
| 140 | id: `doc-mention:${page.id}:${username}`, | |
| 141 | kind: "mention", | |
| 142 | workspace: slug, | |
| 143 | title: `You were mentioned in ${page.title || "Untitled"}`, | |
| 144 | body: excerpt(input.markdown, 140), | |
| 145 | href, | |
| 146 | actor: { kind: last?.startsWith("agent:") ? "agent" : "user", id: last?.slice(last.indexOf(":") + 1) ?? "", name: "Docs" }, | |
| 147 | created_at: at, | |
| 148 | }); | |
| 149 | const notify = notifyClient(env.NOTIFY); | |
| 150 | await Promise.all(fresh.map((name) => notify.notify({ username: name }, notification(name)).catch(() => undefined))); | |
| 151 | } | |
| 152 | } | |
| Docs index by meaning: passages of every page and project doc, embedded on save and recalled for agents; hybrid search for people | 153 | return { version_id: versionId, changed }; |
| Docs: a workspace knowledge base people and agents write together | 154 | } |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 155 | |
| 156 | // ── Folios (Artifacts mode) ───────────────────────────────────────────── | |
| 157 | ||
| 158 | export type SaveFolioEnv = SaveEnv & FileStoreEnv; | |
| 159 | ||
| 160 | export type SaveFolio = { | |
| 161 | folio_id: string; | |
| 162 | /** What the kind rendered from the document (src/kinds/types.ts). */ | |
| 163 | rendition: Rendition; | |
| 164 | /** The editors since the last save, member keys, last one last. */ | |
| 165 | editors: string[]; | |
| 166 | /** The usernames of the people who made these edits, lowercased. */ | |
| 167 | editor_names: string[]; | |
| 168 | state: Uint8Array; | |
| 169 | version: { kind: FolioVersionKind; note: string | null; authors: string[] } | null; | |
| 170 | pending_authors: string[]; | |
| 171 | last_version_at: number; | |
| 172 | workspace_slug: string | null; | |
| 173 | }; | |
| 174 | ||
| 175 | type FolioSaveRow = { id: string; workspace_id: string; kind: FolioKind; title: string; text: string; preview: string | null; mentioned: string; space_id: string | null; trashed_at: string | null }; | |
| 176 | ||
| 177 | /** | |
| 178 | * What a folio's room saves to D1 after a burst of edits: its text | |
| 179 | * rendition, card, search text, links, citations and history. Returns the | |
| 180 | * version recorded (if one was), whether the text changed (so the room | |
| 181 | * indexes it again), and the people newly mentioned, whom the room tells | |
| 182 | * only if they can read the folio (src/folios/notify.ts). | |
| 183 | */ | |
| 184 | export async function saveFolio(env: SaveFolioEnv, input: SaveFolio, now = new Date()): Promise<{ version_id: string | null; changed: boolean; mentioned: string[]; last: string | null }> { | |
| 185 | const at = now.toISOString(); | |
| 186 | const db = env.DB; | |
| 187 | const folio = await db.prepare("SELECT id, workspace_id, kind, title, text, preview, mentioned, space_id, trashed_at FROM folios WHERE id = ?").bind(input.folio_id).first<FolioSaveRow>(); | |
| 188 | if (!folio) return { version_id: null, changed: false, mentioned: [], last: null }; | |
| 189 | const r = input.rendition; | |
| 190 | const changed = folio.text !== r.text; | |
| 191 | const last = input.editors[input.editors.length - 1] ?? null; | |
| 192 | const statements: D1PreparedStatement[] = []; | |
| 193 | const preview = r.preview ? JSON.stringify(r.preview) : null; | |
| 194 | // A folio made from text that already reads as the kind renders it still needs its card. | |
| 195 | if (!changed && preview !== folio.preview) statements.push(db.prepare("UPDATE folios SET preview = ? WHERE id = ?").bind(preview, folio.id)); | |
| 196 | if (changed) { | |
| 197 | statements.push( | |
| 198 | db | |
| 199 | .prepare("UPDATE folios SET text = ?, excerpt = ?, preview = ?, edited_at = ?, edited_by = COALESCE(?, edited_by), updated_at = ? WHERE id = ?") | |
| 200 | .bind(r.text, excerpt(r.text), preview, at, last, at, folio.id), | |
| 201 | db.prepare("DELETE FROM folios_fts WHERE folio_id = ?").bind(folio.id), | |
| 202 | db.prepare("INSERT INTO folios_fts (folio_id, kind, title, body) VALUES (?, ?, ?, ?)").bind(folio.id, folio.kind, folio.title, searchText(r.text)), | |
| 203 | db.prepare("DELETE FROM folio_links WHERE from_folio = ?").bind(folio.id), | |
| 204 | db.prepare("DELETE FROM folio_citations WHERE folio_id = ? AND source = 'body'").bind(folio.id), | |
| 205 | ); | |
| 206 | for (const to of r.links.filter((id) => id !== folio.id).slice(0, 200)) { | |
| 207 | statements.push(db.prepare("INSERT OR IGNORE INTO folio_links (from_folio, to_folio) VALUES (?, ?)").bind(folio.id, to)); | |
| 208 | } | |
| 209 | for (const c of r.citations) { | |
| 210 | statements.push( | |
| 211 | db.prepare("INSERT OR IGNORE INTO folio_citations (folio_id, repo, path, kind, label, ref, source) VALUES (?, ?, ?, ?, ?, ?, 'body')").bind(folio.id, c.repo, c.path, c.kind, c.label ?? "", c.ref), | |
| 212 | ); | |
| 213 | } | |
| 214 | } | |
| 215 | // A version: asked for, or the first save with changes after enough time. | |
| 216 | let versionId: string | null = null; | |
| 217 | const timed = input.pending_authors.length > 0 && now.getTime() - input.last_version_at >= VERSION_EVERY_MS; | |
| 218 | if (input.version || (timed && changed)) { | |
| 219 | versionId = newId("ver", now.getTime()); | |
| 220 | const authors = input.version?.authors.length ? input.version.authors : input.pending_authors; | |
| 221 | let state: Uint8Array | null = input.state.byteLength <= MAX_VERSION_STATE ? input.state : null; | |
| 222 | let stateKey: string | null = null; | |
| 223 | if (!state) { | |
| 224 | // Too large for a row: the file store keeps it. | |
| 225 | try { | |
| 226 | stateKey = `docs/versions/${folio.id}/${versionId}`; | |
| 227 | await fileStore(env).put(stateKey, input.state, "application/octet-stream"); | |
| 228 | } catch (error) { | |
| 229 | console.error("folios could not keep a large version's state; its text is kept", folio.id, String(error)); | |
| 230 | stateKey = null; | |
| 231 | state = null; | |
| 232 | } | |
| 233 | } | |
| 234 | statements.push( | |
| 235 | db | |
| 236 | .prepare("INSERT INTO folio_versions (id, folio_id, created_at, kind, authors, note, text, state, state_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 237 | .bind(versionId, folio.id, at, input.version?.kind ?? "edit", JSON.stringify([...new Set(authors)]), input.version?.note ?? null, r.text, state, stateKey), | |
| 238 | ); | |
| 239 | } | |
| 240 | let told: string[] = []; | |
| 241 | try { | |
| 242 | told = JSON.parse(folio.mentioned) as string[]; | |
| 243 | } catch { | |
| 244 | told = []; | |
| 245 | } | |
| 246 | const editors = new Set(input.editor_names); | |
| 247 | const fresh = r.mentions.filter((name) => !told.includes(name) && !editors.has(name)); | |
| 248 | if (fresh.length || r.mentions.length !== told.length) { | |
| 249 | statements.push(db.prepare("UPDATE folios SET mentioned = ? WHERE id = ?").bind(JSON.stringify([...new Set([...told.filter((n) => r.mentions.includes(n)), ...fresh])]), folio.id)); | |
| 250 | } | |
| 251 | if (statements.length) await db.batch(statements); | |
| 252 | const kind = input.version?.kind ?? "edit"; | |
| 253 | if (versionId && kind !== "created" && input.workspace_slug && !folio.trashed_at) { | |
| 254 | const open = await workspaceReadable(db, folio.id).catch(() => false); | |
| 255 | await publishFolioEvent( | |
| 256 | env.EVENTS, | |
| 257 | "folio.updated", | |
| 258 | { | |
| 259 | workspace: input.workspace_slug, | |
| 260 | workspaceId: folio.workspace_id, | |
| 261 | folioId: folio.id, | |
| 262 | kind: folio.kind, | |
| 263 | spaceId: folio.space_id, | |
| 264 | title: open ? folio.title : null, | |
| 265 | versionId, | |
| 266 | versionKind: kind, | |
| 267 | authors: [...new Set(input.version?.authors.length ? input.version.authors : input.pending_authors)], | |
| 268 | }, | |
| 269 | last, | |
| 270 | ); | |
| 271 | } | |
| 272 | return { version_id: versionId, changed, mentioned: folio.trashed_at ? [] : fresh, last }; | |
| 273 | } |