openwriter 0.13.0 → 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-BlLnLdoc.js → index-BxI3DazW.js} +2 -2
- package/dist/client/index.html +1 -1
- package/dist/server/documents.js +11 -11
- package/dist/server/index.js +2 -5
- package/dist/server/markdown-parse.js +144 -5
- package/dist/server/markdown-serialize.js +96 -9
- package/dist/server/markdown.js +32 -0
- 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 +172 -46
- package/package.json +1 -1
- package/skill/SKILL.md +1 -0
package/dist/server/state.js
CHANGED
|
@@ -6,11 +6,43 @@
|
|
|
6
6
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
|
-
import { tiptapToMarkdown, tiptapToBody, markdownToTiptap } from './markdown.js';
|
|
9
|
+
import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
13
|
import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } from './backlinks.js';
|
|
14
|
+
import { isAutoAcceptInheritedForDoc } from './workspaces.js';
|
|
15
|
+
import { matchNodes } from './node-matcher.js';
|
|
16
|
+
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
17
|
+
/** Read the persisted identity graph (nodes + graveyard) from a file's
|
|
18
|
+
* frontmatter. This is the matcher's previousNodes baseline at save time —
|
|
19
|
+
* the disk is the source of truth, not a parallel in-memory cache. Returns
|
|
20
|
+
* empty arrays for a brand-new file or unreadable frontmatter. */
|
|
21
|
+
function readPersistedIdentity(filePath) {
|
|
22
|
+
if (!filePath || !existsSync(filePath))
|
|
23
|
+
return { previousNodes: [], graveyard: [] };
|
|
24
|
+
try {
|
|
25
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
26
|
+
const { data } = matter(raw);
|
|
27
|
+
return {
|
|
28
|
+
previousNodes: normalizeNodeEntries(data.nodes),
|
|
29
|
+
graveyard: normalizeNodeEntries(data.graveyard),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { previousNodes: [], graveyard: [] };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Defensive parse of frontmatter node entries — drops any malformed rows.
|
|
37
|
+
* Mirrors the same-named helper in markdown-parse.ts so save and load apply
|
|
38
|
+
* identical validation. */
|
|
39
|
+
function normalizeNodeEntries(raw) {
|
|
40
|
+
if (!Array.isArray(raw))
|
|
41
|
+
return [];
|
|
42
|
+
return raw
|
|
43
|
+
.filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
|
|
44
|
+
.map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
|
|
45
|
+
}
|
|
14
46
|
const DEFAULT_DOC = {
|
|
15
47
|
type: 'doc',
|
|
16
48
|
content: [{ type: 'paragraph', content: [] }],
|
|
@@ -577,43 +609,109 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
|
|
|
577
609
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
578
610
|
if (!found)
|
|
579
611
|
continue;
|
|
580
|
-
|
|
612
|
+
let contentArray = Array.isArray(change.content) ? change.content : [change.content];
|
|
581
613
|
const originalNode = structuredClone(found.parent[found.index]);
|
|
614
|
+
// Preserve target node type when plain text would otherwise demote it.
|
|
615
|
+
// Markdown-it parses plain text as a paragraph, so rewriting a heading or
|
|
616
|
+
// list item with plain prose silently changes the type. Two adaptations:
|
|
617
|
+
// - Block wrappers (listItem, blockquote) wrap the parsed paragraph as
|
|
618
|
+
// their child, keeping the wrapper's type and attrs.
|
|
619
|
+
// - Inline-content leaves (heading, codeBlock) take the paragraph's
|
|
620
|
+
// inline text and host it inside the original type, preserving level
|
|
621
|
+
// and other attrs.
|
|
622
|
+
// Explicit markdown (e.g. "## Foo", "- bar") still wins because the
|
|
623
|
+
// parser produces a matching node type before we get here.
|
|
624
|
+
const targetType = originalNode.type;
|
|
625
|
+
const parsedType = contentArray[0]?.type;
|
|
626
|
+
const BLOCK_WRAPPERS = new Set(['listItem', 'blockquote']);
|
|
627
|
+
const INLINE_LEAVES = new Set(['heading', 'codeBlock']);
|
|
628
|
+
let isWrappedRewrite = false;
|
|
629
|
+
if (parsedType === 'paragraph' && targetType !== 'paragraph') {
|
|
630
|
+
if (BLOCK_WRAPPERS.has(targetType)) {
|
|
631
|
+
contentArray = [{
|
|
632
|
+
type: targetType,
|
|
633
|
+
attrs: { ...originalNode.attrs },
|
|
634
|
+
content: contentArray,
|
|
635
|
+
}];
|
|
636
|
+
isWrappedRewrite = true;
|
|
637
|
+
}
|
|
638
|
+
else if (INLINE_LEAVES.has(targetType)) {
|
|
639
|
+
// Standard stamping handles the leaf case — heading/codeBlock are
|
|
640
|
+
// themselves the decoration target, so no special branch needed below.
|
|
641
|
+
contentArray = [{
|
|
642
|
+
type: targetType,
|
|
643
|
+
attrs: { ...originalNode.attrs },
|
|
644
|
+
content: contentArray[0].content || [],
|
|
645
|
+
}];
|
|
646
|
+
}
|
|
647
|
+
}
|
|
582
648
|
// Empty node rewrite → treat as insert (green, not blue)
|
|
583
649
|
const originalText = extractText(originalNode.content || []);
|
|
584
650
|
const isEmptyNode = !originalText.trim();
|
|
585
651
|
// Only store original on first rewrite (preserve baseline for reject)
|
|
586
652
|
const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
|
|
587
653
|
// Detect partial change: if only a sub-range of the node text changed,
|
|
588
|
-
// attach selection range attrs so the frontend decorates only that part
|
|
654
|
+
// attach selection range attrs so the frontend decorates only that part.
|
|
655
|
+
// For wrapped rewrites (listItem), compare paragraph content against the
|
|
656
|
+
// listItem's inner paragraph so offsets align with what the user sees.
|
|
589
657
|
let partialRange = null;
|
|
590
658
|
if (!isEmptyNode && contentArray.length === 1 && !autoAccept) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
659
|
+
const baseContent = isWrappedRewrite
|
|
660
|
+
? (existingOriginal?.content?.[0]?.content || originalNode.content?.[0]?.content || [])
|
|
661
|
+
: (existingOriginal?.content || originalNode.content || []);
|
|
662
|
+
const newContent = isWrappedRewrite
|
|
663
|
+
? (contentArray[0].content?.[0]?.content || [])
|
|
664
|
+
: (contentArray[0].content || []);
|
|
665
|
+
partialRange = computePartialRange(baseContent, newContent);
|
|
666
|
+
}
|
|
667
|
+
// Build first node. For wrapped rewrites, pendingStatus and related attrs
|
|
668
|
+
// belong on the inner leaf (paragraph) so the decoration renderer — which
|
|
669
|
+
// keys off LEAF_BLOCK_TYPES — picks them up. The wrapper keeps the original
|
|
670
|
+
// node's id/attrs so subsequent calls can still target it.
|
|
671
|
+
let firstNode;
|
|
672
|
+
if (isWrappedRewrite && !autoAccept) {
|
|
673
|
+
const innerLeaf = contentArray[0].content?.[0] || { type: 'paragraph', content: [] };
|
|
674
|
+
const innerWithPending = {
|
|
675
|
+
...innerLeaf,
|
|
676
|
+
attrs: {
|
|
677
|
+
...innerLeaf.attrs,
|
|
678
|
+
id: innerLeaf.attrs?.id || generateNodeId(),
|
|
679
|
+
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
680
|
+
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
681
|
+
...(partialRange ? {
|
|
682
|
+
pendingSelectionFrom: partialRange.selectionFrom,
|
|
683
|
+
pendingSelectionTo: partialRange.selectionTo,
|
|
684
|
+
pendingOriginalFrom: partialRange.originalFrom,
|
|
685
|
+
pendingOriginalTo: partialRange.originalTo,
|
|
686
|
+
} : {}),
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
firstNode = {
|
|
690
|
+
type: 'listItem',
|
|
691
|
+
attrs: { ...contentArray[0].attrs, id: change.nodeId },
|
|
692
|
+
content: [innerWithPending, ...contentArray[0].content.slice(1)],
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
firstNode = {
|
|
697
|
+
...contentArray[0],
|
|
698
|
+
attrs: autoAccept ? {
|
|
699
|
+
...contentArray[0].attrs,
|
|
700
|
+
id: change.nodeId,
|
|
701
|
+
} : {
|
|
702
|
+
...contentArray[0].attrs,
|
|
703
|
+
id: change.nodeId,
|
|
704
|
+
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
705
|
+
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
706
|
+
...(partialRange ? {
|
|
707
|
+
pendingSelectionFrom: partialRange.selectionFrom,
|
|
708
|
+
pendingSelectionTo: partialRange.selectionTo,
|
|
709
|
+
pendingOriginalFrom: partialRange.originalFrom,
|
|
710
|
+
pendingOriginalTo: partialRange.originalTo,
|
|
711
|
+
} : {}),
|
|
712
|
+
},
|
|
713
|
+
};
|
|
595
714
|
}
|
|
596
|
-
// First node replaces the target (rewrite or insert if empty).
|
|
597
|
-
// In autoAccept mode, omit all pendingStatus/pendingOriginalContent attrs
|
|
598
|
-
// so the change commits cleanly with no review surface.
|
|
599
|
-
const firstNode = {
|
|
600
|
-
...contentArray[0],
|
|
601
|
-
attrs: autoAccept ? {
|
|
602
|
-
...contentArray[0].attrs,
|
|
603
|
-
id: change.nodeId,
|
|
604
|
-
} : {
|
|
605
|
-
...contentArray[0].attrs,
|
|
606
|
-
id: change.nodeId,
|
|
607
|
-
pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
|
|
608
|
-
...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
|
|
609
|
-
...(partialRange ? {
|
|
610
|
-
pendingSelectionFrom: partialRange.selectionFrom,
|
|
611
|
-
pendingSelectionTo: partialRange.selectionTo,
|
|
612
|
-
pendingOriginalFrom: partialRange.originalFrom,
|
|
613
|
-
pendingOriginalTo: partialRange.originalTo,
|
|
614
|
-
} : {}),
|
|
615
|
-
},
|
|
616
|
-
};
|
|
617
715
|
// Additional nodes get inserted after — as pending inserts in normal mode,
|
|
618
716
|
// as plain blocks in autoAccept mode.
|
|
619
717
|
const extraNodes = contentArray.slice(1).map((node) => ({
|
|
@@ -741,17 +839,11 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
|
|
|
741
839
|
export function isAutoAcceptActive(filename, metadata) {
|
|
742
840
|
if (metadata?.autoAccept === true)
|
|
743
841
|
return true;
|
|
842
|
+
if (metadata?.autoAccept === false)
|
|
843
|
+
return false; // explicit doc-level override of inheritance
|
|
744
844
|
if (!filename)
|
|
745
845
|
return false;
|
|
746
|
-
|
|
747
|
-
try {
|
|
748
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
749
|
-
const { isAutoAcceptInheritedForDoc } = require('./workspaces.js');
|
|
750
|
-
return isAutoAcceptInheritedForDoc(filename);
|
|
751
|
-
}
|
|
752
|
-
catch {
|
|
753
|
-
return false;
|
|
754
|
-
}
|
|
846
|
+
return isAutoAcceptInheritedForDoc(filename);
|
|
755
847
|
}
|
|
756
848
|
/** Apply changes to the active document singleton. */
|
|
757
849
|
function applyChangesToDocument(changes) {
|
|
@@ -789,7 +881,12 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
789
881
|
}]);
|
|
790
882
|
return { success: true };
|
|
791
883
|
}
|
|
792
|
-
/** Set the active document state. Used by documents.ts for multi-doc operations.
|
|
884
|
+
/** Set the active document state. Used by documents.ts for multi-doc operations.
|
|
885
|
+
*
|
|
886
|
+
* Identity tracking is NOT cached on PadState — the save-time matcher reads
|
|
887
|
+
* previousNodes + graveyard directly from disk frontmatter every write
|
|
888
|
+
* (Option B in adr/node-identity-matcher.md). Markdown is the source of
|
|
889
|
+
* truth; memory is an ephemeral working copy. */
|
|
793
890
|
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
|
|
794
891
|
state.document = doc;
|
|
795
892
|
state.title = title;
|
|
@@ -869,7 +966,11 @@ function populatePendingCache() {
|
|
|
869
966
|
}
|
|
870
967
|
}
|
|
871
968
|
const docCache = new Map(); // key = filePath
|
|
872
|
-
/** Cache the active document's full state, keyed by filePath. Call after save().
|
|
969
|
+
/** Cache the active document's full state, keyed by filePath. Call after save().
|
|
970
|
+
*
|
|
971
|
+
* Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
|
|
972
|
+
* it from disk frontmatter each write, so the cache stays a pure content
|
|
973
|
+
* snapshot. */
|
|
873
974
|
export function cacheActiveDocument() {
|
|
874
975
|
if (!state.filePath)
|
|
875
976
|
return;
|
|
@@ -1054,7 +1155,36 @@ function writeToDisk() {
|
|
|
1054
1155
|
: body;
|
|
1055
1156
|
}
|
|
1056
1157
|
else {
|
|
1057
|
-
|
|
1158
|
+
// Save-time matcher pass (Option B: disk is the source of truth).
|
|
1159
|
+
//
|
|
1160
|
+
// Read the existing file's frontmatter to recover previousNodes +
|
|
1161
|
+
// graveyard, run the matcher against the current TipTap tree, and apply
|
|
1162
|
+
// pinned IDs back onto the tree. Without this, type-change and
|
|
1163
|
+
// graveyard-restore never fire within a session — the editor mints fresh
|
|
1164
|
+
// IDs at insert time and the load-time matcher only sees the post-edit
|
|
1165
|
+
// state. Memory holds no identity cache; identity always re-derives from
|
|
1166
|
+
// disk at the save boundary.
|
|
1167
|
+
//
|
|
1168
|
+
// adr: adr/node-identity-matcher.md
|
|
1169
|
+
const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
|
|
1170
|
+
let nextGraveyard = graveyard;
|
|
1171
|
+
if (previousNodes.length > 0) {
|
|
1172
|
+
const newBlocks = tiptapToBlocks(state.document);
|
|
1173
|
+
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
|
|
1174
|
+
const pinnedByPosition = new Map();
|
|
1175
|
+
for (const p of matchResult.pinned)
|
|
1176
|
+
pinnedByPosition.set(p.position, p.id);
|
|
1177
|
+
applyIdsToTiptap(state.document, pinnedByPosition);
|
|
1178
|
+
nextGraveyard = matchResult.nextGraveyard;
|
|
1179
|
+
}
|
|
1180
|
+
// Pass graveyard through metadata so the serializer can emit it in frontmatter.
|
|
1181
|
+
const metaWithGraveyard = nextGraveyard.length > 0
|
|
1182
|
+
? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
|
|
1183
|
+
: state.metadata;
|
|
1184
|
+
// Checked serializer — verifies the TipTap → markdown → TipTap round-trip
|
|
1185
|
+
// preserves block shape. Logs to console on drift; never blocks the save.
|
|
1186
|
+
const result = tiptapToMarkdownChecked(state.document, state.title, metaWithGraveyard);
|
|
1187
|
+
markdown = result.markdown;
|
|
1058
1188
|
}
|
|
1059
1189
|
if (existsSync(state.filePath)) {
|
|
1060
1190
|
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
@@ -1448,12 +1578,8 @@ export function setAutoAcceptOnFile(filename, enabled) {
|
|
|
1448
1578
|
try {
|
|
1449
1579
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
1450
1580
|
const parsed = markdownToTiptap(raw);
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
}
|
|
1454
|
-
else {
|
|
1455
|
-
delete parsed.metadata.autoAccept;
|
|
1456
|
-
}
|
|
1581
|
+
// Explicit false (not delete) so the user's "off" overrides any workspace inheritance.
|
|
1582
|
+
parsed.metadata.autoAccept = enabled;
|
|
1457
1583
|
let markdown;
|
|
1458
1584
|
if (isExternalDoc(targetPath)) {
|
|
1459
1585
|
const body = tiptapToBody(parsed.document);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
package/skill/SKILL.md
CHANGED
|
@@ -205,6 +205,7 @@ For making changes to existing documents — rewrites, insertions, deletions:
|
|
|
205
205
|
- Always `read_pad` before editing to get fresh node IDs
|
|
206
206
|
- Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
|
|
207
207
|
- Content accepts markdown strings (preferred) or TipTap JSON
|
|
208
|
+
- **`rewrite` preserves the target node's type.** Sending plain prose to rewrite a heading keeps it a heading; the same for list items and blockquotes. To intentionally change a node's type, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
|
|
208
209
|
- Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
|
|
209
210
|
- **Never re-populate a document to fix it.** `populate_document` re-sends the entire document body — extremely token-expensive. To remove nodes, use `write_to_pad` with `{ operation: "delete", nodeId: "..." }`. To fix content, use `rewrite`. Only use `populate_document` once during initial creation, or as a last resort if the document is severely broken.
|
|
210
211
|
|