openwriter 0.23.0 → 0.25.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-AWIKUHJ_.css +1 -0
- package/dist/client/assets/{index-C65mFCh7.js → index-DmHLFNTs.js} +59 -59
- package/dist/client/index.html +2 -2
- package/dist/server/activity-log.js +2 -0
- package/dist/server/documents.js +97 -3
- package/dist/server/index.js +135 -3
- package/dist/server/mcp.js +185 -56
- package/dist/server/peek-outline.js +370 -0
- package/dist/server/state.js +117 -0
- package/dist/server/title-from-body.js +125 -0
- package/dist/server/workspaces.js +23 -0
- package/dist/server/ws.js +136 -6
- package/package.json +1 -1
- package/skill/SKILL.md +89 -41
- package/skill/agents/openwriter-enrichment-minion.md +7 -0
- package/skill/docs/setup.md +62 -0
- package/dist/client/assets/index-Ch3Z898_.css +0 -1
package/dist/server/mcp.js
CHANGED
|
@@ -10,10 +10,11 @@ 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, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, } 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, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, setSortProposalOnFile, clearSortRequestOnFile, } from './state.js';
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
|
+
import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
|
|
15
16
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
16
|
-
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
17
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions } from './documents.js';
|
|
17
18
|
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
18
19
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
19
20
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
|
|
@@ -214,16 +215,28 @@ function formatCommentsOutput({ byFile, scopeLabel }) {
|
|
|
214
215
|
}
|
|
215
216
|
return lines.join('\n');
|
|
216
217
|
}
|
|
218
|
+
/** Hard cap on words returned per read_pad call. Above this, the response
|
|
219
|
+
* is truncated at a top-level node boundary and a continuation hint is
|
|
220
|
+
* appended pointing at peek_doc / outline_doc / search_docs. The cap exists
|
|
221
|
+
* so the agent can't accidentally token-blow a 50k-word doc — read_pad's
|
|
222
|
+
* contract is "doc opening + handle to continue," not "everything."
|
|
223
|
+
* v0.25 — see CHANGELOG. */
|
|
224
|
+
const READ_PAD_MAX_WORDS = 2000;
|
|
225
|
+
/** First-truncation FYI shows once per MCP process lifetime so the agent
|
|
226
|
+
* learns the new behavior without repeating the explanation. Resets on
|
|
227
|
+
* server restart. */
|
|
228
|
+
let firstTruncationShown = false;
|
|
217
229
|
export const TOOL_REGISTRY = [
|
|
218
230
|
{
|
|
219
231
|
name: 'read_pad',
|
|
220
|
-
description:
|
|
232
|
+
description: `Read a document by docId. Returns compact tagged-line format with [type:id] per node. Capped at ~${READ_PAD_MAX_WORDS} words per call — for longer docs you get the opening slice plus a lastNodeId hint. Use peek_doc({ around: lastNodeId, after: N }) to continue linearly, outline_doc to jump by heading, or search_docs({ query, docId }) to find a specific passage. For docs under the cap, returns the full body as before.`,
|
|
221
233
|
schema: {
|
|
222
234
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
223
235
|
},
|
|
224
236
|
handler: async ({ docId }) => {
|
|
225
237
|
const target = resolveDocTarget(docId);
|
|
226
|
-
const
|
|
238
|
+
const trunc = truncateRead(target.document, READ_PAD_MAX_WORDS);
|
|
239
|
+
const compact = toCompactFormat(trunc.doc, target.title, trunc.returnedWords, target.pendingCount, target.docId, target.metadata);
|
|
227
240
|
const localCount = getCommentCount(target.filename);
|
|
228
241
|
const { totalComments: otherCount, docCount: otherDocs } = getGlobalCommentSummary(target.filename);
|
|
229
242
|
let hint = '';
|
|
@@ -233,6 +246,17 @@ export const TOOL_REGISTRY = [
|
|
|
233
246
|
hint += `\n[${otherCount} comment${otherCount !== 1 ? 's' : ''} on ${otherDocs} other document${otherDocs !== 1 ? 's' : ''}]`;
|
|
234
247
|
if (hint)
|
|
235
248
|
hint += '\n[call get_comments to review]';
|
|
249
|
+
if (trunc.truncated) {
|
|
250
|
+
if (!firstTruncationShown) {
|
|
251
|
+
hint += `\n\n[FYI: read_pad caps at ~${READ_PAD_MAX_WORDS} words to keep cost predictable. This notice shows once per session — future truncations skip the explanation.]`;
|
|
252
|
+
firstTruncationShown = true;
|
|
253
|
+
}
|
|
254
|
+
const anchor = trunc.lastNodeId ?? '<no-id>';
|
|
255
|
+
hint += `\n[TRUNCATED — ${trunc.totalWords.toLocaleString()} words total, ${trunc.returnedWords.toLocaleString()} returned, ${trunc.remaining.toLocaleString()} remain. Continue with:`
|
|
256
|
+
+ `\n peek_doc({ docId: "${target.docId}", target: { around: "${anchor}", after: 100 } }) — linear continuation`
|
|
257
|
+
+ `\n outline_doc({ docId: "${target.docId}" }) — heading skeleton to jump to a section`
|
|
258
|
+
+ `\n search_docs({ query: "...", docId: "${target.docId}" }) — find a specific passage]`;
|
|
259
|
+
}
|
|
236
260
|
return { content: [{ type: 'text', text: compact + hint }] };
|
|
237
261
|
},
|
|
238
262
|
},
|
|
@@ -348,9 +372,46 @@ export const TOOL_REGISTRY = [
|
|
|
348
372
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
349
373
|
},
|
|
350
374
|
},
|
|
375
|
+
{
|
|
376
|
+
name: 'outline_doc',
|
|
377
|
+
description: 'Get the structural skeleton of a doc — heading tree by default, optionally drill into one section. The right tool to orient on a long doc before reading anything. Heading tree only is ~5 tokens per heading vs read_pad\'s ~50–100 per block. For 1000-node docs the entire outline is typically <500 tokens. Drill into a section with `underHeading: nodeId` to see one section\'s block previews (~15 tokens/block). Falls back to top-level block previews when the doc has no headings. Use after `search_docs` finds the doc but before `read_pad` or `peek_doc`.',
|
|
378
|
+
schema: {
|
|
379
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
380
|
+
underHeading: z.string().optional().describe('Heading nodeId — drill into that section and return all blocks beneath it (until the next same-or-shallower heading).'),
|
|
381
|
+
depth: z.number().optional().describe('Cap on heading levels shown (1 = h1 only, 2 = h1+h2, etc.). Default 3. Ignored when underHeading is set.'),
|
|
382
|
+
offset: z.number().optional().describe('Pagination start index (default 0). Useful for very long unstructured docs.'),
|
|
383
|
+
limit: z.number().optional().describe('Pagination max lines (default 200).'),
|
|
384
|
+
},
|
|
385
|
+
handler: async ({ docId, underHeading, depth, offset, limit }) => {
|
|
386
|
+
const target = resolveDocTarget(docId);
|
|
387
|
+
const text = outline(target.document, { underHeading, depth, offset, limit });
|
|
388
|
+
return { content: [{ type: 'text', text }] };
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
name: 'peek_doc',
|
|
393
|
+
description: 'Read a windowed slice of nodes from a doc — once you have a nodeId from search_docs or outline_doc. Six target shapes: { node } single block, { nodes } array of explicit IDs, { around, before, after } window around an anchor, { from, to } range between two anchors, { first } top N blocks, { last } bottom N blocks, { position, span } positional read (position 0.0–1.0). All return compact tagged-line format. Use this instead of read_pad whenever you only need part of a doc.',
|
|
394
|
+
schema: {
|
|
395
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
396
|
+
target: z.union([
|
|
397
|
+
z.object({ node: z.string() }).strict(),
|
|
398
|
+
z.object({ nodes: z.array(z.string()) }).strict(),
|
|
399
|
+
z.object({ around: z.string(), before: z.number().optional(), after: z.number().optional() }).strict(),
|
|
400
|
+
z.object({ from: z.string(), to: z.string() }).strict(),
|
|
401
|
+
z.object({ first: z.number() }).strict(),
|
|
402
|
+
z.object({ last: z.number() }).strict(),
|
|
403
|
+
z.object({ position: z.number(), span: z.number().optional() }).strict(),
|
|
404
|
+
]).describe('Target spec — exactly one shape. See description for the six options.'),
|
|
405
|
+
},
|
|
406
|
+
handler: async ({ docId, target }) => {
|
|
407
|
+
const docTarget = resolveDocTarget(docId);
|
|
408
|
+
const nodes = peek(docTarget.document, target);
|
|
409
|
+
return { content: [{ type: 'text', text: compactNodes(nodes) }] };
|
|
410
|
+
},
|
|
411
|
+
},
|
|
351
412
|
{
|
|
352
413
|
name: 'get_nodes',
|
|
353
|
-
description: '
|
|
414
|
+
description: 'DEPRECATED — superseded by peek_doc. Use peek_doc with target { nodes: [ids] } for the same behavior, or one of the other target shapes for richer windowed reads. This alias may be removed in a future release.',
|
|
354
415
|
schema: {
|
|
355
416
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
356
417
|
nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
|
|
@@ -384,7 +445,7 @@ export const TOOL_REGISTRY = [
|
|
|
384
445
|
return `${main}\n → ${d.logline}`;
|
|
385
446
|
return main;
|
|
386
447
|
});
|
|
387
|
-
const footer = enrichmentFooter()
|
|
448
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
388
449
|
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
389
450
|
},
|
|
390
451
|
},
|
|
@@ -501,7 +562,7 @@ export const TOOL_REGISTRY = [
|
|
|
501
562
|
// this entry. Fires after the file exists, so documents-changed arrives with
|
|
502
563
|
// the real entry that the sidebar filters behind the spinner until populate.
|
|
503
564
|
spinnerKey = result.filename;
|
|
504
|
-
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
|
|
565
|
+
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
|
|
505
566
|
broadcastDocumentsChanged();
|
|
506
567
|
return {
|
|
507
568
|
content: [{
|
|
@@ -654,7 +715,7 @@ export const TOOL_REGISTRY = [
|
|
|
654
715
|
const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
|
|
655
716
|
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
656
717
|
}
|
|
657
|
-
broadcastWritingStarted(w.title, wsTarget, result.filename);
|
|
718
|
+
broadcastWritingStarted(w.title, wsTarget, result.filename, result.filename, result.docId);
|
|
658
719
|
broadcastedKeys.push(result.filename);
|
|
659
720
|
results.push({ docId: result.docId, filename: result.filename, title: result.title });
|
|
660
721
|
}
|
|
@@ -902,6 +963,7 @@ export const TOOL_REGISTRY = [
|
|
|
902
963
|
kind: 'enrichment',
|
|
903
964
|
headline: `Enrichment stamped ${target.title || target.filename}`,
|
|
904
965
|
detail: item.logline,
|
|
966
|
+
docId: item.docId,
|
|
905
967
|
filename: target.filename,
|
|
906
968
|
});
|
|
907
969
|
results.push({ docId: item.docId, ok: true });
|
|
@@ -932,12 +994,84 @@ export const TOOL_REGISTRY = [
|
|
|
932
994
|
},
|
|
933
995
|
},
|
|
934
996
|
{
|
|
935
|
-
name: '
|
|
936
|
-
description: '
|
|
997
|
+
name: 'list_pending_sorts',
|
|
998
|
+
description: 'List documents that the user has marked for sorting via the sidebar. Each entry includes the doc identity, where it currently lives, the requestedAt timestamp, and (when present) a proposal already written by an earlier pass. Call this first to know what sort work is pending; for each entry, read the doc body, consider workspace/container purpose hints, and either discuss the destination with the user in chat (1–3 docs) or write a proposal via propose_sort (many docs → batch UI accept/reject).',
|
|
999
|
+
schema: {
|
|
1000
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
1001
|
+
},
|
|
1002
|
+
handler: async ({ workspaceFile }) => {
|
|
1003
|
+
const docs = listPendingSorts(workspaceFile);
|
|
1004
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
{
|
|
1008
|
+
name: 'propose_sort',
|
|
1009
|
+
description: 'Write a sort proposal back to a doc. Used in confirm-mode: the minion picks a destination (workspace + container) and a one-line reasoning, stamps it onto the doc\'s sortRequest, and the sidebar flips the doc\'s badge to "proposal ready" so the user can accept or reject in-browser. Does NOT move the doc — that happens on user accept (via the UI) or in auto-mode via mark_sorted+move_item. Accepts an array so a batch lands in one call.',
|
|
1010
|
+
schema: {
|
|
1011
|
+
proposals: z.array(z.object({
|
|
1012
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_pending_sorts).'),
|
|
1013
|
+
wsFilename: z.string().describe('Destination workspace manifest filename.'),
|
|
1014
|
+
containerId: z.string().nullable().describe('Destination container ID, or null for workspace root.'),
|
|
1015
|
+
reasoning: z.string().max(200).describe('One-line rationale shown to the user beside the proposal. Under 200 chars.'),
|
|
1016
|
+
}).strict()).describe('One or more proposals. Single-doc calls are a length-1 array.'),
|
|
1017
|
+
},
|
|
1018
|
+
handler: async ({ proposals }) => {
|
|
1019
|
+
const results = [];
|
|
1020
|
+
for (const p of proposals) {
|
|
1021
|
+
try {
|
|
1022
|
+
const filename = resolveDocId(p.docId);
|
|
1023
|
+
setSortProposalOnFile(filename, { wsFilename: p.wsFilename, containerId: p.containerId, reasoning: p.reasoning });
|
|
1024
|
+
results.push({ docId: p.docId, ok: true });
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
results.push({ docId: p.docId, ok: false, error: String(err?.message ?? err) });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
broadcastDocumentsChanged();
|
|
1031
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1032
|
+
const failCount = results.length - okCount;
|
|
1033
|
+
const summary = failCount === 0
|
|
1034
|
+
? `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}`
|
|
1035
|
+
: `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
1036
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ proposals: results })}` }] };
|
|
1037
|
+
},
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
name: 'mark_sorted',
|
|
1041
|
+
description: 'Clear a doc\'s sortRequest and stamp lastSortedAt. Used in auto-mode after the minion has actually called move_item (or decided the doc is already in the right place), and in confirm-mode after the user has accepted/rejected via the UI. Accepts an array for batch fulfillment. Does NOT perform the move — call move_item first. The retire-on-fulfillment pattern mirrors mark_enriched / enrichmentStale.',
|
|
1042
|
+
schema: {
|
|
1043
|
+
docs: z.array(z.object({
|
|
1044
|
+
docId: z.string().describe('Target document by docId.'),
|
|
1045
|
+
}).strict()).describe('One or more docs to mark sorted.'),
|
|
1046
|
+
},
|
|
1047
|
+
handler: async ({ docs }) => {
|
|
1048
|
+
const results = [];
|
|
1049
|
+
for (const item of docs) {
|
|
1050
|
+
try {
|
|
1051
|
+
const filename = resolveDocId(item.docId);
|
|
1052
|
+
clearSortRequestOnFile(filename);
|
|
1053
|
+
results.push({ docId: item.docId, ok: true });
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
broadcastDocumentsChanged();
|
|
1060
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1061
|
+
const failCount = results.length - okCount;
|
|
1062
|
+
const summary = failCount === 0
|
|
1063
|
+
? `Marked ${okCount} sorted`
|
|
1064
|
+
: `Marked ${okCount} sorted, ${failCount} failed`;
|
|
1065
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
name: 'browse_docs',
|
|
1070
|
+
description: 'Browse the workspace shelf at concept level — bulk-read enriched frontmatter per doc, filtered by criteria. Returns identity + logline + status + tags + stale flag (~60 tokens/doc). No bodies, no nodes, no pending overlay, no container nesting. Filters compose with AND semantics; empty filter returns every non-archived doc. Use this between get_workspace_structure (tree shape) and read_pad (full body) — see which docs look relevant before paying to read one.',
|
|
937
1071
|
schema: {
|
|
938
1072
|
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
939
1073
|
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
940
|
-
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common
|
|
1074
|
+
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common browse is `status: canonical`.'),
|
|
941
1075
|
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
942
1076
|
},
|
|
943
1077
|
handler: async (filter) => {
|
|
@@ -945,6 +1079,20 @@ export const TOOL_REGISTRY = [
|
|
|
945
1079
|
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
946
1080
|
},
|
|
947
1081
|
},
|
|
1082
|
+
{
|
|
1083
|
+
name: 'crawl',
|
|
1084
|
+
description: 'DEPRECATED — renamed to browse_docs. Use browse_docs instead. This alias may be removed in a future release.',
|
|
1085
|
+
schema: {
|
|
1086
|
+
workspaceFile: z.string().optional(),
|
|
1087
|
+
tags: z.array(z.string()).optional(),
|
|
1088
|
+
status: z.enum(['canonical', 'draft']).optional(),
|
|
1089
|
+
hasLogline: z.boolean().optional(),
|
|
1090
|
+
},
|
|
1091
|
+
handler: async (filter) => {
|
|
1092
|
+
const docs = crawlDocs(filter);
|
|
1093
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
948
1096
|
{
|
|
949
1097
|
name: 'list_workspaces',
|
|
950
1098
|
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
@@ -952,7 +1100,7 @@ export const TOOL_REGISTRY = [
|
|
|
952
1100
|
handler: async () => {
|
|
953
1101
|
const workspaces = listWorkspaces();
|
|
954
1102
|
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
955
|
-
const footer = enrichmentFooter()
|
|
1103
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
956
1104
|
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
957
1105
|
},
|
|
958
1106
|
},
|
|
@@ -988,46 +1136,20 @@ export const TOOL_REGISTRY = [
|
|
|
988
1136
|
},
|
|
989
1137
|
{
|
|
990
1138
|
name: 'get_workspace_structure',
|
|
991
|
-
description: 'Get the
|
|
1139
|
+
description: 'Get the tree shape of a workspace: containers and docs with their IDs/filenames, plus workspace-level structural fields (schema, vocab, enrichment flag). NO per-doc loglines, status, tags, or stale flags — those are concept-level and live in `browse`. Use this when you need to find a destination container (sort, move) or understand nesting. For "what is each doc about" call `browse` instead.',
|
|
992
1140
|
schema: {
|
|
993
1141
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
994
1142
|
},
|
|
995
1143
|
handler: async ({ filename }) => {
|
|
996
1144
|
const ws = getWorkspace(filename);
|
|
997
|
-
// Build a one-pass map of filename → frontmatter so we don't re-read each
|
|
998
|
-
// doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
|
|
999
|
-
const enriched = crawlDocs({ workspaceFile: filename });
|
|
1000
|
-
const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
|
|
1001
1145
|
function renderTree(nodes, indent) {
|
|
1002
1146
|
const lines = [];
|
|
1003
1147
|
for (const node of nodes) {
|
|
1004
1148
|
if (node.type === 'doc') {
|
|
1005
|
-
|
|
1006
|
-
const tags = e?.tags ?? [];
|
|
1007
|
-
const enrichBits = [];
|
|
1008
|
-
// v0.19.0: status (agent-owned) replaces domain + docRole.
|
|
1009
|
-
// Only "canonical" is worth surfacing — draft is the default
|
|
1010
|
-
// and would add noise on every line.
|
|
1011
|
-
if (e?.status === 'canonical')
|
|
1012
|
-
enrichBits.push('canonical');
|
|
1013
|
-
if (tags.length > 0)
|
|
1014
|
-
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
1015
|
-
if (e?.enrichmentStale === true)
|
|
1016
|
-
enrichBits.push('STALE');
|
|
1017
|
-
const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
|
|
1018
|
-
const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
|
|
1019
|
-
lines.push(docLine);
|
|
1020
|
-
if (e?.logline)
|
|
1021
|
-
lines.push(`${indent} → ${e.logline}`);
|
|
1149
|
+
lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
|
|
1022
1150
|
}
|
|
1023
1151
|
else {
|
|
1024
|
-
|
|
1025
|
-
if (node.role)
|
|
1026
|
-
cBits.push(node.role);
|
|
1027
|
-
const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
|
|
1028
|
-
lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
|
|
1029
|
-
if (node.logline)
|
|
1030
|
-
lines.push(`${indent} → ${node.logline}`);
|
|
1152
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
|
|
1031
1153
|
lines.push(...renderTree(node.items, indent + ' '));
|
|
1032
1154
|
}
|
|
1033
1155
|
}
|
|
@@ -1035,25 +1157,18 @@ export const TOOL_REGISTRY = [
|
|
|
1035
1157
|
}
|
|
1036
1158
|
const treeLines = renderTree(ws.root, ' ');
|
|
1037
1159
|
const headerBits = [`workspace: "${ws.title}"`];
|
|
1038
|
-
if (ws.logline)
|
|
1039
|
-
headerBits.push(`logline: ${ws.logline}`);
|
|
1040
|
-
if (ws.domain)
|
|
1041
|
-
headerBits.push(`domain: ${ws.domain}`);
|
|
1042
1160
|
if (ws.schema)
|
|
1043
1161
|
headerBits.push(`schema: ${ws.schema}`);
|
|
1044
1162
|
if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
|
|
1045
1163
|
headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
|
|
1046
1164
|
}
|
|
1047
|
-
if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
|
|
1048
|
-
headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
|
|
1049
|
-
}
|
|
1050
1165
|
if (ws.enrichmentDisabled === true)
|
|
1051
1166
|
headerBits.push('enrichment: disabled');
|
|
1052
1167
|
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
1053
1168
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
1054
1169
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
1055
1170
|
}
|
|
1056
|
-
const footer = enrichmentFooter()
|
|
1171
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
1057
1172
|
return { content: [{ type: 'text', text: `${text}${footer}` }] };
|
|
1058
1173
|
},
|
|
1059
1174
|
},
|
|
@@ -1785,6 +1900,7 @@ export const TOOL_REGISTRY = [
|
|
|
1785
1900
|
broadcastActivityEvent({
|
|
1786
1901
|
kind: 'backlinks-added',
|
|
1787
1902
|
headline: `Linked ${sourceTitle} → ${targetTitle}`,
|
|
1903
|
+
docId: source_doc_id,
|
|
1788
1904
|
filename: sourceFilename,
|
|
1789
1905
|
});
|
|
1790
1906
|
}
|
|
@@ -1823,18 +1939,26 @@ export const TOOL_REGISTRY = [
|
|
|
1823
1939
|
},
|
|
1824
1940
|
{
|
|
1825
1941
|
name: 'search_docs',
|
|
1826
|
-
description: 'Full-text search
|
|
1942
|
+
description: 'Full-text search. Default (no docId): ranked candidates across every workspace doc, returning docId + title + match type + snippet. Scoped (docId set): search inside one doc and return matching NODES with nodeId + type + snippet — the content-to-node bridge that lets the agent jump from "I want the beat about X" to a known nodeId without ever reading the full doc. Pairs with peek_doc for the actual node read. Use the workspace search before link_to. Use the doc-scoped search before peek_doc when researching inside a long doc.',
|
|
1827
1943
|
schema: {
|
|
1828
|
-
query: z.string().describe('Search query (case-insensitive substring match
|
|
1944
|
+
query: z.string().describe('Search query (case-insensitive substring match).'),
|
|
1945
|
+
docId: z.string().optional().describe('When set, scope the search to one doc and return matching nodes (with nodeId) instead of doc snippets. The content-orientation entry point for in-doc research.'),
|
|
1829
1946
|
limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
|
|
1830
1947
|
},
|
|
1831
|
-
handler: async ({ query, limit = 10 }) => {
|
|
1948
|
+
handler: async ({ query, docId, limit = 10 }) => {
|
|
1832
1949
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1950
|
+
// Doc-scoped path: walk the doc's blocks and return matching nodes.
|
|
1951
|
+
if (docId) {
|
|
1952
|
+
const target = resolveDocTarget(docId);
|
|
1953
|
+
const matches = searchInDoc(target.document, query, cap);
|
|
1954
|
+
return { content: [{ type: 'text', text: JSON.stringify({ docId, total: matches.length, matches }) }] };
|
|
1955
|
+
}
|
|
1956
|
+
// Workspace-wide path: existing behavior.
|
|
1833
1957
|
const raw = searchDocuments(query);
|
|
1834
1958
|
// Enrich with docId + enrichment fields from frontmatter so the agent
|
|
1835
1959
|
// can rank/pick candidates without a follow-up body read.
|
|
1836
1960
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1837
|
-
let
|
|
1961
|
+
let resolvedDocId = null;
|
|
1838
1962
|
let logline;
|
|
1839
1963
|
let status;
|
|
1840
1964
|
let tags;
|
|
@@ -1842,7 +1966,7 @@ export const TOOL_REGISTRY = [
|
|
|
1842
1966
|
const filePath = resolveDocPath(r.filename);
|
|
1843
1967
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1844
1968
|
const fm = matter(fileRaw);
|
|
1845
|
-
|
|
1969
|
+
resolvedDocId = fm.data?.docId || null;
|
|
1846
1970
|
// v0.19.0 three-field schema: logline (LLM), status (agent), tags.
|
|
1847
1971
|
if (typeof fm.data?.logline === 'string')
|
|
1848
1972
|
logline = fm.data.logline;
|
|
@@ -1853,7 +1977,7 @@ export const TOOL_REGISTRY = [
|
|
|
1853
1977
|
}
|
|
1854
1978
|
catch { /* fields stay undefined */ }
|
|
1855
1979
|
return {
|
|
1856
|
-
docId,
|
|
1980
|
+
docId: resolvedDocId,
|
|
1857
1981
|
title: r.title,
|
|
1858
1982
|
filename: r.filename,
|
|
1859
1983
|
matchType: r.matchType,
|
|
@@ -1984,10 +2108,15 @@ export async function startMcpServer() {
|
|
|
1984
2108
|
// agent's system context. Empty string when no enrichment work is pending.
|
|
1985
2109
|
// See brief 2026-05-18-frontmatter-enrichment-system.
|
|
1986
2110
|
const enrichmentNotice = buildEnrichmentInstructions();
|
|
2111
|
+
const sortNotice = buildSortInstructions();
|
|
2112
|
+
// Stack notices in the MCP instructions field. Both notices stand on their
|
|
2113
|
+
// own — agents can read and dispatch them independently. Empty when neither
|
|
2114
|
+
// system has pending work.
|
|
2115
|
+
const combinedNotice = [enrichmentNotice, sortNotice].filter(Boolean).join('\n');
|
|
1987
2116
|
const server = new McpServer({
|
|
1988
2117
|
name: 'openwriter',
|
|
1989
2118
|
version: '0.2.0',
|
|
1990
|
-
},
|
|
2119
|
+
}, combinedNotice ? { instructions: combinedNotice } : undefined);
|
|
1991
2120
|
// Wrap each tool handler in withRequestId so every event logged during
|
|
1992
2121
|
// the tool's execution inherits the same request ID. Trace one MCP call
|
|
1993
2122
|
// through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
|