codedeep-mcp 0.1.0 → 0.2.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/README.md +23 -8
- package/dist/config.js +1 -1
- package/dist/fs-util.js +48 -0
- package/dist/git/git-service.js +27 -0
- package/dist/index.js +15 -1
- package/dist/indexer/code-index.js +91 -22
- package/dist/indexer/parser.js +100 -25
- package/dist/indexer/pipeline.js +64 -4
- package/dist/indexer/scanner.js +6 -4
- package/dist/indexer/watcher.js +9 -0
- package/dist/notes/note-store.js +513 -0
- package/dist/notes/staleness.js +168 -0
- package/dist/notes/types.js +19 -0
- package/dist/server.js +105 -16
- package/dist/tools/common.js +51 -41
- package/dist/tools/find-references.js +9 -11
- package/dist/tools/forget.js +26 -0
- package/dist/tools/get-context.js +149 -18
- package/dist/tools/impact.js +18 -5
- package/dist/tools/note-render.js +57 -0
- package/dist/tools/overview.js +76 -3
- package/dist/tools/recall.js +165 -0
- package/dist/tools/remember.js +207 -0
- package/dist/tools/search-structure.js +3 -2
- package/package.json +4 -2
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { errMsg, log } from '../logger.js';
|
|
4
|
+
import { NOTES_STORE_VERSION, } from './types.js';
|
|
5
|
+
// Durable, agent-curated note store backing `remember` / `recall` / `forget`.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle is deliberately the OPPOSITE of CodeIndex on unreadable/foreign
|
|
8
|
+
// data: the index DELETES (it is rebuildable), the note store QUARANTINES
|
|
9
|
+
// (notes are not rebuildable). The store NEVER fs.unlink's notes.json, and any
|
|
10
|
+
// failure degrades to an empty in-memory store rather than throwing out of a
|
|
11
|
+
// tool call.
|
|
12
|
+
//
|
|
13
|
+
// Concurrency: every mutation is a whole-store read-modify-write persisted via
|
|
14
|
+
// an atomic temp+fsync+rename (mirroring CodeIndex.save), serialized behind an
|
|
15
|
+
// in-process write lock. N is expected to be tens–hundreds of notes, so a full
|
|
16
|
+
// rewrite per remember/forget is cheap and crash-safe.
|
|
17
|
+
//
|
|
18
|
+
// KNOWN LIMITATION (MVP): the lock is IN-PROCESS only. Two codedeep servers on
|
|
19
|
+
// the same repo sharing one notes.json can lost-update each other (each has its
|
|
20
|
+
// own snapshot from load()). Acceptable for the single-agent-per-repo common
|
|
21
|
+
// case; a cross-process file lock is deferred (see project memory).
|
|
22
|
+
export class NoteStore {
|
|
23
|
+
notesPath;
|
|
24
|
+
projectRoot;
|
|
25
|
+
notes = [];
|
|
26
|
+
loadPromise = null;
|
|
27
|
+
// Non-null when writes are blocked (and the reason). Set when the on-disk
|
|
28
|
+
// store was written by a NEWER build (recall still serves it; remember/forget
|
|
29
|
+
// refuse so we never down-convert and clobber it), when a corrupt/foreign
|
|
30
|
+
// store could NOT be quarantined (so a later persist can't overwrite the
|
|
31
|
+
// un-recovered original), when notes.json is PRESENT but unreadable
|
|
32
|
+
// (transient lock/EACCES — a write would rename-over the un-read original),
|
|
33
|
+
// OR when notes.json is MISSING but a `.bak` survives (a write would clobber
|
|
34
|
+
// the manual restore our own message invites).
|
|
35
|
+
writeBlocked = null;
|
|
36
|
+
// Non-null when the store's NOTES are unavailable at load — a corrupt/foreign
|
|
37
|
+
// store that was quarantined (moved aside, success OR fail) or a
|
|
38
|
+
// transiently-unreadable one — so an empty view is NOT genuine absence. recall
|
|
39
|
+
// surfaces it. Distinct from writeBlocked: a newer-version store still SERVES
|
|
40
|
+
// its notes (no loadNotice), and a quarantine-SUCCESS leaves writes enabled
|
|
41
|
+
// (no writeBlocked) yet the prior notes were moved aside (loadNotice set).
|
|
42
|
+
loadNotice = null;
|
|
43
|
+
// Identity of the blocked/degraded state the LAST load ended in (null =
|
|
44
|
+
// healthy). Three states reset loadPromise so every tool call re-loads
|
|
45
|
+
// (missing-with-.bak, present-but-unreadable, quarantine-rename-failure) —
|
|
46
|
+
// their stderr warns fire only on ENTERING the state (key change across
|
|
47
|
+
// consecutive loads), not once per recall/remember/forget forever. The
|
|
48
|
+
// in-band loadNotice still rides every response. The .bak state keys on the
|
|
49
|
+
// backup NAME SET, so exiting and re-entering with different backups (e.g.
|
|
50
|
+
// one restored, notes.json rm'd again) is a NEW entry and warns again.
|
|
51
|
+
blockedWarnKey = null;
|
|
52
|
+
storeCreatedAt = null;
|
|
53
|
+
writeLock = Promise.resolve();
|
|
54
|
+
constructor(notesPath, projectRoot) {
|
|
55
|
+
this.notesPath = notesPath;
|
|
56
|
+
this.projectRoot = projectRoot;
|
|
57
|
+
}
|
|
58
|
+
get isReadOnly() {
|
|
59
|
+
return this.writeBlocked !== null;
|
|
60
|
+
}
|
|
61
|
+
get writeBlockReason() {
|
|
62
|
+
return this.writeBlocked;
|
|
63
|
+
}
|
|
64
|
+
// A user-facing notice that the store's notes are unavailable/were moved aside
|
|
65
|
+
// (so recall can distinguish a degraded empty view from genuine emptiness).
|
|
66
|
+
get degradedReason() {
|
|
67
|
+
return this.loadNotice;
|
|
68
|
+
}
|
|
69
|
+
// Idempotent and concurrency-safe (memoized): reads notes.json, validating
|
|
70
|
+
// shape. Missing file → empty store. Unparseable / shape-invalid / foreign
|
|
71
|
+
// projectRoot → quarantine the bytes aside and start empty. A newer on-disk
|
|
72
|
+
// version → read-only. load() is awaited at startup, and every mutation
|
|
73
|
+
// awaits it too, so a write can never race or precede the initial read.
|
|
74
|
+
load() {
|
|
75
|
+
this.loadPromise ??= this.doLoad();
|
|
76
|
+
return this.loadPromise;
|
|
77
|
+
}
|
|
78
|
+
async doLoad() {
|
|
79
|
+
// Clear the block + notice up front so EVERY (re-)load starts from a clean
|
|
80
|
+
// slate — the branches below re-set them only when they genuinely must
|
|
81
|
+
// (unreadable / newer-version / quarantine / missing-with-.bak). This is
|
|
82
|
+
// the single reset point, so a retried load ending in ANY branch recovers
|
|
83
|
+
// instead of latching stale state. `prevWarnKey` is captured here for the
|
|
84
|
+
// same reason: the per-call-retry branches warn only on ENTERING their state.
|
|
85
|
+
const prevWarnKey = this.blockedWarnKey;
|
|
86
|
+
this.blockedWarnKey = null;
|
|
87
|
+
this.writeBlocked = null;
|
|
88
|
+
this.loadNotice = null;
|
|
89
|
+
await this.cleanupStaleTmp();
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await fs.readFile(this.notesPath, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
const code = err?.code;
|
|
96
|
+
if (code !== 'ENOENT') {
|
|
97
|
+
// notes.json is PRESENT but unreadable (a transient Windows lock / EACCES
|
|
98
|
+
// glitch). Do NOT start empty-and-writable — the next write's atomic
|
|
99
|
+
// rename-over would destroy the still-present, un-read original. Block
|
|
100
|
+
// writes so the real notes survive; recall surfaces the block. This is a
|
|
101
|
+
// TRANSIENT condition, so reset the load memo (loadPromise) — the next
|
|
102
|
+
// tool call's load() re-reads and recovers once the lock/perm clears.
|
|
103
|
+
this.writeBlocked =
|
|
104
|
+
`the note store at ${this.notesPath} could not be read (${errMsg(err)}); ` +
|
|
105
|
+
`writes are disabled until it becomes readable (retried each tool call) — ` +
|
|
106
|
+
`or recover/delete it manually.`;
|
|
107
|
+
this.loadNotice = this.writeBlocked; // notes are unavailable this session
|
|
108
|
+
this.blockedWarnKey = 'unreadable';
|
|
109
|
+
if (prevWarnKey !== this.blockedWarnKey) {
|
|
110
|
+
log.warn(`NoteStore.load: failed to read ${this.notesPath}: ${errMsg(err)}; writes disabled (retried each tool call)`);
|
|
111
|
+
}
|
|
112
|
+
this.loadPromise = null; // transient → allow the next load() to re-read
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// notes.json is absent → start empty, but if an interrupted-swap backup is
|
|
116
|
+
// present, warn AND surface it in-band (we never auto-restore — see
|
|
117
|
+
// warnIfBackupPresent). Crucially, BLOCK WRITES and reset the load memo:
|
|
118
|
+
// the message invites a manual `.bak`→notes.json restore, and with the
|
|
119
|
+
// empty view memoized and writable, the very next remember's atomic
|
|
120
|
+
// rename-over would CLOBBER the file the user just restored — destroying
|
|
121
|
+
// exactly the notes we told them to recover. Mirroring the
|
|
122
|
+
// transient-unreadable pattern (block + re-read each tool call) makes the
|
|
123
|
+
// advertised manual path survivable: restore → next load serves the real
|
|
124
|
+
// notes and re-enables writes; delete the .bak instead → next load starts
|
|
125
|
+
// empty-and-writable. Race-free because writes throw before mutating.
|
|
126
|
+
//
|
|
127
|
+
// ADJUDICATED TRADEOFF: this block also captures a stale ORPHAN .bak
|
|
128
|
+
// (successful Windows swap whose final unlink failed) followed by an
|
|
129
|
+
// intentional `rm notes.json` — that user hits a write outage until they
|
|
130
|
+
// delete the orphan. Post-hoc the two cases are indistinguishable on
|
|
131
|
+
// disk, notes are non-rebuildable, and the error message names the exact
|
|
132
|
+
// one-command remediation — so data-safety wins over availability here.
|
|
133
|
+
// Do not weaken the block without a way to tell the cases apart.
|
|
134
|
+
const baks = await this.detectBackups();
|
|
135
|
+
if (baks.length > 0) {
|
|
136
|
+
// Key on the backup NAME SET so a genuine re-entry (one .bak restored,
|
|
137
|
+
// notes.json rm'd again with another .bak still present) reads as a
|
|
138
|
+
// NEW state and warns again, while per-call re-loads of the SAME state
|
|
139
|
+
// stay silent. (detectBackups returns its names pre-sorted.)
|
|
140
|
+
this.blockedWarnKey = `bak:${baks.join('\0')}`;
|
|
141
|
+
if (prevWarnKey !== this.blockedWarnKey) {
|
|
142
|
+
log.warn(`NoteStore.load: ${this.notesPath} is missing but ${baks.length} backup ` +
|
|
143
|
+
`file(s) exist (${basename(this.notesPath)}.bak.*) — possibly from an ` +
|
|
144
|
+
`interrupted or a prior write. Inspect the newest and rename it to ` +
|
|
145
|
+
`${basename(this.notesPath)} to restore it (it may be older than your ` +
|
|
146
|
+
`last state), or delete the .bak file(s) to start fresh; writes stay ` +
|
|
147
|
+
`disabled until one or the other.`);
|
|
148
|
+
}
|
|
149
|
+
this.writeBlocked =
|
|
150
|
+
`the note store at ${this.notesPath} is missing but a backup (.bak) is ` +
|
|
151
|
+
`present — it may hold recoverable notes from an interrupted write. ` +
|
|
152
|
+
`Writes are disabled (retried each tool call) so a manual restore ` +
|
|
153
|
+
`can't be overwritten: rename the newest .bak to ` +
|
|
154
|
+
`${basename(this.notesPath)} to restore it, or delete the .bak ` +
|
|
155
|
+
`file(s) to start fresh.`;
|
|
156
|
+
this.loadNotice = this.writeBlocked;
|
|
157
|
+
this.loadPromise = null; // re-check on the next tool call
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
await this.quarantine('corrupt', 'malformed JSON', prevWarnKey);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!isValidStore(parsed)) {
|
|
170
|
+
await this.quarantine('corrupt', 'shape validation failed', prevWarnKey);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (parsed.projectRoot !== this.projectRoot) {
|
|
174
|
+
// These notes belong to a different project (e.g. a shared explicit
|
|
175
|
+
// cacheDir). Never serve them here; preserve the bytes for recovery.
|
|
176
|
+
await this.quarantine('otherroot', `projectRoot ${parsed.projectRoot}`, prevWarnKey);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.notes = parsed.notes;
|
|
180
|
+
this.storeCreatedAt = parsed.createdAt;
|
|
181
|
+
// NOTE: we deliberately do NOT reap `.bak.*` orphans here. A stray `.bak`
|
|
182
|
+
// cannot be proven redundant from a clean load alone — it may hold the ONLY
|
|
183
|
+
// copy of pre-crash notes (an interrupted Windows swap left notes.json absent,
|
|
184
|
+
// then a fresh, unrelated notes.json was written). Auto-deleting it would
|
|
185
|
+
// break warnIfBackupPresent's "preserve for manual recovery" promise. The
|
|
186
|
+
// successful-swap path already unlinks its own `.bak`; a rare crash-orphan is
|
|
187
|
+
// accepted clutter (Windows-only, locked-target-during-write). Do not re-add a
|
|
188
|
+
// reaper — every prior attempt (rounds 9-11) produced its own data-loss edge.
|
|
189
|
+
// (writeBlocked was already cleared at the top of doLoad.)
|
|
190
|
+
if (parsed.version > NOTES_STORE_VERSION) {
|
|
191
|
+
this.writeBlocked =
|
|
192
|
+
'the note store was written by a newer codedeep build; ' +
|
|
193
|
+
'writes are disabled to avoid clobbering it. Upgrade codedeep.';
|
|
194
|
+
log.warn(`NoteStore.load: ${this.notesPath} is version ${parsed.version} > ` +
|
|
195
|
+
`${NOTES_STORE_VERSION}; serving read-only (remember disabled)`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// --- queries (read-only; callers must have load()ed first) ---
|
|
199
|
+
all() {
|
|
200
|
+
// A COPY — like byFile/bySymbol/search — so a caller can sort/splice the
|
|
201
|
+
// result without mutating the store's live array.
|
|
202
|
+
return [...this.notes];
|
|
203
|
+
}
|
|
204
|
+
getById(id) {
|
|
205
|
+
return this.notes.find((n) => n.id === id);
|
|
206
|
+
}
|
|
207
|
+
// Notes with at least one anchor on `relPath`, newest first (so recall's
|
|
208
|
+
// limit/budget truncation keeps the most RECENT notes — matching search()).
|
|
209
|
+
byFile(relPath) {
|
|
210
|
+
return sortByRecency(this.notes.filter((n) => n.anchors.some((a) => a.file === relPath)));
|
|
211
|
+
}
|
|
212
|
+
// Notes with an anchor on `relPath` naming `symbolName`, newest first. Anchors
|
|
213
|
+
// store the QUALIFIED name ("Class.member"), so: a QUALIFIED query matches
|
|
214
|
+
// exactly (distinguishing two same-simple-named members in the file), while a
|
|
215
|
+
// bare SIMPLE query matches any member with that last segment. `::` scope in
|
|
216
|
+
// the query is folded to the extractor's `.` form first.
|
|
217
|
+
bySymbol(relPath, symbolName) {
|
|
218
|
+
const q = normalizeSymbolQuery(symbolName);
|
|
219
|
+
const qualified = q.includes('.');
|
|
220
|
+
return sortByRecency(this.notes.filter((n) => n.anchors.some((a) => a.file === relPath &&
|
|
221
|
+
a.symbol !== undefined &&
|
|
222
|
+
(qualified ? a.symbol === q : simpleSymbolName(a.symbol) === q))));
|
|
223
|
+
}
|
|
224
|
+
// Token/substring match over note text + anchor file/symbol. Returns
|
|
225
|
+
// {note, score} sorted by score desc then recency; score 0 entries dropped.
|
|
226
|
+
search(query) {
|
|
227
|
+
const tokens = query
|
|
228
|
+
.toLowerCase()
|
|
229
|
+
.split(/\s+/)
|
|
230
|
+
.map((t) => t.trim())
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
if (tokens.length === 0) {
|
|
233
|
+
// No usable query → everything, recency-ranked (score 1 each).
|
|
234
|
+
return sortByRecency(this.notes).map((note) => ({ note, score: 1 }));
|
|
235
|
+
}
|
|
236
|
+
const scored = [];
|
|
237
|
+
for (const note of this.notes) {
|
|
238
|
+
const text = note.text.toLowerCase();
|
|
239
|
+
// Anchors weigh more than free text: a query hitting an anchored
|
|
240
|
+
// file/symbol is a stronger signal than the same word in prose.
|
|
241
|
+
const anchorHay = note.anchors
|
|
242
|
+
.map((a) => `${a.file} ${a.symbol ?? ''}`)
|
|
243
|
+
.join(' ')
|
|
244
|
+
.toLowerCase();
|
|
245
|
+
let score = 0;
|
|
246
|
+
for (const tok of tokens) {
|
|
247
|
+
if (anchorHay.includes(tok))
|
|
248
|
+
score += 2;
|
|
249
|
+
else if (text.includes(tok))
|
|
250
|
+
score += 1;
|
|
251
|
+
}
|
|
252
|
+
if (score > 0)
|
|
253
|
+
scored.push({ note, score });
|
|
254
|
+
}
|
|
255
|
+
// Decorate with the parsed epoch so the tiebreak sort doesn't re-parse dates.
|
|
256
|
+
const decorated = scored.map((s) => ({ ...s, t: createdAtEpoch(s.note) }));
|
|
257
|
+
decorated.sort((a, b) => b.score - a.score || b.t - a.t || (a.note.id < b.note.id ? -1 : 1));
|
|
258
|
+
return decorated.map(({ note, score }) => ({ note, score }));
|
|
259
|
+
}
|
|
260
|
+
// --- mutations (write-through: persist after each change) ---
|
|
261
|
+
async add(note) {
|
|
262
|
+
await this.load();
|
|
263
|
+
if (this.writeBlocked)
|
|
264
|
+
throw new Error(this.writeBlocked);
|
|
265
|
+
// The snapshot, mutation, AND write all run inside runLocked so the
|
|
266
|
+
// read-modify-write is one critical section: `prev` is always the
|
|
267
|
+
// immediately-preceding COMMITTED state, so a rollback on persist failure
|
|
268
|
+
// can never clobber a concurrently-committed mutation (snapshotting
|
|
269
|
+
// outside the lock would let a failed write roll back over a later note).
|
|
270
|
+
await this.runLocked(async () => {
|
|
271
|
+
const prev = this.notes;
|
|
272
|
+
this.notes = [...prev, note];
|
|
273
|
+
try {
|
|
274
|
+
await this.writeStore();
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
this.notes = prev;
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async remove(id) {
|
|
283
|
+
await this.load();
|
|
284
|
+
if (this.writeBlocked)
|
|
285
|
+
throw new Error(this.writeBlocked);
|
|
286
|
+
return this.runLocked(async () => {
|
|
287
|
+
const prev = this.notes;
|
|
288
|
+
const next = prev.filter((n) => n.id !== id);
|
|
289
|
+
if (next.length === prev.length)
|
|
290
|
+
return false;
|
|
291
|
+
this.notes = next;
|
|
292
|
+
try {
|
|
293
|
+
await this.writeStore();
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
this.notes = prev;
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
// --- internals ---
|
|
303
|
+
// Atomic temp+fsync+rename of the current this.notes. NOT self-locked — every
|
|
304
|
+
// caller invokes it INSIDE runLocked (with the snapshot+mutation) so the whole
|
|
305
|
+
// read-modify-write is serialized.
|
|
306
|
+
async writeStore() {
|
|
307
|
+
const createdAt = this.storeCreatedAt ?? new Date().toISOString();
|
|
308
|
+
const data = {
|
|
309
|
+
version: NOTES_STORE_VERSION,
|
|
310
|
+
createdAt,
|
|
311
|
+
projectRoot: this.projectRoot,
|
|
312
|
+
notes: this.notes,
|
|
313
|
+
};
|
|
314
|
+
const json = JSON.stringify(data, null, 2);
|
|
315
|
+
const tmp = `${this.notesPath}.tmp.${process.pid}.${Date.now()}`;
|
|
316
|
+
await fs.mkdir(dirname(this.notesPath), { recursive: true });
|
|
317
|
+
try {
|
|
318
|
+
const fh = await fs.open(tmp, 'w');
|
|
319
|
+
try {
|
|
320
|
+
await fh.writeFile(json);
|
|
321
|
+
await fh.sync();
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await fh.close();
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
await fs.rename(tmp, this.notesPath);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// A rename over an existing file fails on Windows. NEVER unlink the only
|
|
331
|
+
// copy (notes are non-rebuildable, unlike CodeIndex): move the current
|
|
332
|
+
// store aside to a UNIQUE `.bak.<pid>.<ts>` — a FIXED name could itself be
|
|
333
|
+
// a locked leftover and wedge every write; a unique target never collides.
|
|
334
|
+
// Put the new one in place, restore the .bak if the swap fails so a
|
|
335
|
+
// notes.json always survives, then drop the .bak. A crash between the two
|
|
336
|
+
// renames leaves the pre-write store in a `.bak` for MANUAL recovery (load
|
|
337
|
+
// warns; it never auto-restores — see the ENOENT branch in doLoad).
|
|
338
|
+
const bak = `${this.notesPath}.bak.${process.pid}.${Date.now()}`;
|
|
339
|
+
await fs.rename(this.notesPath, bak);
|
|
340
|
+
try {
|
|
341
|
+
await fs.rename(tmp, this.notesPath);
|
|
342
|
+
}
|
|
343
|
+
catch (swapErr) {
|
|
344
|
+
await fs.rename(bak, this.notesPath).catch(() => undefined);
|
|
345
|
+
throw swapErr;
|
|
346
|
+
}
|
|
347
|
+
await fs.unlink(bak).catch(() => undefined);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
// Only stamp the store's createdAt after a SUCCESSFUL persist, so a failed
|
|
355
|
+
// first write (rolled back by add/remove) doesn't leave storeCreatedAt set.
|
|
356
|
+
this.storeCreatedAt = createdAt;
|
|
357
|
+
}
|
|
358
|
+
// notes.json is absent — list any `.bak` survivors from an interrupted swap
|
|
359
|
+
// (or a prior write whose cleanup didn't run). Silent (the caller owns the
|
|
360
|
+
// warn-once-per-state-entry decision). We deliberately do NOT auto-restore:
|
|
361
|
+
// an absent notes.json is indistinguishable from an intentional `rm`, and a
|
|
362
|
+
// lingering backup may be OLDER than the last state — silently resurrecting
|
|
363
|
+
// it is worse than starting empty. The caller BLOCKS writes so the advertised
|
|
364
|
+
// manual restore can't be clobbered by a memoized empty store. Never throws.
|
|
365
|
+
// Returned names are SORTED so callers can use them directly as a stable
|
|
366
|
+
// state-identity key (readdir order is platform-dependent).
|
|
367
|
+
async detectBackups() {
|
|
368
|
+
try {
|
|
369
|
+
const dir = dirname(this.notesPath);
|
|
370
|
+
const prefix = `${basename(this.notesPath)}.bak.`;
|
|
371
|
+
return (await fs.readdir(dir)).filter((e) => e.startsWith(prefix)).sort();
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// no dir / unreadable → nothing to recover, start empty
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Preserve unreadable/foreign bytes for manual recovery, then start empty.
|
|
379
|
+
// If the rename SUCCEEDS the original is safe aside, so writes may proceed
|
|
380
|
+
// (that warn is unconditional — the file is renamed away, so it fires once
|
|
381
|
+
// by nature). If it FAILS the original is still at notesPath — block writes,
|
|
382
|
+
// because a later persist() would atomically overwrite (and destroy) those
|
|
383
|
+
// un-recovered bytes, violating the "never lose notes" invariant; that
|
|
384
|
+
// failure state RE-LOADS per tool call, so its warn is gated on state entry
|
|
385
|
+
// (prevWarnKey). Never throws out of load.
|
|
386
|
+
async quarantine(kind, reason, prevWarnKey) {
|
|
387
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
388
|
+
const aside = `${this.notesPath}.${kind}-${stamp}`;
|
|
389
|
+
try {
|
|
390
|
+
await fs.rename(this.notesPath, aside);
|
|
391
|
+
log.warn(`NoteStore.load: ${this.notesPath} ${reason}; quarantined to ${aside}; starting empty`);
|
|
392
|
+
// Writes are fine (empty store), but the prior notes were MOVED ASIDE —
|
|
393
|
+
// surface it so recall doesn't present the empty view as genuine absence.
|
|
394
|
+
this.loadNotice = `the previous note store was ${reason} and moved aside to ${aside}; starting empty`;
|
|
395
|
+
}
|
|
396
|
+
catch (err) {
|
|
397
|
+
this.writeBlocked =
|
|
398
|
+
`the note store at ${this.notesPath} could not be read or moved aside ` +
|
|
399
|
+
`(${reason}); writes are disabled to avoid overwriting it — recover or ` +
|
|
400
|
+
`delete it manually.`;
|
|
401
|
+
this.loadNotice = this.writeBlocked;
|
|
402
|
+
// The key carries the CAUSE: transitioning between different
|
|
403
|
+
// quarantine-fail reasons (corrupt → foreign-root, say the user swapped
|
|
404
|
+
// the file while the dir stayed unwritable) is a NEW state whose fresh
|
|
405
|
+
// cause must reach stderr, not be suppressed as a repeat.
|
|
406
|
+
this.blockedWarnKey = `quarantine-fail:${kind}:${reason}`;
|
|
407
|
+
if (prevWarnKey !== this.blockedWarnKey) {
|
|
408
|
+
log.warn(`NoteStore.load: ${this.notesPath} ${reason}; could not quarantine ` +
|
|
409
|
+
`(${errMsg(err)}); writes disabled, original left in place`);
|
|
410
|
+
}
|
|
411
|
+
// The rename failure is often TRANSIENT (a read-only-for-a-moment dir) —
|
|
412
|
+
// reset the memo so a later load() re-attempts the quarantine once the dir
|
|
413
|
+
// is writable again, instead of latching the block for the whole session.
|
|
414
|
+
this.loadPromise = null;
|
|
415
|
+
}
|
|
416
|
+
this.notes = [];
|
|
417
|
+
this.storeCreatedAt = null;
|
|
418
|
+
}
|
|
419
|
+
// Scoped to OUR basename prefix so it can never reap an index.json temp
|
|
420
|
+
// sharing the directory (mirrors CodeIndex.cleanupStaleTmp).
|
|
421
|
+
async cleanupStaleTmp() {
|
|
422
|
+
try {
|
|
423
|
+
const dir = dirname(this.notesPath);
|
|
424
|
+
const tmpPrefix = `${basename(this.notesPath)}.tmp.`;
|
|
425
|
+
const entries = await fs.readdir(dir);
|
|
426
|
+
await Promise.all(entries
|
|
427
|
+
.filter((e) => e.startsWith(tmpPrefix))
|
|
428
|
+
.map((e) => fs.unlink(join(dir, e)).catch(() => undefined)));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// ignore: parent dir may not exist yet
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
runLocked(work) {
|
|
435
|
+
const next = this.writeLock.then(work);
|
|
436
|
+
this.writeLock = next.then(() => undefined, () => undefined);
|
|
437
|
+
return next;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// Parse to epoch so an offset-form ISO ("...+08:00") or a hand-edited/imported
|
|
441
|
+
// createdAt can't win a lexicographic comparison and float above real UTC-Z
|
|
442
|
+
// notes. NaN → 0 (treated as oldest) so a garbage value sinks, not dominates.
|
|
443
|
+
function createdAtEpoch(n) {
|
|
444
|
+
return Date.parse(n.createdAt) || 0;
|
|
445
|
+
}
|
|
446
|
+
// Newest first, decorate-sort-undecorate so each createdAt is parsed once (not
|
|
447
|
+
// O(n log n) times inside a comparator).
|
|
448
|
+
function sortByRecency(notes) {
|
|
449
|
+
return notes
|
|
450
|
+
.map((note) => ({ note, t: createdAtEpoch(note) }))
|
|
451
|
+
.sort((a, b) => b.t - a.t || (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0))
|
|
452
|
+
.map(({ note }) => note);
|
|
453
|
+
}
|
|
454
|
+
// Anchors store the qualified symbol name; reduce an FQN-style name to its last
|
|
455
|
+
// segment ("Ns::Type::login" / "Type.login" → "login").
|
|
456
|
+
function simpleSymbolName(s) {
|
|
457
|
+
const parts = s.split(/::|\./);
|
|
458
|
+
return parts[parts.length - 1];
|
|
459
|
+
}
|
|
460
|
+
// A symbol's file-qualified name = its fqn suffix after "<file>:" ("Class.member"
|
|
461
|
+
// for a member, the bare name for a top-level symbol), or `fallback` when the
|
|
462
|
+
// fqn doesn't carry the file prefix. This is what remember stores in
|
|
463
|
+
// anchor.symbol, so staleness must reconstruct it the same way to match.
|
|
464
|
+
export function qualifiedSymbolName(fqn, file, fallback) {
|
|
465
|
+
const prefix = `${file}:`;
|
|
466
|
+
return fqn.startsWith(prefix) ? fqn.slice(prefix.length) : fallback;
|
|
467
|
+
}
|
|
468
|
+
// Fold `::` scope separators to the extractor's dotted FQN form
|
|
469
|
+
// ("Ns::Type::method" → "Ns.Type.method"). The ONE normalization every
|
|
470
|
+
// symbol-name entry point applies — remember's anchor resolution, bySymbol
|
|
471
|
+
// queries — so a note stored from a C++-style query is findable by either form.
|
|
472
|
+
export function normalizeSymbolQuery(symbolName) {
|
|
473
|
+
return symbolName.replace(/::/g, '.');
|
|
474
|
+
}
|
|
475
|
+
function isValidStore(data) {
|
|
476
|
+
if (typeof data !== 'object' || data === null)
|
|
477
|
+
return false;
|
|
478
|
+
const d = data;
|
|
479
|
+
return (typeof d.version === 'number' &&
|
|
480
|
+
typeof d.createdAt === 'string' &&
|
|
481
|
+
typeof d.projectRoot === 'string' &&
|
|
482
|
+
Array.isArray(d.notes) &&
|
|
483
|
+
d.notes.every(isValidNote));
|
|
484
|
+
}
|
|
485
|
+
function isValidNote(value) {
|
|
486
|
+
if (typeof value !== 'object' || value === null)
|
|
487
|
+
return false;
|
|
488
|
+
const n = value;
|
|
489
|
+
return (typeof n.id === 'string' &&
|
|
490
|
+
typeof n.text === 'string' &&
|
|
491
|
+
typeof n.createdAt === 'string' &&
|
|
492
|
+
(n.head === undefined || typeof n.head === 'string') &&
|
|
493
|
+
Array.isArray(n.anchors) &&
|
|
494
|
+
n.anchors.every(isValidAnchor));
|
|
495
|
+
}
|
|
496
|
+
function isValidAnchor(value) {
|
|
497
|
+
if (typeof value !== 'object' || value === null)
|
|
498
|
+
return false;
|
|
499
|
+
const a = value;
|
|
500
|
+
if (typeof a.file !== 'string')
|
|
501
|
+
return false;
|
|
502
|
+
for (const key of [
|
|
503
|
+
'fileContentHash',
|
|
504
|
+
'symbol',
|
|
505
|
+
'symbolId',
|
|
506
|
+
'symbolKind',
|
|
507
|
+
'signature',
|
|
508
|
+
]) {
|
|
509
|
+
if (a[key] !== undefined && typeof a[key] !== 'string')
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|