claude-mem-lite 6.3.0 → 6.5.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/lib/db-backup.mjs CHANGED
@@ -43,26 +43,51 @@ export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
43
43
  // user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
44
44
  const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
45
45
 
46
- /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
47
- export function listSnapshots(dbPath) {
46
+ /**
47
+ * Snapshots for `dbPath`, keeping "there are none" and "I could not look" APART.
48
+ *
49
+ * `listSnapshots` below collapses both into `[]`, which is fine for its callers (a budget
50
+ * sweep has nothing to do either way) and wrong for anything that REPORTS to a human: a
51
+ * diagnostic that says "no backup exists" when it actually could not read the directory
52
+ * ends the reader's search with a false fact. Same rule the doctor bash-hook check
53
+ * settled on — "nothing to check" and "I could not look" are different answers.
54
+ *
55
+ * @returns {{ok: true, snapshots: Array<{path:string,size:number,mtimeMs:number}>}
56
+ * | {ok: false, reason: string}}
57
+ */
58
+ export function readSnapshots(dbPath) {
59
+ let names;
60
+ let dir;
61
+ let prefix;
62
+ // dirname/basename inside the try, not outside it: `listSnapshots` below has always been
63
+ // total (it returned [] for any input), and one of this function's callers runs inside
64
+ // `doctor`'s DB catch block, where the repo's openDb precedent says nothing may throw.
65
+ // Computing the path outside would make listSnapshots(undefined) a TypeError.
48
66
  try {
49
- const dir = dirname(dbPath);
50
- const prefix = `${basename(dbPath)}.`;
51
- const out = [];
52
- for (const n of readdirSync(dir)) {
53
- if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
54
- const full = join(dir, n);
55
- try {
56
- const st = statSync(full);
57
- out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
58
- } catch {
59
- /* raced away */
60
- }
67
+ dir = dirname(dbPath);
68
+ prefix = `${basename(dbPath)}.`;
69
+ names = readdirSync(dir);
70
+ } catch (e) {
71
+ return { ok: false, reason: e?.code || e?.message || 'unreadable' };
72
+ }
73
+ const snapshots = [];
74
+ for (const n of names) {
75
+ if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
76
+ const full = join(dir, n);
77
+ try {
78
+ const st = statSync(full);
79
+ snapshots.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
80
+ } catch {
81
+ /* raced away */
61
82
  }
62
- return out;
63
- } catch {
64
- return [];
65
83
  }
84
+ return { ok: true, snapshots };
85
+ }
86
+
87
+ /** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
88
+ export function listSnapshots(dbPath) {
89
+ const r = readSnapshots(dbPath);
90
+ return r.ok ? r.snapshots : [];
66
91
  }
67
92
 
68
93
  /**
@@ -0,0 +1,178 @@
1
+ // lib/db-unusable.mjs — the database file is not a usable database.
2
+ //
3
+ // The sibling of lib/schema-skew.mjs, and it exists for the same reason: the throw is
4
+ // correct, and everything downstream of it was missing. Measured 2026-09-08 in a sandboxed
5
+ // HOME with a corrupted header:
6
+ //
7
+ // • CLI (`search` / `recent` / `stats` / `get` / `save` / `browse` / `fts-check`) — all exit
8
+ // 1 with the SQLite message. Correct.
9
+ // • `status` — "⚠ Database: exists but check failed". Correct.
10
+ // • `doctor` — names the file and prints an exact repair command. Correct.
11
+ // • Hooks — `openDb()` returns null, `hook.mjs`'s `const db = openDb(); if (!db) return;`
12
+ // ends SessionStart with EMPTY stdout and EMPTY stderr, and every fire wrote another
13
+ // stack trace to runtime/hook-errors/. Nothing the user sees says memory is off.
14
+ //
15
+ // State that last figure carefully, because the first draft got it wrong twice over: it said
16
+ // "20 fires → 10 identical ~1.5 KB records", which was a MIXED fire set (only some events
17
+ // reach this path) reported as if it were 20 SessionStarts, and a size nobody measured.
18
+ // Re-measured over 20 SessionStart fires: 20 records, 859-873 bytes each — one per fire, and
19
+ // ~1.7x smaller than claimed. `recordHookError` caps the stack at 6 frames and the message at
20
+ // 500 chars, so the per-record size is bounded; the unbounded quantity is the COUNT.
21
+ //
22
+ // So the one surface the user is actually looking at during a session was the only silent
23
+ // one, on a condition that never heals by itself. That is the schema-skew shape exactly, and
24
+ // this module is deliberately its mirror image so the two cannot drift apart.
25
+ //
26
+ // It imports no native binding: the static graph is 10 modules and zero package edges (the
27
+ // only bare specifiers anywhere in it are the `node:` builtins). That is the property that
28
+ // matters — a classifier the hook path may need while the binding is the broken thing must
29
+ // not sit behind a native import. It is NOT as tight as schema-skew.mjs, which imports
30
+ // `node:` builtins and nothing else; this one reaches `db-backup → utils → …` for
31
+ // `readSnapshots`. Same guarantee, larger graph — do not restate it as "same discipline".
32
+
33
+ import { basename, dirname } from 'node:path';
34
+ import { readSnapshots } from './db-backup.mjs';
35
+
36
+ /** Marker prefix for lib/record-once.mjs. Per project, like the skew one. */
37
+ export const DB_UNUSABLE_MARKER_PREFIX = '.db-unusable-logged-';
38
+
39
+ // SQLite's own spellings for "this file is not a usable database". Kept here as the single
40
+ // definition so install.mjs's doctor check and the hook path classify identically — they used
41
+ // to hold one copy each, which is this repo's named twin-drift class.
42
+ //
43
+ // Deliberately NOT matching "unable to open database file": that is a PERMISSIONS or
44
+ // missing-directory failure, whose remedy is nothing like the two below, and answering it
45
+ // with "move the file aside" would tell a user to destroy a healthy store.
46
+ const UNUSABLE_RE = /not a database|disk image is malformed/i;
47
+
48
+ /**
49
+ * Whether an error is a damaged FTS5 INDEX rather than a damaged database file.
50
+ *
51
+ * THE MESSAGE CANNOT TELL THEM APART. SQLite reports a damaged index as
52
+ * SQLITE_CORRUPT_VTAB with the same "database disk image is malformed" text the file-level
53
+ * faults use, so the code is the only discriminator — and matching on message alone is what
54
+ * conflated them before R10 P3-9. The remedy is rebuildFTS, which the FTS content lets us do
55
+ * losslessly; treating it as file corruption would offer `cp <old snapshot> <db>` over a
56
+ * database whose rows are all intact.
57
+ *
58
+ * This lives HERE rather than in schema.mjs (which re-exports it, so every existing importer
59
+ * is unchanged) because both consumers of the distinction now need it and schema.mjs imports
60
+ * better-sqlite3 — a classifier the hook path may need while the binding is the broken thing
61
+ * must not be behind a native import.
62
+ *
63
+ * @param {unknown} err
64
+ * @returns {boolean}
65
+ */
66
+ export function isFtsCorruptionError(err) {
67
+ return /SQLITE_CORRUPT_VTAB/i.test(`${err?.code || ''}`);
68
+ }
69
+
70
+ /**
71
+ * True when `err` means "this file exists and SQLite cannot use it as a database".
72
+ *
73
+ * Accepts anything thrown (Error, string, null) because recordHookError does.
74
+ *
75
+ * @param {unknown} err
76
+ * @returns {boolean}
77
+ */
78
+ export function isDbUnusableError(err) {
79
+ if (!err) return false;
80
+ // FIRST, and for the same reason isDbCorruptionError does it first: a damaged index is a
81
+ // HEALTHY FILE, and every remedy below is wrong — and destructive — for one.
82
+ if (isFtsCorruptionError(err)) return false;
83
+ return UNUSABLE_RE.test(String(err.message ?? err ?? ''));
84
+ }
85
+
86
+ /**
87
+ * What the user should actually run. Three outcomes, never two — the same rule schema-skew.mjs
88
+ * states: "there is no backup" and "I could not look for one" must not print in the same
89
+ * voice, because a green-sounding line ends the reader's search.
90
+ *
91
+ * `snapshotCount` is part of the return rather than something a caller re-derives: doctor's
92
+ * sentence names it ("Restore the newest of 2 backup snapshot(s)"), and a second
93
+ * `readSnapshots` call to recover a number this function already had would be a second
94
+ * directory read that can disagree with the first.
95
+ *
96
+ * @param {string} dbPath
97
+ * @returns {{kind: 'restore'|'set-aside'|'unknown', command: string, note: string, snapshotCount: number}}
98
+ */
99
+ export function dbUnusableRemedy(dbPath) {
100
+ const clear = `rm -f "${dbPath}-wal" "${dbPath}-shm"`;
101
+ const snap = readSnapshots(dbPath);
102
+ if (!snap.ok) {
103
+ return {
104
+ kind: 'unknown',
105
+ command: '',
106
+ snapshotCount: 0,
107
+ note:
108
+ `Could not read ${dirname(dbPath)} to look for a backup snapshot (${snap.reason}) — ` +
109
+ `fix that directory first, then look for ${basename(dbPath)}.*.bak beside the database.`,
110
+ };
111
+ }
112
+ if (snap.snapshots.length === 0) {
113
+ return {
114
+ kind: 'set-aside',
115
+ command: `${clear} && mv "${dbPath}" "${dbPath}.corrupt"`,
116
+ snapshotCount: 0,
117
+ note:
118
+ 'No backup snapshot exists beside the database. That command sets the broken file ' +
119
+ 'aside so a fresh store is created on the next session — memories in it are not ' +
120
+ 'recoverable without a backup.',
121
+ };
122
+ }
123
+ // Newest by mtime, ties broken by name (which carries an ISO stamp) so the answer is total
124
+ // rather than dependent on readdir order — the D#9 shape.
125
+ const newest = snap.snapshots
126
+ .slice()
127
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? 1 : -1))[0];
128
+ return {
129
+ kind: 'restore',
130
+ command: `${clear} && cp "${newest.path}" "${dbPath}"`,
131
+ snapshotCount: snap.snapshots.length,
132
+ note:
133
+ `Restores the newest of ${snap.snapshots.length} backup snapshot(s). Move the broken ` +
134
+ 'file aside first if you want to keep it for inspection.',
135
+ };
136
+ }
137
+
138
+ /**
139
+ * The user-facing block. Short on purpose — at SessionStart it shares one stdout envelope
140
+ * with the startup dashboard and the `<claude-mem-context>` block.
141
+ *
142
+ * @param {{dbPath: string, remedy: ReturnType<typeof dbUnusableRemedy>}} info
143
+ * @returns {string}
144
+ */
145
+ export function formatDbUnusableNotice({ dbPath, remedy }) {
146
+ const lines = [
147
+ '⚠️ [claude-mem-lite] Memory is OFF: the database file cannot be opened.',
148
+ ` ${dbPath} exists but SQLite does not recognise it as a database.`,
149
+ ];
150
+ if (remedy.command) lines.push(` ${remedy.command}`);
151
+ if (remedy.note) lines.push(` ${remedy.note}`);
152
+ lines.push(' Until then, saves and recall are disabled. `claude-mem-lite doctor` re-checks.');
153
+ return lines.join('\n');
154
+ }
155
+
156
+ /**
157
+ * The same fact, with NO COMMAND IN IT, for the model channel.
158
+ *
159
+ * The schema-skew twin sends one string to both channels and that is safe there — its
160
+ * commands are `git pull` / `claude plugin update` / `npm i -g`. This family's remedy is
161
+ * `rm -f …-wal …-shm && cp "<snapshot>" "<db>"`, which OVERWRITES the database, and handing
162
+ * a ready-to-run irreversible shell line to an agent that has Bash — with no addressee and no
163
+ * "ask first" — is a different proposition. Worse, the restore arm defeats its own advice: a
164
+ * reader who acts on the command line destroys the broken file the next line tells them to
165
+ * keep for inspection.
166
+ *
167
+ * So the human gets the command and the model gets the situation. Both still learn memory is
168
+ * off, which is the point of using both channels at all.
169
+ *
170
+ * @returns {string}
171
+ */
172
+ export function formatDbUnusableModelNotice() {
173
+ return [
174
+ '⚠️ [claude-mem-lite] Memory is OFF for this session: the database file is unreadable.',
175
+ ' Saves and recall are disabled. Do not attempt to repair it yourself — tell the user to',
176
+ ' run `claude-mem-lite doctor`, which prints the exact command for their machine.',
177
+ ].join('\n');
178
+ }
@@ -24,8 +24,11 @@
24
24
  /** Marketplace this plugin is published under. */
