openwriter 0.14.0 → 0.15.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.
@@ -10,19 +10,20 @@ 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, isAutoAcceptActive, } from './state.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, } from './state.js';
14
14
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments } from './documents.js';
15
15
  import { extractForwardLinks } from './backlinks.js';
16
- import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
17
- import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
16
+ import { logger, generateRequestId, withRequestId } from './logger.js';
17
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
18
+ import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
18
19
  import { findDocNode } from './workspace-tree.js';
19
20
  import { importGoogleDoc } from './gdoc-import.js';
20
21
  import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
21
22
  import matter from 'gray-matter';
22
23
  import { getUpdateInfo } from './update-check.js';
23
- import { listVersions, forceSnapshot, restoreVersion } from './versions.js';
24
+ import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
24
25
  import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
25
- import { getMarks, getMarkCount, getGlobalMarkSummary, resolveMarks } from './marks.js';
26
+ import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
26
27
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
27
28
  /** Map a content type string to its frontmatter metadata object. */
28
29
  function resolveTypeMeta(type, url) {
@@ -113,6 +114,75 @@ function resolveDocTarget(docId) {
113
114
  lastModified: statSync(filePath).mtime,
114
115
  };
115
116
  }
117
+ /** Human-friendly relative time for ISO timestamps. */
118
+ function relativeTime(iso) {
119
+ const then = new Date(iso).getTime();
120
+ if (!isFinite(then))
121
+ return '';
122
+ const diff = Date.now() - then;
123
+ if (diff < 0)
124
+ return 'just now';
125
+ const sec = Math.round(diff / 1000);
126
+ if (sec < 60)
127
+ return 'just now';
128
+ const min = Math.round(sec / 60);
129
+ if (min < 60)
130
+ return `${min}m ago`;
131
+ const hr = Math.round(min / 60);
132
+ if (hr < 24)
133
+ return `${hr}h ago`;
134
+ const day = Math.round(hr / 24);
135
+ if (day < 14)
136
+ return `${day}d ago`;
137
+ const wk = Math.round(day / 7);
138
+ if (wk < 8)
139
+ return `${wk}w ago`;
140
+ const mo = Math.round(day / 30);
141
+ if (mo < 12)
142
+ return `${mo}mo ago`;
143
+ return `${Math.round(day / 365)}y ago`;
144
+ }
145
+ /** Apply a scope filter to the comments index. Returns the matching comments
146
+ * grouped by file plus a human-readable label for the scope. */
147
+ function gatherComments(filename, scope) {
148
+ const effectiveScope = scope ?? (filename ? 'workspace' : 'all');
149
+ if (effectiveScope === 'document' && filename) {
150
+ return { byFile: getComments(filename), scopeLabel: `document "${filename}"` };
151
+ }
152
+ if (effectiveScope === 'workspace' && filename) {
153
+ const containing = findWorkspacesContainingDoc(filename);
154
+ if (containing.length === 0) {
155
+ // Doc isn't filed in any workspace — fall back to single-doc scope.
156
+ return { byFile: getComments(filename), scopeLabel: `document "${filename}" (not in any workspace)` };
157
+ }
158
+ const ws = containing[0];
159
+ const filesInWs = collectFilesInWorkspace(ws.filename);
160
+ const byFile = {};
161
+ for (const f of filesInWs) {
162
+ const docComments = getComments(f);
163
+ if (docComments[f] && docComments[f].length > 0)
164
+ byFile[f] = docComments[f];
165
+ }
166
+ return { byFile, scopeLabel: `workspace "${ws.title}" (${filesInWs.length} docs)` };
167
+ }
168
+ return { byFile: getComments(), scopeLabel: 'all documents' };
169
+ }
170
+ function formatCommentsOutput({ byFile, scopeLabel }) {
171
+ const entries = Object.entries(byFile);
172
+ if (entries.length === 0)
173
+ return `No comments found in ${scopeLabel}.`;
174
+ const total = entries.reduce((sum, [, list]) => sum + list.length, 0);
175
+ const lines = [`Comments in ${scopeLabel} — ${total} total:`];
176
+ for (const [file, comments] of entries) {
177
+ lines.push(`\n${file}:`);
178
+ for (const c of comments) {
179
+ const notePart = c.note ? ` — "${c.note}"` : '';
180
+ const age = c.createdAt ? ` [${relativeTime(c.createdAt)}]` : '';
181
+ lines.push(` [${c.id}] "${c.text}"${notePart}${age} (node:${c.nodeId})`);
182
+ }
183
+ }
184
+ return lines.join('\n');
185
+ }
116
186
  export const TOOL_REGISTRY = [
117
187
  {
118
188
  name: 'read_pad',
@@ -123,15 +193,15 @@ export const TOOL_REGISTRY = [
123
193
  handler: async ({ docId }) => {
124
194
  const target = resolveDocTarget(docId);
125
195
  const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
126
- const localCount = getMarkCount(target.filename);
127
- const { totalMarks: otherMarks, docCount: otherDocs } = getGlobalMarkSummary(target.filename);
196
+ const localCount = getCommentCount(target.filename);
197
+ const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
128
198
  let hint = '';
129
199
  if (localCount > 0)
130
- hint += `\n[${localCount} agent mark${localCount !== 1 ? 's' : ''} on this document]`;
131
- if (otherMarks > 0)
132
- hint += `\n[${otherMarks} agent mark${otherMarks !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
200
+ hint += `\n[${localCount} comment${localCount !== 1 ? 's' : ''} on this document]`;
201
+ if (otherCount > 0)
202
+ hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
133
203
  if (hint)
134
- hint += '\n[call get_agent_marks to review]';
204
+ hint += '\n[call get_comments to review]';
135
205
  return { content: [{ type: 'text', text: compact + hint }] };
136
206
  },
137
207
  },
@@ -226,6 +296,22 @@ export const TOOL_REGISTRY = [
226
296
  // so the agent stops waiting for review when it's on.
227
297
  if (isAutoAcceptActive(target.filename, target.metadata))
228
298
  status.autoAccept = true;
299
+ // External-write drift: only meaningful for the active doc (non-active
300
+ // docs are read fresh from disk on each access). If the file's on-disk
301
+ // mtime differs from what we loaded, an external writer modified the
302
+ // file — the next save would be blocked by the guard. Surface this so
303
+ // agents can call reload_from_disk before re-attempting a write.
304
+ // adr: adr/external-write-guard.md
305
+ if (target.isActive) {
306
+ const drift = getExternalMtimeDrift();
307
+ if (drift) {
308
+ status.externalWriteDetected = {
309
+ diskMtime: new Date(drift.diskMtime).toISOString(),
310
+ loadedMtime: new Date(drift.loadedMtime).toISOString(),
311
+ note: 'File modified externally. Call reload_from_disk before writing or your changes will be blocked.',
312
+ };
313
+ }
314
+ }
229
315
  const latestVersion = getUpdateInfo();
230
316
  const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
231
317
  return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
@@ -317,8 +403,8 @@ export const TOOL_REGISTRY = [
317
403
  try {
318
404
  if (empty) {
319
405
  // Immediate switch — no spinner, no populate_document needed
320
- setAgentLock();
321
406
  const result = createDocument(title, undefined, path);
407
+ setAgentLock(result.filename);
322
408
  // Apply type-specific metadata
323
409
  if (content_type) {
324
410
  const typeMeta = resolveTypeMeta(content_type, url);
@@ -418,7 +504,7 @@ export const TOOL_REGISTRY = [
418
504
  // Active target (or no filename): existing flow.
419
505
  // Skip pending tagging when autoAccept is effectively on (doc flag or
420
506
  // inherited from workspace/container) — content commits directly.
421
- setAgentLock(); // Block browser doc-updates during population
507
+ setAgentLock(filename || getActiveFilename()); // Block browser doc-updates during population
422
508
  if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
423
509
  markAllNodesAsPending(doc, 'insert');
424
510
  }
@@ -1075,7 +1161,7 @@ export const TOOL_REGISTRY = [
1075
1161
  }
1076
1162
  updateDocument(doc);
1077
1163
  save();
1078
- setAgentLock();
1164
+ setAgentLockActive();
1079
1165
  broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
1080
1166
  return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
1081
1167
  }
@@ -1153,27 +1239,45 @@ export const TOOL_REGISTRY = [
1153
1239
  },
1154
1240
  handler: async ({ docId, timestamp }) => {
1155
1241
  const target = resolveDocTarget(docId);
1156
- // Safety net: snapshot current state before restoring
1242
+ // Safety checkpoint = the current canonical state. Under the layered
1243
+ // model, disk is already canonical (pending lives in the sidecar
1244
+ // overlay, not in the .md), so forceSnapshot of the current file is
1245
+ // a clean recovery point. The canonical-only-clone special case is
1246
+ // no longer needed.
1247
+ // adr: adr/pending-overlay-model.md
1157
1248
  try {
1158
1249
  forceSnapshot(target.docId, target.filePath);
1159
1250
  }
1160
1251
  catch { /* best effort */ }
1161
- const parsed = restoreVersion(target.docId, timestamp);
1162
- if (!parsed)
1252
+ // Read the target snapshot's content
1253
+ const snapshotMarkdown = getVersionContent(target.docId, timestamp);
1254
+ if (!snapshotMarkdown)
1163
1255
  return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
1256
+ // Write the snapshot directly to disk — this becomes the new canonical.
1257
+ // The pending overlay sidecar is unchanged; on reload, the matcher
1258
+ // re-pairs nodeIds and pending decorations re-attach where possible.
1259
+ // Pending entries whose anchors disappeared become orphan-inserts.
1260
+ atomicWriteFileSync(target.filePath, snapshotMarkdown);
1164
1261
  if (target.isActive) {
1165
- updateDocument(parsed.document);
1166
- save();
1167
- broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
1262
+ // Unified reload pathway — same code path as external-write reload
1263
+ // and explicit reload_from_disk. Re-reads canonical + applies
1264
+ // sidecar overlay + classifies orphans/stale-baseline.
1265
+ const reloaded = reloadActiveDocFromDisk();
1266
+ if (!reloaded)
1267
+ return { content: [{ type: 'text', text: `Error: Failed to reload after restore.` }] };
1268
+ broadcastDocumentSwitched(reloaded.document, reloaded.title, reloaded.filename);
1269
+ broadcastPendingDocsChanged();
1270
+ const summary = reloaded.orphans.length > 0 || reloaded.staleBaseline.length > 0
1271
+ ? ` (${reloaded.orphans.length} orphan, ${reloaded.staleBaseline.length} stale-baseline pending)` : '';
1272
+ return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()}${summary}` }] };
1168
1273
  }
