openwriter 0.12.1 → 0.14.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.
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Markdown -> TipTap JSON parsing.
3
3
  * Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
4
+ *
5
+ * Node identity is reassigned via the matcher when the frontmatter carries a
6
+ * `nodes` array (the new path). For legacy docs without `nodes`, trailing
7
+ * `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
8
+ * recognized as ID sources so existing files keep their identities through
9
+ * the migration. Once a migrated doc is saved, the body is clean and all
10
+ * identity lives in frontmatter.
11
+ *
12
+ * adr: adr/node-identity-matcher.md
4
13
  */
5
14
  import MarkdownIt from 'markdown-it';
6
15
  import matter from 'gray-matter';
@@ -10,6 +19,8 @@ import markdownItSub from 'markdown-it-sub';
10
19
  import markdownItSup from 'markdown-it-sup';
11
20
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
12
21
  import { nodeText } from './markdown-serialize.js';
22
+ import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
23
+ import { matchNodes } from './node-matcher.js';
13
24
  // ============================================================================
14
25
  // Markdown -> TipTap
15
26
  // ============================================================================
@@ -19,24 +30,152 @@ md.use(markdownItIns);
19
30
  md.use(markdownItMark);
20
31
  md.use(markdownItSub);
21
32
  md.use(markdownItSup);
33
+ /**
34
+ * Normalize blank lines INSIDE markdown tables before parsing.
35
+ *
36
+ * Per CommonMark, a blank line terminates a table block. Agents writing
37
+ * markdown content frequently insert blank lines between table rows for
38
+ * readability (e.g. `| row |\n\n| row |`), which the strict parser then
39
+ * splits into "table with 1 header row" + N orphan paragraphs that happen
40
+ * to contain pipe characters. The broken structure persists across saves
41
+ * because every serialize → re-parse cycle re-breaks it.
42
+ *
43
+ * This pre-pass detects "table region" (saw a separator row `| --- |`,
44
+ * haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
45
+ * inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
46
+ * inside them stay untouched.
47
+ *
48
+ * Self-healing: a doc on disk with blank-separated rows loads as a proper
49
+ * N-row table; the next save writes contiguous markdown. No migration
50
+ * script needed.
51
+ */
52
+ function normalizeTableBlankLines(markdown) {
53
+ if (!markdown.includes('|'))
54
+ return markdown;
55
+ const lines = markdown.split('\n');
56
+ const out = [];
57
+ let inFence = false;
58
+ let inTable = false; // true between a separator row and the next non-pipe-row
59
+ const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
60
+ const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
61
+ const isBlank = (s) => s.trim() === '';
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i];
64
+ // Code-fence toggle — pipes inside fences stay verbatim
65
+ if (/^[`~]{3,}/.test(line)) {
66
+ inFence = !inFence;
67
+ inTable = false;
68
+ out.push(line);
69
+ continue;
70
+ }
71
+ if (inFence) {
72
+ out.push(line);
73
+ continue;
74
+ }
75
+ if (isSeparator(line)) {
76
+ // Separator confirms we're in a table region from here on
77
+ inTable = true;
78
+ out.push(line);
79
+ continue;
80
+ }
81
+ if (inTable && isBlank(line)) {
82
+ // Look ahead past additional blanks for the next non-blank line
83
+ let j = i + 1;
84
+ while (j < lines.length && lines[j].trim() === '')
85
+ j++;
86
+ // If the next non-blank line is another pipe-row, it's EITHER a
87
+ // continuation row (merge across the blank) OR the header of a NEW
88
+ // table (the blank is the boundary, don't merge). We tell them apart
89
+ // by peeking one further: if line j+1 is a separator row, j is a new
90
+ // table's header — preserve the blank and exit the current table region.
91
+ if (j < lines.length && isPipeRow(lines[j])) {
92
+ if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
93
+ // New table starting — keep the boundary blank, end current region
94
+ inTable = false;
95
+ out.push(line);
96
+ continue;
97
+ }
98
+ // Continuation row — drop the blank
99
+ continue;
100
+ }
101
+ // Lookahead found non-pipe content — table region is ending
102
+ inTable = false;
103
+ out.push(line);
104
+ continue;
105
+ }
106
+ if (inTable && !isPipeRow(line)) {
107
+ // Non-pipe content ends the table region
108
+ inTable = false;
109
+ }
110
+ out.push(line);
111
+ }
112
+ return out.join('\n');
113
+ }
22
114
  export function markdownToTiptap(markdown) {
23
115
  const result = matter(markdown);
24
116
  const { data, content } = result;
25
117
  const title = data.title || 'Untitled';
26
- const tokens = md.parse(content, {});
118
+ const normalizedContent = normalizeTableBlankLines(content);
119
+ const tokens = md.parse(normalizedContent, {});
27
120
  const docContent = tokensToTiptap(tokens);
28
121
  const doc = {
29
122
  type: 'doc',
30
123
  content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
31
124
  };
125
+ // Extract identity graph from frontmatter — these become the matcher's
126
+ // previousNodes input on both the load-time pass below AND on every
127
+ // subsequent save-time pass while the doc stays loaded.
128
+ const previousNodes = normalizeNodeEntries(data.nodes);
129
+ const graveyard = normalizeNodeEntries(data.graveyard);
130
+ // Load-time matcher pass — when frontmatter carries `nodes`, reassign IDs
131
+ // based on fingerprint match. Legacy docs (no `nodes` field) keep whatever
132
+ // IDs the body parser extracted from caret anchors or minted fresh.
133
+ if (previousNodes.length > 0) {
134
+ applyMatcher(doc, previousNodes, graveyard);
135
+ }
32
136
  // Rehydrate pending state from frontmatter into node attrs
33
137
  if (data.pending) {
34
138
  rehydratePendingState(doc, data.pending);
35
139
  }
36
- // Strip pending from returned metadata (consumed into node attrs)
140
+ // Strip consumed keys from returned metadata
37
141
  const metadata = { ...data };
38
142
  delete metadata.pending;
39
- return { title, metadata, document: doc, rawFrontmatter: result.matter || null };
143
+ delete metadata.nodes;
144
+ delete metadata.graveyard;
145
+ return {
146
+ title,
147
+ metadata,
148
+ document: doc,
149
+ rawFrontmatter: result.matter || null,
150
+ graveyard,
151
+ previousNodes,
152
+ };
153
+ }
154
+ /** Defensive parse of frontmatter node entries — drops any malformed rows. */
155
+ function normalizeNodeEntries(raw) {
156
+ if (!Array.isArray(raw))
157
+ return [];
158
+ return raw
159
+ .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
160
+ .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
161
+ }
162
+ /**
163
+ * Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
164
+ * the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
165
+ *
166
+ * Graveyard is passed through so paste-back of recently-deleted content
167
+ * restores the original ID (matched by exact fingerprint).
168
+ */
169
+ function applyMatcher(doc, previousNodes, graveyard) {
170
+ if (previousNodes.length === 0)
171
+ return;
172
+ const newBlocks = tiptapToBlocks(doc);
173
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
174
+ const pinnedByPosition = new Map();
175
+ for (const p of matchResult.pinned) {
176
+ pinnedByPosition.set(p.position, p.id);
177
+ }
178
+ applyIdsToTiptap(doc, pinnedByPosition);
40
179
  }
