openwriter 0.14.0 → 0.16.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-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- 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,22 @@ 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,
|
|
14
|
-
import {
|
|
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, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, } from './state.js';
|
|
14
|
+
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
|
+
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
16
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
15
17
|
import { extractForwardLinks } from './backlinks.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
+
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
19
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
20
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
18
21
|
import { findDocNode } from './workspace-tree.js';
|
|
19
22
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
20
23
|
import { toCompactFormat, compactNodes, parseMarkdownContent, mergeParagraphsToHardBreaks } from './compact.js';
|
|
21
24
|
import matter from 'gray-matter';
|
|
22
25
|
import { getUpdateInfo } from './update-check.js';
|
|
23
|
-
import { listVersions, forceSnapshot,
|
|
26
|
+
import { listVersions, forceSnapshot, getVersionContent } from './versions.js';
|
|
24
27
|
import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
|
|
25
|
-
import {
|
|
28
|
+
import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
|
|
26
29
|
import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
27
30
|
/** Map a content type string to its frontmatter metadata object. */
|
|
28
31
|
function resolveTypeMeta(type, url) {
|
|
@@ -113,6 +116,75 @@ function resolveDocTarget(docId) {
|
|
|
113
116
|
lastModified: statSync(filePath).mtime,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
119
|
+
/** Human-friendly relative time for ISO timestamps. */
|
|
120
|
+
function relativeTime(iso) {
|
|
121
|
+
const then = new Date(iso).getTime();
|
|
122
|
+
if (!isFinite(then))
|
|
123
|
+
return '';
|
|
124
|
+
const diff = Date.now() - then;
|
|
125
|
+
if (diff < 0)
|
|
126
|
+
return 'just now';
|
|
127
|
+
const sec = Math.round(diff / 1000);
|
|
128
|
+
if (sec < 60)
|
|
129
|
+
return 'just now';
|
|
130
|
+
const min = Math.round(sec / 60);
|
|
131
|
+
if (min < 60)
|
|
132
|
+
return `${min}m ago`;
|
|
133
|
+
const hr = Math.round(min / 60);
|
|
134
|
+
if (hr < 24)
|
|
135
|
+
return `${hr}h ago`;
|
|
136
|
+
const day = Math.round(hr / 24);
|
|
137
|
+
if (day < 14)
|
|
138
|
+
return `${day}d ago`;
|
|
139
|
+
const wk = Math.round(day / 7);
|
|
140
|
+
if (wk < 8)
|
|
141
|
+
return `${wk}w ago`;
|
|
142
|
+
const mo = Math.round(day / 30);
|
|
143
|
+
if (mo < 12)
|
|
144
|
+
return `${mo}mo ago`;
|
|
145
|
+
return `${Math.round(day / 365)}y ago`;
|
|
146
|
+
}
|
|
147
|
+
/** Apply a scope filter to the comments index. Returns the matching comments
|
|
148
|
+
* grouped by file plus a human-readable label for the scope. */
|
|
149
|
+
function gatherComments(filename, scope) {
|
|
150
|
+
const effectiveScope = scope ?? (filename ? 'workspace' : 'all');
|
|
151
|
+
if (effectiveScope === 'document' && filename) {
|
|
152
|
+
return { byFile: getComments(filename), scopeLabel: `document "${filename}"` };
|
|
153
|
+
}
|
|
154
|
+
if (effectiveScope === 'workspace' && filename) {
|
|
155
|
+
const containing = findWorkspacesContainingDoc(filename);
|
|
156
|
+
if (containing.length === 0) {
|
|
157
|
+
// Doc isn't filed in any workspace — fall back to single-doc scope.
|
|
158
|
+
return { byFile: getComments(filename), scopeLabel: `document "${filename}" (not in any workspace)` };
|
|
159
|
+
}
|
|
160
|
+
const ws = containing[0];
|
|
161
|
+
const filesInWs = collectFilesInWorkspace(ws.filename);
|
|
162
|
+
const byFile = {};
|
|
163
|
+
for (const f of filesInWs) {
|
|
164
|
+
const docComments = getComments(f);
|
|
165
|
+
if (docComments[f] && docComments[f].length > 0)
|
|
166
|
+
byFile[f] = docComments[f];
|
|
167
|
+
}
|
|
168
|
+
return { byFile, scopeLabel: `workspace "${ws.title}" (${filesInWs.length} docs)` };
|
|
169
|
+
}
|
|
170
|
+
return { byFile: getComments(), scopeLabel: 'all documents' };
|
|
171
|
+
}
|
|
172
|
+
function formatCommentsOutput({ byFile, scopeLabel }) {
|
|
173
|
+
const entries = Object.entries(byFile);
|
|
174
|
+
if (entries.length === 0)
|
|
175
|
+
return `No comments found in ${scopeLabel}.`;
|
|
176
|
+
const total = entries.reduce((sum, [, list]) => sum + list.length, 0);
|
|
177
|
+
const lines = [`Comments in ${scopeLabel} — ${total} total:`];
|
|
178
|
+
for (const [file, comments] of entries) {
|
|
179
|
+
lines.push(`\n${file}:`);
|
|
180
|
+
for (const c of comments) {
|
|
181
|
+
const notePart = c.note ? ` — "${c.note}"` : '';
|
|
182
|
+
const age = c.createdAt ? ` [${relativeTime(c.createdAt)}]` : '';
|
|
183
|
+
lines.push(` [${c.id}] "${c.text}"${notePart}${age} (node:${c.nodeId})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
116
188
|
export const TOOL_REGISTRY = [
|
|
117
189
|
{
|
|
118
190
|
name: 'read_pad',
|
|
@@ -123,15 +195,15 @@ export const TOOL_REGISTRY = [
|
|
|
123
195
|
handler: async ({ docId }) => {
|
|
124
196
|
const target = resolveDocTarget(docId);
|
|
125
197
|
const compact = toCompactFormat(target.document, target.title, target.wordCount, target.pendingCount, target.docId, target.metadata);
|
|
126
|
-
const localCount =
|
|
127
|
-
const {
|
|
198
|
+
const localCount = getCommentCount(target.filename);
|
|
199
|
+
const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
|
|
128
200
|
let hint = '';
|
|
129
201
|
if (localCount > 0)
|
|
130
|
-
hint += `\n[${localCount}
|
|
131
|
-
if (
|
|
132
|
-
hint += `\n[${
|
|
202
|
+
hint += `\n[${localCount} comment${localCount !== 1 ? 's' : ''} on this document]`;
|
|
203
|
+
if (otherCount > 0)
|
|
204
|
+
hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
133
205
|
if (hint)
|
|
134
|
-
hint += '\n[call
|
|
206
|
+
hint += '\n[call get_comments to review]';
|
|
135
207
|
return { content: [{ type: 'text', text: compact + hint }] };
|
|
136
208
|
},
|
|
137
209
|
},
|
|
@@ -226,6 +298,22 @@ export const TOOL_REGISTRY = [
|
|
|
226
298
|
// so the agent stops waiting for review when it's on.
|
|
227
299
|
if (isAutoAcceptActive(target.filename, target.metadata))
|
|
228
300
|
status.autoAccept = true;
|
|
301
|
+
// External-write drift: only meaningful for the active doc (non-active
|
|
302
|
+
// docs are read fresh from disk on each access). If the file's on-disk
|
|
303
|
+
// mtime differs from what we loaded, an external writer modified the
|
|
304
|
+
// file — the next save would be blocked by the guard. Surface this so
|
|
305
|
+
// agents can call reload_from_disk before re-attempting a write.
|
|
306
|
+
// adr: adr/external-write-guard.md
|
|
307
|
+
if (target.isActive) {
|
|
308
|
+
const drift = getExternalMtimeDrift();
|
|
309
|
+
if (drift) {
|
|
310
|
+
status.externalWriteDetected = {
|
|
311
|
+
diskMtime: new Date(drift.diskMtime).toISOString(),
|
|
312
|
+
loadedMtime: new Date(drift.loadedMtime).toISOString(),
|
|
313
|
+
note: 'File modified externally. Call reload_from_disk before writing or your changes will be blocked.',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
229
317
|
const latestVersion = getUpdateInfo();
|
|
230
318
|
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
231
319
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
@@ -246,7 +334,7 @@ export const TOOL_REGISTRY = [
|
|
|
246
334
|
},
|
|
247
335
|
{
|
|
248
336
|
name: 'list_documents',
|
|
249
|
-
description: 'List all documents. Shows title, docId
|
|
337
|
+
description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline, domain, docRole) when present. Use the docId to target documents in other tools.',
|
|
250
338
|
schema: {},
|
|
251
339
|
handler: async () => {
|
|
252
340
|
const docs = listDocuments();
|
|
@@ -254,9 +342,21 @@ export const TOOL_REGISTRY = [
|
|
|
254
342
|
const active = d.isActive ? ' (active)' : '';
|
|
255
343
|
const id = d.docId ? ` [${d.docId}]` : '';
|
|
256
344
|
const date = d.lastModified.split('T')[0];
|
|
257
|
-
|
|
345
|
+
const enrichBits = [];
|
|
346
|
+
if (d.domain)
|
|
347
|
+
enrichBits.push(d.domain);
|
|
348
|
+
if (d.docRole)
|
|
349
|
+
enrichBits.push(d.docRole);
|
|
350
|
+
if (d.enrichmentStale === true)
|
|
351
|
+
enrichBits.push('STALE');
|
|
352
|
+
const enrichTag = enrichBits.length > 0 ? ` (${enrichBits.join(', ')})` : '';
|
|
353
|
+
const main = ` "${d.title}"${id}${active}${enrichTag} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
354
|
+
if (d.logline)
|
|
355
|
+
return `${main}\n → ${d.logline}`;
|
|
356
|
+
return main;
|
|
258
357
|
});
|
|
259
|
-
|
|
358
|
+
const footer = enrichmentFooter();
|
|
359
|
+
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
260
360
|
},
|
|
261
361
|
},
|
|
262
362
|
{
|
|
@@ -317,8 +417,8 @@ export const TOOL_REGISTRY = [
|
|
|
317
417
|
try {
|
|
318
418
|
if (empty) {
|
|
319
419
|
// Immediate switch — no spinner, no populate_document needed
|
|
320
|
-
setAgentLock();
|
|
321
420
|
const result = createDocument(title, undefined, path);
|
|
421
|
+
setAgentLock(result.filename);
|
|
322
422
|
// Apply type-specific metadata
|
|
323
423
|
if (content_type) {
|
|
324
424
|
const typeMeta = resolveTypeMeta(content_type, url);
|
|
@@ -418,7 +518,7 @@ export const TOOL_REGISTRY = [
|
|
|
418
518
|
// Active target (or no filename): existing flow.
|
|
419
519
|
// Skip pending tagging when autoAccept is effectively on (doc flag or
|
|
420
520
|
// inherited from workspace/container) — content commits directly.
|
|
421
|
-
setAgentLock(); // Block browser doc-updates during population
|
|
521
|
+
setAgentLock(filename || getActiveFilename()); // Block browser doc-updates during population
|
|
422
522
|
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
423
523
|
markAllNodesAsPending(doc, 'insert');
|
|
424
524
|
}
|
|
@@ -663,6 +763,117 @@ export const TOOL_REGISTRY = [
|
|
|
663
763
|
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
664
764
|
},
|
|
665
765
|
},
|
|
766
|
+
{
|
|
767
|
+
name: 'mark_enriched',
|
|
768
|
+
description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied enrichment fields, and clears enrichmentStale. The agent never touches the sentence-hash layer — openwriter computes the baseline from current canonical content. Accepts an array so a workspace-wide sweep is one call. See brief 2026-05-18-frontmatter-enrichment-system.',
|
|
769
|
+
schema: {
|
|
770
|
+
docs: z.array(z.object({
|
|
771
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
772
|
+
logline: z.string().optional().describe('One-sentence "what this doc is about" — ≤150 chars recommended.'),
|
|
773
|
+
domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
|
|
774
|
+
concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
|
|
775
|
+
docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
|
|
776
|
+
status: z.string().optional().describe('Doc status: draft / canonical / stale. Archive state is implied by archivedAt.'),
|
|
777
|
+
})).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array.'),
|
|
778
|
+
},
|
|
779
|
+
handler: async ({ docs }) => {
|
|
780
|
+
const now = new Date().toISOString();
|
|
781
|
+
const results = [];
|
|
782
|
+
let anyTitleSideEffect = false;
|
|
783
|
+
for (const item of docs) {
|
|
784
|
+
try {
|
|
785
|
+
const target = resolveDocTarget(item.docId);
|
|
786
|
+
// Harvest current sentence hashes + char count from canonical view.
|
|
787
|
+
// Active doc: getCanonical() returns the no-overlay primary state.
|
|
788
|
+
// Non-active: cloneWithPendingReverted on the cached/loaded document.
|
|
789
|
+
const canonical = target.isActive
|
|
790
|
+
? getCanonical()
|
|
791
|
+
: cloneWithPendingReverted(target.document);
|
|
792
|
+
const blocks = tiptapToBlocks(canonical);
|
|
793
|
+
const lastEnrichedSentences = harvestSentenceHashes(blocks);
|
|
794
|
+
const lastEnrichedCharCount = harvestCharCount(blocks);
|
|
795
|
+
// Build the atomic enrichment payload.
|
|
796
|
+
const update = {
|
|
797
|
+
lastEnrichedAt: now,
|
|
798
|
+
lastEnrichedCharCount,
|
|
799
|
+
lastEnrichedSentences,
|
|
800
|
+
enrichmentStale: false,
|
|
801
|
+
};
|
|
802
|
+
if (item.logline !== undefined)
|
|
803
|
+
update.logline = item.logline;
|
|
804
|
+
if (item.domain !== undefined)
|
|
805
|
+
update.domain = item.domain;
|
|
806
|
+
if (item.concepts !== undefined)
|
|
807
|
+
update.concepts = item.concepts;
|
|
808
|
+
if (item.docRole !== undefined)
|
|
809
|
+
update.docRole = item.docRole;
|
|
810
|
+
if (item.status !== undefined)
|
|
811
|
+
update.status = item.status;
|
|
812
|
+
if (target.isActive) {
|
|
813
|
+
// Active doc: setMetadata mutates state.metadata but doesn't bump
|
|
814
|
+
// docVersion on its own — without an explicit bump, save() would
|
|
815
|
+
// hit the no-op gate (docVersion === lastSavedDocVersion when
|
|
816
|
+
// there's no body change). bumpDocVersion forces save() through.
|
|
817
|
+
// writeToDisk's staleness check will see the just-stamped baseline
|
|
818
|
+
// (volumeRatio=1, drift=0) and NOT flip the flag back to true.
|
|
819
|
+
setMetadata(update);
|
|
820
|
+
bumpDocVersion();
|
|
821
|
+
save();
|
|
822
|
+
broadcastMetadataChanged(getMetadata());
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// Non-active: write directly to disk, bypassing flushDocToFile's
|
|
826
|
+
// staleness check (which would otherwise see stale state for one
|
|
827
|
+
// serialize cycle before the new baseline lands). Disk write +
|
|
828
|
+
// cache invalidation mirrors set_metadata's non-active path.
|
|
829
|
+
const newMeta = { ...target.metadata, ...update };
|
|
830
|
+
const markdown = tiptapToMarkdown(target.document, target.title, newMeta);
|
|
831
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
832
|
+
invalidateDocCache(target.filePath);
|
|
833
|
+
}
|
|
834
|
+
results.push({ docId: item.docId, ok: true });
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Single broadcast at the end so sidebar refreshes once for the whole batch.
|
|
841
|
+
broadcastDocumentsChanged();
|
|
842
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
843
|
+
const failCount = results.length - okCount;
|
|
844
|
+
const summary = failCount === 0
|
|
845
|
+
? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
|
|
846
|
+
: `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
847
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
name: 'list_dirty_docs',
|
|
852
|
+
description: 'List documents that need enrichment — never enriched (no lastEnrichedAt) or flagged stale by openwriter (drift/volume thresholds tripped). Returns identity + reason only; no enrichment fields, no bodies. The minion calls this first to know what to work on. Docs in opted-out workspaces (enrichmentDisabled: true) are excluded. See brief 2026-05-18-frontmatter-enrichment-system.',
|
|
853
|
+
schema: {
|
|
854
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
855
|
+
},
|
|
856
|
+
handler: async ({ workspaceFile }) => {
|
|
857
|
+
const docs = listDirtyDocs(workspaceFile);
|
|
858
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
name: 'crawl',
|
|
863
|
+
description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~150 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay.',
|
|
864
|
+
schema: {
|
|
865
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
866
|
+
domain: z.string().optional().describe('Exact domain match.'),
|
|
867
|
+
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
868
|
+
concepts: z.array(z.string()).optional().describe('Docs must reference ALL listed concepts.'),
|
|
869
|
+
docRole: z.string().optional().describe('Exact docRole match (canonical / vignette / reference / draft / chapter / beat).'),
|
|
870
|
+
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
871
|
+
},
|
|
872
|
+
handler: async (filter) => {
|
|
873
|
+
const docs = crawlDocs(filter);
|
|
874
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
875
|
+
},
|
|
876
|
+
},
|
|
666
877
|
{
|
|
667
878
|
name: 'list_workspaces',
|
|
668
879
|
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
@@ -670,7 +881,8 @@ export const TOOL_REGISTRY = [
|
|
|
670
881
|
handler: async () => {
|
|
671
882
|
const workspaces = listWorkspaces();
|
|
672
883
|
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
673
|
-
|
|
884
|
+
const footer = enrichmentFooter();
|
|
885
|
+
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
674
886
|
},
|
|
675
887
|
},
|
|
676
888
|
{
|
|
@@ -705,57 +917,136 @@ export const TOOL_REGISTRY = [
|
|
|
705
917
|
},
|
|
706
918
|
{
|
|
707
919
|
name: 'get_workspace_structure',
|
|
708
|
-
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc tags, plus context (characters, settings, rules). Use to understand workspace
|
|
920
|
+
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, domain, tags, docRole, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies.',
|
|
709
921
|
schema: {
|
|
710
922
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
711
923
|
},
|
|
712
924
|
handler: async ({ filename }) => {
|
|
713
925
|
const ws = getWorkspace(filename);
|
|
926
|
+
// Build a one-pass map of filename → frontmatter so we don't re-read each
|
|
927
|
+
// doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
|
|
928
|
+
const enriched = crawlDocs({ workspaceFile: filename });
|
|
929
|
+
const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
|
|
714
930
|
function renderTree(nodes, indent) {
|
|
715
931
|
const lines = [];
|
|
716
932
|
for (const node of nodes) {
|
|
717
933
|
if (node.type === 'doc') {
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
|
|
934
|
+
const e = enrichByFile.get(node.file);
|
|
935
|
+
const tags = e?.tags ?? [];
|
|
936
|
+
const enrichBits = [];
|
|
937
|
+
if (e?.domain)
|
|
938
|
+
enrichBits.push(e.domain);
|
|
939
|
+
if (e?.docRole)
|
|
940
|
+
enrichBits.push(e.docRole);
|
|
941
|
+
if (tags.length > 0)
|
|
942
|
+
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
943
|
+
if (e?.enrichmentStale === true)
|
|
944
|
+
enrichBits.push('STALE');
|
|
945
|
+
const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
|
|
946
|
+
const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
|
|
947
|
+
lines.push(docLine);
|
|
948
|
+
if (e?.logline)
|
|
949
|
+
lines.push(`${indent} → ${e.logline}`);
|
|
721
950
|
}
|
|
722
951
|
else {
|
|
723
|
-
|
|
952
|
+
const cBits = [];
|
|
953
|
+
if (node.role)
|
|
954
|
+
cBits.push(node.role);
|
|
955
|
+
const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
|
|
956
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
|
|
957
|
+
if (node.logline)
|
|
958
|
+
lines.push(`${indent} → ${node.logline}`);
|
|
724
959
|
lines.push(...renderTree(node.items, indent + ' '));
|
|
725
960
|
}
|
|
726
961
|
}
|
|
727
962
|
return lines;
|
|
728
963
|
}
|
|
729
964
|
const treeLines = renderTree(ws.root, ' ');
|
|
730
|
-
|
|
965
|
+
const headerBits = [`workspace: "${ws.title}"`];
|
|
966
|
+
if (ws.logline)
|
|
967
|
+
headerBits.push(`logline: ${ws.logline}`);
|
|
968
|
+
if (ws.domain)
|
|
969
|
+
headerBits.push(`domain: ${ws.domain}`);
|
|
970
|
+
if (ws.schema)
|
|
971
|
+
headerBits.push(`schema: ${ws.schema}`);
|
|
972
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
|
|
973
|
+
headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
|
|
974
|
+
}
|
|
975
|
+
if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
|
|
976
|
+
headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
|
|
977
|
+
}
|
|
978
|
+
if (ws.enrichmentDisabled === true)
|
|
979
|
+
headerBits.push('enrichment: disabled');
|
|
980
|
+
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
731
981
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
732
982
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
733
983
|
}
|
|
734
|
-
|
|
984
|
+
const footer = enrichmentFooter();
|
|
985
|
+
return { content: [{ type: 'text', text: `${text}${footer}` }] };
|
|
735
986
|
},
|
|
736
987
|
},
|
|
737
988
|
{
|
|
738
989
|
name: 'get_item_context',
|
|
739
|
-
description: 'Get progressive
|
|
990
|
+
description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline, domain, concepts, docRole, status), tags, and the enrichmentStale flag. Use before writing to understand context, or before reading to decide whether a body read is necessary.',
|
|
740
991
|
schema: {
|
|
741
992
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
742
993
|
docId: z.string().describe('Document docId (8-char hex from list_documents)'),
|
|
743
994
|
},
|
|
744
995
|
handler: async ({ workspaceFile, docId }) => {
|
|
745
996
|
const filename = resolveDocId(docId);
|
|
746
|
-
|
|
997
|
+
const base = getItemContext(workspaceFile, filename);
|
|
998
|
+
// Layer doc-level enrichment + workspace-level vocab/schema into the response.
|
|
999
|
+
// The agent's crawl pattern: get_item_context for one doc → see logline +
|
|
1000
|
+
// domain + concepts. Decide whether the doc warrants a body read.
|
|
1001
|
+
try {
|
|
1002
|
+
const ws = getWorkspace(workspaceFile);
|
|
1003
|
+
const enriched = crawlDocs({ workspaceFile });
|
|
1004
|
+
const docEnrich = enriched.find((e) => e.filename === filename);
|
|
1005
|
+
if (docEnrich) {
|
|
1006
|
+
if (docEnrich.logline)
|
|
1007
|
+
base.logline = docEnrich.logline;
|
|
1008
|
+
if (docEnrich.domain)
|
|
1009
|
+
base.domain = docEnrich.domain;
|
|
1010
|
+
if (docEnrich.concepts)
|
|
1011
|
+
base.concepts = docEnrich.concepts;
|
|
1012
|
+
if (docEnrich.docRole)
|
|
1013
|
+
base.docRole = docEnrich.docRole;
|
|
1014
|
+
if (docEnrich.status)
|
|
1015
|
+
base.status = docEnrich.status;
|
|
1016
|
+
if (docEnrich.enrichmentStale === true)
|
|
1017
|
+
base.enrichmentStale = true;
|
|
1018
|
+
}
|
|
1019
|
+
if (ws.schema)
|
|
1020
|
+
base.workspaceSchema = ws.schema;
|
|
1021
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0)
|
|
1022
|
+
base.workspaceVocab = ws.vocab;
|
|
1023
|
+
if (ws.logline)
|
|
1024
|
+
base.workspaceLogline = ws.logline;
|
|
1025
|
+
if (ws.domain)
|
|
1026
|
+
base.workspaceDomain = ws.domain;
|
|
1027
|
+
}
|
|
1028
|
+
catch { /* best-effort enrichment overlay */ }
|
|
1029
|
+
return { content: [{ type: 'text', text: JSON.stringify(base, null, 2) }] };
|
|
747
1030
|
},
|
|
748
1031
|
},
|
|
749
1032
|
{
|
|
750
1033
|
name: 'update_workspace_context',
|
|
751
|
-
description: 'Update
|
|
1034
|
+
description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus enrichment fields (logline, domain, schema, vocab, relatedWorkspaces, enrichmentVolumeThreshold, enrichmentDriftThreshold, enrichmentDisabled — set on workspace top-level). Pass `null` to clear an enrichment field. Use this to opt a workspace out of enrichment (enrichmentDisabled: true), declare a closed vocab for domain classification, or set workspace-level loglines/schemas. One tool covers writing context + enrichment config.',
|
|
752
1035
|
schema: {
|
|
753
1036
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
754
1037
|
context: z.object({
|
|
755
|
-
characters: z.record(z.string()).optional().describe('Character name → description'),
|
|
756
|
-
settings: z.record(z.string()).optional().describe('Setting name → description'),
|
|
757
|
-
rules: z.array(z.string()).optional().describe('Writing rules for this workspace'),
|
|
758
|
-
|
|
1038
|
+
characters: z.record(z.string()).optional().describe('Character name → description (merged)'),
|
|
1039
|
+
settings: z.record(z.string()).optional().describe('Setting name → description (merged)'),
|
|
1040
|
+
rules: z.array(z.string()).optional().describe('Writing rules for this workspace (replaces)'),
|
|
1041
|
+
logline: z.string().nullable().optional().describe('One-sentence "what this workspace is for". Set null to clear.'),
|
|
1042
|
+
domain: z.string().nullable().optional().describe('Subject area (e.g. "Male ethology"). Set null to clear.'),
|
|
1043
|
+
schema: z.string().nullable().optional().describe('Workspace kind: book / concept-library / inbox / social / reference. Set null to clear.'),
|
|
1044
|
+
vocab: z.array(z.string()).nullable().optional().describe('Closed list of valid domain names — Haiku classifies docs INTO these. Set null to clear (opens vocab to free-form).'),
|
|
1045
|
+
relatedWorkspaces: z.array(z.string()).nullable().optional().describe('Sibling workspace filenames. Set null to clear.'),
|
|
1046
|
+
enrichmentVolumeThreshold: z.number().nullable().optional().describe('Volume-ratio threshold (default 1.5). Set null to revert.'),
|
|
1047
|
+
enrichmentDriftThreshold: z.number().nullable().optional().describe('Jaccard-drift threshold (default 0.3). Set null to revert.'),
|
|
1048
|
+
enrichmentDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of enrichment surfacing. Set null or false to re-enable.'),
|
|
1049
|
+
}).describe('Writing context + enrichment config to apply'),
|
|
759
1050
|
},
|
|
760
1051
|
handler: async ({ workspaceFile, context }) => {
|
|
761
1052
|
updateWorkspaceContext(workspaceFile, context);
|
|
@@ -1075,7 +1366,7 @@ export const TOOL_REGISTRY = [
|
|
|
1075
1366
|
}
|
|
1076
1367
|
updateDocument(doc);
|
|
1077
1368
|
save();
|
|
1078
|
-
|
|
1369
|
+
setAgentLockActive();
|
|
1079
1370
|
broadcastDocumentSwitched(doc, getTitle(), getActiveFilename(), getMetadata());
|
|
1080
1371
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, src, lastNodeId: imgId }) }] };
|
|
1081
1372
|
}
|
|
@@ -1153,27 +1444,45 @@ export const TOOL_REGISTRY = [
|
|
|
1153
1444
|
},
|
|
1154
1445
|
handler: async ({ docId, timestamp }) => {
|
|
1155
1446
|
const target = resolveDocTarget(docId);
|
|
1156
|
-
// Safety
|
|
1447
|
+
// Safety checkpoint = the current canonical state. Under the layered
|
|
1448
|
+
// model, disk is already canonical (pending lives in the sidecar
|
|
1449
|
+
// overlay, not in the .md), so forceSnapshot of the current file is
|
|
1450
|
+
// a clean recovery point. The canonical-only-clone special case is
|
|
1451
|
+
// no longer needed.
|
|
1452
|
+
// adr: adr/pending-overlay-model.md
|
|
1157
1453
|
try {
|
|
1158
1454
|
forceSnapshot(target.docId, target.filePath);
|
|
1159
1455
|
}
|
|
1160
1456
|
catch { /* best effort */ }
|
|
1161
|
-
|
|
1162
|
-
|
|
1457
|
+
// Read the target snapshot's content
|
|
1458
|
+
const snapshotMarkdown = getVersionContent(target.docId, timestamp);
|
|
1459
|
+
if (!snapshotMarkdown)
|
|
1163
1460
|
return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
|
|
1461
|
+
// Write the snapshot directly to disk — this becomes the new canonical.
|
|
1462
|
+
// The pending overlay sidecar is unchanged; on reload, the matcher
|
|
1463
|
+
// re-pairs nodeIds and pending decorations re-attach where possible.
|
|
1464
|
+
// Pending entries whose anchors disappeared become orphan-inserts.
|
|
1465
|
+
atomicWriteFileSync(target.filePath, snapshotMarkdown);
|
|
1164
1466
|
if (target.isActive) {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1467
|
+
// Unified reload pathway — same code path as external-write reload
|
|
1468
|
+
// and explicit reload_from_disk. Re-reads canonical + applies
|
|
1469
|
+
// sidecar overlay + classifies orphans/stale-baseline.
|
|
1470
|
+
const reloaded = reloadActiveDocFromDisk();
|
|
1471
|
+
if (!reloaded)
|
|
1472
|
+
return { content: [{ type: 'text', text: `Error: Failed to reload after restore.` }] };
|
|
1473
|
+
broadcastDocumentSwitched(reloaded.document, reloaded.title, reloaded.filename);
|
|
1474
|
+
broadcastPendingDocsChanged();
|
|
1475
|
+
const summary = reloaded.orphans.length > 0 || reloaded.staleBaseline.length > 0
|
|
1476
|
+
? ` (${reloaded.orphans.length} orphan, ${reloaded.staleBaseline.length} stale-baseline pending)` : '';
|
|
1477
|
+
return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()}${summary}` }] };
|
|
1168
1478
|
}
|
|
1169
1479
|
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
1480
|
invalidateDocCache(target.filePath);
|
|
1481
|
+
removePendingCacheEntry(target.filename);
|
|
1174
1482
|
broadcastDocumentsChanged();
|
|
1483
|
+
broadcastPendingDocsChanged();
|
|
1484
|
+
return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()}` }] };
|
|
1175
1485
|
}
|
|
1176
|
-
return { content: [{ type: 'text', text: `Restored version from ${new Date(timestamp).toISOString()} — "${parsed.title}"` }] };
|
|
1177
1486
|
},
|
|
1178
1487
|
},
|
|
1179
1488
|
{
|
|
@@ -1187,12 +1496,19 @@ export const TOOL_REGISTRY = [
|
|
|
1187
1496
|
if (!existsSync(target.filePath))
|
|
1188
1497
|
return { content: [{ type: 'text', text: `Error: File not found: ${target.filePath}` }] };
|
|
1189
1498
|
if (target.isActive) {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1499
|
+
// Unified reload pathway — same as the fs.watch handler and
|
|
1500
|
+
// restore_version. Re-parses canonical + re-applies the sidecar
|
|
1501
|
+
// overlay (preserves pending changes by nodeId) + classifies any
|
|
1502
|
+
// orphans / stale-baseline entries + restamps loadedMtime.
|
|
1503
|
+
// adr: adr/pending-overlay-model.md · adr: adr/active-doc-watcher.md
|
|
1504
|
+
const reloaded = reloadActiveDocFromDisk();
|
|
1505
|
+
if (!reloaded)
|
|
1506
|
+
return { content: [{ type: 'text', text: `Error: Failed to reload from disk.` }] };
|
|
1507
|
+
broadcastDocumentSwitched(reloaded.document, reloaded.title, reloaded.filename);
|
|
1508
|
+
broadcastPendingDocsChanged();
|
|
1509
|
+
const summary = reloaded.orphans.length > 0 || reloaded.staleBaseline.length > 0
|
|
1510
|
+
? ` (${reloaded.orphans.length} orphan, ${reloaded.staleBaseline.length} stale-baseline pending)` : '';
|
|
1511
|
+
return { content: [{ type: 'text', text: `Reloaded "${reloaded.title}" from disk${summary}` }] };
|
|
1196
1512
|
}
|
|
1197
1513
|
else {
|
|
1198
1514
|
// Non-active: just invalidate cache so next access re-reads from disk
|
|
@@ -1201,47 +1517,67 @@ export const TOOL_REGISTRY = [
|
|
|
1201
1517
|
}
|
|
1202
1518
|
},
|
|
1203
1519
|
},
|
|
1520
|
+
{
|
|
1521
|
+
name: 'get_comments',
|
|
1522
|
+
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.',
|
|
1523
|
+
schema: {
|
|
1524
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). When provided, default scope is "workspace".'),
|
|
1525
|
+
scope: z.enum(['workspace', 'document', 'all']).optional().describe('Filter scope. Default: "workspace" when docId is given, otherwise "all".'),
|
|
1526
|
+
},
|
|
1527
|
+
handler: async ({ docId, scope }) => {
|
|
1528
|
+
const filename = docId ? resolveDocId(docId) : undefined;
|
|
1529
|
+
const resolved = gatherComments(filename, scope);
|
|
1530
|
+
return { content: [{ type: 'text', text: formatCommentsOutput(resolved) }] };
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
name: 'resolve_comments',
|
|
1535
|
+
description: 'Remove comments after addressing the user\'s feedback. Pass the comment IDs from get_comments. Decorations clear in the browser immediately.',
|
|
1536
|
+
schema: {
|
|
1537
|
+
comment_ids: z.array(z.string()).describe('Array of comment IDs to resolve'),
|
|
1538
|
+
},
|
|
1539
|
+
handler: async ({ comment_ids }) => {
|
|
1540
|
+
const resolved = resolveComments(comment_ids);
|
|
1541
|
+
const activeFile = getActiveFilename();
|
|
1542
|
+
broadcastCommentsChanged(activeFile);
|
|
1543
|
+
return {
|
|
1544
|
+
content: [{
|
|
1545
|
+
type: 'text',
|
|
1546
|
+
text: resolved.length > 0
|
|
1547
|
+
? `Resolved ${resolved.length} comment${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
|
|
1548
|
+
: 'No matching comments found.',
|
|
1549
|
+
}],
|
|
1550
|
+
};
|
|
1551
|
+
},
|
|
1552
|
+
},
|
|
1204
1553
|
{
|
|
1205
1554
|
name: 'get_agent_marks',
|
|
1206
|
-
description: '
|
|
1555
|
+
description: 'DEPRECATED — renamed to get_comments. Use get_comments instead. This alias may be removed in a future release.',
|
|
1207
1556
|
schema: {
|
|
1208
|
-
docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get
|
|
1557
|
+
docId: z.string().optional().describe('Target document by docId (8-char hex). Omit to get comments across all documents.'),
|
|
1209
1558
|
},
|
|
1210
1559
|
handler: async ({ docId }) => {
|
|
1211
1560
|
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') }] };
|
|
1561
|
+
const resolved = gatherComments(filename, undefined);
|
|
1562
|
+
return { content: [{ type: 'text', text: formatCommentsOutput(resolved) }] };
|
|
1226
1563
|
},
|
|
1227
1564
|
},
|
|
1228
1565
|
{
|
|
1229
1566
|
name: 'resolve_agent_marks',
|
|
1230
|
-
description: '
|
|
1567
|
+
description: 'DEPRECATED — renamed to resolve_comments. Use resolve_comments instead. This alias may be removed in a future release.',
|
|
1231
1568
|
schema: {
|
|
1232
|
-
mark_ids: z.array(z.string()).describe('Array of
|
|
1569
|
+
mark_ids: z.array(z.string()).describe('Array of comment IDs to resolve'),
|
|
1233
1570
|
},
|
|
1234
1571
|
handler: async ({ mark_ids }) => {
|
|
1235
|
-
const resolved =
|
|
1236
|
-
// Broadcast to browser so decorations update
|
|
1572
|
+
const resolved = resolveComments(mark_ids);
|
|
1237
1573
|
const activeFile = getActiveFilename();
|
|
1238
|
-
|
|
1574
|
+
broadcastCommentsChanged(activeFile);
|
|
1239
1575
|
return {
|
|
1240
1576
|
content: [{
|
|
1241
1577
|
type: 'text',
|
|
1242
1578
|
text: resolved.length > 0
|
|
1243
|
-
? `Resolved ${resolved.length}
|
|
1244
|
-
: 'No matching
|
|
1579
|
+
? `Resolved ${resolved.length} comment${resolved.length !== 1 ? 's' : ''}: ${resolved.join(', ')}`
|
|
1580
|
+
: 'No matching comments found.',
|
|
1245
1581
|
}],
|
|
1246
1582
|
};
|
|
1247
1583
|
},
|
|
@@ -1298,17 +1634,68 @@ export const TOOL_REGISTRY = [
|
|
|
1298
1634
|
},
|
|
1299
1635
|
{
|
|
1300
1636
|
name: 'link_to',
|
|
1301
|
-
description: 'Wrap anchor text in
|
|
1637
|
+
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
1638
|
schema: {
|
|
1303
|
-
text: z.string().describe('Anchor text in the
|
|
1304
|
-
|
|
1639
|
+
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.'),
|
|
1640
|
+
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.'),
|
|
1641
|
+
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
1642
|
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
1643
|
quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
|
|
1307
1644
|
},
|
|
1308
|
-
handler: async ({ text, target_doc_id, target_node_id, quote }) => {
|
|
1309
|
-
|
|
1310
|
-
|
|
1645
|
+
handler: async ({ text, source_doc_id, target_doc_id, target_node_id, quote }) => {
|
|
1646
|
+
const sourceFilename = resolveDocId(source_doc_id);
|
|
1647
|
+
if (!sourceFilename) {
|
|
1648
|
+
return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
|
|
1649
|
+
}
|
|
1650
|
+
// Build the href in canonical doc:DOCID#NODEID?q=quote form so we can also
|
|
1651
|
+
// detect "this text is already wrapped with THIS link" and skip it.
|
|
1652
|
+
let href = `doc:${target_doc_id}`;
|
|
1653
|
+
if (target_node_id)
|
|
1654
|
+
href += `#${target_node_id}`;
|
|
1655
|
+
if (quote)
|
|
1656
|
+
href += `?q=${encodeURIComponent(quote)}`;
|
|
1657
|
+
// Load the source doc — from in-memory state if it's active, from disk
|
|
1658
|
+
// otherwise. Explicit source dispatch prevents the active-doc race where
|
|
1659
|
+
// a user click in the browser silently changes which doc gets edited.
|
|
1660
|
+
const sourceIsActive = sourceFilename === getActiveFilename();
|
|
1661
|
+
let sourceDoc;
|
|
1662
|
+
if (sourceIsActive) {
|
|
1663
|
+
sourceDoc = getDocument();
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
const cached = getCachedDocument(resolveDocPath(sourceFilename));
|
|
1667
|
+
if (cached) {
|
|
1668
|
+
sourceDoc = cached.document;
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
try {
|
|
1672
|
+
const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
|
|
1673
|
+
sourceDoc = markdownToTiptap(raw).document;
|
|
1674
|
+
}
|
|
1675
|
+
catch (err) {
|
|
1676
|
+
return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
// Locate the first block containing the anchor text WHERE the text is
|
|
1681
|
+
// not already entirely wrapped with a link to the same href. This makes
|
|
1682
|
+
// link_to idempotent and lets repeat calls wrap successive occurrences.
|
|
1311
1683
|
let sourceNodeId = null;
|
|
1684
|
+
let totalOccurrences = 0;
|
|
1685
|
+
let alreadyLinkedOccurrences = 0;
|
|
1686
|
+
function isTextAlreadyLinked(nodeContent) {
|
|
1687
|
+
// Concatenate text from inline children that have a link mark matching href
|
|
1688
|
+
let linkedText = '';
|
|
1689
|
+
for (const child of nodeContent) {
|
|
1690
|
+
if (child.type !== 'text' || !child.text)
|
|
1691
|
+
continue;
|
|
1692
|
+
const marks = child.marks || [];
|
|
1693
|
+
const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
|
|
1694
|
+
if (hasMatchingLink)
|
|
1695
|
+
linkedText += child.text;
|
|
1696
|
+
}
|
|
1697
|
+
return linkedText.includes(text);
|
|
1698
|
+
}
|
|
1312
1699
|
function walk(nodes) {
|
|
1313
1700
|
if (sourceNodeId)
|
|
1314
1701
|
return;
|
|
@@ -1318,6 +1705,12 @@ export const TOOL_REGISTRY = [
|
|
|
1318
1705
|
if (Array.isArray(node.content)) {
|
|
1319
1706
|
const blockText = node.content.map((c) => c.text || '').join('');
|
|
1320
1707
|
if (node.attrs?.id && blockText.includes(text)) {
|
|
1708
|
+
totalOccurrences++;
|
|
1709
|
+
if (isTextAlreadyLinked(node.content)) {
|
|
1710
|
+
alreadyLinkedOccurrences++;
|
|
1711
|
+
walk(node.content);
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1321
1714
|
sourceNodeId = node.attrs.id;
|
|
1322
1715
|
return;
|
|
1323
1716
|
}
|
|
@@ -1325,26 +1718,32 @@ export const TOOL_REGISTRY = [
|
|
|
1325
1718
|
}
|
|
1326
1719
|
}
|
|
1327
1720
|
}
|
|
1328
|
-
walk(
|
|
1721
|
+
walk(sourceDoc.content);
|
|
1329
1722
|
if (!sourceNodeId) {
|
|
1330
|
-
|
|
1723
|
+
if (totalOccurrences > 0 && totalOccurrences === alreadyLinkedOccurrences) {
|
|
1724
|
+
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.` }] };
|
|
1725
|
+
}
|
|
1726
|
+
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
1727
|
}
|
|
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}` }] };
|
|
1728
|
+
// Apply the link mark in place. Dispatch by active vs non-active so the
|
|
1729
|
+
// edit always lands in the right doc — never silently in whatever doc
|
|
1730
|
+
// happens to be foregrounded in the browser.
|
|
1731
|
+
const editResult = sourceIsActive
|
|
1732
|
+
? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
|
|
1733
|
+
: applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
|
|
1734
|
+
if (!editResult.success) {
|
|
1735
|
+
return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
|
|
1345
1736
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1737
|
+
if (sourceIsActive)
|
|
1738
|
+
save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
|
|
1739
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1740
|
+
success: true,
|
|
1741
|
+
sourceDocId: source_doc_id,
|
|
1742
|
+
sourceFilename,
|
|
1743
|
+
nodeId: sourceNodeId,
|
|
1744
|
+
href,
|
|
1745
|
+
...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
|
|
1746
|
+
}) }] };
|
|
1348
1747
|
},
|
|
1349
1748
|
},
|
|
1350
1749
|
{
|
|
@@ -1357,16 +1756,29 @@ export const TOOL_REGISTRY = [
|
|
|
1357
1756
|
handler: async ({ query, limit = 10 }) => {
|
|
1358
1757
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1359
1758
|
const raw = searchDocuments(query);
|
|
1360
|
-
// Enrich with docId
|
|
1759
|
+
// Enrich with docId + enrichment fields from frontmatter so the agent
|
|
1760
|
+
// can rank/pick candidates without a follow-up body read.
|
|
1361
1761
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1362
1762
|
let docId = null;
|
|
1763
|
+
let logline;
|
|
1764
|
+
let domain;
|
|
1765
|
+
let docRole;
|
|
1766
|
+
let tags;
|
|
1363
1767
|
try {
|
|
1364
1768
|
const filePath = resolveDocPath(r.filename);
|
|
1365
1769
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1366
1770
|
const fm = matter(fileRaw);
|
|
1367
1771
|
docId = fm.data?.docId || null;
|
|
1772
|
+
if (typeof fm.data?.logline === 'string')
|
|
1773
|
+
logline = fm.data.logline;
|
|
1774
|
+
if (typeof fm.data?.domain === 'string')
|
|
1775
|
+
domain = fm.data.domain;
|
|
1776
|
+
if (typeof fm.data?.docRole === 'string')
|
|
1777
|
+
docRole = fm.data.docRole;
|
|
1778
|
+
if (Array.isArray(fm.data?.tags) && fm.data.tags.length > 0)
|
|
1779
|
+
tags = fm.data.tags;
|
|
1368
1780
|
}
|
|
1369
|
-
catch { /*
|
|
1781
|
+
catch { /* fields stay undefined */ }
|
|
1370
1782
|
return {
|
|
1371
1783
|
docId,
|
|
1372
1784
|
title: r.title,
|
|
@@ -1374,6 +1786,10 @@ export const TOOL_REGISTRY = [
|
|
|
1374
1786
|
matchType: r.matchType,
|
|
1375
1787
|
snippet: r.snippet,
|
|
1376
1788
|
matchedTag: r.matchedTag,
|
|
1789
|
+
...(logline ? { logline } : {}),
|
|
1790
|
+
...(domain ? { domain } : {}),
|
|
1791
|
+
...(docRole ? { docRole } : {}),
|
|
1792
|
+
...(tags ? { tags } : {}),
|
|
1377
1793
|
};
|
|
1378
1794
|
});
|
|
1379
1795
|
return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
|
|
@@ -1501,12 +1917,35 @@ export function removePluginTools(names) {
|
|
|
1501
1917
|
}
|
|
1502
1918
|
}
|
|
1503
1919
|
export async function startMcpServer() {
|
|
1920
|
+
// Build session-start enrichment notice. Read once at boot — MCP's instructions
|
|
1921
|
+
// field is delivered as part of the InitializeResult and becomes part of the
|
|
1922
|
+
// agent's system context. Empty string when no enrichment work is pending.
|
|
1923
|
+
// See brief 2026-05-18-frontmatter-enrichment-system.
|
|
1924
|
+
const enrichmentNotice = buildEnrichmentInstructions();
|
|
1504
1925
|
const server = new McpServer({
|
|
1505
1926
|
name: 'openwriter',
|
|
1506
1927
|
version: '0.2.0',
|
|
1507
|
-
});
|
|
1928
|
+
}, enrichmentNotice ? { instructions: enrichmentNotice } : undefined);
|
|
1929
|
+
// Wrap each tool handler in withRequestId so every event logged during
|
|
1930
|
+
// the tool's execution inherits the same request ID. Trace one MCP call
|
|
1931
|
+
// through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
|
|
1932
|
+
// adr: adr/logging-system.md
|
|
1508
1933
|
for (const tool of TOOL_REGISTRY) {
|
|
1509
|
-
|
|
1934
|
+
const wrappedHandler = async (args) => {
|
|
1935
|
+
const reqId = generateRequestId(`mcp-${tool.name}`);
|
|
1936
|
+
return await withRequestId(reqId, async () => {
|
|
1937
|
+
logger.debug('mcp', 'tool-call', tool.name, { tool: tool.name });
|
|
1938
|
+
try {
|
|
1939
|
+
const result = await tool.handler(args);
|
|
1940
|
+
return result;
|
|
1941
|
+
}
|
|
1942
|
+
catch (err) {
|
|
1943
|
+
logger.error('mcp', 'tool-error', `${tool.name}: ${err.message}`, { tool: tool.name }, err);
|
|
1944
|
+
throw err;
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
};
|
|
1948
|
+
server.tool(tool.name, tool.description, tool.schema, wrappedHandler);
|
|
1510
1949
|
}
|
|
1511
1950
|
mcpServerInstance = server;
|
|
1512
1951
|
const transport = new StdioServerTransport();
|