openwriter 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-CbSQ8xxn.css +1 -0
- package/dist/client/assets/{index-B5MXw2pg.js → index-JMMJM_G_.js} +53 -53
- 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/documents.js +234 -3
- package/dist/server/enrichment.js +114 -0
- package/dist/server/install-skill.js +15 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +14 -16
- package/dist/server/mcp.js +250 -23
- 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 +203 -26
- package/dist/server/workspaces.js +27 -5
- package/dist/server/ws.js +10 -0
- package/package.json +1 -1
- package/skill/SKILL.md +26 -7
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- package/dist/client/assets/index-B3iORmCT.css +0 -1
|
@@ -136,6 +136,21 @@ export function installSkill() {
|
|
|
136
136
|
}
|
|
137
137
|
log(` ✓ Skill docs copied to ${docsTarget}`);
|
|
138
138
|
}
|
|
139
|
+
// Install custom Claude Code subagents to ~/.claude/agents/. These have
|
|
140
|
+
// allowlist-restricted tools so the main agent can dispatch them without
|
|
141
|
+
// loading the full MCP tool registry into the subagent's context
|
|
142
|
+
// (~50K tokens of overhead avoided per spawn).
|
|
143
|
+
const agentsSource = path.join(__dirname, '../../skill/agents');
|
|
144
|
+
if (fs.existsSync(agentsSource)) {
|
|
145
|
+
const agentsTarget = path.join(os.homedir(), '.claude', 'agents');
|
|
146
|
+
fs.mkdirSync(agentsTarget, { recursive: true });
|
|
147
|
+
for (const file of fs.readdirSync(agentsSource)) {
|
|
148
|
+
if (!file.endsWith('.md'))
|
|
149
|
+
continue;
|
|
150
|
+
fs.copyFileSync(path.join(agentsSource, file), path.join(agentsTarget, file));
|
|
151
|
+
log(` ✓ Subagent installed: ${file}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
139
154
|
// Step 2: Global install or update
|
|
140
155
|
let useNpx = false;
|
|
141
156
|
const currentVersion = getGlobalVersion();
|
|
@@ -21,6 +21,7 @@ import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
|
21
21
|
import { nodeText } from './markdown-serialize.js';
|
|
22
22
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
23
23
|
import { matchNodes } from './node-matcher.js';
|
|
24
|
+
import { enrichEntries, enrichSlimArray, fingerprintAll, isLegacyRawEntry, anyLegacyRaw, } from './node-fingerprint.js';
|
|
24
25
|
// ============================================================================
|
|
25
26
|
// Markdown -> TipTap
|
|
26
27
|
// ============================================================================
|
|
@@ -122,14 +123,15 @@ export function markdownToTiptap(markdown) {
|
|
|
122
123
|
type: 'doc',
|
|
123
124
|
content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
124
125
|
};
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
// Resolve identity graph from frontmatter. Two on-disk formats live in the
|
|
127
|
+
// wild: ultra-lean slim tuples (current) and legacy verbose objects (v0.14
|
|
128
|
+
// and v0.15). Legacy entries get positionally re-fingerprinted from the
|
|
129
|
+
// freshly-parsed body — the body IS the previous state at load time, and
|
|
130
|
+
// re-fingerprinting produces hashes the matcher can pin against cleanly.
|
|
131
|
+
// adr: adr/node-identity-matcher.md
|
|
132
|
+
const blocksForEnrich = tiptapToBlocks(doc);
|
|
133
|
+
const previousNodes = resolvePreviousNodes(data.nodes, blocksForEnrich);
|
|
134
|
+
const graveyard = resolveGraveyard(data.graveyard);
|
|
133
135
|
if (previousNodes.length > 0) {
|
|
134
136
|
applyMatcher(doc, previousNodes, graveyard);
|
|
135
137
|
}
|
|
@@ -151,13 +153,68 @@ export function markdownToTiptap(markdown) {
|
|
|
151
153
|
previousNodes,
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Resolve `nodes:` frontmatter into rich NodeEntry[] suitable for the matcher.
|
|
158
|
+
*
|
|
159
|
+
* Two on-disk formats:
|
|
160
|
+
* - Ultra-lean: each entry is an array tuple. enrichSlimArray derives all
|
|
161
|
+
* positional/structural fields from the slim array itself — no body parse
|
|
162
|
+
* needed. The slim array IS the previous state (position = array index,
|
|
163
|
+
* parent = most-recent unfilled container, neighbors = slim[i±1]).
|
|
164
|
+
* - Legacy (v0.14/v0.15): each entry is an object with `id` and `fp` keys.
|
|
165
|
+
* We re-fingerprint positionally from the body — the body IS the previous
|
|
166
|
+
* state at load time, and a fresh fingerprint over the same body produces
|
|
167
|
+
* hashes the matcher can pin against. After the next save, disk is in the
|
|
168
|
+
* ultra-lean format and the body-parse cost drops away.
|
|
169
|
+
*
|
|
170
|
+
* `blocks` is only consulted for the legacy path; slim path ignores it. Pass
|
|
171
|
+
* an empty array when you only have slim input to avoid the body parse cost.
|
|
172
|
+
*/
|
|
173
|
+
export function resolvePreviousNodes(raw, blocks) {
|
|
174
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
157
175
|
return [];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
176
|
+
if (anyLegacyRaw(raw)) {
|
|
177
|
+
// Positional re-fingerprint: take each legacy entry's id, assign it to a
|
|
178
|
+
// freshly-computed fingerprint at the same position in the body.
|
|
179
|
+
const freshFps = fingerprintAll(blocks);
|
|
180
|
+
const out = [];
|
|
181
|
+
for (let i = 0; i < raw.length; i++) {
|
|
182
|
+
const r = raw[i];
|
|
183
|
+
const id = isLegacyRawEntry(r) ? r.id : (Array.isArray(r) ? r[0] : null);
|
|
184
|
+
if (!id || typeof id !== 'string' || !freshFps[i])
|
|
185
|
+
continue;
|
|
186
|
+
out.push({ id, fingerprint: freshFps[i] });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
// Ultra-lean: walk the slim array directly. No body parse required.
|
|
191
|
+
return enrichSlimArray(raw).map((e) => ({
|
|
192
|
+
id: e.id,
|
|
193
|
+
fingerprint: e.fingerprint,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolve `graveyard:` frontmatter into rich NodeEntry[].
|
|
198
|
+
*
|
|
199
|
+
* Ultra-lean tuples enrich without block context (deleted blocks have no
|
|
200
|
+
* body). Derived fields default to safe values; matcher rules for graveyard
|
|
201
|
+
* restore only consult type + sentences + structureSig + childTypes, all
|
|
202
|
+
* carried in slim. Legacy graveyard entries are dropped — their stored
|
|
203
|
+
* fingerprints don't translate to the new hash semantics (terminator is now
|
|
204
|
+
* folded into the hash), so they'd never match a fresh paste-back anyway.
|
|
205
|
+
*/
|
|
206
|
+
export function resolveGraveyard(raw) {
|
|
207
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
208
|
+
return [];
|
|
209
|
+
if (anyLegacyRaw(raw)) {
|
|
210
|
+
// Mixed input: drop legacy entries, enrich slim ones.
|
|
211
|
+
const slimOnly = raw.filter((r) => Array.isArray(r));
|
|
212
|
+
return enrichEntries(slimOnly, []).map((e) => ({ id: e.id, fingerprint: e.fingerprint }));
|
|
213
|
+
}
|
|
214
|
+
return enrichEntries(raw, []).map((e) => ({
|
|
215
|
+
id: e.id,
|
|
216
|
+
fingerprint: e.fingerprint,
|
|
217
|
+
}));
|
|
161
218
|
}
|
|
162
219
|
/**
|
|
163
220
|
* Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
20
20
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
21
|
-
import { fingerprintAll } from './node-fingerprint.js';
|
|
21
|
+
import { fingerprintAll, slimEntry } from './node-fingerprint.js';
|
|
22
22
|
// ============================================================================
|
|
23
23
|
// TipTap -> Markdown
|
|
24
24
|
// ============================================================================
|
|
@@ -74,25 +74,23 @@ function collectPendingState(doc) {
|
|
|
74
74
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
75
75
|
}
|
|
76
76
|
/**
|
|
77
|
-
* Build the `nodes` frontmatter entry — one
|
|
78
|
-
*
|
|
77
|
+
* Build the `nodes` frontmatter entry — one slim tuple per block in
|
|
78
|
+
* pre-order traversal of the TipTap tree.
|
|
79
79
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* the visible doc).
|
|
80
|
+
* Disk shape is the ultra-lean tuple form from node-fingerprint.ts. Derived
|
|
81
|
+
* fields (position, parent indices, neighbor types, char/word counts) are
|
|
82
|
+
* recomputed at load time from the block tree itself — they don't go to disk.
|
|
84
83
|
*/
|
|
85
84
|
function collectNodesFrontmatter(doc) {
|
|
86
85
|
const blocks = tiptapToBlocks(doc);
|
|
87
86
|
const fingerprints = fingerprintAll(blocks);
|
|
88
87
|
const ids = collectBlockIds(doc);
|
|
89
|
-
|
|
90
|
-
const entries = [];
|
|
88
|
+
const out = [];
|
|
91
89
|
for (let i = 0; i < blocks.length; i++) {
|
|
92
90
|
const id = ids[i] || generateNodeId();
|
|
93
|
-
|
|
91
|
+
out.push(slimEntry(id, fingerprints[i]));
|
|
94
92
|
}
|
|
95
|
-
return
|
|
93
|
+
return out;
|
|
96
94
|
}
|
|
97
95
|
/**
|
|
98
96
|
* Cap graveyard size to avoid frontmatter bloat on docs with many edits.
|
|
@@ -156,12 +154,12 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
156
154
|
else {
|
|
157
155
|
delete meta.nodes;
|
|
158
156
|
}
|
|
159
|
-
// Graveyard: recently-orphaned
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// we cap it here to keep the file small.
|
|
157
|
+
// Graveyard: recently-orphaned entries kept across saves so paste-back/undo
|
|
158
|
+
// can restore the original ID via exact fingerprint match. Caller passes
|
|
159
|
+
// `{id, fingerprint}` objects (matcher output); we cap, slim, and emit.
|
|
163
160
|
if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
|
|
164
|
-
|
|
161
|
+
const capped = meta.graveyard.slice(0, GRAVEYARD_MAX);
|
|
162
|
+
meta.graveyard = capped.map((g) => Array.isArray(g) ? g : slimEntry(g.id, g.fingerprint || g.fp));
|
|
165
163
|
}
|
|
166
164
|
else {
|
|
167
165
|
delete meta.graveyard;
|
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';
|
|
@@ -332,7 +334,7 @@ export const TOOL_REGISTRY = [
|
|
|
332
334
|
},
|
|
333
335
|
{
|
|
334
336
|
name: 'list_documents',
|
|
335
|
-
description: 'List all documents. Shows title, docId
|
|
337
|
+
description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline, domain, docRole) when present. Use the docId to target documents in other tools.',
|
|
336
338
|
schema: {},
|
|
337
339
|
handler: async () => {
|
|
338
340
|
const docs = listDocuments();
|
|
@@ -340,9 +342,21 @@ export const TOOL_REGISTRY = [
|
|
|
340
342
|
const active = d.isActive ? ' (active)' : '';
|
|
341
343
|
const id = d.docId ? ` [${d.docId}]` : '';
|
|
342
344
|
const date = d.lastModified.split('T')[0];
|
|
343
|
-
|
|
345
|
+
const enrichBits = [];
|
|
346
|
+
if (d.domain)
|
|
347
|
+
enrichBits.push(d.domain);
|
|
348
|
+
if (d.docRole)
|
|
349
|
+
enrichBits.push(d.docRole);
|
|
350
|
+
if (d.enrichmentStale === true)
|
|
351
|
+
enrichBits.push('STALE');
|
|
352
|
+
const enrichTag = enrichBits.length > 0 ? ` (${enrichBits.join(', ')})` : '';
|
|
353
|
+
const main = ` "${d.title}"${id}${active}${enrichTag} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
354
|
+
if (d.logline)
|
|
355
|
+
return `${main}\n → ${d.logline}`;
|
|
356
|
+
return main;
|
|
344
357
|
});
|
|
345
|
-
|
|
358
|
+
const footer = enrichmentFooter();
|
|
359
|
+
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
346
360
|
},
|
|
347
361
|
},
|
|
348
362
|
{
|
|
@@ -749,6 +763,117 @@ export const TOOL_REGISTRY = [
|
|
|
749
763
|
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
750
764
|
},
|
|
751
765
|
},
|
|
766
|
+
{
|
|
767
|
+
name: 'mark_enriched',
|
|
768
|
+
description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied enrichment fields, and clears enrichmentStale. The agent never touches the sentence-hash layer — openwriter computes the baseline from current canonical content. Accepts an array so a workspace-wide sweep is one call. See brief 2026-05-18-frontmatter-enrichment-system.',
|
|
769
|
+
schema: {
|
|
770
|
+
docs: z.array(z.object({
|
|
771
|
+
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
772
|
+
logline: z.string().optional().describe('One-sentence "what this doc is about" — ≤150 chars recommended.'),
|
|
773
|
+
domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
|
|
774
|
+
concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
|
|
775
|
+
docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
|
|
776
|
+
status: z.string().optional().describe('Doc status: draft / canonical / stale. Archive state is implied by archivedAt.'),
|
|
777
|
+
})).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array.'),
|
|
778
|
+
},
|
|
779
|
+
handler: async ({ docs }) => {
|
|
780
|
+
const now = new Date().toISOString();
|
|
781
|
+
const results = [];
|
|
782
|
+
let anyTitleSideEffect = false;
|
|
783
|
+
for (const item of docs) {
|
|
784
|
+
try {
|
|
785
|
+
const target = resolveDocTarget(item.docId);
|
|
786
|
+
// Harvest current sentence hashes + char count from canonical view.
|
|
787
|
+
// Active doc: getCanonical() returns the no-overlay primary state.
|
|
788
|
+
// Non-active: cloneWithPendingReverted on the cached/loaded document.
|
|
789
|
+
const canonical = target.isActive
|
|
790
|
+
? getCanonical()
|
|
791
|
+
: cloneWithPendingReverted(target.document);
|
|
792
|
+
const blocks = tiptapToBlocks(canonical);
|
|
793
|
+
const lastEnrichedSentences = harvestSentenceHashes(blocks);
|
|
794
|
+
const lastEnrichedCharCount = harvestCharCount(blocks);
|
|
795
|
+
// Build the atomic enrichment payload.
|
|
796
|
+
const update = {
|
|
797
|
+
lastEnrichedAt: now,
|
|
798
|
+
lastEnrichedCharCount,
|
|
799
|
+
lastEnrichedSentences,
|
|
800
|
+
enrichmentStale: false,
|
|
801
|
+
};
|
|
802
|
+
if (item.logline !== undefined)
|
|
803
|
+
update.logline = item.logline;
|
|
804
|
+
if (item.domain !== undefined)
|
|
805
|
+
update.domain = item.domain;
|
|
806
|
+
if (item.concepts !== undefined)
|
|
807
|
+
update.concepts = item.concepts;
|
|
808
|
+
if (item.docRole !== undefined)
|
|
809
|
+
update.docRole = item.docRole;
|
|
810
|
+
if (item.status !== undefined)
|
|
811
|
+
update.status = item.status;
|
|
812
|
+
if (target.isActive) {
|
|
813
|
+
// Active doc: setMetadata mutates state.metadata but doesn't bump
|
|
814
|
+
// docVersion on its own — without an explicit bump, save() would
|
|
815
|
+
// hit the no-op gate (docVersion === lastSavedDocVersion when
|
|
816
|
+
// there's no body change). bumpDocVersion forces save() through.
|
|
817
|
+
// writeToDisk's staleness check will see the just-stamped baseline
|
|
818
|
+
// (volumeRatio=1, drift=0) and NOT flip the flag back to true.
|
|
819
|
+
setMetadata(update);
|
|
820
|
+
bumpDocVersion();
|
|
821
|
+
save();
|
|
822
|
+
broadcastMetadataChanged(getMetadata());
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
// Non-active: write directly to disk, bypassing flushDocToFile's
|
|
826
|
+
// staleness check (which would otherwise see stale state for one
|
|
827
|
+
// serialize cycle before the new baseline lands). Disk write +
|
|
828
|
+
// cache invalidation mirrors set_metadata's non-active path.
|
|
829
|
+
const newMeta = { ...target.metadata, ...update };
|
|
830
|
+
const markdown = tiptapToMarkdown(target.document, target.title, newMeta);
|
|
831
|
+
atomicWriteFileSync(target.filePath, markdown);
|
|
832
|
+
invalidateDocCache(target.filePath);
|
|
833
|
+
}
|
|
834
|
+
results.push({ docId: item.docId, ok: true });
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
results.push({ docId: item.docId, ok: false, error: String(err?.message ?? err) });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Single broadcast at the end so sidebar refreshes once for the whole batch.
|
|
841
|
+
broadcastDocumentsChanged();
|
|
842
|
+
const okCount = results.filter((r) => r.ok).length;
|
|
843
|
+
const failCount = results.length - okCount;
|
|
844
|
+
const summary = failCount === 0
|
|
845
|
+
? `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}`
|
|
846
|
+
: `Enriched ${okCount} doc${okCount === 1 ? '' : 's'}, ${failCount} failed`;
|
|
847
|
+
return { content: [{ type: 'text', text: `${summary}\n${JSON.stringify({ docs: results })}` }] };
|
|
848
|
+
},
|
|
849
|
+
},
|
|
850
|
+
{
|
|
851
|
+
name: 'list_dirty_docs',
|
|
852
|
+
description: 'List documents that need enrichment — never enriched (no lastEnrichedAt) or flagged stale by openwriter (drift/volume thresholds tripped). Returns identity + reason only; no enrichment fields, no bodies. The minion calls this first to know what to work on. Docs in opted-out workspaces (enrichmentDisabled: true) are excluded. See brief 2026-05-18-frontmatter-enrichment-system.',
|
|
853
|
+
schema: {
|
|
854
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace. Omit to scan all workspaces.'),
|
|
855
|
+
},
|
|
856
|
+
handler: async ({ workspaceFile }) => {
|
|
857
|
+
const docs = listDirtyDocs(workspaceFile);
|
|
858
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
name: 'crawl',
|
|
863
|
+
description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~150 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay.',
|
|
864
|
+
schema: {
|
|
865
|
+
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
866
|
+
domain: z.string().optional().describe('Exact domain match.'),
|
|
867
|
+
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
868
|
+
concepts: z.array(z.string()).optional().describe('Docs must reference ALL listed concepts.'),
|
|
869
|
+
docRole: z.string().optional().describe('Exact docRole match (canonical / vignette / reference / draft / chapter / beat).'),
|
|
870
|
+
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
871
|
+
},
|
|
872
|
+
handler: async (filter) => {
|
|
873
|
+
const docs = crawlDocs(filter);
|
|
874
|
+
return { content: [{ type: 'text', text: JSON.stringify({ total: docs.length, docs }) }] };
|
|
875
|
+
},
|
|
876
|
+
},
|
|
752
877
|
{
|
|
753
878
|
name: 'list_workspaces',
|
|
754
879
|
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
@@ -756,7 +881,8 @@ export const TOOL_REGISTRY = [
|
|
|
756
881
|
handler: async () => {
|
|
757
882
|
const workspaces = listWorkspaces();
|
|
758
883
|
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
759
|
-
|
|
884
|
+
const footer = enrichmentFooter();
|
|
885
|
+
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}${footer}` }] };
|
|
760
886
|
},
|
|
761
887
|
},
|
|
762
888
|
{
|
|
@@ -791,57 +917,136 @@ export const TOOL_REGISTRY = [
|
|
|
791
917
|
},
|
|
792
918
|
{
|
|
793
919
|
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
|
|
920
|
+
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, domain, tags, docRole, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies.',
|
|
795
921
|
schema: {
|
|
796
922
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
797
923
|
},
|
|
798
924
|
handler: async ({ filename }) => {
|
|
799
925
|
const ws = getWorkspace(filename);
|
|
926
|
+
// Build a one-pass map of filename → frontmatter so we don't re-read each
|
|
927
|
+
// doc file per tree node. crawlDocs is cheap (one disk pass per workspace).
|
|
928
|
+
const enriched = crawlDocs({ workspaceFile: filename });
|
|
929
|
+
const enrichByFile = new Map(enriched.map((e) => [e.filename, e]));
|
|
800
930
|
function renderTree(nodes, indent) {
|
|
801
931
|
const lines = [];
|
|
802
932
|
for (const node of nodes) {
|
|
803
933
|
if (node.type === 'doc') {
|
|
804
|
-
const
|
|
805
|
-
const
|
|
806
|
-
|
|
934
|
+
const e = enrichByFile.get(node.file);
|
|
935
|
+
const tags = e?.tags ?? [];
|
|
936
|
+
const enrichBits = [];
|
|
937
|
+
if (e?.domain)
|
|
938
|
+
enrichBits.push(e.domain);
|
|
939
|
+
if (e?.docRole)
|
|
940
|
+
enrichBits.push(e.docRole);
|
|
941
|
+
if (tags.length > 0)
|
|
942
|
+
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
943
|
+
if (e?.enrichmentStale === true)
|
|
944
|
+
enrichBits.push('STALE');
|
|
945
|
+
const tagStr = enrichBits.length > 0 ? ` [${enrichBits.join(' | ')}]` : '';
|
|
946
|
+
const docLine = `${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`;
|
|
947
|
+
lines.push(docLine);
|
|
948
|
+
if (e?.logline)
|
|
949
|
+
lines.push(`${indent} → ${e.logline}`);
|
|
807
950
|
}
|
|
808
951
|
else {
|
|
809
|
-
|
|
952
|
+
const cBits = [];
|
|
953
|
+
if (node.role)
|
|
954
|
+
cBits.push(node.role);
|
|
955
|
+
const cTag = cBits.length > 0 ? ` [${cBits.join(' | ')}]` : '';
|
|
956
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})${cTag}`);
|
|
957
|
+
if (node.logline)
|
|
958
|
+
lines.push(`${indent} → ${node.logline}`);
|
|
810
959
|
lines.push(...renderTree(node.items, indent + ' '));
|
|
811
960
|
}
|
|
812
961
|
}
|
|
813
962
|
return lines;
|
|
814
963
|
}
|
|
815
964
|
const treeLines = renderTree(ws.root, ' ');
|
|
816
|
-
|
|
965
|
+
const headerBits = [`workspace: "${ws.title}"`];
|
|
966
|
+
if (ws.logline)
|
|
967
|
+
headerBits.push(`logline: ${ws.logline}`);
|
|
968
|
+
if (ws.domain)
|
|
969
|
+
headerBits.push(`domain: ${ws.domain}`);
|
|
970
|
+
if (ws.schema)
|
|
971
|
+
headerBits.push(`schema: ${ws.schema}`);
|
|
972
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0) {
|
|
973
|
+
headerBits.push(`vocab: ${ws.vocab.join(', ')}`);
|
|
974
|
+
}
|
|
975
|
+
if (Array.isArray(ws.relatedWorkspaces) && ws.relatedWorkspaces.length > 0) {
|
|
976
|
+
headerBits.push(`related: ${ws.relatedWorkspaces.join(', ')}`);
|
|
977
|
+
}
|
|
978
|
+
if (ws.enrichmentDisabled === true)
|
|
979
|
+
headerBits.push('enrichment: disabled');
|
|
980
|
+
let text = `${headerBits.join('\n')}\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
817
981
|
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
818
982
|
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
819
983
|
}
|
|
820
|
-
|
|
984
|
+
const footer = enrichmentFooter();
|
|
985
|
+
return { content: [{ type: 'text', text: `${text}${footer}` }] };
|
|
821
986
|
},
|
|
822
987
|
},
|
|
823
988
|
{
|
|
824
989
|
name: 'get_item_context',
|
|
825
|
-
description: 'Get progressive
|
|
990
|
+
description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline, domain, concepts, docRole, status), tags, and the enrichmentStale flag. Use before writing to understand context, or before reading to decide whether a body read is necessary.',
|
|
826
991
|
schema: {
|
|
827
992
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
828
993
|
docId: z.string().describe('Document docId (8-char hex from list_documents)'),
|
|
829
994
|
},
|
|
830
995
|
handler: async ({ workspaceFile, docId }) => {
|
|
831
996
|
const filename = resolveDocId(docId);
|
|
832
|
-
|
|
997
|
+
const base = getItemContext(workspaceFile, filename);
|
|
998
|
+
// Layer doc-level enrichment + workspace-level vocab/schema into the response.
|
|
999
|
+
// The agent's crawl pattern: get_item_context for one doc → see logline +
|
|
1000
|
+
// domain + concepts. Decide whether the doc warrants a body read.
|
|
1001
|
+
try {
|
|
1002
|
+
const ws = getWorkspace(workspaceFile);
|
|
1003
|
+
const enriched = crawlDocs({ workspaceFile });
|
|
1004
|
+
const docEnrich = enriched.find((e) => e.filename === filename);
|
|
1005
|
+
if (docEnrich) {
|
|
1006
|
+
if (docEnrich.logline)
|
|
1007
|
+
base.logline = docEnrich.logline;
|
|
1008
|
+
if (docEnrich.domain)
|
|
1009
|
+
base.domain = docEnrich.domain;
|
|
1010
|
+
if (docEnrich.concepts)
|
|
1011
|
+
base.concepts = docEnrich.concepts;
|
|
1012
|
+
if (docEnrich.docRole)
|
|
1013
|
+
base.docRole = docEnrich.docRole;
|
|
1014
|
+
if (docEnrich.status)
|
|
1015
|
+
base.status = docEnrich.status;
|
|
1016
|
+
if (docEnrich.enrichmentStale === true)
|
|
1017
|
+
base.enrichmentStale = true;
|
|
1018
|
+
}
|
|
1019
|
+
if (ws.schema)
|
|
1020
|
+
base.workspaceSchema = ws.schema;
|
|
1021
|
+
if (Array.isArray(ws.vocab) && ws.vocab.length > 0)
|
|
1022
|
+
base.workspaceVocab = ws.vocab;
|
|
1023
|
+
if (ws.logline)
|
|
1024
|
+
base.workspaceLogline = ws.logline;
|
|
1025
|
+
if (ws.domain)
|
|
1026
|
+
base.workspaceDomain = ws.domain;
|
|
1027
|
+
}
|
|
1028
|
+
catch { /* best-effort enrichment overlay */ }
|
|
1029
|
+
return { content: [{ type: 'text', text: JSON.stringify(base, null, 2) }] };
|
|
833
1030
|
},
|
|
834
1031
|
},
|
|
835
1032
|
{
|
|
836
1033
|
name: 'update_workspace_context',
|
|
837
|
-
description: 'Update
|
|
1034
|
+
description: 'Update workspace configuration. Accepts writing context (characters, settings, rules — merged into existing) plus enrichment fields (logline, domain, schema, vocab, relatedWorkspaces, enrichmentVolumeThreshold, enrichmentDriftThreshold, enrichmentDisabled — set on workspace top-level). Pass `null` to clear an enrichment field. Use this to opt a workspace out of enrichment (enrichmentDisabled: true), declare a closed vocab for domain classification, or set workspace-level loglines/schemas. One tool covers writing context + enrichment config.',
|
|
838
1035
|
schema: {
|
|
839
1036
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
840
1037
|
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
|
-
|
|
1038
|
+
characters: z.record(z.string()).optional().describe('Character name → description (merged)'),
|
|
1039
|
+
settings: z.record(z.string()).optional().describe('Setting name → description (merged)'),
|
|
1040
|
+
rules: z.array(z.string()).optional().describe('Writing rules for this workspace (replaces)'),
|
|
1041
|
+
logline: z.string().nullable().optional().describe('One-sentence "what this workspace is for". Set null to clear.'),
|
|
1042
|
+
domain: z.string().nullable().optional().describe('Subject area (e.g. "Male ethology"). Set null to clear.'),
|
|
1043
|
+
schema: z.string().nullable().optional().describe('Workspace kind: book / concept-library / inbox / social / reference. Set null to clear.'),
|
|
1044
|
+
vocab: z.array(z.string()).nullable().optional().describe('Closed list of valid domain names — Haiku classifies docs INTO these. Set null to clear (opens vocab to free-form).'),
|
|
1045
|
+
relatedWorkspaces: z.array(z.string()).nullable().optional().describe('Sibling workspace filenames. Set null to clear.'),
|
|
1046
|
+
enrichmentVolumeThreshold: z.number().nullable().optional().describe('Volume-ratio threshold (default 1.5). Set null to revert.'),
|
|
1047
|
+
enrichmentDriftThreshold: z.number().nullable().optional().describe('Jaccard-drift threshold (default 0.3). Set null to revert.'),
|
|
1048
|
+
enrichmentDisabled: z.boolean().nullable().optional().describe('True = opt this workspace out of enrichment surfacing. Set null or false to re-enable.'),
|
|
1049
|
+
}).describe('Writing context + enrichment config to apply'),
|
|
845
1050
|
},
|
|
846
1051
|
handler: async ({ workspaceFile, context }) => {
|
|
847
1052
|
updateWorkspaceContext(workspaceFile, context);
|
|
@@ -1551,16 +1756,29 @@ export const TOOL_REGISTRY = [
|
|
|
1551
1756
|
handler: async ({ query, limit = 10 }) => {
|
|
1552
1757
|
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1553
1758
|
const raw = searchDocuments(query);
|
|
1554
|
-
// Enrich with docId
|
|
1759
|
+
// Enrich with docId + enrichment fields from frontmatter so the agent
|
|
1760
|
+
// can rank/pick candidates without a follow-up body read.
|
|
1555
1761
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1556
1762
|
let docId = null;
|
|
1763
|
+
let logline;
|
|
1764
|
+
let domain;
|
|
1765
|
+
let docRole;
|
|
1766
|
+
let tags;
|
|
1557
1767
|
try {
|
|
1558
1768
|
const filePath = resolveDocPath(r.filename);
|
|
1559
1769
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1560
1770
|
const fm = matter(fileRaw);
|
|
1561
1771
|
docId = fm.data?.docId || null;
|
|
1772
|
+
if (typeof fm.data?.logline === 'string')
|
|
1773
|
+
logline = fm.data.logline;
|
|
1774
|
+
if (typeof fm.data?.domain === 'string')
|
|
1775
|
+
domain = fm.data.domain;
|
|
1776
|
+
if (typeof fm.data?.docRole === 'string')
|
|
1777
|
+
docRole = fm.data.docRole;
|
|
1778
|
+
if (Array.isArray(fm.data?.tags) && fm.data.tags.length > 0)
|
|
1779
|
+
tags = fm.data.tags;
|
|
1562
1780
|
}
|
|
1563
|
-
catch { /*
|
|
1781
|
+
catch { /* fields stay undefined */ }
|
|
1564
1782
|
return {
|
|
1565
1783
|
docId,
|
|
1566
1784
|
title: r.title,
|
|
@@ -1568,6 +1786,10 @@ export const TOOL_REGISTRY = [
|
|
|
1568
1786
|
matchType: r.matchType,
|
|
1569
1787
|
snippet: r.snippet,
|
|
1570
1788
|
matchedTag: r.matchedTag,
|
|
1789
|
+
...(logline ? { logline } : {}),
|
|
1790
|
+
...(domain ? { domain } : {}),
|
|
1791
|
+
...(docRole ? { docRole } : {}),
|
|
1792
|
+
...(tags ? { tags } : {}),
|
|
1571
1793
|
};
|
|
1572
1794
|
});
|
|
1573
1795
|
return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
|
|
@@ -1695,10 +1917,15 @@ export function removePluginTools(names) {
|
|
|
1695
1917
|
}
|
|
1696
1918
|
}
|
|
1697
1919
|
export async function startMcpServer() {
|
|
1920
|
+
// Build session-start enrichment notice. Read once at boot — MCP's instructions
|
|
1921
|
+
// field is delivered as part of the InitializeResult and becomes part of the
|
|
1922
|
+
// agent's system context. Empty string when no enrichment work is pending.
|
|
1923
|
+
// See brief 2026-05-18-frontmatter-enrichment-system.
|
|
1924
|
+
const enrichmentNotice = buildEnrichmentInstructions();
|
|
1698
1925
|
const server = new McpServer({
|
|
1699
1926
|
name: 'openwriter',
|
|
1700
1927
|
version: '0.2.0',
|
|
1701
|
-
});
|
|
1928
|
+
}, enrichmentNotice ? { instructions: enrichmentNotice } : undefined);
|
|
1702
1929
|
// Wrap each tool handler in withRequestId so every event logged during
|
|
1703
1930
|
// the tool's execution inherits the same request ID. Trace one MCP call
|
|
1704
1931
|
// through the system with: jq 'select(.requestId=="mcp-toolname-xxxxxx")'.
|