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.
@@ -6,10 +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
+ 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
+ }
13
46
  const DEFAULT_DOC = {
14
47
  type: 'doc',
15
48
  content: [{ type: 'paragraph', content: [] }],
@@ -576,43 +609,109 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
576
609
  const found = findNode(doc.content, change.nodeId, doc.content);
577
610
  if (!found)
578
611
  continue;
579
- const contentArray = Array.isArray(change.content) ? change.content : [change.content];
612
+ let contentArray = Array.isArray(change.content) ? change.content : [change.content];
580
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
+ }
581
648
  // Empty node rewrite → treat as insert (green, not blue)
582
649
  const originalText = extractText(originalNode.content || []);
583
650
  const isEmptyNode = !originalText.trim();
584
651
  // Only store original on first rewrite (preserve baseline for reject)
585
652
  const existingOriginal = found.parent[found.index].attrs?.pendingOriginalContent;
586
653
  // Detect partial change: if only a sub-range of the node text changed,
587
- // 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.
588
657
  let partialRange = null;
589
658
  if (!isEmptyNode && contentArray.length === 1 && !autoAccept) {
590
- // Use true original for partial range when a prior pending rewrite exists,
591
- // so offsets align with pendingOriginalContent
592
- const baseContent = existingOriginal?.content || originalNode.content || [];
593
- 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
+ };
594
714
  }
595
- // First node replaces the target (rewrite or insert if empty).
596
- // In autoAccept mode, omit all pendingStatus/pendingOriginalContent attrs
597
- // so the change commits cleanly with no review surface.
598
- const firstNode = {
599
- ...contentArray[0],
600
- attrs: autoAccept ? {
601
- ...contentArray[0].attrs,
602
- id: change.nodeId,
603
- } : {
604
- ...contentArray[0].attrs,
605
- id: change.nodeId,
606
- pendingStatus: isEmptyNode ? 'insert' : 'rewrite',
607
- ...(isEmptyNode ? {} : { pendingOriginalContent: existingOriginal || originalNode }),
608
- ...(partialRange ? {
609
- pendingSelectionFrom: partialRange.selectionFrom,
610
- pendingSelectionTo: partialRange.selectionTo,
611
- pendingOriginalFrom: partialRange.originalFrom,
612
- pendingOriginalTo: partialRange.originalTo,
613
- } : {}),
614
- },
615
- };
616
715
  // Additional nodes get inserted after — as pending inserts in normal mode,
617
716
  // as plain blocks in autoAccept mode.
