openwriter 0.15.0 → 0.17.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 (40) hide show
  1. package/dist/client/assets/index-0ttVnjRp.css +1 -0
  2. package/dist/client/assets/{index-B5MXw2pg.js → index-BZ7LCzrR.js} +64 -64
  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/compact.js +28 -2
  21. package/dist/server/documents.js +234 -3
  22. package/dist/server/enrichment.js +125 -0
  23. package/dist/server/export-routes.js +2 -0
  24. package/dist/server/install-skill.js +15 -0
  25. package/dist/server/markdown-parse.js +153 -14
  26. package/dist/server/markdown-serialize.js +100 -17
  27. package/dist/server/mcp.js +291 -25
  28. package/dist/server/node-blocks.js +41 -1
  29. package/dist/server/node-fingerprint.js +347 -73
  30. package/dist/server/node-matcher.js +19 -44
  31. package/dist/server/pending-overlay.js +21 -4
  32. package/dist/server/state.js +225 -41
  33. package/dist/server/workspaces.js +27 -5
  34. package/dist/server/ws.js +10 -0
  35. package/package.json +2 -1
  36. package/skill/SKILL.md +38 -7
  37. package/skill/agents/openwriter-enrichment-minion.md +177 -0
  38. package/skill/docs/enrichment.md +179 -0
  39. package/skill/docs/footnotes.md +178 -0
  40. package/dist/client/assets/index-B3iORmCT.css +0 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Frontmatter enrichment staleness detection.
