openwriter 0.12.1 → 0.14.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-BxI3DazW.js +212 -0
- package/dist/client/assets/{index-CRImKlcp.css → index-OV13QtgQ.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/backlinks.js +323 -0
- package/dist/server/documents.js +11 -11
- package/dist/server/index.js +132 -6
- package/dist/server/markdown-parse.js +187 -9
- package/dist/server/markdown-serialize.js +97 -2
- package/dist/server/markdown.js +32 -0
- package/dist/server/marks.js +9 -0
- package/dist/server/mcp.js +148 -6
- package/dist/server/node-blocks.js +256 -0
- package/dist/server/node-fingerprint.js +264 -0
- package/dist/server/node-matcher.js +564 -0
- package/dist/server/node-sync-check.js +110 -0
- package/dist/server/state.js +210 -43
- package/dist/server/workspace-routes.js +31 -3
- package/dist/server/workspaces.js +85 -0
- package/package.json +1 -1
- package/skill/SKILL.md +4 -7
- package/dist/client/assets/index-CNmzNvB_.js +0 -211
- package/skill/docs/anti-ai.md +0 -71
- package/skill/docs/voices.md +0 -88
- package/skill/voices/authority.md +0 -102
- package/skill/voices/business.md +0 -103
- package/skill/voices/logical.md +0 -104
- package/skill/voices/provocateur.md +0 -101
- package/skill/voices/storyteller.md +0 -104
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown -> TipTap JSON parsing.
|
|
3
3
|
* Parses markdown (with optional YAML frontmatter) into TipTap document JSON.
|
|
4
|
+
*
|
|
5
|
+
* Node identity is reassigned via the matcher when the frontmatter carries a
|
|
6
|
+
* `nodes` array (the new path). For legacy docs without `nodes`, trailing
|
|
7
|
+
* `^id` caret anchors and `<!-- ^id -->` empty-paragraph markers are still
|
|
8
|
+
* recognized as ID sources so existing files keep their identities through
|
|
9
|
+
* the migration. Once a migrated doc is saved, the body is clean and all
|
|
10
|
+
* identity lives in frontmatter.
|
|
11
|
+
*
|
|
12
|
+
* adr: adr/node-identity-matcher.md
|
|
4
13
|
*/
|
|
5
14
|
import MarkdownIt from 'markdown-it';
|
|
6
15
|
import matter from 'gray-matter';
|
|
@@ -10,6 +19,8 @@ import markdownItSub from 'markdown-it-sub';
|
|
|
10
19
|
import markdownItSup from 'markdown-it-sup';
|
|
11
20
|
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
12
21
|
import { nodeText } from './markdown-serialize.js';
|
|
22
|
+
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
23
|
+
import { matchNodes } from './node-matcher.js';
|
|
13
24
|
// ============================================================================
|
|
14
25
|
// Markdown -> TipTap
|
|
15
26
|
// ============================================================================
|
|
@@ -19,24 +30,152 @@ md.use(markdownItIns);
|
|
|
19
30
|
md.use(markdownItMark);
|
|
20
31
|
md.use(markdownItSub);
|
|
21
32
|
md.use(markdownItSup);
|
|
33
|
+
/**
|
|
34
|
+
* Normalize blank lines INSIDE markdown tables before parsing.
|
|
35
|
+
*
|
|
36
|
+
* Per CommonMark, a blank line terminates a table block. Agents writing
|
|
37
|
+
* markdown content frequently insert blank lines between table rows for
|
|
38
|
+
* readability (e.g. `| row |\n\n| row |`), which the strict parser then
|
|
39
|
+
* splits into "table with 1 header row" + N orphan paragraphs that happen
|
|
40
|
+
* to contain pipe characters. The broken structure persists across saves
|
|
41
|
+
* because every serialize → re-parse cycle re-breaks it.
|
|
42
|
+
*
|
|
43
|
+
* This pre-pass detects "table region" (saw a separator row `| --- |`,
|
|
44
|
+
* haven't hit a non-pipe-row yet) and strips blank lines between pipe-rows
|
|
45
|
+
* inside that region. Code fences (` ``` `, `~~~`) are honored — pipes
|
|
46
|
+
* inside them stay untouched.
|
|
47
|
+
*
|
|
48
|
+
* Self-healing: a doc on disk with blank-separated rows loads as a proper
|
|
49
|
+
* N-row table; the next save writes contiguous markdown. No migration
|
|
50
|
+
* script needed.
|
|
51
|
+
*/
|
|
52
|
+
function normalizeTableBlankLines(markdown) {
|
|
53
|
+
if (!markdown.includes('|'))
|
|
54
|
+
return markdown;
|
|
55
|
+
const lines = markdown.split('\n');
|
|
56
|
+
const out = [];
|
|
57
|
+
let inFence = false;
|
|
58
|
+
let inTable = false; // true between a separator row and the next non-pipe-row
|
|
59
|
+
const isPipeRow = (s) => /^\s*\|.*\|\s*$/.test(s);
|
|
60
|
+
const isSeparator = (s) => /^\s*\|[\s:|-]+\|\s*$/.test(s);
|
|
61
|
+
const isBlank = (s) => s.trim() === '';
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i];
|
|
64
|
+
// Code-fence toggle — pipes inside fences stay verbatim
|
|
65
|
+
if (/^[`~]{3,}/.test(line)) {
|
|
66
|
+
inFence = !inFence;
|
|
67
|
+
inTable = false;
|
|
68
|
+
out.push(line);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (inFence) {
|
|
72
|
+
out.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (isSeparator(line)) {
|
|
76
|
+
// Separator confirms we're in a table region from here on
|
|
77
|
+
inTable = true;
|
|
78
|
+
out.push(line);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (inTable && isBlank(line)) {
|
|
82
|
+
// Look ahead past additional blanks for the next non-blank line
|
|
83
|
+
let j = i + 1;
|
|
84
|
+
while (j < lines.length && lines[j].trim() === '')
|
|
85
|
+
j++;
|
|
86
|
+
// If the next non-blank line is another pipe-row, it's EITHER a
|
|
87
|
+
// continuation row (merge across the blank) OR the header of a NEW
|
|
88
|
+
// table (the blank is the boundary, don't merge). We tell them apart
|
|
89
|
+
// by peeking one further: if line j+1 is a separator row, j is a new
|
|
90
|
+
// table's header — preserve the blank and exit the current table region.
|
|
91
|
+
if (j < lines.length && isPipeRow(lines[j])) {
|
|
92
|
+
if (j + 1 < lines.length && isSeparator(lines[j + 1])) {
|
|
93
|
+
// New table starting — keep the boundary blank, end current region
|
|
94
|
+
inTable = false;
|
|
95
|
+
out.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Continuation row — drop the blank
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Lookahead found non-pipe content — table region is ending
|
|
102
|
+
inTable = false;
|
|
103
|
+
out.push(line);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (inTable && !isPipeRow(line)) {
|
|
107
|
+
// Non-pipe content ends the table region
|
|
108
|
+
inTable = false;
|
|
109
|
+
}
|
|
110
|
+
out.push(line);
|
|
111
|
+
}
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
}
|
|
22
114
|
export function markdownToTiptap(markdown) {
|
|
23
115
|
const result = matter(markdown);
|
|
24
116
|
const { data, content } = result;
|
|
25
117
|
const title = data.title || 'Untitled';
|
|
26
|
-
const
|
|
118
|
+
const normalizedContent = normalizeTableBlankLines(content);
|
|
119
|
+
const tokens = md.parse(normalizedContent, {});
|
|
27
120
|
const docContent = tokensToTiptap(tokens);
|
|
28
121
|
const doc = {
|
|
29
122
|
type: 'doc',
|
|
30
123
|
content: docContent.length > 0 ? docContent : [{ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] }],
|
|
31
124
|
};
|
|
125
|
+
// Extract identity graph from frontmatter — these become the matcher's
|
|
126
|
+
// previousNodes input on both the load-time pass below AND on every
|
|
127
|
+
// subsequent save-time pass while the doc stays loaded.
|
|
128
|
+
const previousNodes = normalizeNodeEntries(data.nodes);
|
|
129
|
+
const graveyard = normalizeNodeEntries(data.graveyard);
|
|
130
|
+
// Load-time matcher pass — when frontmatter carries `nodes`, reassign IDs
|
|
131
|
+
// based on fingerprint match. Legacy docs (no `nodes` field) keep whatever
|
|
132
|
+
// IDs the body parser extracted from caret anchors or minted fresh.
|
|
133
|
+
if (previousNodes.length > 0) {
|
|
134
|
+
applyMatcher(doc, previousNodes, graveyard);
|
|
135
|
+
}
|
|
32
136
|
// Rehydrate pending state from frontmatter into node attrs
|
|
33
137
|
if (data.pending) {
|
|
34
138
|
rehydratePendingState(doc, data.pending);
|
|
35
139
|
}
|
|
36
|
-
// Strip
|
|
140
|
+
// Strip consumed keys from returned metadata
|
|
37
141
|
const metadata = { ...data };
|
|
38
142
|
delete metadata.pending;
|
|
39
|
-
|
|
143
|
+
delete metadata.nodes;
|
|
144
|
+
delete metadata.graveyard;
|
|
145
|
+
return {
|
|
146
|
+
title,
|
|
147
|
+
metadata,
|
|
148
|
+
document: doc,
|
|
149
|
+
rawFrontmatter: result.matter || null,
|
|
150
|
+
graveyard,
|
|
151
|
+
previousNodes,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Defensive parse of frontmatter node entries — drops any malformed rows. */
|
|
155
|
+
function normalizeNodeEntries(raw) {
|
|
156
|
+
if (!Array.isArray(raw))
|
|
157
|
+
return [];
|
|
158
|
+
return raw
|
|
159
|
+
.filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
|
|
160
|
+
.map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Run the matcher: compare frontmatter `nodes` (previous fingerprints) to
|
|
164
|
+
* the current TipTap tree's blocks, then apply pinned IDs back onto the tree.
|
|
165
|
+
*
|
|
166
|
+
* Graveyard is passed through so paste-back of recently-deleted content
|
|
167
|
+
* restores the original ID (matched by exact fingerprint).
|
|
168
|
+
*/
|
|
169
|
+
function applyMatcher(doc, previousNodes, graveyard) {
|
|
170
|
+
if (previousNodes.length === 0)
|
|
171
|
+
return;
|
|
172
|
+
const newBlocks = tiptapToBlocks(doc);
|
|
173
|
+
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
|
|
174
|
+
const pinnedByPosition = new Map();
|
|
175
|
+
for (const p of matchResult.pinned) {
|
|
176
|
+
pinnedByPosition.set(p.position, p.id);
|
|
177
|
+
}
|
|
178
|
+
applyIdsToTiptap(doc, pinnedByPosition);
|
|
40
179
|
}
|
|
41
180
|
/**
|
|
42
181
|
* Rehydrate pending state from frontmatter into leaf block node attrs.
|
|
@@ -116,19 +255,21 @@ function tokensToTiptap(tokens) {
|
|
|
116
255
|
if (token.type === 'heading_open') {
|
|
117
256
|
const level = parseInt(token.tag.slice(1));
|
|
118
257
|
const inlineToken = tokens[i + 1];
|
|
119
|
-
const
|
|
120
|
-
|
|
258
|
+
const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
|
|
259
|
+
const { content, id } = extractTrailingNodeId(rawContent);
|
|
260
|
+
nodes.push({ type: 'heading', attrs: { id: id || generateNodeId(), level }, content });
|
|
121
261
|
i += 3;
|
|
122
262
|
}
|
|
123
263
|
else if (token.type === 'paragraph_open') {
|
|
124
264
|
const inlineToken = tokens[i + 1];
|
|
125
|
-
const
|
|
265
|
+
const rawContent = inlineToken?.children ? inlineTokensToTiptap(inlineToken.children) : [];
|
|
266
|
+
const { content, id } = extractTrailingNodeId(rawContent);
|
|
126
267
|
// Check for solo image — promote to block-level image node
|
|
127
268
|
if (content.length === 1 && content[0].type === 'image') {
|
|
128
269
|
nodes.push(content[0]);
|
|
129
270
|
}
|
|
130
271
|
else {
|
|
131
|
-
nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content });
|
|
272
|
+
nodes.push({ type: 'paragraph', attrs: { id: id || generateNodeId() }, content });
|
|
132
273
|
}
|
|
133
274
|
i += 3;
|
|
134
275
|
}
|
|
@@ -168,10 +309,18 @@ function tokensToTiptap(tokens) {
|
|
|
168
309
|
i += 1;
|
|
169
310
|
}
|
|
170
311
|
else if (token.type === 'html_block') {
|
|
171
|
-
// <!-- --> is our sentinel for empty paragraphs
|
|
172
|
-
|
|
312
|
+
// <!-- --> is our sentinel for empty paragraphs.
|
|
313
|
+
// <!-- ^abc12345 --> is the same sentinel with a persisted nodeId.
|
|
314
|
+
const trimmed = token.content.trim();
|
|
315
|
+
if (trimmed === '<!-- -->') {
|
|
173
316
|
nodes.push({ type: 'paragraph', attrs: { id: generateNodeId() }, content: [] });
|
|
174
317
|
}
|
|
318
|
+
else {
|
|
319
|
+
const idMatch = trimmed.match(/^<!--\s*\^([a-f0-9]{8})\s*-->$/);
|
|
320
|
+
if (idMatch) {
|
|
321
|
+
nodes.push({ type: 'paragraph', attrs: { id: idMatch[1] }, content: [] });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
175
324
|
i += 1;
|
|
176
325
|
}
|
|
177
326
|
else if (token.type === 'table_open') {
|
|
@@ -440,3 +589,32 @@ function popMarkByType(stack, type) {
|
|
|
440
589
|
}
|
|
441
590
|
}
|
|
442
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* Extract a trailing nodeId anchor from inline content.
|
|
594
|
+
* Format: ` ^abc12345` (space + caret + 8 lowercase hex chars at end of line).
|
|
595
|
+
* Strips the marker from the visible text and returns the captured id.
|
|
596
|
+
* Returns id=null if no anchor found.
|
|
597
|
+
*
|
|
598
|
+
* Known limit: prose ending with the literal pattern ` ^[8 lowercase hex]`
|
|
599
|
+
* will be interpreted as an anchor. Vanishingly rare in real writing.
|
|
600
|
+
*/
|
|
601
|
+
function extractTrailingNodeId(content) {
|
|
602
|
+
if (!content || content.length === 0)
|
|
603
|
+
return { content, id: null };
|
|
604
|
+
const lastNode = content[content.length - 1];
|
|
605
|
+
if (lastNode.type !== 'text' || !lastNode.text)
|
|
606
|
+
return { content, id: null };
|
|
607
|
+
const match = lastNode.text.match(/ \^([a-f0-9]{8})\s*$/);
|
|
608
|
+
if (!match)
|
|
609
|
+
return { content, id: null };
|
|
610
|
+
const id = match[1];
|
|
611
|
+
const newText = lastNode.text.slice(0, match.index);
|
|
612
|
+
const newContent = [...content];
|
|
613
|
+
if (newText) {
|
|
614
|
+
newContent[newContent.length - 1] = { ...lastNode, text: newText };
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
newContent.pop();
|
|
618
|
+
}
|
|
619
|
+
return { content: newContent, id };
|
|
620
|
+
}
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* TipTap JSON -> Markdown serialization.
|
|
3
3
|
* Converts TipTap document to markdown with YAML frontmatter.
|
|
4
|
+
*
|
|
5
|
+
* Node identity persistence:
|
|
6
|
+
* - Frontmatter `nodes` array carries the (id, fingerprint) pair for every
|
|
7
|
+
* block in the document. On load, markdown-parse.ts runs the matcher
|
|
8
|
+
* against this array to reassign IDs to surviving blocks.
|
|
9
|
+
* - Markdown body is COMPLETELY UNDISTURBED — no anchors, no comment
|
|
10
|
+
* sentinels (except the legacy `<!-- -->` empty-paragraph marker which
|
|
11
|
+
* has no semantic ID attached anymore).
|
|
12
|
+
* - Legacy `^id` caret anchors are no longer emitted. Old docs that still
|
|
13
|
+
* contain them are migrated transparently on the next save: parse reads
|
|
14
|
+
* the anchors, matcher pins them, serialize emits the new `nodes`
|
|
15
|
+
* frontmatter and a clean body.
|
|
16
|
+
*
|
|
17
|
+
* adr: adr/node-identity-matcher.md
|
|
4
18
|
*/
|
|
5
|
-
import { LEAF_BLOCK_TYPES } from './helpers.js';
|
|
19
|
+
import { generateNodeId, LEAF_BLOCK_TYPES } from './helpers.js';
|
|
20
|
+
import { tiptapToBlocks } from './node-blocks.js';
|
|
21
|
+
import { fingerprintAll } from './node-fingerprint.js';
|
|
6
22
|
// ============================================================================
|
|
7
23
|
// TipTap -> Markdown
|
|
8
24
|
// ============================================================================
|
|
@@ -57,11 +73,67 @@ function collectPendingState(doc) {
|
|
|
57
73
|
walk(doc.content || []);
|
|
58
74
|
return Object.keys(pending).length > 0 ? pending : undefined;
|
|
59
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the `nodes` frontmatter entry — one (id, fingerprint) per block
|
|
78
|
+
* in pre-order traversal of the TipTap tree.
|
|
79
|
+
*
|
|
80
|
+
* Each block's ID comes from its TipTap node's `attrs.id`. Fingerprints are
|
|
81
|
+
* computed from the walker-style block list derived directly from the TipTap
|
|
82
|
+
* tree (no separate markdown re-parse — same source of truth that builds
|
|
83
|
+
* the visible doc).
|
|
84
|
+
*/
|
|
85
|
+
function collectNodesFrontmatter(doc) {
|
|
86
|
+
const blocks = tiptapToBlocks(doc);
|
|
87
|
+
const fingerprints = fingerprintAll(blocks);
|
|
88
|
+
const ids = collectBlockIds(doc);
|
|
89
|
+
// ids array is parallel to blocks array — same pre-order traversal.
|
|
90
|
+
const entries = [];
|
|
91
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
92
|
+
const id = ids[i] || generateNodeId();
|
|
93
|
+
entries.push({ id, fp: fingerprints[i] });
|
|
94
|
+
}
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Cap graveyard size to avoid frontmatter bloat on docs with many edits.
|
|
99
|
+
* Newest entries (highest position) win — the ones most likely to be
|
|
100
|
+
* paste-back targets. Older entries expire silently.
|
|
101
|
+
*/
|
|
102
|
+
const GRAVEYARD_MAX = 50;
|
|
103
|
+
/** Walk the TipTap tree in the SAME pre-order as tiptapToBlocks, collect IDs. */
|
|
104
|
+
function collectBlockIds(doc) {
|
|
105
|
+
const ids = [];
|
|
106
|
+
const blockTypes = new Set([
|
|
107
|
+
'heading', 'paragraph', 'bulletList', 'orderedList', 'taskList',
|
|
108
|
+
'listItem', 'taskItem', 'blockquote', 'codeBlock', 'horizontalRule',
|
|
109
|
+
'table', 'image', 'tableRow', 'tableCell', 'tableHeader',
|
|
110
|
+
]);
|
|
111
|
+
const containerTypes = new Set([
|
|
112
|
+
'bulletList', 'orderedList', 'taskList', 'listItem', 'taskItem', 'blockquote',
|
|
113
|
+
]);
|
|
114
|
+
function walk(nodes) {
|
|
115
|
+
if (!nodes)
|
|
116
|
+
return;
|
|
117
|
+
for (const node of nodes) {
|
|
118
|
+
if (blockTypes.has(node.type)) {
|
|
119
|
+
ids.push(node.attrs?.id || '');
|
|
120
|
+
if (containerTypes.has(node.type) && node.content)
|
|
121
|
+
walk(node.content);
|
|
122
|
+
}
|
|
123
|
+
else if (node.content) {
|
|
124
|
+
walk(node.content);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
walk(doc.content || []);
|
|
129
|
+
return ids;
|
|
130
|
+
}
|
|
60
131
|
/**
|
|
61
132
|
* Convert TipTap document to markdown with JSON frontmatter.
|
|
62
133
|
* Metadata stored as minified JSON between --- delimiters (valid YAML).
|
|
63
134
|
* Editor never sees frontmatter — it's stripped on load, regenerated on save.
|
|
64
135
|
* Pending state is persisted in frontmatter `pending` key.
|
|
136
|
+
* Node identity persisted in frontmatter `nodes` key (id + fingerprint per block).
|
|
65
137
|
*/
|
|
66
138
|
export function tiptapToMarkdown(doc, title, metadata) {
|
|
67
139
|
const meta = { ...metadata, title };
|
|
@@ -73,6 +145,24 @@ export function tiptapToMarkdown(doc, title, metadata) {
|
|
|
73
145
|
else {
|
|
74
146
|
delete meta.pending;
|
|
75
147
|
}
|
|
148
|
+
// Collect node identity graph (id + fingerprint per block) for next-load matcher
|
|
149
|
+
const nodes = collectNodesFrontmatter(doc);
|
|
150
|
+
if (nodes.length > 0) {
|
|
151
|
+
meta.nodes = nodes;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
delete meta.nodes;
|
|
155
|
+
}
|
|
156
|
+
// Graveyard: recently-orphaned (id, fingerprint) entries kept across saves so
|
|
157
|
+
// paste-back/undo can restore the original ID via exact fingerprint match.
|
|
158
|
+
// The caller (writeToDisk) puts the matcher's nextGraveyard into metadata.graveyard;
|
|
159
|
+
// we cap it here to keep the file small.
|
|
160
|
+
if (Array.isArray(meta.graveyard) && meta.graveyard.length > 0) {
|
|
161
|
+
meta.graveyard = meta.graveyard.slice(0, GRAVEYARD_MAX);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
delete meta.graveyard;
|
|
165
|
+
}
|
|
76
166
|
// Strip undefined/null values
|
|
77
167
|
for (const key of Object.keys(meta)) {
|
|
78
168
|
if (meta[key] === undefined || meta[key] === null)
|
|
@@ -98,11 +188,16 @@ function nodeToMarkdown(node, indent) {
|
|
|
98
188
|
case 'heading': {
|
|
99
189
|
const level = node.attrs?.level || 1;
|
|
100
190
|
const prefix = '#'.repeat(level);
|
|
191
|
+
// Body stays undisturbed — node ID is persisted in frontmatter `nodes`, not as a trailing anchor.
|
|
101
192
|
return `${prefix} ${inlineToMarkdown(node.content)}\n\n`;
|
|
102
193
|
}
|
|
103
194
|
case 'paragraph': {
|
|
104
195
|
const text = inlineToMarkdown(node.content);
|
|
105
|
-
|
|
196
|
+
if (text) {
|
|
197
|
+
return `${indent}${text}\n\n`;
|
|
198
|
+
}
|
|
199
|
+
// Empty paragraph: use plain sentinel (frontmatter `nodes` carries the ID).
|
|
200
|
+
return `${indent}<!-- -->\n\n`;
|
|
106
201
|
}
|
|
107
202
|
case 'bulletList':
|
|
108
203
|
return listToMarkdown(node.content, '- ', indent);
|
package/dist/server/markdown.js
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Barrel re-export for markdown serialization and parsing.
|
|
3
3
|
* All existing imports from './markdown.js' continue to work unchanged.
|
|
4
|
+
*
|
|
5
|
+
* Also exposes `tiptapToMarkdownChecked` — a sync-observed wrapper that
|
|
6
|
+
* verifies the TipTap → markdown → TipTap round-trip preserves document
|
|
7
|
+
* shape. Use this when you want to catch silent drift; the unchecked
|
|
8
|
+
* `tiptapToMarkdown` stays the cheap path for hot loops.
|
|
4
9
|
*/
|
|
10
|
+
import { tiptapToMarkdown } from './markdown-serialize.js';
|
|
11
|
+
import { markdownToTiptap } from './markdown-parse.js';
|
|
12
|
+
import { shapeOfTiptap, compareShapes, formatSyncReport, } from './node-sync-check.js';
|
|
5
13
|
export { tiptapToMarkdown, tiptapToBody, nodeText, inlineToMarkdown } from './markdown-serialize.js';
|
|
6
14
|
export { markdownToTiptap, markdownToNodes } from './markdown-parse.js';
|
|
15
|
+
export { shapeOfTiptap, computeShape, compareShapes, formatSyncReport, } from './node-sync-check.js';
|
|
16
|
+
/**
|
|
17
|
+
* Serialize TipTap to markdown, then re-parse and verify that the round-trip
|
|
18
|
+
* preserved document shape. If the shapes diverge, a node was misapplied —
|
|
19
|
+
* content ended up in the wrong block on save, or the parse interpreted
|
|
20
|
+
* something differently than the serialize emitted.
|
|
21
|
+
*
|
|
22
|
+
* Returns the markdown AND the sync report. Callers decide whether a failed
|
|
23
|
+
* report should block the save (throw) or just log and continue.
|
|
24
|
+
*
|
|
25
|
+
* Cost: one extra parse + two shape computations per save. Acceptable for
|
|
26
|
+
* any user-driven save; consider the unchecked path inside tight loops.
|
|
27
|
+
*/
|
|
28
|
+
export function tiptapToMarkdownChecked(doc, title, metadata) {
|
|
29
|
+
const markdown = tiptapToMarkdown(doc, title, metadata);
|
|
30
|
+
const expectedShape = shapeOfTiptap(doc);
|
|
31
|
+
const reparsed = markdownToTiptap(markdown);
|
|
32
|
+
const actualShape = shapeOfTiptap(reparsed.document);
|
|
33
|
+
const syncReport = compareShapes(expectedShape, actualShape);
|
|
34
|
+
if (!syncReport.ok) {
|
|
35
|
+
console.error(formatSyncReport(syncReport, `serialize:${title}`));
|
|
36
|
+
}
|
|
37
|
+
return { markdown, syncReport };
|
|
38
|
+
}
|
package/dist/server/marks.js
CHANGED
|
@@ -114,6 +114,15 @@ export function getGlobalMarkSummary(excludeFilename) {
|
|
|
114
114
|
catch { /* dir doesn't exist */ }
|
|
115
115
|
return { totalMarks, docCount };
|
|
116
116
|
}
|
|
117
|
+
export function editMark(filename, id, note) {
|
|
118
|
+
const data = readMarkFile(filename);
|
|
119
|
+
const mark = data.marks.find((m) => m.id === id);
|
|
120
|
+
if (!mark)
|
|
121
|
+
return null;
|
|
122
|
+
mark.note = note;
|
|
123
|
+
writeMarkFile(filename, data);
|
|
124
|
+
return mark;
|
|
125
|
+
}
|
|
117
126
|
export function resolveMarks(ids) {
|
|
118
127
|
const idSet = new Set(ids);
|
|
119
128
|
const resolved = [];
|
package/dist/server/mcp.js
CHANGED
|
@@ -10,8 +10,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
import { getDataDir, ensureDataDir, resolveDocPath, generateNodeId, atomicWriteFileSync } from './helpers.js';
|
|
13
|
-
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, } from './state.js';
|
|
14
|
-
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId } from './documents.js';
|
|
13
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, findNodesByIds, getMetadata, setMetadata, mergeMetadataUpdates, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, updatePendingCacheForActiveDoc, populateDocumentFile, applyChangesToFile, applyTextEditsToFile, getDocId, getFilePath, extractText, countPending, addDocTag, removeDocTag, getDocTagsByFilename, getCachedDocument, invalidateDocCache, isAutoAcceptActive, } from './state.js';
|
|
14
|
+
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, searchDocuments } from './documents.js';
|
|
15
|
+
import { extractForwardLinks } from './backlinks.js';
|
|
15
16
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastMarksChanged } from './ws.js';
|
|
16
17
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
17
18
|
import { findDocNode } from './workspace-tree.js';
|
|
@@ -221,8 +222,9 @@ export const TOOL_REGISTRY = [
|
|
|
221
222
|
pendingChanges: target.pendingCount,
|
|
222
223
|
lastModified: target.lastModified.toISOString(),
|
|
223
224
|
};
|
|
224
|
-
// Surface autoAccept
|
|
225
|
-
|
|
225
|
+
// Surface effective autoAccept (doc flag OR workspace/container inherited)
|
|
226
|
+
// so the agent stops waiting for review when it's on.
|
|
227
|
+
if (isAutoAcceptActive(target.filename, target.metadata))
|
|
226
228
|
status.autoAccept = true;
|
|
227
229
|
const latestVersion = getUpdateInfo();
|
|
228
230
|
const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
|
|
@@ -414,9 +416,10 @@ export const TOOL_REGISTRY = [
|
|
|
414
416
|
};
|
|
415
417
|
}
|
|
416
418
|
// Active target (or no filename): existing flow.
|
|
417
|
-
// Skip pending tagging when autoAccept is on
|
|
419
|
+
// Skip pending tagging when autoAccept is effectively on (doc flag or
|
|
420
|
+
// inherited from workspace/container) — content commits directly.
|
|
418
421
|
setAgentLock(); // Block browser doc-updates during population
|
|
419
|
-
if (
|
|
422
|
+
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
420
423
|
markAllNodesAsPending(doc, 'insert');
|
|
421
424
|
}
|
|
422
425
|
updateDocument(doc);
|
|
@@ -1293,6 +1296,145 @@ export const TOOL_REGISTRY = [
|
|
|
1293
1296
|
return { content: [{ type: 'text', text: ok ? `Removed task ${id}.` : `Task ${id} not found.` }] };
|
|
1294
1297
|
},
|
|
1295
1298
|
},
|
|
1299
|
+
{
|
|
1300
|
+
name: 'link_to',
|
|
1301
|
+
description: 'Wrap anchor text in the ACTIVE doc with a doc: link pointing at another doc. Optionally target a specific paragraph (target_node_id) for paragraph-level navigation, with an optional quote for scroll-anchor fallback. The on-save backlinks pipeline then auto-updates the target doc\'s frontmatter `backlinks` field — so this single tool call creates both the forward link and the backlink. Use after writing prose to cross-reference concepts: agent writes about "territorial imperative" then calls link_to to point that phrase at the canonical concept doc.',
|
|
1302
|
+
schema: {
|
|
1303
|
+
text: z.string().describe('Anchor text in the active doc to wrap with the link. Exact substring match. First occurrence wins if the text appears multiple times.'),
|
|
1304
|
+
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs).'),
|
|
1305
|
+
target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
|
|
1306
|
+
quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
|
|
1307
|
+
},
|
|
1308
|
+
handler: async ({ text, target_doc_id, target_node_id, quote }) => {
|
|
1309
|
+
// 1. Locate the block in the active doc that contains the anchor text
|
|
1310
|
+
const doc = getDocument();
|
|
1311
|
+
let sourceNodeId = null;
|
|
1312
|
+
function walk(nodes) {
|
|
1313
|
+
if (sourceNodeId)
|
|
1314
|
+
return;
|
|
1315
|
+
for (const node of nodes) {
|
|
1316
|
+
if (sourceNodeId)
|
|
1317
|
+
return;
|
|
1318
|
+
if (Array.isArray(node.content)) {
|
|
1319
|
+
const blockText = node.content.map((c) => c.text || '').join('');
|
|
1320
|
+
if (node.attrs?.id && blockText.includes(text)) {
|
|
1321
|
+
sourceNodeId = node.attrs.id;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
walk(node.content);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
walk(doc.content);
|
|
1329
|
+
if (!sourceNodeId) {
|
|
1330
|
+
return { content: [{ type: 'text', text: `Anchor text "${text}" not found in the active doc. Use search_docs first to locate the right doc.` }] };
|
|
1331
|
+
}
|
|
1332
|
+
// 2. Build the href in canonical doc:DOCID#NODEID?q=quote form
|
|
1333
|
+
let href = `doc:${target_doc_id}`;
|
|
1334
|
+
if (target_node_id)
|
|
1335
|
+
href += `#${target_node_id}`;
|
|
1336
|
+
if (quote)
|
|
1337
|
+
href += `?q=${encodeURIComponent(quote)}`;
|
|
1338
|
+
// 3. Apply the link mark to the matched substring via the existing text-edit pipeline
|
|
1339
|
+
const result = applyTextEdits(sourceNodeId, [{
|
|
1340
|
+
find: text,
|
|
1341
|
+
addMark: { type: 'link', attrs: { href } },
|
|
1342
|
+
}]);
|
|
1343
|
+
if (!result.success) {
|
|
1344
|
+
return { content: [{ type: 'text', text: `Failed to apply link mark: ${result.error}` }] };
|
|
1345
|
+
}
|
|
1346
|
+
save(); // triggers writeToDisk → backlinks pipeline auto-updates the target's frontmatter
|
|
1347
|
+
return { content: [{ type: 'text', text: `Linked "${text}" in node ${sourceNodeId} → ${href}. Target doc's backlinks frontmatter will refresh on next save.` }] };
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
name: 'search_docs',
|
|
1352
|
+
description: 'Full-text search across all documents. Returns ranked candidates with docId, title, match type, and snippet. Use this BEFORE link_to to find the right target — the agent\'s primary primitive for resolving concept references to their canonical docs.',
|
|
1353
|
+
schema: {
|
|
1354
|
+
query: z.string().describe('Search query (case-insensitive substring match against title, tags, then content).'),
|
|
1355
|
+
limit: z.number().optional().describe('Max results to return (default 10, max 50).'),
|
|
1356
|
+
},
|
|
1357
|
+
handler: async ({ query, limit = 10 }) => {
|
|
1358
|
+
const cap = Math.min(Math.max(limit, 1), 50);
|
|
1359
|
+
const raw = searchDocuments(query);
|
|
1360
|
+
// Enrich with docId by reading each result's frontmatter
|
|
1361
|
+
const enriched = raw.slice(0, cap).map((r) => {
|
|
1362
|
+
let docId = null;
|
|
1363
|
+
try {
|
|
1364
|
+
const filePath = resolveDocPath(r.filename);
|
|
1365
|
+
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1366
|
+
const fm = matter(fileRaw);
|
|
1367
|
+
docId = fm.data?.docId || null;
|
|
1368
|
+
}
|
|
1369
|
+
catch { /* docId stays null */ }
|
|
1370
|
+
return {
|
|
1371
|
+
docId,
|
|
1372
|
+
title: r.title,
|
|
1373
|
+
filename: r.filename,
|
|
1374
|
+
matchType: r.matchType,
|
|
1375
|
+
snippet: r.snippet,
|
|
1376
|
+
matchedTag: r.matchedTag,
|
|
1377
|
+
};
|
|
1378
|
+
});
|
|
1379
|
+
return { content: [{ type: 'text', text: JSON.stringify(enriched) }] };
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
name: 'get_graph',
|
|
1384
|
+
description: 'Return forward links + backlinks for a doc — the crawl primitive for cross-doc context retrieval. Forward links extracted from the doc body, backlinks read from the doc\'s frontmatter (maintained by the on-save backlinks pipeline). Optional depth walks neighbors recursively (cap 3).',
|
|
1385
|
+
schema: {
|
|
1386
|
+
docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
|
|
1387
|
+
depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
|
|
1388
|
+
},
|
|
1389
|
+
handler: async ({ docId, depth = 1 }) => {
|
|
1390
|
+
const maxDepth = Math.min(Math.max(depth, 1), 3);
|
|
1391
|
+
const seen = new Set();
|
|
1392
|
+
const nodes = [];
|
|
1393
|
+
function visit(id, hopsLeft) {
|
|
1394
|
+
if (seen.has(id) || hopsLeft < 0)
|
|
1395
|
+
return;
|
|
1396
|
+
seen.add(id);
|
|
1397
|
+
let target;
|
|
1398
|
+
try {
|
|
1399
|
+
target = resolveDocTarget(id);
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
const forward = extractForwardLinks(target.document, id);
|
|
1405
|
+
const backlinks = Array.isArray(target.metadata.backlinks) ? target.metadata.backlinks : [];
|
|
1406
|
+
nodes.push({
|
|
1407
|
+
docId: id,
|
|
1408
|
+
title: target.title,
|
|
1409
|
+
forward: forward.map((l) => ({
|
|
1410
|
+
text: l.text,
|
|
1411
|
+
from_node: l.from_node,
|
|
1412
|
+
to_doc: l.to_doc,
|
|
1413
|
+
...(l.to_node ? { to_node: l.to_node } : {}),
|
|
1414
|
+
})),
|
|
1415
|
+
backlinks: backlinks.map((b) => ({
|
|
1416
|
+
text: b.text,
|
|
1417
|
+
from_doc: b.from_doc,
|
|
1418
|
+
from_node: b.from_node,
|
|
1419
|
+
...(b.to_node ? { to_node: b.to_node } : {}),
|
|
1420
|
+
})),
|
|
1421
|
+
});
|
|
1422
|
+
if (hopsLeft > 0) {
|
|
1423
|
+
const neighbors = new Set();
|
|
1424
|
+
for (const l of forward)
|
|
1425
|
+
neighbors.add(l.to_doc);
|
|
1426
|
+
for (const b of backlinks)
|
|
1427
|
+
neighbors.add(b.from_doc);
|
|
1428
|
+
for (const n of neighbors) {
|
|
1429
|
+
if (!seen.has(n))
|
|
1430
|
+
visit(n, hopsLeft - 1);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
visit(docId, maxDepth - 1);
|
|
1435
|
+
return { content: [{ type: 'text', text: JSON.stringify(nodes) }] };
|
|
1436
|
+
},
|
|
1437
|
+
},
|
|
1296
1438
|
];
|
|
1297
1439
|
/** Live MCP server instance — used to register plugin tools dynamically. */
|
|
1298
1440
|
let mcpServerInstance = null;
|