1169
1274
  else {
1170
- // Write restored content to file without switching active doc
1171
- const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
1172
- atomicWriteFileSync(target.filePath, markdown);
1173
1275
  invalidateDocCache(target.filePath);
1276
+ removePendingCacheEntry(target.filename);
1174
1277
  broadcastDocumentsChanged();
1278
+ broadcastPendingDocsChanged();
1279
+ return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()}` }] };
1175
1280
  }
1176
- return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
1177
1281
  },
1178
1282
  },
1179
1283
  {
@@ -1187,12 +1291,19 @@ export const TOOL_REGISTRY = [
1187
1291
  if (!existsSync(target.filePath))
1188
1292
  return { content: [{ type: 'text', text: `Error: File not found: ${target.filePath}` }] };
1189
1293
  if (target.isActive) {
1190
- const markdown = readFileSync(target.filePath, 'utf-8');
1191
- const parsed = markdownToTiptap(markdown);
1192
- updateDocument(parsed.document);
1193
- save();
1194
- broadcastDocumentSwitched(parsed.document, parsed.title, target.filename);
1195
- return { content: [{ type: 'text', text: `Reloaded "${parsed.title}" from disk` }] };
1294
+ // Unified reload pathway — same as the fs.watch handler and
1295
+ // restore_version. Re-parses canonical + re-applies the sidecar
1296
+ // overlay (preserves pending changes by nodeId) + classifies any
1297
+ // orphans / stale-baseline entries + restamps loadedMtime.
1298
+ // adr: adr/pending-overlay-model.md · adr: adr/active-doc-watcher.md
1299
+ const reloaded = reloadActiveDocFromDisk();
1300
+ if (!reloaded)
1301
+ return { content: [{ type: 'text', text: `Error: Failed to reload from disk.` }] };
1302
+ broadcastDocumentSwitched(reloaded.document, reloaded.title, reloaded.filename);
1303
+ broadcastPendingDocsChanged();
1304
+ const summary = reloaded.orphans.length > 0 || reloaded.staleBaseline.length > 0
1305
+ ? ` (${reloaded.orphans.length} orphan, ${reloaded.staleBaseline.length} stale-baseline pending)` : '';
1306
+ return { content: [{ type: 'text', text: `Reloaded "${reloaded.title}" from disk${summary}` }] };
1196
1307
  }
1197
1308
  else {
1198
1309
  // Non-active: just invalidate cache so next access re-reads from disk
@@ -1201,47 +1312,67 @@ export const TOOL_REGISTRY = [
1201
1312
  }
1202
1313
  },
1203
1314
  },
1315
+ {
1316
+ name: 'get_comments',
1317
+ description: 'Get inline reader comments left by the user. Users select text in the editor, right-click → Comment, and leave a note for the agent. Returns comments grouped by document with text, note, nodeId, and a relative age. Default scope is "workspace" when docId is provided — comments for every doc in the same project. Pass scope:"document" to narrow to one doc, or scope:"all" to span every document on disk. Call resolve_comments after addressing each comment.',
1318
+ schema: {
1319
+ docId: z.string().optional().describe('Target document by docId (8-char hex). When provided, default scope is "workspace".'),
1320
+ scope: z.enum(['workspace', 'document', 'all']).optional().describe('Filter scope. Default: "workspace" when docId is given, otherwise "all".'),
1321
+ },
1322
+ handler: async ({ docId, scope }) => {
1323
+ const filename = docId ? resolveDocId(docId) : undefined;
1324
+ const resolved = gatherComments(filename, scope);
1325
+ return { content: [{ type: 'text', text: formatCommentsOutput(resolved) }] };
1326
+ },
1327
+ },
1328
+ {
1329
+ name: 'resolve_comments',
1330
+ description: 'Remove comments after addressing the user\'s feedback. Pass the comment IDs from get_comments. Decorations clear in the browser immediately.',
1331
+ schema: {
1332
+ comment_ids: z.array(z.string()).describe('Array of comment IDs to resolve'),
1333
+ },
1334
+ handler: async ({ comment_ids }) => {
1335
+ const resolved = resolveComments(comment_ids);
1336
+ const activeFile = getActiveFilename();
1337
+ broadcastCommentsChanged(activeFile);
1338
+ return {
1339
+ content: [{
1340
+ type: 'text',
1341
+ text: resolved.length > 0
1342
+ ? `Resolved ${resolved.length} comment${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
1343
+ : 'No matching comments found.',
1344
+ }],
1345
+ };
1346
+ },
1347
+ },
1204
1348
  {
1205
1349
  name: 'get_agent_marks',
1206
- description: 'Get inline feedback marks left by the user. Users select text in the editor, right-click → Agent Mark, and leave notes for the agent. Returns marks grouped by document with text, note, and nodeId. Call resolve_agent_marks after addressing each mark.',
1350
+ description: 'DEPRECATED renamed to get_comments. Use get_comments instead. This alias may be removed in a future release.',
1207
1351
  schema: {
1208
- docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get marks across all documents.'),
1352
+ docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get comments across all documents.'),
1209
1353
  },
1210
1354
  handler: async ({ docId }) => {
1211
1355
  const filename = docId ? resolveDocId(docId) : undefined;
1212
- const marks = getMarks(filename);
1213
- const entries = Object.entries(marks);
1214
- if (entries.length === 0) {
1215
- return { content: [{ type: 'text', text: 'No agent marks found.' }] };
1216
- }
1217
- const lines = [];
1218
- for (const [file, fileMarks] of entries) {
1219
- lines.push(`${file}:`);
1220
- for (const m of fileMarks) {
1221
- const notePart = m.note ? ` — "${m.note}"` : '';
1222
- lines.push(` [${m.id}] "${m.text}"${notePart} (node:${m.nodeId})`);
1223
- }
1224
- }
1225
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1356
+ const resolved = gatherComments(filename, undefined);
1357
+ return { content: [{ type: 'text', text: formatCommentsOutput(resolved) }] };
1226
1358
  },
1227
1359
  },
