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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +22 -2
- package/README.zh-CN.md +19 -2
- package/hook-context.mjs +17 -2
- package/hook-shared.mjs +48 -3
- package/hook-update.mjs +6 -1
- package/hook.mjs +35 -1
- package/install.mjs +431 -40
- package/lib/data-paths.mjs +33 -0
- package/lib/db-backup.mjs +42 -17
- package/lib/db-unusable.mjs +178 -0
- package/lib/plugin-key.mjs +4 -1
- package/lib/record-once.mjs +71 -0
- package/lib/save-observation.mjs +16 -5
- package/lib/schema-skew.mjs +21 -30
- package/lib/timeline-core.mjs +54 -15
- package/mem-cli.mjs +33 -32
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/schema.mjs +15 -22
- package/scripts/hook-launcher.mjs +25 -4
- package/server.mjs +59 -1
- package/source-files.mjs +7 -0
- package/tool-schemas.mjs +74 -47
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
|
-
/**
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
}
|
package/lib/plugin-key.mjs
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/save-observation.mjs
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
package/lib/schema-skew.mjs
CHANGED
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
// child process (probeSchemaCompatInFreshProcess).
|
|
39
39
|
|
|
40
40
|
import { spawnSync } from 'node:child_process';
|
|
41
|
-
import {
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/timeline-core.mjs
CHANGED
|
@@ -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(
|
|
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)
|
|
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(
|
|
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)
|
|
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(
|
|
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: {
|
|
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
|