openwriter 0.14.0 → 0.15.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-B3iORmCT.css +1 -0
- package/dist/client/assets/index-B5MXw2pg.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +60 -18
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-serialize.js +122 -25
- package/dist/server/mcp.js +289 -77
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-matcher.js +57 -5
- package/dist/server/pending-overlay.js +845 -0
- package/dist/server/state.js +981 -78
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +15 -0
- package/dist/server/ws.js +184 -37
- package/package.json +1 -1
- package/skill/SKILL.md +30 -19
- package/dist/client/assets/index-BxI3DazW.js +0 -212
- package/dist/client/assets/index-OV13QtgQ.css +0 -1
package/dist/server/state.js
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
* Each document is a .md file in ~/.openwriter/ with YAML frontmatter.
|
|
4
4
|
* Title lives in frontmatter metadata. Filenames are stable identifiers.
|
|
5
5
|
*/
|
|
6
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync } from 'fs';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync, utimesSync, watch } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import matter from 'gray-matter';
|
|
9
9
|
import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTiptap } from './markdown.js';
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
|
-
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
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 } from './versions.js';
|
|
13
13
|
import { extractForwardLinks, extractForwardLinksFromDisk, updateBacklinksForSource } 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
|
+
import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
17
18
|
/** Read the persisted identity graph (nodes + graveyard) from a file's
|
|
18
19
|
* frontmatter. This is the matcher's previousNodes baseline at save time —
|
|
19
20
|
* the disk is the source of truth, not a parallel in-memory cache. Returns
|
|
@@ -48,6 +49,8 @@ const DEFAULT_DOC = {
|
|
|
48
49
|
content: [{ type: 'paragraph', content: [] }],
|
|
49
50
|
};
|
|
50
51
|
let state = {
|
|
52
|
+
canonical: DEFAULT_DOC,
|
|
53
|
+
overlay: new Map(),
|
|
51
54
|
document: DEFAULT_DOC,
|
|
52
55
|
title: 'Untitled',
|
|
53
56
|
metadata: { title: 'Untitled' },
|
|
@@ -56,37 +59,396 @@ let state = {
|
|
|
56
59
|
lastModified: new Date(),
|
|
57
60
|
docId: '',
|
|
58
61
|
originalFrontmatter: null,
|
|
62
|
+
loadedMtime: 0,
|
|
59
63
|
};
|
|
64
|
+
// ============================================================================
|
|
65
|
+
// PRIMARY-STATE WRITE HELPERS
|
|
66
|
+
// ============================================================================
|
|
67
|
+
// All mutations to canonical or overlay go through these helpers. Each updates
|
|
68
|
+
// the underlying state AND recomputes state.document so external readers via
|
|
69
|
+
// getDocument() see the new merged view. Direct assignment to state.canonical,
|
|
70
|
+
// state.overlay, or state.document is forbidden outside this module.
|
|
71
|
+
// adr: adr/pending-overlay-model.md
|
|
72
|
+
/** Recompute the merged view from primary state. Idempotent — running twice
|
|
73
|
+
* produces the same result. */
|
|
74
|
+
function recomputeMerged() {
|
|
75
|
+
state.document = applyOverlayPure(state.canonical, Array.from(state.overlay.values()));
|
|
76
|
+
}
|
|
77
|
+
/** Replace canonical wholesale. Used by load paths and accept-all flows. */
|
|
78
|
+
function setCanonical(doc) {
|
|
79
|
+
state.canonical = doc;
|
|
80
|
+
recomputeMerged();
|
|
81
|
+
}
|
|
82
|
+
/** Replace overlay wholesale from an entry array. Dedupes by nodeId
|
|
83
|
+
* (first occurrence wins; preserves original anchors). Preserves
|
|
84
|
+
* addedAtVersion from any pre-existing state.overlay entry with the
|
|
85
|
+
* same nodeId; stamps current docVersion on entries that are new. */
|
|
86
|
+
function setOverlayFromEntries(entries) {
|
|
87
|
+
const currentVersion = getDocVersion();
|
|
88
|
+
const newOverlay = new Map();
|
|
89
|
+
for (const e of entries) {
|
|
90
|
+
if (newOverlay.has(e.nodeId))
|
|
91
|
+
continue;
|
|
92
|
+
const existing = state.overlay.get(e.nodeId);
|
|
93
|
+
const entry = { ...e };
|
|
94
|
+
if (existing?.addedAtVersion !== undefined) {
|
|
95
|
+
entry.addedAtVersion = existing.addedAtVersion;
|
|
96
|
+
}
|
|
97
|
+
else if (entry.addedAtVersion === undefined) {
|
|
98
|
+
entry.addedAtVersion = currentVersion;
|
|
99
|
+
}
|
|
100
|
+
newOverlay.set(e.nodeId, entry);
|
|
101
|
+
}
|
|
102
|
+
state.overlay = newOverlay;
|
|
103
|
+
recomputeMerged();
|
|
104
|
+
}
|
|
105
|
+
/** Replace primary state from a merged-shape doc. Splits via splitMergedDoc.
|
|
106
|
+
* Used by paths that receive a merged doc (browser doc-updates, legacy
|
|
107
|
+
* on-disk migration). */
|
|
108
|
+
function setPrimaryFromMerged(merged) {
|
|
109
|
+
const { canonical, overlayEntries } = splitMergedDoc(merged);
|
|
110
|
+
state.canonical = canonical;
|
|
111
|
+
setOverlayFromEntries(overlayEntries);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Sync routing for a stale-version browser doc-update. The browser's
|
|
115
|
+
* submission was captured at server version `browserVersion`; the server
|
|
116
|
+
* has since advanced to a higher version because the agent wrote concurrently.
|
|
117
|
+
*
|
|
118
|
+
* Behavior: the browser's view of canonical is accepted as authoritative.
|
|
119
|
+
* The overlay merges browser's view with server's recent additions: any
|
|
120
|
+
* server overlay entry with addedAtVersion > browserVersion is an agent
|
|
121
|
+
* addition the browser hadn't seen, so it survives. Conflicting nodeIds
|
|
122
|
+
* (both browser and server have entries) → server wins, on the principle
|
|
123
|
+
* that an explicit agent proposal outranks a browser save that didn't see
|
|
124
|
+
* it. The user can reject the server's entry through the normal review UI
|
|
125
|
+
* if they disagree.
|
|
126
|
+
*
|
|
127
|
+
* Replaces the older "BLOCK stale doc-update" behavior — that pattern lost
|
|
128
|
+
* the user's typing without warning. The merge approach preserves both
|
|
129
|
+
* sides' work in the common case (disjoint touches) and surfaces conflicts
|
|
130
|
+
* (same-paragraph touches) via the existing pending-review UI.
|
|
131
|
+
*
|
|
132
|
+
* Returns the count of server overlay entries that were preserved.
|
|
133
|
+
* adr: adr/pending-overlay-model.md
|
|
134
|
+
*/
|
|
135
|
+
export function syncBrowserDocUpdate(browserDoc, browserVersion) {
|
|
136
|
+
const { canonical: browserCanonical, overlayEntries: browserOverlay } = splitMergedDoc(browserDoc);
|
|
137
|
+
// Identify server overlay entries to preserve: those added after browser's baseline.
|
|
138
|
+
const preserved = [];
|
|
139
|
+
for (const [, entry] of state.overlay) {
|
|
140
|
+
const added = entry.addedAtVersion ?? 0;
|
|
141
|
+
if (added > browserVersion)
|
|
142
|
+
preserved.push(entry);
|
|
143
|
+
}
|
|
144
|
+
// Build the merged overlay. Browser's view first; server-preserved entries
|
|
145
|
+
// overwrite (server wins on conflict).
|
|
146
|
+
const merged = new Map();
|
|
147
|
+
for (const e of browserOverlay) {
|
|
148
|
+
if (!merged.has(e.nodeId))
|
|
149
|
+
merged.set(e.nodeId, e);
|
|
150
|
+
}
|
|
151
|
+
for (const e of preserved) {
|
|
152
|
+
merged.set(e.nodeId, e);
|
|
153
|
+
}
|
|
154
|
+
// Apply: browser's canonical view + merged overlay.
|
|
155
|
+
state.canonical = browserCanonical;
|
|
156
|
+
setOverlayFromEntries(Array.from(merged.values()));
|
|
157
|
+
return { preservedServerEntries: preserved.length };
|
|
158
|
+
}
|
|
60
159
|
const listeners = new Set();
|
|
160
|
+
const idRewriteListeners = new Set();
|
|
161
|
+
const externalWriteConflictListeners = new Set();
|
|
162
|
+
export function onExternalWriteConflict(listener) {
|
|
163
|
+
externalWriteConflictListeners.add(listener);
|
|
164
|
+
return () => externalWriteConflictListeners.delete(listener);
|
|
165
|
+
}
|
|
166
|
+
function notifyExternalWriteConflict(filePath, diskMtime, loadedMtime) {
|
|
167
|
+
for (const listener of externalWriteConflictListeners) {
|
|
168
|
+
try {
|
|
169
|
+
listener({ filePath, diskMtime, loadedMtime });
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
console.error('[State] external-write listener threw:', err);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Check whether the active doc's on-disk mtime is newer than what we
|
|
178
|
+
* loaded/saved. Returns null when the doc has no file path, the file is
|
|
179
|
+
* gone, or there's no drift. Exposed for the get_pad_status MCP tool so
|
|
180
|
+
* agents can detect "you need to reload_from_disk before your next save"
|
|
181
|
+
* without waiting for the save itself to fail.
|
|
182
|
+
*/
|
|
183
|
+
export function getExternalMtimeDrift() {
|
|
184
|
+
if (!state.filePath || state.loadedMtime === 0)
|
|
185
|
+
return null;
|
|
186
|
+
try {
|
|
187
|
+
const diskMtime = statSync(state.filePath).mtimeMs;
|
|
188
|
+
if (diskMtime !== state.loadedMtime) {
|
|
189
|
+
return { diskMtime, loadedMtime: state.loadedMtime };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch { /* file missing */ }
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
/** Force-refresh the active doc's loadedMtime snapshot to current disk mtime.
|
|
196
|
+
* Used by reload_from_disk after re-reading the file, so the freshly
|
|
197
|
+
* adopted content's mtime becomes our new baseline. */
|
|
198
|
+
export function refreshLoadedMtime() {
|
|
199
|
+
if (!state.filePath)
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
203
|
+
}
|
|
204
|
+
catch { /* best-effort */ }
|
|
205
|
+
}
|
|
206
|
+
// ============================================================================
|
|
207
|
+
// ACTIVE DOC FILE WATCHER
|
|
208
|
+
// ============================================================================
|
|
209
|
+
//
|
|
210
|
+
// Watches the currently-active doc's file for external writes. When any
|
|
211
|
+
// other process (Edit tool, VSCode, a script) mutates the file, fs.watch
|
|
212
|
+
// fires; we debounce burst events, verify the disk mtime actually advanced
|
|
213
|
+
// past our last-stamped `loadedMtime`, and route through the unified
|
|
214
|
+
// reloadActiveDocFromDisk pathway — which re-parses disk, re-attaches the
|
|
215
|
+
// pending overlay by nodeId, runs the matcher, and lets subscribers
|
|
216
|
+
// broadcast a document-reloaded message to clients.
|
|
217
|
+
//
|
|
218
|
+
// Why push (fs.watch) instead of pull (mtime poll on writes only):
|
|
219
|
+
// - The browser autosave race was: external editor writes → server's
|
|
220
|
+
// in-memory state is now stale → browser sends an autosave from BEFORE
|
|
221
|
+
// the external write → server's version counter hasn't advanced (no
|
|
222
|
+
// MCP write occurred) → version check passes → stale content clobbers
|
|
223
|
+
// the external write.
|
|
224
|
+
// - The fix is to advance docVersion the instant the file changes on
|
|
225
|
+
// disk, not the instant we try to save. fs.watch makes that immediate.
|
|
226
|
+
//
|
|
227
|
+
// Single watcher at a time: we only care about the active doc. Switching
|
|
228
|
+
// docs tears down the previous watcher and opens a new one.
|
|
229
|
+
//
|
|
230
|
+
// Cross-platform: Node's fs.watch is inotify on Linux, FSEvents on macOS,
|
|
231
|
+
// ReadDirectoryChangesW on Windows. Single-file watching is reliable on
|
|
232
|
+
// all three; chokidar would add value for recursive directory trees, not
|
|
233
|
+
// here.
|
|
234
|
+
//
|
|
235
|
+
// adr: adr/active-doc-watcher.md
|
|
236
|
+
let activeWatcher = null;
|
|
237
|
+
let activeWatcherPath = '';
|
|
238
|
+
let watcherDebounceTimer = null;
|
|
239
|
+
const documentReloadedListeners = new Set();
|
|
240
|
+
export function onDocumentReloaded(listener) {
|
|
241
|
+
documentReloadedListeners.add(listener);
|
|
242
|
+
return () => documentReloadedListeners.delete(listener);
|
|
243
|
+
}
|
|
244
|
+
function notifyDocumentReloaded(event) {
|
|
245
|
+
for (const listener of documentReloadedListeners) {
|
|
246
|
+
try {
|
|
247
|
+
listener(event);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
console.error('[State] document-reloaded listener threw:', err);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** Tear down the active-doc watcher (if any) and clear bookkeeping. */
|
|
255
|
+
function stopActiveDocWatcher() {
|
|
256
|
+
if (watcherDebounceTimer) {
|
|
257
|
+
clearTimeout(watcherDebounceTimer);
|
|
258
|
+
watcherDebounceTimer = null;
|
|
259
|
+
}
|
|
260
|
+
if (activeWatcher) {
|
|
261
|
+
try {
|
|
262
|
+
activeWatcher.close();
|
|
263
|
+
}
|
|
264
|
+
catch { /* best-effort */ }
|
|
265
|
+
activeWatcher = null;
|
|
266
|
+
}
|
|
267
|
+
activeWatcherPath = '';
|
|
268
|
+
}
|
|
269
|
+
/** Handle a watcher event after debounce — reload if mtime actually advanced. */
|
|
270
|
+
function handleWatcherEvent() {
|
|
271
|
+
// Only act if the watched path is still the active doc. If the user
|
|
272
|
+
// switched away during the debounce window, drop this event — the new
|
|
273
|
+
// active doc has its own watcher already running.
|
|
274
|
+
if (!state.filePath || state.filePath !== activeWatcherPath)
|
|
275
|
+
return;
|
|
276
|
+
// Skip if the file is gone (e.g., delete during watch). The next save
|
|
277
|
+
// will re-create it from in-memory state.
|
|
278
|
+
if (!existsSync(state.filePath))
|
|
279
|
+
return;
|
|
280
|
+
let diskMtime;
|
|
281
|
+
try {
|
|
282
|
+
diskMtime = statSync(state.filePath).mtimeMs;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Filter out events from our own writes. writeToDisk re-stamps
|
|
288
|
+
// state.loadedMtime to the post-write disk mtime, so by the time this
|
|
289
|
+
// handler fires for our own atomicWriteFileSync, mtimes match and we
|
|
290
|
+
// skip. Only genuine external writes have diskMtime > loadedMtime.
|
|
291
|
+
if (diskMtime === state.loadedMtime)
|
|
292
|
+
return;
|
|
293
|
+
const reloaded = reloadActiveDocFromDisk();
|
|
294
|
+
if (!reloaded)
|
|
295
|
+
return;
|
|
296
|
+
// Bump version so any in-flight stale browser autosave is rejected by
|
|
297
|
+
// the existing version check in the WS handler. Without this bump, the
|
|
298
|
+
// browser's pre-external-write state would silently overwrite the
|
|
299
|
+
// freshly-loaded disk content.
|
|
300
|
+
bumpDocVersion();
|
|
301
|
+
notifyDocumentReloaded({
|
|
302
|
+
filePath: state.filePath,
|
|
303
|
+
filename: reloaded.filename,
|
|
304
|
+
document: reloaded.document,
|
|
305
|
+
title: reloaded.title,
|
|
306
|
+
docId: state.docId,
|
|
307
|
+
metadata: state.metadata,
|
|
308
|
+
orphans: reloaded.orphans,
|
|
309
|
+
staleBaseline: reloaded.staleBaseline,
|
|
310
|
+
});
|
|
311
|
+
const orphanCount = reloaded.orphans.length;
|
|
312
|
+
const staleCount = reloaded.staleBaseline.length;
|
|
313
|
+
const suffix = orphanCount || staleCount
|
|
314
|
+
? ` (${orphanCount} orphan, ${staleCount} stale-baseline pending entries)`
|
|
315
|
+
: '';
|
|
316
|
+
console.log(`[State] reload active doc from external write: ${reloaded.filename}${suffix}`);
|
|
317
|
+
}
|
|
318
|
+
/** Start (or restart) the watcher on the active doc's filePath. Called
|
|
319
|
+
* whenever the active doc changes, or whenever load() lands a file. */
|
|
320
|
+
export function startActiveDocWatcher() {
|
|
321
|
+
stopActiveDocWatcher();
|
|
322
|
+
if (!state.filePath || !existsSync(state.filePath))
|
|
323
|
+
return;
|
|
324
|
+
// Some test environments (Windows CI, ephemeral filesystems) error from
|
|
325
|
+
// fs.watch on transient files. Swallow the failure — we'll simply not
|
|
326
|
+
// have external-write detection for this doc, which is degraded but
|
|
327
|
+
// not broken. writeToDisk still has the loadedMtime guard as a backstop.
|
|
328
|
+
try {
|
|
329
|
+
activeWatcherPath = state.filePath;
|
|
330
|
+
activeWatcher = watch(state.filePath, { persistent: false }, () => {
|
|
331
|
+
// Burst-debounce: editors often write through a temp file + rename,
|
|
332
|
+
// which fires multiple events in rapid succession. 80ms is short
|
|
333
|
+
// enough that human-perceptible latency is unchanged and long
|
|
334
|
+
// enough that one logical save coalesces.
|
|
335
|
+
if (watcherDebounceTimer)
|
|
336
|
+
clearTimeout(watcherDebounceTimer);
|
|
337
|
+
watcherDebounceTimer = setTimeout(handleWatcherEvent, 80);
|
|
338
|
+
});
|
|
339
|
+
activeWatcher.on('error', (err) => {
|
|
340
|
+
console.error(`[State] active doc watcher error on ${activeWatcherPath}:`, err.message);
|
|
341
|
+
stopActiveDocWatcher();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
console.error(`[State] failed to watch ${state.filePath}:`, err?.message || err);
|
|
346
|
+
stopActiveDocWatcher();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
61
349
|
// ============================================================================
|
|
62
350
|
// EXTERNAL DOCUMENT REGISTRY
|
|
63
351
|
// ============================================================================
|
|
64
352
|
function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
|
|
353
|
+
/** External docs registered with openwriter — canonicalized paths only.
|
|
354
|
+
* Adding via `registerExternalDoc` runs paths through `canonicalizePath`
|
|
355
|
+
* before insertion, so the same physical file via any spelling
|
|
356
|
+
* (forward/back slash, mixed case drive letter, symlink) collapses to a
|
|
357
|
+
* single entry.
|
|
358
|
+
* adr: adr/path-canonicalization.md */
|
|
65
359
|
const externalDocs = new Set();
|
|
360
|
+
// ============================================================================
|
|
361
|
+
// AGENT-STUB REGISTRY (in-memory only — never persisted)
|
|
362
|
+
// ============================================================================
|
|
363
|
+
//
|
|
364
|
+
// A doc is an "agent stub" between create_document (which mints an empty
|
|
365
|
+
// shell) and populate_document + accept (which fills it with content). If
|
|
366
|
+
// the user rejects the populated content, the stub is deleted — the agent
|
|
367
|
+
// proposed a doc, the user declined, nothing should remain.
|
|
368
|
+
//
|
|
369
|
+
// HISTORICAL MISTAKE: stub status was stored as `agentCreated: true` in
|
|
370
|
+
// frontmatter on disk. That made it sticky across sessions, server
|
|
371
|
+
// restarts, and arbitrary file lifetimes. Any reject-all on a doc whose
|
|
372
|
+
// stub status had been forgotten in a previous flow would destructively
|
|
373
|
+
// delete a file with hours of accepted work. The fix is not "guard the
|
|
374
|
+
// destruction more carefully"; the fix is "don't persist transient
|
|
375
|
+
// session state to disk in the first place."
|
|
376
|
+
//
|
|
377
|
+
// Stub status is now an in-memory Set<filename> with process lifetime.
|
|
378
|
+
// - markAsAgentStub(filename) — called on create_document
|
|
379
|
+
// - unmarkAgentStub(filename) — called on any save that contains
|
|
380
|
+
// non-pending content (graduation), on accept-all, on rename
|
|
381
|
+
// - isAgentStub(filename) — the only thing reject-all-deletes consults
|
|
382
|
+
//
|
|
383
|
+
// A stub that survives a server restart is by definition no longer fresh.
|
|
384
|
+
// It loads from disk like any other doc and reject-all will not delete it.
|
|
385
|
+
// This is intentional: graduating-by-lifetime is the safest fallback.
|
|
386
|
+
//
|
|
387
|
+
// adr: adr/agent-stub-model.md
|
|
388
|
+
const agentStubFilenames = new Set();
|
|
389
|
+
export function markAsAgentStub(filename) {
|
|
390
|
+
if (filename)
|
|
391
|
+
agentStubFilenames.add(filename);
|
|
392
|
+
}
|
|
393
|
+
export function unmarkAgentStub(filename) {
|
|
394
|
+
if (filename)
|
|
395
|
+
agentStubFilenames.delete(filename);
|
|
396
|
+
}
|
|
397
|
+
export function isAgentStub(filename) {
|
|
398
|
+
return !!filename && agentStubFilenames.has(filename);
|
|
399
|
+
}
|
|
400
|
+
/** Legacy migration. If a file's frontmatter has `agentCreated: true`, that
|
|
401
|
+
* field came from the pre-architectural-fix code path. Strip it on load so
|
|
402
|
+
* we don't keep round-tripping a dead field. No in-memory stub registration
|
|
403
|
+
* happens — by definition, if the flag survived to disk this long, the doc
|
|
404
|
+
* is not a fresh stub anymore. */
|
|
405
|
+
function stripLegacyAgentCreated(metadata) {
|
|
406
|
+
if (metadata && 'agentCreated' in metadata)
|
|
407
|
+
delete metadata.agentCreated;
|
|
408
|
+
}
|
|
66
409
|
function persistExternalDocs() {
|
|
67
410
|
try {
|
|
68
411
|
atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
|
|
69
412
|
}
|
|
70
413
|
catch { /* best-effort */ }
|
|
71
414
|
}
|
|
415
|
+
/** Load external-doc registry from disk and canonicalize every entry on
|
|
416
|
+
* the way in. The Set's branded type collapses any pre-existing
|
|
417
|
+
* duplicates (forward-slash vs backslash entries for the same file)
|
|
418
|
+
* to one — that's the one-time migration. If canonicalization changed
|
|
419
|
+
* the on-disk representation, we re-persist so the file matches
|
|
420
|
+
* in-memory state.
|
|
421
|
+
* adr: adr/path-canonicalization.md */
|
|
72
422
|
function loadExternalDocs() {
|
|
73
423
|
try {
|
|
74
|
-
if (existsSync(getExternalDocsFile()))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
424
|
+
if (!existsSync(getExternalDocsFile()))
|
|
425
|
+
return;
|
|
426
|
+
const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
|
|
427
|
+
let needsRewrite = false;
|
|
428
|
+
for (const p of paths) {
|
|
429
|
+
if (!existsSync(p)) {
|
|
430
|
+
needsRewrite = true;
|
|
431
|
+
continue;
|
|
79
432
|
}
|
|
433
|
+
const canon = canonicalizePath(p);
|
|
434
|
+
if (canon !== p)
|
|
435
|
+
needsRewrite = true;
|
|
436
|
+
externalDocs.add(canon);
|
|
437
|
+
}
|
|
438
|
+
// If the on-disk file had duplicates or non-canonical entries, the
|
|
439
|
+
// collapsed Set is now smaller and/or different — persist it back.
|
|
440
|
+
if (needsRewrite || externalDocs.size !== paths.length) {
|
|
441
|
+
persistExternalDocs();
|
|
80
442
|
}
|
|
81
443
|
}
|
|
82
444
|
catch { /* corrupt file — start fresh */ }
|
|
83
445
|
}
|
|
84
446
|
export function registerExternalDoc(fullPath) {
|
|
85
|
-
externalDocs.add(fullPath);
|
|
447
|
+
externalDocs.add(canonicalizePath(fullPath));
|
|
86
448
|
persistExternalDocs();
|
|
87
449
|
}
|
|
88
450
|
export function unregisterExternalDoc(fullPath) {
|
|
89
|
-
externalDocs.delete(fullPath);
|
|
451
|
+
externalDocs.delete(canonicalizePath(fullPath));
|
|
90
452
|
persistExternalDocs();
|
|
91
453
|
}
|
|
92
454
|
export function getExternalDocs() {
|
|
@@ -110,6 +472,20 @@ function isDocEmpty(doc) {
|
|
|
110
472
|
export function getDocument() {
|
|
111
473
|
return state.document;
|
|
112
474
|
}
|
|
475
|
+
/** The clean canonical document — what disk holds, what the matcher pairs
|
|
476
|
+
* against, what snapshots capture. Primary state; not derived. */
|
|
477
|
+
export function getCanonical() {
|
|
478
|
+
return state.canonical;
|
|
479
|
+
}
|
|
480
|
+
/** Snapshot of the structured pending overlay. Primary state. */
|
|
481
|
+
export function getOverlayEntries() {
|
|
482
|
+
return Array.from(state.overlay.values());
|
|
483
|
+
}
|
|
484
|
+
/** Read-only view of the overlay Map. Use when you need keyed lookup
|
|
485
|
+
* rather than iteration. */
|
|
486
|
+
export function getOverlay() {
|
|
487
|
+
return state.overlay;
|
|
488
|
+
}
|
|
113
489
|
export function getTitle() {
|
|
114
490
|
return state.title;
|
|
115
491
|
}
|
|
@@ -409,7 +785,10 @@ export function updateDocument(doc) {
|
|
|
409
785
|
if (serverHadPending) {
|
|
410
786
|
transferPendingAttrs(state.document, doc);
|
|
411
787
|
}
|
|
412
|
-
|
|
788
|
+
// Route the incoming merged-shape doc through the primary-state setter
|
|
789
|
+
// so canonical + overlay get refreshed and state.document is the
|
|
790
|
+
// recomputed merged view. Direct assignment is forbidden.
|
|
791
|
+
setPrimaryFromMerged(doc);
|
|
413
792
|
state.lastModified = new Date();
|
|
414
793
|
// Validate: if server had pending changes, verify they survived the transfer
|
|
415
794
|
if (serverHadPending && !hasPendingChanges()) {
|
|
@@ -479,15 +858,58 @@ function transferPendingAttrs(source, target) {
|
|
|
479
858
|
// ============================================================================
|
|
480
859
|
// AGENT WRITE LOCK
|
|
481
860
|
// ============================================================================
|
|
861
|
+
// adr: adr/agent-lock-per-doc.md
|
|
482
862
|
const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
863
|
+
const lockExpiry = new Map();
|
|
864
|
+
let globalLockExpiry = 0;
|
|
865
|
+
/** Derive the identifier used for locking the active doc. Mirrors
|
|
866
|
+
* documents.ts:getActiveFilename without importing it (circular dep). */
|
|
867
|
+
function activeDocLockKey() {
|
|
868
|
+
const fp = state.filePath;
|
|
869
|
+
if (!fp)
|
|
870
|
+
return '';
|
|
871
|
+
if (isExternalDoc(fp))
|
|
872
|
+
return canonicalizeIdentifier(fp);
|
|
873
|
+
return fp.split(/[/\\]/).pop() || '';
|
|
874
|
+
}
|
|
875
|
+
/** Set the agent write lock for a specific document. */
|
|
876
|
+
export function setAgentLock(filename) {
|
|
877
|
+
if (!filename) {
|
|
878
|
+
// Defensive: empty filename means we don't know what to lock — lock global.
|
|
879
|
+
setAgentLockGlobal();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const key = canonicalizeIdentifier(filename);
|
|
883
|
+
const wasActive = (lockExpiry.get(key) ?? 0) > Date.now();
|
|
884
|
+
lockExpiry.set(key, Date.now() + AGENT_LOCK_MS);
|
|
885
|
+
diagLog(`[Lock] SET filename=${key} ttl=${AGENT_LOCK_MS}ms${wasActive ? ' (extends active lock)' : ''}`);
|
|
886
|
+
}
|
|
887
|
+
/** Lock the currently active document. Convenience for callers that mutate
|
|
888
|
+
* via state.filePath rather than an explicit filename. */
|
|
889
|
+
export function setAgentLockActive() {
|
|
890
|
+
setAgentLock(activeDocLockKey());
|
|
891
|
+
}
|
|
892
|
+
/** Lock every document briefly. Used at server init so reconnecting browsers
|
|
893
|
+
* can't push stale state from before the restart. */
|
|
894
|
+
export function setAgentLockGlobal() {
|
|
895
|
+
globalLockExpiry = Date.now() + AGENT_LOCK_MS;
|
|
896
|
+
diagLog(`[Lock] SET global ttl=${AGENT_LOCK_MS}ms`);
|
|
897
|
+
}
|
|
898
|
+
/** Check if the agent write lock is active for a given document. */
|
|
899
|
+
export function isAgentLocked(filename) {
|
|
900
|
+
const now = Date.now();
|
|
901
|
+
if (globalLockExpiry > now)
|
|
902
|
+
return true;
|
|
903
|
+
if (!filename)
|
|
904
|
+
return false;
|
|
905
|
+
const key = canonicalizeIdentifier(filename);
|
|
906
|
+
const expiry = lockExpiry.get(key);
|
|
907
|
+
if (expiry === undefined)
|
|
908
|
+
return false;
|
|
909
|
+
if (expiry > now)
|
|
910
|
+
return true;
|
|
911
|
+
lockExpiry.delete(key);
|
|
912
|
+
return false;
|
|
491
913
|
}
|
|
492
914
|
// ---- Document version counter: prevents stale browser doc-updates ----
|
|
493
915
|
let docVersion = 0;
|
|
@@ -508,9 +930,16 @@ export function resetDocVersion() {
|
|
|
508
930
|
docVersion = 0;
|
|
509
931
|
}
|
|
510
932
|
// ---- Debounced save: coalesces rapid agent writes into a single disk write ----
|
|
933
|
+
//
|
|
934
|
+
// Single timer for the entire process. Both state.ts (MCP write paths, applyChanges,
|
|
935
|
+
// updateDocument) and ws.ts (browser doc-update, pending-resolved) call into this.
|
|
936
|
+
// Previously each module had its own timer (state.ts 500ms, ws.ts 2s) which meant
|
|
937
|
+
// a save could be armed by one path, reset by another, and fire on a delay that
|
|
938
|
+
// matched neither documented value. One timer, one TTL — predictable.
|
|
939
|
+
// adr: adr/pending-overlay-model.md
|
|
511
940
|
let saveTimer = null;
|
|
512
941
|
const SAVE_DEBOUNCE_MS = 500;
|
|
513
|
-
function debouncedSave() {
|
|
942
|
+
export function debouncedSave() {
|
|
514
943
|
if (saveTimer)
|
|
515
944
|
clearTimeout(saveTimer);
|
|
516
945
|
saveTimer = setTimeout(() => {
|
|
@@ -530,7 +959,7 @@ export function applyChanges(changes) {
|
|
|
530
959
|
const processed = applyChangesToDocument(changes);
|
|
531
960
|
// Bump version + lock browser doc-updates to prevent stale state overwrite
|
|
532
961
|
const version = bumpDocVersion();
|
|
533
|
-
|
|
962
|
+
setAgentLockActive();
|
|
534
963
|
// Broadcast processed changes (with server-assigned IDs + version) to browser clients
|
|
535
964
|
for (const listener of listeners) {
|
|
536
965
|
listener(processed, version);
|
|
@@ -554,6 +983,10 @@ export function applyChanges(changes) {
|
|
|
554
983
|
}
|
|
555
984
|
return { count: processed.length, lastNodeId };
|
|
556
985
|
}
|
|
986
|
+
export function onIdRewrites(listener) {
|
|
987
|
+
idRewriteListeners.add(listener);
|
|
988
|
+
return () => idRewriteListeners.delete(listener);
|
|
989
|
+
}
|
|
557
990
|
export function onChanges(listener) {
|
|
558
991
|
listeners.add(listener);
|
|
559
992
|
return () => listeners.delete(listener);
|
|
@@ -562,6 +995,24 @@ export function onChanges(listener) {
|
|
|
562
995
|
// SERVER-SIDE DOCUMENT MUTATIONS
|
|
563
996
|
// ============================================================================
|
|
564
997
|
// generateNodeId imported from helpers.ts
|
|
998
|
+
/**
|
|
999
|
+
* Containers whose internals are NOT addressable via MCP. `findNode` must not
|
|
1000
|
+
* descend into these — their child IDs are ephemeral (regenerated every
|
|
1001
|
+
* `markdownToTiptap` parse, untracked by the matcher) and never exposed via
|
|
1002
|
+
* `compactNodes`, so any agent-provided ID that happens to match a node inside
|
|
1003
|
+
* one is a collision, not a legitimate target.
|
|
1004
|
+
*
|
|
1005
|
+
* Before this guard existed, a `write_to_pad` rewrite could silently corrupt a
|
|
1006
|
+
* table cell: an agent ID collision with a freshly-minted table-internal node
|
|
1007
|
+
* routed the splice into the table instead of the intended top-level
|
|
1008
|
+
* paragraph. Reported as success, observable as stray paragraphs / mangled
|
|
1009
|
+
* rows in the saved markdown.
|
|
1010
|
+
*
|
|
1011
|
+
* Mirrors `node-blocks.ts`'s walker, which already treats tables as opaque.
|
|
1012
|
+
*
|
|
1013
|
+
* adr: adr/node-identity-matcher.md
|
|
1014
|
+
*/
|
|
1015
|
+
const OPAQUE_CONTAINER_TYPES = new Set(['table', 'tableRow', 'tableCell', 'tableHeader']);
|
|
565
1016
|
/**
|
|
566
1017
|
* Find a node by ID in any document tree.
|
|
567
1018
|
* topLevel is used to resolve the "end" sentinel.
|
|
@@ -577,6 +1028,11 @@ function findNode(nodes, id, topLevel) {
|
|
|
577
1028
|
if (nodes[i].attrs?.id === id) {
|
|
578
1029
|
return { parent: nodes, index: i };
|
|
579
1030
|
}
|
|
1031
|
+
// Don't descend into table internals (table, tableRow, tableCell, tableHeader).
|
|
1032
|
+
// Their IDs aren't addressable via MCP and they regenerate on every parse,
|
|
1033
|
+
// so any match inside is a collision that would silently corrupt the table.
|
|
1034
|
+
if (OPAQUE_CONTAINER_TYPES.has(nodes[i].type))
|
|
1035
|
+
continue;
|
|
580
1036
|
if (nodes[i].content && Array.isArray(nodes[i].content)) {
|
|
581
1037
|
const result = findNode(nodes[i].content, id, topLevel);
|
|
582
1038
|
if (result)
|
|
@@ -845,12 +1301,23 @@ export function isAutoAcceptActive(filename, metadata) {
|
|
|
845
1301
|
return false;
|
|
846
1302
|
return isAutoAcceptInheritedForDoc(filename);
|
|
847
1303
|
}
|
|
848
|
-
/** Apply changes to the active document singleton.
|
|
1304
|
+
/** Apply changes to the active document singleton.
|
|
1305
|
+
*
|
|
1306
|
+
* The applyChangesToDoc engine mutates the merged view (state.document) —
|
|
1307
|
+
* it's where the pending-status stamping logic lives. After the mutation,
|
|
1308
|
+
* re-split state.document back into primary state (canonical + overlay) so
|
|
1309
|
+
* the cache + matcher + save paths see consistent primary state. The
|
|
1310
|
+
* re-split is the bridge between the legacy "mutate merged in place" engine
|
|
1311
|
+
* and the new "primary state is canonical + overlay" model. */
|
|
849
1312
|
function applyChangesToDocument(changes) {
|
|
850
1313
|
const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
|
|
851
1314
|
const processed = applyChangesToDoc(state.document, changes, autoAccept);
|
|
852
1315
|
if (processed.length > 0) {
|
|
853
1316
|
state.lastModified = new Date();
|
|
1317
|
+
// Re-sync primary state from the now-mutated merged view. Idempotent:
|
|
1318
|
+
// splitMergedDoc + applyOverlayPure round-trip leaves state.document
|
|
1319
|
+
// structurally equivalent to itself.
|
|
1320
|
+
setPrimaryFromMerged(state.document);
|
|
854
1321
|
}
|
|
855
1322
|
return processed;
|
|
856
1323
|
}
|
|
@@ -864,8 +1331,16 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
864
1331
|
return { success: false, error: `Node ${nodeId} not found` };
|
|
865
1332
|
const originalNode = found.parent[found.index];
|
|
866
1333
|
const result = applyTextEditsToNode(originalNode, edits);
|
|
867
|
-
if (!result)
|
|
868
|
-
|
|
1334
|
+
if (!result) {
|
|
1335
|
+
// Surface a slice of the actual node text alongside the searched `find`
|
|
1336
|
+
// strings so the agent can diff for unicode/whitespace mismatches
|
|
1337
|
+
// (em-dash vs hyphen-minus, NBSP vs space, smart quotes, etc.). Without
|
|
1338
|
+
// this the failure is opaque and the agent has to guess.
|
|
1339
|
+
const nodeText = extractText(originalNode.content || []);
|
|
1340
|
+
const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
|
|
1341
|
+
const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
|
|
1342
|
+
return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
|
|
1343
|
+
}
|
|
869
1344
|
// Inline edit decoration only matters when there's a review surface — skip in autoAccept.
|
|
870
1345
|
if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
|
|
871
1346
|
result.node.attrs = {
|
|
@@ -888,14 +1363,42 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
888
1363
|
* (Option B in adr/node-identity-matcher.md). Markdown is the source of
|
|
889
1364
|
* truth; memory is an ephemeral working copy. */
|
|
890
1365
|
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
|
|
891
|
-
state
|
|
1366
|
+
// Route the incoming doc through the primary-state setter — splits into
|
|
1367
|
+
// canonical + overlay (handles legacy in-frontmatter pending if present)
|
|
1368
|
+
// and recomputes the merged view.
|
|
1369
|
+
setPrimaryFromMerged(doc);
|
|
892
1370
|
state.title = title;
|
|
893
1371
|
state.metadata = metadata || { title };
|
|
894
|
-
|
|
1372
|
+
// Legacy: strip any pre-architectural-fix `agentCreated` field that
|
|
1373
|
+
// arrived in metadata (e.g. from a re-parse of an old on-disk file).
|
|
1374
|
+
// The in-memory agentStubFilenames Set is the only authority for stub
|
|
1375
|
+
// status — disk frontmatter must not carry stub state.
|
|
1376
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1377
|
+
// Canonicalize at the identity boundary: same physical file via any
|
|
1378
|
+
// spelling (forward/back slash, drive-letter case, symlink) lands the
|
|
1379
|
+
// same string in state.filePath, which is the cache key for the doc
|
|
1380
|
+
// cache and the subscription path for the fs watcher.
|
|
1381
|
+
// adr: adr/path-canonicalization.md
|
|
1382
|
+
state.filePath = filePath ? canonicalizePath(filePath) : '';
|
|
895
1383
|
state.isTemp = isTemp;
|
|
896
1384
|
state.lastModified = lastModified || new Date();
|
|
897
1385
|
state.docId = ensureDocId(state.metadata);
|
|
898
1386
|
state.originalFrontmatter = originalFrontmatter ?? null;
|
|
1387
|
+
// Snapshot the on-disk mtime so writeToDisk can detect external writes
|
|
1388
|
+
// that land while this doc is active. 0 = no file on disk yet (new doc).
|
|
1389
|
+
try {
|
|
1390
|
+
state.loadedMtime = filePath && existsSync(filePath) ? statSync(filePath).mtimeMs : 0;
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
state.loadedMtime = 0;
|
|
1394
|
+
}
|
|
1395
|
+
// Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
|
|
1396
|
+
// (sidecar present, legacy migration, no pending).
|
|
1397
|
+
mergeOverlayOnLoad();
|
|
1398
|
+
// Subscribe the fs watcher to this doc so external writes (Edit tool,
|
|
1399
|
+
// VSCode, scripts) trigger a unified reload + version bump + broadcast.
|
|
1400
|
+
// adr: adr/active-doc-watcher.md
|
|
1401
|
+
startActiveDocWatcher();
|
|
899
1402
|
}
|
|
900
1403
|
// ============================================================================
|
|
901
1404
|
// PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
|
|
@@ -965,7 +1468,12 @@ function populatePendingCache() {
|
|
|
965
1468
|
catch { /* skip unreadable files */ }
|
|
966
1469
|
}
|
|
967
1470
|
}
|
|
968
|
-
|
|
1471
|
+
/** Keyed by canonical path. Two spellings of the same physical file
|
|
1472
|
+
* (forward/back slash, drive-letter case) collapse to one cache entry —
|
|
1473
|
+
* preventing parallel state where the same disk file lives in two
|
|
1474
|
+
* cache slots.
|
|
1475
|
+
* adr: adr/path-canonicalization.md */
|
|
1476
|
+
const docCache = new Map();
|
|
969
1477
|
/** Cache the active document's full state, keyed by filePath. Call after save().
|
|
970
1478
|
*
|
|
971
1479
|
* Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
|
|
@@ -979,8 +1487,14 @@ export function cacheActiveDocument() {
|
|
|
979
1487
|
fileMtime = statSync(state.filePath).mtimeMs;
|
|
980
1488
|
}
|
|
981
1489
|
catch { /* file may not exist yet */ }
|
|
982
|
-
|
|
983
|
-
|
|
1490
|
+
// Split the live merged document into canonical + overlay AT cache time —
|
|
1491
|
+
// this is what protects against the duplicate-insert bug. Storing merged
|
|
1492
|
+
// and re-applying overlay later was the broken pattern.
|
|
1493
|
+
const split = splitMergedDoc(state.document);
|
|
1494
|
+
docCache.set(canonicalizePath(state.filePath), {
|
|
1495
|
+
canonical: split.canonical,
|
|
1496
|
+
overlayEntries: split.overlayEntries,
|
|
1497
|
+
document: applyOverlayPure(split.canonical, split.overlayEntries),
|
|
984
1498
|
metadata: structuredClone(state.metadata),
|
|
985
1499
|
title: state.title,
|
|
986
1500
|
isTemp: state.isTemp,
|
|
@@ -992,27 +1506,28 @@ export function cacheActiveDocument() {
|
|
|
992
1506
|
}
|
|
993
1507
|
/** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
|
|
994
1508
|
export function getCachedDocument(filePath) {
|
|
995
|
-
const
|
|
1509
|
+
const key = canonicalizePath(filePath);
|
|
1510
|
+
const cached = docCache.get(key);
|
|
996
1511
|
if (!cached)
|
|
997
1512
|
return null;
|
|
998
1513
|
try {
|
|
999
1514
|
const currentMtime = statSync(filePath).mtimeMs;
|
|
1000
1515
|
if (currentMtime !== cached.fileMtime) {
|
|
1001
1516
|
// File changed on disk — invalidate cache
|
|
1002
|
-
docCache.delete(
|
|
1517
|
+
docCache.delete(key);
|
|
1003
1518
|
return null;
|
|
1004
1519
|
}
|
|
1005
1520
|
}
|
|
1006
1521
|
catch {
|
|
1007
1522
|
// File doesn't exist or can't be read — invalidate
|
|
1008
|
-
docCache.delete(
|
|
1523
|
+
docCache.delete(key);
|
|
1009
1524
|
return null;
|
|
1010
1525
|
}
|
|
1011
1526
|
return cached;
|
|
1012
1527
|
}
|
|
1013
1528
|
/** Remove a specific file from the document cache. */
|
|
1014
1529
|
export function invalidateDocCache(filePath) {
|
|
1015
|
-
docCache.delete(filePath);
|
|
1530
|
+
docCache.delete(canonicalizePath(filePath));
|
|
1016
1531
|
}
|
|
1017
1532
|
/** Update the cache entry for a file after writing changes (without cloning the active state). */
|
|
1018
1533
|
export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
@@ -1021,10 +1536,18 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
|
|
|
1021
1536
|
fileMtime = statSync(filePath).mtimeMs;
|
|
1022
1537
|
}
|
|
1023
1538
|
catch { /* best-effort */ }
|
|
1539
|
+
const key = canonicalizePath(filePath);
|
|
1024
1540
|
// Preserve originalFrontmatter from existing cache entry (if any)
|
|
1025
|
-
const existing = docCache.get(
|
|
1026
|
-
|
|
1027
|
-
|
|
1541
|
+
const existing = docCache.get(key);
|
|
1542
|
+
// Split the incoming doc into canonical + overlay before caching so the
|
|
1543
|
+
// cache stores canonical-only and the duplicate-insert chain is broken.
|
|
1544
|
+
const split = splitMergedDoc(doc);
|
|
1545
|
+
const overlayEntries = split.overlayEntries;
|
|
1546
|
+
const canonicalClone = split.canonical;
|
|
1547
|
+
docCache.set(key, {
|
|
1548
|
+
canonical: canonicalClone,
|
|
1549
|
+
overlayEntries,
|
|
1550
|
+
document: applyOverlayPure(canonicalClone, overlayEntries),
|
|
1028
1551
|
metadata: structuredClone(metadata),
|
|
1029
1552
|
title,
|
|
1030
1553
|
isTemp,
|
|
@@ -1036,10 +1559,15 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
|
|
|
1036
1559
|
}
|
|
1037
1560
|
/** Reset all in-memory caches. Called on profile switch. */
|
|
1038
1561
|
export function clearAllCaches() {
|
|
1562
|
+
// Tear down the active-doc watcher before swapping state — leaving it
|
|
1563
|
+
// alive would point at a path the new profile doesn't own.
|
|
1564
|
+
stopActiveDocWatcher();
|
|
1039
1565
|
docCache.clear();
|
|
1040
1566
|
pendingDocCache.clear();
|
|
1041
1567
|
externalDocs.clear();
|
|
1042
1568
|
state = {
|
|
1569
|
+
canonical: DEFAULT_DOC,
|
|
1570
|
+
overlay: new Map(),
|
|
1043
1571
|
document: DEFAULT_DOC,
|
|
1044
1572
|
title: 'Untitled',
|
|
1045
1573
|
metadata: { title: 'Untitled' },
|
|
@@ -1048,6 +1576,7 @@ export function clearAllCaches() {
|
|
|
1048
1576
|
lastModified: new Date(),
|
|
1049
1577
|
docId: '',
|
|
1050
1578
|
originalFrontmatter: null,
|
|
1579
|
+
loadedMtime: 0,
|
|
1051
1580
|
};
|
|
1052
1581
|
}
|
|
1053
1582
|
// ============================================================================
|
|
@@ -1069,23 +1598,225 @@ export function hasPendingChanges(doc) {
|
|
|
1069
1598
|
}
|
|
1070
1599
|
return scan(target.content);
|
|
1071
1600
|
}
|
|
1072
|
-
/** Strip all pending attrs from the current document (after browser resolves all changes).
|
|
1601
|
+
/** Strip all pending attrs from the current document (after browser resolves all changes).
|
|
1602
|
+
* In the new model this is implemented by clearing the overlay — the merged
|
|
1603
|
+
* view recomputes to canonical (which already has no pending markers). */
|
|
1073
1604
|
export function stripPendingAttrs() {
|
|
1074
|
-
|
|
1075
|
-
|
|
1605
|
+
state.overlay = new Map();
|
|
1606
|
+
recomputeMerged();
|
|
1607
|
+
removePendingCacheEntry(activeDocFilename());
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Return a deep clone of `doc` with all pending changes reverted, as if the
|
|
1611
|
+
* user had rejected every pending decoration:
|
|
1612
|
+
* - pendingStatus=insert → drop the node
|
|
1613
|
+
* - pendingStatus=rewrite → replace node with `pendingOriginalContent` (or drop if absent)
|
|
1614
|
+
* - pendingStatus=delete → keep node, clear pending attrs (the rejection is "no, don't delete")
|
|
1615
|
+
* - no pendingStatus → keep node, strip any stray pending attrs
|
|
1616
|
+
*
|
|
1617
|
+
* Used by `restore_version` to write a canonical-only safety checkpoint so
|
|
1618
|
+
* the snapshot represents a clean recovery point rather than a flattened
|
|
1619
|
+
* pending+canonical hybrid that never actually existed in the user's view.
|
|
1620
|
+
*
|
|
1621
|
+
* Does NOT mutate the input doc.
|
|
1622
|
+
*/
|
|
1623
|
+
/**
|
|
1624
|
+
* Reload the active doc from disk. Re-reads the canonical .md file, applies
|
|
1625
|
+
* the pending overlay from the sidecar (with orphan + stale-baseline
|
|
1626
|
+
* classification), and updates state.document, state.metadata, state.title,
|
|
1627
|
+
* and state.loadedMtime in place.
|
|
1628
|
+
*
|
|
1629
|
+
* Called by:
|
|
1630
|
+
* - chokidar watcher when an external write modifies the active file
|
|
1631
|
+
* - mcp.restore_version after writing the snapshot's content to disk
|
|
1632
|
+
* - mcp.reload_from_disk tool (explicit user-driven reload)
|
|
1633
|
+
* - writeToDisk's mtime-CAS backstop (recover-and-retry path)
|
|
1634
|
+
*
|
|
1635
|
+
* Returns the reload result so callers can broadcast or react to the
|
|
1636
|
+
* orphan / staleBaseline classifications. Returns null if there's no
|
|
1637
|
+
* active file path to reload (no-op).
|
|
1638
|
+
*
|
|
1639
|
+
* adr: adr/pending-overlay-model.md
|
|
1640
|
+
*/
|
|
1641
|
+
export function reloadActiveDocFromDisk() {
|
|
1642
|
+
if (!state.filePath || !existsSync(state.filePath))
|
|
1643
|
+
return null;
|
|
1644
|
+
const raw = readFileSync(state.filePath, 'utf-8');
|
|
1645
|
+
const parsed = markdownToTiptap(raw);
|
|
1646
|
+
// Split parsed doc: canonical (clean) + any legacy in-frontmatter pending.
|
|
1647
|
+
const { canonical, overlayEntries: legacyOverlay } = splitMergedDoc(parsed.document);
|
|
1648
|
+
state.title = parsed.title;
|
|
1649
|
+
state.metadata = parsed.metadata;
|
|
1650
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1651
|
+
state.docId = ensureDocId(state.metadata);
|
|
1652
|
+
state.lastModified = new Date(statSync(state.filePath).mtimeMs);
|
|
1653
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
1654
|
+
// Sidecar is authoritative if present. Otherwise legacy in-doc pending
|
|
1655
|
+
// migrates to sidecar (one-time).
|
|
1656
|
+
const sidecar = loadOverlay(state.docId);
|
|
1657
|
+
let entries;
|
|
1658
|
+
if (sidecar.length > 0) {
|
|
1659
|
+
entries = sidecar;
|
|
1660
|
+
}
|
|
1661
|
+
else if (legacyOverlay.length > 0) {
|
|
1662
|
+
entries = legacyOverlay;
|
|
1663
|
+
saveOverlay(state.docId, legacyOverlay);
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
entries = [];
|
|
1667
|
+
}
|
|
1668
|
+
// Set primary state directly — canonical from disk parse, overlay from
|
|
1669
|
+
// sidecar (or legacy migration). Recompute merged via the helper.
|
|
1670
|
+
state.canonical = canonical;
|
|
1671
|
+
setOverlayFromEntries(entries);
|
|
1672
|
+
return {
|
|
1673
|
+
document: state.document,
|
|
1674
|
+
title: state.title,
|
|
1675
|
+
filename: state.filePath.split(/[/\\]/).pop() || '',
|
|
1676
|
+
orphans: [],
|
|
1677
|
+
staleBaseline: [],
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Pending overlay rehydration. Runs after a doc loads from disk (load() or
|
|
1682
|
+
* setActiveDocument). Three cases:
|
|
1683
|
+
*
|
|
1684
|
+
* 1. Sidecar has entries: state.document is canonical (parser found no
|
|
1685
|
+
* `meta.pending`). Apply sidecar overlay to layer pending decorations
|
|
1686
|
+
* back onto the canonical tree.
|
|
1687
|
+
*
|
|
1688
|
+
* 2. Sidecar empty, parser stamped pending attrs from legacy frontmatter:
|
|
1689
|
+
* one-time migration. Extract overlay from state.document, save to
|
|
1690
|
+
* sidecar. State.document already has pending attrs from parse, so
|
|
1691
|
+
* no apply step needed.
|
|
1692
|
+
*
|
|
1693
|
+
* 3. Sidecar empty, parser stamped nothing: no pending state. No-op.
|
|
1694
|
+
*
|
|
1695
|
+
* Returns the merged overlay entries for callers that need them.
|
|
1696
|
+
* adr: adr/pending-overlay-model.md
|
|
1697
|
+
*/
|
|
1698
|
+
function mergeOverlayOnLoad() {
|
|
1699
|
+
if (!state.docId)
|
|
1700
|
+
return [];
|
|
1701
|
+
// setActiveDocument just populated primary state via setPrimaryFromMerged.
|
|
1702
|
+
// If a sidecar exists for this docId, it overrides any legacy in-doc pending
|
|
1703
|
+
// that the splitter extracted from the input. If no sidecar exists and the
|
|
1704
|
+
// legacy split found entries, persist them as a one-time migration.
|
|
1705
|
+
const sidecar = loadOverlay(state.docId);
|
|
1706
|
+
const legacy = Array.from(state.overlay.values());
|
|
1707
|
+
if (sidecar.length > 0) {
|
|
1708
|
+
setOverlayFromEntries(sidecar);
|
|
1709
|
+
return sidecar;
|
|
1710
|
+
}
|
|
1711
|
+
else if (legacy.length > 0) {
|
|
1712
|
+
saveOverlay(state.docId, legacy);
|
|
1713
|
+
// overlay already has these entries from setPrimaryFromMerged; no-op
|
|
1714
|
+
return legacy;
|
|
1715
|
+
}
|
|
1716
|
+
return [];
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Apply an oldId → newId translation map to a TipTap doc tree in place.
|
|
1720
|
+
* Used by writeToDisk to bring state.document's nodeIds in sync with the
|
|
1721
|
+
* matcher's post-pass canonical IDs.
|
|
1722
|
+
*/
|
|
1723
|
+
function applyIdTranslationToDoc(doc, translation) {
|
|
1724
|
+
if (translation.size === 0)
|
|
1725
|
+
return;
|
|
1726
|
+
function walk(nodes) {
|
|
1727
|
+
if (!Array.isArray(nodes))
|
|
1076
1728
|
return;
|
|
1077
1729
|
for (const node of nodes) {
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
delete node.attrs.pendingTextEdits;
|
|
1730
|
+
const oldId = node?.attrs?.id;
|
|
1731
|
+
if (oldId && translation.has(oldId)) {
|
|
1732
|
+
node.attrs.id = translation.get(oldId);
|
|
1082
1733
|
}
|
|
1083
|
-
if (node
|
|
1084
|
-
|
|
1734
|
+
if (node?.content)
|
|
1735
|
+
walk(node.content);
|
|
1085
1736
|
}
|
|
1086
1737
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1738
|
+
walk(doc.content || []);
|
|
1739
|
+
}
|
|
1740
|
+
export function cloneWithPendingReverted(doc) {
|
|
1741
|
+
const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
|
|
1742
|
+
function clean(node) {
|
|
1743
|
+
const clone = JSON.parse(JSON.stringify(node));
|
|
1744
|
+
if (clone.attrs) {
|
|
1745
|
+
for (const k of PENDING_KEYS)
|
|
1746
|
+
delete clone.attrs[k];
|
|
1747
|
+
}
|
|
1748
|
+
if (clone.content)
|
|
1749
|
+
clone.content = walk(clone.content);
|
|
1750
|
+
return clone;
|
|
1751
|
+
}
|
|
1752
|
+
function walk(nodes) {
|
|
1753
|
+
const result = [];
|
|
1754
|
+
for (const node of nodes) {
|
|
1755
|
+
const status = node.attrs?.pendingStatus;
|
|
1756
|
+
if (status === 'insert')
|
|
1757
|
+
continue; // drop fresh agent inserts
|
|
1758
|
+
if (status === 'rewrite') {
|
|
1759
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
1760
|
+
if (original)
|
|
1761
|
+
result.push(clean(original));
|
|
1762
|
+
// If no original stashed, drop the node — we have nothing to revert to.
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
// 'delete' status or no status: keep the node, strip any pending attrs.
|
|
1766
|
+
result.push(clean(node));
|
|
1767
|
+
}
|
|
1768
|
+
return result;
|
|
1769
|
+
}
|
|
1770
|
+
return { type: 'doc', content: walk(doc.content || []) };
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Does the document have any "accepted" content — i.e. blocks that wouldn't
|
|
1774
|
+
* vanish under reject-all? Used to clear the `agentCreated` flag once a stub
|
|
1775
|
+
* has graduated into a real document, so a later reject-all on stale pending
|
|
1776
|
+
* decorations doesn't accidentally trigger the delete-on-reject cascade.
|
|
1777
|
+
*
|
|
1778
|
+
* A node counts as accepted if it has no pendingStatus, or has
|
|
1779
|
+
* pendingStatus=delete (reject keeps the node), or pendingStatus=rewrite with
|
|
1780
|
+
* `pendingOriginalContent` present (reject restores prior content).
|
|
1781
|
+
*/
|
|
1782
|
+
export function hasAcceptedContent(doc) {
|
|
1783
|
+
function extractTextLocal(nodes) {
|
|
1784
|
+
let out = '';
|
|
1785
|
+
for (const n of nodes) {
|
|
1786
|
+
if (typeof n.text === 'string')
|
|
1787
|
+
out += n.text;
|
|
1788
|
+
if (n.content)
|
|
1789
|
+
out += extractTextLocal(n.content);
|
|
1790
|
+
}
|
|
1791
|
+
return out;
|
|
1792
|
+
}
|
|
1793
|
+
function walk(nodes) {
|
|
1794
|
+
if (!nodes)
|
|
1795
|
+
return false;
|
|
1796
|
+
for (const node of nodes) {
|
|
1797
|
+
const status = node.attrs?.pendingStatus;
|
|
1798
|
+
// Nodes that would NOT survive reject-all: skip entirely, including their
|
|
1799
|
+
// children. Otherwise an insert-pending paragraph's text children would be
|
|
1800
|
+
// misread as accepted content.
|
|
1801
|
+
if (status === 'insert')
|
|
1802
|
+
continue;
|
|
1803
|
+
if (status === 'rewrite' && !node.attrs?.pendingOriginalContent)
|
|
1804
|
+
continue;
|
|
1805
|
+
// This node survives reject-all (no status, or delete, or rewrite-with-original).
|
|
1806
|
+
const surfaceText = node.text || extractTextLocal(node.content || []);
|
|
1807
|
+
if (surfaceText && surfaceText.trim().length > 0)
|
|
1808
|
+
return true;
|
|
1809
|
+
// Non-text leaf types also count as accepted content
|
|
1810
|
+
if (node.type === 'image' || node.type === 'horizontalRule' || node.type === 'table')
|
|
1811
|
+
return true;
|
|
1812
|
+
// Recurse into container nodes (lists, blockquotes) only when the
|
|
1813
|
+
// container itself isn't a dropped-on-reject node.
|
|
1814
|
+
if (node.content && walk(node.content))
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
return false;
|
|
1818
|
+
}
|
|
1819
|
+
return walk(doc.content || []);
|
|
1089
1820
|
}
|
|
1090
1821
|
/**
|
|
1091
1822
|
* Mark leaf block nodes as pending within a node array.
|
|
@@ -1146,54 +1877,145 @@ function writeToDisk() {
|
|
|
1146
1877
|
}
|
|
1147
1878
|
catch { /* best-effort */ }
|
|
1148
1879
|
}
|
|
1880
|
+
// Stub graduation: once the doc contains accepted content, it's no longer
|
|
1881
|
+
// a fresh stub. Remove it from the in-memory stub registry so reject-all
|
|
1882
|
+
// can never trigger the cleanup-delete on it.
|
|
1883
|
+
// adr: adr/agent-stub-model.md
|
|
1884
|
+
if (hasAcceptedContent(state.document)) {
|
|
1885
|
+
unmarkAgentStub(activeDocFilename());
|
|
1886
|
+
}
|
|
1887
|
+
// Defensive: never serialize `agentCreated` to disk. The field is dead;
|
|
1888
|
+
// any code reading it would be the bug, not the field's presence.
|
|
1889
|
+
if (state.metadata)
|
|
1890
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1149
1891
|
let markdown;
|
|
1150
1892
|
if (isExternalDoc(state.filePath)) {
|
|
1151
|
-
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
|
|
1893
|
+
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
|
|
1894
|
+
// External docs don't participate in the pending overlay system (the external editor
|
|
1895
|
+
// is the source of truth for the file's structure).
|
|
1152
1896
|
const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
|
|
1153
1897
|
markdown = state.originalFrontmatter
|
|
1154
1898
|
? `---\n${state.originalFrontmatter}\n---\n\n${body}`
|
|
1155
1899
|
: body;
|
|
1156
1900
|
}
|
|
1157
1901
|
else {
|
|
1158
|
-
// Save-time matcher pass
|
|
1902
|
+
// Save-time matcher pass + pending overlay split.
|
|
1903
|
+
//
|
|
1904
|
+
// Architectural model: disk is canonical only. Pending state lives in a
|
|
1905
|
+
// sidecar at `_pending/{docId}.json`. We split state.document into:
|
|
1906
|
+
// - canonical: a clone with all pending reverted (matcher operates on this)
|
|
1907
|
+
// - overlay: the extracted pending entries (saved to sidecar)
|
|
1159
1908
|
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
// state. Memory holds no identity cache; identity always re-derives from
|
|
1166
|
-
// disk at the save boundary.
|
|
1909
|
+
// The matcher runs on canonical so the on-disk `nodes:` fingerprints match
|
|
1910
|
+
// the on-disk body. Pre-matcher canonical IDs are translated to post-matcher
|
|
1911
|
+
// IDs and the same translation is applied to (a) state.document, so the
|
|
1912
|
+
// in-memory tree stays consistent with disk, and (b) the overlay entries,
|
|
1913
|
+
// so they re-anchor correctly on reload.
|
|
1167
1914
|
//
|
|
1168
|
-
// adr: adr/node-identity-matcher.md
|
|
1915
|
+
// adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
|
|
1916
|
+
const canonical = cloneWithPendingReverted(state.document);
|
|
1169
1917
|
const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
|
|
1170
1918
|
let nextGraveyard = graveyard;
|
|
1919
|
+
const idTranslation = new Map();
|
|
1171
1920
|
if (previousNodes.length > 0) {
|
|
1172
|
-
const newBlocks = tiptapToBlocks(
|
|
1921
|
+
const newBlocks = tiptapToBlocks(canonical);
|
|
1922
|
+
const beforeIds = newBlocks.map((b) => b.id);
|
|
1173
1923
|
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
|
|
1174
1924
|
const pinnedByPosition = new Map();
|
|
1175
1925
|
for (const p of matchResult.pinned)
|
|
1176
1926
|
pinnedByPosition.set(p.position, p.id);
|
|
1177
|
-
applyIdsToTiptap(
|
|
1927
|
+
applyIdsToTiptap(canonical, pinnedByPosition);
|
|
1178
1928
|
nextGraveyard = matchResult.nextGraveyard;
|
|
1929
|
+
// Build pre→post id translation (canonical's IDs match state.document's
|
|
1930
|
+
// IDs at non-insert positions, since cloneWithPendingReverted preserves
|
|
1931
|
+
// IDs on rewrite/delete/passthrough nodes).
|
|
1932
|
+
for (let i = 0; i < beforeIds.length; i++) {
|
|
1933
|
+
const oldId = beforeIds[i];
|
|
1934
|
+
const newId = pinnedByPosition.get(i);
|
|
1935
|
+
if (oldId && newId && oldId !== newId) {
|
|
1936
|
+
idTranslation.set(oldId, newId);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
// Apply translation to primary state. The matcher renamed IDs on
|
|
1940
|
+
// canonical (above); we need state.canonical, state.overlay's entry
|
|
1941
|
+
// keys (the nodeId fields), and state.document to all see the new IDs.
|
|
1942
|
+
if (idTranslation.size > 0) {
|
|
1943
|
+
applyIdTranslationToDoc(state.canonical, idTranslation);
|
|
1944
|
+
// Translate overlay entry nodeIds (rewrite/delete entries point at
|
|
1945
|
+
// canonical IDs that may have shifted; insert entries have unique
|
|
1946
|
+
// IDs not in the translation map and pass through).
|
|
1947
|
+
const newOverlay = new Map();
|
|
1948
|
+
for (const [nodeId, entry] of state.overlay) {
|
|
1949
|
+
const newNodeId = idTranslation.get(nodeId) ?? nodeId;
|
|
1950
|
+
newOverlay.set(newNodeId, { ...entry, nodeId: newNodeId });
|
|
1951
|
+
}
|
|
1952
|
+
state.overlay = newOverlay;
|
|
1953
|
+
recomputeMerged();
|
|
1954
|
+
}
|
|
1955
|
+
// Broadcast id-rewrites so browser clients converge their TipTap state.
|
|
1956
|
+
if (idRewriteListeners.size > 0 && idTranslation.size > 0) {
|
|
1957
|
+
const rewrites = Array.from(idTranslation, ([oldId, newId]) => ({ oldId, newId }));
|
|
1958
|
+
for (const listener of idRewriteListeners)
|
|
1959
|
+
listener(rewrites);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
// Persist the structured overlay to sidecar. state.overlay IS the overlay
|
|
1963
|
+
// in the new model — no extraction needed.
|
|
1964
|
+
if (state.docId) {
|
|
1965
|
+
saveOverlay(state.docId, Array.from(state.overlay.values()));
|
|
1179
1966
|
}
|
|
1180
1967
|
// Pass graveyard through metadata so the serializer can emit it in frontmatter.
|
|
1181
1968
|
const metaWithGraveyard = nextGraveyard.length > 0
|
|
1182
1969
|
? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
|
|
1183
1970
|
: state.metadata;
|
|
1184
|
-
// Checked serializer —
|
|
1185
|
-
//
|
|
1186
|
-
const result = tiptapToMarkdownChecked(
|
|
1971
|
+
// Checked serializer — operates on canonical (already pending-reverted).
|
|
1972
|
+
// The serializer no longer emits `meta.pending` (overlay handles that).
|
|
1973
|
+
const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
|
|
1187
1974
|
markdown = result.markdown;
|
|
1188
1975
|
}
|
|
1189
1976
|
if (existsSync(state.filePath)) {
|
|
1190
1977
|
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
1191
1978
|
try {
|
|
1192
1979
|
const existing = readFileSync(state.filePath, 'utf-8');
|
|
1193
|
-
if (existing === markdown)
|
|
1980
|
+
if (existing === markdown) {
|
|
1981
|
+
// Even on a no-op write, refresh our mtime snapshot so we don't
|
|
1982
|
+
// misread a stale `loadedMtime` as evidence of an external write.
|
|
1983
|
+
try {
|
|
1984
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
1985
|
+
}
|
|
1986
|
+
catch { /* best-effort */ }
|
|
1194
1987
|
return;
|
|
1988
|
+
}
|
|
1195
1989
|
}
|
|
1196
1990
|
catch { /* read failed, proceed with write */ }
|
|
1991
|
+
// EXTERNAL-WRITE GUARD: if disk mtime is newer than the mtime we stamped
|
|
1992
|
+
// at load (or our last successful save), an external writer modified the
|
|
1993
|
+
// file out from under us. Blindly writing our in-memory state would
|
|
1994
|
+
// clobber their content silently. Block the write, log, and surface via
|
|
1995
|
+
// sync-status so the agent/user can resolve via reload_from_disk.
|
|
1996
|
+
//
|
|
1997
|
+
// Edge cases handled:
|
|
1998
|
+
// - First save of a new doc: loadedMtime=0, so the guard never fires
|
|
1999
|
+
// (every real file's mtime will be > 0).
|
|
2000
|
+
// - Atomic-write race: writeFileSync momentarily mtime-bumps. We re-
|
|
2001
|
+
// stamp loadedMtime AFTER every successful own write so subsequent
|
|
2002
|
+
// guard checks compare against our own write, not a phantom delta.
|
|
2003
|
+
// - Clock drift: we compare exact ms equality (not >); any change at
|
|
2004
|
+
// all is treated as external. Filesystems guarantee monotonic mtime
|
|
2005
|
+
// per file on the same host so this is safe.
|
|
2006
|
+
if (state.loadedMtime > 0) {
|
|
2007
|
+
try {
|
|
2008
|
+
const diskMtime = statSync(state.filePath).mtimeMs;
|
|
2009
|
+
if (diskMtime !== state.loadedMtime) {
|
|
2010
|
+
console.error(`[State] BLOCKED save: external write detected on ${state.filePath} ` +
|
|
2011
|
+
`(disk mtime ${new Date(diskMtime).toISOString()} != loaded mtime ${new Date(state.loadedMtime).toISOString()}). ` +
|
|
2012
|
+
`Call reload_from_disk to adopt external content, or write_to_pad to re-apply changes on top.`);
|
|
2013
|
+
notifyExternalWriteConflict(state.filePath, diskMtime, state.loadedMtime);
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
catch { /* stat failed, proceed with save */ }
|
|
2018
|
+
}
|
|
1197
2019
|
// Safety: don't overwrite a file with substantial content using near-empty content.
|
|
1198
2020
|
// Prevents save cascades where empty editor state destroys chapter files.
|
|
1199
2021
|
// Exception: docs with pending changes may legitimately be smaller (agent replaced content).
|
|
@@ -1209,6 +2031,12 @@ function writeToDisk() {
|
|
|
1209
2031
|
}
|
|
1210
2032
|
}
|
|
1211
2033
|
atomicWriteFileSync(state.filePath, markdown);
|
|
2034
|
+
// Re-stamp loadedMtime so the next save's guard compares against our own
|
|
2035
|
+
// most-recent write, not the prior load's mtime.
|
|
2036
|
+
try {
|
|
2037
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2038
|
+
}
|
|
2039
|
+
catch { /* best-effort */ }
|
|
1212
2040
|
// Best-effort version snapshot — never blocks saves
|
|
1213
2041
|
try {
|
|
1214
2042
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
@@ -1228,14 +2056,17 @@ function writeToDisk() {
|
|
|
1228
2056
|
}
|
|
1229
2057
|
export function save() {
|
|
1230
2058
|
if (!state.filePath) {
|
|
1231
|
-
// First save — assign a file path
|
|
2059
|
+
// First save — assign a file path. Canonicalize at this identity
|
|
2060
|
+
// boundary so cache lookups and watcher subscriptions key on the
|
|
2061
|
+
// same string regardless of how this path was produced.
|
|
2062
|
+
// adr: adr/path-canonicalization.md
|
|
1232
2063
|
ensureDataDir();
|
|
1233
2064
|
if (state.title === 'Untitled') {
|
|
1234
|
-
state.filePath = tempFilePath();
|
|
2065
|
+
state.filePath = canonicalizePath(tempFilePath());
|
|
1235
2066
|
state.isTemp = true;
|
|
1236
2067
|
}
|
|
1237
2068
|
else {
|
|
1238
|
-
state.filePath = filePathForTitle(state.title);
|
|
2069
|
+
state.filePath = canonicalizePath(filePathForTitle(state.title));
|
|
1239
2070
|
state.isTemp = false;
|
|
1240
2071
|
}
|
|
1241
2072
|
}
|
|
@@ -1243,6 +2074,9 @@ export function save() {
|
|
|
1243
2074
|
}
|
|
1244
2075
|
export function load() {
|
|
1245
2076
|
ensureDataDir();
|
|
2077
|
+
// One-time sidecar repair MUST run before any doc loads so the file-walk
|
|
2078
|
+
// loop reads deduped sidecars, not corrupted ones. adr: adr/pending-overlay-model.md
|
|
2079
|
+
repairOverlaysOnStartup();
|
|
1246
2080
|
// Restore external document registry from disk
|
|
1247
2081
|
loadExternalDocs();
|
|
1248
2082
|
// Migrate any .sw.json files to .md
|
|
@@ -1268,19 +2102,38 @@ export function load() {
|
|
|
1268
2102
|
// Skip empty temp files — prefer a real document
|
|
1269
2103
|
if (isTemp && isDocEmpty(parsed.document))
|
|
1270
2104
|
continue;
|
|
1271
|
-
state
|
|
2105
|
+
// Route the parsed doc through the primary-state setter — splits into
|
|
2106
|
+
// canonical + overlay (handles legacy in-frontmatter pending) and
|
|
2107
|
+
// recomputes the merged view via state.document.
|
|
2108
|
+
setPrimaryFromMerged(parsed.document);
|
|
1272
2109
|
state.title = parsed.title;
|
|
1273
2110
|
state.metadata = parsed.metadata;
|
|
2111
|
+
// Legacy: strip any pre-architectural-fix `agentCreated` field that
|
|
2112
|
+
// survived on disk. The in-memory stub registry is the only authority.
|
|
2113
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1274
2114
|
state.lastModified = new Date(statSync(file.path).mtimeMs);
|
|
1275
|
-
state.filePath = file.path;
|
|
2115
|
+
state.filePath = canonicalizePath(file.path);
|
|
1276
2116
|
state.isTemp = isTemp;
|
|
2117
|
+
state.loadedMtime = statSync(file.path).mtimeMs;
|
|
1277
2118
|
// Lazy docId migration: assign if missing, save to persist
|
|
1278
2119
|
const hadDocId = !!state.metadata.docId;
|
|
1279
2120
|
state.docId = ensureDocId(state.metadata);
|
|
1280
2121
|
if (!hadDocId) {
|
|
1281
2122
|
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
1282
2123
|
atomicWriteFileSync(state.filePath, md);
|
|
2124
|
+
try {
|
|
2125
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2126
|
+
}
|
|
2127
|
+
catch { /* best-effort */ }
|
|
1283
2128
|
}
|
|
2129
|
+
// Pending overlay merge: rehydrate pending decorations from the sidecar.
|
|
2130
|
+
// For legacy files (parser stamped pending attrs from old `meta.pending`),
|
|
2131
|
+
// capture those into the overlay format and write the sidecar as a one-
|
|
2132
|
+
// time migration. For migrated files (parser stamped nothing because
|
|
2133
|
+
// `meta.pending` is gone from frontmatter), the sidecar is the only
|
|
2134
|
+
// source.
|
|
2135
|
+
// adr: adr/pending-overlay-model.md
|
|
2136
|
+
mergeOverlayOnLoad();
|
|
1284
2137
|
break;
|
|
1285
2138
|
}
|
|
1286
2139
|
catch {
|
|
@@ -1290,15 +2143,18 @@ export function load() {
|
|
|
1290
2143
|
}
|
|
1291
2144
|
// If nothing loaded (all files were empty temps or corrupt), start fresh
|
|
1292
2145
|
if (!state.filePath) {
|
|
1293
|
-
state.filePath = tempFilePath();
|
|
2146
|
+
state.filePath = canonicalizePath(tempFilePath());
|
|
1294
2147
|
state.isTemp = true;
|
|
1295
2148
|
}
|
|
1296
2149
|
// Populate pending doc cache from disk (single scan on startup)
|
|
1297
2150
|
populatePendingCache();
|
|
1298
2151
|
// Overlay active doc's in-memory state (may have unsaved pending changes)
|
|
1299
2152
|
updatePendingCacheForActiveDoc();
|
|
2153
|
+
// Subscribe the active-doc watcher so external writes route through the
|
|
2154
|
+
// unified reload pathway. adr: adr/active-doc-watcher.md
|
|
2155
|
+
startActiveDocWatcher();
|
|
1300
2156
|
// Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
|
|
1301
|
-
|
|
2157
|
+
setAgentLockGlobal();
|
|
1302
2158
|
}
|
|
1303
2159
|
/** Migrate legacy .sw.json files to .md format */
|
|
1304
2160
|
function migrateSwJsonFiles() {
|
|
@@ -1388,9 +2244,13 @@ function cleanupEmptyTempFiles() {
|
|
|
1388
2244
|
try {
|
|
1389
2245
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
1390
2246
|
const parsed = markdownToTiptap(raw);
|
|
1391
|
-
// Keep temp files that have meaningful metadata (templates, pending changes, tags)
|
|
2247
|
+
// Keep temp files that have meaningful metadata (templates, pending changes, tags).
|
|
2248
|
+
// Note: `agentCreated` used to be a disk-frontmatter signal here. Stub status is now
|
|
2249
|
+
// in-memory only — see agentStubFilenames. An empty temp file with no other meaningful
|
|
2250
|
+
// metadata that survived a server restart is by definition no longer a fresh stub and
|
|
2251
|
+
// can be cleaned up.
|
|
1392
2252
|
const meta = parsed.metadata || {};
|
|
1393
|
-
const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
|
|
2253
|
+
const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
|
|
1394
2254
|
|| (Array.isArray(meta.tags) && meta.tags.length > 0);
|
|
1395
2255
|
if (isDocEmpty(parsed.document) && !hasMetadata) {
|
|
1396
2256
|
unlinkSync(fullPath);
|
|
@@ -1540,6 +2400,13 @@ export function removeDocTag(filename, tag) {
|
|
|
1540
2400
|
/**
|
|
1541
2401
|
* Save a browser doc-update to a specific file on disk.
|
|
1542
2402
|
* Used when the browser sends a doc-update for a non-active document (race condition guard).
|
|
2403
|
+
*
|
|
2404
|
+
* Disk gets canonical (pending stripped by serialization). Any pending attrs
|
|
2405
|
+
* carried on `doc` (transferred from disk) are persisted to the overlay
|
|
2406
|
+
* sidecar in the same pass. Without the symmetric overlay save, pending
|
|
2407
|
+
* content would vanish silently between the strip-during-serialize and the
|
|
2408
|
+
* disk write.
|
|
2409
|
+
* adr: adr/pending-overlay-model.md
|
|
1543
2410
|
*/
|
|
1544
2411
|
export function saveDocToFile(filename, doc) {
|
|
1545
2412
|
const targetPath = resolveDocPath(filename);
|
|
@@ -1563,6 +2430,11 @@ export function saveDocToFile(filename, doc) {
|
|
|
1563
2430
|
markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
1564
2431
|
}
|
|
1565
2432
|
atomicWriteFileSync(targetPath, markdown);
|
|
2433
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
|
|
2434
|
+
if (docId) {
|
|
2435
|
+
const overlay = extractOverlay(doc);
|
|
2436
|
+
saveOverlay(docId, overlay);
|
|
2437
|
+
}
|
|
1566
2438
|
}
|
|
1567
2439
|
catch { /* best-effort */ }
|
|
1568
2440
|
}
|
|
@@ -1597,9 +2469,15 @@ export function setAutoAcceptOnFile(filename, enabled) {
|
|
|
1597
2469
|
}
|
|
1598
2470
|
/**
|
|
1599
2471
|
* Strip pending attrs from a specific file on disk (not the active document).
|
|
1600
|
-
*
|
|
2472
|
+
*
|
|
2473
|
+
* The `_legacyClearAgentCreated` parameter is preserved for callsite-signature
|
|
2474
|
+
* stability but no longer does anything meaningful. Stub status is in-memory
|
|
2475
|
+
* only — there is no `agentCreated` field to clear on disk. The on-load
|
|
2476
|
+
* legacy-strip handles any residual occurrences from pre-architectural-fix
|
|
2477
|
+
* files.
|
|
2478
|
+
* adr: adr/agent-stub-model.md
|
|
1601
2479
|
*/
|
|
1602
|
-
export function stripPendingAttrsFromFile(filename,
|
|
2480
|
+
export function stripPendingAttrsFromFile(filename, _legacyClearAgentCreated) {
|
|
1603
2481
|
const targetPath = resolveDocPath(filename);
|
|
1604
2482
|
if (!existsSync(targetPath))
|
|
1605
2483
|
return;
|
|
@@ -1621,9 +2499,9 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1621
2499
|
}
|
|
1622
2500
|
}
|
|
1623
2501
|
strip(parsed.document.content);
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
2502
|
+
// Belt-and-suspenders: strip any legacy on-disk agentCreated (e.g. an
|
|
2503
|
+
// old file that hasn't been re-saved since the migration).
|
|
2504
|
+
stripLegacyAgentCreated(parsed.metadata);
|
|
1627
2505
|
let markdown;
|
|
1628
2506
|
if (isExternalDoc(targetPath)) {
|
|
1629
2507
|
const body = tiptapToBody(parsed.document);
|
|
@@ -1635,6 +2513,12 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1635
2513
|
markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1636
2514
|
}
|
|
1637
2515
|
atomicWriteFileSync(targetPath, markdown);
|
|
2516
|
+
// Pending was just cleared on disk; the sidecar overlay must go too,
|
|
2517
|
+
// otherwise the next load would re-apply stale pending entries.
|
|
2518
|
+
// adr: adr/pending-overlay-model.md
|
|
2519
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
|
|
2520
|
+
if (docId)
|
|
2521
|
+
deleteOverlay(docId);
|
|
1638
2522
|
removePendingCacheEntry(filename);
|
|
1639
2523
|
}
|
|
1640
2524
|
catch { /* best-effort */ }
|
|
@@ -1658,10 +2542,25 @@ export function countPending(nodes) {
|
|
|
1658
2542
|
return count;
|
|
1659
2543
|
}
|
|
1660
2544
|
/** Write a mutated doc back to disk and update the pending cache. */
|
|
2545
|
+
/** Write a mutated doc back to disk and update the pending cache.
|
|
2546
|
+
*
|
|
2547
|
+
* Disk gets canonical (pending stripped by `tiptapToMarkdown`). The
|
|
2548
|
+
* overlay sidecar gets the extracted pending entries — without this,
|
|
2549
|
+
* any pending content on `doc` vanishes between strip and disk write.
|
|
2550
|
+
* This mirrors writeToDisk's active-doc path; the foundation commit
|
|
2551
|
+
* established the contract there but missed this non-active-doc
|
|
2552
|
+
* callsite. Without symmetric overlay save, populate_document /
|
|
2553
|
+
* write_to_pad on a non-active doc silently dropped pending content.
|
|
2554
|
+
* adr: adr/pending-overlay-model.md */
|
|
1661
2555
|
function flushDocToFile(filename, doc, title, metadata) {
|
|
1662
2556
|
const targetPath = resolveDocPath(filename);
|
|
1663
2557
|
const markdown = tiptapToMarkdown(doc, title, metadata);
|
|
1664
2558
|
atomicWriteFileSync(targetPath, markdown);
|
|
2559
|
+
const docId = (metadata && typeof metadata.docId === 'string') ? metadata.docId : '';
|
|
2560
|
+
if (docId) {
|
|
2561
|
+
const overlay = extractOverlay(doc);
|
|
2562
|
+
saveOverlay(docId, overlay);
|
|
2563
|
+
}
|
|
1665
2564
|
setPendingCacheEntry(filename, countPending(doc.content));
|
|
1666
2565
|
}
|
|
1667
2566
|
export function populateDocumentFile(filename, doc) {
|
|
@@ -1762,8 +2661,12 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
|
|
|
1762
2661
|
return { success: false, error: `Node ${nodeId} not found` };
|
|
1763
2662
|
const originalNode = found.parent[found.index];
|
|
1764
2663
|
const result = applyTextEditsToNode(originalNode, edits);
|
|
1765
|
-
if (!result)
|
|
1766
|
-
|
|
2664
|
+
if (!result) {
|
|
2665
|
+
const nodeText = extractText(originalNode.content || []);
|
|
2666
|
+
const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
|
|
2667
|
+
const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
|
|
2668
|
+
return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
|
|
2669
|
+
}
|
|
1767
2670
|
const autoAccept = isAutoAcceptActive(filename, metadata);
|
|
1768
2671
|
// pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
|
|
1769
2672
|
// since the change commits directly.
|