41
180
  /**
42
181
  * Rehydrate pending state from frontmatter into leaf block node attrs.
@@ -116,19 +255,21 @@ function tokensToTiptap(tokens) {
116
255
  if (token.type === 'heading_open') {
117
256
  const level = parseInt(token.tag.slice(1));
118
257
  const inlineToken = tokens[i + 1];
119
- const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
120
- nodes.push({ type: 'heading', attrs: { id: generateNodeId(), level }, content });
258
+ const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
259
+ const { content, id } = extractTrailingNodeId(rawContent);
260
+ nodes.push({ type: 'heading', attrs: { id: id || generateNodeId(), level }, content });
121
261
  i += 3;
122
262
  }
123
263
  else if (token.type === 'paragraph_open') {
124
264
  const inlineToken = tokens[i + 1];
125
- const content = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
265
+ const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
266
+ const { content, id } = extractTrailingNodeId(rawContent);
126
267
  // Check for solo image — promote to block-level image node
127
268
  if (content.length === 1 && content[0].type === 'image') {
128
269
  nodes.push(content[0]);
129
270
  }
130
271
  else {
131
- nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content });
272
+ nodes.push({ type: 'paragraph', attrs: { id: id || generateNodeId() }, content });
132
273
  }
133
274
  i += 3;
134
275
  }
@@ -168,10 +309,18 @@ function tokensToTiptap(tokens) {
168
309
  i += 1;
169
310
  }
170
311
  else if (token.type === 'html_block') {
171
- // <!-- --> is our sentinel for empty paragraphs
172
- if (token.content.trim() === '<!-- -->') {
312
+ // <!-- --> is our sentinel for empty paragraphs.
313
+ // <!-- ^abc12345 --> is the same sentinel with a persisted nodeId.
314
+ const trimmed = token.content.trim();
315
+ if (trimmed === '<!-- -->') {
173
316
  nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] });
174
317
  }
318
+ else {
319
+ const idMatch = trimmed.match(/^<!--\s*\^([a-f0-9]{8})\s*-->$/);
320
+ if (idMatch) {
321
+ nodes.push({ type: 'paragraph', attrs: { id: idMatch[1] }, content: [] });
322
+ }
323
+ }
175
324
  i += 1;
176
325
  }
177
326
  else if (token.type === 'table_open') {
@@ -440,3 +589,32 @@ function popMarkByType(stack, type) {
440
589
  }
441
590
  }
442
591
  }
592
+ /**
593
+ * Extract a trailing nodeId anchor from inline content.
594
+ * Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
595
+ * Strips the marker from the visible text and returns the captured id.
596
+ * Returns id=null if no anchor found.
597
+ *
598
+ * Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
599
+ * will be interpreted as an anchor. Vanishingly rare in real writing.
600
+ */
601
+ function extractTrailingNodeId(content) {
602
+ if (!content || content.length === 0)
603
+ return { content, id: null };
604
+ const lastNode = content[content.length - 1];
605
+ if (lastNode.type !== 'text' || !lastNode.text)
606
+ return { content, id: null };
607
+ const match = lastNode.text.match(/ \^([a-f0-9]{8})\s*$/);
608
+ if (!match)
609
+ return { content, id: null };
610
+ const id = match[1];
611
+ const newText = lastNode.text.slice(0, match.index);
612
+ const newContent = [...content];
613
+ if (newText) {
614
+ newContent[newContent.length - 1] = { ...lastNode, text: newText };
615
+ }
616
+ else {
617
+ newContent.pop();
618
+ }
619
+ return { content: newContent, id };
620
+ }
@@ -1,8 +1,24 @@
1
1
  /**
2
2
  * TipTap JSON -> Markdown serialization.
3
3
  * Converts TipTap document to markdown with YAML frontmatter.
4
+ *
5
+ * Node identity persistence:
6
+ * - Frontmatter `nodes` array carries the (id, fingerprint) pair for every
7
+ * block in the document. On load, markdown-parse.ts runs the matcher
8
+ * against this array to reassign IDs to surviving blocks.
9
+ * - Markdown body is COMPLETELY UNDISTURBED — no anchors, no comment
10
+ * sentinels (except the legacy `<!-- -->` empty-paragraph marker which
11
+ * has no semantic ID attached anymore).
12
+ * - Legacy `^id` caret anchors are no longer emitted. Old docs that still
13
+ * contain them are migrated transparently on the next save: parse reads
14
+ * the anchors, matcher pins them, serialize emits the new `nodes`
15
+ * frontmatter and a clean body.
16
+ *
17
+ * adr: adr/node-identity-matcher.md
4
18
  */
