openwriter 0.22.1 → 0.24.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-DmHLFNTs.js +212 -0
- 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 +159 -4
- package/dist/server/mcp.js +183 -55
- package/dist/server/peek-outline.js +304 -0
- package/dist/server/state.js +128 -0
- package/dist/server/title-from-body.js +125 -0
- package/dist/server/workspaces.js +23 -0
- package/dist/server/ws.js +176 -3
- package/package.json +1 -1
- package/skill/SKILL.md +611 -712
- package/skill/agents/openwriter-enrichment-minion.md +7 -0
- package/skill/docs/setup.md +62 -0
- package/dist/client/assets/index-DFbNF7q0.css +0 -1
- package/dist/client/assets/index-OAhOx_JE.js +0 -212
package/dist/server/mcp.js
CHANGED
|
@@ -10,13 +10,14 @@ 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 } 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
|
-
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
20
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
|
|
20
21
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
21
22
|
import { findDocNode } from './workspace-tree.js';
|
|
22
23
|
import { importGoogleDoc } from './gdoc-import.js';
|
|
@@ -348,9 +349,46 @@ export const TOOL_REGISTRY = [
|
|
|
348
349
|
return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
|
|
349
350
|
},
|
|
350
351
|
},
|
|
352
|
+
{
|
|
353
|
+
name: 'outline_doc',
|
|
354
|
+
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`.',
|
|
355
|
+
schema: {
|
|
356
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
357
|
+
underHeading: z.string().optional().describe('Heading nodeId — drill into that section and return all blocks beneath it (until the next same-or-shallower heading).'),
|
|
358
|
+
depth: z.number().optional().describe('Cap on heading levels shown (1 = h1 only, 2 = h1+h2, etc.). Default 3. Ignored when underHeading is set.'),
|
|
359
|
+
offset: z.number().optional().describe('Pagination start index (default 0). Useful for very long unstructured docs.'),
|
|
360
|
+
limit: z.number().optional().describe('Pagination max lines (default 200).'),
|
|
361
|
+
},
|
|
362
|
+
handler: async ({ docId, underHeading, depth, offset, limit }) => {
|
|
363
|
+
const target = resolveDocTarget(docId);
|
|
364
|
+
const text = outline(target.document, { underHeading, depth, offset, limit });
|
|
365
|
+
return { content: [{ type: 'text', text }] };
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'peek_doc',
|
|
370
|
+
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.',
|
|
371
|
+
schema: {
|
|
372
|
+
docId: z.string().describe('Target document by docId (8-char hex).'),
|
|
373
|
+
target: z.union([
|
|
374
|
+
z.object({ node: z.string() }).strict(),
|
|
375
|
+
z.object({ nodes: z.array(z.string()) }).strict(),
|
|
376
|
+
z.object({ around: z.string(), before: z.number().optional(), after: z.number().optional() }).strict(),
|
|
377
|
+
z.object({ from: z.string(), to: z.string() }).strict(),
|
|
378
|
+
z.object({ first: z.number() }).strict(),
|
|
379
|
+
z.object({ last: z.number() }).strict(),
|
|
380
|
+
z.object({ position: z.number(), span: z.number().optional() }).strict(),
|
|
381
|
+
]).describe('Target spec — exactly one shape. See description for the six options.'),
|
|
382
|
+
},
|
|
383
|
+
handler: async ({ docId, target }) => {
|
|
384
|
+
const docTarget = resolveDocTarget(docId);
|
|
385
|
+
const nodes = peek(docTarget.document, target);
|
|
386
|
+
return { content: [{ type: 'text', text: compactNodes(nodes) }] };
|
|
387
|
+
},
|
|
388
|
+
},
|
|
351
389
|
{
|
|
352
390
|
name: 'get_nodes',
|
|
353
|
-
description: '
|
|
391
|
+
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
392
|
schema: {
|
|
355
393
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
356
394
|
nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
|
|
@@ -384,7 +422,7 @@ export const TOOL_REGISTRY = [
|
|
|
384
422
|
return `${main}\n → ${d.logline}`;
|
|
385
423
|
return main;
|
|
386
424
|
});
|
|
387
|
-
const footer = enrichmentFooter()
|
|
425
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
388
426
|
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
389
427
|
},
|
|
390
428
|
},
|
|
@@ -501,7 +539,7 @@ export const TOOL_REGISTRY = [
|
|
|
501
539
|
// this entry. Fires after the file exists, so documents-changed arrives with
|
|
502
540
|
// the real entry that the sidebar filters behind the spinner until populate.
|
|
503
541
|
spinnerKey = result.filename;
|
|
504
|
-
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey);
|
|
542
|
+
broadcastWritingStarted(title || 'Untitled', wsTarget, spinnerKey, result.filename, result.docId);
|
|
505
543
|
broadcastDocumentsChanged();
|
|
506
544
|
return {
|
|
507
545
|
content: [{
|
|
@@ -654,7 +692,7 @@ export const TOOL_REGISTRY = [
|
|
|
654
692
|
const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
|
|
655
693
|
addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
|
|
656
694
|
}
|
|
657
|
-
broadcastWritingStarted(w.title, wsTarget, result.filename);
|
|
695
|
+
broadcastWritingStarted(w.title, wsTarget, result.filename, result.filename, result.docId);
|
|
658
696
|
broadcastedKeys.push(result.filename);
|
|
659
697
|
results.push({ docId: result.docId, filename: result.filename, title: result.title });
|
|
660
698
|
}
|
|
@@ -897,6 +935,14 @@ export const TOOL_REGISTRY = [
|
|
|
897
935
|
atomicWriteFileSync(target.filePath, markdown);
|
|
898
936
|
invalidateDocCache(target.filePath);
|
|
899
937
|
}
|
|
938
|
+
// Right-rail Activity: one entry per enriched doc. adr: adr/right-rail.md
|
|
939
|
+
broadcastActivityEvent({
|
|
940
|
+
kind: 'enrichment',
|
|
941
|
+
headline: `Enrichment stamped ${target.title || target.filename}`,
|
|
942
|
+
detail: item.logline,
|
|
943
|
+
docId: item.docId,
|
|
944
|
+
filename: target.filename,
|
|
945
|
+
});
|
|
900
946
|
results.push({ docId: item.docId, ok: true });
|
|
901
947
|
}
|
|
902
948
|
catch (err) {
|
|
@@ -925,12 +971,84 @@ export const TOOL_REGISTRY = [
|
|
|
925
971
|
},
|
|
926
972
|
},
|
|
927
973
|
{
|
|
928
|
-
name: '
|
|
929
|
-
description: '
|
|
974
|
+
name: 'list_pending_sorts',
|
|
975
|
+
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).',
|
|
976
|
+
schema: {
|
|
977
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
978
|
+
},
|
|
979
|
+
handler: async ({ workspaceFile }) => {
|
|
980
|
+
const docs = listPendingSorts(workspaceFile);
|
|
981
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
name: 'propose_sort',
|
|
986
|
+
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.',
|
|
987
|
+
schema: {
|
|
988
|
+
proposals: z.array(z.object({
|
|
989
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_pending_sorts).'),
|
|
990
|
+
wsFilename: z.string().describe('Destination workspace manifest filename.'),
|
|
991
|
+
containerId: z.string().nullable().describe('Destination container ID, or null for workspace root.'),
|
|
992
|
+
reasoning: z.string().max(200).describe('One-line rationale shown to the user beside the proposal. Under 200 chars.'),
|
|
993
|
+
}).strict()).describe('One or more proposals. Single-doc calls are a length-1 array.'),
|
|
994
|
+
},
|
|
995
|
+
handler: async ({ proposals }) => {
|
|
996
|
+
const results = [];
|
|
997
|
+
for (const p of proposals) {
|
|
998
|
+
try {
|
|
999
|
+
const filename = resolveDocId(p.docId);
|
|
1000
|
+
setSortProposalOnFile(filename, { wsFilename: p.wsFilename, containerId: p.containerId, reasoning: p.reasoning });
|
|
1001
|
+
results.push({ docId: p.docId, ok: true });
|
|
1002
|
+
}
|
|
1003
|
+
catch (err) {
|
|
1004
|
+
results.push({ docId: p.docId, ok: false, error: String(err?.message ?? err) });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
broadcastDocumentsChanged();
|
|
1008
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1009
|
+
const failCount = results.length - okCount;
|
|
1010
|
+
const summary = failCount === 0
|
|
1011
|
+
? `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}`
|
|
1012
|
+
: `Wrote ${okCount} proposal${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
1013
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ proposals: results })}` }] };
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
{
|
|
1017
|
+
name: 'mark_sorted',
|
|
1018
|
+
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.',
|
|
1019
|
+
schema: {
|
|
1020
|
+
docs: z.array(z.object({
|
|
1021
|
+
docId: z.string().describe('Target document by docId.'),
|
|
1022
|
+
}).strict()).describe('One or more docs to mark sorted.'),
|
|
1023
|
+
},
|
|
1024
|
+
handler: async ({ docs }) => {
|
|
1025
|
+
const results = [];
|
|
1026
|
+
for (const item of docs) {
|
|
1027
|
+
try {
|
|
1028
|
+
const filename = resolveDocId(item.docId);
|
|
1029
|
+
clearSortRequestOnFile(filename);
|
|
1030
|
+
results.push({ docId: item.docId, ok: true });
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
broadcastDocumentsChanged();
|
|
1037
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
1038
|
+
const failCount = results.length - okCount;
|
|
1039
|
+
const summary = failCount === 0
|
|
1040
|
+
? `Marked ${okCount} sorted`
|
|
1041
|
+
: `Marked ${okCount} sorted, ${failCount} failed`;
|
|
1042
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
name: 'browse_docs',
|
|
1047
|
+
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.',
|
|
930
1048
|
schema: {
|
|
931
1049
|
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
932
1050
|
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
933
|
-
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
|
|
1051
|
+
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`.'),
|
|
934
1052
|
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
935
1053
|
},
|
|
936
1054
|
handler: async (filter) => {
|
|
@@ -938,6 +1056,20 @@ export const TOOL_REGISTRY = [
|
|
|
938
1056
|
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
939
1057
|
},
|
|
940
1058
|
},
|
|
1059
|
+
{
|
|
1060
|
+
name: 'crawl',
|
|
1061
|
+
description: 'DEPRECATED — renamed to browse_docs. Use browse_docs instead. This alias may be removed in a future release.',
|
|
1062
|
+
schema: {
|
|
1063
|
+
workspaceFile: z.string().optional(),
|
|
1064
|
+
tags: z.array(z.string()).optional(),
|
|
1065
|
+
status: z.enum(['canonical', 'draft']).optional(),
|
|
1066
|
+
hasLogline: z.boolean().optional(),
|
|
1067
|
+
},
|
|
1068
|
+
handler: async (filter) => {
|
|
1069
|
+
const docs = crawlDocs(filter);
|
|
1070
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
1071
|
+
},
|
|
1072
|
+
},
|
|
941
1073
|
{
|
|
942
1074
|
name: 'list_workspaces',
|
|
943
1075
|
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
@@ -945,7 +1077,7 @@ export const TOOL_REGISTRY = [
|
|
|
945
1077
|
handler: async () => {
|
|
946
1078
|
const workspaces = listWorkspaces();
|
|
947
1079
|
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
948
|
-
const footer = enrichmentFooter()
|
|
1080
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
949
1081
|
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
950
1082
|
},
|
|
951
1083
|
},
|
|
@@ -981,46 +1113,20 @@ export const TOOL_REGISTRY = [
|
|
|
981
1113
|
},
|
|
982
1114
|
{
|
|
983
1115
|
name: 'get_workspace_structure',
|
|
984
|
-
description: 'Get the
|
|
1116
|
+
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.',
|
|
985
1117
|
schema: {
|
|
986
1118
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
987
1119
|
},
|
|
988
1120
|
handler: async ({ filename }) => {
|
|
989
1121
|
const ws = getWorkspace(filename);
|
|
990
|
-
// Build a one-pass map of filename → frontmatter so we don't re-read each
|
|
991
|
-
// doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
|
|
992
|
-
const enriched = crawlDocs({ workspaceFile: filename });
|
|
993
|
-
const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
|
|
994
1122
|
function renderTree(nodes, indent) {
|
|
995
1123
|
const lines = [];
|
|
996
1124
|
for (const node of nodes) {
|
|
997
1125
|
if (node.type === 'doc') {
|
|
998
|
-
|
|
999
|
-
const tags = e?.tags ?? [];
|
|
1000
|
-
const enrichBits = [];
|
|
1001
|
-
// v0.19.0: status (agent-owned) replaces domain + docRole.
|
|
1002
|
-
// Only "canonical" is worth surfacing — draft is the default
|
|
1003
|
-
// and would add noise on every line.
|
|
1004
|
-
if (e?.status === 'canonical')
|
|
1005
|
-
enrichBits.push('canonical');
|
|
1006
|
-
if (tags.length > 0)
|
|
1007
|
-
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
1008
|
-
if (e?.enrichmentStale === true)
|
|
1009
|
-
enrichBits.push('STALE');
|
|
1010
|
-
const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
|
|
1011
|
-
const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
|
|
1012
|
-
lines.push(docLine);
|
|
1013
|
-
if (e?.logline)
|
|
1014
|
-
lines.push(`${indent} → ${e.logline}`);
|
|
1126
|
+
lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
|
|
1015
1127
|
}
|
|
1016
1128
|
else {
|
|
1017
|
-
|
|
1018
|
-
if (node.role)
|
|
1019
|
-
cBits.push(node.role);
|
|
1020
|
-
const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
|
|
1021
|
-
lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
|
|
1022
|
-
if (node.logline)
|
|
1023
|
-
lines.push(`${indent} → ${node.logline}`);
|
|
1129
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
|
|
1024
1130
|
lines.push(...renderTree(node.items, indent + ' '));
|
|
1025
1131
|
}
|
|
1026
1132
|
}
|
|
@@ -1028,25 +1134,18 @@ export const TOOL_REGISTRY = [
|
|
|
1028
1134
|
}
|
|
1029
1135
|
const treeLines = renderTree(ws.root, ' ');
|
|
1030
1136
|
const headerBits = [`workspace: "${ws.title}"`];
|
|
1031
|
-
if (ws.logline)
|
|
1032
|
-
headerBits.push(`logline: ${ws.logline}`);
|
|
1033
|
-
if (ws.domain)
|
|
1034
|
-
headerBits.push(`domain: ${ws.domain}`);
|
|
1035
1137
|
if (ws.schema)
|
|
1036
1138
|
headerBits.push(`schema: ${ws.schema}`);
|
|
1037
1139
|
if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
|
|
1038
1140
|
headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
|
|
1039
1141
|
}
|
|
1040
|
-
if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
|
|
1041
|
-
headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
|
|
1042
|
-
}
|
|
1043
1142
|
if (ws.enrichmentDisabled === true)
|
|
1044
1143
|
headerBits.push('enrichment: disabled');
|
|
1045
1144
|
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
1046
1145
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
1047
1146
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
1048
1147
|
}
|
|
1049
|
-
const footer = enrichmentFooter()
|
|
1148
|
+
const footer = `${enrichmentFooter()}${sortFooter()}`;
|
|
1050
1149
|
return { content: [{ type: 'text', text: `${text}${footer}` }] };
|
|
1051
1150
|
},
|
|
1052
1151
|
},
|
|
@@ -1767,6 +1866,22 @@ export const TOOL_REGISTRY = [
|
|
|
1767
1866
|
invalidateDocCache(resolveDocPath(sourceFilename));
|
|
1768
1867
|
invalidateBacklinksCache();
|
|
1769
1868
|
}
|
|
1869
|
+
// Right-rail Activity: one entry per new link. Wrapped in try/catch
|
|
1870
|
+
// because resolveDocTarget can throw on the rare race where one of
|
|
1871
|
+
// the docs was deleted between resolveDocId and now — the link itself
|
|
1872
|
+
// landed successfully, the activity entry is the only thing skipped.
|
|
1873
|
+
// adr: adr/right-rail.md
|
|
1874
|
+
try {
|
|
1875
|
+
const sourceTitle = resolveDocTarget(source_doc_id).title || sourceFilename;
|
|
1876
|
+
const targetTitle = resolveDocTarget(target_doc_id).title || targetFilename;
|
|
1877
|
+
broadcastActivityEvent({
|
|
1878
|
+
kind: 'backlinks-added',
|
|
1879
|
+
headline: `Linked ${sourceTitle} → ${targetTitle}`,
|
|
1880
|
+
docId: source_doc_id,
|
|
1881
|
+
filename: sourceFilename,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
catch { /* activity is best-effort */ }
|
|
1770
1885
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1771
1886
|
success: true,
|
|
1772
1887
|
sourceDocId: source_doc_id,
|
|
@@ -1801,18 +1916,26 @@ export const TOOL_REGISTRY = [
|
|
|
1801
1916
|
},
|
|
1802
1917
|
{
|
|
1803
1918
|
name: 'search_docs',
|
|
1804
|
-
description: 'Full-text search
|
|
1919
|
+
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.',
|
|
1805
1920
|
schema: {
|
|
1806
|
-
query: z.string().describe('Search query (case-insensitive substring match
|
|
1921
|
+
query: z.string().describe('Search query (case-insensitive substring match).'),
|
|
1922
|
+
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.'),
|
|
1807
1923
|
limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
|
|
1808
1924
|
},
|
|
1809
|
-
handler: async ({ query, limit = 10 }) => {
|
|
1925
|
+
handler: async ({ query, docId, limit = 10 }) => {
|
|
1810
1926
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1927
|
+
// Doc-scoped path: walk the doc's blocks and return matching nodes.
|
|
1928
|
+
if (docId) {
|
|
1929
|
+
const target = resolveDocTarget(docId);
|
|
1930
|
+
const matches = searchInDoc(target.document, query, cap);
|
|
1931
|
+
return { content: [{ type: 'text', text: JSON.stringify({ docId, total: matches.length, matches }) }] };
|
|
1932
|
+
}
|
|
1933
|
+
// Workspace-wide path: existing behavior.
|
|
1811
1934
|
const raw = searchDocuments(query);
|
|
1812
1935
|
// Enrich with docId + enrichment fields from frontmatter so the agent
|
|
1813
1936
|
// can rank/pick candidates without a follow-up body read.
|
|
1814
1937
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1815
|
-
let
|
|
1938
|
+
let resolvedDocId = null;
|
|
1816
1939
|
let logline;
|
|
1817
1940
|
let status;
|
|
1818
1941
|
let tags;
|
|
@@ -1820,7 +1943,7 @@ export const TOOL_REGISTRY = [
|
|
|
1820
1943
|
const filePath = resolveDocPath(r.filename);
|
|
1821
1944
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1822
1945
|
const fm = matter(fileRaw);
|
|
1823
|
-
|
|
1946
|
+
resolvedDocId = fm.data?.docId || null;
|
|
1824
1947
|
// v0.19.0 three-field schema: logline (LLM), status (agent), tags.
|
|
1825
1948
|
if (typeof fm.data?.logline === 'string')
|
|
1826
1949
|
logline = fm.data.logline;
|
|
@@ -1831,7 +1954,7 @@ export const TOOL_REGISTRY = [
|
|
|
1831
1954
|
}
|
|
1832
1955
|
catch { /* fields stay undefined */ }
|
|
1833
1956
|
return {
|
|
1834
|
-
docId,
|
|
1957
|
+
docId: resolvedDocId,
|
|
1835
1958
|
title: r.title,
|
|
1836
1959
|
filename: r.filename,
|
|
1837
1960
|
matchType: r.matchType,
|
|
@@ -1962,10 +2085,15 @@ export async function startMcpServer() {
|
|
|
1962
2085
|
// agent's system context. Empty string when no enrichment work is pending.
|
|
1963
2086
|
// See brief 2026-05-18-frontmatter-enrichment-system.
|
|
1964
2087
|
const enrichmentNotice = buildEnrichmentInstructions();
|
|
2088
|
+
const sortNotice = buildSortInstructions();
|
|
2089
|
+
// Stack notices in the MCP instructions field. Both notices stand on their
|
|
2090
|
+
// own — agents can read and dispatch them independently. Empty when neither
|
|
2091
|
+
// system has pending work.
|
|
2092
|
+
const combinedNotice = [enrichmentNotice, sortNotice].filter(Boolean).join('\n');
|
|
1965
2093
|
const server = new McpServer({
|
|
1966
2094
|
name: 'openwriter',
|
|
1967
2095
|
version: '0.2.0',
|
|
1968
|
-
},
|
|
2096
|
+
}, combinedNotice ? { instructions: combinedNotice } : undefined);
|
|
1969
2097
|
// Wrap each tool handler in withRequestId so every event logged during
|
|
1970
2098
|
// the tool's execution inherits the same request ID. Trace one MCP call
|
|
1971
2099
|
// through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
|