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.
- 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 +60 -10
- package/dist/server/node-blocks.js +41 -1
- package/dist/server/state.js +22 -15
- package/dist/server/workspace-tree.js +18 -3
- package/dist/server/workspaces.js +2 -2
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
|
1459
|
-
if (!
|
|
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
|
}
|
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
|
|
@@ -89,10 +89,13 @@ export function addDocToContainer(root, containerId, file, title, afterIdentifie
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
else {
|
|
92
|
-
|
|
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
|
-
|
|
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
|
// ============================================================================
|