openwriter 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/client/assets/index-CbSQ8xxn.css +1 -0
  2. package/dist/client/assets/index-JMMJM_G_.js +212 -0
  3. package/dist/client/index.html +2 -2
  4. package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
  5. package/dist/plugins/authors-voice/dist/index.js +206 -0
  6. package/dist/plugins/authors-voice/package.json +23 -0
  7. package/dist/plugins/image-gen/dist/index.d.ts +35 -0
  8. package/dist/plugins/image-gen/dist/index.js +141 -0
  9. package/dist/plugins/image-gen/package.json +26 -0
  10. package/dist/plugins/publish/dist/helpers.d.ts +66 -0
  11. package/dist/plugins/publish/dist/helpers.js +199 -0
  12. package/dist/plugins/publish/dist/index.d.ts +3 -0
  13. package/dist/plugins/publish/dist/index.js +1130 -0
  14. package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
  15. package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
  16. package/dist/plugins/publish/package.json +31 -0
  17. package/dist/plugins/x-api/dist/index.d.ts +27 -0
  18. package/dist/plugins/x-api/dist/index.js +240 -0
  19. package/dist/plugins/x-api/package.json +27 -0
  20. package/dist/server/comments.js +256 -0
  21. package/dist/server/documents.js +293 -20
  22. package/dist/server/enrichment.js +114 -0
  23. package/dist/server/helpers.js +63 -8
  24. package/dist/server/index.js +94 -40
  25. package/dist/server/install-skill.js +15 -0
  26. package/dist/server/logger.js +246 -0
  27. package/dist/server/markdown-parse.js +71 -14
  28. package/dist/server/markdown-serialize.js +136 -41
  29. package/dist/server/mcp.js +538 -99
  30. package/dist/server/node-blocks.js +22 -4
  31. package/dist/server/node-fingerprint.js +347 -73
  32. package/dist/server/node-matcher.js +76 -49
  33. package/dist/server/pending-overlay.js +862 -0
  34. package/dist/server/state.js +1178 -98
  35. package/dist/server/versions.js +18 -0
  36. package/dist/server/workspaces.js +42 -5
  37. package/dist/server/ws.js +194 -37
  38. package/package.json +1 -1
  39. package/skill/SKILL.md +51 -21
  40. package/skill/agents/openwriter-enrichment-minion.md +184 -0
  41. package/skill/docs/enrichment.md +179 -0
  42. package/dist/client/assets/index-BxI3DazW.js +0 -212
  43. package/dist/client/assets/index-OV13QtgQ.css +0 -1
@@ -7,11 +7,11 @@ import { createServer } from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
9
  import { existsSync, readFileSync } from 'fs';
10
- import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
14
- import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile } from './state.js';
14
+ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, markAsAgentStub } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
16
  import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
17
17
  import { createWorkspaceRouter } from './workspace-routes.js';
@@ -34,11 +34,18 @@ import { createTaskRouter } from './task-routes.js';
34
34
  import { platformFetch, isAuthenticated } from './connections.js';
35
35
  import { PluginManager } from './plugin-manager.js';
36
36
  import { checkForUpdate, getUpdateInfo, getCurrentVersion } from './update-check.js';
37
- import { addMark, getMarks, resolveMarks, editMark } from './marks.js';
37
+ import { addComment, getComments, resolveComments, unresolveComments, deleteComments, editComment } from './comments.js';
38
+ import { initLogger, logger, generateRequestId, withRequestId } from './logger.js';
38
39
  const __filename = fileURLToPath(import.meta.url);
39
40
  const __dirname = dirname(__filename);