5
- import { LEAF_BLOCK_TYPES } from './helpers.js';
19
+ import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
20
+ import { tiptapToBlocks } from './node-blocks.js';
21
+ import { fingerprintAll } from './node-fingerprint.js';
6
22
  // ============================================================================
7
23
  // TipTap -> Markdown
8
24
  // ============================================================================
@@ -57,11 +73,67 @@ function collectPendingState(doc) {
57
73
  walk(doc.content || []);
58
74
  return Object.keys(pending).length > 0 ? pending : undefined;
59
75
  }
76
+ /**
77
+ * Build the `nodes` frontmatter entry — one (id, fingerprint) per block
78
+ * in pre-order traversal of the TipTap tree.
79
+ *
80
+ * Each block's ID comes from its TipTap node's `attrs.id`. Fingerprints are
81
+ * computed from the walker-style block list derived directly from the TipTap
82
+ * tree (no separate markdown re-parse — same source of truth that builds
83
+ * the visible doc).
84
+ */
85
+ function collectNodesFrontmatter(doc) {
86
+ const blocks = tiptapToBlocks(doc);
87
+ const fingerprints = fingerprintAll(blocks);
88
+ const ids = collectBlockIds(doc);
89
+ // ids array is parallel to blocks array — same pre-order traversal.
90
+ const entries = [];
91
+ for (let i = 0; i < blocks.length; i++) {
92
+ const id = ids[i] || generateNodeId();
93
+ entries.push({ id, fp: fingerprints[i] });
94
+ }
95
+ return entries;
96
+ }
97
+ /**
98
+ * Cap graveyard size to avoid frontmatter bloat on docs with many edits.
99
+ * Newest entries (highest position) win — the ones most likely to be
100
+ * paste-back targets. Older entries expire silently.
101
+ */
102
+ const GRAVEYARD_MAX = 50;
103
+ /** Walk the TipTap tree in the SAME pre-order as tiptapToBlocks, collect IDs. */
104
+ function collectBlockIds(doc) {
105
+ const ids = [];
106
+ const blockTypes = new Set([
107
+ 'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
108
+ 'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
109
+ 'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
110
+ ]);
111
+ const containerTypes = new Set([
112
+ 'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
113
+ ]);
114
+ function walk(nodes) {
115
+ if (!nodes)
116
+ return;
117
+ for (const node of nodes) {
118
+ if (blockTypes.has(node.type)) {
119
+ ids.push(node.attrs?.id || '');
120
+ if (containerTypes.has(node.type) && node.content)
121
+ walk(node.content);
122
+ }
123
+ else if (node.content) {
124
+ walk(node.content);
125
+ }
126
+ }
127
+ }
128
+ walk(doc.content || []);
129
+ return ids;
130
+ }
60
131
  /**
61
132
  * Convert TipTap document to markdown with JSON frontmatter.
62
133
  * Metadata stored as minified JSON between --- delimiters (valid YAML).
63
134
  * Editor never sees frontmatter — it's stripped on load, regenerated on save.
64
135
  * Pending state is persisted in frontmatter `pending` key.
136
+ * Node identity persisted in frontmatter `nodes` key (id + fingerprint per block).
65
137
  */