25
25
  export const MARKETPLACE_KEY = 'sdsrss';
26
26
 
27
+ /** This plugin's own name — the directory Claude Code materializes versions under. */
28
+ export const PLUGIN_NAME = 'claude-mem-lite';
29
+
27
30
  /** The key Claude Code writes under `enabledPlugins` in `~/.claude/settings.json`. */
28
- export const PLUGIN_KEY = `claude-mem-lite@${MARKETPLACE_KEY}`;
31
+ export const PLUGIN_KEY = `${PLUGIN_NAME}@${MARKETPLACE_KEY}`;
29
32
 
30
33
  /**
31
34
  * Whether the user has EXPLICITLY switched the plugin off.
@@ -0,0 +1,71 @@
1
+ // lib/record-once.mjs — "has this condition already been logged recently, for this project?"
2
+ //
3
+ // Extracted from lib/schema-skew.mjs when a SECOND unhealable DB-open family needed the same
4
+ // dedup (a database file that is not a database). Both callers run inside `openDb()`'s catch,
5
+ // where the contract is return-null-never-throw, and both log a multi-line stack trace on a
6
+ // condition that repeats on every single hook fire until a human intervenes — the shape that
7
+ // put >=648 identical lines in one day's runtime/hook-errors/ before the skew round.
8
+ //
9
+ // Copying the implementation instead would be this repo's named twin-drift class, on a
10
+ // function whose two hard properties (total, fails-toward-recording) were each paid for with
11
+ // a review round.
12
+ //
13
+ // TOTAL by contract — every path returns a boolean and nothing escapes. The first version of
14
+ // the skew dedup called `getSessionId()` from inside the catch, which is not a read: it MINTS
15
+ // and writes a session id, so an unwritable runtime dir turned `openDb()` itself into a
16
+ // thrower. Keep every filesystem call in this file inside its own try.
17
+ //
18
+ // FAILS TOWARD RECORDING — an unreadable or unwritable marker must never silence the log.
19
+
20
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ /**
24
+ * One hour. The condition persists until the user acts, so "record it once, ever" would be
25
+ * defensible; an hour keeps the log's remaining job (when did this start, is it still
26
+ * happening) answerable at <=24 lines/day/project.
27
+ */
28
+ export const RELOG_INTERVAL_MS = 60 * 60 * 1000;
29
+
30
+ /**
31
+ * @param {string} runtimeDir Where the marker lives.
32
+ * @param {string} markerPrefix Distinguishes conditions; the project is appended to it.
33
+ * @param {string} project Marker scope. PER PROJECT, deliberately: one shared marker keyed on
34
+ * a per-project value let two projects on one data dir overwrite each other's key, so every
35
+ * fire recorded again — measured at 8 fires / 2 projects → 8 records. Anything falsy
36
+ * collapses to one shared file, which is a worse dedup rather than a crash.
37
+ * @param {string} key Re-record when this changes: new information is not the fault already
38
+ * logged.
39
+ * @param {{now?: number, intervalMs?: number}} [opts]
40
+ * @returns {boolean} true = write the log line now
41
+ */
42
+ export function shouldRecordOnce(
43
+ runtimeDir,
44
+ markerPrefix,
45
+ project,
46
+ key,
47
+ { now = Date.now(), intervalMs = RELOG_INTERVAL_MS } = {},
48
+ ) {
49
+ try {
50
+ const scope = String(project || 'unscoped').replace(/[^A-Za-z0-9._-]/g, '_');
51
+ const file = join(runtimeDir, markerPrefix + scope);
52
+ try {
53
+ const prev = JSON.parse(readFileSync(file, 'utf8'));
54
+ if (prev.key === key && typeof prev.ts === 'number' && now - prev.ts < intervalMs) return false;
55
+ } catch {
56
+ /* absent, unreadable or corrupt → record */
57
+ }
58
+ try {
59
+ // The dir may not exist yet: hook-shared creates RUNTIME_DIR at module scope, but the
60
+ // `ups` face does not import it, so on a fresh data dir the first marker write failed
61
+ // silently and the SECOND fire recorded again.
62
+ mkdirSync(runtimeDir, { recursive: true });
63
+ writeFileSync(file, JSON.stringify({ key, ts: now }), { mode: 0o600 });
64
+ } catch {
65
+ /* an unwritable marker must not suppress the record */
66
+ }
67
+ return true;
68
+ } catch {
69
+ return true;
70
+ }
71
+ }
@@ -140,6 +140,7 @@ export function formatSupersedeSkipped(skipped) {
140
140
  * @param {string} params.project Resolved project key.
141
141
  * @param {string[]} [params.files=[]] File paths to attach (junction table).
142
142
  * @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
143
+ * @param {boolean} [params.force=false] Skip the near-duplicate window (below).
143
144
  * @param {Date} [params.now] Override for tests.
144
145
  * Both result shapes carry `supersededIds` (observations actually tombstoned) and
145
146
  * `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
@@ -257,11 +258,21 @@ export function saveObservation(db, params) {
257
258
  malformed: malformedSupersedes,
258
259
  } = splitSupersedeTokens(rawSupersedes);
259
260
 
260
- const dupMatch = recent.find(
261
- (r) =>
262
- jaccardSimilarity(r.title, safeTitle) > DEDUP_JACCARD_THRESHOLD ||
263
- jaccardSimilarity(r.text || '', safeContent) > DEDUP_JACCARD_THRESHOLD,
264
- );
261
+ // `force` is the escape hatch, and it is opt-in for a reason on each side. The window
262
+ // exists to stop hook-driven auto-saves re-accumulating one story, and it must stay the
263
+ // default. But it also refuses a LEGITIMATE distinct memory: measured 2026-09-08, three
264
+ // deliberately different lessons about the same file inside five minutes collapsed to one
265
+ // save, the second and third answered with "Skipped: similar to existing #N" and exit 0 —
266
+ // and there was no flag, so the only workaround was to reword until Jaccard fell below
267
+ // 0.7, i.e. to degrade the memory to get it stored. A caller who has read that message and
268
+ // still means it can say so.
269
+ const dupMatch = params.force
270
+ ? undefined
271
+ : recent.find(
272
+ (r) =>
273
+ jaccardSimilarity(r.title, safeTitle) > DEDUP_JACCARD_THRESHOLD ||
274
+ jaccardSimilarity(r.text || '', safeContent) > DEDUP_JACCARD_THRESHOLD,
275
+ );
265
276
  if (dupMatch) {
266
277
  // D#201: a dedup short-circuit swallows the requested supersession too — you
267
278
  // write a correction, it reads as a near-duplicate of something saved in the
@@ -38,9 +38,10 @@
38
38
  // child process (probeSchemaCompatInFreshProcess).
39
39
 
40
40
  import { spawnSync } from 'node:child_process';
41
- import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
41
+ import { realpathSync } from 'node:fs';
42
42
  import { join, resolve } from 'node:path';
43
43
  import { pathToFileURL } from 'node:url';
44
+ import { shouldRecordOnce } from './record-once.mjs';
44
45
 
45
46
  /** Machine-readable marker set by schema.mjs on the forward-incompat throw. */
46
47
  export const SCHEMA_SKEW_CODE = 'CLAUDE_MEM_SCHEMA_TOO_NEW';
@@ -76,38 +77,28 @@ export const SKEW_RELOG_INTERVAL_MS = 60 * 60 * 1000;
76
77
  * @param {{now?: number, intervalMs?: number}} [opts]
77
78
  * @returns {boolean}
78
79
  */
79
- export function shouldRecordSkew(
80
- runtimeDir,
81
- project,
82
- info,
83
- { now = Date.now(), intervalMs = SKEW_RELOG_INTERVAL_MS } = {},
84
- ) {
80
+ export function shouldRecordSkew(runtimeDir, project, info, opts = {}) {
81
+ // The body moved to lib/record-once.mjs when a second unhealable DB-open family needed the
82
+ // same dedup; this signature and its key derivation stay here because they are skew's, not
83
+ // the generic helper's. A changed version pair always re-records: a PARTIAL upgrade (the
84
+ // binary moves v48→v49 while the DB moves to v50) is new information, not the fault already
85
+ // logged.
86
+ // The key derivation is INSIDE a try because this function's contract is TOTAL and the
87
+ // extraction briefly lost that: `info` reaches here from a catch block, and a property
88
+ // getter or `toString` that throws would have propagated out of `openDb()`'s catch — the
89
+ // same shape as the `getSessionId()` incident this module's header cites. Unreachable from
90
+ // today's two callers (both pass a plain object or null); restored anyway, because the
91
+ // sentence at hook-shared.mjs's call site asserts the property, not the reachability.
92
+ let key;
85
93
  try {
86
- const scope = String(project || 'unscoped').replace(/[^A-Za-z0-9._-]/g, '_');
87
- const file = join(runtimeDir, SKEW_MARKER_PREFIX + scope);
88
- const key = `${info?.dbVersion ?? '?'}:${info?.binaryVersion ?? '?'}`;
89
- try {
90
- const prev = JSON.parse(readFileSync(file, 'utf8'));
91
- // A changed version pair always re-records: a PARTIAL upgrade (the binary moves v48→v49
92
- // while the DB moves to v50) is new information, not the fault we already logged.
93
- if (prev.key === key && typeof prev.ts === 'number' && now - prev.ts < intervalMs) return false;
94
- } catch {
95
- /* absent, unreadable or corrupt → record */
96
- }
97
- try {
98
- // The dir may not exist yet. hook-shared creates RUNTIME_DIR at module scope, but the
99
- // `ups` face does not import it — so on a fresh data dir the first marker write failed
100
- // silently and the SECOND fire recorded again. Measured: 4 ups fires produced 2 records
101
- // instead of 1, both from the same call site, with the marker present afterwards.
102
- mkdirSync(runtimeDir, { recursive: true });
103
- writeFileSync(file, JSON.stringify({ key, ts: now }), { mode: 0o600 });
104
- } catch {
105
- /* an unwritable marker must not suppress the record */
106
- }
107
- return true;
94
+ key = `${info?.dbVersion ?? '?'}:${info?.binaryVersion ?? '?'}`;
108
95
  } catch {
109
- return true;
96
+ key = '?:?';
110
97
  }
98
+ return shouldRecordOnce(runtimeDir, SKEW_MARKER_PREFIX, project, key, {
99
+ intervalMs: SKEW_RELOG_INTERVAL_MS,
100
+ ...opts,
101
+ });
111
102
  }
112
103
 
113
104
  // The shipped message, which older builds throw with no code field at all. Kept as a
@@ -18,6 +18,23 @@ import { liveObsFilterSql } from './inject-search-core.mjs';
18
18
 
19
19
  const TIMELINE_COLS = 'id, type, title, subtitle, project, created_at, created_at_epoch';
20
20
 
21
+ // A P#/S#/E# anchor names a row that BELONGS to a project, and both surfaces document the
22
+ // conversion as "the nearest-in-time observation in the same project". It was resolved
23
+ // globally, which does not merely pick one odd row: fetchTimelineWindow below then scopes
24
+ // the whole before/after window to `project || anchorRow.project`, so one cross-project
25
+ // anchor moves the entire rendered timeline into another project with nothing marking it.
26
+ // Each source therefore reports its own project alongside the epoch. `user_prompts` has no
27
+ // project column — it reaches one through the session that owns the prompt, which is the
28
+ // same edge `sdk_sessions` already carries for every other prompt-scoped read.
29
+ const SOURCE_ROW_SQL = {
30
+ prompt: `SELECT p.created_at_epoch AS created_at_epoch, s.project AS project
31
+ FROM user_prompts p
32
+ LEFT JOIN sdk_sessions s ON s.content_session_id = p.content_session_id
33
+ WHERE p.id = ?`,
34
+ session: 'SELECT created_at_epoch, project FROM session_summaries WHERE id = ?',
35
+ event: 'SELECT created_at_epoch, project FROM events WHERE id = ?',
36
+ };
37
+
21
38
  /** Nearest live (non-compressed, non-superseded) observation to `epoch`. */
22
39
  function nearestObservation(db, epoch, project) {
23
40
  return db
@@ -48,17 +65,25 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
48
65
  }
49
66
 
50
67
  if (parsed.source === 'prompt' || parsed.source === 'session') {
51
- const srcTable = parsed.source === 'prompt' ? 'user_prompts' : 'session_summaries';
52
68
  const srcPrefix = parsed.source === 'prompt' ? 'P#' : 'S#';
53
69
  const srcName = parsed.source === 'prompt' ? 'Prompt' : 'Session';
54
- const row = db.prepare(`SELECT created_at_epoch FROM ${srcTable} WHERE id = ?`).get(parsed.id);
70
+ const row = db.prepare(SOURCE_ROW_SQL[parsed.source]).get(parsed.id);
55
71
  if (!row)
56
72
  return {
57
73
  ok: false,
58
74
  error: { code: 'source-not-found', name: srcName, prefix: srcPrefix, id: parsed.id },
59
75
  };
60
- const nearest = nearestObservation(db, row.created_at_epoch, project);
61
- if (!nearest) return { ok: false, error: { code: 'no-obs-near', prefix: srcPrefix, id: parsed.id } };
76
+ const nearest = nearestObservation(db, row.created_at_epoch, project ?? row.project ?? null);
77
+ if (!nearest)
78
+ return {
79
+ ok: false,
80
+ error: {
81
+ code: 'no-obs-near',
82
+ prefix: srcPrefix,
83
+ id: parsed.id,
84
+ scope: project ?? row.project ?? null,
85
+ },
86
+ };
62
87
  return {
63
88
  ok: true,
64
89
  anchorId: nearest.id,
@@ -71,11 +96,15 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
71
96
  // nearest observation by epoch. Without this explicit branch an E#N token would fall through
72
97
  // to the bare-int obs lookup below and silently resolve to the COLLIDING observation id.
73
98
  if (parsed.source === 'event') {
74
- const row = db.prepare('SELECT created_at_epoch FROM events WHERE id = ?').get(parsed.id);
99
+ const row = db.prepare(SOURCE_ROW_SQL.event).get(parsed.id);
75
100
  if (!row)
76
101
  return { ok: false, error: { code: 'source-not-found', name: 'Event', prefix: 'E#', id: parsed.id } };
77
- const nearest = nearestObservation(db, row.created_at_epoch, project);
78
- if (!nearest) return { ok: false, error: { code: 'no-obs-near', prefix: 'E#', id: parsed.id } };
102
+ const nearest = nearestObservation(db, row.created_at_epoch, project ?? row.project ?? null);
103
+ if (!nearest)
104
+ return {
105
+ ok: false,
106
+ error: { code: 'no-obs-near', prefix: 'E#', id: parsed.id, scope: project ?? row.project ?? null },
107
+ };
79
108
  return {
80
109
  ok: true,
81
110
  anchorId: nearest.id,
@@ -124,21 +153,25 @@ export function resolveAnchorToken(db, rawAnchor, { project = null } = {}) {
124
153
 
125
154
  // Fall back to user_prompts then session_summaries so pasted P#/S# ids still
126
155
  // work when the prefix is omitted — matches prefix-aware routing in search/probe.
127
- const promptRow = db.prepare('SELECT created_at_epoch FROM user_prompts WHERE id = ?').get(parsed.id);
128
- const sessionRow = promptRow
129
- ? null
130
- : db.prepare('SELECT created_at_epoch FROM session_summaries WHERE id = ?').get(parsed.id);
156
+ const promptRow = db.prepare(SOURCE_ROW_SQL.prompt).get(parsed.id);
157
+ const sessionRow = promptRow ? null : db.prepare(SOURCE_ROW_SQL.session).get(parsed.id);
131
158
  const hit = promptRow
132
159
  ? { row: promptRow, prefix: 'P#', name: 'prompt' }
133
160
  : sessionRow
134
161
  ? { row: sessionRow, prefix: 'S#', name: 'session' }
135
162
  : null;
136
163
  if (!hit) return { ok: false, error: { code: 'id-not-found', id: parsed.id } };
137
- const nearest = nearestObservation(db, hit.row.created_at_epoch, project);
164
+ const nearest = nearestObservation(db, hit.row.created_at_epoch, project ?? hit.row.project ?? null);
138
165
  if (!nearest)
139
166
  return {
140
167
  ok: false,
141
- error: { code: 'no-obs-near', prefix: hit.prefix, id: parsed.id, srcName: hit.name },
168
+ error: {
169
+ code: 'no-obs-near',
170
+ prefix: hit.prefix,
171
+ id: parsed.id,
172
+ srcName: hit.name,
173
+ scope: project ?? hit.row.project ?? null,
174
+ },
142
175
  };
143
176
  return {
144
177
  ok: true,
@@ -169,9 +202,15 @@ export function formatAnchorError(error, dialect) {
169
202
  : `${error.name} ${error.prefix}${error.id} not found.`;
170
203
  case 'no-obs-near': {
171
204
  const suffix = error.srcName ? ` (${error.srcName})` : '';
205
+ // NAME THE SCOPE. Since the anchor became project-scoped, this is the answer a caller
206
+ // gets when the source row's own project holds no live observations — and without the
207
+ // scope the message reads as "that id has no neighbours anywhere", which is a different
208
+ // and wrong fact. `--project` is the lever, so say it.
209
+ const where = error.scope ? ` in project "${error.scope}"` : '';
210
+ const hint = error.scope ? ' — pass --project to search another' : '';
172
211
  return cli
173
- ? `[mem] No observations near ${error.prefix}${error.id}${suffix}`
174
- : `No observations near ${error.prefix}${error.id}${suffix}.`;
212
+ ? `[mem] No observations near ${error.prefix}${error.id}${suffix}${where}${hint}`
213
+ : `No observations near ${error.prefix}${error.id}${suffix}${where}.`;
175
214
  }
176
215
  case 'compressed-pruned':
177
216
  return cli