40
41
  export async function startHttpServer(options = {}) {
41
42
  const port = options.port || 5050;
43
+ // Initialize structured logging first — every subsequent module call can
44
+ // emit events from this point. Config file lives at ~/.openwriter/
45
+ // log-config.json (missing = safe public defaults: error-only, no text).
46
+ // adr: adr/logging-system.md
47
+ initLogger();
48
+ logger.info('state', 'server-boot', `OpenWriter starting on port ${port}`, { port });
42
49
  const app = express();
43
50
  app.use(express.json({ limit: '10mb' }));
44
51
  // API routes for direct HTTP access (fallback if WS not available)
@@ -56,26 +63,33 @@ export async function startHttpServer(options = {}) {
56
63
  });
57
64
  // MCP-over-HTTP: allows client-mode terminals to proxy tool calls
58
65
  app.post('/api/mcp-call', async (req, res) => {
59
- try {
60
- const { tool: toolName, arguments: args } = req.body;
61
- const tool = TOOL_REGISTRY.find((t) => t.name === toolName);
62
- if (!tool) {
63
- res.status(404).json({ error: `Unknown tool: ${toolName}` });
64
- return;
66
+ const { tool: toolName, arguments: args } = req.body;
67
+ // Wrap the call in a request ID scope so every event logged during
68
+ // this tool invocation correlates. adr: adr/logging-system.md
69
+ const reqId = generateRequestId(`mcp-http-${toolName || 'unknown'}`);
70
+ await withRequestId(reqId, async () => {
71
+ try {
72
+ const tool = TOOL_REGISTRY.find((t) => t.name === toolName);
73
+ if (!tool) {
74
+ res.status(404).json({ error: `Unknown tool: ${toolName}` });
75
+ return;
76
+ }
77
+ // Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
78
+ const schema = z.object(tool.schema);
79
+ const parsed = schema.safeParse(args || {});
80
+ if (!parsed.success) {
81
+ res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
82
+ return;
83
+ }
84
+ logger.debug('mcp', 'tool-call-http', tool.name, { tool: tool.name });
85
+ const result = await tool.handler(parsed.data);
86
+ res.json(result);
65
87
  }
66
- // Validate arguments against the tool's Zod schema (mirrors McpServer.validateToolInput)
67
- const schema = z.object(tool.schema);
68
- const parsed = schema.safeParse(args || {});
69
- if (!parsed.success) {
70
- res.status(400).json({ content: [{ type: 'text', text: `Validation error: ${parsed.error.message}` }] });
71
- return;
88
+ catch (err) {
89
+ logger.error('mcp', 'tool-error-http', `${toolName}: ${err.message}`, { tool: toolName }, err);
90
+ res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
72
91
  }
73
- const result = await tool.handler(parsed.data);
74
- res.json(result);
75
- }
76
- catch (err) {
77
- res.status(500).json({ content: [{ type: 'text', text: `Error: ${err.message}` }] });
78
- }
92
+ });
79
93
  });
80
94
  app.get('/api/update-info', (_req, res) => {
81
95
  const latestVersion = getUpdateInfo();
@@ -245,7 +259,7 @@ export async function startHttpServer(options = {}) {
245
259
  // Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
246
260
  app.post('/api/flush', (req, res) => {
247
261
  try {
248
- if (isAgentLocked()) {
262
+ if (isAgentLocked(getActiveFilename())) {
249
263
  console.log('[Flush] Blocked (agent write lock active)');
250
264
  res.status(204).end();
251
265
  return;
@@ -391,8 +405,9 @@ export async function startHttpServer(options = {}) {
391
405
  save();
392
406
  }
393
407
  if (req.body.agentCreated) {
394
- setMetadata({ agentCreated: true });
395
- save();
408
+ // In-memory stub registry — not persisted to disk frontmatter.
409
+ // adr: adr/agent-stub-model.md
410
+ markAsAgentStub(result.filename);
396
411
  }
397
412
  broadcastDocumentSwitched(result.document, result.title, result.filename);
398
413
  if (req.body.markPending || req.body.agentCreated) {
@@ -611,64 +626,103 @@ export async function startHttpServer(options = {}) {
611
626
  res.status(400).json({ error: err.message });
612
627
  }
613
628
  });
614
- // Agent marks
615
- app.post('/api/marks', (req, res) => {
629
+ // Comments (formerly "agent marks")
630
+ app.post('/api/comments', (req, res) => {
616
631
  try {
617
632
  const { filename, text, note, nodeId, nodeIds } = req.body;
618
633
  if (!filename || !text || !nodeId) {
619
634
  res.status(400).json({ error: 'filename, text, and nodeId are required' });
620
635
  return;
621
636
  }
622
- const mark = addMark(filename, text, note || '', nodeId, nodeIds);
623
- broadcastMarksChanged(filename);
624
- res.json({ success: true, mark });
637
+ const comment = addComment(filename, text, note || '', nodeId, nodeIds);
638
+ broadcastCommentsChanged(filename);
639
+ res.json({ success: true, comment });
625
640
  }
626
641
  catch (err) {
627
642
  res.status(500).json({ error: err.message });
628
643
  }
629
644
  });
630
- app.get('/api/marks/:filename', (req, res) => {
645
+ app.get('/api/comments/:filename', (req, res) => {
631
646
  try {
632
- const marks = getMarks(req.params.filename);
633
- res.json({ marks: marks[req.params.filename] || [] });
647
+ const byFile = getComments(req.params.filename);
648
+ res.json({ comments: byFile[req.params.filename] || [] });
634
649
  }
635
650
  catch (err) {
636
651
  res.status(500).json({ error: err.message });
637
652
  }
638
653
  });
639
- app.patch('/api/marks', (req, res) => {
654
+ app.patch('/api/comments', (req, res) => {
640
655
  try {
641
656
  const { filename, id, note } = req.body;
642
657
  if (!filename || !id || typeof note !== 'string') {
643
658
  res.status(400).json({ error: 'filename, id, and note are required' });
644
659
  return;
645
660
  }
646
- const mark = editMark(filename, id, note);
647
- if (!mark) {
648
- res.status(404).json({ error: 'mark not found' });
661
+ const comment = editComment(filename, id, note);
662
+ if (!comment) {
663
+ res.status(404).json({ error: 'comment not found' });
649
664
  return;
650
665
  }
651
- broadcastMarksChanged(filename);
652
- res.json({ success: true, mark });
666
+ broadcastCommentsChanged(filename);
667
+ res.json({ success: true, comment });
653
668
  }
654
669
  catch (err) {
655
670
  res.status(500).json({ error: err.message });
656
671
  }
657
672
  });
658
- app.delete('/api/marks', (req, res) => {
673
+ // Permanently delete comments. Different from /resolve this is the
674
+ // "remove this record" path; resolve is the "addressed, archive it" path.
675
+ app.delete('/api/comments', (req, res) => {
659
676
  try {
660
677
  const { ids } = req.body;
661
678
  if (!Array.isArray(ids)) {
662
679
  res.status(400).json({ error: 'ids must be an array' });
663
680
  return;
664
681
  }
665
- const resolved = resolveMarks(ids);
682
+ const deleted = deleteComments(ids);
683
+ const activeFilename = getActiveFilename();
684
+ broadcastCommentsChanged(activeFilename);
685
+ res.json({ success: true, deleted });
686
+ }
687
+ catch (err) {
688
+ res.status(500).json({ error: err.message });
689
+ }
690
+ });
691
+ // Mark comments as resolved (state change, not deletion). The records
692
+ // stay on disk; only the decoration disappears.
693
+ app.post('/api/comments/resolve', (req, res) => {
694
+ try {
695
+ const { ids } = req.body;
696
+ if (!Array.isArray(ids)) {
697
+ res.status(400).json({ error: 'ids must be an array' });
698
+ return;
699
+ }
700
+ const resolved = resolveComments(ids);
701
+ const activeFilename = getActiveFilename();
702
+ broadcastCommentsChanged(activeFilename);
666
703
  res.json({ success: true, resolved });
667
704
  }
668
705
  catch (err) {
669
706
  res.status(500).json({ error: err.message });
670
707
  }
671
708
  });
709
+ // Clear the resolved flag — the comment surfaces again and re-decorates.
710
+ app.post('/api/comments/unresolve', (req, res) => {
711
+ try {
712
+ const { ids } = req.body;
713
+ if (!Array.isArray(ids)) {
714
+ res.status(400).json({ error: 'ids must be an array' });
715
+ return;
716
+ }
717
+ const cleared = unresolveComments(ids);
718
+ const activeFilename = getActiveFilename();
719
+ broadcastCommentsChanged(activeFilename);
720
+ res.json({ success: true, cleared });
721
+ }
722
+ catch (err) {
723
+ res.status(500).json({ error: err.message });
724
+ }
725
+ });
672
726
  // Mount workspace CRUD + doc/container routes
673
727
  app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
674
728
  // Mount link-doc routes (create-link-doc, auto-tag-link)
@@ -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();
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Structured event logger for openwriter.
3
+ *
4
+ * Architectural model:
5
+ * - One JSON-per-line file at `~/.openwriter/profiles/<profile>/events.log`.
6
+ * One file (not per-category) so grep/jq covers everything in one place.
7
+ * - Levels: error < warn < info < debug < trace. Default `error` — safe
8
+ * for public installs. Travis's machine overrides to `trace` via
9
+ * `~/.openwriter/log-config.json`.
10
+ * - Document text is redacted unless `includeText: true` is set in the
11
+ * config file. Public users never have text content land in logs.
12
+ * - Request IDs flow through async chains via AsyncLocalStorage. One
13
+ * external trigger (MCP tool call, WS message) gets one ID; every
14
+ * event emitted while processing inherits it. "What did this request
15
+ * cause" is a single jq query.
16
+ * - 50 MB rotation, keep last 5. No manual cleanup ever needed.
17
+ * - File handle owned by us (not stdout) so logs survive MCP kill+
18
+ * restart. The current `diagnostic.log` proved this works.
19
+ *
20
+ * Public users see: errors only, no text, small file, share freely for
21
+ * bug reports without privacy concern. Travis sees: everything, with text.
22
+ *
23
+ * adr: adr/logging-system.md
24
+ */
25
+ import { existsSync, mkdirSync, statSync, renameSync, unlinkSync, appendFileSync, readFileSync, watch } from 'fs';
26
+ import { join } from 'path';
27
+ import { AsyncLocalStorage } from 'async_hooks';
28
+ import { homedir } from 'os';
29
+ import { getDataDir } from './helpers.js';
30
+ const LEVEL_RANK = {
31
+ error: 0,
32
+ warn: 1,
33
+ info: 2,
34
+ debug: 3,
35
+ trace: 4,
36
+ };
37
+ // ============================================================================
38
+ // CONFIG
39
+ // ============================================================================
40
+ const CONFIG_PATH = join(homedir(), '.openwriter', 'log-config.json');
41
+ const DEFAULT_CONFIG = {
42
+ level: 'error', // safe-for-public: errors only
43
+ includeText: false, // safe-for-public: no document content
44
+ };
45
+ let currentConfig = { ...DEFAULT_CONFIG };
46
+ let configWatcher = null;
47
+ function readConfig() {
48
+ if (!existsSync(CONFIG_PATH))
49
+ return { ...DEFAULT_CONFIG };
50
+ try {
51
+ const raw = readFileSync(CONFIG_PATH, 'utf-8');
52
+ const data = JSON.parse(raw);
53
+ const level = (typeof data.level === 'string' && data.level in LEVEL_RANK) ? data.level : DEFAULT_CONFIG.level;
54
+ const includeText = data.includeText === true;
55
+ return { level, includeText };
56
+ }
57
+ catch {
58
+ return { ...DEFAULT_CONFIG };
59
+ }
60
+ }
61
+ /** Initialize the logger. Reads config file, sets up live-reload watcher. */
62
+ export function initLogger() {
63
+ currentConfig = readConfig();
64
+ // Live-reload on config changes — flip verbosity without restarting.
65
+ if (existsSync(CONFIG_PATH)) {
66
+ try {
67
+ configWatcher?.close();
68
+ configWatcher = watch(CONFIG_PATH, { persistent: false }, () => {
69
+ const next = readConfig();
70
+ const changed = next.level !== currentConfig.level || next.includeText !== currentConfig.includeText;
71
+ currentConfig = next;
72
+ if (changed) {
73
+ // Log the config change itself so it's traceable in the log.
74
+ writeEvent({
75
+ ts: new Date().toISOString(),
76
+ level: 'info',
77
+ category: 'state',
78
+ event: 'log-config-reloaded',
79
+ fields: { level: next.level, includeText: next.includeText },
80
+ });
81
+ }
82
+ });
83
+ }
84
+ catch { /* best-effort */ }
85
+ }
86
+ }
87
+ export function getLogConfig() {
88
+ return { ...currentConfig };
89
+ }
90
+ // ============================================================================
91
+ // REQUEST ID CONTEXT
92
+ // ============================================================================
93
+ const requestContext = new AsyncLocalStorage();
94
+ /** Generate a short, greppable request ID. */
95
+ export function generateRequestId(prefix) {
96
+ return `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
97
+ }
98
+ /** Run an async chain with a request ID attached. Every log call within
99
+ * fn (and its async descendants) automatically inherits the requestId. */
100
+ export function withRequestId(requestId, fn) {
101
+ return requestContext.run({ requestId }, fn);
102
+ }
103
+ /** Current request ID, if any. Undefined outside a withRequestId scope. */
104
+ export function getCurrentRequestId() {
105
+ return requestContext.getStore()?.requestId;
106
+ }
107
+ // ============================================================================
108
+ // TEXT REDACTION
109
+ // ============================================================================
110
+ /** Wrap any text content that should be redacted in public logs. When
111
+ * `includeText: true` in config, returns the original. Otherwise returns
112
+ * `<redacted:Nchars>`. Use this for any document text excerpt before
113
+ * passing it to a log call's `fields`. */
114
+ export function redactText(text) {
115
+ if (text == null)
116
+ return '<null>';
117
+ if (currentConfig.includeText)
118
+ return text;
119
+ return `<redacted:${text.length}chars>`;
120
+ }
121
+ // ============================================================================
122
+ // FILE I/O + ROTATION
123
+ // ============================================================================
124
+ const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
125
+ const KEEP_ROTATIONS = 5;
126
+ function getLogPath() {
127
+ return join(getDataDir(), 'events.log');
128
+ }
129
+ function ensureLogDir() {
130
+ const dir = getDataDir();
131
+ if (!existsSync(dir))
132
+ mkdirSync(dir, { recursive: true });
133
+ }
134
+ function rotateIfNeeded() {
135
+ const path = getLogPath();
136
+ if (!existsSync(path))
137
+ return;
138
+ let size;
139
+ try {
140
+ size = statSync(path).size;
141
+ }
142
+ catch {
143
+ return;
144
+ }
145
+ if (size < MAX_FILE_BYTES)
146
+ return;
147
+ try {
148
+ // Shift events.log.4 → .5 → discard, .3 → .4, ..., .log → .log.1
149
+ for (let i = KEEP_ROTATIONS; i >= 1; i--) {
150
+ const oldPath = i === 1 ? path : `${path}.${i - 1}`;
151
+ const newPath = `${path}.${i}`;
152
+ if (existsSync(oldPath)) {
153
+ if (i === KEEP_ROTATIONS && existsSync(newPath)) {
154
+ try {
155
+ unlinkSync(newPath);
156
+ }
157
+ catch { /* best-effort */ }
158
+ }
159
+ if (existsSync(oldPath)) {
160
+ try {
161
+ renameSync(oldPath, newPath);
162
+ }
163
+ catch { /* best-effort */ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ catch { /* best-effort */ }
169
+ }
170
+ function writeEvent(evt) {
171
+ try {
172
+ ensureLogDir();
173
+ rotateIfNeeded();
174
+ appendFileSync(getLogPath(), JSON.stringify(evt) + '\n');
175
+ }
176
+ catch { /* logging must never throw — swallow */ }
177
+ }
178
+ // ============================================================================
179
+ // CORE LOG FUNCTIONS
180
+ // ============================================================================
181
+ function shouldLog(level) {
182
+ // Errors always log regardless of level (a crash trace is non-negotiable).
183
+ if (level === 'error')
184
+ return true;
185
+ return LEVEL_RANK[level] <= LEVEL_RANK[currentConfig.level];
186
+ }
187
+ function log(level, category, event, msg, fields, err) {
188
+ if (!shouldLog(level))
189
+ return;
190
+ const evt = {
191
+ ts: new Date().toISOString(),
192
+ level,
193
+ category,
194
+ event,
195
+ };
196
+ const reqId = getCurrentRequestId();
197
+ if (reqId)
198
+ evt.requestId = reqId;
199
+ if (msg)
200
+ evt.msg = msg;
201
+ if (fields && Object.keys(fields).length > 0)
202
+ evt.fields = fields;
203
+ if (err)
204
+ evt.err = { message: err.message, stack: err.stack };
205
+ writeEvent(evt);
206
+ }
207
+ export const logger = {
208
+ error(category, event, msg, fields, err) {
209
+ log('error', category, event, msg, fields, err);
210
+ },
211
+ warn(category, event, msg, fields) {
212
+ log('warn', category, event, msg, fields);
213
+ },
214
+ info(category, event, msg, fields) {
215
+ log('info', category, event, msg, fields);
216
+ },
217
+ debug(category, event, msg, fields) {
218
+ log('debug', category, event, msg, fields);
219
+ },
220
+ trace(category, event, msg, fields) {
221
+ log('trace', category, event, msg, fields);
222
+ },
223
+ };
224
+ // ============================================================================
225
+ // MIGRATION SHIM — `diagLog` callsites get a clean migration path
226
+ // ============================================================================
227
+ /** Legacy shim — preserves the diagLog(line) API but routes through the
228
+ * structured logger as a plain `info`-level event. New code should use
229
+ * `logger.info/warn/etc(category, event, ...)` directly. */
230
+ export function diagLog(line) {
231
+ // Categorize based on prefix heuristics for free re-tagging.
232
+ let category = 'state';
233
+ if (line.startsWith('[Overlay]'))
234
+ category = 'overlay';
235
+ else if (line.startsWith('[WS]'))
236
+ category = 'ws';
237
+ else if (line.startsWith('[Lock]'))
238
+ category = 'lock';
239
+ else if (line.startsWith('[MCP]'))
240
+ category = 'mcp';
241
+ else if (line.startsWith('[Save]') || line.startsWith('[Disk]'))
242
+ category = 'save';
243
+ else if (line.startsWith('[Watch]') || line.startsWith('[fs.watch]'))
244
+ category = 'watch';
245
+ logger.info(category, 'legacy-diag', line);
246
+ }
@@ -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
- // Extract identity graph from frontmatter these become the matcher's
126
- // previousNodes input on both the load-time pass below AND on every
127
- // subsequent save-time pass while the doc stays loaded.
128
- const previousNodes = normalizeNodeEntries(data.nodes);
129
- const graveyard = normalizeNodeEntries(data.graveyard);
130
- // Load-time matcher pass — when frontmatter carries `nodes`, reassign IDs
131
- // based on fingerprint match. Legacy docs (no `nodes` field) keep whatever
132
- // IDs the body parser extracted from caret anchors or minted fresh.
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
- /** Defensive parse of frontmatter node entries — drops any malformed rows. */
155
- function normalizeNodeEntries(raw) {
156
- if (!Array.isArray(raw))
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
- return raw
159
- .filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
160
- .map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
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