openwriter 0.14.0 → 0.16.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-CbSQ8xxn.css +1 -0
- package/dist/client/assets/index-JMMJM_G_.js +212 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +206 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +141 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +66 -0
- package/dist/plugins/publish/dist/helpers.js +199 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +1130 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +394 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +240 -0
- package/dist/plugins/x-api/package.json +27 -0
- package/dist/server/comments.js +256 -0
- package/dist/server/documents.js +293 -20
- package/dist/server/enrichment.js +114 -0
- package/dist/server/helpers.js +63 -8
- package/dist/server/index.js +94 -40
- package/dist/server/install-skill.js +15 -0
- package/dist/server/logger.js +246 -0
- package/dist/server/markdown-parse.js +71 -14
- package/dist/server/markdown-serialize.js +136 -41
- package/dist/server/mcp.js +538 -99
- package/dist/server/node-blocks.js +22 -4
- package/dist/server/node-fingerprint.js +347 -73
- package/dist/server/node-matcher.js +76 -49
- package/dist/server/pending-overlay.js +862 -0
- package/dist/server/state.js +1178 -98
- package/dist/server/versions.js +18 -0
- package/dist/server/workspaces.js +42 -5
- package/dist/server/ws.js +194 -37
- package/package.json +1 -1
- package/skill/SKILL.md +51 -21
- package/skill/agents/openwriter-enrichment-minion.md +184 -0
- package/skill/docs/enrichment.md +179 -0
- 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,51 +3,85 @@
|
|
|
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 { anyLegacyRaw } from './node-fingerprint.js';
|
|
18
|
+
import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markdown-parse.js';
|
|
19
|
+
import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
|
|
20
|
+
import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
|
|
17
21
|
/** Read the persisted identity graph (nodes + graveyard) from a file's
|
|
18
|
-
* frontmatter.
|
|
19
|
-
* the disk is the source of truth, not
|
|
20
|
-
*
|
|
22
|
+
* frontmatter. The save-time matcher reads previousNodes + graveyard
|
|
23
|
+
* directly from disk every write — the disk is the source of truth, not
|
|
24
|
+
* a parallel in-memory cache.
|
|
25
|
+
*
|
|
26
|
+
* Slim disk entries are enriched against the freshly-parsed disk body so
|
|
27
|
+
* derived fields (position, neighbor types, etc.) flow into the rich
|
|
28
|
+
* Fingerprint the matcher expects. Legacy verbose-object entries are
|
|
29
|
+
* positionally re-fingerprinted via the same helper.
|
|
30
|
+
* adr: adr/node-identity-matcher.md */
|
|
21
31
|
function readPersistedIdentity(filePath) {
|
|
22
32
|
if (!filePath || !existsSync(filePath))
|
|
23
33
|
return { previousNodes: [], graveyard: [] };
|
|
24
34
|
try {
|
|
25
35
|
const raw = readFileSync(filePath, 'utf-8');
|
|
26
|
-
|
|
36
|
+
// Bypass gray-matter for identity reads. gray-matter caches its parsed
|
|
37
|
+
// `data` object by raw string within a process, so any upstream
|
|
38
|
+
// mutation (test wrappers, dev tools) leaks into the matcher's input
|
|
39
|
+
// on subsequent reads. Identity fields live in a JSON frontmatter
|
|
40
|
+
// block emitted by tiptapToMarkdown — parse it directly so we always
|
|
41
|
+
// see fresh data.
|
|
42
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
43
|
+
let rawNodes = [];
|
|
44
|
+
let rawGraveyard = [];
|
|
45
|
+
let body = raw;
|
|
46
|
+
if (fmMatch) {
|
|
47
|
+
try {
|
|
48
|
+
const fmObj = JSON.parse(fmMatch[1]);
|
|
49
|
+
if (Array.isArray(fmObj.nodes))
|
|
50
|
+
rawNodes = fmObj.nodes;
|
|
51
|
+
if (Array.isArray(fmObj.graveyard))
|
|
52
|
+
rawGraveyard = fmObj.graveyard;
|
|
53
|
+
}
|
|
54
|
+
catch { /* malformed JSON frontmatter — treat as no identity */ }
|
|
55
|
+
body = raw.slice(fmMatch[0].length).replace(/^[\r\n]+/, '');
|
|
56
|
+
}
|
|
57
|
+
// Slim entries derive position/parent/neighbors from the slim array
|
|
58
|
+
// itself — no body parse needed. Legacy entries need positional
|
|
59
|
+
// re-fingerprinting from the body, so we parse it lazily only then.
|
|
60
|
+
// This avoids a full markdown re-parse on every save for the common
|
|
61
|
+
// (ultra-lean) case, which was the dominant cost of switchDocument's
|
|
62
|
+
// pre-switch save() for large docs.
|
|
63
|
+
const needsBodyParse = (Array.isArray(rawNodes) && rawNodes.length > 0 && anyLegacyRaw(rawNodes));
|
|
64
|
+
let previousBlocks = [];
|
|
65
|
+
if (needsBodyParse) {
|
|
66
|
+
const previousDocContent = markdownToNodes(body);
|
|
67
|
+
previousBlocks = tiptapToBlocks({ content: previousDocContent });
|
|
68
|
+
}
|
|
27
69
|
return {
|
|
28
|
-
previousNodes:
|
|
29
|
-
graveyard:
|
|
70
|
+
previousNodes: resolvePreviousNodes(rawNodes, previousBlocks),
|
|
71
|
+
graveyard: resolveGraveyard(rawGraveyard),
|
|
30
72
|
};
|
|
31
73
|
}
|
|
32
74
|
catch {
|
|
33
75
|
return { previousNodes: [], graveyard: [] };
|
|
34
76
|
}
|
|
35
77
|
}
|
|
36
|
-
/** Defensive parse of frontmatter node entries — drops any malformed rows.
|
|
37
|
-
* Mirrors the same-named helper in markdown-parse.ts so save and load apply
|
|
38
|
-
* identical validation. */
|
|
39
|
-
function normalizeNodeEntries(raw) {
|
|
40
|
-
if (!Array.isArray(raw))
|
|
41
|
-
return [];
|
|
42
|
-
return raw
|
|
43
|
-
.filter((entry) => entry && typeof entry === 'object' && entry.id && entry.fp)
|
|
44
|
-
.map((entry) => ({ id: String(entry.id), fingerprint: entry.fp }));
|
|
45
|
-
}
|
|
46
78
|
const DEFAULT_DOC = {
|
|
47
79
|
type: 'doc',
|
|
48
80
|
content: [{ type: 'paragraph', content: [] }],
|
|
49
81
|
};
|
|
50
82
|
let state = {
|
|
83
|
+
canonical: DEFAULT_DOC,
|
|
84
|
+
overlay: new Map(),
|
|
51
85
|
document: DEFAULT_DOC,
|
|
52
86
|
title: 'Untitled',
|
|
53
87
|
metadata: { title: 'Untitled' },
|
|
@@ -56,37 +90,393 @@ let state = {
|
|
|
56
90
|
lastModified: new Date(),
|
|
57
91
|
docId: '',
|
|
58
92
|
originalFrontmatter: null,
|
|
93
|
+
loadedMtime: 0,
|
|
59
94
|
};
|
|
95
|
+
// ============================================================================
|
|
96
|
+
// PRIMARY-STATE WRITE HELPERS
|
|
97
|
+
// ============================================================================
|
|
98
|
+
// All mutations to canonical or overlay go through these helpers. Each updates
|
|
99
|
+
// the underlying state AND recomputes state.document so external readers via
|
|
100
|
+
// getDocument() see the new merged view. Direct assignment to state.canonical,
|
|
101
|
+
// state.overlay, or state.document is forbidden outside this module.
|
|
102
|
+
// adr: adr/pending-overlay-model.md
|
|
103
|
+
/** Recompute the merged view from primary state. Idempotent — running twice
|
|
104
|
+
* produces the same result. */
|
|
105
|
+
function recomputeMerged() {
|
|
106
|
+
state.document = applyOverlayPure(state.canonical, Array.from(state.overlay.values()));
|
|
107
|
+
}
|
|
108
|
+
/** Replace canonical wholesale. Used by load paths and accept-all flows. */
|
|
109
|
+
function setCanonical(doc) {
|
|
110
|
+
state.canonical = doc;
|
|
111
|
+
recomputeMerged();
|
|
112
|
+
}
|
|
113
|
+
/** Replace overlay wholesale from an entry array. Dedupes by nodeId
|
|
114
|
+
* (first occurrence wins; preserves original anchors). Preserves
|
|
115
|
+
* addedAtVersion from any pre-existing state.overlay entry with the
|
|
116
|
+
* same nodeId; stamps current docVersion on entries that are new. */
|
|
117
|
+
function setOverlayFromEntries(entries) {
|
|
118
|
+
const currentVersion = getDocVersion();
|
|
119
|
+
const newOverlay = new Map();
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
if (newOverlay.has(e.nodeId))
|
|
122
|
+
continue;
|
|
123
|
+
const existing = state.overlay.get(e.nodeId);
|
|
124
|
+
const entry = { ...e };
|
|
125
|
+
if (existing?.addedAtVersion !== undefined) {
|
|
126
|
+
entry.addedAtVersion = existing.addedAtVersion;
|
|
127
|
+
}
|
|
128
|
+
else if (entry.addedAtVersion === undefined) {
|
|
129
|
+
entry.addedAtVersion = currentVersion;
|
|
130
|
+
}
|
|
131
|
+
newOverlay.set(e.nodeId, entry);
|
|
132
|
+
}
|
|
133
|
+
state.overlay = newOverlay;
|
|
134
|
+
recomputeMerged();
|
|
135
|
+
}
|
|
136
|
+
/** Replace primary state from a merged-shape doc. Splits via splitMergedDoc.
|
|
137
|
+
* Used by paths that receive a merged doc (browser doc-updates, legacy
|
|
138
|
+
* on-disk migration). */
|
|
139
|
+
function setPrimaryFromMerged(merged) {
|
|
140
|
+
const { canonical, overlayEntries } = splitMergedDoc(merged);
|
|
141
|
+
state.canonical = canonical;
|
|
142
|
+
setOverlayFromEntries(overlayEntries);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Sync routing for a stale-version browser doc-update. The browser's
|
|
146
|
+
* submission was captured at server version `browserVersion`; the server
|
|
147
|
+
* has since advanced to a higher version because the agent wrote concurrently.
|
|
148
|
+
*
|
|
149
|
+
* Behavior: the browser's view of canonical is accepted as authoritative.
|
|
150
|
+
* The overlay merges browser's view with server's recent additions: any
|
|
151
|
+
* server overlay entry with addedAtVersion > browserVersion is an agent
|
|
152
|
+
* addition the browser hadn't seen, so it survives. Conflicting nodeIds
|
|
153
|
+
* (both browser and server have entries) → server wins, on the principle
|
|
154
|
+
* that an explicit agent proposal outranks a browser save that didn't see
|
|
155
|
+
* it. The user can reject the server's entry through the normal review UI
|
|
156
|
+
* if they disagree.
|
|
157
|
+
*
|
|
158
|
+
* Replaces the older "BLOCK stale doc-update" behavior — that pattern lost
|
|
159
|
+
* the user's typing without warning. The merge approach preserves both
|
|
160
|
+
* sides' work in the common case (disjoint touches) and surfaces conflicts
|
|
161
|
+
* (same-paragraph touches) via the existing pending-review UI.
|
|
162
|
+
*
|
|
163
|
+
* Returns the count of server overlay entries that were preserved.
|
|
164
|
+
* adr: adr/pending-overlay-model.md
|
|
165
|
+
*/
|
|
166
|
+
export function syncBrowserDocUpdate(browserDoc, browserVersion) {
|
|
167
|
+
const { canonical: browserCanonical, overlayEntries: browserOverlay } = splitMergedDoc(browserDoc);
|
|
168
|
+
// Identify server overlay entries to preserve: those added after browser's baseline.
|
|
169
|
+
const preserved = [];
|
|
170
|
+
for (const [, entry] of state.overlay) {
|
|
171
|
+
const added = entry.addedAtVersion ?? 0;
|
|
172
|
+
if (added > browserVersion)
|
|
173
|
+
preserved.push(entry);
|
|
174
|
+
}
|
|
175
|
+
// Build the merged overlay. Browser's view first; server-preserved entries
|
|
176
|
+
// overwrite (server wins on conflict).
|
|
177
|
+
const merged = new Map();
|
|
178
|
+
for (const e of browserOverlay) {
|
|
179
|
+
if (!merged.has(e.nodeId))
|
|
180
|
+
merged.set(e.nodeId, e);
|
|
181
|
+
}
|
|
182
|
+
for (const e of preserved) {
|
|
183
|
+
merged.set(e.nodeId, e);
|
|
184
|
+
}
|
|
185
|
+
// Apply: browser's canonical view + merged overlay.
|
|
186
|
+
state.canonical = browserCanonical;
|
|
187
|
+
setOverlayFromEntries(Array.from(merged.values()));
|
|
188
|
+
return { preservedServerEntries: preserved.length };
|
|
189
|
+
}
|
|
60
190
|
const listeners = new Set();
|
|
191
|
+
const idRewriteListeners = new Set();
|
|
192
|
+
const externalWriteConflictListeners = new Set();
|
|
193
|
+
export function onExternalWriteConflict(listener) {
|
|
194
|
+
externalWriteConflictListeners.add(listener);
|
|
195
|
+
return () => externalWriteConflictListeners.delete(listener);
|
|
196
|
+
}
|
|
197
|
+
function notifyExternalWriteConflict(filePath, diskMtime, loadedMtime) {
|
|
198
|
+
for (const listener of externalWriteConflictListeners) {
|
|
199
|
+
try {
|
|
200
|
+
listener({ filePath, diskMtime, loadedMtime });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
console.error('[State] external-write listener threw:', err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Check whether the active doc's on-disk mtime is newer than what we
|
|
209
|
+
* loaded/saved. Returns null when the doc has no file path, the file is
|
|
210
|
+
* gone, or there's no drift. Exposed for the get_pad_status MCP tool so
|
|
211
|
+
* agents can detect "you need to reload_from_disk before your next save"
|
|
212
|
+
* without waiting for the save itself to fail.
|
|
213
|
+
*/
|
|
214
|
+
export function getExternalMtimeDrift() {
|
|
215
|
+
if (!state.filePath || state.loadedMtime === 0)
|
|
216
|
+
return null;
|
|
217
|
+
try {
|
|
218
|
+
const diskMtime = statSync(state.filePath).mtimeMs;
|
|
219
|
+
if (diskMtime !== state.loadedMtime) {
|
|
220
|
+
return { diskMtime, loadedMtime: state.loadedMtime };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch { /* file missing */ }
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/** Force-refresh the active doc's loadedMtime snapshot to current disk mtime.
|
|
227
|
+
* Used by reload_from_disk after re-reading the file, so the freshly
|
|
228
|
+
* adopted content's mtime becomes our new baseline. */
|
|
229
|
+
export function refreshLoadedMtime() {
|
|
230
|
+
if (!state.filePath)
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
234
|
+
}
|
|
235
|
+
catch { /* best-effort */ }
|
|
236
|
+
}
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// ACTIVE DOC FILE WATCHER
|
|
239
|
+
// ============================================================================
|
|
240
|
+
//
|
|
241
|
+
// Watches the currently-active doc's file for external writes. When any
|
|
242
|
+
// other process (Edit tool, VSCode, a script) mutates the file, fs.watch
|
|
243
|
+
// fires; we debounce burst events, verify the disk mtime actually advanced
|
|
244
|
+
// past our last-stamped `loadedMtime`, and route through the unified
|
|
245
|
+
// reloadActiveDocFromDisk pathway — which re-parses disk, re-attaches the
|
|
246
|
+
// pending overlay by nodeId, runs the matcher, and lets subscribers
|
|
247
|
+
// broadcast a document-reloaded message to clients.
|
|
248
|
+
//
|
|
249
|
+
// Why push (fs.watch) instead of pull (mtime poll on writes only):
|
|
250
|
+
// - The browser autosave race was: external editor writes → server's
|
|
251
|
+
// in-memory state is now stale → browser sends an autosave from BEFORE
|
|
252
|
+
// the external write → server's version counter hasn't advanced (no
|
|
253
|
+
// MCP write occurred) → version check passes → stale content clobbers
|
|
254
|
+
// the external write.
|
|
255
|
+
// - The fix is to advance docVersion the instant the file changes on
|
|
256
|
+
// disk, not the instant we try to save. fs.watch makes that immediate.
|
|
257
|
+
//
|
|
258
|
+
// Single watcher at a time: we only care about the active doc. Switching
|
|
259
|
+
// docs tears down the previous watcher and opens a new one.
|
|
260
|
+
//
|
|
261
|
+
// Cross-platform: Node's fs.watch is inotify on Linux, FSEvents on macOS,
|
|
262
|
+
// ReadDirectoryChangesW on Windows. Single-file watching is reliable on
|
|
263
|
+
// all three; chokidar would add value for recursive directory trees, not
|
|
264
|
+
// here.
|
|
265
|
+
//
|
|
266
|
+
// adr: adr/active-doc-watcher.md
|
|
267
|
+
let activeWatcher = null;
|
|
268
|
+
let activeWatcherPath = '';
|
|
269
|
+
let watcherDebounceTimer = null;
|
|
270
|
+
const documentReloadedListeners = new Set();
|
|
271
|
+
export function onDocumentReloaded(listener) {
|
|
272
|
+
documentReloadedListeners.add(listener);
|
|
273
|
+
return () => documentReloadedListeners.delete(listener);
|
|
274
|
+
}
|
|
275
|
+
function notifyDocumentReloaded(event) {
|
|
276
|
+
for (const listener of documentReloadedListeners) {
|
|
277
|
+
try {
|
|
278
|
+
listener(event);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
console.error('[State] document-reloaded listener threw:', err);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Tear down the active-doc watcher (if any) and clear bookkeeping. */
|
|
286
|
+
function stopActiveDocWatcher() {
|
|
287
|
+
if (watcherDebounceTimer) {
|
|
288
|
+
clearTimeout(watcherDebounceTimer);
|
|
289
|
+
watcherDebounceTimer = null;
|
|
290
|
+
}
|
|
291
|
+
if (activeWatcher) {
|
|
292
|
+
try {
|
|
293
|
+
activeWatcher.close();
|
|
294
|
+
}
|
|
295
|
+
catch { /* best-effort */ }
|
|
296
|
+
activeWatcher = null;
|
|
297
|
+
}
|
|
298
|
+
activeWatcherPath = '';
|
|
299
|
+
}
|
|
300
|
+
/** Handle a watcher event after debounce — reload if mtime actually advanced. */
|
|
301
|
+
function handleWatcherEvent() {
|
|
302
|
+
// Only act if the watched path is still the active doc. If the user
|
|
303
|
+
// switched away during the debounce window, drop this event — the new
|
|
304
|
+
// active doc has its own watcher already running.
|
|
305
|
+
if (!state.filePath || state.filePath !== activeWatcherPath)
|
|
306
|
+
return;
|
|
307
|
+
// Skip if the file is gone (e.g., delete during watch). The next save
|
|
308
|
+
// will re-create it from in-memory state.
|
|
309
|
+
if (!existsSync(state.filePath))
|
|
310
|
+
return;
|
|
311
|
+
let diskMtime;
|
|
312
|
+
try {
|
|
313
|
+
diskMtime = statSync(state.filePath).mtimeMs;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Filter out events from our own writes. writeToDisk re-stamps
|
|
319
|
+
// state.loadedMtime to the post-write disk mtime, so by the time this
|
|
320
|
+
// handler fires for our own atomicWriteFileSync, mtimes match and we
|
|
321
|
+
// skip. Only genuine external writes have diskMtime > loadedMtime.
|
|
322
|
+
if (diskMtime === state.loadedMtime)
|
|
323
|
+
return;
|
|
324
|
+
const reloaded = reloadActiveDocFromDisk();
|
|
325
|
+
if (!reloaded)
|
|
326
|
+
return;
|
|
327
|
+
// (docVersion already bumped inside reloadActiveDocFromDisk — single
|
|
328
|
+
// source of truth for the reload-version-bump.)
|
|
329
|
+
notifyDocumentReloaded({
|
|
330
|
+
filePath: state.filePath,
|
|
331
|
+
filename: reloaded.filename,
|
|
332
|
+
document: reloaded.document,
|
|
333
|
+
title: reloaded.title,
|
|
334
|
+
docId: state.docId,
|
|
335
|
+
metadata: state.metadata,
|
|
336
|
+
orphans: reloaded.orphans,
|
|
337
|
+
staleBaseline: reloaded.staleBaseline,
|
|
338
|
+
});
|
|
339
|
+
const orphanCount = reloaded.orphans.length;
|
|
340
|
+
const staleCount = reloaded.staleBaseline.length;
|
|
341
|
+
const suffix = orphanCount || staleCount
|
|
342
|
+
? ` (${orphanCount} orphan, ${staleCount} stale-baseline pending entries)`
|
|
343
|
+
: '';
|
|
344
|
+
console.log(`[State] reload active doc from external write: ${reloaded.filename}${suffix}`);
|
|
345
|
+
}
|
|
346
|
+
/** Start (or restart) the watcher on the active doc's filePath. Called
|
|
347
|
+
* whenever the active doc changes, or whenever load() lands a file. */
|
|
348
|
+
export function startActiveDocWatcher() {
|
|
349
|
+
stopActiveDocWatcher();
|
|
350
|
+
if (!state.filePath || !existsSync(state.filePath))
|
|
351
|
+
return;
|
|
352
|
+
// Some test environments (Windows CI, ephemeral filesystems) error from
|
|
353
|
+
// fs.watch on transient files. Swallow the failure — we'll simply not
|
|
354
|
+
// have external-write detection for this doc, which is degraded but
|
|
355
|
+
// not broken. writeToDisk still has the loadedMtime guard as a backstop.
|
|
356
|
+
try {
|
|
357
|
+
activeWatcherPath = state.filePath;
|
|
358
|
+
activeWatcher = watch(state.filePath, { persistent: false }, () => {
|
|
359
|
+
// Burst-debounce: editors often write through a temp file + rename,
|
|
360
|
+
// which fires multiple events in rapid succession. 80ms is short
|
|
361
|
+
// enough that human-perceptible latency is unchanged and long
|
|
362
|
+
// enough that one logical save coalesces.
|
|
363
|
+
if (watcherDebounceTimer)
|
|
364
|
+
clearTimeout(watcherDebounceTimer);
|
|
365
|
+
watcherDebounceTimer = setTimeout(handleWatcherEvent, 80);
|
|
366
|
+
});
|
|
367
|
+
activeWatcher.on('error', (err) => {
|
|
368
|
+
console.error(`[State] active doc watcher error on ${activeWatcherPath}:`, err.message);
|
|
369
|
+
stopActiveDocWatcher();
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
console.error(`[State] failed to watch ${state.filePath}:`, err?.message || err);
|
|
374
|
+
stopActiveDocWatcher();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
61
377
|
// ============================================================================
|
|
62
378
|
// EXTERNAL DOCUMENT REGISTRY
|
|
63
379
|
// ============================================================================
|
|
64
380
|
function getExternalDocsFile() { return join(getDataDir(), 'external-docs.json'); }
|
|
381
|
+
/** External docs registered with openwriter — canonicalized paths only.
|
|
382
|
+
* Adding via `registerExternalDoc` runs paths through `canonicalizePath`
|
|
383
|
+
* before insertion, so the same physical file via any spelling
|
|
384
|
+
* (forward/back slash, mixed case drive letter, symlink) collapses to a
|
|
385
|
+
* single entry.
|
|
386
|
+
* adr: adr/path-canonicalization.md */
|
|
65
387
|
const externalDocs = new Set();
|
|
388
|
+
// ============================================================================
|
|
389
|
+
// AGENT-STUB REGISTRY (in-memory only — never persisted)
|
|
390
|
+
// ============================================================================
|
|
391
|
+
//
|
|
392
|
+
// A doc is an "agent stub" between create_document (which mints an empty
|
|
393
|
+
// shell) and populate_document + accept (which fills it with content). If
|
|
394
|
+
// the user rejects the populated content, the stub is deleted — the agent
|
|
395
|
+
// proposed a doc, the user declined, nothing should remain.
|
|
396
|
+
//
|
|
397
|
+
// HISTORICAL MISTAKE: stub status was stored as `agentCreated: true` in
|
|
398
|
+
// frontmatter on disk. That made it sticky across sessions, server
|
|
399
|
+
// restarts, and arbitrary file lifetimes. Any reject-all on a doc whose
|
|
400
|
+
// stub status had been forgotten in a previous flow would destructively
|
|
401
|
+
// delete a file with hours of accepted work. The fix is not "guard the
|
|
402
|
+
// destruction more carefully"; the fix is "don't persist transient
|
|
403
|
+
// session state to disk in the first place."
|
|
404
|
+
//
|
|
405
|
+
// Stub status is now an in-memory Set<filename> with process lifetime.
|
|
406
|
+
// - markAsAgentStub(filename) — called on create_document
|
|
407
|
+
// - unmarkAgentStub(filename) — called on any save that contains
|
|
408
|
+
// non-pending content (graduation), on accept-all, on rename
|
|
409
|
+
// - isAgentStub(filename) — the only thing reject-all-deletes consults
|
|
410
|
+
//
|
|
411
|
+
// A stub that survives a server restart is by definition no longer fresh.
|
|
412
|
+
// It loads from disk like any other doc and reject-all will not delete it.
|
|
413
|
+
// This is intentional: graduating-by-lifetime is the safest fallback.
|
|
414
|
+
//
|
|
415
|
+
// adr: adr/agent-stub-model.md
|
|
416
|
+
const agentStubFilenames = new Set();
|
|
417
|
+
export function markAsAgentStub(filename) {
|
|
418
|
+
if (filename)
|
|
419
|
+
agentStubFilenames.add(filename);
|
|
420
|
+
}
|
|
421
|
+
export function unmarkAgentStub(filename) {
|
|
422
|
+
if (filename)
|
|
423
|
+
agentStubFilenames.delete(filename);
|
|
424
|
+
}
|
|
425
|
+
export function isAgentStub(filename) {
|
|
426
|
+
return !!filename && agentStubFilenames.has(filename);
|
|
427
|
+
}
|
|
428
|
+
/** Legacy migration. If a file's frontmatter has `agentCreated: true`, that
|
|
429
|
+
* field came from the pre-architectural-fix code path. Strip it on load so
|
|
430
|
+
* we don't keep round-tripping a dead field. No in-memory stub registration
|
|
431
|
+
* happens — by definition, if the flag survived to disk this long, the doc
|
|
432
|
+
* is not a fresh stub anymore. */
|
|
433
|
+
function stripLegacyAgentCreated(metadata) {
|
|
434
|
+
if (metadata && 'agentCreated' in metadata)
|
|
435
|
+
delete metadata.agentCreated;
|
|
436
|
+
}
|
|
66
437
|
function persistExternalDocs() {
|
|
67
438
|
try {
|
|
68
439
|
atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
|
|
69
440
|
}
|
|
70
441
|
catch { /* best-effort */ }
|
|
71
442
|
}
|
|
443
|
+
/** Load external-doc registry from disk and canonicalize every entry on
|
|
444
|
+
* the way in. The Set's branded type collapses any pre-existing
|
|
445
|
+
* duplicates (forward-slash vs backslash entries for the same file)
|
|
446
|
+
* to one — that's the one-time migration. If canonicalization changed
|
|
447
|
+
* the on-disk representation, we re-persist so the file matches
|
|
448
|
+
* in-memory state.
|
|
449
|
+
* adr: adr/path-canonicalization.md */
|
|
72
450
|
function loadExternalDocs() {
|
|
73
451
|
try {
|
|
74
|
-
if (existsSync(getExternalDocsFile()))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
452
|
+
if (!existsSync(getExternalDocsFile()))
|
|
453
|
+
return;
|
|
454
|
+
const paths = JSON.parse(readFileSync(getExternalDocsFile(), 'utf-8'));
|
|
455
|
+
let needsRewrite = false;
|
|
456
|
+
for (const p of paths) {
|
|
457
|
+
if (!existsSync(p)) {
|
|
458
|
+
needsRewrite = true;
|
|
459
|
+
continue;
|
|
79
460
|
}
|
|
461
|
+
const canon = canonicalizePath(p);
|
|
462
|
+
if (canon !== p)
|
|
463
|
+
needsRewrite = true;
|
|
464
|
+
externalDocs.add(canon);
|
|
465
|
+
}
|
|
466
|
+
// If the on-disk file had duplicates or non-canonical entries, the
|
|
467
|
+
// collapsed Set is now smaller and/or different — persist it back.
|
|
468
|
+
if (needsRewrite || externalDocs.size !== paths.length) {
|
|
469
|
+
persistExternalDocs();
|
|
80
470
|
}
|
|
81
471
|
}
|
|
82
472
|
catch { /* corrupt file — start fresh */ }
|
|
83
473
|
}
|
|
84
474
|
export function registerExternalDoc(fullPath) {
|
|
85
|
-
externalDocs.add(fullPath);
|
|
475
|
+
externalDocs.add(canonicalizePath(fullPath));
|
|
86
476
|
persistExternalDocs();
|
|
87
477
|
}
|
|
88
478
|
export function unregisterExternalDoc(fullPath) {
|
|
89
|
-
externalDocs.delete(fullPath);
|
|
479
|
+
externalDocs.delete(canonicalizePath(fullPath));
|
|
90
480
|
persistExternalDocs();
|
|
91
481
|
}
|
|
92
482
|
export function getExternalDocs() {
|
|
@@ -110,6 +500,20 @@ function isDocEmpty(doc) {
|
|
|
110
500
|
export function getDocument() {
|
|
111
501
|
return state.document;
|
|
112
502
|
}
|
|
503
|
+
/** The clean canonical document — what disk holds, what the matcher pairs
|
|
504
|
+
* against, what snapshots capture. Primary state; not derived. */
|
|
505
|
+
export function getCanonical() {
|
|
506
|
+
return state.canonical;
|
|
507
|
+
}
|
|
508
|
+
/** Snapshot of the structured pending overlay. Primary state. */
|
|
509
|
+
export function getOverlayEntries() {
|
|
510
|
+
return Array.from(state.overlay.values());
|
|
511
|
+
}
|
|
512
|
+
/** Read-only view of the overlay Map. Use when you need keyed lookup
|
|
513
|
+
* rather than iteration. */
|
|
514
|
+
export function getOverlay() {
|
|
515
|
+
return state.overlay;
|
|
516
|
+
}
|
|
113
517
|
export function getTitle() {
|
|
114
518
|
return state.title;
|
|
115
519
|
}
|
|
@@ -409,8 +813,19 @@ export function updateDocument(doc) {
|
|
|
409
813
|
if (serverHadPending) {
|
|
410
814
|
transferPendingAttrs(state.document, doc);
|
|
411
815
|
}
|
|
412
|
-
|
|
816
|
+
// Route the incoming merged-shape doc through the primary-state setter
|
|
817
|
+
// so canonical + overlay get refreshed and state.document is the
|
|
818
|
+
// recomputed merged view. Direct assignment is forbidden.
|
|
819
|
+
setPrimaryFromMerged(doc);
|
|
413
820
|
state.lastModified = new Date();
|
|
821
|
+
// Bump docVersion so the writeToDisk no-op gate (which compares
|
|
822
|
+
// docVersion to lastSavedDocVersion) sees this mutation. Browser
|
|
823
|
+
// doc-updates flow through here, and without the bump the subsequent
|
|
824
|
+
// debouncedSave would short-circuit and the user's edits would never
|
|
825
|
+
// hit disk. The canonical contract: any path that mutates
|
|
826
|
+
// state.document MUST bump docVersion. applyChanges does the same.
|
|
827
|
+
// adr: adr/pending-overlay-model.md
|
|
828
|
+
bumpDocVersion();
|
|
414
829
|
// Validate: if server had pending changes, verify they survived the transfer
|
|
415
830
|
if (serverHadPending && !hasPendingChanges()) {
|
|
416
831
|
console.error('[State] WARNING: pending changes lost after updateDocument — browser doc-update overwrote pending attrs');
|
|
@@ -479,18 +894,69 @@ function transferPendingAttrs(source, target) {
|
|
|
479
894
|
// ============================================================================
|
|
480
895
|
// AGENT WRITE LOCK
|
|
481
896
|
// ============================================================================
|
|
897
|
+
// adr: adr/agent-lock-per-doc.md
|
|
482
898
|
const AGENT_LOCK_MS = 3000; // Block browser doc-updates for 3s after agent write
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
899
|
+
const lockExpiry = new Map();
|
|
900
|
+
let globalLockExpiry = 0;
|
|
901
|
+
/** Derive the identifier used for locking the active doc. Mirrors
|
|
902
|
+
* documents.ts:getActiveFilename without importing it (circular dep). */
|
|
903
|
+
function activeDocLockKey() {
|
|
904
|
+
const fp = state.filePath;
|
|
905
|
+
if (!fp)
|
|
906
|
+
return '';
|
|
907
|
+
if (isExternalDoc(fp))
|
|
908
|
+
return canonicalizeIdentifier(fp);
|
|
909
|
+
return fp.split(/[/\\]/).pop() || '';
|
|
910
|
+
}
|
|
911
|
+
/** Set the agent write lock for a specific document. */
|
|
912
|
+
export function setAgentLock(filename) {
|
|
913
|
+
if (!filename) {
|
|
914
|
+
// Defensive: empty filename means we don't know what to lock — lock global.
|
|
915
|
+
setAgentLockGlobal();
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const key = canonicalizeIdentifier(filename);
|
|
919
|
+
const wasActive = (lockExpiry.get(key) ?? 0) > Date.now();
|
|
920
|
+
lockExpiry.set(key, Date.now() + AGENT_LOCK_MS);
|
|
921
|
+
diagLog(`[Lock] SET filename=${key} ttl=${AGENT_LOCK_MS}ms${wasActive ? ' (extends active lock)' : ''}`);
|
|
922
|
+
}
|
|
923
|
+
/** Lock the currently active document. Convenience for callers that mutate
|
|
924
|
+
* via state.filePath rather than an explicit filename. */
|
|
925
|
+
export function setAgentLockActive() {
|
|
926
|
+
setAgentLock(activeDocLockKey());
|
|
927
|
+
}
|
|
928
|
+
/** Lock every document briefly. Used at server init so reconnecting browsers
|
|
929
|
+
* can't push stale state from before the restart. */
|
|
930
|
+
export function setAgentLockGlobal() {
|
|
931
|
+
globalLockExpiry = Date.now() + AGENT_LOCK_MS;
|
|
932
|
+
diagLog(`[Lock] SET global ttl=${AGENT_LOCK_MS}ms`);
|
|
933
|
+
}
|
|
934
|
+
/** Check if the agent write lock is active for a given document. */
|
|
935
|
+
export function isAgentLocked(filename) {
|
|
936
|
+
const now = Date.now();
|
|
937
|
+
if (globalLockExpiry > now)
|
|
938
|
+
return true;
|
|
939
|
+
if (!filename)
|
|
940
|
+
return false;
|
|
941
|
+
const key = canonicalizeIdentifier(filename);
|
|
942
|
+
const expiry = lockExpiry.get(key);
|
|
943
|
+
if (expiry === undefined)
|
|
944
|
+
return false;
|
|
945
|
+
if (expiry > now)
|
|
946
|
+
return true;
|
|
947
|
+
lockExpiry.delete(key);
|
|
948
|
+
return false;
|
|
491
949
|
}
|
|
492
950
|
// ---- Document version counter: prevents stale browser doc-updates ----
|
|
493
951
|
let docVersion = 0;
|
|
952
|
+
// Counterpart to docVersion: the docVersion at which we last confirmed
|
|
953
|
+
// in-memory state matches disk. save()/writeToDisk() is a strict no-op when
|
|
954
|
+
// these are equal (and a file exists on disk). This is the server-side
|
|
955
|
+
// counterpart to the client diff-gate in App.tsx — it makes doc-switches
|
|
956
|
+
// between unchanged docs free (no serialize, no sidecar write, no snapshot,
|
|
957
|
+
// no mtime bump that would invalidate the doc cache).
|
|
958
|
+
// adr: adr/pending-overlay-model.md
|
|
959
|
+
let lastSavedDocVersion = 0;
|
|
494
960
|
/** Increment version after agent writes. Returns the new version. */
|
|
495
961
|
export function bumpDocVersion() {
|
|
496
962
|
return ++docVersion;
|
|
@@ -503,14 +969,25 @@ export function getDocVersion() {
|
|
|
503
969
|
export function isVersionCurrent(browserVersion) {
|
|
504
970
|
return browserVersion >= docVersion;
|
|
505
971
|
}
|
|
506
|
-
/** Reset version on document switch (new document = new version lineage).
|
|
972
|
+
/** Reset version on document switch (new document = new version lineage).
|
|
973
|
+
* Both counters move together: the new doc was just loaded from disk (or
|
|
974
|
+
* cache, which mtime-validates against disk), so in-memory matches disk
|
|
975
|
+
* by definition. */
|
|
507
976
|
export function resetDocVersion() {
|
|
508
977
|
docVersion = 0;
|
|
978
|
+
lastSavedDocVersion = 0;
|
|
509
979
|
}
|
|
510
980
|
// ---- Debounced save: coalesces rapid agent writes into a single disk write ----
|
|
981
|
+
//
|
|
982
|
+
// Single timer for the entire process. Both state.ts (MCP write paths, applyChanges,
|
|
983
|
+
// updateDocument) and ws.ts (browser doc-update, pending-resolved) call into this.
|
|
984
|
+
// Previously each module had its own timer (state.ts 500ms, ws.ts 2s) which meant
|
|
985
|
+
// a save could be armed by one path, reset by another, and fire on a delay that
|
|
986
|
+
// matched neither documented value. One timer, one TTL — predictable.
|
|
987
|
+
// adr: adr/pending-overlay-model.md
|
|
511
988
|
let saveTimer = null;
|
|
512
989
|
const SAVE_DEBOUNCE_MS = 500;
|
|
513
|
-
function debouncedSave() {
|
|
990
|
+
export function debouncedSave() {
|
|
514
991
|
if (saveTimer)
|
|
515
992
|
clearTimeout(saveTimer);
|
|
516
993
|
saveTimer = setTimeout(() => {
|
|
@@ -530,7 +1007,7 @@ export function applyChanges(changes) {
|
|
|
530
1007
|
const processed = applyChangesToDocument(changes);
|
|
531
1008
|
// Bump version + lock browser doc-updates to prevent stale state overwrite
|
|
532
1009
|
const version = bumpDocVersion();
|
|
533
|
-
|
|
1010
|
+
setAgentLockActive();
|
|
534
1011
|
// Broadcast processed changes (with server-assigned IDs + version) to browser clients
|
|
535
1012
|
for (const listener of listeners) {
|
|
536
1013
|
listener(processed, version);
|
|
@@ -554,6 +1031,10 @@ export function applyChanges(changes) {
|
|
|
554
1031
|
}
|
|
555
1032
|
return { count: processed.length, lastNodeId };
|
|
556
1033
|
}
|
|
1034
|
+
export function onIdRewrites(listener) {
|
|
1035
|
+
idRewriteListeners.add(listener);
|
|
1036
|
+
return () => idRewriteListeners.delete(listener);
|
|
1037
|
+
}
|
|
557
1038
|
export function onChanges(listener) {
|
|
558
1039
|
listeners.add(listener);
|
|
559
1040
|
return () => listeners.delete(listener);
|
|
@@ -562,6 +1043,24 @@ export function onChanges(listener) {
|
|
|
562
1043
|
// SERVER-SIDE DOCUMENT MUTATIONS
|
|
563
1044
|
// ============================================================================
|
|
564
1045
|
// generateNodeId imported from helpers.ts
|
|
1046
|
+
/**
|
|
1047
|
+
* Containers whose internals are NOT addressable via MCP. `findNode` must not
|
|
1048
|
+
* descend into these — their child IDs are ephemeral (regenerated every
|
|
1049
|
+
* `markdownToTiptap` parse, untracked by the matcher) and never exposed via
|
|
1050
|
+
* `compactNodes`, so any agent-provided ID that happens to match a node inside
|
|
1051
|
+
* one is a collision, not a legitimate target.
|
|
1052
|
+
*
|
|
1053
|
+
* Before this guard existed, a `write_to_pad` rewrite could silently corrupt a
|
|
1054
|
+
* table cell: an agent ID collision with a freshly-minted table-internal node
|
|
1055
|
+
* routed the splice into the table instead of the intended top-level
|
|
1056
|
+
* paragraph. Reported as success, observable as stray paragraphs / mangled
|
|
1057
|
+
* rows in the saved markdown.
|
|
1058
|
+
*
|
|
1059
|
+
* Mirrors `node-blocks.ts`'s walker, which already treats tables as opaque.
|
|
1060
|
+
*
|
|
1061
|
+
* adr: adr/node-identity-matcher.md
|
|
1062
|
+
*/
|
|
1063
|
+
const OPAQUE_CONTAINER_TYPES = new Set(['table', 'tableRow', 'tableCell', 'tableHeader']);
|
|
565
1064
|
/**
|
|
566
1065
|
* Find a node by ID in any document tree.
|
|
567
1066
|
* topLevel is used to resolve the "end" sentinel.
|
|
@@ -577,6 +1076,11 @@ function findNode(nodes, id, topLevel) {
|
|
|
577
1076
|
if (nodes[i].attrs?.id === id) {
|
|
578
1077
|
return { parent: nodes, index: i };
|
|
579
1078
|
}
|
|
1079
|
+
// Don't descend into table internals (table, tableRow, tableCell, tableHeader).
|
|
1080
|
+
// Their IDs aren't addressable via MCP and they regenerate on every parse,
|
|
1081
|
+
// so any match inside is a collision that would silently corrupt the table.
|
|
1082
|
+
if (OPAQUE_CONTAINER_TYPES.has(nodes[i].type))
|
|
1083
|
+
continue;
|
|
580
1084
|
if (nodes[i].content && Array.isArray(nodes[i].content)) {
|
|
581
1085
|
const result = findNode(nodes[i].content, id, topLevel);
|
|
582
1086
|
if (result)
|
|
@@ -845,12 +1349,23 @@ export function isAutoAcceptActive(filename, metadata) {
|
|
|
845
1349
|
return false;
|
|
846
1350
|
return isAutoAcceptInheritedForDoc(filename);
|
|
847
1351
|
}
|
|
848
|
-
/** Apply changes to the active document singleton.
|
|
1352
|
+
/** Apply changes to the active document singleton.
|
|
1353
|
+
*
|
|
1354
|
+
* The applyChangesToDoc engine mutates the merged view (state.document) —
|
|
1355
|
+
* it's where the pending-status stamping logic lives. After the mutation,
|
|
1356
|
+
* re-split state.document back into primary state (canonical + overlay) so
|
|
1357
|
+
* the cache + matcher + save paths see consistent primary state. The
|
|
1358
|
+
* re-split is the bridge between the legacy "mutate merged in place" engine
|
|
1359
|
+
* and the new "primary state is canonical + overlay" model. */
|
|
849
1360
|
function applyChangesToDocument(changes) {
|
|
850
1361
|
const autoAccept = isAutoAcceptActive(activeDocFilename(), state.metadata);
|
|
851
1362
|
const processed = applyChangesToDoc(state.document, changes, autoAccept);
|
|
852
1363
|
if (processed.length > 0) {
|
|
853
1364
|
state.lastModified = new Date();
|
|
1365
|
+
// Re-sync primary state from the now-mutated merged view. Idempotent:
|
|
1366
|
+
// splitMergedDoc + applyOverlayPure round-trip leaves state.document
|
|
1367
|
+
// structurally equivalent to itself.
|
|
1368
|
+
setPrimaryFromMerged(state.document);
|
|
854
1369
|
}
|
|
855
1370
|
return processed;
|
|
856
1371
|
}
|
|
@@ -864,8 +1379,16 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
864
1379
|
return { success: false, error: `Node ${nodeId} not found` };
|
|
865
1380
|
const originalNode = found.parent[found.index];
|
|
866
1381
|
const result = applyTextEditsToNode(originalNode, edits);
|
|
867
|
-
if (!result)
|
|
868
|
-
|
|
1382
|
+
if (!result) {
|
|
1383
|
+
// Surface a slice of the actual node text alongside the searched `find`
|
|
1384
|
+
// strings so the agent can diff for unicode/whitespace mismatches
|
|
1385
|
+
// (em-dash vs hyphen-minus, NBSP vs space, smart quotes, etc.). Without
|
|
1386
|
+
// this the failure is opaque and the agent has to guess.
|
|
1387
|
+
const nodeText = extractText(originalNode.content || []);
|
|
1388
|
+
const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
|
|
1389
|
+
const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
|
|
1390
|
+
return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
|
|
1391
|
+
}
|
|
869
1392
|
// Inline edit decoration only matters when there's a review surface — skip in autoAccept.
|
|
870
1393
|
if (!isAutoAcceptActive(activeDocFilename(), state.metadata)) {
|
|
871
1394
|
result.node.attrs = {
|
|
@@ -888,14 +1411,42 @@ export function applyTextEdits(nodeId, edits) {
|
|
|
888
1411
|
* (Option B in adr/node-identity-matcher.md). Markdown is the source of
|
|
889
1412
|
* truth; memory is an ephemeral working copy. */
|
|
890
1413
|
export function setActiveDocument(doc, title, filePath, isTemp, lastModified, metadata, originalFrontmatter) {
|
|
891
|
-
state
|
|
1414
|
+
// Route the incoming doc through the primary-state setter — splits into
|
|
1415
|
+
// canonical + overlay (handles legacy in-frontmatter pending if present)
|
|
1416
|
+
// and recomputes the merged view.
|
|
1417
|
+
setPrimaryFromMerged(doc);
|
|
892
1418
|
state.title = title;
|
|
893
1419
|
state.metadata = metadata || { title };
|
|
894
|
-
|
|
1420
|
+
// Legacy: strip any pre-architectural-fix `agentCreated` field that
|
|
1421
|
+
// arrived in metadata (e.g. from a re-parse of an old on-disk file).
|
|
1422
|
+
// The in-memory agentStubFilenames Set is the only authority for stub
|
|
1423
|
+
// status — disk frontmatter must not carry stub state.
|
|
1424
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1425
|
+
// Canonicalize at the identity boundary: same physical file via any
|
|
1426
|
+
// spelling (forward/back slash, drive-letter case, symlink) lands the
|
|
1427
|
+
// same string in state.filePath, which is the cache key for the doc
|
|
1428
|
+
// cache and the subscription path for the fs watcher.
|
|
1429
|
+
// adr: adr/path-canonicalization.md
|
|
1430
|
+
state.filePath = filePath ? canonicalizePath(filePath) : '';
|
|
895
1431
|
state.isTemp = isTemp;
|
|
896
1432
|
state.lastModified = lastModified || new Date();
|
|
897
1433
|
state.docId = ensureDocId(state.metadata);
|
|
898
1434
|
state.originalFrontmatter = originalFrontmatter ?? null;
|
|
1435
|
+
// Snapshot the on-disk mtime so writeToDisk can detect external writes
|
|
1436
|
+
// that land while this doc is active. 0 = no file on disk yet (new doc).
|
|
1437
|
+
try {
|
|
1438
|
+
state.loadedMtime = filePath && existsSync(filePath) ? statSync(filePath).mtimeMs : 0;
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
state.loadedMtime = 0;
|
|
1442
|
+
}
|
|
1443
|
+
// Pending overlay rehydration. See mergeOverlayOnLoad for the three cases
|
|
1444
|
+
// (sidecar present, legacy migration, no pending).
|
|
1445
|
+
mergeOverlayOnLoad();
|
|
1446
|
+
// Subscribe the fs watcher to this doc so external writes (Edit tool,
|
|
1447
|
+
// VSCode, scripts) trigger a unified reload + version bump + broadcast.
|
|
1448
|
+
// adr: adr/active-doc-watcher.md
|
|
1449
|
+
startActiveDocWatcher();
|
|
899
1450
|
}
|
|
900
1451
|
// ============================================================================
|
|
901
1452
|
// PENDING DOCUMENT CACHE (avoids disk scans on every broadcast)
|
|
@@ -965,7 +1516,12 @@ function populatePendingCache() {
|
|
|
965
1516
|
catch { /* skip unreadable files */ }
|
|
966
1517
|
}
|
|
967
1518
|
}
|
|
968
|
-
|
|
1519
|
+
/** Keyed by canonical path. Two spellings of the same physical file
|
|
1520
|
+
* (forward/back slash, drive-letter case) collapse to one cache entry —
|
|
1521
|
+
* preventing parallel state where the same disk file lives in two
|
|
1522
|
+
* cache slots.
|
|
1523
|
+
* adr: adr/path-canonicalization.md */
|
|
1524
|
+
const docCache = new Map();
|
|
969
1525
|
/** Cache the active document's full state, keyed by filePath. Call after save().
|
|
970
1526
|
*
|
|
971
1527
|
* Identity (nodes + graveyard) is NOT cached — the save-time matcher reads
|
|
@@ -979,8 +1535,14 @@ export function cacheActiveDocument() {
|
|
|
979
1535
|
fileMtime = statSync(state.filePath).mtimeMs;
|
|
980
1536
|
}
|
|
981
1537
|
catch { /* file may not exist yet */ }
|
|
982
|
-
|
|
983
|
-
|
|
1538
|
+
// Split the live merged document into canonical + overlay AT cache time —
|
|
1539
|
+
// this is what protects against the duplicate-insert bug. Storing merged
|
|
1540
|
+
// and re-applying overlay later was the broken pattern.
|
|
1541
|
+
const split = splitMergedDoc(state.document);
|
|
1542
|
+
docCache.set(canonicalizePath(state.filePath), {
|
|
1543
|
+
canonical: split.canonical,
|
|
1544
|
+
overlayEntries: split.overlayEntries,
|
|
1545
|
+
document: applyOverlayPure(split.canonical, split.overlayEntries),
|
|
984
1546
|
metadata: structuredClone(state.metadata),
|
|
985
1547
|
title: state.title,
|
|
986
1548
|
isTemp: state.isTemp,
|
|
@@ -992,27 +1554,28 @@ export function cacheActiveDocument() {
|
|
|
992
1554
|
}
|
|
993
1555
|
/** Get a cached document if the file hasn't been modified externally. Returns null on miss or stale. */
|
|
994
1556
|
export function getCachedDocument(filePath) {
|
|
995
|
-
const
|
|
1557
|
+
const key = canonicalizePath(filePath);
|
|
1558
|
+
const cached = docCache.get(key);
|
|
996
1559
|
if (!cached)
|
|
997
1560
|
return null;
|
|
998
1561
|
try {
|
|
999
1562
|
const currentMtime = statSync(filePath).mtimeMs;
|
|
1000
1563
|
if (currentMtime !== cached.fileMtime) {
|
|
1001
1564
|
// File changed on disk — invalidate cache
|
|
1002
|
-
docCache.delete(
|
|
1565
|
+
docCache.delete(key);
|
|
1003
1566
|
return null;
|
|
1004
1567
|
}
|
|
1005
1568
|
}
|
|
1006
1569
|
catch {
|
|
1007
1570
|
// File doesn't exist or can't be read — invalidate
|
|
1008
|
-
docCache.delete(
|
|
1571
|
+
docCache.delete(key);
|
|
1009
1572
|
return null;
|
|
1010
1573
|
}
|
|
1011
1574
|
return cached;
|
|
1012
1575
|
}
|
|
1013
1576
|
/** Remove a specific file from the document cache. */
|
|
1014
1577
|
export function invalidateDocCache(filePath) {
|
|
1015
|
-
docCache.delete(filePath);
|
|
1578
|
+
docCache.delete(canonicalizePath(filePath));
|
|
1016
1579
|
}
|
|
1017
1580
|
/** Update the cache entry for a file after writing changes (without cloning the active state). */
|
|
1018
1581
|
export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId) {
|
|
@@ -1021,10 +1584,18 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
|
|
|
1021
1584
|
fileMtime = statSync(filePath).mtimeMs;
|
|
1022
1585
|
}
|
|
1023
1586
|
catch { /* best-effort */ }
|
|
1587
|
+
const key = canonicalizePath(filePath);
|
|
1024
1588
|
// Preserve originalFrontmatter from existing cache entry (if any)
|
|
1025
|
-
const existing = docCache.get(
|
|
1026
|
-
|
|
1027
|
-
|
|
1589
|
+
const existing = docCache.get(key);
|
|
1590
|
+
// Split the incoming doc into canonical + overlay before caching so the
|
|
1591
|
+
// cache stores canonical-only and the duplicate-insert chain is broken.
|
|
1592
|
+
const split = splitMergedDoc(doc);
|
|
1593
|
+
const overlayEntries = split.overlayEntries;
|
|
1594
|
+
const canonicalClone = split.canonical;
|
|
1595
|
+
docCache.set(key, {
|
|
1596
|
+
canonical: canonicalClone,
|
|
1597
|
+
overlayEntries,
|
|
1598
|
+
document: applyOverlayPure(canonicalClone, overlayEntries),
|
|
1028
1599
|
metadata: structuredClone(metadata),
|
|
1029
1600
|
title,
|
|
1030
1601
|
isTemp,
|
|
@@ -1036,10 +1607,15 @@ export function updateCacheEntry(filePath, doc, title, metadata, isTemp, docId)
|
|
|
1036
1607
|
}
|
|
1037
1608
|
/** Reset all in-memory caches. Called on profile switch. */
|
|
1038
1609
|
export function clearAllCaches() {
|
|
1610
|
+
// Tear down the active-doc watcher before swapping state — leaving it
|
|
1611
|
+
// alive would point at a path the new profile doesn't own.
|
|
1612
|
+
stopActiveDocWatcher();
|
|
1039
1613
|
docCache.clear();
|
|
1040
1614
|
pendingDocCache.clear();
|
|
1041
1615
|
externalDocs.clear();
|
|
1042
1616
|
state = {
|
|
1617
|
+
canonical: DEFAULT_DOC,
|
|
1618
|
+
overlay: new Map(),
|
|
1043
1619
|
document: DEFAULT_DOC,
|
|
1044
1620
|
title: 'Untitled',
|
|
1045
1621
|
metadata: { title: 'Untitled' },
|
|
@@ -1048,6 +1624,7 @@ export function clearAllCaches() {
|
|
|
1048
1624
|
lastModified: new Date(),
|
|
1049
1625
|
docId: '',
|
|
1050
1626
|
originalFrontmatter: null,
|
|
1627
|
+
loadedMtime: 0,
|
|
1051
1628
|
};
|
|
1052
1629
|
}
|
|
1053
1630
|
// ============================================================================
|
|
@@ -1069,29 +1646,252 @@ export function hasPendingChanges(doc) {
|
|
|
1069
1646
|
}
|
|
1070
1647
|
return scan(target.content);
|
|
1071
1648
|
}
|
|
1072
|
-
/** Strip all pending attrs from the current document (after browser resolves all changes).
|
|
1649
|
+
/** Strip all pending attrs from the current document (after browser resolves all changes).
|
|
1650
|
+
* In the new model this is implemented by clearing the overlay — the merged
|
|
1651
|
+
* view recomputes to canonical (which already has no pending markers). */
|
|
1073
1652
|
export function stripPendingAttrs() {
|
|
1074
|
-
|
|
1075
|
-
|
|
1653
|
+
state.overlay = new Map();
|
|
1654
|
+
recomputeMerged();
|
|
1655
|
+
removePendingCacheEntry(activeDocFilename());
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Return a deep clone of `doc` with all pending changes reverted, as if the
|
|
1659
|
+
* user had rejected every pending decoration:
|
|
1660
|
+
* - pendingStatus=insert → drop the node
|
|
1661
|
+
* - pendingStatus=rewrite → replace node with `pendingOriginalContent` (or drop if absent)
|
|
1662
|
+
* - pendingStatus=delete → keep node, clear pending attrs (the rejection is "no, don't delete")
|
|
1663
|
+
* - no pendingStatus → keep node, strip any stray pending attrs
|
|
1664
|
+
*
|
|
1665
|
+
* Used by `restore_version` to write a canonical-only safety checkpoint so
|
|
1666
|
+
* the snapshot represents a clean recovery point rather than a flattened
|
|
1667
|
+
* pending+canonical hybrid that never actually existed in the user's view.
|
|
1668
|
+
*
|
|
1669
|
+
* Does NOT mutate the input doc.
|
|
1670
|
+
*/
|
|
1671
|
+
/**
|
|
1672
|
+
* Reload the active doc from disk. Re-reads the canonical .md file, applies
|
|
1673
|
+
* the pending overlay from the sidecar (with orphan + stale-baseline
|
|
1674
|
+
* classification), and updates state.document, state.metadata, state.title,
|
|
1675
|
+
* and state.loadedMtime in place.
|
|
1676
|
+
*
|
|
1677
|
+
* Called by:
|
|
1678
|
+
* - chokidar watcher when an external write modifies the active file
|
|
1679
|
+
* - mcp.restore_version after writing the snapshot's content to disk
|
|
1680
|
+
* - mcp.reload_from_disk tool (explicit user-driven reload)
|
|
1681
|
+
* - writeToDisk's mtime-CAS backstop (recover-and-retry path)
|
|
1682
|
+
*
|
|
1683
|
+
* Returns the reload result so callers can broadcast or react to the
|
|
1684
|
+
* orphan / staleBaseline classifications. Returns null if there's no
|
|
1685
|
+
* active file path to reload (no-op).
|
|
1686
|
+
*
|
|
1687
|
+
* adr: adr/pending-overlay-model.md
|
|
1688
|
+
*/
|
|
1689
|
+
export function reloadActiveDocFromDisk() {
|
|
1690
|
+
if (!state.filePath || !existsSync(state.filePath))
|
|
1691
|
+
return null;
|
|
1692
|
+
const raw = readFileSync(state.filePath, 'utf-8');
|
|
1693
|
+
const parsed = markdownToTiptap(raw);
|
|
1694
|
+
// Split parsed doc: canonical (clean) + any legacy in-frontmatter pending.
|
|
1695
|
+
const { canonical, overlayEntries: legacyOverlay } = splitMergedDoc(parsed.document);
|
|
1696
|
+
state.title = parsed.title;
|
|
1697
|
+
state.metadata = parsed.metadata;
|
|
1698
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1699
|
+
state.docId = ensureDocId(state.metadata);
|
|
1700
|
+
state.lastModified = new Date(statSync(state.filePath).mtimeMs);
|
|
1701
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
1702
|
+
// Sidecar is authoritative if present. Otherwise legacy in-doc pending
|
|
1703
|
+
// migrates to sidecar (one-time).
|
|
1704
|
+
const sidecar = loadOverlay(state.docId);
|
|
1705
|
+
let entries;
|
|
1706
|
+
if (sidecar.length > 0) {
|
|
1707
|
+
entries = sidecar;
|
|
1708
|
+
}
|
|
1709
|
+
else if (legacyOverlay.length > 0) {
|
|
1710
|
+
entries = legacyOverlay;
|
|
1711
|
+
saveOverlay(state.docId, legacyOverlay);
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
entries = [];
|
|
1715
|
+
}
|
|
1716
|
+
// Set primary state directly — canonical from disk parse, overlay from
|
|
1717
|
+
// sidecar (or legacy migration). Recompute merged via the helper.
|
|
1718
|
+
state.canonical = canonical;
|
|
1719
|
+
setOverlayFromEntries(entries);
|
|
1720
|
+
// External writes change the body but leave disk frontmatter pointing at
|
|
1721
|
+
// the previous save's fingerprints. If the user cuts/deletes a block before
|
|
1722
|
+
// the next browser-driven save, the matcher graveyards with that stale
|
|
1723
|
+
// fingerprint and a later paste-back can't match by exact fingerprint —
|
|
1724
|
+
// graveyard-restore silently misses. Resync disk frontmatter with the
|
|
1725
|
+
// reloaded body now: the matcher's edit rule pins IDs and emits fresh
|
|
1726
|
+
// per-block fingerprints. fs.watch self-suppression via state.loadedMtime
|
|
1727
|
+
// (handleWatcherEvent) prevents a reload→save→reload loop.
|
|
1728
|
+
//
|
|
1729
|
+
// Bump docVersion BEFORE writeToDisk: serves two purposes at once. (1)
|
|
1730
|
+
// Rejects any in-flight stale browser autosaves (the WS handler checks
|
|
1731
|
+
// version currency). (2) Forces writeToDisk's no-op gate to see "dirty"
|
|
1732
|
+
// state and actually persist the refreshed frontmatter — without the bump,
|
|
1733
|
+
// the gate would short-circuit and the stale-fingerprint bug returns.
|
|
1734
|
+
// adr: adr/node-identity-matcher.md
|
|
1735
|
+
bumpDocVersion();
|
|
1736
|
+
try {
|
|
1737
|
+
writeToDisk();
|
|
1738
|
+
}
|
|
1739
|
+
catch { /* best-effort — reload still useful even if save fails */ }
|
|
1740
|
+
return {
|
|
1741
|
+
document: state.document,
|
|
1742
|
+
title: state.title,
|
|
1743
|
+
filename: state.filePath.split(/[/\\]/).pop() || '',
|
|
1744
|
+
orphans: [],
|
|
1745
|
+
staleBaseline: [],
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Pending overlay rehydration. Runs after a doc loads from disk (load() or
|
|
1750
|
+
* setActiveDocument). Three cases:
|
|
1751
|
+
*
|
|
1752
|
+
* 1. Sidecar has entries: state.document is canonical (parser found no
|
|
1753
|
+
* `meta.pending`). Apply sidecar overlay to layer pending decorations
|
|
1754
|
+
* back onto the canonical tree.
|
|
1755
|
+
*
|
|
1756
|
+
* 2. Sidecar empty, parser stamped pending attrs from legacy frontmatter:
|
|
1757
|
+
* one-time migration. Extract overlay from state.document, save to
|
|
1758
|
+
* sidecar. State.document already has pending attrs from parse, so
|
|
1759
|
+
* no apply step needed.
|
|
1760
|
+
*
|
|
1761
|
+
* 3. Sidecar empty, parser stamped nothing: no pending state. No-op.
|
|
1762
|
+
*
|
|
1763
|
+
* Returns the merged overlay entries for callers that need them.
|
|
1764
|
+
* adr: adr/pending-overlay-model.md
|
|
1765
|
+
*/
|
|
1766
|
+
function mergeOverlayOnLoad() {
|
|
1767
|
+
if (!state.docId)
|
|
1768
|
+
return [];
|
|
1769
|
+
// setActiveDocument just populated primary state via setPrimaryFromMerged.
|
|
1770
|
+
// If a sidecar exists for this docId, it overrides any legacy in-doc pending
|
|
1771
|
+
// that the splitter extracted from the input. If no sidecar exists and the
|
|
1772
|
+
// legacy split found entries, persist them as a one-time migration.
|
|
1773
|
+
const sidecar = loadOverlay(state.docId);
|
|
1774
|
+
const legacy = Array.from(state.overlay.values());
|
|
1775
|
+
if (sidecar.length > 0) {
|
|
1776
|
+
setOverlayFromEntries(sidecar);
|
|
1777
|
+
return sidecar;
|
|
1778
|
+
}
|
|
1779
|
+
else if (legacy.length > 0) {
|
|
1780
|
+
saveOverlay(state.docId, legacy);
|
|
1781
|
+
// overlay already has these entries from setPrimaryFromMerged; no-op
|
|
1782
|
+
return legacy;
|
|
1783
|
+
}
|
|
1784
|
+
return [];
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Apply an oldId → newId translation map to a TipTap doc tree in place.
|
|
1788
|
+
* Used by writeToDisk to bring state.document's nodeIds in sync with the
|
|
1789
|
+
* matcher's post-pass canonical IDs.
|
|
1790
|
+
*/
|
|
1791
|
+
function applyIdTranslationToDoc(doc, translation) {
|
|
1792
|
+
if (translation.size === 0)
|
|
1793
|
+
return;
|
|
1794
|
+
function walk(nodes) {
|
|
1795
|
+
if (!Array.isArray(nodes))
|
|
1076
1796
|
return;
|
|
1077
1797
|
for (const node of nodes) {
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
delete node.attrs.pendingTextEdits;
|
|
1798
|
+
const oldId = node?.attrs?.id;
|
|
1799
|
+
if (oldId && translation.has(oldId)) {
|
|
1800
|
+
node.attrs.id = translation.get(oldId);
|
|
1082
1801
|
}
|
|
1083
|
-
if (node
|
|
1084
|
-
|
|
1802
|
+
if (node?.content)
|
|
1803
|
+
walk(node.content);
|
|
1085
1804
|
}
|
|
1086
1805
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1806
|
+
walk(doc.content || []);
|
|
1807
|
+
}
|
|
1808
|
+
export function cloneWithPendingReverted(doc) {
|
|
1809
|
+
const PENDING_KEYS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingTextEdits', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo', 'pendingOrphan', 'pendingStaleBaseline'];
|
|
1810
|
+
function clean(node) {
|
|
1811
|
+
const clone = JSON.parse(JSON.stringify(node));
|
|
1812
|
+
if (clone.attrs) {
|
|
1813
|
+
for (const k of PENDING_KEYS)
|
|
1814
|
+
delete clone.attrs[k];
|
|
1815
|
+
}
|
|
1816
|
+
if (clone.content)
|
|
1817
|
+
clone.content = walk(clone.content);
|
|
1818
|
+
return clone;
|
|
1819
|
+
}
|
|
1820
|
+
function walk(nodes) {
|
|
1821
|
+
const result = [];
|
|
1822
|
+
for (const node of nodes) {
|
|
1823
|
+
const status = node.attrs?.pendingStatus;
|
|
1824
|
+
if (status === 'insert')
|
|
1825
|
+
continue; // drop fresh agent inserts
|
|
1826
|
+
if (status === 'rewrite') {
|
|
1827
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
1828
|
+
if (original)
|
|
1829
|
+
result.push(clean(original));
|
|
1830
|
+
// If no original stashed, drop the node — we have nothing to revert to.
|
|
1831
|
+
continue;
|
|
1832
|
+
}
|
|
1833
|
+
// 'delete' status or no status: keep the node, strip any pending attrs.
|
|
1834
|
+
result.push(clean(node));
|
|
1835
|
+
}
|
|
1836
|
+
return result;
|
|
1837
|
+
}
|
|
1838
|
+
return { type: 'doc', content: walk(doc.content || []) };
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Does the document have any "accepted" content — i.e. blocks that wouldn't
|
|
1842
|
+
* vanish under reject-all? Used to clear the `agentCreated` flag once a stub
|
|
1843
|
+
* has graduated into a real document, so a later reject-all on stale pending
|
|
1844
|
+
* decorations doesn't accidentally trigger the delete-on-reject cascade.
|
|
1845
|
+
*
|
|
1846
|
+
* A node counts as accepted if it has no pendingStatus, or has
|
|
1847
|
+
* pendingStatus=delete (reject keeps the node), or pendingStatus=rewrite with
|
|
1848
|
+
* `pendingOriginalContent` present (reject restores prior content).
|
|
1849
|
+
*/
|
|
1850
|
+
export function hasAcceptedContent(doc) {
|
|
1851
|
+
function extractTextLocal(nodes) {
|
|
1852
|
+
let out = '';
|
|
1853
|
+
for (const n of nodes) {
|
|
1854
|
+
if (typeof n.text === 'string')
|
|
1855
|
+
out += n.text;
|
|
1856
|
+
if (n.content)
|
|
1857
|
+
out += extractTextLocal(n.content);
|
|
1858
|
+
}
|
|
1859
|
+
return out;
|
|
1860
|
+
}
|
|
1861
|
+
function walk(nodes) {
|
|
1862
|
+
if (!nodes)
|
|
1863
|
+
return false;
|
|
1864
|
+
for (const node of nodes) {
|
|
1865
|
+
const status = node.attrs?.pendingStatus;
|
|
1866
|
+
// Nodes that would NOT survive reject-all: skip entirely, including their
|
|
1867
|
+
// children. Otherwise an insert-pending paragraph's text children would be
|
|
1868
|
+
// misread as accepted content.
|
|
1869
|
+
if (status === 'insert')
|
|
1870
|
+
continue;
|
|
1871
|
+
if (status === 'rewrite' && !node.attrs?.pendingOriginalContent)
|
|
1872
|
+
continue;
|
|
1873
|
+
// This node survives reject-all (no status, or delete, or rewrite-with-original).
|
|
1874
|
+
const surfaceText = node.text || extractTextLocal(node.content || []);
|
|
1875
|
+
if (surfaceText && surfaceText.trim().length > 0)
|
|
1876
|
+
return true;
|
|
1877
|
+
// Non-text leaf types also count as accepted content
|
|
1878
|
+
if (node.type === 'image' || node.type === 'horizontalRule' || node.type === 'table')
|
|
1879
|
+
return true;
|
|
1880
|
+
// Recurse into container nodes (lists, blockquotes) only when the
|
|
1881
|
+
// container itself isn't a dropped-on-reject node.
|
|
1882
|
+
if (node.content && walk(node.content))
|
|
1883
|
+
return true;
|
|
1884
|
+
}
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
return walk(doc.content || []);
|
|
1089
1888
|
}
|
|
1090
1889
|
/**
|
|
1091
1890
|
* Mark leaf block nodes as pending within a node array.
|
|
1092
1891
|
* Only marks text-containing blocks (paragraph, heading, codeBlock, etc.)
|
|
1093
1892
|
* NOT container nodes (bulletList, orderedList, listItem, blockquote).
|
|
1094
|
-
*
|
|
1893
|
+
* Used by `applyChangesToDoc` for write_to_pad inserts where containers
|
|
1894
|
+
* are handled by the explicit firstNode top-level mark.
|
|
1095
1895
|
*/
|
|
1096
1896
|
function markLeafBlocksAsPending(nodes, status) {
|
|
1097
1897
|
if (!nodes)
|
|
@@ -1108,8 +1908,45 @@ function markLeafBlocksAsPending(nodes, status) {
|
|
|
1108
1908
|
}
|
|
1109
1909
|
}
|
|
1110
1910
|
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Block-level container types. Tagged as pending alongside leaves on the
|
|
1913
|
+
* populate path so a fresh doc with nested content (lists, blockquotes)
|
|
1914
|
+
* records the wrappers as overlay entries, not just the inner paragraphs.
|
|
1915
|
+
* Without this, on reload the wrappers are gone (empty containers have no
|
|
1916
|
+
* markdown representation) and inner-paragraph entries with parentNodeId
|
|
1917
|
+
* pointing at the missing wrapper get classified as orphans.
|
|
1918
|
+
*
|
|
1919
|
+
* adr: adr/pending-overlay-model.md
|
|
1920
|
+
*/
|
|
1921
|
+
const CONTAINER_BLOCK_TYPES = new Set([
|
|
1922
|
+
'bulletList', 'orderedList', 'listItem',
|
|
1923
|
+
'taskList', 'taskItem',
|
|
1924
|
+
'blockquote',
|
|
1925
|
+
]);
|
|
1926
|
+
/**
|
|
1927
|
+
* Mark every block node (leaves + containers) as pending. Used by the
|
|
1928
|
+
* populate path where the entire doc tree is the agent's proposal — every
|
|
1929
|
+
* structural node must become an overlay entry so on reload the leaves'
|
|
1930
|
+
* parentNodeId references resolve through entries placed earlier in the
|
|
1931
|
+
* same batch.
|
|
1932
|
+
*/
|
|
1933
|
+
function markAllBlockNodesAsPending(nodes, status) {
|
|
1934
|
+
if (!nodes)
|
|
1935
|
+
return;
|
|
1936
|
+
for (const node of nodes) {
|
|
1937
|
+
if (node.type && (LEAF_BLOCK_TYPES.has(node.type) || CONTAINER_BLOCK_TYPES.has(node.type))) {
|
|
1938
|
+
node.attrs = { ...node.attrs, pendingStatus: status };
|
|
1939
|
+
if (!node.attrs.id) {
|
|
1940
|
+
node.attrs.id = generateNodeId();
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (node.content && !LEAF_BLOCK_TYPES.has(node.type)) {
|
|
1944
|
+
markAllBlockNodesAsPending(node.content, status);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1111
1948
|
export function markAllNodesAsPending(doc, status) {
|
|
1112
|
-
|
|
1949
|
+
markAllBlockNodesAsPending(doc.content, status);
|
|
1113
1950
|
}
|
|
1114
1951
|
/** Read pending doc info from in-memory cache (O(1) instead of disk scan). */
|
|
1115
1952
|
export function getPendingDocInfo() {
|
|
@@ -1135,6 +1972,16 @@ export function getPendingDocInfo() {
|
|
|
1135
1972
|
// PERSISTENCE
|
|
1136
1973
|
// ============================================================================
|
|
1137
1974
|
function writeToDisk() {
|
|
1975
|
+
// No-op gate: when the in-memory document hasn't been mutated since the
|
|
1976
|
+
// last successful write (or byte-equality skip), bail before any work.
|
|
1977
|
+
// Skips the full serialize + matcher pipeline (~50ms on medium docs), the
|
|
1978
|
+
// sidecar overlay write, the snapshot read+write, and the mtime bump that
|
|
1979
|
+
// would invalidate the doc cache. The existsSync check ensures first-save
|
|
1980
|
+
// of a new file still runs even when version state looks clean.
|
|
1981
|
+
// adr: adr/pending-overlay-model.md
|
|
1982
|
+
if (state.filePath && existsSync(state.filePath) && docVersion === lastSavedDocVersion) {
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1138
1985
|
ensureDataDir();
|
|
1139
1986
|
// Capture old forward links BEFORE we overwrite the file — needed by the
|
|
1140
1987
|
// backlinks engine to know which target docs to refresh when source changes.
|
|
@@ -1146,54 +1993,181 @@ function writeToDisk() {
|
|
|
1146
1993
|
}
|
|
1147
1994
|
catch { /* best-effort */ }
|
|
1148
1995
|
}
|
|
1996
|
+
// Stub graduation: once the doc contains accepted content, it's no longer
|
|
1997
|
+
// a fresh stub. Remove it from the in-memory stub registry so reject-all
|
|
1998
|
+
// can never trigger the cleanup-delete on it.
|
|
1999
|
+
// adr: adr/agent-stub-model.md
|
|
2000
|
+
if (hasAcceptedContent(state.document)) {
|
|
2001
|
+
unmarkAgentStub(activeDocFilename());
|
|
2002
|
+
}
|
|
2003
|
+
// Defensive: never serialize `agentCreated` to disk. The field is dead;
|
|
2004
|
+
// any code reading it would be the bug, not the field's presence.
|
|
2005
|
+
if (state.metadata)
|
|
2006
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1149
2007
|
let markdown;
|
|
1150
2008
|
if (isExternalDoc(state.filePath)) {
|
|
1151
|
-
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected
|
|
2009
|
+
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
|
|
2010
|
+
// External docs don't participate in the pending overlay system (the external editor
|
|
2011
|
+
// is the source of truth for the file's structure).
|
|
1152
2012
|
const body = tiptapToBody(state.document).replace(/(?:\s*<!-- -->\s*)+$/, '\n');
|
|
1153
2013
|
markdown = state.originalFrontmatter
|
|
1154
2014
|
? `---\n${state.originalFrontmatter}\n---\n\n${body}`
|
|
1155
2015
|
: body;
|
|
1156
2016
|
}
|
|
1157
2017
|
else {
|
|
1158
|
-
// Save-time matcher pass
|
|
2018
|
+
// Save-time matcher pass + pending overlay split.
|
|
1159
2019
|
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
// IDs at insert time and the load-time matcher only sees the post-edit
|
|
1165
|
-
// state. Memory holds no identity cache; identity always re-derives from
|
|
1166
|
-
// disk at the save boundary.
|
|
2020
|
+
// Architectural model: disk is canonical only. Pending state lives in a
|
|
2021
|
+
// sidecar at `_pending/{docId}.json`. We split state.document into:
|
|
2022
|
+
// - canonical: a clone with all pending reverted (matcher operates on this)
|
|
2023
|
+
// - overlay: the extracted pending entries (saved to sidecar)
|
|
1167
2024
|
//
|
|
1168
|
-
//
|
|
2025
|
+
// The matcher runs on canonical so the on-disk `nodes:` fingerprints match
|
|
2026
|
+
// the on-disk body. Pre-matcher canonical IDs are translated to post-matcher
|
|
2027
|
+
// IDs and the same translation is applied to (a) state.document, so the
|
|
2028
|
+
// in-memory tree stays consistent with disk, and (b) the overlay entries,
|
|
2029
|
+
// so they re-anchor correctly on reload.
|
|
2030
|
+
//
|
|
2031
|
+
// adr: adr/node-identity-matcher.md · adr: adr/pending-overlay-model.md
|
|
2032
|
+
const canonical = cloneWithPendingReverted(state.document);
|
|
1169
2033
|
const { previousNodes, graveyard } = readPersistedIdentity(state.filePath);
|
|
2034
|
+
// previousNodes and graveyard are already in rich Fingerprint form —
|
|
2035
|
+
// readPersistedIdentity handles slim-tuple enrichment and legacy
|
|
2036
|
+
// re-fingerprinting before returning. Matcher gets a uniform input
|
|
2037
|
+
// regardless of what's on disk.
|
|
2038
|
+
// adr: adr/node-identity-matcher.md
|
|
2039
|
+
//
|
|
2040
|
+
// newBlocks is computed once and reused by:
|
|
2041
|
+
// (a) the matcher branch below (when there are previous nodes to match)
|
|
2042
|
+
// (b) the enrichment staleness check (always — even on first save)
|
|
2043
|
+
// Hoisted outside the matcher conditional so first-save staleness still
|
|
2044
|
+
// gets the current sentence-hash signal.
|
|
2045
|
+
const newBlocks = tiptapToBlocks(canonical);
|
|
1170
2046
|
let nextGraveyard = graveyard;
|
|
2047
|
+
const idTranslation = new Map();
|
|
1171
2048
|
if (previousNodes.length > 0) {
|
|
1172
|
-
const
|
|
1173
|
-
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard });
|
|
2049
|
+
const beforeIds = newBlocks.map((b) => b.id);
|
|
2050
|
+
const matchResult = matchNodes(previousNodes, newBlocks, { graveyard: nextGraveyard });
|
|
1174
2051
|
const pinnedByPosition = new Map();
|
|
1175
2052
|
for (const p of matchResult.pinned)
|
|
1176
2053
|
pinnedByPosition.set(p.position, p.id);
|
|
1177
|
-
applyIdsToTiptap(
|
|
2054
|
+
applyIdsToTiptap(canonical, pinnedByPosition);
|
|
1178
2055
|
nextGraveyard = matchResult.nextGraveyard;
|
|
2056
|
+
// Build pre→post id translation (canonical's IDs match state.document's
|
|
2057
|
+
// IDs at non-insert positions, since cloneWithPendingReverted preserves
|
|
2058
|
+
// IDs on rewrite/delete/passthrough nodes).
|
|
2059
|
+
for (let i = 0; i < beforeIds.length; i++) {
|
|
2060
|
+
const oldId = beforeIds[i];
|
|
2061
|
+
const newId = pinnedByPosition.get(i);
|
|
2062
|
+
if (oldId && newId && oldId !== newId) {
|
|
2063
|
+
idTranslation.set(oldId, newId);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
// Apply translation to primary state. The matcher renamed IDs on
|
|
2067
|
+
// canonical (above); we need state.canonical, state.overlay's entry
|
|
2068
|
+
// keys (the nodeId fields), and state.document to all see the new IDs.
|
|
2069
|
+
if (idTranslation.size > 0) {
|
|
2070
|
+
applyIdTranslationToDoc(state.canonical, idTranslation);
|
|
2071
|
+
// Translate overlay entry nodeIds (rewrite/delete entries point at
|
|
2072
|
+
// canonical IDs that may have shifted; insert entries have unique
|
|
2073
|
+
// IDs not in the translation map and pass through).
|
|
2074
|
+
const newOverlay = new Map();
|
|
2075
|
+
for (const [nodeId, entry] of state.overlay) {
|
|
2076
|
+
const newNodeId = idTranslation.get(nodeId) ?? nodeId;
|
|
2077
|
+
newOverlay.set(newNodeId, { ...entry, nodeId: newNodeId });
|
|
2078
|
+
}
|
|
2079
|
+
state.overlay = newOverlay;
|
|
2080
|
+
recomputeMerged();
|
|
2081
|
+
}
|
|
2082
|
+
// Broadcast id-rewrites so browser clients converge their TipTap state.
|
|
2083
|
+
if (idRewriteListeners.size > 0 && idTranslation.size > 0) {
|
|
2084
|
+
const rewrites = Array.from(idTranslation, ([oldId, newId]) => ({ oldId, newId }));
|
|
2085
|
+
for (const listener of idRewriteListeners)
|
|
2086
|
+
listener(rewrites);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
// Persist the structured overlay to sidecar. state.overlay IS the overlay
|
|
2090
|
+
// in the new model — no extraction needed.
|
|
2091
|
+
if (state.docId) {
|
|
2092
|
+
saveOverlay(state.docId, Array.from(state.overlay.values()));
|
|
2093
|
+
}
|
|
2094
|
+
// ENRICHMENT STALENESS — reuses the matcher's sentence-hash machinery.
|
|
2095
|
+
// After the matcher pass, harvest current sentence hashes + char count
|
|
2096
|
+
// from the same blocks the matcher just operated on; compare against the
|
|
2097
|
+
// at-enrichment baseline in frontmatter. Flip enrichmentStale=true when
|
|
2098
|
+
// volume or drift thresholds trip. OpenWriter never clears the flag —
|
|
2099
|
+
// that's the agent's job via mark_enriched (Phase 4).
|
|
2100
|
+
//
|
|
2101
|
+
// adr: see brief 2026-05-18-frontmatter-enrichment-system
|
|
2102
|
+
try {
|
|
2103
|
+
const currentSentences = harvestSentenceHashes(newBlocks);
|
|
2104
|
+
const currentChars = harvestCharCount(newBlocks);
|
|
2105
|
+
const stale = isEnrichmentStale(currentSentences, currentChars, state.metadata);
|
|
2106
|
+
if (stale && state.metadata.enrichmentStale !== true) {
|
|
2107
|
+
state.metadata.enrichmentStale = true;
|
|
2108
|
+
diagLog(`[Enrichment] stale: ${state.filePath}`);
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
catch (err) {
|
|
2112
|
+
// Staleness detection is observational, never load-bearing for the save.
|
|
2113
|
+
console.error('[Enrichment] staleness check failed:', err);
|
|
1179
2114
|
}
|
|
1180
2115
|
// Pass graveyard through metadata so the serializer can emit it in frontmatter.
|
|
1181
2116
|
const metaWithGraveyard = nextGraveyard.length > 0
|
|
1182
2117
|
? { ...state.metadata, graveyard: nextGraveyard.map((g) => ({ id: g.id, fp: g.fingerprint })) }
|
|
1183
2118
|
: state.metadata;
|
|
1184
|
-
// Checked serializer —
|
|
1185
|
-
//
|
|
1186
|
-
const result = tiptapToMarkdownChecked(
|
|
2119
|
+
// Checked serializer — operates on canonical (already pending-reverted).
|
|
2120
|
+
// The serializer no longer emits `meta.pending` (overlay handles that).
|
|
2121
|
+
const result = tiptapToMarkdownChecked(canonical, state.title, metaWithGraveyard);
|
|
1187
2122
|
markdown = result.markdown;
|
|
1188
2123
|
}
|
|
1189
2124
|
if (existsSync(state.filePath)) {
|
|
1190
2125
|
// Skip write if content is identical (prevents phantom git changes on doc switch)
|
|
1191
2126
|
try {
|
|
1192
2127
|
const existing = readFileSync(state.filePath, 'utf-8');
|
|
1193
|
-
if (existing === markdown)
|
|
2128
|
+
if (existing === markdown) {
|
|
2129
|
+
// Even on a no-op write, refresh our mtime snapshot so we don't
|
|
2130
|
+
// misread a stale `loadedMtime` as evidence of an external write.
|
|
2131
|
+
try {
|
|
2132
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2133
|
+
}
|
|
2134
|
+
catch { /* best-effort */ }
|
|
2135
|
+
// Mark in-sync at this docVersion so the next save bails at the
|
|
2136
|
+
// top-level gate before re-running serialize. Without this, the
|
|
2137
|
+
// gate would only kick in after a real disk write.
|
|
2138
|
+
lastSavedDocVersion = docVersion;
|
|
1194
2139
|
return;
|
|
2140
|
+
}
|
|
1195
2141
|
}
|
|
1196
2142
|
catch { /* read failed, proceed with write */ }
|
|
2143
|
+
// EXTERNAL-WRITE GUARD: if disk mtime is newer than the mtime we stamped
|
|
2144
|
+
// at load (or our last successful save), an external writer modified the
|
|
2145
|
+
// file out from under us. Blindly writing our in-memory state would
|
|
2146
|
+
// clobber their content silently. Block the write, log, and surface via
|
|
2147
|
+
// sync-status so the agent/user can resolve via reload_from_disk.
|
|
2148
|
+
//
|
|
2149
|
+
// Edge cases handled:
|
|
2150
|
+
// - First save of a new doc: loadedMtime=0, so the guard never fires
|
|
2151
|
+
// (every real file's mtime will be > 0).
|
|
2152
|
+
// - Atomic-write race: writeFileSync momentarily mtime-bumps. We re-
|
|
2153
|
+
// stamp loadedMtime AFTER every successful own write so subsequent
|
|
2154
|
+
// guard checks compare against our own write, not a phantom delta.
|
|
2155
|
+
// - Clock drift: we compare exact ms equality (not >); any change at
|
|
2156
|
+
// all is treated as external. Filesystems guarantee monotonic mtime
|
|
2157
|
+
// per file on the same host so this is safe.
|
|
2158
|
+
if (state.loadedMtime > 0) {
|
|
2159
|
+
try {
|
|
2160
|
+
const diskMtime = statSync(state.filePath).mtimeMs;
|
|
2161
|
+
if (diskMtime !== state.loadedMtime) {
|
|
2162
|
+
console.error(`[State] BLOCKED save: external write detected on ${state.filePath} ` +
|
|
2163
|
+
`(disk mtime ${new Date(diskMtime).toISOString()} != loaded mtime ${new Date(state.loadedMtime).toISOString()}). ` +
|
|
2164
|
+
`Call reload_from_disk to adopt external content, or write_to_pad to re-apply changes on top.`);
|
|
2165
|
+
notifyExternalWriteConflict(state.filePath, diskMtime, state.loadedMtime);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
catch { /* stat failed, proceed with save */ }
|
|
2170
|
+
}
|
|
1197
2171
|
// Safety: don't overwrite a file with substantial content using near-empty content.
|
|
1198
2172
|
// Prevents save cascades where empty editor state destroys chapter files.
|
|
1199
2173
|
// Exception: docs with pending changes may legitimately be smaller (agent replaced content).
|
|
@@ -1209,6 +2183,15 @@ function writeToDisk() {
|
|
|
1209
2183
|
}
|
|
1210
2184
|
}
|
|
1211
2185
|
atomicWriteFileSync(state.filePath, markdown);
|
|
2186
|
+
// Re-stamp loadedMtime so the next save's guard compares against our own
|
|
2187
|
+
// most-recent write, not the prior load's mtime.
|
|
2188
|
+
try {
|
|
2189
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2190
|
+
}
|
|
2191
|
+
catch { /* best-effort */ }
|
|
2192
|
+
// Record that disk now matches in-memory at this docVersion. Subsequent
|
|
2193
|
+
// save() calls without further mutations will bail at the top-level gate.
|
|
2194
|
+
lastSavedDocVersion = docVersion;
|
|
1212
2195
|
// Best-effort version snapshot — never blocks saves
|
|
1213
2196
|
try {
|
|
1214
2197
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
@@ -1228,14 +2211,17 @@ function writeToDisk() {
|
|
|
1228
2211
|
}
|
|
1229
2212
|
export function save() {
|
|
1230
2213
|
if (!state.filePath) {
|
|
1231
|
-
// First save — assign a file path
|
|
2214
|
+
// First save — assign a file path. Canonicalize at this identity
|
|
2215
|
+
// boundary so cache lookups and watcher subscriptions key on the
|
|
2216
|
+
// same string regardless of how this path was produced.
|
|
2217
|
+
// adr: adr/path-canonicalization.md
|
|
1232
2218
|
ensureDataDir();
|
|
1233
2219
|
if (state.title === 'Untitled') {
|
|
1234
|
-
state.filePath = tempFilePath();
|
|
2220
|
+
state.filePath = canonicalizePath(tempFilePath());
|
|
1235
2221
|
state.isTemp = true;
|
|
1236
2222
|
}
|
|
1237
2223
|
else {
|
|
1238
|
-
state.filePath = filePathForTitle(state.title);
|
|
2224
|
+
state.filePath = canonicalizePath(filePathForTitle(state.title));
|
|
1239
2225
|
state.isTemp = false;
|
|
1240
2226
|
}
|
|
1241
2227
|
}
|
|
@@ -1243,6 +2229,9 @@ export function save() {
|
|
|
1243
2229
|
}
|
|
1244
2230
|
export function load() {
|
|
1245
2231
|
ensureDataDir();
|
|
2232
|
+
// One-time sidecar repair MUST run before any doc loads so the file-walk
|
|
2233
|
+
// loop reads deduped sidecars, not corrupted ones. adr: adr/pending-overlay-model.md
|
|
2234
|
+
repairOverlaysOnStartup();
|
|
1246
2235
|
// Restore external document registry from disk
|
|
1247
2236
|
loadExternalDocs();
|
|
1248
2237
|
// Migrate any .sw.json files to .md
|
|
@@ -1268,19 +2257,38 @@ export function load() {
|
|
|
1268
2257
|
// Skip empty temp files — prefer a real document
|
|
1269
2258
|
if (isTemp && isDocEmpty(parsed.document))
|
|
1270
2259
|
continue;
|
|
1271
|
-
state
|
|
2260
|
+
// Route the parsed doc through the primary-state setter — splits into
|
|
2261
|
+
// canonical + overlay (handles legacy in-frontmatter pending) and
|
|
2262
|
+
// recomputes the merged view via state.document.
|
|
2263
|
+
setPrimaryFromMerged(parsed.document);
|
|
1272
2264
|
state.title = parsed.title;
|
|
1273
2265
|
state.metadata = parsed.metadata;
|
|
2266
|
+
// Legacy: strip any pre-architectural-fix `agentCreated` field that
|
|
2267
|
+
// survived on disk. The in-memory stub registry is the only authority.
|
|
2268
|
+
stripLegacyAgentCreated(state.metadata);
|
|
1274
2269
|
state.lastModified = new Date(statSync(file.path).mtimeMs);
|
|
1275
|
-
state.filePath = file.path;
|
|
2270
|
+
state.filePath = canonicalizePath(file.path);
|
|
1276
2271
|
state.isTemp = isTemp;
|
|
2272
|
+
state.loadedMtime = statSync(file.path).mtimeMs;
|
|
1277
2273
|
// Lazy docId migration: assign if missing, save to persist
|
|
1278
2274
|
const hadDocId = !!state.metadata.docId;
|
|
1279
2275
|
state.docId = ensureDocId(state.metadata);
|
|
1280
2276
|
if (!hadDocId) {
|
|
1281
2277
|
const md = tiptapToMarkdown(state.document, state.title, state.metadata);
|
|
1282
2278
|
atomicWriteFileSync(state.filePath, md);
|
|
2279
|
+
try {
|
|
2280
|
+
state.loadedMtime = statSync(state.filePath).mtimeMs;
|
|
2281
|
+
}
|
|
2282
|
+
catch { /* best-effort */ }
|
|
1283
2283
|
}
|
|
2284
|
+
// Pending overlay merge: rehydrate pending decorations from the sidecar.
|
|
2285
|
+
// For legacy files (parser stamped pending attrs from old `meta.pending`),
|
|
2286
|
+
// capture those into the overlay format and write the sidecar as a one-
|
|
2287
|
+
// time migration. For migrated files (parser stamped nothing because
|
|
2288
|
+
// `meta.pending` is gone from frontmatter), the sidecar is the only
|
|
2289
|
+
// source.
|
|
2290
|
+
// adr: adr/pending-overlay-model.md
|
|
2291
|
+
mergeOverlayOnLoad();
|
|
1284
2292
|
break;
|
|
1285
2293
|
}
|
|
1286
2294
|
catch {
|
|
@@ -1290,15 +2298,18 @@ export function load() {
|
|
|
1290
2298
|
}
|
|
1291
2299
|
// If nothing loaded (all files were empty temps or corrupt), start fresh
|
|
1292
2300
|
if (!state.filePath) {
|
|
1293
|
-
state.filePath = tempFilePath();
|
|
2301
|
+
state.filePath = canonicalizePath(tempFilePath());
|
|
1294
2302
|
state.isTemp = true;
|
|
1295
2303
|
}
|
|
1296
2304
|
// Populate pending doc cache from disk (single scan on startup)
|
|
1297
2305
|
populatePendingCache();
|
|
1298
2306
|
// Overlay active doc's in-memory state (may have unsaved pending changes)
|
|
1299
2307
|
updatePendingCacheForActiveDoc();
|
|
2308
|
+
// Subscribe the active-doc watcher so external writes route through the
|
|
2309
|
+
// unified reload pathway. adr: adr/active-doc-watcher.md
|
|
2310
|
+
startActiveDocWatcher();
|
|
1300
2311
|
// Startup lock: block browser doc-updates briefly to prevent stale reconnect pushes
|
|
1301
|
-
|
|
2312
|
+
setAgentLockGlobal();
|
|
1302
2313
|
}
|
|
1303
2314
|
/** Migrate legacy .sw.json files to .md format */
|
|
1304
2315
|
function migrateSwJsonFiles() {
|
|
@@ -1388,9 +2399,13 @@ function cleanupEmptyTempFiles() {
|
|
|
1388
2399
|
try {
|
|
1389
2400
|
const raw = readFileSync(fullPath, 'utf-8');
|
|
1390
2401
|
const parsed = markdownToTiptap(raw);
|
|
1391
|
-
// Keep temp files that have meaningful metadata (templates, pending changes, tags)
|
|
2402
|
+
// Keep temp files that have meaningful metadata (templates, pending changes, tags).
|
|
2403
|
+
// Note: `agentCreated` used to be a disk-frontmatter signal here. Stub status is now
|
|
2404
|
+
// in-memory only — see agentStubFilenames. An empty temp file with no other meaningful
|
|
2405
|
+
// metadata that survived a server restart is by definition no longer a fresh stub and
|
|
2406
|
+
// can be cleaned up.
|
|
1392
2407
|
const meta = parsed.metadata || {};
|
|
1393
|
-
const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
|
|
2408
|
+
const hasMetadata = meta.tweetContext || meta.articleContext || meta.pending
|
|
1394
2409
|
|| (Array.isArray(meta.tags) && meta.tags.length > 0);
|
|
1395
2410
|
if (isDocEmpty(parsed.document) && !hasMetadata) {
|
|
1396
2411
|
unlinkSync(fullPath);
|
|
@@ -1540,6 +2555,13 @@ export function removeDocTag(filename, tag) {
|
|
|
1540
2555
|
/**
|
|
1541
2556
|
* Save a browser doc-update to a specific file on disk.
|
|
1542
2557
|
* Used when the browser sends a doc-update for a non-active document (race condition guard).
|
|
2558
|
+
*
|
|
2559
|
+
* Disk gets canonical (pending stripped by serialization). Any pending attrs
|
|
2560
|
+
* carried on `doc` (transferred from disk) are persisted to the overlay
|
|
2561
|
+
* sidecar in the same pass. Without the symmetric overlay save, pending
|
|
2562
|
+
* content would vanish silently between the strip-during-serialize and the
|
|
2563
|
+
* disk write.
|
|
2564
|
+
* adr: adr/pending-overlay-model.md
|
|
1543
2565
|
*/
|
|
1544
2566
|
export function saveDocToFile(filename, doc) {
|
|
1545
2567
|
const targetPath = resolveDocPath(filename);
|
|
@@ -1563,6 +2585,11 @@ export function saveDocToFile(filename, doc) {
|
|
|
1563
2585
|
markdown = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
1564
2586
|
}
|
|
1565
2587
|
atomicWriteFileSync(targetPath, markdown);
|
|
2588
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
|
|
2589
|
+
if (docId) {
|
|
2590
|
+
const overlay = extractOverlay(doc);
|
|
2591
|
+
saveOverlay(docId, overlay);
|
|
2592
|
+
}
|
|
1566
2593
|
}
|
|
1567
2594
|
catch { /* best-effort */ }
|
|
1568
2595
|
}
|
|
@@ -1597,9 +2624,15 @@ export function setAutoAcceptOnFile(filename, enabled) {
|
|
|
1597
2624
|
}
|
|
1598
2625
|
/**
|
|
1599
2626
|
* Strip pending attrs from a specific file on disk (not the active document).
|
|
1600
|
-
*
|
|
2627
|
+
*
|
|
2628
|
+
* The `_legacyClearAgentCreated` parameter is preserved for callsite-signature
|
|
2629
|
+
* stability but no longer does anything meaningful. Stub status is in-memory
|
|
2630
|
+
* only — there is no `agentCreated` field to clear on disk. The on-load
|
|
2631
|
+
* legacy-strip handles any residual occurrences from pre-architectural-fix
|
|
2632
|
+
* files.
|
|
2633
|
+
* adr: adr/agent-stub-model.md
|
|
1601
2634
|
*/
|
|
1602
|
-
export function stripPendingAttrsFromFile(filename,
|
|
2635
|
+
export function stripPendingAttrsFromFile(filename, _legacyClearAgentCreated) {
|
|
1603
2636
|
const targetPath = resolveDocPath(filename);
|
|
1604
2637
|
if (!existsSync(targetPath))
|
|
1605
2638
|
return;
|
|
@@ -1621,9 +2654,9 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1621
2654
|
}
|
|
1622
2655
|
}
|
|
1623
2656
|
strip(parsed.document.content);
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
2657
|
+
// Belt-and-suspenders: strip any legacy on-disk agentCreated (e.g. an
|
|
2658
|
+
// old file that hasn't been re-saved since the migration).
|
|
2659
|
+
stripLegacyAgentCreated(parsed.metadata);
|
|
1627
2660
|
let markdown;
|
|
1628
2661
|
if (isExternalDoc(targetPath)) {
|
|
1629
2662
|
const body = tiptapToBody(parsed.document);
|
|
@@ -1635,6 +2668,12 @@ export function stripPendingAttrsFromFile(filename, clearAgentCreated) {
|
|
|
1635
2668
|
markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
|
|
1636
2669
|
}
|
|
1637
2670
|
atomicWriteFileSync(targetPath, markdown);
|
|
2671
|
+
// Pending was just cleared on disk; the sidecar overlay must go too,
|
|
2672
|
+
// otherwise the next load would re-apply stale pending entries.
|
|
2673
|
+
// adr: adr/pending-overlay-model.md
|
|
2674
|
+
const docId = (parsed.metadata && typeof parsed.metadata.docId === 'string') ? parsed.metadata.docId : '';
|
|
2675
|
+
if (docId)
|
|
2676
|
+
deleteOverlay(docId);
|
|
1638
2677
|
removePendingCacheEntry(filename);
|
|
1639
2678
|
}
|
|
1640
2679
|
catch { /* best-effort */ }
|
|
@@ -1658,10 +2697,47 @@ export function countPending(nodes) {
|
|
|
1658
2697
|
return count;
|
|
1659
2698
|
}
|
|
1660
2699
|
/** Write a mutated doc back to disk and update the pending cache. */
|
|
2700
|
+
/** Write a mutated doc back to disk and update the pending cache.
|
|
2701
|
+
*
|
|
2702
|
+
* Disk gets canonical (pending stripped by `tiptapToMarkdown`). The
|
|
2703
|
+
* overlay sidecar gets the extracted pending entries — without this,
|
|
2704
|
+
* any pending content on `doc` vanishes between strip and disk write.
|
|
2705
|
+
* This mirrors writeToDisk's active-doc path; the foundation commit
|
|
2706
|
+
* established the contract there but missed this non-active-doc
|
|
2707
|
+
* callsite. Without symmetric overlay save, populate_document /
|
|
2708
|
+
* write_to_pad on a non-active doc silently dropped pending content.
|
|
2709
|
+
* adr: adr/pending-overlay-model.md */
|
|
1661
2710
|
function flushDocToFile(filename, doc, title, metadata) {
|
|
1662
2711
|
const targetPath = resolveDocPath(filename);
|
|
2712
|
+
// Enrichment staleness — same signal as writeToDisk, but flushDocToFile
|
|
2713
|
+
// bypasses the matcher entirely so we harvest sentence hashes directly.
|
|
2714
|
+
// Measure the canonical (pending-reverted) view since that's what lands on
|
|
2715
|
+
// disk; pending overlay content rides in the sidecar and isn't part of the
|
|
2716
|
+
// doc's "published" content for enrichment purposes. External docs skip —
|
|
2717
|
+
// they don't participate in the enrichment graph.
|
|
2718
|
+
// adr: see brief 2026-05-18-frontmatter-enrichment-system
|
|
2719
|
+
if (!isExternalDoc(targetPath)) {
|
|
2720
|
+
try {
|
|
2721
|
+
const canonical = cloneWithPendingReverted(doc);
|
|
2722
|
+
const blocks = tiptapToBlocks(canonical);
|
|
2723
|
+
const currentSentences = harvestSentenceHashes(blocks);
|
|
2724
|
+
const currentChars = harvestCharCount(blocks);
|
|
2725
|
+
const stale = isEnrichmentStale(currentSentences, currentChars, metadata);
|
|
2726
|
+
if (stale && metadata.enrichmentStale !== true) {
|
|
2727
|
+
metadata.enrichmentStale = true;
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
catch (err) {
|
|
2731
|
+
console.error('[Enrichment] staleness check (flush) failed:', err);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
1663
2734
|
const markdown = tiptapToMarkdown(doc, title, metadata);
|
|
1664
2735
|
atomicWriteFileSync(targetPath, markdown);
|
|
2736
|
+
const docId = (metadata && typeof metadata.docId === 'string') ? metadata.docId : '';
|
|
2737
|
+
if (docId) {
|
|
2738
|
+
const overlay = extractOverlay(doc);
|
|
2739
|
+
saveOverlay(docId, overlay);
|
|
2740
|
+
}
|
|
1665
2741
|
setPendingCacheEntry(filename, countPending(doc.content));
|
|
1666
2742
|
}
|
|
1667
2743
|
export function populateDocumentFile(filename, doc) {
|
|
@@ -1762,8 +2838,12 @@ export function applyTextEditsToFile(filename, nodeId, edits) {
|
|
|
1762
2838
|
return { success: false, error: `Node ${nodeId} not found` };
|
|
1763
2839
|
const originalNode = found.parent[found.index];
|
|
1764
2840
|
const result = applyTextEditsToNode(originalNode, edits);
|
|
1765
|
-
if (!result)
|
|
1766
|
-
|
|
2841
|
+
if (!result) {
|
|
2842
|
+
const nodeText = extractText(originalNode.content || []);
|
|
2843
|
+
const truncated = nodeText.length > 240 ? nodeText.slice(0, 240) + '…' : nodeText;
|
|
2844
|
+
const searched = edits.map((e) => JSON.stringify(e.find)).join(', ');
|
|
2845
|
+
return { success: false, error: `No edits matched in node ${nodeId}. Searched: ${searched}. Node text starts: ${JSON.stringify(truncated)}` };
|
|
2846
|
+
}
|
|
1767
2847
|
const autoAccept = isAutoAcceptActive(filename, metadata);
|
|
1768
2848
|
// pendingTextEdits is the fine-grained inline-edit decoration — skip in autoAccept
|
|
1769
2849
|
// since the change commits directly.
|