66
138
  export function tiptapToMarkdown(doc, title, metadata) {
67
139
  const meta = { ...metadata, title };
@@ -73,6 +145,24 @@ export function tiptapToMarkdown(doc, title, metadata) {
73
145
  else {
74
146
  delete meta.pending;
75
147
  }
148
+ // Collect node identity graph (id + fingerprint per block) for next-load matcher
149
+ const nodes = collectNodesFrontmatter(doc);
150
+ if (nodes.length > 0) {
151
+ meta.nodes = nodes;
152
+ }
153
+ else {
154
+ delete meta.nodes;
155
+ }
156
+ // Graveyard: recently-orphaned (id, fingerprint) entries kept across saves so
157
+ // paste-back/undo can restore the original ID via exact fingerprint match.
158
+ // The caller (writeToDisk) puts the matcher's nextGraveyard into metadata.graveyard;
159
+ // we cap it here to keep the file small.
160
+ if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
161
+ meta.graveyard = meta.graveyard.slice(0, GRAVEYARD_MAX);
162
+ }
163
+ else {
164
+ delete meta.graveyard;
165
+ }
76
166
  // Strip undefined/null values
77
167
  for (const key of Object.keys(meta)) {
78
168
  if (meta[key] === undefined || meta[key] === null)
@@ -98,11 +188,16 @@ function nodeToMarkdown(node, indent) {
98
188
  case 'heading': {
99
189
  const level = node.attrs?.level || 1;
100
190
  const prefix = '#'.repeat(level);
191
+ // Body stays undisturbed — node ID is persisted in frontmatter `nodes`, not as a trailing anchor.
101
192
  return `${prefix} ${inlineToMarkdown(node.content)}\n\n`;
102
193
  }
103
194
  case 'paragraph': {
104
195
  const text = inlineToMarkdown(node.content);
105
- return text ? `${indent}${text}\n\n` : `${indent}<!-- -->\n\n`;
196
+ if (text) {
197
+ return `${indent}${text}\n\n`;
198
+ }
199
+ // Empty paragraph: use plain sentinel (frontmatter `nodes` carries the ID).
200
+ return `${indent}<!-- -->\n\n`;
106
201
  }
107
202
  case 'bulletList':
108
203
  return listToMarkdown(node.content, '- ', indent);
@@ -1,6 +1,38 @@
1
1
  /**
2
2
  * Barrel re-export for markdown serialization and parsing.
3
3
  * All existing imports from './markdown.js' continue to work unchanged.
4
+ *
5
+ * Also exposes `tiptapToMarkdownChecked` — a sync-observed wrapper that
6
+ * verifies the TipTap → markdown → TipTap round-trip preserves document
7
+ * shape. Use this when you want to catch silent drift; the unchecked
8
+ * `tiptapToMarkdown` stays the cheap path for hot loops.
4
9
  */
10
+ import { tiptapToMarkdown } from './markdown-serialize.js';
11
+ import { markdownToTiptap } from './markdown-parse.js';
12
+ import { shapeOfTiptap, compareShapes, formatSyncReport, } from './node-sync-check.js';
5
13
  export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
6
14
  export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
15
+ export { shapeOfTiptap, computeShape, compareShapes, formatSyncReport, } from './node-sync-check.js';
16
+ /**
17
+ * Serialize TipTap to markdown, then re-parse and verify that the round-trip
18
+ * preserved document shape. If the shapes diverge, a node was misapplied —
19
+ * content ended up in the wrong block on save, or the parse interpreted
20
+ * something differently than the serialize emitted.
21
+ *
22
+ * Returns the markdown AND the sync report. Callers decide whether a failed
23
+ * report should block the save (throw) or just log and continue.
24
+ *
25
+ * Cost: one extra parse + two shape computations per save. Acceptable for
26
+ * any user-driven save; consider the unchecked path inside tight loops.
27
+ */
28
+ export function tiptapToMarkdownChecked(doc, title, metadata) {
29
+ const markdown = tiptapToMarkdown(doc, title, metadata);
30
+ const expectedShape = shapeOfTiptap(doc);
31
+ const reparsed = markdownToTiptap(markdown);
32
+ const actualShape = shapeOfTiptap(reparsed.document);
33
+ const syncReport = compareShapes(expectedShape, actualShape);
34
+ if (!syncReport.ok) {
35
+ console.error(formatSyncReport(syncReport, `serialize:${title}`));
36
+ }
37
+ return { markdown, syncReport };
38
+ }
@@ -114,6 +114,15 @@ export function getGlobalMarkSummary(excludeFilename) {
114
114
  catch { /* dir doesn't exist */ }
115
115
  return { totalMarks, docCount };
116
116
  }
117
+ export function editMark(filename, id, note) {
118
+ const data = readMarkFile(filename);
119
+ const mark = data.marks.find((m) => m.id === id);
120
+ if (!mark)
121
+ return null;
122
+ mark.note = note;
123
+ writeMarkFile(filename, data);
124
+ return mark;
125
+ }
117
126
  export function resolveMarks(ids) {
118
127
  const idSet = new Set(ids);
119
128
  const resolved = [];
@@ -10,8 +10,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { z } from 'zod';
12
12
  import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
13
- import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
14
- import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, isAutoAcceptActive, } from './state.js';
14
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments } from './documents.js';
15
+ import { extractForwardLinks } from './backlinks.js';
15
16
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
16
17
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
17
18
  import { findDocNode } from './workspace-tree.js';
@@ -221,8 +222,9 @@ export const TOOL_REGISTRY = [
221
222
  pendingChanges: target.pendingCount,
222
223
  lastModified: target.lastModified.toISOString(),
223
224
  };
224
- // Surface autoAccept so the agent stops waiting for review when it's on.
225
- if (target.metadata?.autoAccept === true)
225
+ // Surface effective autoAccept (doc flag OR workspace/container inherited)
226
+ // so the agent stops waiting for review when it's on.
227
+ if (isAutoAcceptActive(target.filename, target.metadata))
226
228
  status.autoAccept = true;
227
229
  const latestVersion = getUpdateInfo();
228
230
  const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
@@ -414,9 +416,10 @@ export const TOOL_REGISTRY = [
414
416
  };
415
417
  }
416
418
  // Active target (or no filename): existing flow.
417
- // Skip pending tagging when autoAccept is on for this doc content commits directly.
419
+ // Skip pending tagging when autoAccept is effectively on (doc flag or
420
+ // inherited from workspace/container) — content commits directly.
418
421
  setAgentLock(); // Block browser doc-updates during population
419
- if (getMetadata()?.autoAccept !== true) {
422
+ if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
420
423
  markAllNodesAsPending(doc, 'insert');
421
424
  }
422
425
  updateDocument(doc);
@@ -1293,6 +1296,145 @@ export const TOOL_REGISTRY = [
1293
1296
  return { content: [{ type: 'text', text: ok ? `Removed task ${id}.` : `Task ${id} not found.` }] };
1294
1297
  },
1295
1298
  },
