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
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Pending overlay — the in-memory + sidecar layer that holds proposed
3
+ * changes to a document. The disk `.md` file is canonical only; the
4
+ * overlay is the agent's proposed mutations that the user hasn't
5
+ * accepted or rejected yet.
6
+ *
7
+ * Architectural model:
8
+ * - Disk = canonical content. Clean markdown. External editors see
9
+ * what the user sees as "the doc," no pending decoration metadata.
10
+ * - Overlay = in-memory `Map<docId, PendingEntry[]>` mirrored to a
11
+ * sidecar JSON at `_pending/{docId}.json` so pending state survives
12
+ * graceful restarts. Sidecar is keyed by docId because filenames
13
+ * can change (renames) but docIds are stable.
14
+ * - Live state = canonical merged with overlay. Browser and MCP see
15
+ * this merged view — TipTap nodes with `pendingStatus` attrs.
16
+ *
17
+ * Pending entries are keyed by `nodeId` (the stable per-block ID the
18
+ * matcher maintains). This is the load-bearing decision — position
19
+ * keying fails when canonical content shifts (external edit,
20
+ * restore_version), node IDs survive content changes by design.
21
+ *
22
+ * Reload flow:
23
+ * 1. Canonical reloaded from disk.
24
+ * 2. Matcher pairs old in-memory nodes to new canonical nodes via
25
+ * fingerprint, returning nodeId continuity.
26
+ * 3. Overlay re-applied to new canonical by nodeId. Entries whose
27
+ * anchor nodeId no longer exists become "orphan" (rewrite→insert,
28
+ * delete→discarded). Entries whose target node exists but the
29
+ * content drifted from `originalBaseline` become "staleBaseline"
30
+ * (surfaced for review).
31
+ *
32
+ * adr: adr/pending-overlay-model.md
33
+ */
34
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync, rmSync } from 'fs';
35
+ import { join } from 'path';
36
+ import { getDataDir, atomicWriteFileSync } from './helpers.js';
37
+ // ============================================================================
38
+ // DIAGNOSTIC HELPERS — node-text preview + entry summary for log readability
39
+ // ============================================================================
40
+ // The diagLog function moved to `./logger.ts` (structured logger). Imported
41
+ // + re-exported here as a shim so existing import sites keep working; new
42
+ // code should call `logger.{level}(category, event, ...)` directly.
43
+ // adr: adr/logging-system.md
44
+ import { diagLog, redactText } from './logger.js';
45
+ export { diagLog };
46
+ /** Extract a short text preview from a TipTap node for log readability.
47
+ * Returns the first ~60 chars of concatenated text content, or the node type
48
+ * if there's no text. Routes through `redactText` so document content is
49
+ * redacted when the log config has `includeText: false` (default for public
50
+ * installs — privacy by default). adr: adr/logging-system.md */
51
+ export function nodeTextPreview(node, limit = 60) {
52
+ if (!node)
53
+ return '<null>';
54
+ let text = '';
55
+ function walk(n) {
56
+ if (!n)
57
+ return;
58
+ if (typeof n.text === 'string') {
59
+ text += n.text;
60
+ return;
61
+ }
62
+ if (Array.isArray(n.content))
63
+ n.content.forEach(walk);
64
+ }
65
+ walk(node);
66
+ if (!text)
67
+ return `<${node.type || 'unknown'}>`;
68
+ const collapsed = text.replace(/\s+/g, ' ').trim();
69
+ const truncated = collapsed.length > limit ? collapsed.slice(0, limit) + '…' : collapsed;
70
+ return redactText(truncated);
71
+ }
72
+ /** One-line summary of a pending entry for log readability. */
73
+ export function entrySummary(e) {
74
+ const newPrev = nodeTextPreview(e.newContent);
75
+ const origPrev = e.originalBaseline ? nodeTextPreview(e.originalBaseline) : '<none>';
76
+ const identity = (e.status === 'rewrite' && e.newContent && e.originalBaseline && newPrev === origPrev)
77
+ ? ' IDENTITY-WARNING(new===orig)'
78
+ : '';
79
+ return `${e.nodeId}/${e.status} new="${newPrev}" orig="${origPrev}"${identity}`;
80
+ }
81
+ // ============================================================================
82
+ // SIDECAR I/O
83
+ // ============================================================================
84
+ function getPendingDir() { return join(getDataDir(), '_pending'); }
85
+ function getSidecarPath(docId) {
86
+ return join(getPendingDir(), `${docId}.json`);
87
+ }
88
+ function ensurePendingDir() {
89
+ const dir = getPendingDir();
90
+ if (!existsSync(dir))
91
+ mkdirSync(dir, { recursive: true });
92
+ }
93
+ export function loadOverlay(docId) {
94
+ if (!docId)
95
+ return [];
96
+ const path = getSidecarPath(docId);
97
+ if (!existsSync(path))
98
+ return [];
99
+ try {
100
+ const raw = readFileSync(path, 'utf-8');
101
+ const data = JSON.parse(raw);
102
+ if (Array.isArray(data?.entries))
103
+ return data.entries;
104
+ return [];
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ }
110
+ export function saveOverlay(docId, entries) {
111
+ if (!docId)
112
+ return;
113
+ // Read previous on-disk sidecar BEFORE we overwrite, so we can log the diff.
114
+ // adr: adr/pending-overlay-model.md
115
+ const prevEntries = loadOverlay(docId);
116
+ const prevById = new Map(prevEntries.map((e) => [e.nodeId, e]));
117
+ // Deduplicate incoming entries by nodeId. The Map collapse enforces the
118
+ // invariant "each nodeId appears at most once in the sidecar."
119
+ //
120
+ // TRIPWIRE — post-fix expectation: this dedup should never drop anything.
121
+ // The historical generator (non-idempotent applyOverlay) is gone; the
122
+ // splitMergedDoc path is now symmetric with the serializer; the read-side
123
+ // paths route through applyOverlayPure which is idempotent by construction.
124
+ // If `droppedDuplicates > 0` after those fixes landed, something
125
+ // regressed — file a bug. The dedup itself stays as cheap defense, but
126
+ // any non-zero drop count is a signal, not normal operation.
127
+ // adr: adr/pending-overlay-model.md
128
+ const dedupedMap = new Map();
129
+ let droppedDuplicates = 0;
130
+ for (const e of entries) {
131
+ if (dedupedMap.has(e.nodeId)) {
132
+ droppedDuplicates++;
133
+ continue;
134
+ }
135
+ dedupedMap.set(e.nodeId, e);
136
+ }
137
+ if (droppedDuplicates > 0) {
138
+ diagLog(`[Overlay] TRIPWIRE docId=${docId} dropped ${droppedDuplicates} duplicate entries by nodeId — generator regressed, investigate`);
139
+ }
140
+ const dedupedEntries = Array.from(dedupedMap.values());
141
+ const newById = dedupedMap;
142
+ const changes = [];
143
+ for (const e of dedupedEntries) {
144
+ const prev = prevById.get(e.nodeId);
145
+ if (!prev) {
146
+ changes.push(`+${entrySummary(e)}`);
147
+ }
148
+ else {
149
+ const prevSig = entrySummary(prev);
150
+ const newSig = entrySummary(e);
151
+ if (prevSig !== newSig)
152
+ changes.push(`~${e.nodeId} BEFORE="${nodeTextPreview(prev.newContent)}" AFTER="${nodeTextPreview(e.newContent)}"`);
153
+ }
154
+ }
155
+ for (const e of prevEntries) {
156
+ if (!newById.has(e.nodeId))
157
+ changes.push(`-${entrySummary(e)}`);
158
+ }
159
+ if (dedupedEntries.length === 0) {
160
+ if (prevEntries.length > 0) {
161
+ diagLog(`[Overlay] SAVE docId=${docId} → DELETE (was ${prevEntries.length} entries)`);
162
+ }
163
+ deleteOverlay(docId);
164
+ return;
165
+ }
166
+ ensurePendingDir();
167
+ const path = getSidecarPath(docId);
168
+ atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: dedupedEntries }, null, 2));
169
+ if (changes.length > 0) {
170
+ diagLog(`[Overlay] SAVE docId=${docId} entries=${dedupedEntries.length} changes=[${changes.join(' | ')}]`);
171
+ }
172
+ // TRIPWIRE — identity-rewrite detection. A rewrite where new===orig is a
173
+ // degenerate state: nothing to review, the entry shouldn't exist.
174
+ //
175
+ // Post-fix expectation: unreachable. The two known generators are closed:
176
+ // - preview-swap echo (ReviewPanel.tsx togglePreview order flip, ADR
177
+ // entry 2026-05-17)
178
+ // - splitMergedDoc canonical-drift (stripPendingFromDoc restoration,
179
+ // ADR entry 2026-05-18)
180
+ //
181
+ // If this log fires post-fix, a third generator path exists — investigate
182
+ // and shut it at the source rather than papering over via auto-resolve.
183
+ // The earlier "Open follow-up" to refuse-to-persist these at saveOverlay
184
+ // was deliberately NOT shipped: legitimate rewrites can converge to
185
+ // identity via canonical evolution and should clear naturally at next
186
+ // save, not get silently rejected here.
187
+ // adr: adr/pending-overlay-model.md
188
+ for (const e of dedupedEntries) {
189
+ if (e.status === 'rewrite' && e.newContent && e.originalBaseline) {
190
+ const newPrev = nodeTextPreview(e.newContent);
191
+ const origPrev = nodeTextPreview(e.originalBaseline);
192
+ if (newPrev === origPrev) {
193
+ diagLog(`[Overlay] TRIPWIRE docId=${docId} nodeId=${e.nodeId} IDENTITY-REWRITE new===orig text="${newPrev}" — generator regressed, investigate`);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ export function deleteOverlay(docId) {
199
+ if (!docId)
200
+ return;
201
+ const path = getSidecarPath(docId);
202
+ if (existsSync(path)) {
203
+ try {
204
+ unlinkSync(path);
205
+ }
206
+ catch { /* best-effort */ }
207
+ }
208
+ }
209
+ /** Clear every sidecar file. Called on profile switch / clearAllCaches. */
210
+ export function clearAllOverlays() {
211
+ const dir = getPendingDir();
212
+ if (!existsSync(dir))
213
+ return;
214
+ try {
215
+ rmSync(dir, { recursive: true, force: true });
216
+ }
217
+ catch { /* best-effort */ }
218
+ }
219
+ /**
220
+ * One-time repair pass: walk every sidecar in _pending/, dedupe entries by
221
+ * nodeId (keep first occurrence so the original anchor wins over the
222
+ * self-referential corrupt anchors that the non-idempotent applyOverlay
223
+ * bug produced), and rewrite the file. Runs once at startup as defense
224
+ * against historical corruption.
225
+ *
226
+ * BACKWARD-COMPAT ONLY post-fix. The generators that produced duplicate
227
+ * entries are architecturally closed (idempotent applyOverlayPure + correct
228
+ * splitMergedDoc). This pass exists to clean up sidecars that were corrupted
229
+ * BEFORE those fixes landed. If `[Overlay] STARTUP-REPAIR` log lines fire
230
+ * on a profile that has been running on post-fix builds, that's a tripwire:
231
+ * a new corruption path leaked through, find and fix it at the source.
232
+ * Once enough time has passed that all production sidecars have been
233
+ * rewritten cleanly through the new save paths, this can be removed.
234
+ * adr: adr/pending-overlay-model.md
235
+ */
236
+ export function repairOverlaysOnStartup() {
237
+ const dir = getPendingDir();
238
+ if (!existsSync(dir))
239
+ return;
240
+ let files;
241
+ try {
242
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'));
243
+ }
244
+ catch {
245
+ return;
246
+ }
247
+ let repaired = 0;
248
+ let totalDropped = 0;
249
+ for (const f of files) {
250
+ const path = join(dir, f);
251
+ try {
252
+ const raw = readFileSync(path, 'utf-8');
253
+ const parsed = JSON.parse(raw);
254
+ if (!Array.isArray(parsed?.entries))
255
+ continue;
256
+ const dedup = new Map();
257
+ let dropped = 0;
258
+ for (const e of parsed.entries) {
259
+ if (!e?.nodeId)
260
+ continue;
261
+ if (dedup.has(e.nodeId)) {
262
+ dropped++;
263
+ continue;
264
+ }
265
+ dedup.set(e.nodeId, e);
266
+ }
267
+ if (dropped > 0) {
268
+ const cleaned = Array.from(dedup.values());
269
+ atomicWriteFileSync(path, JSON.stringify({ version: 1, entries: cleaned }, null, 2));
270
+ repaired++;
271
+ totalDropped += dropped;
272
+ }
273
+ }
274
+ catch { /* skip unreadable sidecar */ }
275
+ }
276
+ if (repaired > 0) {
277
+ diagLog(`[Overlay] TRIPWIRE STARTUP-REPAIR repaired=${repaired} files, dropped=${totalDropped} duplicate entries — should be unreachable post-fix, investigate`);
278
+ }
279
+ }
280
+ // ============================================================================
281
+ // EXTRACT — walk a doc-with-pending-attrs and produce an overlay
282
+ // ============================================================================
283
+ /**
284
+ * Walk a TipTap doc that has pending attrs on its nodes and extract those
285
+ * attrs into a PendingEntry[] keyed by nodeId. Does NOT mutate the doc.
286
+ *
287
+ * For inserts: captures the previous-sibling nodeId (or null) and the
288
+ * parent nodeId so the entry can be re-anchored on reload even if the
289
+ * doc tree has changed around it.
290
+ *
291
+ * For rewrites: captures the node's `pendingOriginalContent` (if
292
+ * present) as the `originalBaseline` so future reloads can detect
293
+ * baseline drift.
294
+ *
295
+ * Insert-pending nodes are extracted with the `newContent` carrying
296
+ * the node minus the pending attrs (i.e. what to insert).
297
+ */
298
+ export function extractOverlay(doc) {
299
+ const entries = [];
300
+ function walk(nodes, parentNodeId) {
301
+ if (!Array.isArray(nodes))
302
+ return;
303
+ let prevSiblingId = null;
304
+ for (const node of nodes) {
305
+ const nodeId = node?.attrs?.id;
306
+ const status = node?.attrs?.pendingStatus;
307
+ if (nodeId && status) {
308
+ const entry = {
309
+ nodeId,
310
+ status,
311
+ };
312
+ if (status === 'insert') {
313
+ entry.afterNodeId = prevSiblingId;
314
+ entry.parentNodeId = parentNodeId;
315
+ // For inserts, the node itself IS the content. Capture it as
316
+ // newContent with pending attrs stripped (the overlay carries
317
+ // them separately).
318
+ entry.newContent = stripPendingAttrs(node);
319
+ }
320
+ else if (status === 'rewrite') {
321
+ // The node's current content is the agent's proposed prose.
322
+ // pendingOriginalContent (if present) is the snapshot baseline.
323
+ entry.newContent = stripPendingAttrs(node);
324
+ if (node.attrs?.pendingOriginalContent) {
325
+ entry.originalBaseline = node.attrs.pendingOriginalContent;
326
+ }
327
+ }
328
+ // status === 'delete': no newContent, no anchor (node still in canonical)
329
+ // Sub-paragraph enhance fields
330
+ if (node.attrs?.pendingGroupId)
331
+ entry.pendingGroupId = node.attrs.pendingGroupId;
332
+ if (node.attrs?.pendingTextEdits)
333
+ entry.pendingTextEdits = node.attrs.pendingTextEdits;
334
+ if (node.attrs?.pendingSelectionFrom != null)
335
+ entry.pendingSelectionFrom = node.attrs.pendingSelectionFrom;
336
+ if (node.attrs?.pendingSelectionTo != null)
337
+ entry.pendingSelectionTo = node.attrs.pendingSelectionTo;
338
+ if (node.attrs?.pendingOriginalFrom != null)
339
+ entry.pendingOriginalFrom = node.attrs.pendingOriginalFrom;
340
+ if (node.attrs?.pendingOriginalTo != null)
341
+ entry.pendingOriginalTo = node.attrs.pendingOriginalTo;
342
+ entries.push(entry);
343
+ }
344
+ // Track previous sibling for inserts (only at this level; recursion
345
+ // resets prevSiblingId for child arrays).
346
+ if (nodeId)
347
+ prevSiblingId = nodeId;
348
+ if (node?.content)
349
+ walk(node.content, nodeId || parentNodeId);
350
+ }
351
+ }
352
+ walk(doc?.content || [], null);
353
+ return entries;
354
+ }
355
+ const PENDING_ATTR_KEYS = [
356
+ 'pendingStatus', 'pendingOriginalContent', 'pendingGroupId',
357
+ 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo',
358
+ 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline',
359
+ ];
360
+ function stripPendingAttrs(node) {
361
+ const cloned = JSON.parse(JSON.stringify(node));
362
+ if (cloned.attrs) {
363
+ for (const k of PENDING_ATTR_KEYS)
364
+ delete cloned.attrs[k];
365
+ }
366
+ if (cloned.content)
367
+ cloned.content = cloned.content.map((c) => stripPendingAttrs(c));
368
+ return cloned;
369
+ }
370
+ // ============================================================================
371
+ // APPLY — merge overlay onto a canonical doc, with orphan + stale-baseline
372
+ // classification
373
+ // ============================================================================
374
+ /**
375
+ * Mutate `canonical` to layer in the overlay's pending decorations.
376
+ * Returns classification: which entries became orphan (anchor gone) and
377
+ * which became stale-baseline (anchor present but content drifted).
378
+ *
379
+ * Orphan handling:
380
+ * - `rewrite` orphan → converted to `insert` with `pendingOrphan: true`,
381
+ * placed at the position of its last-known parent's tail (or end of
382
+ * doc if parent also gone). The agent's creative content is preserved.
383
+ * - `insert` orphan → kept as `insert` with `pendingOrphan: true`,
384
+ * placed via the same fallback positioning.
385
+ * - `delete` orphan → discarded (the target was already gone, the
386
+ * delete intent is moot).
387
+ *
388
+ * Stale-baseline detection (rewrites only): compare canonical node's
389
+ * current content to `entry.originalBaseline`. If different, set
390
+ * `pendingStaleBaseline: true` on the merged node.
391
+ */
392
+ export function applyOverlay(canonical, entries) {
393
+ const orphans = [];
394
+ const staleBaseline = [];
395
+ if (entries.length > 0) {
396
+ diagLog(`[Overlay] APPLY entries=${entries.length}: ${entries.map(entrySummary).join(' | ')}`);
397
+ }
398
+ // Build a nodeId → node map for the canonical doc (read-side lookup).
399
+ const nodeById = new Map();
400
+ function indexNodes(nodes) {
401
+ if (!Array.isArray(nodes))
402
+ return;
403
+ for (const node of nodes) {
404
+ const id = node?.attrs?.id;
405
+ if (id)
406
+ nodeById.set(id, node);
407
+ if (node?.content)
408
+ indexNodes(node.content);
409
+ }
410
+ }
411
+ indexNodes(canonical?.content || []);
412
+ // Helper: find the parent array and index for a given nodeId.
413
+ function findNodeWithParent(targetId) {
414
+ function search(nodes) {
415
+ if (!Array.isArray(nodes))
416
+ return null;
417
+ for (let i = 0; i < nodes.length; i++) {
418
+ if (nodes[i]?.attrs?.id === targetId)
419
+ return { parent: nodes, index: i };
420
+ if (nodes[i]?.content) {
421
+ const r = search(nodes[i].content);
422
+ if (r)
423
+ return r;
424
+ }
425
+ }
426
+ return null;
427
+ }
428
+ return search(canonical?.content || []);
429
+ }
430
+ // Process deletes and rewrites first (in-place mutations on existing nodes).
431
+ // Then process inserts (which add new nodes that wouldn't affect lookups).
432
+ for (const entry of entries) {
433
+ if (entry.status === 'insert')
434
+ continue;
435
+ const target = nodeById.get(entry.nodeId);
436
+ if (!target) {
437
+ // Anchor gone. Delete-orphans are discarded. Rewrite-orphans get
438
+ // converted to inserts (handled below).
439
+ if (entry.status === 'delete') {
440
+ // discard silently
441
+ }
442
+ else {
443
+ orphans.push(entry);
444
+ }
445
+ continue;
446
+ }
447
+ target.attrs = target.attrs || {};
448
+ target.attrs.pendingStatus = entry.status;
449
+ if (entry.status === 'rewrite') {
450
+ // Stale-baseline check: compare canonical content to baseline.
451
+ if (entry.originalBaseline && !sameContent(target, entry.originalBaseline)) {
452
+ target.attrs.pendingStaleBaseline = true;
453
+ staleBaseline.push(entry);
454
+ }
455
+ // Stash original baseline so reject can restore it.
456
+ if (entry.originalBaseline) {
457
+ target.attrs.pendingOriginalContent = entry.originalBaseline;
458
+ }
459
+ else {
460
+ // No baseline recorded — synthesize from current canonical content
461
+ // (best-effort; reject will restore to "current state").
462
+ target.attrs.pendingOriginalContent = sanitizeNodeForBaseline(target);
463
+ }
464
+ // Replace target's content with the proposed new content.
465
+ if (entry.newContent?.content) {
466
+ target.content = entry.newContent.content;
467
+ }
468
+ }
469
+ // status === 'delete': just the pendingStatus marker; content unchanged.
470
+ // Carry sub-paragraph enhance fields.
471
+ if (entry.pendingGroupId)
472
+ target.attrs.pendingGroupId = entry.pendingGroupId;
473
+ if (entry.pendingTextEdits)
474
+ target.attrs.pendingTextEdits = entry.pendingTextEdits;
475
+ if (entry.pendingSelectionFrom != null)
476
+ target.attrs.pendingSelectionFrom = entry.pendingSelectionFrom;
477
+ if (entry.pendingSelectionTo != null)
478
+ target.attrs.pendingSelectionTo = entry.pendingSelectionTo;
479
+ if (entry.pendingOriginalFrom != null)
480
+ target.attrs.pendingOriginalFrom = entry.pendingOriginalFrom;
481
+ if (entry.pendingOriginalTo != null)
482
+ target.attrs.pendingOriginalTo = entry.pendingOriginalTo;
483
+ }
484
+ // Inserts: find anchor and splice the new node in.
485
+ for (const entry of entries) {
486
+ if (entry.status !== 'insert')
487
+ continue;
488
+ if (!entry.newContent)
489
+ continue;
490
+ const newNode = JSON.parse(JSON.stringify(entry.newContent));
491
+ newNode.attrs = newNode.attrs || {};
492
+ newNode.attrs.id = entry.nodeId;
493
+ newNode.attrs.pendingStatus = 'insert';
494
+ if (entry.pendingGroupId)
495
+ newNode.attrs.pendingGroupId = entry.pendingGroupId;
496
+ // Try anchor: afterNodeId first, then parentNodeId.
497
+ let placed = false;
498
+ if (entry.afterNodeId) {
499
+ const loc = findNodeWithParent(entry.afterNodeId);
500
+ if (loc) {
501
+ loc.parent.splice(loc.index + 1, 0, newNode);
502
+ placed = true;
503
+ }
504
+ }
505
+ if (!placed && entry.parentNodeId) {
506
+ const parentLoc = findNodeWithParent(entry.parentNodeId);
507
+ if (parentLoc) {
508
+ const parent = parentLoc.parent[parentLoc.index];
509
+ parent.content = parent.content || [];
510
+ parent.content.unshift(newNode);
511
+ placed = true;
512
+ }
513
+ }
514
+ if (!placed && entry.afterNodeId === null && entry.parentNodeId === null) {
515
+ // Originally at doc root, no previous sibling — insert at start.
516
+ canonical.content = canonical.content || [];
517
+ canonical.content.unshift(newNode);
518
+ placed = true;
519
+ }
520
+ if (!placed) {
521
+ // Both anchors gone. Mark as orphan and append at end of doc with
522
+ // pendingOrphan: true so the user can see and decide.
523
+ newNode.attrs.pendingOrphan = true;
524
+ canonical.content = canonical.content || [];
525
+ canonical.content.push(newNode);
526
+ orphans.push(entry);
527
+ }
528
+ }
529
+ // Convert rewrite-orphans to orphan-inserts at the end of the doc, with
530
+ // pendingOrphan: true. Preserves the agent's proposed content.
531
+ for (const entry of orphans) {
532
+ if (entry.status === 'rewrite' && entry.newContent) {
533
+ const newNode = JSON.parse(JSON.stringify(entry.newContent));
534
+ newNode.attrs = newNode.attrs || {};
535
+ newNode.attrs.id = entry.nodeId;
536
+ newNode.attrs.pendingStatus = 'insert';
537
+ newNode.attrs.pendingOrphan = true;
538
+ if (entry.pendingGroupId)
539
+ newNode.attrs.pendingGroupId = entry.pendingGroupId;
540
+ canonical.content = canonical.content || [];
541
+ canonical.content.push(newNode);
542
+ }
543
+ }
544
+ return { orphans, staleBaseline };
545
+ }
546
+ /**
547
+ * Pure version of applyOverlay. Returns a new merged doc; does NOT mutate
548
+ * the canonical input. Idempotent by construction: if `canonical` already
549
+ * contains a node with an insert entry's nodeId, the entry is skipped
550
+ * (treated as already applied). This is the safe-to-call-anywhere version.
551
+ *
552
+ * The architectural invariant this enforces: applying the same overlay
553
+ * to the same canonical twice produces the same result as applying it
554
+ * once. The pre-existing mutating applyOverlay violated this when fed
555
+ * its own output (the cache-restore + re-apply path), causing the
556
+ * unbounded duplicate-insert bug.
557
+ *
558
+ * adr: adr/pending-overlay-model.md
559
+ */
560
+ export function applyOverlayPure(canonical, entries) {
561
+ const merged = canonical ? JSON.parse(JSON.stringify(canonical)) : { type: 'doc', content: [] };
562
+ if (entries.length === 0)
563
+ return merged;
564
+ // Build a nodeId → node map for the merged doc (read-side lookup).
565
+ const nodeById = new Map();
566
+ function indexNodes(nodes) {
567
+ if (!Array.isArray(nodes))
568
+ return;
569
+ for (const node of nodes) {
570
+ const id = node?.attrs?.id;
571
+ if (id)
572
+ nodeById.set(id, node);
573
+ if (node?.content)
574
+ indexNodes(node.content);
575
+ }
576
+ }
577
+ indexNodes(merged?.content || []);
578
+ function findNodeWithParent(targetId) {
579
+ function search(nodes) {
580
+ if (!Array.isArray(nodes))
581
+ return null;
582
+ for (let i = 0; i < nodes.length; i++) {
583
+ if (nodes[i]?.attrs?.id === targetId)
584
+ return { parent: nodes, index: i };
585
+ if (nodes[i]?.content) {
586
+ const r = search(nodes[i].content);
587
+ if (r)
588
+ return r;
589
+ }
590
+ }
591
+ return null;
592
+ }
593
+ return search(merged?.content || []);
594
+ }
595
+ // Process deletes and rewrites first.
596
+ for (const entry of entries) {
597
+ if (entry.status === 'insert')
598
+ continue;
599
+ const target = nodeById.get(entry.nodeId);
600
+ if (!target)
601
+ continue; // Orphan — skipped in pure version. Caller handles classification separately.
602
+ target.attrs = target.attrs || {};
603
+ target.attrs.pendingStatus = entry.status;
604
+ if (entry.status === 'rewrite') {
605
+ if (entry.originalBaseline && !sameContent(target, entry.originalBaseline)) {
606
+ target.attrs.pendingStaleBaseline = true;
607
+ }
608
+ if (entry.originalBaseline) {
609
+ target.attrs.pendingOriginalContent = entry.originalBaseline;
610
+ }
611
+ else {
612
+ target.attrs.pendingOriginalContent = sanitizeNodeForBaseline(target);
613
+ }
614
+ if (entry.newContent?.content) {
615
+ target.content = entry.newContent.content;
616
+ }
617
+ }
618
+ if (entry.pendingGroupId)
619
+ target.attrs.pendingGroupId = entry.pendingGroupId;
620
+ if (entry.pendingTextEdits)
621
+ target.attrs.pendingTextEdits = entry.pendingTextEdits;
622
+ if (entry.pendingSelectionFrom != null)
623
+ target.attrs.pendingSelectionFrom = entry.pendingSelectionFrom;
624
+ if (entry.pendingSelectionTo != null)
625
+ target.attrs.pendingSelectionTo = entry.pendingSelectionTo;
626
+ if (entry.pendingOriginalFrom != null)
627
+ target.attrs.pendingOriginalFrom = entry.pendingOriginalFrom;
628
+ if (entry.pendingOriginalTo != null)
629
+ target.attrs.pendingOriginalTo = entry.pendingOriginalTo;
630
+ }
631
+ // Inserts: idempotency check FIRST. If a node with this ID already exists,
632
+ // refresh its pending marker but do NOT splice another copy.
633
+ //
634
+ // Idempotency MUST account for descendant IDs too: when a container entry
635
+ // places its newContent (a subtree of listItems/paragraphs/etc.), those
636
+ // descendants land in canonical but aren't in nodeById until we re-index.
637
+ // Without that, the descendants' own entries don't see the existing
638
+ // placement and would splice duplicate copies. adr: adr/pending-overlay-model.md
639
+ function indexSubtree(node) {
640
+ if (!node)
641
+ return;
642
+ const id = node?.attrs?.id;
643
+ if (id)
644
+ nodeById.set(id, node);
645
+ if (Array.isArray(node?.content)) {
646
+ for (const child of node.content)
647
+ indexSubtree(child);
648
+ }
649
+ }
650
+ for (const entry of entries) {
651
+ if (entry.status !== 'insert')
652
+ continue;
653
+ if (!entry.newContent)
654
+ continue;
655
+ const existing = nodeById.get(entry.nodeId);
656
+ if (existing) {
657
+ existing.attrs = existing.attrs || {};
658
+ existing.attrs.pendingStatus = 'insert';
659
+ if (entry.pendingGroupId)
660
+ existing.attrs.pendingGroupId = entry.pendingGroupId;
661
+ continue;
662
+ }
663
+ const newNode = JSON.parse(JSON.stringify(entry.newContent));
664
+ newNode.attrs = newNode.attrs || {};
665
+ newNode.attrs.id = entry.nodeId;
666
+ newNode.attrs.pendingStatus = 'insert';
667
+ if (entry.pendingGroupId)
668
+ newNode.attrs.pendingGroupId = entry.pendingGroupId;
669
+ let placed = false;
670
+ if (entry.afterNodeId) {
671
+ const loc = findNodeWithParent(entry.afterNodeId);
672
+ if (loc) {
673
+ loc.parent.splice(loc.index + 1, 0, newNode);
674
+ indexSubtree(newNode);
675
+ placed = true;
676
+ }
677
+ }
678
+ if (!placed && entry.parentNodeId) {
679
+ const parentLoc = findNodeWithParent(entry.parentNodeId);
680
+ if (parentLoc) {
681
+ const parent = parentLoc.parent[parentLoc.index];
682
+ parent.content = parent.content || [];
683
+ parent.content.unshift(newNode);
684
+ indexSubtree(newNode);
685
+ placed = true;
686
+ }
687
+ }
688
+ if (!placed && entry.afterNodeId === null && entry.parentNodeId === null) {
689
+ merged.content = merged.content || [];
690
+ merged.content.unshift(newNode);
691
+ indexSubtree(newNode);
692
+ placed = true;
693
+ }
694
+ if (!placed) {
695
+ newNode.attrs.pendingOrphan = true;
696
+ merged.content = merged.content || [];
697
+ merged.content.push(newNode);
698
+ indexSubtree(newNode);
699
+ }
700
+ }
701
+ return merged;
702
+ }
703
+ /**
704
+ * Split a merged document (one that has pending decorations baked into
705
+ * its node tree) into a clean canonical + structured overlay. The inverse
706
+ * of applyOverlayPure. Used to process inputs that arrive in merged form
707
+ * (browser doc-update messages, legacy on-disk docs with frontmatter pending)
708
+ * and turn them into the canonical + overlay representation the system
709
+ * now operates on.
710
+ *
711
+ * Returns:
712
+ * canonical — deep clone of merged with:
713
+ * - All `pendingStatus: 'insert'` nodes removed (they only live in overlay).
714
+ * - All pending attrs cleared from remaining nodes.
715
+ * overlayEntries — PendingEntry[] extracted from the original merged tree.
716
+ */
717
+ export function splitMergedDoc(merged) {
718
+ const overlayEntries = extractOverlay(merged);
719
+ const canonical = stripPendingFromDoc(merged);
720
+ return { canonical, overlayEntries };
721
+ }
722
+ /**
723
+ * Deep clone a doc with all pending content reverted to canonical:
724
+ * - status='insert' → drop the node (lives only in overlay)
725
+ * - status='rewrite' → restore node.content from pendingOriginalContent
726
+ * (the baseline captured when the rewrite was proposed)
727
+ * - status='delete' → keep the node, just strip pending attrs
728
+ * - no status → keep, strip stray pending attrs
729
+ *
730
+ * The rewrite-restoration step is load-bearing. Without it, the rewrite TEXT
731
+ * stays in canonical after splitMergedDoc. The next applyOverlayPure compares
732
+ * canonical-as-rewrite to baseline-as-original, sees they differ, and falsely
733
+ * flags `pendingStaleBaseline` — surfacing the dotted-underline indicator
734
+ * even though nothing actually drifted. Mirrors the on-disk serializer's
735
+ * `revertPendingForSerialization` in markdown-serialize.ts; the two were
736
+ * silently divergent and the split path produced corrupt canonical that
737
+ * survived in state.canonical and the cache.
738
+ *
739
+ * adr: adr/pending-overlay-model.md
740
+ */
741
+ export function stripPendingFromDoc(doc) {
742
+ if (!doc)
743
+ return doc;
744
+ const cloned = JSON.parse(JSON.stringify(doc));
745
+ function walk(parent) {
746
+ if (!Array.isArray(parent?.content))
747
+ return;
748
+ // Filter out pending-insert nodes (they only live in overlay).
749
+ parent.content = parent.content.filter((n) => n?.attrs?.pendingStatus !== 'insert');
750
+ // For surviving nodes: restore canonical content (rewrites only) then
751
+ // strip pending markers. Type and id are preserved from the current node;
752
+ // we only swap node.content back to the baseline. If the baseline is
753
+ // missing (legacy entries pre-baseline-capture), leave content alone —
754
+ // best-effort, the user sees the rewrite as canonical.
755
+ for (const node of parent.content) {
756
+ if (node?.attrs) {
757
+ if (node.attrs.pendingStatus === 'rewrite') {
758
+ const baseline = node.attrs.pendingOriginalContent;
759
+ if (baseline && Array.isArray(baseline.content)) {
760
+ node.content = JSON.parse(JSON.stringify(baseline.content));
761
+ }
762
+ }
763
+ for (const k of PENDING_ATTR_KEYS)
764
+ delete node.attrs[k];
765
+ }
766
+ walk(node);
767
+ }
768
+ }
769
+ walk(cloned);
770
+ return cloned;
771
+ }
772
+ function sameContent(a, b) {
773
+ // Cheap structural equality on the relevant subtree. Stringify is
774
+ // fine for sub-paragraph TipTap nodes; they're typically small.
775
+ const aClean = sanitizeNodeForBaseline(a);
776
+ const bClean = sanitizeNodeForBaseline(b);
777
+ return JSON.stringify(aClean) === JSON.stringify(bClean);
778
+ }
779
+ function sanitizeNodeForBaseline(node) {
780
+ // Strip volatile fields (ids, pending attrs) for content comparison.
781
+ const cloned = JSON.parse(JSON.stringify(node));
782
+ function strip(n) {
783
+ if (n?.attrs) {
784
+ const a = { ...n.attrs };
785
+ delete a.id;
786
+ for (const k of PENDING_ATTR_KEYS)
787
+ delete a[k];
788
+ n.attrs = a;
789
+ }
790
+ if (n?.content)
791
+ n.content.forEach(strip);
792
+ }
793
+ strip(cloned);
794
+ return cloned;
795
+ }
796
+ // ============================================================================
797
+ // LEGACY MIGRATION — convert frontmatter `pending` (position-keyed) to
798
+ // overlay format (nodeId-keyed) on first load
799
+ // ============================================================================
800
+ /**
801
+ * Convert legacy position-keyed pending data (from `meta.pending`) into
802
+ * nodeId-keyed PendingEntry[]. Walks the doc in the same pre-order the
803
+ * legacy serializer used (LEAF_BLOCK_TYPES), reads each leaf block's
804
+ * current nodeId, and pairs it with the legacy pending entry at the
805
+ * same position.
806
+ *
807
+ * This runs once when an old file is loaded; the next save writes
808
+ * canonical-only to disk and the sidecar takes over.
809
+ */
810
+ const LEAF_BLOCK_TYPES = new Set(['paragraph', 'heading', 'codeBlock', 'horizontalRule']);
811
+ export function migrateLegacyPending(doc, legacyPending) {
812
+ const entries = [];
813
+ let index = 0;
814
+ function walk(nodes, parentId) {
815
+ if (!Array.isArray(nodes))
816
+ return;
817
+ let prevSiblingId = null;
818
+ for (const node of nodes) {
819
+ if (LEAF_BLOCK_TYPES.has(node.type)) {
820
+ const legacyEntry = legacyPending[String(index)];
821
+ if (legacyEntry && node.attrs?.id) {
822
+ const entry = {
823
+ nodeId: node.attrs.id,
824
+ status: legacyEntry.s,
825
+ };
826
+ if (legacyEntry.o) {
827
+ // Legacy stored original as full node — use as originalBaseline.
828
+ entry.originalBaseline = legacyEntry.o;
829
+ }
830
+ if (legacyEntry.g)
831
+ entry.pendingGroupId = legacyEntry.g;
832
+ if (legacyEntry.sf != null)
833
+ entry.pendingSelectionFrom = legacyEntry.sf;
834
+ if (legacyEntry.st != null)
835
+ entry.pendingSelectionTo = legacyEntry.st;
836
+ if (legacyEntry.of != null)
837
+ entry.pendingOriginalFrom = legacyEntry.of;
838
+ if (legacyEntry.ot != null)
839
+ entry.pendingOriginalTo = legacyEntry.ot;
840
+ // For rewrites: the legacy doc body already contains the new prose
841
+ // (since the body is post-rewrite). We capture it as newContent.
842
+ if (legacyEntry.s === 'rewrite' || legacyEntry.s === 'insert') {
843
+ entry.newContent = stripPendingAttrs(node);
844
+ }
845
+ if (legacyEntry.s === 'insert') {
846
+ entry.afterNodeId = prevSiblingId;
847
+ entry.parentNodeId = parentId;
848
+ }
849
+ entries.push(entry);
850
+ }
851
+ index++;
852
+ }
853
+ else if (node.content) {
854
+ walk(node.content, node.attrs?.id || parentId);
855
+ }
856
+ if (node.attrs?.id)
857
+ prevSiblingId = node.attrs.id;
858
+ }
859
+ }
860
+ walk(doc?.content || [], null);
861
+ return entries;
862
+ }