openwriter 0.28.1 → 0.29.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-Gdw1m46J.css +1 -0
- package/dist/client/assets/index-SAoCjUU-.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +16 -4
- package/dist/server/index.js +25 -2
- package/dist/server/mcp.js +13 -4
- package/dist/server/pending-overlay.js +46 -0
- package/dist/server/prompt-debug.js +68 -0
- package/dist/server/state.js +74 -10
- package/package.json +1 -1
- package/skill/SKILL.md +3 -1
- package/dist/client/assets/index-3no79ry9.css +0 -1
- package/dist/client/assets/index-DzHT4klX.js +0 -214
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-SAoCjUU-.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -1108,7 +1108,7 @@ export function openFile(fullPath) {
|
|
|
1108
1108
|
const filename = isExternalDoc(canonPath) ? canonPath : baseName;
|
|
1109
1109
|
return { document: getDocument(), title: getTitle(), filename };
|
|
1110
1110
|
}
|
|
1111
|
-
export function duplicateDocument(filename) {
|
|
1111
|
+
export function duplicateDocument(filename, variant) {
|
|
1112
1112
|
// Cancel any pending debounced save, then save current doc immediately
|
|
1113
1113
|
cancelDebouncedSave();
|
|
1114
1114
|
save();
|
|
@@ -1118,17 +1118,29 @@ export function duplicateDocument(filename) {
|
|
|
1118
1118
|
}
|
|
1119
1119
|
const raw = readFileSync(sourcePath, 'utf-8');
|
|
1120
1120
|
const parsed = markdownToTiptap(raw);
|
|
1121
|
+
// Title suffix: variants read as "(Tweet)" / "(Blog)", plain copies as "(Copy)".
|
|
1122
|
+
const suffix = variant?.variantType
|
|
1123
|
+
? variant.variantType.charAt(0).toUpperCase() + variant.variantType.slice(1)
|
|
1124
|
+
: 'Copy';
|
|
1121
1125
|
// Generate deduplicated title
|
|
1122
|
-
let newTitle = `${parsed.title} (
|
|
1126
|
+
let newTitle = `${parsed.title} (${suffix})`;
|
|
1123
1127
|
let filePath = filePathForTitle(newTitle);
|
|
1124
1128
|
if (existsSync(filePath)) {
|
|
1125
1129
|
let counter = 2;
|
|
1126
|
-
while (existsSync(filePathForTitle(`${parsed.title} (
|
|
1130
|
+
while (existsSync(filePathForTitle(`${parsed.title} (${suffix} ${counter})`)))
|
|
1127
1131
|
counter++;
|
|
1128
|
-
newTitle = `${parsed.title} (
|
|
1132
|
+
newTitle = `${parsed.title} (${suffix} ${counter})`;
|
|
1129
1133
|
filePath = filePathForTitle(newTitle);
|
|
1130
1134
|
}
|
|
1131
1135
|
const metadata = { ...parsed.metadata, title: newTitle, docId: generateNodeId() };
|
|
1136
|
+
// Variant relationship — set AFTER the spread so it overrides any inherited
|
|
1137
|
+
// masterDocId/variantType from the source doc. masterDocId points at the
|
|
1138
|
+
// source (the master); variantType labels this copy's intended format.
|
|
1139
|
+
// adr: docs/variants.md
|
|
1140
|
+
if (variant?.masterDocId)
|
|
1141
|
+
metadata.masterDocId = variant.masterDocId;
|
|
1142
|
+
if (variant?.variantType)
|
|
1143
|
+
metadata.variantType = variant.variantType;
|
|
1132
1144
|
setActiveDocument(parsed.document, newTitle, filePath, false, undefined, metadata);
|
|
1133
1145
|
const { markdown } = tiptapToMarkdownChecked(parsed.document, newTitle, metadata);
|
|
1134
1146
|
ensureDataDir();
|
package/dist/server/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, ge
|
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
16
|
import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
|
|
17
17
|
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
|
|
18
|
+
import { writePromptDebug, isPromptDebugEnabled } from './prompt-debug.js';
|
|
18
19
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
19
20
|
import { createLinkRouter } from './link-routes.js';
|
|
20
21
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -224,6 +225,28 @@ export async function startHttpServer(options = {}) {
|
|
|
224
225
|
res.status(500).json({ error: err.message });
|
|
225
226
|
}
|
|
226
227
|
});
|
|
228
|
+
// Prompt debug inspector: write the realized AV prompt to a timestamped .md doc
|
|
229
|
+
// for hand review. Off by default — gated by OW_PROMPT_DEBUG (see docs/prompt-debug.md).
|
|
230
|
+
// When off, no-ops so the client POST stays harmless.
|
|
231
|
+
app.post('/api/prompt-debug', (req, res) => {
|
|
232
|
+
try {
|
|
233
|
+
if (!isPromptDebugEnabled()) {
|
|
234
|
+
res.json({ success: false, skipped: true });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const { action, debug, metadata } = req.body;
|
|
238
|
+
if (!debug) {
|
|
239
|
+
res.status(400).json({ error: 'debug payload is required' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const filename = writePromptDebug(action, debug, metadata);
|
|
243
|
+
broadcastDocumentsChanged();
|
|
244
|
+
res.json({ success: true, filename });
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
res.status(500).json({ error: err.message });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
227
250
|
// Toggle auto-accept on a workspace or container. Inherits to every doc inside.
|
|
228
251
|
// Body: { wsFile, containerId?, enabled }. Omit containerId to target the
|
|
229
252
|
// whole workspace; pass it to target a specific container.
|
|
@@ -601,12 +624,12 @@ export async function startHttpServer(options = {}) {
|
|
|
601
624
|
});
|
|
602
625
|
app.post('/api/documents/duplicate', (req, res) => {
|
|
603
626
|
try {
|
|
604
|
-
const { filename } = req.body;
|
|
627
|
+
const { filename, masterDocId, variantType } = req.body;
|
|
605
628
|
if (!filename) {
|
|
606
629
|
res.status(400).json({ error: 'filename is required' });
|
|
607
630
|
return;
|
|
608
631
|
}
|
|
609
|
-
const result = duplicateDocument(filename);
|
|
632
|
+
const result = duplicateDocument(filename, (masterDocId || variantType) ? { masterDocId, variantType } : undefined);
|
|
610
633
|
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
611
634
|
broadcastDocumentsChanged();
|
|
612
635
|
res.json(result);
|
package/dist/server/mcp.js
CHANGED
|
@@ -497,8 +497,10 @@ export const TOOL_REGISTRY = [
|
|
|
497
497
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
498
498
|
afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
|
|
499
499
|
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
|
|
500
|
+
masterDocId: z.string().optional().describe('Make this doc a VARIANT of another doc. Pass the master doc\'s docId (8-char hex). The variant nests under its master in the sidebar (expandable tree). Use when creating a derivative — e.g. a tweet thread from a blog post. Pair with variantType. See docs/variants.md.'),
|
|
501
|
+
variantType: z.string().optional().describe('Label for what kind of variant this is (e.g. "tweet", "blog", "linkedin"). Shows as a badge in the sidebar. Only meaningful alongside masterDocId.'),
|
|
500
502
|
},
|
|
501
|
-
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status }) => {
|
|
503
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
|
|
502
504
|
// Require url for reply/quote
|
|
503
505
|
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
504
506
|
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
@@ -532,13 +534,20 @@ export const TOOL_REGISTRY = [
|
|
|
532
534
|
// canonical via set_metadata({ status: "canonical" }) on lifecycle
|
|
533
535
|
// transitions. See brief 2026-05-21-simplify-enrichment-schema-three-fields.
|
|
534
536
|
const statusMeta = { status: status ?? 'draft' };
|
|
537
|
+
// Variant relationship (optional) — lands on the first disk write so the
|
|
538
|
+
// doc nests under its master in the sidebar immediately. See docs/variants.md.
|
|
539
|
+
const variantMeta = {};
|
|
540
|
+
if (masterDocId)
|
|
541
|
+
variantMeta.masterDocId = masterDocId;
|
|
542
|
+
if (variantType)
|
|
543
|
+
variantMeta.variantType = variantType;
|
|
535
544
|
try {
|
|
536
545
|
if (empty) {
|
|
537
546
|
// Immediate switch — no spinner, no populate_document needed
|
|
538
547
|
const result = createDocument(title, undefined, path);
|
|
539
548
|
setAgentLock(result.filename);
|
|
540
|
-
// Apply status + type-specific metadata in one merge
|
|
541
|
-
const initMeta = { ...statusMeta };
|
|
549
|
+
// Apply status + variant + type-specific metadata in one merge
|
|
550
|
+
const initMeta = { ...statusMeta, ...variantMeta };
|
|
542
551
|
if (content_type) {
|
|
543
552
|
const typeMeta = resolveTypeMeta(content_type, url);
|
|
544
553
|
if (typeMeta)
|
|
@@ -578,7 +587,7 @@ export const TOOL_REGISTRY = [
|
|
|
578
587
|
// Merge status with any content-type metadata so it lands on the first
|
|
579
588
|
// disk write.
|
|
580
589
|
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
581
|
-
const initialMeta = { ...statusMeta, ...(typeMeta || {}) };
|
|
590
|
+
const initialMeta = { ...statusMeta, ...variantMeta, ...(typeMeta || {}) };
|
|
582
591
|
const result = createDocumentFile(title, path, initialMeta);
|
|
583
592
|
let wsInfo = '';
|
|
584
593
|
if (wsTarget) {
|
|
@@ -859,6 +859,52 @@ function sameContent(a, b) {
|
|
|
859
859
|
const bClean = sanitizeNodeForBaseline(b);
|
|
860
860
|
return JSON.stringify(aClean) === JSON.stringify(bClean);
|
|
861
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* Repair canonical rewrite nodes that still hold the rewrite's NEW content.
|
|
864
|
+
*
|
|
865
|
+
* `splitMergedDoc` / `stripPendingFromDoc` can only revert a rewrite via the
|
|
866
|
+
* node's own `pendingOriginalContent` attr. When a merged doc arrives from an
|
|
867
|
+
* untrusted source — a browser doc-update (`syncBrowserDocUpdate`) — whose
|
|
868
|
+
* rewrite node dropped or staled that attr, the revert silently fails and the
|
|
869
|
+
* rewrite TEXT lands in canonical. The next `applyOverlayPure` then compares
|
|
870
|
+
* canonical-as-rewrite to the (still-correct) overlay baseline and falsely
|
|
871
|
+
* flags `pendingStaleBaseline` (the amber dotted-underline indicator).
|
|
872
|
+
*
|
|
873
|
+
* The server's overlay entry retains the authoritative `originalBaseline`, so
|
|
874
|
+
* re-assert it here — but ONLY when the canonical node currently equals the
|
|
875
|
+
* entry's `newContent`. That condition means "the revert failed." If canonical
|
|
876
|
+
* holds anything else (a genuine out-of-band edit), it is left untouched so
|
|
877
|
+
* real stale-baseline drift is still surfaced for review.
|
|
878
|
+
*
|
|
879
|
+
* adr: adr/pending-overlay-model.md · adr: adr/tweet-paragraph-convention.md
|
|
880
|
+
*/
|
|
881
|
+
export function reconcileCanonicalToBaselines(canonical, entries) {
|
|
882
|
+
const byId = new Map();
|
|
883
|
+
for (const e of entries) {
|
|
884
|
+
if (e.status === 'rewrite' && e.originalBaseline?.content && e.newContent?.content) {
|
|
885
|
+
byId.set(e.nodeId, e);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (byId.size === 0)
|
|
889
|
+
return;
|
|
890
|
+
const key = (content) => JSON.stringify(sanitizeNodeForBaseline({ content }));
|
|
891
|
+
function walk(nodes) {
|
|
892
|
+
if (!Array.isArray(nodes))
|
|
893
|
+
return;
|
|
894
|
+
for (const n of nodes) {
|
|
895
|
+
const e = n?.attrs?.id ? byId.get(n.attrs.id) : undefined;
|
|
896
|
+
if (e && key(n.content) === key(e.newContent.content)) {
|
|
897
|
+
// Canonical is holding the rewrite text — the split couldn't revert it.
|
|
898
|
+
// Restore the authoritative baseline.
|
|
899
|
+
n.content = JSON.parse(JSON.stringify(e.originalBaseline.content));
|
|
900
|
+
}
|
|
901
|
+
else if (n.content) {
|
|
902
|
+
walk(n.content);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
walk(canonical?.content || []);
|
|
907
|
+
}
|
|
862
908
|
function sanitizeNodeForBaseline(node) {
|
|
863
909
|
// Strip volatile fields (ids, pending attrs) for content comparison.
|
|
864
910
|
const cloned = JSON.parse(JSON.stringify(node));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: prompt-debug.ts
|
|
3
|
+
* Purpose: Write AV prompt debug data to timestamped .md files for inspection.
|
|
4
|
+
* Each enhance creates a new `_prompt-<action>-<ts>.md` doc in the data dir,
|
|
5
|
+
* visible in the OpenWriter sidebar — so the exact system+user prompt that was
|
|
6
|
+
* sent to the AI can be reviewed by hand.
|
|
7
|
+
* Control: gated by isPromptDebugEnabled() (OW_PROMPT_DEBUG env). Off by default.
|
|
8
|
+
* See docs/prompt-debug.md for the on/off switch.
|
|
9
|
+
*/
|
|
10
|
+
import { getDataDir, ensureDataDir, atomicWriteFileSync } from './helpers.js';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
/** True when the prompt-debug inspector is switched on. Read lazily so the
|
|
13
|
+
* process picks up OW_PROMPT_DEBUG without code changes. */
|
|
14
|
+
export function isPromptDebugEnabled() {
|
|
15
|
+
const v = process.env.OW_PROMPT_DEBUG;
|
|
16
|
+
return v === '1' || v === 'true';
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Write prompt debug info to a timestamped markdown file.
|
|
20
|
+
* Returns the filename created.
|
|
21
|
+
*/
|
|
22
|
+
export function writePromptDebug(action, debug, metadata) {
|
|
23
|
+
ensureDataDir();
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
26
|
+
const filename = `_prompt-${action || 'debug'}-${ts}.md`;
|
|
27
|
+
const filePath = join(getDataDir(), filename);
|
|
28
|
+
const timeStr = now.toLocaleTimeString('en-US', { hour12: true, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
29
|
+
let md = `---\ntitle: "Prompt Debug: ${action} @ ${timeStr}"\n---\n\n`;
|
|
30
|
+
// Metadata summary
|
|
31
|
+
if (metadata) {
|
|
32
|
+
md += `## Metadata\n\n`;
|
|
33
|
+
md += `| Key | Value |\n|-----|-------|\n`;
|
|
34
|
+
if (metadata.action)
|
|
35
|
+
md += `| Action | ${metadata.action} |\n`;
|
|
36
|
+
if (metadata.profileUsed)
|
|
37
|
+
md += `| Profile | ${metadata.profileUsed} |\n`;
|
|
38
|
+
if (metadata.nodesIn != null)
|
|
39
|
+
md += `| Nodes In | ${metadata.nodesIn} |\n`;
|
|
40
|
+
if (metadata.nodesOut != null)
|
|
41
|
+
md += `| Nodes Out | ${metadata.nodesOut} |\n`;
|
|
42
|
+
if (metadata.ragExamples != null)
|
|
43
|
+
md += `| RAG Examples | ${metadata.ragExamples} |\n`;
|
|
44
|
+
if (metadata.ragTotalWords != null)
|
|
45
|
+
md += `| RAG Total Words | ${metadata.ragTotalWords} |\n`;
|
|
46
|
+
if (metadata.processingTimeMs != null)
|
|
47
|
+
md += `| Processing Time | ${metadata.processingTimeMs}ms |\n`;
|
|
48
|
+
if (metadata.estimatedCost != null)
|
|
49
|
+
md += `| Estimated Cost | $${metadata.estimatedCost.toFixed(4)} |\n`;
|
|
50
|
+
md += `\n`;
|
|
51
|
+
}
|
|
52
|
+
// System prompt
|
|
53
|
+
md += `## System Prompt\n\n`;
|
|
54
|
+
md += debug.systemPrompt + '\n\n';
|
|
55
|
+
// User prompt
|
|
56
|
+
md += `---\n\n## User Prompt\n\n`;
|
|
57
|
+
md += debug.userPrompt + '\n\n';
|
|
58
|
+
// RAG examples
|
|
59
|
+
if (debug.ragExamples && debug.ragExamples.length > 0) {
|
|
60
|
+
md += `---\n\n## RAG Examples (${debug.ragExamples.length})\n\n`;
|
|
61
|
+
for (const ex of debug.ragExamples) {
|
|
62
|
+
md += `### ${ex.anchor} (${ex.wordCount} words)\n\n`;
|
|
63
|
+
md += ex.context + '\n\n';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
atomicWriteFileSync(filePath, md);
|
|
67
|
+
return filename;
|
|
68
|
+
}
|
package/dist/server/state.js
CHANGED
|
@@ -9,14 +9,14 @@ import matter from 'gray-matter';
|
|
|
9
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, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
|
|
12
|
-
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
12
|
+
import { snapshotIfNeeded, ensureDocId, forceSnapshot } from './versions.js';
|
|
13
13
|
import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
|
|
14
14
|
import { isAutoAcceptInheritedForDoc } from './workspaces.js';
|
|
15
15
|
import { matchNodes } from './node-matcher.js';
|
|
16
16
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
17
17
|
import { anyLegacyRaw } from './node-fingerprint.js';
|
|
18
18
|
import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
|
|
19
|
-
import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, loadDocFromDisk, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
19
|
+
import { extractOverlay, applyOverlayPure, splitMergedDoc, reconcileCanonicalToBaselines, saveOverlay, loadOverlay, loadDocFromDisk, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
20
20
|
import { loadPendingMetadata, savePendingMetadata } from './pending-metadata.js';
|
|
21
21
|
import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
|
|
22
22
|
import { clearActivityBuffer } from './activity-log.js';
|
|
@@ -145,6 +145,42 @@ function setPrimaryFromMerged(merged) {
|
|
|
145
145
|
state.canonical = canonical;
|
|
146
146
|
setOverlayFromEntries(overlayEntries);
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Browser-write body-fidelity invariant. adr: adr/browser-write-fidelity.md
|
|
150
|
+
*
|
|
151
|
+
* A browser editor surface can mount empty, or parse the canonical body
|
|
152
|
+
* through a NARROWER schema (the X-article / tweet compose extensions),
|
|
153
|
+
* then autosave that lossy view back — collapsing a populated body to
|
|
154
|
+
* empty/near-empty on disk. This is the "autosave-clobber" class: silent
|
|
155
|
+
* data loss the user never authored.
|
|
156
|
+
*
|
|
157
|
+
* The invariant lives at the BROWSER-WRITE BOUNDARY — every function that
|
|
158
|
+
* replaces canonical from a browser-sent doc (`updateDocument`,
|
|
159
|
+
* `syncBrowserDocUpdate`, `saveDocToFile`). It deliberately does NOT live
|
|
160
|
+
* at the disk chokepoint (`writeToDisk`): restore_version, MCP edits, and
|
|
161
|
+
* agent `applyChanges` mutate canonical directly and are TRUSTED to shrink
|
|
162
|
+
* a doc intentionally. Recovery-restores GROW the doc (incoming > current)
|
|
163
|
+
* and so pass freely here too.
|
|
164
|
+
*
|
|
165
|
+
* A replacement that collapses a substantial body (>5 nodes) to under 30%
|
|
166
|
+
* of its node count is a view artifact, not an edit — refuse it.
|
|
167
|
+
*/
|
|
168
|
+
export function wouldCollapseBody(current, incoming) {
|
|
169
|
+
const currentNodes = current?.content?.length ?? 0;
|
|
170
|
+
const incomingNodes = incoming?.content?.length ?? 0;
|
|
171
|
+
return currentNodes > 5 && incomingNodes < currentNodes * 0.3;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Checkpoint-then-refuse helper for the active doc. Forces a version
|
|
175
|
+
* snapshot of the current ON-DISK body — still the good content, since the
|
|
176
|
+
* clobbering write is being refused — so it is recoverable under a labeled
|
|
177
|
+
* version even if no prior autosave snapshot happened to retain it.
|
|
178
|
+
*/
|
|
179
|
+
function checkpointActiveBody() {
|
|
180
|
+
const docId = getDocId();
|
|
181
|
+
if (docId && state.filePath)
|
|
182
|
+
forceSnapshot(docId, state.filePath);
|
|
183
|
+
}
|
|
148
184
|
/**
|
|
149
185
|
* Sync routing for a stale-version browser doc-update. The browser's
|
|
150
186
|
* submission was captured at server version `browserVersion`; the server
|
|
@@ -168,6 +204,15 @@ function setPrimaryFromMerged(merged) {
|
|
|
168
204
|
* adr: adr/pending-overlay-model.md
|
|
169
205
|
*/
|
|
170
206
|
export function syncBrowserDocUpdate(browserDoc, browserVersion) {
|
|
207
|
+
// Browser-write fidelity: a STALE-version browser doc-update is the same
|
|
208
|
+
// autosave-clobber class as the current-version path — and this path
|
|
209
|
+
// previously bypassed the guard, writing the empty surface straight to
|
|
210
|
+
// canonical. Refuse + checkpoint here too. adr: adr/browser-write-fidelity.md
|
|
211
|
+
if (wouldCollapseBody(state.document, browserDoc)) {
|
|
212
|
+
checkpointActiveBody();
|
|
213
|
+
console.error(`[State] REFUSED body-collapse in syncBrowserDocUpdate: ${browserDoc?.content?.length ?? 0} nodes would replace ${state.document?.content?.length ?? 0} nodes (checkpointed, write refused)`);
|
|
214
|
+
return { preservedServerEntries: 0 };
|
|
215
|
+
}
|
|
171
216
|
const { canonical: browserCanonical, overlayEntries: browserOverlay } = splitMergedDoc(browserDoc);
|
|
172
217
|
// Identify server overlay entries to preserve: those added after browser's baseline.
|
|
173
218
|
const preserved = [];
|
|
@@ -188,7 +233,14 @@ export function syncBrowserDocUpdate(browserDoc, browserVersion) {
|
|
|
188
233
|
}
|
|
189
234
|
// Apply: browser's canonical view + merged overlay.
|
|
190
235
|
state.canonical = browserCanonical;
|
|
191
|
-
|
|
236
|
+
// The browser-derived canonical may still hold a rewrite's NEW text when the
|
|
237
|
+
// browser dropped that node's pendingOriginalContent (stripPendingFromDoc
|
|
238
|
+
// couldn't revert it). Re-assert the authoritative baseline from the merged
|
|
239
|
+
// overlay so the next applyOverlayPure doesn't falsely flag pendingStaleBaseline.
|
|
240
|
+
// adr: adr/pending-overlay-model.md
|
|
241
|
+
const mergedEntries = Array.from(merged.values());
|
|
242
|
+
reconcileCanonicalToBaselines(state.canonical, mergedEntries);
|
|
243
|
+
setOverlayFromEntries(mergedEntries);
|
|
192
244
|
return { preservedServerEntries: preserved.length };
|
|
193
245
|
}
|
|
194
246
|
const listeners = new Set();
|
|
@@ -846,13 +898,14 @@ export function getStatus() {
|
|
|
846
898
|
// SETTERS
|
|
847
899
|
// ============================================================================
|
|
848
900
|
export function updateDocument(doc) {
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
|
|
901
|
+
// Browser-write fidelity: refuse a clobbering body-collapse. A compose
|
|
902
|
+
// surface that mounted empty (wrong view) or parsed the body through a
|
|
903
|
+
// narrower schema can otherwise overwrite a populated body with near-
|
|
904
|
+
// nothing. Checkpoint the good on-disk body first, then refuse.
|
|
905
|
+
// adr: adr/browser-write-fidelity.md
|
|
906
|
+
if (wouldCollapseBody(state.document, doc)) {
|
|
907
|
+
checkpointActiveBody();
|
|
908
|
+
console.error(`[State] REFUSED body-collapse in updateDocument: ${doc?.content?.length ?? 0} nodes would replace ${state.document?.content?.length ?? 0} nodes (checkpointed, write refused)`);
|
|
856
909
|
return;
|
|
857
910
|
}
|
|
858
911
|
// Trust the browser-sent doc as authoritative. The WebSocket handler's
|
|
@@ -2706,6 +2759,17 @@ export function saveDocToFile(filename, doc) {
|
|
|
2706
2759
|
try {
|
|
2707
2760
|
const raw = readFileSync(targetPath, 'utf-8');
|
|
2708
2761
|
const parsed = markdownToTiptap(raw);
|
|
2762
|
+
// Browser-write fidelity: this routes a doc-update to a NON-active file
|
|
2763
|
+
// (the server switched away mid-flight). Refuse a clobbering collapse
|
|
2764
|
+
// against the target's own on-disk body; checkpoint it first.
|
|
2765
|
+
// adr: adr/browser-write-fidelity.md
|
|
2766
|
+
if (wouldCollapseBody(parsed.document, doc)) {
|
|
2767
|
+
const refuseDocId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
|
|
2768
|
+
if (refuseDocId)
|
|
2769
|
+
forceSnapshot(refuseDocId, targetPath);
|
|
2770
|
+
console.error(`[State] REFUSED body-collapse in saveDocToFile(${filename}): ${doc?.content?.length ?? 0} nodes would replace ${parsed.document?.content?.length ?? 0} nodes (checkpointed, write refused)`);
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2709
2773
|
// Transfer pending attrs from on-disk version to the incoming doc
|
|
2710
2774
|
if (hasPendingChanges(parsed.document)) {
|
|
2711
2775
|
transferPendingAttrs(parsed.document, doc);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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.16.
|
|
19
|
+
version: "0.16.4"
|
|
20
20
|
repository: https://github.com/travsteward/openwriter
|
|
21
21
|
license: MIT
|
|
22
22
|
---
|
|
@@ -346,6 +346,8 @@ This eliminates the need for separate `create_workspace`, `create_container`, an
|
|
|
346
346
|
|
|
347
347
|
### Batched Creation (multiple docs at once)
|
|
348
348
|
|
|
349
|
+
**Variants** — when repurposing a doc into another format (a thread off a blog post, a LinkedIn cut of a newsletter), pass `create_document({ masterDocId, variantType })` so the new doc nests under its master in the sidebar instead of floating off as a disconnected doc. Users do the same via right-click → "Create variant".
|
|
350
|
+
|
|
349
351
|
When creating **two or more documents together** — a tweet thread saved as separate docs, a series of blog drafts, newsletter variants, a workspace populated with several files — use `declare_writes` instead of looping `create_document`. It's one tool call, registers all sidebar spinners atomically, and survives app refreshes.
|
|
350
352
|
|
|
351
353
|
```
|