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.
- package/dist/client/assets/index-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/server/mcp.js
CHANGED
|
@@ -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 {
|
|
17
|
-
import {
|
|
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,
|
|
24
|
+
import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
|
|
24
25
|
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
25
|
-
import {
|
|
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 =
|
|
127
|
-
const {
|
|
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}
|
|
131
|
-
if (
|
|
132
|
-
hint += `\n[${
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1162
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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: '
|
|
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
|
|
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
|
|
1213
|
-
|
|
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: '
|
|
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
|
|
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 =
|
|
1236
|
-
// Broadcast to browser so decorations update
|
|
1367
|
+
const resolved = resolveComments(mark_ids);
|
|
1237
1368
|
const activeFile = getActiveFilename();
|
|
1238
|
-
|
|
1369
|
+
broadcastCommentsChanged(activeFile);
|
|
1239
1370
|
return {
|
|
1240
1371
|
content: [{
|
|
1241
1372
|
type: 'text',
|
|
1242
1373
|
text: resolved.length > 0
|
|
1243
|
-
? `Resolved ${resolved.length}
|
|
1244
|
-
: 'No matching
|
|
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
|
|
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
|
|
1304
|
-
|
|
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
|
-
|
|
1310
|
-
|
|
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(
|
|
1516
|
+
walk(sourceDoc.content);
|
|
1329
1517
|
if (!sourceNodeId) {
|
|
1330
|
-
|
|
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
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
href
|
|
1338
|
-
|
|
1339
|
-
|
|
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
|
-
|
|
1347
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
//
|
|
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
|
}
|