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
package/dist/server/state.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1411
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
##
|
|
282
|
+
## Companion Skills (optional)
|
|
282
283
|
|
|
283
|
-
|
|
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
|
|