openwriter 0.28.0 → 0.28.2

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.
@@ -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));
@@ -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
- setOverlayFromEntries(Array.from(merged.values()));
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
- // Safety: reject dramatically smaller documents (same logic as destructive save check).
850
- // Prevents stale browser tabs from overwriting the correct in-memory state with
851
- // corrupted content (e.g. tweet compose view sending 4-node doc vs 40-node original).
852
- const currentNodes = state.document?.content?.length ?? 0;
853
- const incomingNodes = doc?.content?.length ?? 0;
854
- if (currentNodes > 5 && incomingNodes < currentNodes * 0.3) {
855
- console.error(`[State] BLOCKED destructive updateDocument: ${incomingNodes} nodes would replace ${currentNodes} nodes`);
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.28.0",
3
+ "version": "0.28.2",
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",