1299
+ {
1300
+ name: 'link_to',
1301
+ description: 'Wrap anchor text in the ACTIVE doc with a doc: link pointing at another doc. 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.',
1302
+ schema: {
1303
+ text: z.string().describe('Anchor text in the active doc to wrap with the link. Exact substring match. First occurrence wins if the text appears multiple times.'),
1304
+ target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs).'),
1305
+ 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.'),
1306
+ quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
1307
+ },
1308
+ handler: async ({ text, target_doc_id, target_node_id, quote }) => {
1309
+ // 1. Locate the block in the active doc that contains the anchor text
1310
+ const doc = getDocument();
1311
+ let sourceNodeId = null;
1312
+ function walk(nodes) {
1313
+ if (sourceNodeId)
1314
+ return;
1315
+ for (const node of nodes) {
1316
+ if (sourceNodeId)
1317
+ return;
1318
+ if (Array.isArray(node.content)) {
1319
+ const blockText = node.content.map((c) => c.text || '').join('');
1320
+ if (node.attrs?.id && blockText.includes(text)) {
1321
+ sourceNodeId = node.attrs.id;
1322
+ return;
1323
+ }
1324
+ walk(node.content);
1325
+ }
1326
+ }
1327
+ }
1328
+ walk(doc.content);
1329
+ if (!sourceNodeId) {
1330
+ return { content: [{ type: 'text', text: `Anchor text "${text}" not found in the active doc. Use search_docs first to locate the right doc.` }] };
1331
+ }
1332
+ // 2. Build the href in canonical doc:DOCID#NODEID?q=quote form
1333
+ let href = `doc:${target_doc_id}`;
1334
+ if (target_node_id)
1335
+ href += `#${target_node_id}`;
1336
+ if (quote)
1337
+ href += `?q=${encodeURIComponent(quote)}`;
1338
+ // 3. Apply the link mark to the matched substring via the existing text-edit pipeline
1339
+ const result = applyTextEdits(sourceNodeId, [{
1340
+ find: text,
1341
+ addMark: { type: 'link', attrs: { href } },
1342
+ }]);
1343
+ if (!result.success) {
1344
+ return { content: [{ type: 'text', text: `Failed to apply link mark: ${result.error}` }] };
1345
+ }
1346
+ save(); // triggers writeToDisk → backlinks pipeline auto-updates the target's frontmatter
1347
+ return { content: [{ type: 'text', text: `Linked "${text}" in node ${sourceNodeId} → ${href}. Target doc's backlinks frontmatter will refresh on next save.` }] };
1348
+ },
1349
+ },
1350
+ {
1351
+ name: 'search_docs',
1352
+ description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
1353
+ schema: {
1354
+ query: z.string().describe('Search query (case-insensitive substring match against title, tags, then content).'),
1355
+ limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
1356
+ },
1357
+ handler: async ({ query, limit = 10 }) => {
1358
+ const cap = Math.min(Math.max(limit, 1), 50);
1359
+ const raw = searchDocuments(query);
1360
+ // Enrich with docId by reading each result's frontmatter
1361
+ const enriched = raw.slice(0, cap).map((r) => {
1362
+ let docId = null;
1363
+ try {
1364
+ const filePath = resolveDocPath(r.filename);
1365
+ const fileRaw = readFileSync(filePath, 'utf-8');
1366
+ const fm = matter(fileRaw);
1367
+ docId = fm.data?.docId || null;
1368
+ }
1369
+ catch { /* docId stays null */ }
1370
+ return {
1371
+ docId,
1372
+ title: r.title,
1373
+ filename: r.filename,
1374
+ matchType: r.matchType,
1375
+ snippet: r.snippet,
1376
+ matchedTag: r.matchedTag,
1377
+ };
1378
+ });
1379
+ return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
1380
+ },
1381
+ },
1382
+ {
1383
+ name: 'get_graph',
1384
+ 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).',
1385
+ schema: {
1386
+ docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
1387
+ 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.'),
1388
+ },
1389
+ handler: async ({ docId, depth = 1 }) => {
1390
+ const maxDepth = Math.min(Math.max(depth, 1), 3);
1391
+ const seen = new Set();
1392
+ const nodes = [];
1393
+ function visit(id, hopsLeft) {
1394
+ if (seen.has(id) || hopsLeft < 0)
1395
+ return;
1396
+ seen.add(id);
1397
+ let target;
1398
+ try {
1399
+ target = resolveDocTarget(id);
1400
+ }
1401
+ catch {
1402
+ return;
1403
+ }
1404
+ const forward = extractForwardLinks(target.document, id);
1405
+ const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
1406
+ nodes.push({
1407
+ docId: id,
1408
+ title: target.title,
1409
+ forward: forward.map((l) => ({
1410
+ text: l.text,
1411
+ from_node: l.from_node,
1412
+ to_doc: l.to_doc,
1413
+ ...(l.to_node ? { to_node: l.to_node } : {}),
1414
+ })),
1415
+ backlinks: backlinks.map((b) => ({
1416
+ text: b.text,
1417
+ from_doc: b.from_doc,
1418
+ from_node: b.from_node,
1419
+ ...(b.to_node ? { to_node: b.to_node } : {}),
1420
+ })),
1421
+ });
1422
+ if (hopsLeft > 0) {
1423
+ const neighbors = new Set();
1424
+ for (const l of forward)
1425
+ neighbors.add(l.to_doc);
1426
+ for (const b of backlinks)
1427
+ neighbors.add(b.from_doc);
1428
+ for (const n of neighbors) {
1429
+ if (!seen.has(n))
1430
+ visit(n, hopsLeft - 1);
1431
+ }
1432
+ }
1433
+ }
1434
+ visit(docId, maxDepth - 1);
1435
+ return { content: [{ type: 'text', text: JSON.stringify(nodes) }] };
1436
+ },
1437
+ },
1296
1438
  ];
1297
1439
  /** Live MCP server instance — used to register plugin tools dynamically. */
1298
1440
  let mcpServerInstance = null;