3
+ *
4
+ * The matcher already splits every block into sentences and hashes each one on
5
+ * every save (see node-fingerprint.ts). We reuse that machinery here — no new
6
+ * algorithm, no new splitter. Save-time staleness is a small tag-on after the
7
+ * matcher: harvest the current sentence-hash set + char count, compare against
8
+ * the at-enrichment baseline stored in frontmatter, set `enrichmentStale: true`
9
+ * when either threshold trips.
10
+ *
11
+ * Volume ratio captures growth and shrinkage symmetrically. Jaccard distance
12
+ * over the sentence-hash set captures rewrites at constant length. Either
13
+ * tripping flags the doc.
14
+ *
15
+ * OpenWriter owns "is this doc stale". The agent clears the flag via
16
+ * mark_enriched (Phase 4). Both sides read the same field, never compute it
17
+ * independently.
18
+ *
19
+ * See brief: 2026-05-18-frontmatter-enrichment-system.
20
+ */
21
+ import { splitSentences, simpleHash } from './node-fingerprint.js';
22
+ /** Volume-ratio threshold above which a doc is flagged stale by size delta. */
23
+ export const DEFAULT_ENRICHMENT_VOLUME_THRESHOLD = 1.5;
24
+ /**
25
+ * Jaccard-distance threshold above which a doc is flagged stale by drift.
26
+ *
27
+ * 0.10 catches in-place architectural rewrites that volume-ratio misses —
28
+ * e.g. inserting a Status Update section into a 200-sentence doc adds
29
+ * ~25 new sentences, ~0 removed: distance = 25/225 ≈ 0.11, trips at 0.10.
30
+ * Won't false-positive on routine editing — a 3-sentence paragraph addition
31
+ * to a 200-sentence doc is 3/203 ≈ 0.015, well below.
32
+ *
33
+ * Tightened from 0.3 → 0.10 after 2026-05-19 brief reported the Argument
34
+ * Arc class of rewrites slipping through.
35
+ */
36
+ export const DEFAULT_ENRICHMENT_DRIFT_THRESHOLD = 0.10;
37
+ /**
38
+ * Flatten every block's per-sentence hashes into one sorted unique set.
39
+ * Sorted so the on-disk representation is stable across saves (no spurious
40
+ * frontmatter diffs from set-order drift). Unique so duplicate sentences
41
+ * in the same doc don't double-count in the Jaccard math.
42
+ */
43
+ export function harvestSentenceHashes(blocks) {
44
+ const set = new Set();
45
+ for (const block of blocks) {
46
+ const sentences = splitSentences(block.text || '');
47
+ for (const s of sentences) {
48
+ set.add(simpleHash(s.text + s.terminator));
49
+ }
50
+ }
51
+ return Array.from(set).sort();
52
+ }
53
+ /** Total char count across all blocks' text — the volume signal. */
54
+ export function harvestCharCount(blocks) {
55
+ let n = 0;
56
+ for (const b of blocks)
57
+ n += (b.text || '').length;
58
+ return n;
59
+ }
60
+ /**
61
+ * Symmetric size delta. Returns 1 when sizes match, grows toward infinity as
62
+ * they diverge in either direction. Handles zero-size docs safely.
63
+ */
64
+ export function volumeRatio(current, baseline) {
65
+ if (current === 0 && baseline === 0)
66
+ return 1;
67
+ if (current === 0 || baseline === 0)
68
+ return Infinity;
69
+ return Math.max(current, baseline) / Math.min(current, baseline);
70
+ }
71
+ /**
72
+ * Jaccard distance over two sentence-hash sets. 0 = identical, 1 = disjoint.
73
+ * (union - intersection) / union. Empty-vs-empty returns 0.
74
+ */
75
+ export function jaccardDistance(a, b) {
76
+ if (a.length === 0 && b.length === 0)
77
+ return 0;
78
+ const setA = new Set(a);
79
+ const setB = new Set(b);
80
+ let intersection = 0;
81
+ for (const x of setA)
82
+ if (setB.has(x))
83
+ intersection++;
84
+ const union = setA.size + setB.size - intersection;
85
+ return union === 0 ? 0 : (union - intersection) / union;
86
+ }
87
+ /**
88
+ * Compute staleness for a single doc given current matcher-derived signals
89
+ * and the at-enrichment baseline stored in its frontmatter.
90
+ *
91
+ * Returns true when:
92
+ * - the doc has never been enriched (no lastEnrichedAt) — brief: "absent flag = stale"
93
+ * - volumeRatio trips its threshold
94
+ * - Jaccard drift trips its threshold
95
+ *
96
+ * Thresholds: doc-level overrides first, then global defaults. Workspace-level
97
+ * overrides (per the brief) will be layered in when the surfacing handlers
98
+ * (Phase 6) get a workspace pointer — for now the doc carries no workspace
99
+ * reference in writeToDisk's scope.
100
+ */
101
+ export function isEnrichmentStale(currentSentenceHashes, currentCharCount, metadata, workspaceOverrides) {
102
+ // Never enriched → stale by default. New docs land here.
103
+ if (!metadata.lastEnrichedAt)
104
+ return true;
105
+ const baselineHashes = Array.isArray(metadata.lastEnrichedSentences)
106
+ ? metadata.lastEnrichedSentences
107
+ : [];
108
+ const baselineChars = typeof metadata.lastEnrichedCharCount === 'number'
109
+ ? metadata.lastEnrichedCharCount
110
+ : 0;
111
+ const volTh = pickThreshold(metadata.enrichmentVolumeThreshold, workspaceOverrides?.volume, DEFAULT_ENRICHMENT_VOLUME_THRESHOLD);
112
+ const driftTh = pickThreshold(metadata.enrichmentDriftThreshold, workspaceOverrides?.drift, DEFAULT_ENRICHMENT_DRIFT_THRESHOLD);
113
+ if (volumeRatio(currentCharCount, baselineChars) >= volTh)
114
+ return true;
115
+ if (jaccardDistance(currentSentenceHashes, baselineHashes) >= driftTh)
116
+ return true;
117
+ return false;
118
+ }
119
+ function pickThreshold(docLevel, wsLevel, fallback) {
120
+ if (typeof docLevel === 'number' && docLevel > 0)
121
+ return docLevel;
122
+ if (typeof wsLevel === 'number' && wsLevel > 0)
123
+ return wsLevel;
124
+ return fallback;
125
+ }
@@ -8,6 +8,7 @@ import markdownItIns from 'markdown-it-ins';
8
8
  import markdownItMark from 'markdown-it-mark';
9
9
  import markdownItSub from 'markdown-it-sub';
10
10
  import markdownItSup from 'markdown-it-sup';
