openwriter 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
16
16
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
17
- import { extractForwardLinks } from './backlinks.js';
17
+ import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
18
18
  import { logger, generateRequestId, withRequestId } from './logger.js';
19
19
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
20
20
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
@@ -363,7 +363,7 @@ export const TOOL_REGISTRY = [
363
363
  },
364
364
  {
365
365
  name: 'list_documents',
366
- description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline, domain, docRole) when present. Use the docId to target documents in other tools.',
366
+ description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline, status, STALE marker) when present. Use the docId to target documents in other tools. v0.19.0: three-field enrichment schema — logline (LLM), status (agent: canonical / draft), STALE (system).',
367
367
  schema: {},
368
368
  handler: async () => {
369
369
  const docs = listDocuments();
@@ -372,10 +372,10 @@ export const TOOL_REGISTRY = [
372
372
  const id = d.docId ? ` [${d.docId}]` : '';
373
373
  const date = d.lastModified.split('T')[0];
374
374
  const enrichBits = [];
375
- if (d.domain)
376
- enrichBits.push(d.domain);
377
- if (d.docRole)
378
- enrichBits.push(d.docRole);
375
+ // v0.19.0: only canonical surfaces — draft is the default and would
376
+ // clutter the listing on every doc.
377
+ if (d.status === 'canonical')
378
+ enrichBits.push('canonical');
379
379
  if (d.enrichmentStale === true)
380
380
  enrichBits.push('STALE');
381
381
  const enrichTag = enrichBits.length > 0 ? ` (${enrichBits.join(', ')})` : '';
@@ -415,8 +415,9 @@ export const TOOL_REGISTRY = [
415
415
  content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
416
416
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
417
417
  afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
418
+ status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
418
419
  },
419
- handler: async ({ title, path, workspace, container, empty, content_type, url, afterId }) => {
420
+ handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status }) => {
420
421
  // Require url for reply/quote
421
422
  if ((content_type === 'reply' || content_type === 'quote') && !url) {
422
423
  return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
@@ -444,18 +445,25 @@ export const TOOL_REGISTRY = [
444
445
  // Track the spinner key so catch can clear exactly this entry
445
446
  // (not siblings from a concurrent declare_writes).
446
447
  let spinnerKey = null;
448
+ // v0.19.0: agent-owned status. Defaults to "draft" when not supplied —
449
+ // canonical is reserved for docs that have committed to the workspace
450
+ // spine (Beats, Research Notes, Master References). Agent flips to
451
+ // canonical via set_metadata({ status: "canonical" }) on lifecycle
452
+ // transitions. See brief 2026-05-21-simplify-enrichment-schema-three-fields.
453
+ const statusMeta = { status: status ?? 'draft' };
447
454
  try {
448
455
  if (empty) {
449
456
  // Immediate switch — no spinner, no populate_document needed
450
457
  const result = createDocument(title, undefined, path);
451
458
  setAgentLock(result.filename);
452
- // Apply type-specific metadata
459
+ // Apply status + type-specific metadata in one merge
460
+ const initMeta = { ...statusMeta };
453
461
  if (content_type) {
454
462
  const typeMeta = resolveTypeMeta(content_type, url);
455
- if (typeMeta) {
456
- setMetadata(typeMeta);
457
- }
463
+ if (typeMeta)
464
+ Object.assign(initMeta, typeMeta);
458
465
  }
466
+ setMetadata(initMeta);
459
467
  let wsInfo = '';
460
468
  if (wsTarget) {
461
469
  // Resolve afterId: it may be a docId (8-char hex) or containerId.
@@ -478,8 +486,11 @@ export const TOOL_REGISTRY = [
478
486
  }
479
487
  // Two-step flow: create file on disk WITHOUT switching the user's view.
480
488
  // The spinner persists in the sidebar until populate_document is called.
489
+ // Merge status with any content-type metadata so it lands on the first
490
+ // disk write.
481
491
  const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
482
- const result = createDocumentFile(title, path, typeMeta);
492
+ const initialMeta = { ...statusMeta, ...(typeMeta || {}) };
493
+ const result = createDocumentFile(title, path, initialMeta);
483
494
  let wsInfo = '';
484
495
  if (wsTarget) {
485
496
  const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
@@ -556,6 +567,28 @@ export const TOOL_REGISTRY = [
556
567
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
557
568
  markAllNodesAsPending(doc, 'insert');
558
569
  }
570
+ // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
571
+ // updateDocument(doc) overwrites state.canonical wholesale — without this
572
+ // merge, the create_document → populate_document sequence loses the stub's
573
+ // auto-generated trailing paragraph from canonical. When the browser later
574
+ // accepts the inserts and sends a doc-update with its TipTap-rendered tree
575
+ // (which also has a trailing empty paragraph, but a different ID), the
576
+ // save-time matcher classifies the stub's original trailing as deleted →
577
+ // graveyard, while the freshly added inserts have no previousNodes match.
578
+ // Cascading state corruption observed in live test 2026-05-22.
579
+ const existingCanonical = getCanonical();
580
+ if (existingCanonical?.content?.length) {
581
+ const incomingIds = new Set(doc.content
582
+ .map((n) => n?.attrs?.id)
583
+ .filter((id) => typeof id === 'string'));
584
+ const preserved = existingCanonical.content.filter((n) => {
585
+ const id = n?.attrs?.id;
586
+ return id && !incomingIds.has(id);
587
+ });
588
+ if (preserved.length > 0) {
589
+ doc.content = [...doc.content, ...preserved];
590
+ }
591
+ }
559
592
  updateDocument(doc);
560
593
  updatePendingCacheForActiveDoc();
561
594
  save();
@@ -733,7 +766,7 @@ export const TOOL_REGISTRY = [
733
766
  },
734
767
  {
735
768
  name: 'set_metadata',
736
- description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
769
+ description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately. Lifecycle convention (v0.19.0): use `set_metadata({ status: "canonical" })` when a doc commits to the workspace spine (Beats locks, Research Note becomes load-bearing); use `set_metadata({ status: "draft" })` when a doc is superseded or demoted. Status is the agent\'s field — the enrichment minion never writes it.',
737
770
  schema: {
738
771
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
739
772
  metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
@@ -801,16 +834,12 @@ export const TOOL_REGISTRY = [
801
834
  },
802
835
  {
803
836
  name: 'mark_enriched',
804
- description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied enrichment fields, and clears enrichmentStale. The agent never touches the sentence-hash layer — openwriter computes the baseline from current canonical content. Accepts an array so a workspace-wide sweep is one call. See brief 2026-05-18-frontmatter-enrichment-system.',
837
+ description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied logline, clears enrichmentStale, and retires legacy enrichment fields (domain, concepts, docRole, and any LLM-written status). The agent never touches the sentence-hash layer — openwriter computes the baseline from current canonical content. Accepts an array so a workspace-wide sweep is one call. Schema simplified in v0.19.0: only logline is LLM-written; status is now agent-owned via create_document / set_metadata; domain / concepts / docRole are gone. See brief 2026-05-21-simplify-enrichment-schema-three-fields.',
805
838
  schema: {
806
839
  docs: z.array(z.object({
807
840
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
808
- logline: z.string().optional().describe('Précis (non-fiction) or logline (fiction). Under 250 chars. Describe the content, not the kind of doc.'),
809
- domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
810
- concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
811
- docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
812
- status: z.string().optional().describe('Doc status: draft / canonical / stale. Archive state is implied by archivedAt.'),
813
- })).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array.'),
841
+ logline: z.string().max(150).describe('Précis (non-fiction) or logline (fiction). Under 150 chars. Describe the content, not the kind of doc.'),
842
+ }).strict()).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array. Strict schema — passing domain / concepts / docRole / status will fail validation (v0.19.0 schema simplification).'),
814
843
  },
815
844
  handler: async ({ docs }) => {
816
845
  const now = new Date().toISOString();
@@ -828,23 +857,19 @@ export const TOOL_REGISTRY = [
828
857
  const blocks = tiptapToBlocks(canonical);
829
858
  const lastEnrichedSentences = harvestSentenceHashes(blocks);
830
859
  const lastEnrichedCharCount = harvestCharCount(blocks);
831
- // Build the atomic enrichment payload.
860
+ // Build the atomic enrichment payload. v0.19.0: only logline is
861
+ // LLM-written. The legacy fields (domain / concepts / docRole) get
862
+ // retired on this write — `LEGACY_FIELDS_TO_RETIRE` is deleted from
863
+ // the merged metadata so disk slowly converges to the new schema
864
+ // as each doc gets re-enriched (lazy migration path from the brief).
832
865
  const update = {
833
866
  lastEnrichedAt: now,
834
867
  lastEnrichedCharCount,
835
868
  lastEnrichedSentences,
836
869
  enrichmentStale: false,
870
+ logline: item.logline,
837
871
  };
838
- if (item.logline !== undefined)
839
- update.logline = item.logline;
840
- if (item.domain !== undefined)
841
- update.domain = item.domain;
842
- if (item.concepts !== undefined)
843
- update.concepts = item.concepts;
844
- if (item.docRole !== undefined)
845
- update.docRole = item.docRole;
846
- if (item.status !== undefined)
847
- update.status = item.status;
872
+ const LEGACY_FIELDS_TO_RETIRE = ['domain', 'concepts', 'docRole'];
848
873
  if (target.isActive) {
849
874
  // Active doc: setMetadata mutates state.metadata but doesn't bump
850
875
  // docVersion on its own — without an explicit bump, save() would
@@ -853,6 +878,9 @@ export const TOOL_REGISTRY = [
853
878
  // writeToDisk's staleness check will see the just-stamped baseline
854
879
  // (volumeRatio=1, drift=0) and NOT flip the flag back to true.
855
880
  setMetadata(update);
881
+ const liveMeta = getMetadata();
882
+ for (const k of LEGACY_FIELDS_TO_RETIRE)
883
+ delete liveMeta[k];
856
884
  bumpDocVersion();
857
885
  save();
858
886
  broadcastMetadataChanged(getMetadata());
@@ -863,6 +891,8 @@ export const TOOL_REGISTRY = [
863
891
  // serialize cycle before the new baseline lands). Disk write +
864
892
  // cache invalidation mirrors set_metadata's non-active path.
865
893
  const newMeta = { ...target.metadata, ...update };
894
+ for (const k of LEGACY_FIELDS_TO_RETIRE)
895
+ delete newMeta[k];
866
896
  const markdown = tiptapToMarkdown(target.document, target.title, newMeta);
867
897
  atomicWriteFileSync(target.filePath, markdown);
868
898
  invalidateDocCache(target.filePath);
@@ -896,13 +926,11 @@ export const TOOL_REGISTRY = [
896
926
  },
897
927
  {
898
928
  name: 'crawl',
899
- description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~150 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay.',
929
+ description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~60 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay. v0.19.0 schema: status (canonical / draft) replaces docRole / domain / concepts filters — those legacy filters were dropped because the fields they queried had no authority discipline. See brief 2026-05-21-simplify-enrichment-schema-three-fields.',
900
930
  schema: {
901
931
  workspaceFile: z.string().optional().describe('Scope to one workspace.'),
902
- domain: z.string().optional().describe('Exact domain match.'),
903
932
  tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
904
- concepts: z.array(z.string()).optional().describe('Docs must reference ALL listed concepts.'),
905
- docRole: z.string().optional().describe('Exact docRole match (canonical / vignette / reference / draft / chapter / beat).'),
933
+ status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common crawl is `status: canonical`.'),
906
934
  hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
907
935
  },
908
936
  handler: async (filter) => {
@@ -953,7 +981,7 @@ export const TOOL_REGISTRY = [
953
981
  },
954
982
  {
955
983
  name: 'get_workspace_structure',
956
- description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, domain, tags, docRole, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies.',
984
+ description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, status, tags, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies. v0.19.0: enrichment fields shown per-doc are logline (LLM-owned), status (agent-owned: canonical / draft), tags, and the STALE marker (system-owned).',
957
985
  schema: {
958
986
  filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
959
987
  },
@@ -970,10 +998,11 @@ export const TOOL_REGISTRY = [
970
998
  const e = enrichByFile.get(node.file);
971
999
  const tags = e?.tags ?? [];
972
1000
  const enrichBits = [];
973
- if (e?.domain)
974
- enrichBits.push(e.domain);
975
- if (e?.docRole)
976
- enrichBits.push(e.docRole);
1001
+ // v0.19.0: status (agent-owned) replaces domain + docRole.
1002
+ // Only "canonical" is worth surfacing — draft is the default
1003
+ // and would add noise on every line.
1004
+ if (e?.status === 'canonical')
1005
+ enrichBits.push('canonical');
977
1006
  if (tags.length > 0)
978
1007
  enrichBits.push(`tags: ${tags.join(', ')}`);
979
1008
  if (e?.enrichmentStale === true)
@@ -1023,7 +1052,7 @@ export const TOOL_REGISTRY = [
1023
1052
  },
1024
1053
  {
1025
1054
  name: 'get_item_context',
1026
- description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline, domain, concepts, docRole, status), tags, and the enrichmentStale flag. Use before writing to understand context, or before reading to decide whether a body read is necessary.',
1055
+ description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline, status), tags, and the enrichmentStale flag. Use before writing to understand context, or before reading to decide whether a body read is necessary. v0.19.0: returns the three-field enrichment schema — logline (LLM), status (agent), enrichmentStale (system).',
1027
1056
  schema: {
1028
1057
  workspaceFile: z.string().describe('Workspace manifest filename'),
1029
1058
  docId: z.string().describe('Document docId (8-char hex from list_documents)'),
@@ -1039,14 +1068,10 @@ export const TOOL_REGISTRY = [
1039
1068
  const enriched = crawlDocs({ workspaceFile });
1040
1069
  const docEnrich = enriched.find((e) => e.filename === filename);
1041
1070
  if (docEnrich) {
1071
+ // v0.19.0 three-field schema: logline (LLM), status (agent),
1072
+ // enrichmentStale (system). domain / concepts / docRole dropped.
1042
1073
  if (docEnrich.logline)
1043
1074
  base.logline = docEnrich.logline;
1044
- if (docEnrich.domain)
1045
- base.domain = docEnrich.domain;
1046
- if (docEnrich.concepts)
1047
- base.concepts = docEnrich.concepts;
1048
- if (docEnrich.docRole)
1049
- base.docRole = docEnrich.docRole;
1050
1075
  if (docEnrich.status)
1051
1076
  base.status = docEnrich.status;
1052
1077
  if (docEnrich.enrichmentStale === true)
@@ -1684,115 +1709,69 @@ export const TOOL_REGISTRY = [
1684
1709
  },
1685
1710
  {
1686
1711
  name: 'link_to',
1687
- description: 'Wrap anchor text in a source doc with a doc: link pointing at another doc. Operates in place on the block containing the anchor text never creates a duplicate paragraph. Optionally target a specific paragraph (target_node_id) for paragraph-level navigation, with an optional quote for scroll-anchor fallback. The on-save backlinks pipeline then auto-updates the target doc\'s frontmatter `backlinks` field so this single tool call creates both the forward link and the backlink. Use after writing prose to cross-reference concepts: agent writes about "territorial imperative" then calls link_to to point that phrase at the canonical concept doc.',
1712
+ description: 'Declare a structural doc-to-doc connection. Writes target_doc_id into the source doc\'s `references:` frontmatter array. Body markdown is NEVER mutatedthis is metadata, not prose. Idempotent: calling twice with the same source/target is a no-op. The inbound list on the target is computed live from the inverse of every doc\'s referencesno stored derived field. v0.20.0 breaking change: dropped `text`, `target_node_id`, and `quote` parameters; connections are structural, not anchored to prose. Legacy prose `doc:` links continue to render and auto-populate `references` on save (backward compat).',
1688
1713
  schema: {
1689
- text: z.string().describe('Anchor text in the source doc to wrap with the link. Exact substring match. First UNLINKED occurrence wins — calling link_to N times with the same anchor wraps N distinct occurrences, skipping ones already linked to the same target.'),
1690
- source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc containing the anchor text. NOT the active doc — must be explicit so user-driven navigation in the browser can\'t silently change the target.'),
1691
- target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
1692
- target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
1693
- quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
1714
+ source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc declaring the connection.'),
1715
+ target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the connection points AT.'),
1694
1716
  },
1695
- handler: async ({ text, source_doc_id, target_doc_id, target_node_id, quote }) => {
1717
+ handler: async ({ source_doc_id, target_doc_id }) => {
1696
1718
  const sourceFilename = resolveDocId(source_doc_id);
1697
1719
  if (!sourceFilename) {
1698
1720
  return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
1699
1721
  }
1700
- // Build the href in canonical doc:DOCID#NODEID?q=quote form so we can also
1701
- // detect "this text is already wrapped with THIS link" and skip it.
1702
- let href = `doc:${target_doc_id}`;
1703
- if (target_node_id)
1704
- href += `#${target_node_id}`;
1705
- if (quote)
1706
- href += `?q=${encodeURIComponent(quote)}`;
1707
- // Load the source doc from in-memory state if it's active, from disk
1708
- // otherwise. Explicit source dispatch prevents the active-doc race where
1709
- // a user click in the browser silently changes which doc gets edited.
1722
+ const targetFilename = resolveDocId(target_doc_id);
1723
+ if (!targetFilename) {
1724
+ return { content: [{ type: 'text', text: `target_doc_id "${target_doc_id}" not found. Use list_documents or search_docs to find the right docId.` }] };
1725
+ }
1726
+ if (source_doc_id === target_doc_id) {
1727
+ return { content: [{ type: 'text', text: `Cannot link a document to itself (source_doc_id and target_doc_id are both "${source_doc_id}").` }] };
1728
+ }
1729
+ // Load source's current references (active doc in-memory; otherwise disk).
1710
1730
  const sourceIsActive = sourceFilename === getActiveFilename();
1711
- let sourceDoc;
1731
+ let currentReferences;
1712
1732
  if (sourceIsActive) {
1713
- sourceDoc = getDocument();
1733
+ const meta = getMetadata();
1734
+ currentReferences = Array.isArray(meta?.references) ? [...meta.references] : [];
1714
1735
  }
1715
1736
  else {
1716
- const cached = getCachedDocument(resolveDocPath(sourceFilename));
1717
- if (cached) {
1718
- sourceDoc = cached.document;
1719
- }
1720
- else {
1721
- try {
1722
- const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
1723
- sourceDoc = markdownToTiptap(raw).document;
1724
- }
1725
- catch (err) {
1726
- return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
1727
- }
1728
- }
1737
+ const fm = readFrontmatter(sourceFilename);
1738
+ currentReferences = Array.isArray(fm?.data?.references) ? [...fm.data.references] : [];
1729
1739
  }
1730
- // Locate the first block containing the anchor text WHERE the text is
1731
- // not already entirely wrapped with a link to the same href. This makes
1732
- // link_to idempotent and lets repeat calls wrap successive occurrences.
1733
- let sourceNodeId = null;
1734
- let totalOccurrences = 0;
1735
- let alreadyLinkedOccurrences = 0;
1736
- function isTextAlreadyLinked(nodeContent) {
1737
- // Concatenate text from inline children that have a link mark matching href
1738
- let linkedText = '';
1739
- for (const child of nodeContent) {
1740
- if (child.type !== 'text' || !child.text)
1741
- continue;
1742
- const marks = child.marks || [];
1743
- const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
1744
- if (hasMatchingLink)
1745
- linkedText += child.text;
1746
- }
1747
- return linkedText.includes(text);
1740
+ // Idempotent: target already declared no-op.
1741
+ if (currentReferences.includes(target_doc_id)) {
1742
+ return { content: [{ type: 'text', text: JSON.stringify({
1743
+ success: true,
1744
+ sourceDocId: source_doc_id,
1745
+ targetDocId: target_doc_id,
1746
+ alreadyReferenced: true,
1747
+ references: currentReferences,
1748
+ }) }] };
1748
1749
  }
1749
- function walk(nodes) {
1750
- if (sourceNodeId)
1751
- return;
1752
- for (const node of nodes) {
1753
- if (sourceNodeId)
1754
- return;
1755
- if (Array.isArray(node.content)) {
1756
- const blockText = node.content.map((c) => c.text || '').join('');
1757
- if (node.attrs?.id && blockText.includes(text)) {
1758
- totalOccurrences++;
1759
- if (isTextAlreadyLinked(node.content)) {
1760
- alreadyLinkedOccurrences++;
1761
- walk(node.content);
1762
- continue;
1763
- }
1764
- sourceNodeId = node.attrs.id;
1765
- return;
1766
- }
1767
- walk(node.content);
1768
- }
1769
- }
1750
+ // Append + dedup via Set round-trip.
1751
+ const newReferences = Array.from(new Set([...currentReferences, target_doc_id]));
1752
+ if (sourceIsActive) {
1753
+ // Active doc: mutate state.metadata and let save() persist the frontmatter.
1754
+ // save()'s writeToDisk path invalidates the backlinks cache.
1755
+ setMetadata({ references: newReferences });
1756
+ save();
1757
+ broadcastMetadataChanged(getMetadata());
1770
1758
  }
1771
- walk(sourceDoc.content);
1772
- if (!sourceNodeId) {
1773
- if (totalOccurrences > 0 && totalOccurrences === alreadyLinkedOccurrences) {
1774
- return { content: [{ type: 'text', text: `Anchor text "${text}" found in source doc but all ${totalOccurrences} occurrence(s) are already linked to ${href}. Nothing to do.` }] };
1759
+ else {
1760
+ // Non-active doc: write frontmatter directly, preserving body verbatim.
1761
+ const fm = readFrontmatter(sourceFilename);
1762
+ if (!fm) {
1763
+ return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}" frontmatter.` }] };
1775
1764
  }
1776
- return { content: [{ type: 'text', text: `Anchor text "${text}" not found in source doc "${source_doc_id}" (${sourceFilename}). Use search_docs or read_pad to verify.` }] };
1777
- }
1778
- // Apply the link mark in place. Dispatch by active vs non-active so the
1779
- // edit always lands in the right doc — never silently in whatever doc
1780
- // happens to be foregrounded in the browser.
1781
- const editResult = sourceIsActive
1782
- ? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
1783
- : applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
1784
- if (!editResult.success) {
1785
- return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
1765
+ const merged = { ...fm.data, references: newReferences };
1766
+ writeFrontmatter(sourceFilename, merged);
1767
+ invalidateDocCache(resolveDocPath(sourceFilename));
1768
+ invalidateBacklinksCache();
1786
1769
  }
1787
- if (sourceIsActive)
1788
- save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
1789
1770
  return { content: [{ type: 'text', text: JSON.stringify({
1790
1771
  success: true,
1791
1772
  sourceDocId: source_doc_id,
1792
- sourceFilename,
1793
- nodeId: sourceNodeId,
1794
- href,
1795
- ...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
1773
+ targetDocId: target_doc_id,
1774
+ references: newReferences,
1796
1775
  }) }] };
1797
1776
  },
1798
1777
  },
@@ -1811,20 +1790,18 @@ export const TOOL_REGISTRY = [
1811
1790
  const enriched = raw.slice(0, cap).map((r) => {
1812
1791
  let docId = null;
1813
1792
  let logline;
1814
- let domain;
1815
- let docRole;
1793
+ let status;
1816
1794
  let tags;
1817
1795
  try {
1818
1796
  const filePath = resolveDocPath(r.filename);
1819
1797
  const fileRaw = readFileSync(filePath, 'utf-8');
1820
1798
  const fm = matter(fileRaw);
1821
1799
  docId = fm.data?.docId || null;
1800
+ // v0.19.0 three-field schema: logline (LLM), status (agent), tags.
1822
1801
  if (typeof fm.data?.logline === 'string')
1823
1802
  logline = fm.data.logline;
1824
- if (typeof fm.data?.domain === 'string')
1825
- domain = fm.data.domain;
1826
- if (typeof fm.data?.docRole === 'string')
1827
- docRole = fm.data.docRole;
1803
+ if (typeof fm.data?.status === 'string')
1804
+ status = fm.data.status;
1828
1805
  if (Array.isArray(fm.data?.tags) && fm.data.tags.length > 0)
1829
1806
  tags = fm.data.tags;
1830
1807
  }
@@ -1837,8 +1814,7 @@ export const TOOL_REGISTRY = [
1837
1814
  snippet: r.snippet,
1838
1815
  matchedTag: r.matchedTag,
1839
1816
  ...(logline ? { logline } : {}),
1840
- ...(domain ? { domain } : {}),
1841
- ...(docRole ? { docRole } : {}),
1817
+ ...(status ? { status } : {}),
1842
1818
  ...(tags ? { tags } : {}),
1843
1819
  };
1844
1820
  });
@@ -1847,7 +1823,7 @@ export const TOOL_REGISTRY = [
1847
1823
  },
1848
1824
  {
1849
1825
  name: 'get_graph',
1850
- description: 'Return forward links + backlinks for a doc — the crawl primitive for cross-doc context retrieval. Forward links extracted from the doc body, backlinks read from the doc\'s frontmatter (maintained by the on-save backlinks pipeline). Optional depth walks neighbors recursively (cap 3).',
1826
+ description: 'Return forward + inbound connections for a doc — the crawl primitive for cross-doc context retrieval. Forward connections read from the doc\'s `references:` frontmatter (structural data, v0.20). Inbound computed live by scanning every doc\'s references for entries pointing at this one (no stored derived field). Optional depth walks neighbors recursively (cap 3). v0.20.0 breaking change: edges are doc-to-doc; per-paragraph granularity (from_node/to_node/anchor text) was dropped — that was an artifact of the prose-link model.',
1851
1827
  schema: {
1852
1828
  docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
1853
1829
  depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
@@ -1867,28 +1843,18 @@ export const TOOL_REGISTRY = [
1867
1843
  catch {
1868
1844
  return;
1869
1845
  }
1870
- const forward = extractForwardLinks(target.document, id);
1871
- const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
1846
+ const forward = Array.isArray(target.metadata.references) ? target.metadata.references : [];
1847
+ const backlinks = computeBacklinksFor(id);
1872
1848
  nodes.push({
1873
1849
  docId: id,
1874
1850
  title: target.title,
1875
- forward: forward.map((l) => ({
1876
- text: l.text,
1877
- from_node: l.from_node,
1878
- to_doc: l.to_doc,
1879
- ...(l.to_node ? { to_node: l.to_node } : {}),
1880
- })),
1881
- backlinks: backlinks.map((b) => ({
1882
- text: b.text,
1883
- from_doc: b.from_doc,
1884
- from_node: b.from_node,
1885
- ...(b.to_node ? { to_node: b.to_node } : {}),
1886
- })),
1851
+ forward: forward.map((to_doc) => ({ to_doc })),
1852
+ backlinks: backlinks.map((b) => ({ from_doc: b.from_doc })),
1887
1853
  });
1888
1854
  if (hopsLeft > 0) {
1889
1855
  const neighbors = new Set();
1890
- for (const l of forward)
1891
- neighbors.add(l.to_doc);
1856
+ for (const to_doc of forward)
1857
+ neighbors.add(to_doc);
1892
1858
  for (const b of backlinks)
1893
1859
  neighbors.add(b.from_doc);
1894
1860
  for (const n of neighbors) {
@@ -10,7 +10,7 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
10
10
  import { applyTextEditsToNode } from './text-edit.js';
11
11
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
12
12
  import { snapshotIfNeeded, ensureDocId } from './versions.js';
13
- import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
13
+ import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
14
14
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
15
  import { matchNodes } from './node-matcher.js';
16
16
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
@@ -434,6 +434,15 @@ function stripLegacyAgentCreated(metadata) {
434
434
  if (metadata && 'agentCreated' in metadata)
435
435
  delete metadata.agentCreated;
436
436
  }
437
+ /** Strip the legacy `backlinks` field. v0.19 stored derived inbound edges in
438
+ * frontmatter; v0.20 computes them live from the inverse of `references`
439
+ * across the workspace. Any save that visits a doc with the stale field drops
440
+ * it — lazy migration. No data loss because the new `references` field on
441
+ * source docs is the authoritative inbound source; we can always recompute. */
442
+ function stripLegacyBacklinks(metadata) {
443
+ if (metadata && 'backlinks' in metadata)
444
+ delete metadata.backlinks;
445
+ }
437
446
  function persistExternalDocs() {
438
447
  try {
439
448
  atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
@@ -1990,16 +1999,6 @@ function writeToDisk() {
1990
1999
  return;
1991
2000
  }
1992
2001
  ensureDataDir();
1993
- // Capture old forward links BEFORE we overwrite the file — needed by the
1994
- // backlinks engine to know which target docs to refresh when source changes.
1995
- // Skip for external docs (they don't participate in the doc graph).
1996
- let oldForwardLinks = [];
1997
- if (!isExternalDoc(state.filePath) && state.docId) {
1998
- try {
1999
- oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
2000
- }
2001
- catch { /* best-effort */ }
2002
- }
2003
2002
  // Stub graduation: once the doc contains accepted content, it's no longer
2004
2003
  // a fresh stub. Remove it from the in-memory stub registry so reject-all
2005
2004
  // can never trigger the cleanup-delete on it.
@@ -2009,8 +2008,10 @@ function writeToDisk() {
2009
2008
  }
2010
2009
  // Defensive: never serialize `agentCreated` to disk. The field is dead;
2011
2010
  // any code reading it would be the bug, not the field's presence.
2012
- if (state.metadata)
2011
+ if (state.metadata) {
2013
2012
  stripLegacyAgentCreated(state.metadata);
2013
+ stripLegacyBacklinks(state.metadata);
2014
+ }
2014
2015
  let markdown;
2015
2016
  if (isExternalDoc(state.filePath)) {
2016
2017
  // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
@@ -2204,16 +2205,27 @@ function writeToDisk() {
2204
2205
  snapshotIfNeeded(state.docId, state.filePath);
2205
2206
  }
2206
2207
  catch { /* ignore */ }
2207
- // Backlinks update: refresh target docs' backlinks frontmatter if source's
2208
- // forward links changed. Best-effort never blocks the save it follows.
2208
+ // Auto-sync references from prose: legacy `doc:` prose links still render
2209
+ // (PadLink extension), but the graph/crawl/backlinks-panel read the
2210
+ // structural `references:` field. After every save, scan the body for
2211
+ // prose links and merge their targets into references — backward compat
2212
+ // without forcing rewrites. Then invalidate the live-backlinks cache so
2213
+ // the next /api/backlinks/:docId call sees the fresh inverse.
2214
+ // Best-effort — never blocks the save it follows.
2209
2215
  if (!isExternalDoc(state.filePath) && state.docId) {
2210
2216
  try {
2211
- const newForwardLinks = extractForwardLinks(state.document, state.docId);
2212
- updateBacklinksForSource(state.docId, newForwardLinks, oldForwardLinks);
2217
+ const sync = syncReferencesFromProse(state.docId, state.document, state.metadata || {});
2218
+ if (sync && state.metadata) {
2219
+ state.metadata.references = sync.newReferences;
2220
+ // Second tiny write: re-persist frontmatter only (body already on disk).
2221
+ const filename = state.filePath.split(/[/\\]/).pop() || '';
2222
+ writeFrontmatter(filename, state.metadata);
2223
+ }
2213
2224
  }
2214
2225
  catch (err) {
2215
- console.error('[State] backlinks update failed:', err);
2226
+ console.error('[State] references auto-sync failed:', err);
2216
2227
  }
2228
+ invalidateBacklinksCache();
2217
2229
  }
2218
2230
  }
2219
2231
  export function save() {
@@ -2756,6 +2768,28 @@ export function populateDocumentFile(filename, doc) {
2756
2768
  if (!isAutoAcceptActive(filename, parsed.metadata)) {
2757
2769
  markAllNodesAsPending(doc, 'insert');
2758
2770
  }
2771
+ // Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
2772
+ // flushDocToFile writes `doc` directly — it does NOT merge with the existing
2773
+ // parsed.document on disk. Without this merge step, the stub's auto-generated
2774
+ // trailing paragraph falls out of canonical, the matcher's `previousNodes`
2775
+ // for any subsequent save no longer references it, and a follow-up Accept All
2776
+ // doc-update can find itself with no matching previousNodes to anchor against.
2777
+ // Cascading: the matcher classifies the newly accepted inserts as deletions
2778
+ // (orphaned from the empty previousNodes set), they go to graveyard, the disk
2779
+ // body ends up empty.
2780
+ // Mirrors the active-doc fix in mcp.ts:populate_document.
2781
+ if (parsed.document?.content?.length) {
2782
+ const incomingIds = new Set(doc.content
2783
+ .map((n) => n?.attrs?.id)
2784
+ .filter((id) => typeof id === 'string'));
2785
+ const preserved = parsed.document.content.filter((n) => {
2786
+ const id = n?.attrs?.id;
2787
+ return id && !incomingIds.has(id);
2788
+ });
2789
+ if (preserved.length > 0) {
2790
+ doc.content = [...doc.content, ...preserved];
2791
+ }
2792
+ }
2759
2793
  flushDocToFile(filename, doc, parsed.title, parsed.metadata);
2760
2794
  const pendingCount = countPending(doc.content);
2761
2795
  const text = extractText(doc.content);