openwriter 0.19.0 → 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';
@@ -567,6 +567,28 @@ export const TOOL_REGISTRY = [
567
567
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
568
568
  markAllNodesAsPending(doc, 'insert');
569
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
+ }
570
592
  updateDocument(doc);
571
593
  updatePendingCacheForActiveDoc();
572
594
  save();
@@ -1687,115 +1709,69 @@ export const TOOL_REGISTRY = [
1687
1709
  },
1688
1710
  {
1689
1711
  name: 'link_to',
1690
- 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).',
1691
1713
  schema: {
1692
- 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.'),
1693
- 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.'),
1694
- target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
1695
- 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.'),
1696
- 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.'),
1697
1716
  },
1698
- handler: async ({ text, source_doc_id, target_doc_id, target_node_id, quote }) => {
1717
+ handler: async ({ source_doc_id, target_doc_id }) => {
1699
1718
  const sourceFilename = resolveDocId(source_doc_id);
1700
1719
  if (!sourceFilename) {
1701
1720
  return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
1702
1721
  }
1703
- // Build the href in canonical doc:DOCID#NODEID?q=quote form so we can also
1704
- // detect "this text is already wrapped with THIS link" and skip it.
1705
- let href = `doc:${target_doc_id}`;
1706
- if (target_node_id)
1707
- href += `#${target_node_id}`;
1708
- if (quote)
1709
- href += `?q=${encodeURIComponent(quote)}`;
1710
- // Load the source doc from in-memory state if it's active, from disk
1711
- // otherwise. Explicit source dispatch prevents the active-doc race where
1712
- // 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).
1713
1730
  const sourceIsActive = sourceFilename === getActiveFilename();
1714
- let sourceDoc;
1731
+ let currentReferences;
1715
1732
  if (sourceIsActive) {
1716
- sourceDoc = getDocument();
1733
+ const meta = getMetadata();
1734
+ currentReferences = Array.isArray(meta?.references) ? [...meta.references] : [];
1717
1735
  }
1718
1736
  else {
1719
- const cached = getCachedDocument(resolveDocPath(sourceFilename));
1720
- if (cached) {
1721
- sourceDoc = cached.document;
1722
- }
1723
- else {
1724
- try {
1725
- const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
1726
- sourceDoc = markdownToTiptap(raw).document;
1727
- }
1728
- catch (err) {
1729
- return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
1730
- }
1731
- }
1737
+ const fm = readFrontmatter(sourceFilename);
1738
+ currentReferences = Array.isArray(fm?.data?.references) ? [...fm.data.references] : [];
1732
1739
  }
1733
- // Locate the first block containing the anchor text WHERE the text is
1734
- // not already entirely wrapped with a link to the same href. This makes
1735
- // link_to idempotent and lets repeat calls wrap successive occurrences.
1736
- let sourceNodeId = null;
1737
- let totalOccurrences = 0;
1738
- let alreadyLinkedOccurrences = 0;
1739
- function isTextAlreadyLinked(nodeContent) {
1740
- // Concatenate text from inline children that have a link mark matching href
1741
- let linkedText = '';
1742
- for (const child of nodeContent) {
1743
- if (child.type !== 'text' || !child.text)
1744
- continue;
1745
- const marks = child.marks || [];
1746
- const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
1747
- if (hasMatchingLink)
1748
- linkedText += child.text;
1749
- }
1750
- 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
+ }) }] };
1751
1749
  }
1752
- function walk(nodes) {
1753
- if (sourceNodeId)
1754
- return;
1755
- for (const node of nodes) {
1756
- if (sourceNodeId)
1757
- return;
1758
- if (Array.isArray(node.content)) {
1759
- const blockText = node.content.map((c) => c.text || '').join('');
1760
- if (node.attrs?.id && blockText.includes(text)) {
1761
- totalOccurrences++;
1762
- if (isTextAlreadyLinked(node.content)) {
1763
- alreadyLinkedOccurrences++;
1764
- walk(node.content);
1765
- continue;
1766
- }
1767
- sourceNodeId = node.attrs.id;
1768
- return;
1769
- }
1770
- walk(node.content);
1771
- }
1772
- }
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());
1773
1758
  }
1774
- walk(sourceDoc.content);
1775
- if (!sourceNodeId) {
1776
- if (totalOccurrences > 0 && totalOccurrences === alreadyLinkedOccurrences) {
1777
- 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.` }] };
1778
1764
  }
1779
- 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.` }] };
1780
- }
1781
- // Apply the link mark in place. Dispatch by active vs non-active so the
1782
- // edit always lands in the right doc — never silently in whatever doc
1783
- // happens to be foregrounded in the browser.
1784
- const editResult = sourceIsActive
1785
- ? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
1786
- : applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
1787
- if (!editResult.success) {
1788
- 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();
1789
1769
  }
1790
- if (sourceIsActive)
1791
- save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
1792
1770
  return { content: [{ type: 'text', text: JSON.stringify({
1793
1771
  success: true,
1794
1772
  sourceDocId: source_doc_id,
1795
- sourceFilename,
1796
- nodeId: sourceNodeId,
1797
- href,
1798
- ...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
1773
+ targetDocId: target_doc_id,
1774
+ references: newReferences,
1799
1775
  }) }] };
1800
1776
  },
1801
1777
  },
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",