openwriter 0.15.0 → 0.17.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-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
- 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/compact.js +28 -2
- package/dist/server/documents.js +234 -3
- package/dist/server/enrichment.js +125 -0
- package/dist/server/export-routes.js +2 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +153 -14
- package/dist/server/markdown-serialize.js +100 -17
- package/dist/server/mcp.js +291 -25
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +19 -44
- package/dist/server/pending-overlay.js +21 -4
- package/dist/server/state.js +225 -41
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +2 -1
- package/skill/SKILL.md +38 -7
- package/skill/agents/openwriter-enrichment-minion.md +177 -0
- package/skill/docs/enrichment.md +179 -0
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
package/dist/server/mcp.js
CHANGED
|
@@ -10,8 +10,10 @@ 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,
|
|
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
18
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
17
19
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
@@ -114,6 +116,35 @@ function resolveDocTarget(docId) {
|
|
|
114
116
|
lastModified: statSync(filePath).mtime,
|
|
115
117
|
};
|
|
116
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Override the `autoAccept` field in a snapshot's frontmatter without
|
|
121
|
+
* reparsing the body. Used by `restore_version` to preserve the CURRENT
|
|
122
|
+
* user toggle (a per-doc UI preference) across a content-restore. Editing
|
|
123
|
+
* the frontmatter line directly avoids a full parse + reserialize, which
|
|
124
|
+
* would re-run the matcher and risk minor body-shape drift for what's
|
|
125
|
+
* supposed to be an exact content restore.
|
|
126
|
+
*
|
|
127
|
+
* adr: adr/pending-overlay-model.md
|
|
128
|
+
*/
|
|
129
|
+
function applyAutoAcceptOverride(snapshotMarkdown, currentAutoAccept) {
|
|
130
|
+
const fmMatch = snapshotMarkdown.match(/^---\n(.+?)\n---\n/s);
|
|
131
|
+
if (!fmMatch)
|
|
132
|
+
return snapshotMarkdown; // no frontmatter to update
|
|
133
|
+
try {
|
|
134
|
+
const fm = JSON.parse(fmMatch[1]);
|
|
135
|
+
if (currentAutoAccept) {
|
|
136
|
+
fm.autoAccept = true;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
delete fm.autoAccept;
|
|
140
|
+
}
|
|
141
|
+
const newFmLine = JSON.stringify(fm);
|
|
142
|
+
return snapshotMarkdown.replace(/^---\n.+?\n---\n/s, `---\n${newFmLine}\n---\n`);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return snapshotMarkdown; // malformed frontmatter — leave alone
|
|
146
|
+
}
|
|
147
|
+
}
|
|
117
148
|
/** Human-friendly relative time for ISO timestamps. */
|
|
118
149
|
function relativeTime(iso) {
|
|
119
150
|
const then = new Date(iso).getTime();
|
|
@@ -332,7 +363,7 @@ export const TOOL_REGISTRY = [
|
|
|
332
363
|
},
|
|
333
364
|
{
|
|
334
365
|
name: 'list_documents',
|
|
335
|
-
description: 'List all documents. Shows title, docId
|
|
366
|
+
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.',
|
|
336
367
|
schema: {},
|
|
337
368
|
handler: async () => {
|
|
338
369
|
const docs = listDocuments();
|
|
@@ -340,9 +371,21 @@ export const TOOL_REGISTRY = [
|
|
|
340
371
|
const active = d.isActive ? ' (active)' : '';
|
|
341
372
|
const id = d.docId ? ` [${d.docId}]` : '';
|
|
342
373
|
const date = d.lastModified.split('T')[0];
|
|
343
|
-
|
|
374
|
+
const enrichBits = [];
|
|
375
|
+
if (d.domain)
|
|
376
|
+
enrichBits.push(d.domain);
|
|
377
|
+
if (d.docRole)
|
|
378
|
+
enrichBits.push(d.docRole);
|
|
379
|
+
if (d.enrichmentStale === true)
|
|
380
|
+
enrichBits.push('STALE');
|
|
381
|
+
const enrichTag = enrichBits.length > 0 ? ` (${enrichBits.join(', ')})` : '';
|
|
382
|
+
const main = ` "${d.title}"${id}${active}${enrichTag} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
383
|
+
if (d.logline)
|
|
384
|
+
return `${main}\n → ${d.logline}`;
|
|
385
|
+
return main;
|
|
344
386
|
});
|
|
345
|
-
|
|
387
|
+
const footer = enrichmentFooter();
|
|
388
|
+
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
346
389
|
},
|
|
347
390
|
},
|
|
348
391
|
{
|
|
@@ -749,6 +792,117 @@ export const TOOL_REGISTRY = [
|
|
|
749
792
|
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
750
793
|
},
|
|
751
794
|
},
|
|
795
|
+
{
|
|
796
|
+
name: 'mark_enriched',
|
|
797
|
+
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.',
|
|
798
|
+
schema: {
|
|
799
|
+
docs: z.array(z.object({
|
|
800
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
801
|
+
logline: z.string().optional().describe('Précis (non-fiction) or logline (fiction). Under 250 chars. Describe the content, not the kind of doc.'),
|
|
802
|
+
domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
|
|
803
|
+
concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
|
|
804
|
+
docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
|
|
805
|
+
status: z.string().optional().describe('Doc status: draft / canonical / stale. Archive state is implied by archivedAt.'),
|
|
806
|
+
})).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array.'),
|
|
807
|
+
},
|
|
808
|
+
handler: async ({ docs }) => {
|
|
809
|
+
const now = new Date().toISOString();
|
|
810
|
+
const results = [];
|
|
811
|
+
let anyTitleSideEffect = false;
|
|
812
|
+
for (const item of docs) {
|
|
813
|
+
try {
|
|
814
|
+
const target = resolveDocTarget(item.docId);
|
|
815
|
+
// Harvest current sentence hashes + char count from canonical view.
|
|
816
|
+
// Active doc: getCanonical() returns the no-overlay primary state.
|
|
817
|
+
// Non-active: cloneWithPendingReverted on the cached/loaded document.
|
|
818
|
+
const canonical = target.isActive
|
|
819
|
+
? getCanonical()
|
|
820
|
+
: cloneWithPendingReverted(target.document);
|
|
821
|
+
const blocks = tiptapToBlocks(canonical);
|
|
822
|
+
const lastEnrichedSentences = harvestSentenceHashes(blocks);
|
|
823
|
+
const lastEnrichedCharCount = harvestCharCount(blocks);
|
|
824
|
+
// Build the atomic enrichment payload.
|
|
825
|
+
const update = {
|
|
826
|
+
lastEnrichedAt: now,
|
|
827
|
+
lastEnrichedCharCount,
|
|
828
|
+
lastEnrichedSentences,
|
|
829
|
+
enrichmentStale: false,
|
|
830
|
+
};
|
|
831
|
+
if (item.logline !== undefined)
|
|
832
|
+
update.logline = item.logline;
|
|
833
|
+
if (item.domain !== undefined)
|
|
834
|
+
update.domain = item.domain;
|
|
835
|
+
if (item.concepts !== undefined)
|
|
836
|
+
update.concepts = item.concepts;
|
|
837
|
+
if (item.docRole !== undefined)
|
|
838
|
+
update.docRole = item.docRole;
|
|
839
|
+
if (item.status !== undefined)
|
|
840
|
+
update.status = item.status;
|
|
841
|
+
if (target.isActive) {
|
|
842
|
+
// Active doc: setMetadata mutates state.metadata but doesn't bump
|
|
843
|
+
// docVersion on its own — without an explicit bump, save() would
|
|
844
|
+
// hit the no-op gate (docVersion === lastSavedDocVersion when
|
|
845
|
+
// there's no body change). bumpDocVersion forces save() through.
|
|
846
|
+
// writeToDisk's staleness check will see the just-stamped baseline
|
|
847
|
+
// (volumeRatio=1, drift=0) and NOT flip the flag back to true.
|
|
848
|
+
setMetadata(update);
|
|
849
|
+
bumpDocVersion();
|
|
850
|
+
save();
|
|
851
|
+
broadcastMetadataChanged(getMetadata());
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
// Non-active: write directly to disk, bypassing flushDocToFile's
|
|
855
|
+
// staleness check (which would otherwise see stale state for one
|
|
856
|
+
// serialize cycle before the new baseline lands). Disk write +
|
|
857
|
+
// cache invalidation mirrors set_metadata's non-active path.
|
|
858
|
+
const newMeta = { ...target.metadata, ...update };
|
|
859
|
+
const markdown = tiptapToMarkdown(target.document, target.title, newMeta);
|
|
860
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
861
|
+
invalidateDocCache(target.filePath);
|
|
862
|
+
}
|
|
863
|
+
results.push({ docId: item.docId, ok: true });
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
// Single broadcast at the end so sidebar refreshes once for the whole batch.
|
|
870
|
+
broadcastDocumentsChanged();
|
|
871
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
872
|
+
const failCount = results.length - okCount;
|
|
873
|
+
const summary = failCount === 0
|
|
874
|
+
? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
|
|
875
|
+
: `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
876
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
877
|
+
},
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
name: 'list_dirty_docs',
|
|
881
|
+
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.',
|
|
882
|
+
schema: {
|
|
883
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
884
|
+
},
|
|
885
|
+
handler: async ({ workspaceFile }) => {
|
|
886
|
+
const docs = listDirtyDocs(workspaceFile);
|
|
887
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
name: 'crawl',
|
|
892
|
+
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.',
|
|
893
|
+
schema: {
|
|
894
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
895
|
+
domain: z.string().optional().describe('Exact domain match.'),
|
|
896
|
+
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
897
|
+
concepts: z.array(z.string()).optional().describe('Docs must reference ALL listed concepts.'),
|
|
898
|
+
docRole: z.string().optional().describe('Exact docRole match (canonical / vignette / reference / draft / chapter / beat).'),
|
|
899
|
+
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
900
|
+
},
|
|
901
|
+
handler: async (filter) => {
|
|
902
|
+
const docs = crawlDocs(filter);
|
|
903
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
904
|
+
},
|
|
905
|
+
},
|
|
752
906
|
{
|
|
753
907
|
name: 'list_workspaces',
|
|
754
908
|
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
@@ -756,7 +910,8 @@ export const TOOL_REGISTRY = [
|
|
|
756
910
|
handler: async () => {
|
|
757
911
|
const workspaces = listWorkspaces();
|
|
758
912
|
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
759
|
-
|
|
913
|
+
const footer = enrichmentFooter();
|
|
914
|
+
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
760
915
|
},
|
|
761
916
|
},
|
|
762
917
|
{
|
|
@@ -791,57 +946,136 @@ export const TOOL_REGISTRY = [
|
|
|
791
946
|
},
|
|
792
947
|
{
|
|
793
948
|
name: 'get_workspace_structure',
|
|
794
|
-
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
|
|
949
|
+
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.',
|
|
795
950
|
schema: {
|
|
796
951
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
797
952
|
},
|
|
798
953
|
handler: async ({ filename }) => {
|
|
799
954
|
const ws = getWorkspace(filename);
|
|
955
|
+
// Build a one-pass map of filename → frontmatter so we don't re-read each
|
|
956
|
+
// doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
|
|
957
|
+
const enriched = crawlDocs({ workspaceFile: filename });
|
|
958
|
+
const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
|
|
800
959
|
function renderTree(nodes, indent) {
|
|
801
960
|
const lines = [];
|
|
802
961
|
for (const node of nodes) {
|
|
803
962
|
if (node.type === 'doc') {
|
|
804
|
-
const
|
|
805
|
-
const
|
|
806
|
-
|
|
963
|
+
const e = enrichByFile.get(node.file);
|
|
964
|
+
const tags = e?.tags ?? [];
|
|
965
|
+
const enrichBits = [];
|
|
966
|
+
if (e?.domain)
|
|
967
|
+
enrichBits.push(e.domain);
|
|
968
|
+
if (e?.docRole)
|
|
969
|
+
enrichBits.push(e.docRole);
|
|
970
|
+
if (tags.length > 0)
|
|
971
|
+
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
972
|
+
if (e?.enrichmentStale === true)
|
|
973
|
+
enrichBits.push('STALE');
|
|
974
|
+
const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
|
|
975
|
+
const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
|
|
976
|
+
lines.push(docLine);
|
|
977
|
+
if (e?.logline)
|
|
978
|
+
lines.push(`${indent} → ${e.logline}`);
|
|
807
979
|
}
|
|
808
980
|
else {
|
|
809
|
-
|
|
981
|
+
const cBits = [];
|
|
982
|
+
if (node.role)
|
|
983
|
+
cBits.push(node.role);
|
|
984
|
+
const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
|
|
985
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
|
|
986
|
+
if (node.logline)
|
|
987
|
+
lines.push(`${indent} → ${node.logline}`);
|
|
810
988
|
lines.push(...renderTree(node.items, indent + ' '));
|
|
811
989
|
}
|
|
812
990
|
}
|
|
813
991
|
return lines;
|
|
814
992
|
}
|
|
815
993
|
const treeLines = renderTree(ws.root, ' ');
|
|
816
|
-
|
|
994
|
+
const headerBits = [`workspace: "${ws.title}"`];
|
|
995
|
+
if (ws.logline)
|
|
996
|
+
headerBits.push(`logline: ${ws.logline}`);
|
|
997
|
+
if (ws.domain)
|
|
998
|
+
headerBits.push(`domain: ${ws.domain}`);
|
|
999
|
+
if (ws.schema)
|
|
1000
|
+
headerBits.push(`schema: ${ws.schema}`);
|
|
1001
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
|
|
1002
|
+
headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
|
|
1003
|
+
}
|
|
1004
|
+
if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
|
|
1005
|
+
headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
|
|
1006
|
+
}
|
|
1007
|
+
if (ws.enrichmentDisabled === true)
|
|
1008
|
+
headerBits.push('enrichment: disabled');
|
|
1009
|
+
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
817
1010
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
818
1011
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
819
1012
|
}
|
|
820
|
-
|
|
1013
|
+
const footer = enrichmentFooter();
|
|
1014
|
+
return { content: [{ type: 'text', text: `${text}${footer}` }] };
|
|
821
1015
|
},
|
|
822
1016
|
},
|
|
823
1017
|
{
|
|
824
1018
|
name: 'get_item_context',
|
|
825
|
-
description: 'Get progressive
|
|
1019
|
+
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.',
|
|
826
1020
|
schema: {
|
|
827
1021
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
828
1022
|
docId: z.string().describe('Document docId (8-char hex from list_documents)'),
|
|
829
1023
|
},
|
|
830
1024
|
handler: async ({ workspaceFile, docId }) => {
|
|
831
1025
|
const filename = resolveDocId(docId);
|
|
832
|
-
|
|
1026
|
+
const base = getItemContext(workspaceFile, filename);
|
|
1027
|
+
// Layer doc-level enrichment + workspace-level vocab/schema into the response.
|
|
1028
|
+
// The agent's crawl pattern: get_item_context for one doc → see logline +
|
|
1029
|
+
// domain + concepts. Decide whether the doc warrants a body read.
|
|
1030
|
+
try {
|
|
1031
|
+
const ws = getWorkspace(workspaceFile);
|
|
1032
|
+
const enriched = crawlDocs({ workspaceFile });
|
|
1033
|
+
const docEnrich = enriched.find((e) => e.filename === filename);
|
|
1034
|
+
if (docEnrich) {
|
|
1035
|
+
if (docEnrich.logline)
|
|
1036
|
+
base.logline = docEnrich.logline;
|
|
1037
|
+
if (docEnrich.domain)
|
|
1038
|
+
base.domain = docEnrich.domain;
|
|
1039
|
+
if (docEnrich.concepts)
|
|
1040
|
+
base.concepts = docEnrich.concepts;
|
|
1041
|
+
if (docEnrich.docRole)
|
|
1042
|
+
base.docRole = docEnrich.docRole;
|
|
1043
|
+
if (docEnrich.status)
|
|
1044
|
+
base.status = docEnrich.status;
|
|
1045
|
+
if (docEnrich.enrichmentStale === true)
|
|
1046
|
+
base.enrichmentStale = true;
|
|
1047
|
+
}
|
|
1048
|
+
if (ws.schema)
|
|
1049
|
+
base.workspaceSchema = ws.schema;
|
|
1050
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0)
|
|
1051
|
+
base.workspaceVocab = ws.vocab;
|
|
1052
|
+
if (ws.logline)
|
|
1053
|
+
base.workspaceLogline = ws.logline;
|
|
1054
|
+
if (ws.domain)
|
|
1055
|
+
base.workspaceDomain = ws.domain;
|
|
1056
|
+
}
|
|
1057
|
+
catch { /* best-effort enrichment overlay */ }
|
|
1058
|
+
return { content: [{ type: 'text', text: JSON.stringify(base, null, 2) }] };
|
|
833
1059
|
},
|
|
834
1060
|
},
|
|
835
1061
|
{
|
|
836
1062
|
name: 'update_workspace_context',
|
|
837
|
-
description: 'Update
|
|
1063
|
+
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.',
|
|
838
1064
|
schema: {
|
|
839
1065
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
840
1066
|
context: z.object({
|
|
841
|
-
characters: z.record(z.string()).optional().describe('Character name → description'),
|
|
842
|
-
settings: z.record(z.string()).optional().describe('Setting name → description'),
|
|
843
|
-
rules: z.array(z.string()).optional().describe('Writing rules for this workspace'),
|
|
844
|
-
|
|
1067
|
+
characters: z.record(z.string()).optional().describe('Character name → description (merged)'),
|
|
1068
|
+
settings: z.record(z.string()).optional().describe('Setting name → description (merged)'),
|
|
1069
|
+
rules: z.array(z.string()).optional().describe('Writing rules for this workspace (replaces)'),
|
|
1070
|
+
logline: z.string().nullable().optional().describe('One-sentence "what this workspace is for". Set null to clear.'),
|
|
1071
|
+
domain: z.string().nullable().optional().describe('Subject area (e.g. "Male ethology"). Set null to clear.'),
|
|
1072
|
+
schema: z.string().nullable().optional().describe('Workspace kind: book / concept-library / inbox / social / reference. Set null to clear.'),
|
|
1073
|
+
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).'),
|
|
1074
|
+
relatedWorkspaces: z.array(z.string()).nullable().optional().describe('Sibling workspace filenames. Set null to clear.'),
|
|
1075
|
+
enrichmentVolumeThreshold: z.number().nullable().optional().describe('Volume-ratio threshold (default 1.5). Set null to revert.'),
|
|
1076
|
+
enrichmentDriftThreshold: z.number().nullable().optional().describe('Jaccard-drift threshold (default 0.3). Set null to revert.'),
|
|
1077
|
+
enrichmentDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of enrichment surfacing. Set null or false to re-enable.'),
|
|
1078
|
+
}).describe('Writing context + enrichment config to apply'),
|
|
845
1079
|
},
|
|
846
1080
|
handler: async ({ workspaceFile, context }) => {
|
|
847
1081
|
updateWorkspaceContext(workspaceFile, context);
|
|
@@ -1250,9 +1484,19 @@ export const TOOL_REGISTRY = [
|
|
|
1250
1484
|
}
|
|
1251
1485
|
catch { /* best effort */ }
|
|
1252
1486
|
// Read the target snapshot's content
|
|
1253
|
-
const
|
|
1254
|
-
if (!
|
|
1487
|
+
const rawSnapshot = getVersionContent(target.docId, timestamp);
|
|
1488
|
+
if (!rawSnapshot)
|
|
1255
1489
|
return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
|
|
1490
|
+
// Preserve the CURRENT autoAccept setting rather than rolling it back
|
|
1491
|
+
// to the snapshot-era value. `autoAccept` is a per-doc user preference
|
|
1492
|
+
// (toggled in the sidebar) that governs how FUTURE writes behave —
|
|
1493
|
+
// it's not document content. Without this, a user who toggled
|
|
1494
|
+
// autoAccept off to review incoming changes would silently lose that
|
|
1495
|
+
// preference when the agent calls restore_version, and the next
|
|
1496
|
+
// write_to_pad would auto-apply instead of arriving as pending.
|
|
1497
|
+
// adr: adr/pending-overlay-model.md
|
|
1498
|
+
const currentAutoAccept = target.metadata?.autoAccept === true;
|
|
1499
|
+
const snapshotMarkdown = applyAutoAcceptOverride(rawSnapshot, currentAutoAccept);
|
|
1256
1500
|
// Write the snapshot directly to disk — this becomes the new canonical.
|
|
1257
1501
|
// The pending overlay sidecar is unchanged; on reload, the matcher
|
|
1258
1502
|
// re-pairs nodeIds and pending decorations re-attach where possible.
|
|
@@ -1551,16 +1795,29 @@ export const TOOL_REGISTRY = [
|
|
|
1551
1795
|
handler: async ({ query, limit = 10 }) => {
|
|
1552
1796
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1553
1797
|
const raw = searchDocuments(query);
|
|
1554
|
-
// Enrich with docId
|
|
1798
|
+
// Enrich with docId + enrichment fields from frontmatter so the agent
|
|
1799
|
+
// can rank/pick candidates without a follow-up body read.
|
|
1555
1800
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1556
1801
|
let docId = null;
|
|
1802
|
+
let logline;
|
|
1803
|
+
let domain;
|
|
1804
|
+
let docRole;
|
|
1805
|
+
let tags;
|
|
1557
1806
|
try {
|
|
1558
1807
|
const filePath = resolveDocPath(r.filename);
|
|
1559
1808
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1560
1809
|
const fm = matter(fileRaw);
|
|
1561
1810
|
docId = fm.data?.docId || null;
|
|
1811
|
+
if (typeof fm.data?.logline === 'string')
|
|
1812
|
+
logline = fm.data.logline;
|
|
1813
|
+
if (typeof fm.data?.domain === 'string')
|
|
1814
|
+
domain = fm.data.domain;
|
|
1815
|
+
if (typeof fm.data?.docRole === 'string')
|
|
1816
|
+
docRole = fm.data.docRole;
|
|
1817
|
+
if (Array.isArray(fm.data?.tags) && fm.data.tags.length > 0)
|
|
1818
|
+
tags = fm.data.tags;
|
|
1562
1819
|
}
|
|
1563
|
-
catch { /*
|
|
1820
|
+
catch { /* fields stay undefined */ }
|
|
1564
1821
|
return {
|
|
1565
1822
|
docId,
|
|
1566
1823
|
title: r.title,
|
|
@@ -1568,6 +1825,10 @@ export const TOOL_REGISTRY = [
|
|
|
1568
1825
|
matchType: r.matchType,
|
|
1569
1826
|
snippet: r.snippet,
|
|
1570
1827
|
matchedTag: r.matchedTag,
|
|
1828
|
+
...(logline ? { logline } : {}),
|
|
1829
|
+
...(domain ? { domain } : {}),
|
|
1830
|
+
...(docRole ? { docRole } : {}),
|
|
1831
|
+
...(tags ? { tags } : {}),
|
|
1571
1832
|
};
|
|
1572
1833
|
});
|
|
1573
1834
|
return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
|
|
@@ -1695,10 +1956,15 @@ export function removePluginTools(names) {
|
|
|
1695
1956
|
}
|
|
1696
1957
|
}
|
|
1697
1958
|
export async function startMcpServer() {
|
|
1959
|
+
// Build session-start enrichment notice. Read once at boot — MCP's instructions
|
|
1960
|
+
// field is delivered as part of the InitializeResult and becomes part of the
|
|
1961
|
+
// agent's system context. Empty string when no enrichment work is pending.
|
|
1962
|
+
// See brief 2026-05-18-frontmatter-enrichment-system.
|
|
1963
|
+
const enrichmentNotice = buildEnrichmentInstructions();
|
|
1698
1964
|
const server = new McpServer({
|
|
1699
1965
|
name: 'openwriter',
|
|
1700
1966
|
version: '0.2.0',
|
|
1701
|
-
});
|
|
1967
|
+
}, enrichmentNotice ? { instructions: enrichmentNotice } : undefined);
|
|
1702
1968
|
// Wrap each tool handler in withRequestId so every event logged during
|
|
1703
1969
|
// the tool's execution inherits the same request ID. Trace one MCP call
|
|
1704
1970
|
// through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
|
|
@@ -36,6 +36,8 @@ const CONTAINER_TYPES = new Set([
|
|
|
36
36
|
'tableRow',
|
|
37
37
|
'tableCell',
|
|
38
38
|
'tableHeader',
|
|
39
|
+
'footnoteSection',
|
|
40
|
+
'footnoteDefinition',
|
|
39
41
|
]);
|
|
40
42
|
function walkNodes(nodes, blocks, parentPosition) {
|
|
41
43
|
let ordinalInParent = 0;
|
|
@@ -105,6 +107,40 @@ function walkNodes(nodes, blocks, parentPosition) {
|
|
|
105
107
|
});
|
|
106
108
|
walkNodes(node.content || [], blocks, bqPosition);
|
|
107
109
|
}
|
|
110
|
+
else if (node.type === 'footnoteSection') {
|
|
111
|
+
// Container holding all `footnoteDefinition`s at end-of-doc. Treated
|
|
112
|
+
// like a blockquote: container fingerprint is content-empty, identity
|
|
113
|
+
// travels through the matcher's structural rules. The serializer
|
|
114
|
+
// enforces end-of-doc position regardless of where it appears in the
|
|
115
|
+
// tree, so position-stability is guaranteed at the boundary.
|
|
116
|
+
// adr: adr/footnote-system.md
|
|
117
|
+
const sectionPosition = blocks.length;
|
|
118
|
+
blocks.push({
|
|
119
|
+
position: sectionPosition,
|
|
120
|
+
type: 'footnoteSection',
|
|
121
|
+
text: '',
|
|
122
|
+
parentPosition,
|
|
123
|
+
ordinalInParent: ordinalInParent++,
|
|
124
|
+
id: node.attrs?.id,
|
|
125
|
+
});
|
|
126
|
+
walkNodes(node.content || [], blocks, sectionPosition);
|
|
127
|
+
}
|
|
128
|
+
else if (node.type === 'footnoteDefinition') {
|
|
129
|
+
// Container for one footnote's content (typically a single paragraph,
|
|
130
|
+
// occasionally multiple). The label attr round-trips with the node;
|
|
131
|
+
// the matcher fingerprints by content + slot like any container.
|
|
132
|
+
// adr: adr/footnote-system.md
|
|
133
|
+
const defPosition = blocks.length;
|
|
134
|
+
blocks.push({
|
|
135
|
+
position: defPosition,
|
|
136
|
+
type: 'footnoteDefinition',
|
|
137
|
+
text: firstParagraphText(node.content || []),
|
|
138
|
+
parentPosition,
|
|
139
|
+
ordinalInParent: ordinalInParent++,
|
|
140
|
+
id: node.attrs?.id,
|
|
141
|
+
});
|
|
142
|
+
walkNodes(node.content || [], blocks, defPosition);
|
|
143
|
+
}
|
|
108
144
|
else if (node.type === 'codeBlock') {
|
|
109
145
|
const text = extractInlineText(node.content || []);
|
|
110
146
|
blocks.push({
|
|
@@ -236,6 +272,8 @@ export function applyIdsToTiptap(doc, pinnedByPosition) {
|
|
|
236
272
|
node.type === 'horizontalRule' ||
|
|
237
273
|
node.type === 'table' ||
|
|
238
274
|
node.type === 'image' ||
|
|
275
|
+
node.type === 'footnoteSection' ||
|
|
276
|
+
node.type === 'footnoteDefinition' ||
|
|
239
277
|
CONTAINER_TYPES.has(node.type);
|
|
240
278
|
if (isBlock) {
|
|
241
279
|
const id = pinnedByPosition.get(position);
|
|
@@ -261,7 +299,9 @@ export function applyIdsToTiptap(doc, pinnedByPosition) {
|
|
|
261
299
|
node.type === 'taskList' ||
|
|
262
300
|
node.type === 'listItem' ||
|
|
263
301
|
node.type === 'taskItem' ||
|
|
264
|
-
node.type === 'blockquote'
|
|
302
|
+
node.type === 'blockquote' ||
|
|
303
|
+
node.type === 'footnoteSection' ||
|
|
304
|
+
node.type === 'footnoteDefinition';
|
|
265
305
|
if (isContainer && node.content)
|
|
266
306
|
walk(node.content);
|
|
267
307
|
}
|