openwriter 0.16.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.
@@ -10,8 +10,8 @@
10
10
  <link rel="preconnect" href="https://fonts.googleapis.com" />
11
11
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
12
12
  <link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
13
- <script type="module" crossorigin src="/assets/index-JMMJM_G_.js"></script>
14
- <link rel="stylesheet" crossorigin href="/assets/index-CbSQ8xxn.css">
13
+ <script type="module" crossorigin src="/assets/index-BZ7LCzrR.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/assets/index-0ttVnjRp.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -33,6 +33,8 @@ const TYPE_MAP = {
33
33
  taskList: 'tasks',
34
34
  taskItem: 'task',
35
35
  image: 'img',
36
+ footnoteSection: 'fnsec',
37
+ footnoteDefinition: 'fndef',
36
38
  };
37
39
  function nodeId(id) {
38
40
  return id || '________';
@@ -50,6 +52,10 @@ function inlineToCompact(nodes) {
50
52
  return nodes.map((node) => {
51
53
  if (node.type === 'hardBreak')
52
54
  return '\n';
55
+ if (node.type === 'footnoteReference') {
56
+ const label = node.attrs?.label || '';
57
+ return `[^${label}]`;
58
+ }
53
59
  if (node.type !== 'text')
54
60
  return '';
55
61
  let text = node.text || '';
@@ -127,14 +133,34 @@ function nodeToCompactLines(node, indent) {
127
133
  lines.push(`${indent}${tag} ![${alt}](${src})`);
128
134
  return lines;
129
135
  }
130
- // Container nodes (lists, blockquotes, taskLists)
131
- if (['bulletList', 'orderedList', 'blockquote', 'taskList'].includes(node.type)) {
136
+ // Container nodes (lists, blockquotes, taskLists, footnoteSection)
137
+ if (['bulletList', 'orderedList', 'blockquote', 'taskList', 'footnoteSection'].includes(node.type)) {
132
138
  lines.push(`${indent}${tag}`);
133
139
  for (const child of node.content || []) {
134
140
  lines.push(...nodeToCompactLines(child, indent + ' '));
135
141
  }
136
142
  return lines;
137
143
  }
144
+ // Footnote definition — show label inline + first paragraph's text;
145
+ // nest additional paragraphs (rare).
146
+ if (node.type === 'footnoteDefinition') {
147
+ const label = node.attrs?.label || '';
148
+ const children = node.content || [];
149
+ if (children.length > 0 && children[0].type === 'paragraph') {
150
+ const text = inlineToCompact(children[0].content);
151
+ lines.push(`${indent}${tag} [^${label}]: ${text}`);
152
+ for (let i = 1; i < children.length; i++) {
153
+ lines.push(...nodeToCompactLines(children[i], indent + ' '));
154
+ }
155
+ }
156
+ else {
157
+ lines.push(`${indent}${tag} [^${label}]:`);
158
+ for (const child of children) {
159
+ lines.push(...nodeToCompactLines(child, indent + ' '));
160
+ }
161
+ }
162
+ return lines;
163
+ }
138
164
  // Task items — checkbox prefix + first paragraph inline
139
165
  if (node.type === 'taskItem') {
140
166
  const checked = node.attrs?.checked ? '[x]' : '[ ]';
@@ -21,8 +21,19 @@
21
21
  import { splitSentences, simpleHash } from './node-fingerprint.js';
22
22
  /** Volume-ratio threshold above which a doc is flagged stale by size delta. */
23
23
  export const DEFAULT_ENRICHMENT_VOLUME_THRESHOLD = 1.5;
24
- /** Jaccard-distance threshold above which a doc is flagged stale by drift. */
25
- export const DEFAULT_ENRICHMENT_DRIFT_THRESHOLD = 0.3;
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;
26
37
  /**
27
38
  * Flatten every block's per-sentence hashes into one sorted unique set.
28
39
  * Sorted so the on-disk representation is stable across saves (no spurious
@@ -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/);
@@ -17,6 +17,7 @@ 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';
@@ -31,6 +32,7 @@ md.use(markdownItIns);
31
32
  md.use(markdownItMark);
32
33
  md.use(markdownItSub);
33
34
  md.use(markdownItSup);
35
+ md.use(markdownItFootnote);
34
36
  /**
35
37
  * Normalize blank lines INSIDE markdown tables before parsing.
36
38
  *
@@ -386,12 +388,81 @@ function tokensToTiptap(tokens) {
386
388
  nodes.push(tableNode);
387
389
  i = end + 1;
388
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
+ }
389
408
  else {
390
409
  i += 1;
391
410
  }
392
411
  }
393
412
  return nodes;
394
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
+ }
395
466
  function findClosingToken(tokens, startIndex, type) {
396
467
  let depth = 0;
397
468
  for (let i = startIndex; i < tokens.length; i++) {
@@ -624,6 +695,17 @@ function inlineTokensToTiptap(tokens) {
624
695
  else if (token.type === 'softbreak') {
625
696
  nodes.push({ type: 'text', text: ' ' });
626
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
+ }
627
709
  }
628
710
  return nodes;
629
711
  }
@@ -105,9 +105,11 @@ function collectBlockIds(doc) {
105
105
  'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
106
106
  'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
107
107
  'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
108
+ 'footnoteSection', 'footnoteDefinition',
108
109
  ]);
109
110
  const containerTypes = new Set([
110
111
  'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
112
+ 'footnoteSection', 'footnoteDefinition',
111
113
  ]);
112
114
  function walk(nodes) {
113
115
  if (!nodes)
@@ -224,12 +226,84 @@ function revertPendingForSerialization(doc) {
224
226
  return { type: 'doc', content: walk(doc?.content || []) };
225
227
  }
226
228
  function nodesToMarkdown(nodes) {
227
- 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 = [];
228
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) {
229
267
  result += nodeToMarkdown(node, '');
230
268
  }
269
+ if (deferredSection) {
270
+ result += footnoteSectionToMarkdown(deferredSection);
271
+ }
231
272
  return result;
232
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
+ }
233
307
  function nodeToMarkdown(node, indent) {
234
308
  switch (node.type) {
235
309
  case 'heading': {
@@ -407,6 +481,17 @@ export function inlineToMarkdown(nodes) {
407
481
  result += '<br>';
408
482
  continue;
409
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
+ }
410
495
  if (node.type !== 'text')
411
496
  continue;
412
497
  const targetMarks = (node.marks || [])
@@ -116,6 +116,35 @@ function resolveDocTarget(docId) {
116
116
  lastModified: statSync(filePath).mtime,
117
117
  };
118
118
  }
119
+ /**
120
+ * Override the `autoAccept` field in a snapshot's frontmatter without
121
+ * reparsing the body. Used by `restore_version` to preserve the CURRENT
122
+ * user toggle (a per-doc UI preference) across a content-restore. Editing
123
+ * the frontmatter line directly avoids a full parse + reserialize, which
124
+ * would re-run the matcher and risk minor body-shape drift for what's
125
+ * supposed to be an exact content restore.
126
+ *
127
+ * adr: adr/pending-overlay-model.md
128
+ */
129
+ function applyAutoAcceptOverride(snapshotMarkdown, currentAutoAccept) {
130
+ const fmMatch = snapshotMarkdown.match(/^---\n(.+?)\n---\n/s);
131
+ if (!fmMatch)
132
+ return snapshotMarkdown; // no frontmatter to update
133
+ try {
134
+ const fm = JSON.parse(fmMatch[1]);
135
+ if (currentAutoAccept) {
136
+ fm.autoAccept = true;
137
+ }
138
+ else {
139
+ delete fm.autoAccept;
140
+ }
141
+ const newFmLine = JSON.stringify(fm);
142
+ return snapshotMarkdown.replace(/^---\n.+?\n---\n/s, `---\n${newFmLine}\n---\n`);
143
+ }
144
+ catch {
145
+ return snapshotMarkdown; // malformed frontmatter — leave alone
146
+ }
147
+ }
119
148
  /** Human-friendly relative time for ISO timestamps. */
120
149
  function relativeTime(iso) {
121
150
  const then = new Date(iso).getTime();
@@ -769,7 +798,7 @@ export const TOOL_REGISTRY = [
769
798
  schema: {
770
799
  docs: z.array(z.object({
771
800
  docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
772
- logline: z.string().optional().describe('One-sentence "what this doc is about" ≤150 chars recommended.'),
801
+ logline: z.string().optional().describe('Précis (non-fiction) or logline (fiction). Under 250 chars. Describe the content, not the kind of doc.'),
773
802
  domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
774
803
  concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
775
804
  docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
@@ -1455,9 +1484,19 @@ export const TOOL_REGISTRY = [
1455
1484
  }
1456
1485
  catch { /* best effort */ }
1457
1486
  // Read the target snapshot's content
1458
- const snapshotMarkdown = getVersionContent(target.docId, timestamp);
1459
- if (!snapshotMarkdown)
1487
+ const rawSnapshot = getVersionContent(target.docId, timestamp);
1488
+ if (!rawSnapshot)
1460
1489
  return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
1490
+ // Preserve the CURRENT autoAccept setting rather than rolling it back
1491
+ // to the snapshot-era value. `autoAccept` is a per-doc user preference
1492
+ // (toggled in the sidebar) that governs how FUTURE writes behave —
1493
+ // it's not document content. Without this, a user who toggled
1494
+ // autoAccept off to review incoming changes would silently lose that
1495
+ // preference when the agent calls restore_version, and the next
1496
+ // write_to_pad would auto-apply instead of arriving as pending.
1497
+ // adr: adr/pending-overlay-model.md
1498
+ const currentAutoAccept = target.metadata?.autoAccept === true;
1499
+ const snapshotMarkdown = applyAutoAcceptOverride(rawSnapshot, currentAutoAccept);
1461
1500
  // Write the snapshot directly to disk — this becomes the new canonical.
1462
1501
  // The pending overlay sidecar is unchanged; on reload, the matcher
1463
1502
  // re-pairs nodeIds and pending decorations re-attach where possible.
@@ -36,6 +36,8 @@ const CONTAINER_TYPES = new Set([
36
36
  'tableRow',
37
37
  'tableCell',
38
38
  'tableHeader',
39
+ 'footnoteSection',
40
+ 'footnoteDefinition',
39
41
  ]);
40
42
  function walkNodes(nodes, blocks, parentPosition) {
41
43
  let ordinalInParent = 0;
@@ -105,6 +107,40 @@ function walkNodes(nodes, blocks, parentPosition) {
105
107
  });
106
108
  walkNodes(node.content || [], blocks, bqPosition);
107
109
  }
110
+ else if (node.type === 'footnoteSection') {
111
+ // Container holding all `footnoteDefinition`s at end-of-doc. Treated
112
+ // like a blockquote: container fingerprint is content-empty, identity
113
+ // travels through the matcher's structural rules. The serializer
114
+ // enforces end-of-doc position regardless of where it appears in the
115
+ // tree, so position-stability is guaranteed at the boundary.
116
+ // adr: adr/footnote-system.md
117
+ const sectionPosition = blocks.length;
118
+ blocks.push({
119
+ position: sectionPosition,
120
+ type: 'footnoteSection',
121
+ text: '',
122
+ parentPosition,
123
+ ordinalInParent: ordinalInParent++,
124
+ id: node.attrs?.id,
125
+ });
126
+ walkNodes(node.content || [], blocks, sectionPosition);
127
+ }
128
+ else if (node.type === 'footnoteDefinition') {
129
+ // Container for one footnote's content (typically a single paragraph,
130
+ // occasionally multiple). The label attr round-trips with the node;
131
+ // the matcher fingerprints by content + slot like any container.
132
+ // adr: adr/footnote-system.md
133
+ const defPosition = blocks.length;
134
+ blocks.push({
135
+ position: defPosition,
136
+ type: 'footnoteDefinition',
137
+ text: firstParagraphText(node.content || []),
138
+ parentPosition,
139
+ ordinalInParent: ordinalInParent++,
140
+ id: node.attrs?.id,
141
+ });
142
+ walkNodes(node.content || [], blocks, defPosition);
143
+ }
108
144
  else if (node.type === 'codeBlock') {
109
145
  const text = extractInlineText(node.content || []);
110
146
  blocks.push({
@@ -236,6 +272,8 @@ export function applyIdsToTiptap(doc, pinnedByPosition) {
236
272
  node.type === 'horizontalRule' ||
237
273
  node.type === 'table' ||
238
274
  node.type === 'image' ||
275
+ node.type === 'footnoteSection' ||
276
+ node.type === 'footnoteDefinition' ||
239
277
  CONTAINER_TYPES.has(node.type);
240
278
  if (isBlock) {
241
279
  const id = pinnedByPosition.get(position);
@@ -261,7 +299,9 @@ export function applyIdsToTiptap(doc, pinnedByPosition) {
261
299
  node.type === 'taskList' ||
262
300
  node.type === 'listItem' ||
263
301
  node.type === 'taskItem' ||
264
- node.type === 'blockquote';
302
+ node.type === 'blockquote' ||
303
+ node.type === 'footnoteSection' ||
304
+ node.type === 'footnoteDefinition';
265
305
  if (isContainer && node.content)
266
306
  walk(node.content);
267
307
  }
@@ -805,17 +805,18 @@ export function updateDocument(doc) {
805
805
  console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
806
806
  return;
807
807
  }
808
- // Preserve pending attrs from server state incoming browser doc.
809
- // The browser's PendingAttributes extension tracks pendingStatus in the TipTap
810
- // document model, but transferPendingAttrs provides a safety net in case the
811
- // browser's doc-update lost them (e.g. timing edge case, stale transaction).
812
- const serverHadPending = hasPendingChanges();
813
- if (serverHadPending) {
814
- transferPendingAttrs(state.document, doc);
815
- }
816
- // Route the incoming merged-shape doc through the primary-state setter
817
- // so canonical + overlay get refreshed and state.document is the
818
- // recomputed merged view. Direct assignment is forbidden.
808
+ // Trust the browser-sent doc as authoritative. The WebSocket handler's
809
+ // version gate (isVersionCurrent) already routed stale browser submissions
810
+ // through syncBrowserDocUpdate (the merge path); by the time we land here,
811
+ // the browser saw the same view of pending state the server has. An
812
+ // incoming doc with pending markers cleared is by definition an intentional
813
+ // accept — never an attrs-lost-in-transit error. The older safety net
814
+ // (transferPendingAttrs re-stamping server's pending onto the incoming doc)
815
+ // worked under the pre-fb666e6 model where state.document was authoritative,
816
+ // but under the canonical+overlay split model it actively reverted user
817
+ // accepts: re-stamped 'insert' markers got filtered out of canonical by
818
+ // stripPendingFromDoc, and the just-accepted body disappeared from disk.
819
+ // adr: adr/pending-overlay-model.md
819
820
  setPrimaryFromMerged(doc);
820
821
  state.lastModified = new Date();
821
822
  // Bump docVersion so the writeToDisk no-op gate (which compares
@@ -826,10 +827,6 @@ export function updateDocument(doc) {
826
827
  // state.document MUST bump docVersion. applyChanges does the same.
827
828
  // adr: adr/pending-overlay-model.md
828
829
  bumpDocVersion();
829
- // Validate: if server had pending changes, verify they survived the transfer
830
- if (serverHadPending && !hasPendingChanges()) {
831
- console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
832
- }
833
830
  }
834
831
  /**
835
832
  * Transfer pending attrs from source doc to target doc by matching node IDs.
@@ -1922,6 +1919,16 @@ const CONTAINER_BLOCK_TYPES = new Set([
1922
1919
  'bulletList', 'orderedList', 'listItem',
1923
1920
  'taskList', 'taskItem',
1924
1921
  'blockquote',
1922
+ // Footnote containers — without these, populate_document's
1923
+ // markAllNodesAsPending pass skipped the section + definition shells,
1924
+ // leaving their pendingStatus unset. The serializer's revert pass then
1925
+ // dropped the inner pending paragraphs but kept the empty container
1926
+ // shells, producing an on-disk file with `[^N]:` definition headers and
1927
+ // no content. Marking them container-level pending makes the entire
1928
+ // subtree get dropped together on canonical serialize and carried whole
1929
+ // in the pending overlay.
1930
+ // adr: adr/footnote-system.md
1931
+ 'footnoteSection', 'footnoteDefinition',
1925
1932
  ]);
1926
1933
  /**
1927
1934
  * Mark every block node (leaves + containers) as pending. Used by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,6 +64,7 @@
64
64
  "gray-matter": "^4.0.3",
65
65
  "lowlight": "^3.3.0",
66
66
  "markdown-it": "^14.1.1",
67
+ "markdown-it-footnote": "^4.0.0",
67
68
  "markdown-it-ins": "^4.0.0",
68
69
  "markdown-it-mark": "^4.0.0",
69
70
  "markdown-it-sub": "^2.0.0",
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.7.5"
19
+ version: "0.7.6"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -303,6 +303,18 @@ When creating **two or more documents together** — a tweet thread saved as sep
303
303
  - `reply` / `quote` types still require `url`
304
304
  - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
305
305
 
306
+ ### Citations & footnotes
307
+
308
+ Long-form writing (especially academic-adjacent nonfiction) uses CommonMark / Pandoc footnote syntax:
309
+
310
+ - **Reference** (inline in prose): `text[^1]` — renders as a superscript chip
311
+ - **Definition** (anywhere in the markdown body): `[^1]: footnote text` — automatically corralled into a "Footnotes" section at end-of-doc on save
312
+ - **Mnemonic labels** allowed: `[^sapolsky2017]` survives round-trip on disk; the editor shows auto-sequential display numbers regardless
313
+
314
+ Just include the syntax in `populate_document` content or `write_to_pad` content — no special tool needed. The parser handles the tokenization, the editor handles the rendering, the serializer enforces the constrained end-of-doc shape.
315
+
316
+ **Scope is per-doc.** Each chapter has its own `[^1]` … `[^N]` numbering; cross-doc references aren't supported at the editor level. Full guide → `docs/footnotes.md`.
317
+
306
318
  ## Companion Skills (optional)
307
319
 
308
320
  For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill — 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
@@ -27,16 +27,9 @@ questions. The main agent dispatched you because the work needs doing.
27
27
 
28
28
  Five frontmatter fields that capture each doc's identity in 50–200 tokens:
29
29
 
30
- - **logline** — one sentence, plain English. "What is this doc about?"
31
- Captures the *what*, not the *how*. No jargon the reader won't recognize.
32
- No promotional language. Test: a reader who has never seen this doc reads
33
- the logline and knows whether to open it.
34
- **Length: 140-character target, 150-character HARD CAP.** Count characters
35
- literally before submitting. If your draft is over 150, rewrite it shorter
36
- — don't submit and hope. Cutting the introductory clause is usually the
37
- fastest fix ("Master reference for human sexual dimorphism: T-gate
38
- mechanism, dimorphic traits, contest selection." → drop "Master reference
39
- for" if you need room).
30
+ - **logline** — précis (non-fiction) or logline (fiction) summarizing the
31
+ content. Under 250 chars. No scaffolding describe the content itself,
32
+ not the kind of doc it is.
40
33
  - **domain** — single classification string. If the workspace declares a
41
34
  `vocab` array, the value must come from that list (closed set). If no
42
35
  vocab, pick a short durable label (1–3 words, title-case). Stay consistent