11
+ import markdownItFootnote from 'markdown-it-footnote';
11
12
  import { tiptapToMarkdown } from './markdown.js';
12
13
  import { getDocument, getTitle, getPlainText, getMetadata } from './state.js';
13
14
  import { buildExportHtml } from './export-html-template.js';
@@ -18,6 +19,7 @@ md.use(markdownItIns);
18
19
  md.use(markdownItMark);
19
20
  md.use(markdownItSub);
20
21
  md.use(markdownItSup);
22
+ md.use(markdownItFootnote);
21
23
  /** Strip YAML frontmatter (---\n...\n---\n\n) from markdown output. */
22
24
  function stripFrontmatter(markdown) {
23
25
  const match = markdown.match(/^---\n[\s\S]*?\n---\n\n/);
@@ -136,6 +136,21 @@ export function installSkill() {
136
136
  }
137
137
  log(` ✓ Skill docs copied to ${docsTarget}`);
138
138
  }
139
+ // Install custom Claude Code subagents to ~/.claude/agents/. These have
140
+ // allowlist-restricted tools so the main agent can dispatch them without
141
+ // loading the full MCP tool registry into the subagent's context
142
+ // (~50K tokens of overhead avoided per spawn).
143
+ const agentsSource = path.join(__dirname, '../../skill/agents');
144
+ if (fs.existsSync(agentsSource)) {
145
+ const agentsTarget = path.join(os.homedir(), '.claude', 'agents');
146
+ fs.mkdirSync(agentsTarget, { recursive: true });
147
+ for (const file of fs.readdirSync(agentsSource)) {
148
+ if (!file.endsWith('.md'))
149
+ continue;
150
+ fs.copyFileSync(path.join(agentsSource, file), path.join(agentsTarget, file));
151
+ log(` ✓ Subagent installed: ${file}`);
152
+ }
153
+ }
139
154
  // Step 2: Global install or update
140
155
  let useNpx = false;
141
156
  const currentVersion = getGlobalVersion();
@@ -17,10 +17,12 @@ import markdownItIns from 'markdown-it-ins';
17
17
  import markdownItMark from 'markdown-it-mark';
18
18
  import markdownItSub from 'markdown-it-sub';
19
19
  import markdownItSup from 'markdown-it-sup';
20
+ import markdownItFootnote from 'markdown-it-footnote';
20
21
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
21
22
  import { nodeText } from './markdown-serialize.js';
22
23
  import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
23
24
  import { matchNodes } from './node-matcher.js';
25
+ import { enrichEntries, enrichSlimArray, fingerprintAll, isLegacyRawEntry, anyLegacyRaw, } from './node-fingerprint.js';
24
26
  // ============================================================================
25
27
  // Markdown -> TipTap
26
28
  // ============================================================================
@@ -30,6 +32,7 @@ md.use(markdownItIns);
30
32
  md.use(markdownItMark);
31
33
  md.use(markdownItSub);
32
34
  md.use(markdownItSup);
