openwriter 0.16.0 → 0.18.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 || [])
@@ -13,7 +13,7 @@ import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteF
13
13
  import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, setAgentLockActive, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getCachedDocument, invalidateDocCache, isAutoAcceptActive, removePendingCacheEntry, getExternalMtimeDrift, reloadActiveDocFromDisk, getCanonical, cloneWithPendingReverted, bumpDocVersion, } from './state.js';
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
16
- import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
16
+ import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
17
17
  import { extractForwardLinks } from './backlinks.js';
18
18
  import { logger, generateRequestId, withRequestId } from './logger.js';
19
19
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
@@ -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();
@@ -385,8 +414,9 @@ export const TOOL_REGISTRY = [
385
414
  empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
386
415
  content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
387
416
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
417
+ afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
388
418
  },
389
- handler: async ({ title, path, workspace, container, empty, content_type, url }) => {
419
+ handler: async ({ title, path, workspace, container, empty, content_type, url, afterId }) => {
390
420
  // Require url for reply/quote
391
421
  if ((content_type === 'reply' || content_type === 'quote') && !url) {
392
422
  return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
@@ -428,7 +458,10 @@ export const TOOL_REGISTRY = [
428
458
  }
429
459
  let wsInfo = '';
430
460
  if (wsTarget) {
431
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
461
+ // Resolve afterId: it may be a docId (8-char hex) or containerId.
462
+ // filenameByDocId resolves docId→filename; if null, treat as containerId.
463
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
464
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
432
465
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
433
466
  }
434
467
  const newDocId = getDocId();
@@ -449,7 +482,8 @@ export const TOOL_REGISTRY = [
449
482
  const result = createDocumentFile(title, path, typeMeta);
450
483
  let wsInfo = '';
451
484
  if (wsTarget) {
452
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
485
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
486
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
453
487
  wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
454
488
  }
455
489
  // Broadcast spinner keyed by filename so populate_document can clear exactly
@@ -557,6 +591,7 @@ export const TOOL_REGISTRY = [
557
591
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters"). Requires workspace.'),
558
592
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote".'),
559
593
  path: z.string().optional().describe('Absolute file path to create the document at. If omitted, creates in ~/.openwriter/.'),
594
+ afterId: z.string().optional().describe('Place the new doc immediately after this docId or containerId inside its parent. Omit to append to the bottom (default, ascending-order convention). Requires workspace.'),
560
595
  })).min(1).describe('List of documents to declare (minimum 1).'),
561
596
  },
562
597
  handler: async ({ writes }) => {
@@ -583,7 +618,8 @@ export const TOOL_REGISTRY = [
583
618
  const typeMeta = resolveTypeMeta(w.content_type, w.url);
584
619
  const result = createDocumentFile(w.title, w.path, typeMeta);
585
620
  if (wsTarget) {
586
- addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
621
+ const afterRef = w.afterId ? (filenameByDocId(w.afterId) ?? w.afterId) : null;
622
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
587
623
  }
588
624
  broadcastWritingStarted(w.title, wsTarget, result.filename);
589
625
  broadcastedKeys.push(result.filename);
@@ -769,7 +805,7 @@ export const TOOL_REGISTRY = [
769
805
  schema: {
770
806
  docs: z.array(z.object({
771
807
  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.'),
808
+ logline: z.string().optional().describe('Précis (non-fiction) or logline (fiction). Under 250 chars. Describe the content, not the kind of doc.'),
773
809
  domain: z.string().optional().describe('Single domain classification from the workspace vocab.'),
774
810
  concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
775
811
  docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
@@ -1061,9 +1097,13 @@ export const TOOL_REGISTRY = [
1061
1097
  workspaceFile: z.string().describe('Workspace manifest filename'),
1062
1098
  name: z.string().describe('Container name (e.g. "Chapters", "Research")'),
1063
1099
  parentContainerId: z.string().optional().describe('Parent container ID for nesting (null = root level)'),
1100
+ afterId: z.string().optional().describe('Place the new container immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention).'),
1064
1101
  },
1065
- handler: async ({ workspaceFile, name, parentContainerId }) => {
1066
- const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name);
1102
+ handler: async ({ workspaceFile, name, parentContainerId, afterId }) => {
1103
+ // Resolve afterId: may be docId (8-char hex) or containerId. filenameByDocId
1104
+ // resolves docId→filename; if null, treat as containerId.
1105
+ const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
1106
+ const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name, afterRef);
1067
1107
  broadcastWorkspacesChanged();
1068
1108
  return { content: [{ type: 'text', text: `Created container "${name}" (id:${result.containerId})` }] };
1069
1109
  },
@@ -1455,9 +1495,19 @@ export const TOOL_REGISTRY = [
1455
1495
  }
1456
1496
  catch { /* best effort */ }
1457
1497
  // Read the target snapshot's content
1458
- const snapshotMarkdown = getVersionContent(target.docId, timestamp);
1459
- if (!snapshotMarkdown)
1498
+ const rawSnapshot = getVersionContent(target.docId, timestamp);
1499
+ if (!rawSnapshot)
1460
1500
  return { content: [{ type: 'text', text: `Error: Version ${timestamp} not found.` }] };
1501
+ // Preserve the CURRENT autoAccept setting rather than rolling it back
1502
+ // to the snapshot-era value. `autoAccept` is a per-doc user preference
1503
+ // (toggled in the sidebar) that governs how FUTURE writes behave —
1504
+ // it's not document content. Without this, a user who toggled
1505
+ // autoAccept off to review incoming changes would silently lose that
1506
+ // preference when the agent calls restore_version, and the next
1507
+ // write_to_pad would auto-apply instead of arriving as pending.
1508
+ // adr: adr/pending-overlay-model.md
1509
+ const currentAutoAccept = target.metadata?.autoAccept === true;
1510
+ const snapshotMarkdown = applyAutoAcceptOverride(rawSnapshot, currentAutoAccept);
1461
1511
  // Write the snapshot directly to disk — this becomes the new canonical.
1462
1512
  // The pending overlay sidecar is unchanged; on reload, the matcher
1463
1513
  // 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
@@ -89,10 +89,13 @@ export function addDocToContainer(root, containerId, file, title, afterIdentifie
89
89
  }
90
90
  }
91
91
  else {
92
- target.unshift(doc);
92
+ // Default: append to the bottom of the parent's child list. This matches
93
+ // the ascending-order convention (newest at bottom, oldest at top). Callers
94
+ // that want top-insertion must pass an explicit afterIdentifier.
95
+ target.push(doc);
93
96
  }
94
97
  }
95
- export function addContainer(root, parentContainerId, name) {
98
+ export function addContainer(root, parentContainerId, name, afterIdentifier) {
96
99
  const depth = getContainerDepth(root, parentContainerId);
97
100
  if (depth >= MAX_DEPTH) {
98
101
  throw new Error(`Maximum nesting depth (${MAX_DEPTH}) reached`);
@@ -106,7 +109,19 @@ export function addContainer(root, parentContainerId, name) {
106
109
  name,
107
110
  items: [],
108
111
  };
109
- target.unshift(container);
112
+ if (afterIdentifier) {
113
+ const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
114
+ if (afterIdx === -1) {
115
+ target.push(container);
116
+ }
117
+ else {
118
+ target.splice(afterIdx + 1, 0, container);
119
+ }
120
+ }
121
+ else {
122
+ // Default: append to the bottom (ascending-order convention).
123
+ target.push(container);
124
+ }
110
125
  return container;
111
126
  }
112
127
  // ============================================================================