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.
| 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. | 1 | /** |
| 2 | * The doc kind: BlockNote on `XmlFragment("document-store")`, comments in | |
| 3 | * the `threads` map, Markdown as its text rendition. The same document, | |
| 4 | * code and behaviour as Docs' pages (src/blocks.ts, src/markdown.ts, | |
| 5 | * src/edits.ts, src/citations.ts, src/threads.ts), which PageRoom still | |
| 6 | * uses until Phase 7 retires it; those modules move here then. Pure. | |
| 7 | */ | |
| 8 | import type { DocEditTarget } from "@g1t/contracts"; | |
| 9 | import * as Y from "yjs"; | |
| 10 | ||
| 11 | import { seed as seedBlocks } from "../../blocks.ts"; | |
| 12 | import { chunkMarkdown } from "../../chunks.ts"; | |
| 13 | import { bodyCitations } from "../../citations.ts"; | |
| 14 | import { applyEdit, findTarget, rangeIds, rangeMarkdown, restoreFrom } from "../../edits.ts"; | |
| 15 | import { citationNodes, documentMarkdown, mentionedIds, outline, searchText } from "../../markdown.ts"; | |
| 16 | import type { FolioOrigin, KindModel, Rendition } from "../types.ts"; | |
| 17 | ||
| 18 | /** Every folio id a text links to, for backlinks (as `linkedFolioIds` in @g1t/contracts; kept here so this module stays pure for Node's tests). */ | |
| 19 | export function linkedFolioIds(text: string): string[] { | |
| 20 | return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])]; | |
| 21 | } | |
| 22 | ||
| 23 | /** Where BlockNote keeps the document. */ | |
| 24 | export const DOC_FRAGMENT = "document-store"; | |
| 25 | /** The most a doc's Markdown may be. */ | |
| 26 | export const DOC_MAX_TEXT = 512 * 1024; | |
| 27 | /** Lines a card shows. */ | |
| 28 | const PREVIEW_LINES = 4; | |
| 29 | ||
| 30 | export function docFragment(doc: Y.Doc): Y.XmlFragment { | |
| 31 | return doc.getXmlFragment(DOC_FRAGMENT); | |
| 32 | } | |
| 33 | ||
| 34 | /** The first lines of a doc, as plain text, for its card. */ | |
| 35 | export function previewLines(markdown: string, max = PREVIEW_LINES): string[] { | |
| 36 | return searchText(markdown) | |
| 37 | .split("\n") | |
| 38 | .map((l) => l.replace(/\s+/g, " ").trim()) | |
| 39 | .filter(Boolean) | |
| 40 | .slice(0, max) | |
| 41 | .map((l) => (l.length > 120 ? `${l.slice(0, 119)}…` : l)); | |
| 42 | } | |
| 43 | ||
| 44 | /** A target's current Markdown and blocks, or null when it is gone. */ | |
| 45 | export function docTarget(doc: Y.Doc, target: DocEditTarget): { markdown: string; block_ids: string[] } | null { | |
| 46 | const fragment = docFragment(doc); | |
| 47 | const range = findTarget(fragment, target); | |
| 48 | if (!range) return null; | |
| 49 | return { markdown: rangeMarkdown(fragment, range), block_ids: rangeIds(fragment, range) }; | |
| 50 | } | |
| 51 | ||
| 52 | /** Where each target is now, for marking open suggestions in the editor (null: gone). */ | |
| 53 | export function docTargets(doc: Y.Doc, targets: DocEditTarget[]): (string[] | null)[] { | |
| 54 | const fragment = docFragment(doc); | |
| 55 | return targets.map((t) => { | |
| 56 | const range = findTarget(fragment, t); | |
| 57 | return range ? rangeIds(fragment, range) : null; | |
| 58 | }); | |
| 59 | } | |
| 60 | ||
| 61 | function describe(target: DocEditTarget): string { | |
| 62 | switch (target.kind) { | |
| 63 | case "append": | |
| 64 | return "Added to the end"; | |
| 65 | case "document": | |
| 66 | return "Rewrote the doc"; | |
| 67 | case "section": | |
| 68 | return `Changed the section "${target.heading}"`; | |
| 69 | case "blocks": | |
| 70 | return "Changed some blocks"; | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | export const doc: KindModel = { | |
| 75 | kind: "doc", | |
| 76 | isEmpty: (d) => docFragment(d).length === 0, | |
| 77 | seed(d, init) { | |
| 78 | seedBlocks(d, docFragment(d), String(init.text ?? "").slice(0, DOC_MAX_TEXT)); | |
| 79 | }, | |
| 80 | render(d): Rendition { | |
| 81 | const fragment = docFragment(d); | |
| 82 | const text = documentMarkdown(fragment); | |
| 83 | return { | |
| 84 | text, | |
| 85 | mentions: mentionedIds(fragment).users, | |
| 86 | links: linkedFolioIds(text), | |
| 87 | citations: bodyCitations(citationNodes(fragment), text), | |
| 88 | preview: { kind: "doc", lines: previewLines(text) }, | |
| 89 | }; | |
| 90 | }, | |
| 91 | chunks: (text, title) => chunkMarkdown(text, title), | |
| 92 | read(d) { | |
| 93 | const fragment = docFragment(d); | |
| 94 | return { content: documentMarkdown(fragment), blocks: outline(fragment) }; | |
| 95 | }, | |
| 96 | applyAgentEdit(d, edit, origin: FolioOrigin) { | |
| 97 | if (edit.kind !== "doc") return { applied: false, summary: "That edit is for another kind of artifact." }; | |
| 98 | const applied = applyEdit(d, docFragment(d), edit.target, String(edit.markdown ?? "").slice(0, DOC_MAX_TEXT), origin); | |
| 99 | return { applied, summary: applied ? describe(edit.target) : "That part of the doc isn't there." }; | |
| 100 | }, | |
| 101 | restore(d, old, origin) { | |
| 102 | restoreFrom(d, docFragment(d), docFragment(old), origin); | |
| 103 | }, | |
| 104 | restoreText(d, text, origin) { | |
| 105 | applyEdit(d, docFragment(d), { kind: "document" }, text, origin); | |
| 106 | }, | |
| 107 | validate(d) { | |
| 108 | return documentMarkdown(docFragment(d)).length > DOC_MAX_TEXT * 2 ? "This doc is too long." : null; | |
| 109 | }, | |
| 110 | }; |