35
+ md.use(markdownItFootnote);
33
36
  /**
34
37
  * Normalize blank lines INSIDE markdown tables before parsing.
35
38
  *
@@ -122,14 +125,15 @@ export function markdownToTiptap(markdown) {
122
125
  type: 'doc',
123
126
  content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
124
127
  };
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.
128
+ // Resolve identity graph from frontmatter. Two on-disk formats live in the
129
+ // wild: ultra-lean slim tuples (current) and legacy verbose objects (v0.14
130
+ // and v0.15). Legacy entries get positionally re-fingerprinted from the
131
+ // freshly-parsed body — the body IS the previous state at load time, and
132
+ // re-fingerprinting produces hashes the matcher can pin against cleanly.
133
+ // adr: adr/node-identity-matcher.md
134
+ const blocksForEnrich = tiptapToBlocks(doc);
135
+ const previousNodes = resolvePreviousNodes(data.nodes, blocksForEnrich);
136
+ const graveyard = resolveGraveyard(data.graveyard);
133
137
  if (previousNodes.length > 0) {
134
138
  applyMatcher(doc, previousNodes, graveyard);
135
139
  }
@@ -151,13 +155,68 @@ export function markdownToTiptap(markdown) {
151
155
  previousNodes,
152
156
  };
153
157
  }
154
- /** Defensive parse of frontmatter node entries — drops any malformed rows. */
155
- function normalizeNodeEntries(raw) {
156
- if (!Array.isArray(raw))
158
+ /**
159
+ * Resolve `nodes:` frontmatter into rich NodeEntry[] suitable for the matcher.
160
+ *
161
+ * Two on-disk formats:
162
+ * - Ultra-lean: each entry is an array tuple. enrichSlimArray derives all
163
+ * positional/structural fields from the slim array itself — no body parse
164
+ * needed. The slim array IS the previous state (position = array index,
165
+ * parent = most-recent unfilled container, neighbors = slim[i±1]).
166
+ * - Legacy (v0.14/v0.15): each entry is an object with `id` and `fp` keys.
167
+ * We re-fingerprint positionally from the body — the body IS the previous
168
+ * state at load time, and a fresh fingerprint over the same body produces
169
+ * hashes the matcher can pin against. After the next save, disk is in the
170
+ * ultra-lean format and the body-parse cost drops away.
171
+ *
172
+ * `blocks` is only consulted for the legacy path; slim path ignores it. Pass
173
+ * an empty array when you only have slim input to avoid the body parse cost.
174
+ */
175
+ export function resolvePreviousNodes(raw, blocks) {
176
+ if (!Array.isArray(raw) || raw.length === 0)
177
+ return [];
178
+ if (anyLegacyRaw(raw)) {
179
+ // Positional re-fingerprint: take each legacy entry's id, assign it to a
180
+ // freshly-computed fingerprint at the same position in the body.
181
+ const freshFps = fingerprintAll(blocks);
182
+ const out = [];
183
+ for (let i = 0; i < raw.length; i++) {
184
+ const r = raw[i];
185
+ const id = isLegacyRawEntry(r) ? r.id : (Array.isArray(r) ? r[0] : null);
186
+ if (!id || typeof id !== 'string' || !freshFps[i])
187
+ continue;
188
+ out.push({ id, fingerprint: freshFps[i] });
189
+ }
190
+ return out;
191
+ }
192
+ // Ultra-lean: walk the slim array directly. No body parse required.
193
+ return enrichSlimArray(raw).map((e) => ({
194
+ id: e.id,
195
+ fingerprint: e.fingerprint,
196
+ }));
197
+ }
198
+ /**
199
+ * Resolve `graveyard:` frontmatter into rich NodeEntry[].
200
+ *
201
+ * Ultra-lean tuples enrich without block context (deleted blocks have no
202
+ * body). Derived fields default to safe values; matcher rules for graveyard
203
+ * restore only consult type + sentences + structureSig + childTypes, all
204
+ * carried in slim. Legacy graveyard entries are dropped — their stored
205
+ * fingerprints don't translate to the new hash semantics (terminator is now
206
+ * folded into the hash), so they'd never match a fresh paste-back anyway.
207
+ */
208
+ export function resolveGraveyard(raw) {
209
+ if (!Array.isArray(raw) || raw.length === 0)
157
210
  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 }));
211
+ if (anyLegacyRaw(raw)) {
212
+ // Mixed input: drop legacy entries, enrich slim ones.
213
+ const slimOnly = raw.filter((r) => Array.isArray(r));
214
+ return enrichEntries(slimOnly, []).map((e) => ({ id: e.id, fingerprint: e.fingerprint }));
215
+ }
216
+ return enrichEntries(raw, []).map((e) => ({
217
+ id: e.id,
218
+ fingerprint: e.fingerprint,
219
+ }));
161
220
  }