618
717
  const extraNodes = contentArray.slice(1).map((node) => ({
@@ -733,9 +832,22 @@ function applyChangesToDoc(doc, changes, autoAccept = false) {
733
832
  }
734
833
  return processed;
735
834
  }
835
+ /**
836
+ * Effective auto-accept for a doc: true if the doc's own frontmatter has it,
837
+ * OR if any workspace/container ancestor in the workspace tree has it on.
838
+ */
839
+ export function isAutoAcceptActive(filename, metadata) {
840
+ if (metadata?.autoAccept === true)
841
+ return true;
842
+ if (metadata?.autoAccept === false)
843
+ return false; // explicit doc-level override of inheritance
844
+ if (!filename)
845
+ return false;
846
+ return isAutoAcceptInheritedForDoc(filename);
847
+ }
736
848
  /** Apply changes to the active document singleton. */
737
849
  function applyChangesToDocument(changes) {
738
- const autoAccept = state.metadata?.autoAccept === true;
850
+ const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
739
851
  const processed = applyChangesToDoc(state.document, changes, autoAccept);
740
852
  if (processed.length > 0) {
741
853
  state.lastModified = new Date();
@@ -755,7 +867,7 @@ export function applyTextEdits(nodeId, edits) {
755
867
  if (!result)
756
868
  return { success: false, error: 'No edits matched' };
757
869
  // Inline edit decoration only matters when there's a review surface — skip in autoAccept.
758
- if (state.metadata?.autoAccept !== true) {
870
+ if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
759
871
  result.node.attrs = {
760
872
  ...result.node.attrs,
761
873
  pendingTextEdits: result.textEdits,
@@ -769,7 +881,12 @@ export function applyTextEdits(nodeId, edits) {
769
881
  }]);
770
882
  return { success: true };
771
883
  }
772
- /** 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. */
773
890
  export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
774
891
  state.document = doc;
775
892
  state.title = title;
@@ -849,7 +966,11 @@ function populatePendingCache() {
849
966
  }
850
967
  }
851
968
  const docCache = new Map(); // key = filePath
852
- /** 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. */
853
974
  export function cacheActiveDocument() {
854
975
  if (!state.filePath)
855
976
  return;
@@ -1015,6 +1136,16 @@ export function getPendingDocInfo() {
1015
1136
  // ============================================================================
1016
1137
  function writeToDisk() {
1017
1138
  ensureDataDir();
1139
+ // Capture old forward links BEFORE we overwrite the file — needed by the
1140
+ // backlinks engine to know which target docs to refresh when source changes.
1141
+ // Skip for external docs (they don't participate in the doc graph).
1142
+ let oldForwardLinks = [];
1143
+ if (!isExternalDoc(state.filePath) && state.docId) {
1144
+ try {
1145
+ oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
1146
+ }
1147
+ catch { /* best-effort */ }
1148
+ }
1018
1149
  let markdown;
1019
1150
  if (isExternalDoc(state.filePath)) {
1020
1151
  // External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
@@ -1024,7 +1155,36 @@ function writeToDisk() {
1024
1155
  : body;
1025
1156
  }
1026
1157
  else {
1027
- 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;
1028
1188
  }
1029
1189
  if (existsSync(state.filePath)) {
1030
1190
  // Skip write if content is identical (prevents phantom git changes on doc switch)
@@ -1054,6 +1214,17 @@ function writeToDisk() {
1054
1214
  snapshotIfNeeded(state.docId, state.filePath);
1055
1215
  }
1056
1216
  catch { /* ignore */ }
1217
+ // Backlinks update: refresh target docs' backlinks frontmatter if source's
1218
+ // forward links changed. Best-effort — never blocks the save it follows.
1219
+ if (!isExternalDoc(state.filePath) && state.docId) {
1220
+ try {
1221
+ const newForwardLinks = extractForwardLinks(state.document, state.docId);
1222
+ updateBacklinksForSource(state.docId, newForwardLinks, oldForwardLinks);
1223
+ }
1224
+ catch (err) {
1225
+ console.error('[State] backlinks update failed:', err);
1226
+ }
1227
+ }
1057
1228
  }
1058
1229
  export function save() {
1059
1230
  if (!state.filePath) {
@@ -1407,12 +1578,8 @@ export function setAutoAcceptOnFile(filename, enabled) {
1407
1578
  try {
1408
1579
  const raw = readFileSync(targetPath, 'utf-8');
1409
1580
  const parsed = markdownToTiptap(raw);
1410
- if (enabled) {
1411
- parsed.metadata.autoAccept = true;
1412
- }
1413
- else {
1414
- delete parsed.metadata.autoAccept;
1415
- }
1581
+ // Explicit false (not delete) so the user's "off" overrides any workspace inheritance.
1582
+ parsed.metadata.autoAccept = enabled;
1416
1583
  let markdown;
1417
1584
  if (isExternalDoc(targetPath)) {
1418
1585
  const body = tiptapToBody(parsed.document);
@@ -1501,9 +1668,9 @@ export function populateDocumentFile(filename, doc) {
1501
1668
  const targetPath = resolveDocPath(filename);
1502
1669
  const raw = readFileSync(targetPath, 'utf-8');
1503
1670
  const parsed = markdownToTiptap(raw);
1504
- // Skip pending tagging when the target doc has autoAccept on —
1671
+ // Skip pending tagging when the target doc effectively has autoAccept on —
1505
1672
  // content commits directly as accepted.
1506
- if (parsed.metadata?.autoAccept !== true) {
1673
+ if (!isAutoAcceptActive(filename, parsed.metadata)) {
1507
1674
  markAllNodesAsPending(doc, 'insert');
1508
1675
  }
1509
1676
  flushDocToFile(filename, doc, parsed.title, parsed.metadata);
@@ -1541,7 +1708,7 @@ export function applyChangesToFile(filename, changes) {
1541
1708
  docId = metadata.docId || '';
1542
1709
  isTemp = false;
1543
1710
  }
1544
- const autoAccept = metadata?.autoAccept === true;
1711
+ const autoAccept = isAutoAcceptActive(filename, metadata);
1545
1712
  const processed = applyChangesToDoc(doc, changes, autoAccept);
1546
1713
  if (processed.length > 0) {
1547
1714
  flushDocToFile(filename, doc, title, metadata);
@@ -1597,7 +1764,7 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
1597
1764
  const result = applyTextEditsToNode(originalNode, edits);
1598
1765
  if (!result)
1599
1766
  return { success: false, error: 'No edits matched' };
1600
- const autoAccept = metadata?.autoAccept === true;
1767
+ const autoAccept = isAutoAcceptActive(filename, metadata);
1601
1768
  // pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
1602
1769
  // since the change commits directly.
1603
1770
  if (!autoAccept) {
@@ -4,6 +4,17 @@
4
4
  */
5
5
  import { Router } from 'express';
6
6
  import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, crossMoveContainer, promoteContainerToWorkspace, } from './workspaces.js';
7
+ import { findNode } from './workspace-tree.js';
8
+ import { deleteDocument } from './documents.js';
9
+ function collectDocFilesInSubtree(nodes, out = []) {
10
+ for (const n of nodes) {
11
+ if (n.type === 'doc')
12
+ out.push(n.file);
13
+ else if (n.type === 'container')
14
+ collectDocFilesInSubtree(n.items, out);
15
+ }
16
+ return out;
17
+ }
7
18
  export function createWorkspaceRouter(b) {
8
19
  const router = Router();
9
20
  router.get('/api/workspaces', (_req, res) => {
@@ -130,11 +141,28 @@ export function createWorkspaceRouter(b) {
130
141
  res.status(400).json({ error: err.message });
131
142
  }
132
143
  });
133
- router.delete('/api/workspaces/:filename/containers/:containerId', (req, res) => {
134
- try {
144
+ router.delete('/api/workspaces/:filename/containers/:containerId', async (req, res) => {
145
+ try {
146
+ const cascade = req.query.cascade === 'true' || req.query.cascade === '1';
147
+ let deletedDocs = 0;
148
+ if (cascade) {
149
+ // Find the container, collect all docs in its subtree, delete them from disk.
150
+ const current = getWorkspace(req.params.filename);
151
+ const found = findNode(current.root, (n) => n.type === 'container' && n.id === req.params.containerId);
152
+ if (found && found.node.type === 'container') {
153
+ const files = collectDocFilesInSubtree(found.node.items);
154
+ for (const file of files) {
155
+ try {
156
+ await deleteDocument(file);
157
+ deletedDocs++;
158
+ }
159
+ catch { /* swallow per-doc failures; keep going */ }
160
+ }
161
+ }
162
+ }
135
163
  const ws = removeContainer(req.params.filename, req.params.containerId);
136
164
  b.broadcastWorkspacesChanged();
137
- res.json(ws);
165
+ res.json({ ...ws, deletedDocs });
138
166
  }
139
167
  catch (err) {
140
168
  res.status(400).json({ error: err.message });
@@ -437,6 +437,91 @@ export function getWorkspaceAssignedFiles() {
437
437
  }
438
438
  return assigned;
439
439
  }
440
+ /**
441
+ * Walk every workspace and return true if `file` is inside one where auto-accept
442
+ * is on at the workspace level or on any ancestor container. Returns false when
443
+ * the doc isn't in any workspace or no ancestor has the flag set.
444
+ *
445
+ * A doc's own `autoAccept` frontmatter is NOT checked here — that's the caller's
446
+ * job (combined with this lookup, OR-style).
447
+ */
448
+ export function isAutoAcceptInheritedForDoc(file) {
449
+ const workspaces = listWorkspaces();
450
+ for (const info of workspaces) {
451
+ try {
452
+ const ws = readWorkspace(info.filename);
453
+ // Walk root to find the doc; collect ancestor containers along the way.
454
+ function walk(nodes, ancestors) {
455
+ for (const n of nodes) {
456
+ if (n.type === 'doc' && n.file === file) {
457
+ if (ws.autoAccept === true)
458
+ return true;
459
+ for (const c of ancestors)
460
+ if (c.autoAccept === true)
461
+ return true;
462
+ return false; // doc lives here but no ancestor flag set
463
+ }
464
+ if (n.type === 'container') {
465
+ const result = walk(n.items, [...ancestors, n]);
466
+ if (result !== null)
467
+ return result;
468
+ }
469
+ }
470
+ return null;
471
+ }
472
+ const found = walk(ws.root, []);
473
+ if (found === true)
474
+ return true;
475
+ // if found === false, doc IS in this workspace but no ancestor flag is on;
476
+ // continue scanning other workspaces (a doc could be referenced in multiple)
477
+ }
478
+ catch { /* skip corrupt manifests */ }
479
+ }
480
+ return false;
481
+ }
482
+ /** Set or clear workspace-level autoAccept. */
483
+ export function setWorkspaceAutoAccept(wsFile, enabled) {
484
+ const ws = readWorkspace(wsFile);
485
+ if (enabled)
486
+ ws.autoAccept = true;
487
+ else
488
+ delete ws.autoAccept;
489
+ writeWorkspace(wsFile, ws);
490
+ }
491
+ /** Set or clear container-level autoAccept. */
492
+ export function setContainerAutoAccept(wsFile, containerId, enabled) {
493
+ const ws = readWorkspace(wsFile);
494
+ const found = findContainer(ws.root, containerId);
495
+ if (!found)
496
+ throw new Error(`Container ${containerId} not found in ${wsFile}`);
497
+ if (enabled)
498
+ found.node.autoAccept = true;
499
+ else
500
+ delete found.node.autoAccept;
501
+ writeWorkspace(wsFile, ws);
502
+ }
503
+ /** Collect every file inside a workspace or container subtree. Used for broadcast. */
504
+ export function collectFilesInWorkspace(wsFile) {
505
+ try {
506
+ const ws = readWorkspace(wsFile);
507
+ return collectAllFiles(ws.root);
508
+ }
509
+ catch {
510
+ return [];
511
+ }
512
+ }
513
+ export function collectFilesInContainer(wsFile, containerId) {
514
+ try {
515
+ const ws = readWorkspace(wsFile);
516
+ const found = findContainer(ws.root, containerId);
517
+ if (!found)
518
+ return [];
519
+ return collectAllFiles(found.node.items);
520
+ }
521
+ catch {
522
+ return [];
523
+ }
524
+ }
440
525
  export function getWorkspaceStructure(filename) {
441
526
  return getWorkspace(filename);
442
527
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.12.1",
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
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.6.0"
19
+ version: "0.7.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -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
 
@@ -278,13 +279,9 @@ When creating **two or more documents together** — a tweet thread saved as sep
278
279
  - `reply` / `quote` types still require `url`
279
280
  - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
280
281
 
281
- ## Voice Frames
282
+ ## Companion Skills (optional)
282
283
 
283
- Pre-built voice postures for when the user wants a specific style but has no custom voice profile. Five frames cover the common needs: authority, provocateur, logical, storyteller, business.
284
-
285
- **Triggers** — any of the following should make you load frames: "write authoritatively", "authority voice", "contrarian take", "provocateur", "first principles", "logical/analytical essay", "tell the story", "storyteller", "business email", "high-status brevity", or an explicit frame name.
286
-
287
- **Protocol** — load `docs/voices.md` for the full selection guide and 4-step protocol. Then read the specific `voices/<frame>.md` for the rules. Apply all 6 category rules as hard constraints while drafting in the editor, and run the `docs/anti-ai.md` Tier 1 pass before leaving the output.
284
+ For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
288
285
 
289
286
  ## Workflow
290
287