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.
@@ -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
- const contentArray = Array.isArray(change.content) ? change.content : [change.content];
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
- // Use true original for partial range when a prior pending rewrite exists,
592
- // so offsets align with pendingOriginalContent
593
- const baseContent = existingOriginal?.content || originalNode.content || [];
594
- partialRange = computePartialRange(baseContent, contentArray[0].content || []);
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
- // Lazy import to avoid circular dep between state.ts and workspaces.ts
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
- markdown = tiptapToMarkdown(state.document, state.title, state.metadata);
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
- if (enabled) {
1452
- parsed.metadata.autoAccept = true;
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.13.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