162
221
  /**
163
222
  * Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
@@ -329,12 +388,81 @@ function tokensToTiptap(tokens) {
329
388
  nodes.push(tableNode);
330
389
  i = end + 1;
331
390
  }
391
+ else if (token.type === 'footnote_block_open') {
392
+ // Footnote definitions section. markdown-it-footnote always emits
393
+ // this block at end-of-doc with all `[^N]: ...` definitions inside,
394
+ // regardless of where in the source the `[^N]:` lines appeared.
395
+ // Parse accepts flexibly; serializer always emits at end-of-doc.
396
+ // adr: adr/footnote-system.md
397
+ const end = findClosingToken(tokens, i, 'footnote_block');
398
+ const definitions = parseFootnoteDefinitions(tokens.slice(i + 1, end));
399
+ if (definitions.length > 0) {
400
+ nodes.push({
401
+ type: 'footnoteSection',
402
+ attrs: { id: generateNodeId() },
403
+ content: definitions,
404
+ });
405
+ }
406
+ i = end + 1;
407
+ }
332
408
  else {
333
409
  i += 1;
334
410
  }
335
411
  }
336
412
  return nodes;
337
413
  }
414
+ /**
415
+ * Parse the contents of a footnote_block (between footnote_block_open and
416
+ * footnote_block_close). Each footnote is a footnote_open ... footnote_close
417
+ * range containing block-level tokens (paragraphs, lists, etc).
418
+ *
419
+ * Strips the trailing `footnote_anchor` back-link that markdown-it-footnote
420
+ * appends to each footnote's last paragraph — it's a renderer artifact, not
421
+ * source content.
422
+ */
423
+ function parseFootnoteDefinitions(tokens) {
424
+ const definitions = [];
425
+ let i = 0;
426
+ while (i < tokens.length) {
427
+ if (tokens[i].type === 'footnote_open') {
428
+ const label = tokens[i].meta?.label || String(tokens[i].meta?.id ?? '');
429
+ const end = findClosingToken(tokens, i, 'footnote');
430
+ const innerTokens = stripFootnoteAnchors(tokens.slice(i + 1, end));
431
+ const content = tokensToTiptap(innerTokens);
432
+ definitions.push({
433
+ type: 'footnoteDefinition',
434
+ attrs: { id: generateNodeId(), label },
435
+ content: content.length > 0
436
+ ? content
437
+ : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
438
+ });
439
+ i = end + 1;
440
+ }
441
+ else {
442
+ i += 1;
443
+ }
444
+ }
445
+ return definitions;
446
+ }
447
+ /**
448
+ * Remove `footnote_anchor` tokens from inline children. markdown-it-footnote
449
+ * appends an anchor (rendered as ↩︎) to the last paragraph of each footnote
450
+ * to back-link to the reference. We strip it on parse because it's a
451
+ * rendering artifact, not source content the author wrote.
452
+ */
453
+ function stripFootnoteAnchors(tokens) {
454
+ return tokens.map((token) => {
455
+ if (token.type !== 'inline' || !token.children)
456
+ return token;
457
+ const filtered = token.children.filter((c) => c.type !== 'footnote_anchor');
458
+ if (filtered.length === token.children.length)
459
+ return token;
460
+ // Clone token with filtered children
461
+ const cloned = Object.assign(Object.create(Object.getPrototypeOf(token)), token);
462
+ cloned.children = filtered;
463
+ return cloned;
464
+ });
465
+ }
338
466
  function findClosingToken(tokens, startIndex, type) {
339
467
  let depth = 0;
340
468
  for (let i = startIndex; i < tokens.length; i++) {
@@ -567,6 +695,17 @@ function inlineTokensToTiptap(tokens) {
567
695
  else if (token.type === 'softbreak') {
568
696
  nodes.push({ type: 'text', text: ' ' });
569
697
  }
698
+ else if (token.type === 'footnote_ref') {
699
+ // `[^N]` inline reference. Carry the author-written label verbatim so
700
+ // mnemonic labels (`[^sapolsky2017]`) survive round-trip. Display
701
+ // numbering is recomputed by the renderer.
702
+ // adr: adr/footnote-system.md
703
+ const label = token.meta?.label || String(token.meta?.id ?? '');
704
+ nodes.push({
705
+ type: 'footnoteReference',
706
+ attrs: { label },
707
+ });
708
+ }
570
709
  }
571
710
  return nodes;
572
711
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
20
20
  import { tiptapToBlocks } from './node-blocks.js';
21
- import { fingerprintAll } from './node-fingerprint.js';
21
+ import { fingerprintAll, slimEntry } from './node-fingerprint.js';
22
22
  // ============================================================================
23
23
  // TipTap -> Markdown
24
24
  // ============================================================================
@@ -74,25 +74,23 @@ function collectPendingState(doc) {
74
74
  return Object.keys(pending).length > 0 ? pending : undefined;
75
75
  }
76
76
  /**
77
- * Build the `nodes` frontmatter entry — one (id, fingerprint) per block
78
- * in pre-order traversal of the TipTap tree.
77
+ * Build the `nodes` frontmatter entry — one slim tuple per block in
78
+ * pre-order traversal of the TipTap tree.
79
79
  *
80
- * Each block's ID comes from its TipTap node's `attrs.id`. Fingerprints are
81
- * computed from the walker-style block list derived directly from the TipTap
82
- * tree (no separate markdown re-parsesame source of truth that builds
83
- * the visible doc).
80
+ * Disk shape is the ultra-lean tuple form from node-fingerprint.ts. Derived
81
+ * fields (position, parent indices, neighbor types, char/word counts) are
82
+ * recomputed at load time from the block tree itself they don't go to disk.
84
83
  */
85
84
  function collectNodesFrontmatter(doc) {
86
85
  const blocks = tiptapToBlocks(doc);
87
86
  const fingerprints = fingerprintAll(blocks);
88
87
  const ids = collectBlockIds(doc);
89
- // ids array is parallel to blocks array — same pre-order traversal.
90
- const entries = [];
88
+ const out = [];
91
89
  for (let i = 0; i < blocks.length; i++) {
92
90
  const id = ids[i] || generateNodeId();
93
- entries.push({ id, fp: fingerprints[i] });
91
+ out.push(slimEntry(id, fingerprints[i]));
94
92
  }
95
- return entries;
93
+ return out;
96
94
  }
97
95
  /**
98
96
  * Cap graveyard size to avoid frontmatter bloat on docs with many edits.
@@ -107,9 +105,11 @@ function collectBlockIds(doc) {
107
105
  'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
108
106
  'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
109
107
  'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
108
+ 'footnoteSection', 'footnoteDefinition',
110
109
  ]);
111
110
  const containerTypes = new Set([
112
111
  'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
112
+ 'footnoteSection', 'footnoteDefinition',
113
113
  ]);
114
114
  function walk(nodes) {
115
115
  if (!nodes)
@@ -156,12 +156,12 @@ export function tiptapToMarkdown(doc, title, metadata) {
156
156
  else {
157
157
  delete meta.nodes;
158
158
  }
159
- // Graveyard: recently-orphaned (id, fingerprint) entries kept across saves so
160
- // paste-back/undo can restore the original ID via exact fingerprint match.
161
- // The caller (writeToDisk) puts the matcher's nextGraveyard into metadata.graveyard;
162
- // we cap it here to keep the file small.
159
+ // Graveyard: recently-orphaned entries kept across saves so paste-back/undo
160
+ // can restore the original ID via exact fingerprint match. Caller passes
161
+ // `{id, fingerprint}` objects (matcher output); we cap, slim, and emit.
163
162
  if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
164
- meta.graveyard = meta.graveyard.slice(0, GRAVEYARD_MAX);
163
+ const capped = meta.graveyard.slice(0, GRAVEYARD_MAX);
164
+ meta.graveyard = capped.map((g) => Array.isArray(g) ? g : slimEntry(g.id, g.fingerprint || g.fp));
165
165
  }
166
166
  else {
167
167
  delete meta.graveyard;
@@ -226,12 +226,84 @@ function revertPendingForSerialization(doc) {
226
226
  return { type: 'doc', content: walk(doc?.content || []) };
227
227
  }
228
228
  function nodesToMarkdown(nodes) {
229
- let result = '';
229
+ // Constrained model: `footnoteSection` is always emitted last, regardless of
230
+ // its position in the tree. Authors / agents / editor drag operations may
231
+ // place it anywhere; the serializer normalizes. Parse accepts flexibly;
232
+ // serialize produces strictly. First save of any non-canonical file becomes
233
+ // the one-time migration.
234
+ //
235
+ // Trailing-empty-paragraph stripping: TipTap inserts an empty paragraph at
236
+ // end-of-doc as a cursor-landing artifact. It serializes to a stray
237
+ // `<!-- -->` marker — visual cruft with no semantic value, and the next
238
+ // load just re-creates the cursor-landing paragraph anyway. Drop trailing
239
+ // empty paragraphs unconditionally (whether or not a footnoteSection is
240
+ // present): they're editor state, not on-disk content.
241
+ //
242
+ // Empty paragraphs in the MIDDLE of the doc are preserved — those are
243
+ // authored blank lines between sections and carry intent. Only the trailing
244
+ // run of empties at end-of-body gets stripped.
245
+ //
246
+ // adr: adr/footnote-system.md
247
+ let deferredSection = null;
248
+ const body = [];
230
249
  for (const node of nodes) {
250
+ if (node.type === 'footnoteSection') {
251
+ deferredSection = node;
252
+ continue;
253
+ }
254
+ body.push(node);
255
+ }
256
+ while (body.length > 0) {
257
+ const last = body[body.length - 1];
258
+ if (last.type === 'paragraph' && (!last.content || last.content.length === 0)) {
259
+ body.pop();
260
+ }
261
+ else {
262
+ break;
263
+ }
264
+ }
265
+ let result = '';
266
+ for (const node of body) {
231
267
  result += nodeToMarkdown(node, '');
232
268
  }
269
+ if (deferredSection) {
270
+ result += footnoteSectionToMarkdown(deferredSection);
271
+ }
233
272
  return result;
234
273
  }
274
+ /**
275
+ * Serialize the footnoteSection block. Each definition emits as
276
+ * `[^label]: first-paragraph-content` with continuation paragraphs indented
277
+ * 4 spaces (Pandoc continuation convention). Empty section emits nothing.
278
+ *
279
+ * Definitions with no content still emit a `[^label]:` line so the reference
280
+ * doesn't dangle on round-trip.
281
+ *
282
+ * adr: adr/footnote-system.md
283
+ */
284
+ function footnoteSectionToMarkdown(section) {
285
+ const definitions = (section.content || []).filter((d) => d.type === 'footnoteDefinition');
286
+ if (definitions.length === 0)
287
+ return '';
288
+ const lines = [];
289
+ for (const def of definitions) {
290
+ const label = def.attrs?.label || '';
291
+ const paragraphs = (def.content || []).filter((c) => c.type === 'paragraph');
292
+ if (paragraphs.length === 0) {
293
+ lines.push(`[^${label}]: `);
294
+ continue;
295
+ }
296
+ const firstText = inlineToMarkdown(paragraphs[0].content || []);
297
+ lines.push(`[^${label}]: ${firstText}`);
298
+ // Continuation paragraphs: 4-space indent per Pandoc convention.
299
+ for (let i = 1; i < paragraphs.length; i++) {
300
+ const text = inlineToMarkdown(paragraphs[i].content || []);
301
+ lines.push('');
302
+ lines.push(` ${text}`);
303
+ }
304
+ }
305
+ return lines.join('\n') + '\n';
306
+ }
235
307
  function nodeToMarkdown(node, indent) {
236
308
  switch (node.type) {
237
309
  case 'heading': {
@@ -409,6 +481,17 @@ export function inlineToMarkdown(nodes) {
409
481
  result += '<br>';
410
482
  continue;
411
483
  }
484
+ if (node.type === 'footnoteReference') {
485
+ // Close any open marks so `[^N]` lands at the prose level, not inside
486
+ // a bold/italic span. Footnote references are visually distinct chips;
487
+ // wrapping them in bold or italic doesn't make sense.
488
+ // adr: adr/footnote-system.md
489
+ result += closeAllMarks(openMarks);
490
+ openMarks = [];
491
+ const label = node.attrs?.label || '';
492
+ result += `[^${label}]`;
493
+ continue;
494
+ }
412
495
  if (node.type !== 'text')
413
496
  continue;
414
497
  const targetMarks = (node.marks || [])