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.
- package/dist/client/assets/index-0ttVnjRp.css +1 -0
- package/dist/client/assets/{index-JMMJM_G_.js → index-BZ7LCzrR.js} +57 -57
- package/dist/client/index.html +2 -2
- package/dist/server/compact.js +28 -2
- package/dist/server/enrichment.js +13 -2
- package/dist/server/export-routes.js +2 -0
- package/dist/server/markdown-parse.js +82 -0
- package/dist/server/markdown-serialize.js +86 -1
- package/dist/server/mcp.js +42 -3
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/state.js +22 -15
- package/package.json +2 -1
- package/skill/SKILL.md +13 -1
- package/skill/agents/openwriter-enrichment-minion.md +3 -10
- package/skill/docs/footnotes.md +178 -0
- package/dist/client/assets/index-CbSQ8xxn.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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>
|
package/dist/server/compact.js
CHANGED
|
@@ -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} `);
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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 || [])
|
package/dist/server/mcp.js
CHANGED
|
@@ -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('
|
|
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
|
|
1459
|
-
if (!
|
|
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
|
}
|
package/dist/server/state.js
CHANGED
|
@@ -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
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// browser
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
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.
|
|
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.
|
|
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** —
|
|
31
|
-
|
|
32
|
-
|
|
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
|