openwriter 0.14.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();
@@ -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)
@@ -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
+ }
@@ -137,16 +137,19 @@ function collectBlockIds(doc) {
137
137
  */
138
138
  export function tiptapToMarkdown(doc, title, metadata) {
139
139
  const meta = { ...metadata, title };
140
- // Collect pending state from node attrs into frontmatter
141
- const pendingState = collectPendingState(doc);
142
- if (pendingState) {
143
- meta.pending = pendingState;
144
- }
145
- else {
146
- delete meta.pending;
147
- }
140
+ // Disk is canonical only never emit `pending:` frontmatter. Pending
141
+ // state lives in the sidecar at `_pending/{docId}.json`, separated from
142
+ // the .md file so external markdown editors see clean canonical content.
143
+ //
144
+ // If the caller passed a doc that still has in-memory pending attrs,
145
+ // serialize from a reverted clone so the body is canonical. Callers
146
+ // that have already done the split (writeToDisk's overlay path) pass
147
+ // an already-canonical doc; this revert is a no-op for them.
148
+ // adr: adr/pending-overlay-model.md
149
+ delete meta.pending;
150
+ const canonicalDoc = revertPendingForSerialization(doc);
148
151
  // Collect node identity graph (id + fingerprint per block) for next-load matcher
149
- const nodes = collectNodesFrontmatter(doc);
152
+ const nodes = collectNodesFrontmatter(canonicalDoc);
150
153
  if (nodes.length > 0) {
151
154
  meta.nodes = nodes;
152
155
  }
@@ -169,12 +172,58 @@ export function tiptapToMarkdown(doc, title, metadata) {
169
172
  delete meta[key];
170
173
  }
171
174
  const frontmatter = `---\n${JSON.stringify(meta)}\n---\n\n`;
172
- const body = nodesToMarkdown(doc.content || []);
175
+ // Serialize the body from the canonical (reverted) clone — never from the
176
+ // pending-modified live doc, otherwise the on-disk body would contain
177
+ // rewritten prose without the original anywhere to revert to.
178
+ const body = nodesToMarkdown(canonicalDoc.content || []);
173
179
  return frontmatter + body;
174
180
  }
175
- /** Convert TipTap document to markdown body only (no frontmatter). */
181
+ /** Convert TipTap document to markdown body only (no frontmatter).
182
+ * Like tiptapToMarkdown, the body is canonical (pending reverted). */
176
183
  export function tiptapToBody(doc) {
177
- return nodesToMarkdown(doc.content || []);
184
+ const canonicalDoc = revertPendingForSerialization(doc);
185
+ return nodesToMarkdown(canonicalDoc.content || []);
186
+ }
187
+ /**
188
+ * Deep clone of `doc` with pending decorations reverted, used by the
189
+ * markdown serializer to ensure disk content is canonical. Mirrors
190
+ * state.cloneWithPendingReverted but is local to the serializer to
191
+ * avoid a state.ts → markdown-serialize.ts cycle.
192
+ *
193
+ * - status='insert' → drop the node
194
+ * - status='rewrite' → restore from pendingOriginalContent (or drop if absent)
195
+ * - status='delete' → keep but clear pending attrs
196
+ * - no status → keep, strip stray pending attrs
197
+ */
198
+ const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
199
+ function revertPendingForSerialization(doc) {
200
+ function clean(node) {
201
+ const clone = JSON.parse(JSON.stringify(node));
202
+ if (clone.attrs) {
203
+ for (const k of PENDING_KEYS)
204
+ delete clone.attrs[k];
205
+ }
206
+ if (clone.content)
207
+ clone.content = walk(clone.content);
208
+ return clone;
209
+ }
210
+ function walk(nodes) {
211
+ const result = [];
212
+ for (const node of nodes || []) {
213
+ const status = node?.attrs?.pendingStatus;
214
+ if (status === 'insert')
215
+ continue;
216
+ if (status === 'rewrite') {
217
+ const original = node.attrs?.pendingOriginalContent;
218
+ if (original)
219
+ result.push(clean(original));
220
+ continue;
221
+ }
222
+ result.push(clean(node));
223
+ }
224
+ return result;
225
+ }
226
+ return { type: 'doc', content: walk(doc?.content || []) };
178
227
  }
179
228
  function nodesToMarkdown(nodes) {
180
229
  let result = '';
@@ -275,28 +324,76 @@ function taskListToMarkdown(items, indent) {
275
324
  }
276
325
  return result + '\n';
277
326
  }
327
+ /**
328
+ * Serialize a TipTap table node to GFM markdown.
329
+ *
330
+ * Critical invariants (each one's absence causes silent table → paragraph
331
+ * loss on round-trip — observed live as `sync-check FAIL: expected table,
332
+ * got paragraph` on the Beat Sheet doc):
333
+ *
334
+ * 1. ALWAYS emit the header-separator row `| --- | --- |` after the first
335
+ * row, regardless of whether any cell is a `tableHeader`. GFM table
336
+ * recognition requires the delimiter row — without it, markdown-it
337
+ * parses each `| ... |` line as a paragraph and the entire table is
338
+ * dropped. (One-time consequence: a header-less table's first row
339
+ * becomes `tableHeader` cells after the first round-trip. Stable
340
+ * thereafter.)
341
+ *
342
+ * 2. Escape `|` inside cell text as `\|` so it doesn't terminate the cell
343
+ * column.
344
+ *
345
+ * 3. Collapse multi-paragraph cells with `<br>` joiners. The inline
346
+ * cell format can't represent multiple block paragraphs; without
347
+ * collapsing, only the first paragraph round-trips and the rest are
348
+ * silently lost.
349
+ *
350
+ * 4. Ensure a blank line precedes the table block (caller does `\n\n`
351
+ * tailing on prior nodes; we keep the leading newline minimal).
352
+ */
278
353
  function tableToMarkdown(node) {
279
354
  const rows = node.content || [];
280
355
  if (rows.length === 0)
281
356
  return '';
357
+ function cellContentToText(cell) {
358
+ const content = cell.content || [];
359
+ if (content.length === 0)
360
+ return '';
361
+ // Each cell typically holds one paragraph, but a TipTap table can carry
362
+ // multi-paragraph cells (and arbitrary blocks). Concatenate paragraphs
363
+ // with <br> so no inline content is dropped.
364
+ const parts = [];
365
+ for (const child of content) {
366
+ if (child.type === 'paragraph') {
367
+ parts.push(inlineToMarkdown(child.content));
368
+ }
369
+ else if (child.content) {
370
+ // Non-paragraph block (rare in tables) — fall through to inline.
371
+ parts.push(inlineToMarkdown(child.content));
372
+ }
373
+ }
374
+ // Escape pipes and replace newlines with <br>.
375
+ return parts.join('<br>').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
376
+ }
282
377
  const lines = [];
283
- let isFirstRow = true;
284
- for (const row of rows) {
378
+ const firstRowCells = rows[0]?.content || [];
379
+ const columnCount = firstRowCells.length;
380
+ for (let r = 0; r < rows.length; r++) {
381
+ const row = rows[r];
285
382
  const cells = row.content || [];
286
- const cellTexts = cells.map((cell) => {
287
- const para = cell.content?.[0];
288
- return para ? inlineToMarkdown(para.content) : '';
289
- });
383
+ const cellTexts = cells.map(cellContentToText);
384
+ // Pad short rows so the markdown table has consistent column count.
385
+ while (cellTexts.length < columnCount)
386
+ cellTexts.push('');
290
387
  lines.push(`| ${cellTexts.join(' | ')} |`);
291
- if (isFirstRow) {
292
- const hasHeaders = cells.some((c) => c.type === 'tableHeader');
293
- if (hasHeaders) {
294
- lines.push(`| ${cellTexts.map(() => '---').join(' | ')} |`);
295
- }
296
- isFirstRow = false;
388
+ if (r === 0) {
389
+ // ALWAYS emit the separator GFM parsing requires it for table
390
+ // recognition. This is the load-bearing invariant.
391
+ lines.push(`| ${Array(columnCount).fill('---').join(' | ')} |`);
297
392
  }
298
393
  }
299
- return lines.join('\n') + '\n\n';
394
+ // Leading blank line ensures we're not glued to the prior block (which
395
+ // would cause the table to be consumed as a paragraph continuation).
396
+ return '\n' + lines.join('\n') + '\n\n';
300
397
  }
301
398
  // ---- Inline mark serialization ----
302
399
  const SERIALIZED_MARKS = ['bold', 'italic', 'code', 'strike', 'underline', 'highlight', 'subscript', 'superscript', 'link'];