openwriter 0.13.0 → 0.15.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.
@@ -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();
@@ -167,12 +181,9 @@ export async function startHttpServer(options = {}) {
167
181
  if (isActiveDoc) {
168
182
  if (enabled) {
169
183
  stripPendingAttrs(); // accept any pending changes
170
- setMetadata({ autoAccept: true });
171
- }
172
- else {
173
- const meta = getMetadata();
174
- delete meta.autoAccept;
175
184
  }
185
+ // Explicit boolean (not delete) — false overrides workspace inheritance.
186
+ setMetadata({ autoAccept: enabled });
176
187
  save();
177
188
  updatePendingCacheForActiveDoc();
178
189
  broadcastMetadataChanged(getMetadata());
@@ -248,7 +259,7 @@ export async function startHttpServer(options = {}) {
248
259
  // Client sends as application/json Blob (non-CORS-safelisted, so cross-origin sendBeacon is blocked)
249
260
  app.post('/api/flush', (req, res) => {
250
261
  try {
251
- if (isAgentLocked()) {
262
+ if (isAgentLocked(getActiveFilename())) {
252
263
  console.log('[Flush] Blocked (agent write lock active)');
253
264
  res.status(204).end();
254
265
  return;
@@ -394,8 +405,9 @@ export async function startHttpServer(options = {}) {
394
405
  save();
395
406
  }
396
407
  if (req.body.agentCreated) {
397
- setMetadata({ agentCreated: true });
398
- save();
408
+ // In-memory stub registry — not persisted to disk frontmatter.
409
+ // adr: adr/agent-stub-model.md
410
+ markAsAgentStub(result.filename);
399
411
  }
400
412
  broadcastDocumentSwitched(result.document, result.title, result.filename);
401
413
  if (req.body.markPending || req.body.agentCreated) {
@@ -614,64 +626,103 @@ export async function startHttpServer(options = {}) {
614
626
  res.status(400).json({ error: err.message });
615
627
  }
616
628
  });
617
- // Agent marks
618
- app.post('/api/marks', (req, res) => {
629
+ // Comments (formerly "agent marks")
630
+ app.post('/api/comments', (req, res) => {
619
631
  try {
620
632
  const { filename, text, note, nodeId, nodeIds } = req.body;
621
633
  if (!filename || !text || !nodeId) {
622
634
  res.status(400).json({ error: 'filename, text, and nodeId are required' });
623
635
  return;
624
636
  }
625
- const mark = addMark(filename, text, note || '', nodeId, nodeIds);
626
- broadcastMarksChanged(filename);
627
- res.json({ success: true, mark });
637
+ const comment = addComment(filename, text, note || '', nodeId, nodeIds);
638
+ broadcastCommentsChanged(filename);
639
+ res.json({ success: true, comment });
628
640
  }
629
641
  catch (err) {
630
642
  res.status(500).json({ error: err.message });
631
643
  }
632
644
  });
633
- app.get('/api/marks/:filename', (req, res) => {
645
+ app.get('/api/comments/:filename', (req, res) => {
634
646
  try {
635
- const marks = getMarks(req.params.filename);
636
- res.json({ marks: marks[req.params.filename] || [] });
647
+ const byFile = getComments(req.params.filename);
648
+ res.json({ comments: byFile[req.params.filename] || [] });
637
649
  }
638
650
  catch (err) {
639
651
  res.status(500).json({ error: err.message });
640
652
  }
641
653
  });
642
- app.patch('/api/marks', (req, res) => {
654
+ app.patch('/api/comments', (req, res) => {
643
655
  try {
644
656
  const { filename, id, note } = req.body;
645
657
  if (!filename || !id || typeof note !== 'string') {
646
658
  res.status(400).json({ error: 'filename, id, and note are required' });
647
659
  return;
648
660
  }
649
- const mark = editMark(filename, id, note);
650
- if (!mark) {
651
- 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' });
664
+ return;
665
+ }
666
+ broadcastCommentsChanged(filename);
667
+ res.json({ success: true, comment });
668
+ }
669
+ catch (err) {
670
+ res.status(500).json({ error: err.message });
671
+ }
672
+ });
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) => {
676
+ try {
677
+ const { ids } = req.body;
678
+ if (!Array.isArray(ids)) {
679
+ res.status(400).json({ error: 'ids must be an array' });
652
680
  return;
653
681
  }
654
- broadcastMarksChanged(filename);
655
- res.json({ success: true, mark });
682
+ const deleted = deleteComments(ids);
683
+ const activeFilename = getActiveFilename();
684
+ broadcastCommentsChanged(activeFilename);
685
+ res.json({ success: true, deleted });
656
686
  }
657
687
  catch (err) {
658
688
  res.status(500).json({ error: err.message });
659
689
  }
660
690
  });
661
- app.delete('/api/marks', (req, res) => {
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) => {
662
694
  try {
663
695
  const { ids } = req.body;
664
696
  if (!Array.isArray(ids)) {
665
697
  res.status(400).json({ error: 'ids must be an array' });
666
698
  return;
667
699
  }
668
- const resolved = resolveMarks(ids);
700
+ const resolved = resolveComments(ids);
701
+ const activeFilename = getActiveFilename();
702
+ broadcastCommentsChanged(activeFilename);
669
703
  res.json({ success: true, resolved });
670
704
  }
671
705
  catch (err) {
672
706
  res.status(500).json({ error: err.message });
673
707
  }
674
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
+ });
675
726
  // Mount workspace CRUD + doc/container routes
676
727
  app.use(createWorkspaceRouter({ broadcastWorkspacesChanged }));
677
728
  // Mount link-doc routes (create-link-doc, auto-tag-link)
@@ -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
+ }
@@ -1,6 +1,15 @@
1
1
  /**
2
2
  * Markdown -> TipTap JSON parsing.
3
3
  * Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
4
+ *
5
+ * Node identity is reassigned via the matcher when the frontmatter carries a
6
+ * `nodes` array (the new path). For legacy docs without `nodes`, trailing
7
+ * `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
8
+ * recognized as ID sources so existing files keep their identities through
9
+ * the migration. Once a migrated doc is saved, the body is clean and all
10
+ * identity lives in frontmatter.
11
+ *
12
+ * adr: adr/node-identity-matcher.md
4
13
  */
5
14
  import MarkdownIt from 'markdown-it';
6
15
  import matter from 'gray-matter';
@@ -10,6 +19,8 @@ import markdownItSub from 'markdown-it-sub';
10
19
  import markdownItSup from 'markdown-it-sup';
11
20
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
12
21
  import { nodeText } from './markdown-serialize.js';
22
+ import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
23
+ import { matchNodes } from './node-matcher.js';
13
24
  // ============================================================================
14
25
  // Markdown -> TipTap
15
26
  // ============================================================================
@@ -19,24 +30,152 @@ md.use(markdownItIns);
19
30
  md.use(markdownItMark);
20
31
  md.use(markdownItSub);
21
32
  md.use(markdownItSup);
33
+ /**
34
+ * Normalize blank lines INSIDE markdown tables before parsing.
35
+ *
36
+ * Per CommonMark, a blank line terminates a table block. Agents writing
37
+ * markdown content frequently insert blank lines between table rows for
38
+ * readability (e.g. `| row |\n\n| row |`), which the strict parser then
39
+ * splits into "table with 1 header row" + N orphan paragraphs that happen
40
+ * to contain pipe characters. The broken structure persists across saves
41
+ * because every serialize → re-parse cycle re-breaks it.
42
+ *
43
+ * This pre-pass detects "table region" (saw a separator row `| --- |`,
44
+ * haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
45
+ * inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
46
+ * inside them stay untouched.
47
+ *
48
+ * Self-healing: a doc on disk with blank-separated rows loads as a proper
49
+ * N-row table; the next save writes contiguous markdown. No migration
50
+ * script needed.
51
+ */
52
+ function normalizeTableBlankLines(markdown) {
53
+ if (!markdown.includes('|'))
54
+ return markdown;
55
+ const lines = markdown.split('\n');
56
+ const out = [];
57
+ let inFence = false;
58
+ let inTable = false; // true between a separator row and the next non-pipe-row
59
+ const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
60
+ const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
61
+ const isBlank = (s) => s.trim() === '';
62
+ for (let i = 0; i < lines.length; i++) {
63
+ const line = lines[i];
64
+ // Code-fence toggle — pipes inside fences stay verbatim
65
+ if (/^[`~]{3,}/.test(line)) {
66
+ inFence = !inFence;
67
+ inTable = false;
68
+ out.push(line);
69
+ continue;
70
+ }
71
+ if (inFence) {
72
+ out.push(line);
73
+ continue;
74
+ }
75
+ if (isSeparator(line)) {
76
+ // Separator confirms we're in a table region from here on
77
+ inTable = true;
78
+ out.push(line);
79
+ continue;
80
+ }
81
+ if (inTable && isBlank(line)) {
82
+ // Look ahead past additional blanks for the next non-blank line
83
+ let j = i + 1;
84
+ while (j < lines.length && lines[j].trim() === '')
85
+ j++;
86
+ // If the next non-blank line is another pipe-row, it's EITHER a
87
+ // continuation row (merge across the blank) OR the header of a NEW
88
+ // table (the blank is the boundary, don't merge). We tell them apart
89
+ // by peeking one further: if line j+1 is a separator row, j is a new
90
+ // table's header — preserve the blank and exit the current table region.
91
+ if (j < lines.length && isPipeRow(lines[j])) {
92
+ if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
93
+ // New table starting — keep the boundary blank, end current region
94
+ inTable = false;
95
+ out.push(line);
96
+ continue;
97
+ }
98
+ // Continuation row — drop the blank
99
+ continue;
100
+ }
101
+ // Lookahead found non-pipe content — table region is ending
102
+ inTable = false;
103
+ out.push(line);
104
+ continue;
105
+ }
106
+ if (inTable && !isPipeRow(line)) {
107
+ // Non-pipe content ends the table region
108
+ inTable = false;
109
+ }
110
+ out.push(line);
111
+ }
112
+ return out.join('\n');
113
+ }
22
114
  export function markdownToTiptap(markdown) {
23
115
  const result = matter(markdown);
24
116
  const { data, content } = result;
25
117
  const title = data.title || 'Untitled';
26
- const tokens = md.parse(content, {});
118
+ const normalizedContent = normalizeTableBlankLines(content);
119
+ const tokens = md.parse(normalizedContent, {});
27
120
  const docContent = tokensToTiptap(tokens);
28
121
  const doc = {
29
122
  type: 'doc',
30
123
  content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
31
124
  };
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.
133
+ if (previousNodes.length > 0) {
134
+ applyMatcher(doc, previousNodes, graveyard);
135
+ }
32
136
  // Rehydrate pending state from frontmatter into node attrs
33
137
  if (data.pending) {
34
138
  rehydratePendingState(doc, data.pending);
35
139
  }
36
- // Strip pending from returned metadata (consumed into node attrs)
140
+ // Strip consumed keys from returned metadata
37
141
  const metadata = { ...data };
38
142
  delete metadata.pending;
39
- return { title, metadata, document: doc, rawFrontmatter: result.matter || null };
143
+ delete metadata.nodes;
144
+ delete metadata.graveyard;
145
+ return {
146
+ title,
147
+ metadata,
148
+ document: doc,
149
+ rawFrontmatter: result.matter || null,
150
+ graveyard,
151
+ previousNodes,
152
+ };
153
+ }
154
+ /** Defensive parse of frontmatter node entries — drops any malformed rows. */
155
+ function normalizeNodeEntries(raw) {
156
+ if (!Array.isArray(raw))
157
+ 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 }));
161
+ }
162
+ /**
163
+ * Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
164
+ * the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
165
+ *
166
+ * Graveyard is passed through so paste-back of recently-deleted content
167
+ * restores the original ID (matched by exact fingerprint).
168
+ */
169
+ function applyMatcher(doc, previousNodes, graveyard) {
170
+ if (previousNodes.length === 0)
171
+ return;
172
+ const newBlocks = tiptapToBlocks(doc);
173
+ const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
174
+ const pinnedByPosition = new Map();
175
+ for (const p of matchResult.pinned) {
176
+ pinnedByPosition.set(p.position, p.id);
177
+ }
178
+ applyIdsToTiptap(doc, pinnedByPosition);
40
179
  }
41
180
  /**
42
181
  * Rehydrate pending state from frontmatter into leaf block node attrs.
@@ -453,8 +592,8 @@ function popMarkByType(stack, type) {
453
592
  /**
454
593
  * Extract a trailing nodeId anchor from inline content.
455
594
  * Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
456
- * Matches Obsidian's block-reference convention. Strips the marker from the
457
- * visible text and returns the captured id. Returns id=null if no anchor found.
595
+ * Strips the marker from the visible text and returns the captured id.
596
+ * Returns id=null if no anchor found.
458
597
  *
459
598
  * Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
460
599
  * will be interpreted as an anchor. Vanishingly rare in real writing.