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.
@@ -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
+ }