openwriter 0.36.3 → 0.37.1

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.
@@ -10,6 +10,8 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
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
12
  import { snapshotIfNeeded, ensureDocId, forceSnapshot } from './versions.js';
13
+ import { captureAttribution, bindBlameToVersion } from './attribution.js';
14
+ import { scheduleAgentCommit } from './commits.js';
13
15
  import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
14
16
  import { isAutoAcceptInheritedForDoc } from './workspaces.js';
15
17
  import { matchNodes } from './node-matcher.js';
@@ -1087,12 +1089,29 @@ export function resetDocVersion() {
1087
1089
  // adr: adr/pending-overlay-model.md
1088
1090
  let saveTimer = null;
1089
1091
  const SAVE_DEBOUNCE_MS = 500;
1090
- export function debouncedSave() {
1092
+ // The actor whose edits are sitting in the current debounce window. Set at
1093
+ // schedule time (NOT flush time) so attribution can't bleed across actors.
1094
+ // adr: adr/document-history-attribution.md (invariant 3 — no module-global shim;
1095
+ // this is save-scoped: it tracks the SCHEDULER of the pending batch and is
1096
+ // cleared the moment that batch flushes).
1097
+ let pendingSaveActor = null;
1098
+ export function debouncedSave(actor = 'human') {
1099
+ // If a save is already pending under a DIFFERENT actor, flush that batch now
1100
+ // under ITS actor before this actor's edits join the window — otherwise the
1101
+ // single coalesced flush would attribute both actors' new sentences to one.
1102
+ if (saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
1103
+ clearTimeout(saveTimer);
1104
+ saveTimer = null;
1105
+ save(pendingSaveActor);
1106
+ }
1107
+ pendingSaveActor = actor;
1091
1108
  if (saveTimer)
1092
1109
  clearTimeout(saveTimer);
1093
1110
  saveTimer = setTimeout(() => {
1094
1111
  saveTimer = null;
1095
- save();
1112
+ const a = pendingSaveActor ?? 'human';
1113
+ pendingSaveActor = null;
1114
+ save(a);
1096
1115
  }, SAVE_DEBOUNCE_MS);
1097
1116
  }
1098
1117
  /** Cancel any pending debounced save. Call before doc switch (which does its own save). */
@@ -1101,6 +1120,7 @@ export function cancelDebouncedSave() {
1101
1120
  clearTimeout(saveTimer);
1102
1121
  saveTimer = null;
1103
1122
  }
1123
+ pendingSaveActor = null;
1104
1124
  }
1105
1125
  export function applyChanges(changes) {
1106
1126
  // Bump version BEFORE applying so new overlay entries created by
@@ -1120,8 +1140,10 @@ export function applyChanges(changes) {
1120
1140
  for (const listener of listeners) {
1121
1141
  listener(processed, version);
1122
1142
  }
1123
- // Debounced save — coalesces rapid agent writes into a single disk write
1124
- debouncedSave();
1143
+ // Debounced save — coalesces rapid agent writes into a single disk write.
1144
+ // applyChanges is an AGENT door (MCP write tools land here); tag the batch
1145
+ // so the flush attributes its new content to the agent.
1146
+ debouncedSave('agent');
1125
1147
  // Update pending doc cache for the active document
1126
1148
  updatePendingCacheForActiveDoc();
1127
1149
  // Find the last created node ID for chaining inserts
@@ -2129,7 +2151,7 @@ export function getPendingDocInfo() {
2129
2151
  // ============================================================================
2130
2152
  // PERSISTENCE
2131
2153
  // ============================================================================
2132
- function writeToDisk() {
2154
+ function writeToDisk(actor = 'human') {
2133
2155
  // No-op gate: when the in-memory document hasn't been mutated since the
2134
2156
  // last successful write (or byte-equality skip), bail before any work.
2135
2157
  // Skips the full serialize + matcher pipeline (~50ms on medium docs), the
@@ -2270,6 +2292,34 @@ function writeToDisk() {
2270
2292
  // The serializer no longer emits `meta.pending` (overlay handles that).
2271
2293
  const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
2272
2294
  markdown = result.markdown;
2295
+ // Author attribution (Tier A + Tier B). MUST run here — alongside the
2296
+ // overlay save and BEFORE the "content identical" / external-write /
2297
+ // destructive-save guards below. An agent that adds ONLY pending content
2298
+ // leaves the canonical body byte-identical, so those guards short-circuit
2299
+ // the disk write; but the MERGED state (state.document) DID change and was
2300
+ // just persisted to the overlay sidecar — so its attribution must be
2301
+ // captured too. Capture from the MERGED doc (NOT canonical) so an agent's
2302
+ // pending proposals are stamped 'agent' at write time; because blame is
2303
+ // anchored to the sentence content hash, a later human ACCEPT leaves the
2304
+ // hash unchanged and the agent origin holds (accept never launders).
2305
+ // The version binding happens after the body write (a pending-only save
2306
+ // produces no new snapshot, which is correct — it isn't a new version).
2307
+ // adr: adr/document-history-attribution.md
2308
+ if (state.docId) {
2309
+ try {
2310
+ captureAttribution(state.docId, tiptapToBlocks(state.document), actor, Date.now());
2311
+ // Agent-finished commit trigger (debounced, per-doc). Fires from the
2312
+ // CAPTURE site — the only place that reliably sees every agent edit tool
2313
+ // (write_to_pad/populate/edit_text) with the exact target docId. The
2314
+ // spinner broadcast (broadcastWritingFinished) was the wrong hook: not
2315
+ // all write tools fire it. adr: adr/document-history-attribution.md
2316
+ if (actor === 'agent' && state.filePath)
2317
+ scheduleAgentCommit(state.docId, state.filePath);
2318
+ }
2319
+ catch (err) {
2320
+ console.error('[Attribution] capture failed:', err);
2321
+ }
2322
+ }
2273
2323
  }
2274
2324
  if (existsSync(state.filePath)) {
2275
2325
  // Skip write if content is identical (prevents phantom git changes on doc switch)
@@ -2342,11 +2392,22 @@ function writeToDisk() {
2342
2392
  // Record that disk now matches in-memory at this docVersion. Subsequent
2343
2393
  // save() calls without further mutations will bail at the top-level gate.
2344
2394
  lastSavedDocVersion = docVersion;
2345
- // Best-effort version snapshot — never blocks saves
2395
+ // Best-effort version snapshot — never blocks saves. Returns the cut ts
2396
+ // (or null if throttled/unchanged) so attribution can bind to this version.
2397
+ // Attribution itself was already captured in the else branch above (so a
2398
+ // pending-only save, which skips the body write, is still attributed); here
2399
+ // we just stamp the blame with the version cut when a real snapshot landed.
2400
+ let snapshotTs = null;
2346
2401
  try {
2347
- snapshotIfNeeded(state.docId, state.filePath);
2402
+ snapshotTs = snapshotIfNeeded(state.docId, state.filePath);
2348
2403
  }
2349
2404
  catch { /* ignore */ }
2405
+ if (snapshotTs && !isExternalDoc(state.filePath) && state.docId) {
2406
+ try {
2407
+ bindBlameToVersion(state.docId, snapshotTs);
2408
+ }
2409
+ catch { /* best-effort */ }
2410
+ }
2350
2411
  // Auto-sync references from prose: legacy `doc:` prose links still render
2351
2412
  // (PadLink extension), but the graph/crawl/backlinks-panel read the
2352
2413
  // structural `references:` field. After every save, scan the body for
@@ -2370,7 +2431,22 @@ function writeToDisk() {
2370
2431
  invalidateBacklinksCache();
2371
2432
  }
2372
2433
  }
2373
- export function save() {
2434
+ export function save(actor) {
2435
+ // Resolve the save-scoped actor. Explicit arg wins; else fall back to the
2436
+ // actor who scheduled the pending debounce batch this save is flushing; else
2437
+ // 'human' (system/user-initiated saves that don't author prose produce no
2438
+ // attribution spans anyway). adr: adr/document-history-attribution.md
2439
+ // If an explicit actor differs from a pending-batch actor, flush that batch
2440
+ // first under its own actor so this save never absorbs another actor's edits.
2441
+ if (actor && saveTimer && pendingSaveActor && pendingSaveActor !== actor) {
2442
+ clearTimeout(saveTimer);
2443
+ saveTimer = null;
2444
+ const prior = pendingSaveActor;
2445
+ pendingSaveActor = null;
2446
+ writeToDisk(prior);
2447
+ }
2448
+ const resolvedActor = actor ?? pendingSaveActor ?? 'human';
2449
+ pendingSaveActor = null;
2374
2450
  // Auto-title from body content if the title is still default/empty.
2375
2451
  // Runs BEFORE filePath assignment so a brand-new doc lands at its
2376
2452
  // derived-title filename directly (no temp-file detour). For already-
@@ -2406,7 +2482,7 @@ export function save() {
2406
2482
  state.isTemp = false;
2407
2483
  }
2408
2484
  }
2409
- writeToDisk();
2485
+ writeToDisk(resolvedActor);
2410
2486
  }
2411
2487
  export function load() {
2412
2488
  ensureDataDir();
@@ -2809,6 +2885,23 @@ export function saveDocToFile(filename, doc) {
2809
2885
  if (docId) {
2810
2886
  const overlay = extractOverlay(doc);
2811
2887
  saveOverlay(docId, overlay);
2888
+ // This routes a BROWSER doc-update to a non-active file — the content is
2889
+ // the human's. Snapshot + attribute as 'human'. adr: adr/document-history-attribution.md
2890
+ if (!isExternalDoc(targetPath)) {
2891
+ let snapshotTs = null;
2892
+ try {
2893
+ snapshotTs = snapshotIfNeeded(docId, targetPath);
2894
+ }
2895
+ catch { /* ignore */ }
2896
+ try {
2897
+ captureAttribution(docId, tiptapToBlocks(doc), 'human', Date.now());
2898
+ if (snapshotTs)
2899
+ bindBlameToVersion(docId, snapshotTs);
2900
+ }
2901
+ catch (err) {
2902
+ console.error('[Attribution] saveDocToFile capture failed:', err);
2903
+ }
2904
+ }
2812
2905
  }
2813
2906
  // Backlinks cache invalidate — browser sent a doc-update for a non-active
2814
2907
  // doc; the prose-link set on that doc may have changed.
@@ -3041,6 +3134,29 @@ function flushDocToFile(filename, doc, title, metadata) {
3041
3134
  if (docId) {
3042
3135
  const overlay = extractOverlay(doc);
3043
3136
  saveOverlay(docId, overlay);
3137
+ // Door 3: non-active agent writes (populate_document / write_to_pad /
3138
+ // edit_text targeting a non-active doc). This path bypasses writeToDisk
3139
+ // and historically produced NO snapshot — so it had neither a restore
3140
+ // floor nor attribution. Add both. This door is ALWAYS the agent (humans
3141
+ // only edit the active doc in the browser), so actor is 'agent'.
3142
+ // adr: adr/document-history-attribution.md
3143
+ if (!isExternalDoc(targetPath)) {
3144
+ let snapshotTs = null;
3145
+ try {
3146
+ snapshotTs = snapshotIfNeeded(docId, targetPath);
3147
+ }
3148
+ catch { /* ignore */ }
3149
+ try {
3150
+ captureAttribution(docId, tiptapToBlocks(doc), 'agent', Date.now());
3151
+ if (snapshotTs)
3152
+ bindBlameToVersion(docId, snapshotTs);
3153
+ // Door 3 is always an agent write (non-active). Schedule its commit.
3154
+ scheduleAgentCommit(docId, targetPath);
3155
+ }
3156
+ catch (err) {
3157
+ console.error('[Attribution] door-3 capture failed:', err);
3158
+ }
3159
+ }
3044
3160
  }
3045
3161
  setPendingCacheEntry(filename, countPending(doc.content));
3046
3162
  // Backlinks cache invalidation — non-active write paths (populate_document on
@@ -70,30 +70,46 @@ function seedLastSnapshot(docId) {
70
70
  const content = readFileSync(join(dir, `${latest}.md`), 'utf-8');
71
71
  lastSnapshot.set(docId, { time: latest, hash: contentHash(content) });
72
72
  }
73
+ /**
74
+ * Pick a free, strictly-increasing integer timestamp for a new snapshot file.
75
+ * Two snapshots in the same millisecond would collide on `${Date.now()}.md`;
76
+ * bumping (rather than adding a `-N` suffix) keeps the filename a parseable
77
+ * integer so listVersions/seedLastSnapshot's parseInt never yields NaN.
78
+ */
79
+ function freeSnapshotTs(docId) {
80
+ const dir = docDir(docId);
81
+ let now = Date.now();
82
+ while (existsSync(join(dir, `${now}.md`)))
83
+ now++;
84
+ return now;
85
+ }
73
86
  /**
74
87
  * Snapshot after every writeToDisk() — skips if content unchanged or within throttle window.
75
- * Called in best-effort mode (caller wraps in try/catch).
88
+ * Called in best-effort mode (caller wraps in try/catch). Returns the timestamp
89
+ * written (so attribution can bind the blame state to this version cut), or
90
+ * null when the snapshot was skipped.
76
91
  */
77
92
  export function snapshotIfNeeded(docId, filePath) {
78
93
  if (!docId || !filePath || !existsSync(filePath))
79
- return;
94
+ return null;
80
95
  seedLastSnapshot(docId);
81
96
  const markdown = readFileSync(filePath, 'utf-8');
82
97
  const hash = contentHash(markdown);
83
- const now = Date.now();
84
98
  const last = lastSnapshot.get(docId);
85
99
  if (last) {
86
100
  // Skip if content hasn't changed (regardless of time)
87
101
  if (hash === last.hash)
88
- return;
102
+ return null;
89
103
  // Skip if within minimum interval even if content changed
90
- if ((now - last.time) < MIN_INTERVAL_MS)
91
- return;
104
+ if ((Date.now() - last.time) < MIN_INTERVAL_MS)
105
+ return null;
92
106
  }
93
107
  ensureDocDir(docId);
108
+ const now = freeSnapshotTs(docId);
94
109
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
95
110
  lastSnapshot.set(docId, { time: now, hash });
96
111
  pruneVersions(docId);
112
+ return now;
97
113
  }
98
114
  /**
99
115
  * Force a snapshot regardless of dedup. Used before restores as a safety net.
@@ -103,8 +119,8 @@ export function forceSnapshot(docId, filePath) {
103
119
  return;
104
120
  const markdown = readFileSync(filePath, 'utf-8');
105
121
  const hash = contentHash(markdown);
106
- const now = Date.now();
107
122
  ensureDocDir(docId);
123
+ const now = freeSnapshotTs(docId);
108
124
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
109
125
  lastSnapshot.set(docId, { time: now, hash });
110
126
  }
@@ -120,8 +136,15 @@ export function writeSnapshotMarkdown(docId, markdown) {
120
136
  if (!docId)
121
137
  return 0;
122
138
  const hash = contentHash(markdown);
123
- const now = Date.now();
139
+ // Dedup: if the most recent snapshot already holds this exact content, reuse
140
+ // its timestamp instead of writing a duplicate .md. Prevents a commit
141
+ // snapshot from duplicating the auto-snapshot the same save just wrote.
142
+ seedLastSnapshot(docId);
143
+ const last = lastSnapshot.get(docId);
144
+ if (last && last.hash === hash)
145
+ return last.time;
124
146
  ensureDocDir(docId);
147
+ const now = freeSnapshotTs(docId);
125
148
  writeFileSync(join(docDir(docId), `${now}.md`), markdown, 'utf-8');
126
149
  lastSnapshot.set(docId, { time: now, hash });
127
150
  return now;
@@ -4,7 +4,7 @@
4
4
  * Manifests live in ~/.openwriter/_workspaces/*.json.
5
5
  */
6
6
  import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
7
- import { join } from 'path';
7
+ import { join, resolve, isAbsolute, sep } from 'path';
8
8
  import { randomUUID } from 'crypto';
9
9
  import matter from 'gray-matter';
10
10
  import trash from 'trash';
@@ -17,8 +17,44 @@ import { addDocToContainer, addContainer as addContainerToTree, removeNode, move
17
17
  // ============================================================================
18
18
  // INTERNAL HELPERS
19
19
  // ============================================================================
20
+ /**
21
+ * Resolve a workspace manifest filename to an absolute path that is GUARANTEED
22
+ * to live inside the active profile's `_workspaces/` directory.
23
+ *
24
+ * The `filename` originates from MCP tool args (`wsFile`, `filename`), so it is
25
+ * untrusted. Without this guard a value like `../../OtherProfile/_workspaces/x.json`
26
+ * or an absolute path would escape the active profile and read/write/delete
27
+ * another profile's manifests (and, via the doc files they reference, another
28
+ * profile's documents). Profile scoping in OpenWriter is enforced by anchoring
29
+ * every manifest path under `getWorkspacesDir()` (which encodes the active
30
+ * profile) — so containment IS profile scoping. Escaping containment is the
31
+ * only way to cross profiles, and this resolver makes that impossible.
32
+ *
33
+ * Rules: no separators, no `..`, not absolute, no null byte, must end in
34
+ * `.json`, never the reserved `_order.json`. Then a `path.resolve` +
35
+ * prefix-containment assert is the authoritative backstop.
36
+ *
37
+ * adr: (MCP-7 — workspace path traversal + profile scoping)
38
+ */
20
39
  function workspacePath(filename) {
21
- return join(getWorkspacesDir(), filename);
40
+ if (!filename || typeof filename !== 'string') {
41
+ throw new Error('Invalid workspace identifier');
42
+ }
43
+ if (filename.includes('\0') ||
44
+ filename.includes('/') ||
45
+ filename.includes('\\') ||
46
+ filename.includes('..') ||
47
+ isAbsolute(filename) ||
48
+ !filename.endsWith('.json') ||
49
+ filename === '_order.json') {
50
+ throw new Error('Invalid workspace identifier');
51
+ }
52
+ const baseDir = resolve(getWorkspacesDir());
53
+ const resolved = resolve(baseDir, filename);
54
+ if (resolved !== baseDir && !resolved.startsWith(baseDir + sep)) {
55
+ throw new Error('Invalid workspace identifier');
56
+ }
57
+ return resolved;
22
58
  }
23
59
  /**
24
60
  * Migrate workspace-level tags into document frontmatter.
package/dist/server/ws.js CHANGED
@@ -5,6 +5,7 @@ import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { updateDocument, syncBrowserDocUpdate, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, debouncedSave, cancelDebouncedSave, onChanges, onIdRewrites, isAgentLocked, setAgentLockActive, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, onExternalWriteConflict, onDocumentReloaded, onAutoTitleApplied, isAgentStub, unmarkAgentStub, } from './state.js';
6
6
  import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile, listDocuments, acceptPendingTitle, rejectPendingTitle, getPendingTitle } from './documents.js';
7
7
  import { removeDocFromAllWorkspaces } from './workspaces.js';
8
+ import { commitFromFile } from './commits.js';
8
9
  import { canonicalizeIdentifier } from './helpers.js';
9
10
  import { nodeTextPreview, diagLog } from './pending-overlay.js';
10
11
  import { generateRequestId, withRequestId } from './logger.js';
@@ -294,7 +295,7 @@ export function setupWebSocket(server) {
294
295
  const result = syncBrowserDocUpdate(msg.document, browserVersion);
295
296
  diagLog(`[WS] doc-update SYNC-MERGED stale v${browserVersion}→v${serverVersion} preservedServerEntries=${result.preservedServerEntries}`);
296
297
  updatePendingCacheForActiveDoc();
297
- debouncedSave();
298
+ debouncedSave('human');
298
299
  }
299
300
  else if (browserFilename && browserFilename !== getActiveFilename()) {
300
301
  // Browser sent a doc-update for a different document (race: server switched away).
@@ -321,7 +322,7 @@ export function setupWebSocket(server) {
321
322
  diagLog(`[WS] doc-update ACCEPTED (browser: ${nodeCount} nodes, cleaned: ${cleanedCount}, server: ${currentNodeCount} nodes)`);
322
323
  updateDocument(msg.document);
323
324
  updatePendingCacheForActiveDoc(); // Keep cache in sync after browser edits/reject-all
324
- debouncedSave();
325
+ debouncedSave('human');
325
326
  }
326
327
  }
327
328
  // Browser requests fresh state on reconnect (instead of pushing stale state)
@@ -351,7 +352,7 @@ export function setupWebSocket(server) {
351
352
  broadcastDocumentsChanged();
352
353
  }
353
354
  else {
354
- debouncedSave();
355
+ debouncedSave('human');
355
356
  debouncedBroadcastDocumentsChanged();
356
357
  }
357
358
  }
@@ -512,6 +513,19 @@ export function setupWebSocket(server) {
512
513
  stripPendingAttrs();
513
514
  save();
514
515
  updatePendingCacheForActiveDoc(); // Sync cache after strip (prevents stale "has changes" indicator)
516
+ // Commit trigger: accepting agent changes is a version boundary.
517
+ // Bundles the agent's attributed edit-events into a commit (the
518
+ // changeset actors reflect who authored; trigger records the accept).
519
+ // adr: adr/document-history-attribution.md
520
+ if (action === 'accept') {
521
+ try {
522
+ const did = getDocId();
523
+ const fp = getFilePath();
524
+ if (did && fp)
525
+ commitFromFile(did, fp, { trigger: 'accept', actor: 'human', nowTs: Date.now() });
526
+ }
527
+ catch { /* best-effort */ }
528
+ }
515
529
  }
516
530
  else {
517
531
  // Race path: resolved doc is NOT the active one (server switched away).
@@ -691,6 +705,11 @@ activityDocId) {
691
705
  });
692
706
  return writeKey;
693
707
  }
708
+ // NOTE: the agent-finished commit trigger lives at the CAPTURE site
709
+ // (state.ts writeToDisk / flushDocToFile via scheduleAgentCommit), NOT here.
710
+ // broadcastWritingFinished is the spinner mechanism and is not fired by every
711
+ // agent edit tool (write_to_pad never calls it), so it was the wrong hook.
712
+ // adr: adr/document-history-attribution.md
694
713
  // key omitted → clear all (legacy single-write flows). Pass a key for multi-doc.
695
714
  export function broadcastWritingFinished(key) {
696
715
  if (key) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.36.3",
3
+ "version": "0.37.1",
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",