1228
1360
  {
1229
1361
  name: 'resolve_agent_marks',
1230
- description: 'Remove agent marks after addressing the user\'s feedback. Pass the mark IDs from get_agent_marks. Decorations clear in the browser immediately.',
1362
+ description: 'DEPRECATED renamed to resolve_comments. Use resolve_comments instead. This alias may be removed in a future release.',
1231
1363
  schema: {
1232
- mark_ids: z.array(z.string()).describe('Array of mark IDs to resolve'),
1364
+ mark_ids: z.array(z.string()).describe('Array of comment IDs to resolve'),
1233
1365
  },
1234
1366
  handler: async ({ mark_ids }) => {
1235
- const resolved = resolveMarks(mark_ids);
1236
- // Broadcast to browser so decorations update
1367
+ const resolved = resolveComments(mark_ids);
1237
1368
  const activeFile = getActiveFilename();
1238
- broadcastMarksChanged(activeFile);
1369
+ broadcastCommentsChanged(activeFile);
1239
1370
  return {
1240
1371
  content: [{
1241
1372
  type: 'text',
1242
1373
  text: resolved.length > 0
1243
- ? `Resolved ${resolved.length} mark${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
1244
- : 'No matching marks found.',
1374
+ ? `Resolved ${resolved.length} comment${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
1375
+ : 'No matching comments found.',
1245
1376
  }],
1246
1377
  };
1247
1378
  },
@@ -1298,17 +1429,68 @@ export const TOOL_REGISTRY = [
1298
1429
  },
1299
1430
  {
1300
1431
  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.',
1432
+ 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.',
1302
1433
  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).'),
1434
+ 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.'),
1435
+ 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.'),
1436
+ target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
1305
1437
  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
1438
  quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
1307
1439
  },
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();
1440
+ handler: async ({ text, source_doc_id, target_doc_id, target_node_id, quote }) => {
1441
+ const sourceFilename = resolveDocId(source_doc_id);
1442
+ if (!sourceFilename) {
1443
+ return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
1444
+ }
1445
+ // Build the href in canonical doc:DOCID#NODEID?q=quote form so we can also
1446
+ // detect "this text is already wrapped with THIS link" and skip it.
1447
+ let href = `doc:${target_doc_id}`;
1448
+ if (target_node_id)
1449
+ href += `#${target_node_id}`;
1450
+ if (quote)
1451
+ href += `?q=${encodeURIComponent(quote)}`;
1452
+ // Load the source doc — from in-memory state if it's active, from disk
1453
+ // otherwise. Explicit source dispatch prevents the active-doc race where
1454
+ // a user click in the browser silently changes which doc gets edited.
1455
+ const sourceIsActive = sourceFilename === getActiveFilename();
1456
+ let sourceDoc;
1457
+ if (sourceIsActive) {
1458
+ sourceDoc = getDocument();
1459
+ }
1460
+ else {
1461
+ const cached = getCachedDocument(resolveDocPath(sourceFilename));
1462
+ if (cached) {
1463
+ sourceDoc = cached.document;
1464
+ }
1465
+ else {
1466
+ try {
1467
+ const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
1468
+ sourceDoc = markdownToTiptap(raw).document;
1469
+ }
1470
+ catch (err) {
1471
+ return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
1472
+ }
1473
+ }
1474
+ }
1475
+ // Locate the first block containing the anchor text WHERE the text is
1476
+ // not already entirely wrapped with a link to the same href. This makes
1477
+ // link_to idempotent and lets repeat calls wrap successive occurrences.
1311
1478
  let sourceNodeId = null;
1479
+ let totalOccurrences = 0;
1480
+ let alreadyLinkedOccurrences = 0;
1481
+ function isTextAlreadyLinked(nodeContent) {
1482
+ // Concatenate text from inline children that have a link mark matching href
1483
+ let linkedText = '';
1484
+ for (const child of nodeContent) {
1485
+ if (child.type !== 'text' || !child.text)
1486
+ continue;
1487
+ const marks = child.marks || [];
1488
+ const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
1489
+ if (hasMatchingLink)
1490
+ linkedText += child.text;
1491
+ }
1492
+ return linkedText.includes(text);
1493
+ }
1312
1494
  function walk(nodes) {
1313
1495
  if (sourceNodeId)
1314
1496
  return;
@@ -1318,6 +1500,12 @@ export const TOOL_REGISTRY = [
1318
1500
  if (Array.isArray(node.content)) {
1319
1501
  const blockText = node.content.map((c) => c.text || '').join('');
1320
1502
  if (node.attrs?.id && blockText.includes(text)) {
1503
+ totalOccurrences++;
1504
+ if (isTextAlreadyLinked(node.content)) {
1505
+ alreadyLinkedOccurrences++;
1506
+ walk(node.content);
1507
+ continue;
1508
+ }
1321
1509
  sourceNodeId = node.attrs.id;
1322
1510
  return;
1323
1511
  }
@@ -1325,26 +1513,32 @@ export const TOOL_REGISTRY = [
1325
1513
  }
1326
1514
  }
1327
1515
  }
1328
- walk(doc.content);
1516
+ walk(sourceDoc.content);
1329
1517
  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.` }] };
1518
+ if (totalOccurrences > 0 && totalOccurrences === alreadyLinkedOccurrences) {
1519
+ 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.` }] };
1520
+ }
1521
+ 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.` }] };
1331
1522
  }
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}` }] };
1523
+ // Apply the link mark in place. Dispatch by active vs non-active so the
1524
+ // edit always lands in the right doc — never silently in whatever doc
1525
+ // happens to be foregrounded in the browser.
1526
+ const editResult = sourceIsActive
1527
+ ? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
1528
+ : applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
1529
+ if (!editResult.success) {
1530
+ return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
1345
1531
  }
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.` }] };
1532
+ if (sourceIsActive)
1533
+ save(); // triggers writeToDisk backlinks pipeline updates target's frontmatter
1534
+ return { content: [{ type: 'text', text: JSON.stringify({
1535
+ success: true,
1536
+ sourceDocId: source_doc_id,
1537
+ sourceFilename,
1538
+ nodeId: sourceNodeId,
1539
+ href,
1540
+ ...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
1541
+ }) }] };
1348
1542
  },
1349
1543
  },
1350
1544
  {
@@ -1505,8 +1699,26 @@ export async function startMcpServer() {
1505
1699
  name: 'openwriter',
1506
1700
  version: '0.2.0',
1507
1701
  });
1702
+ // Wrap each tool handler in withRequestId so every event logged during
1703
+ // the tool's execution inherits the same request ID. Trace one MCP call
1704
+ // through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
1705
+ // adr: adr/logging-system.md
1508
1706
  for (const tool of TOOL_REGISTRY) {
1509
- server.tool(tool.name, tool.description, tool.schema, tool.handler);
1707
+ const wrappedHandler = async (args) => {
1708
+ const reqId = generateRequestId(`mcp-${tool.name}`);
1709
+ return await withRequestId(reqId, async () => {
1710
+ logger.debug('mcp', 'tool-call', tool.name, { tool: tool.name });
1711
+ try {
1712
+ const result = await tool.handler(args);
1713
+ return result;
1714
+ }
1715
+ catch (err) {
1716
+ logger.error('mcp', 'tool-error', `${tool.name}: ${err.message}`, { tool: tool.name }, err);
1717
+ throw err;
1718
+ }
1719
+ });
1720
+ };
1721
+ server.tool(tool.name, tool.description, tool.schema, wrappedHandler);
1510
1722
  }
1511
1723
  mcpServerInstance = server;
1512
1724
  const transport = new StdioServerTransport();
@@ -51,6 +51,7 @@ function walkNodes(nodes, blocks, parentPosition) {
51
51
  parentPosition,
52
52
  ordinalInParent: ordinalInParent++,
53
53
  inlineMarks: countInlineMarks(node.content || []),
54
+ id: node.attrs?.id,
54
55
  });
55
56
  }
56
57
  else if (node.type === 'paragraph') {
@@ -62,6 +63,7 @@ function walkNodes(nodes, blocks, parentPosition) {
62
63
  parentPosition,
63
64
  ordinalInParent: ordinalInParent++,
64
65
  inlineMarks: countInlineMarks(node.content || []),
66
+ id: node.attrs?.id,
65
67
  });
66
68
  }
67
69
  else if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
@@ -72,6 +74,7 @@ function walkNodes(nodes, blocks, parentPosition) {
72
74
  text: '',
73
75
  parentPosition,
74
76
  ordinalInParent: ordinalInParent++,
77
+ id: node.attrs?.id,
75
78
  });
76
79
  walkNodes(node.content || [], blocks, containerPosition);
77
80
  }
@@ -86,6 +89,7 @@ function walkNodes(nodes, blocks, parentPosition) {
86
89
  text: firstParaText,
87
90
  parentPosition,
88
91
  ordinalInParent: ordinalInParent++,
92
+ id: node.attrs?.id,
89
93
  });
90
94
  walkNodes(node.content || [], blocks, itemPosition);
91
95
  }
@@ -97,6 +101,7 @@ function walkNodes(nodes, blocks, parentPosition) {
97
101
  text: '',
98
102
  parentPosition,
99
103
  ordinalInParent: ordinalInParent++,
104
+ id: node.attrs?.id,
100
105
  });
101
106
  walkNodes(node.content || [], blocks, bqPosition);
102
107
  }
@@ -109,6 +114,7 @@ function walkNodes(nodes, blocks, parentPosition) {
109
114
  text,
110
115
  parentPosition,
111
116
  ordinalInParent: ordinalInParent++,
117
+ id: node.attrs?.id,
112
118
  });
113
119
  }
114
120
  else if (node.type === 'horizontalRule') {
@@ -118,6 +124,7 @@ function walkNodes(nodes, blocks, parentPosition) {
118
124
  text: '',
119
125
  parentPosition,
120
126
  ordinalInParent: ordinalInParent++,
127
+ id: node.attrs?.id,
121
128
  });
122
129
  }
123
130
  else if (node.type === 'table') {
@@ -128,6 +135,7 @@ function walkNodes(nodes, blocks, parentPosition) {
128
135
  text: extractTableText(node),
129
136
  parentPosition,
130
137
  ordinalInParent: ordinalInParent++,
138
+ id: node.attrs?.id,
131
139
  });
132
140
  // Don't descend into table internals — the walker treats tables as opaque.
133
141
  }
@@ -138,6 +146,7 @@ function walkNodes(nodes, blocks, parentPosition) {
138
146
  text: node.attrs?.alt || '',
139
147
  parentPosition,
140
148
  ordinalInParent: ordinalInParent++,
149
+ id: node.attrs?.id,
141
150
  });
142
151
  }
143
152
  else if (CONTAINER_TYPES.has(node.type)) {
@@ -149,6 +158,7 @@ function walkNodes(nodes, blocks, parentPosition) {
149
158
  text: '',
150
159
  parentPosition,
151
160
  ordinalInParent: ordinalInParent++,
161
+ id: node.attrs?.id,
152
162
  });
153
163
  walkNodes(node.content || [], blocks, containerPosition);
154
164
  }
@@ -235,15 +245,23 @@ export function applyIdsToTiptap(doc, pinnedByPosition) {
235
245
  node.attrs.id = id;
236
246
  }
237
247
  position++;
238
- // Descend into container children (matches walkNodes behavior).
239
- // Tables and images are leaf-like in the walker; descend only for containers.
248
+ // Descend into container children, MIRRORING `walkNodes`/`tiptapToBlocks`.
249
+ // CRITICAL: tables are opaque to the walker (see `tiptapToBlocks`
250
+ // emits one Block per table, doesn't descend). `applyIdsToTiptap` MUST
251
+ // use the same descent rule, otherwise position counters diverge and a
252
+ // matcher pin meant for a top-level node gets applied to a node inside
253
+ // a table. That's exactly the table-row-stole-the-paragraph-id
254
+ // corruption pattern (e.g. `tr:3141ee2a` where 3141ee2a was the
255
+ // target-total paragraph's ID).
256
+ //
257
+ // Descend ONLY into types `walkNodes` recursively descends into.
258
+ // adr: adr/node-identity-matcher.md
240
259
  const isContainer = node.type === 'bulletList' ||
241
260
  node.type === 'orderedList' ||
242
261
  node.type === 'taskList' ||
243
262
  node.type === 'listItem' ||
244
263
  node.type === 'taskItem' ||
245
- node.type === 'blockquote' ||
246
- CONTAINER_TYPES.has(node.type);
264
+ node.type === 'blockquote';
247
265
  if (isContainer && node.content)
248
266
  walk(node.content);
249
267
  }