claude-mem-lite 6.8.3 → 6.9.1
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/cli/common.mjs +114 -1
- package/cli.mjs +63 -4
- package/format-utils.mjs +30 -0
- package/hook-episode.mjs +11 -2
- package/hook-llm.mjs +1 -1
- package/hook.mjs +6 -1
- package/install.mjs +114 -12
- package/lib/doctor-modes.mjs +31 -0
- package/lib/install-shape.mjs +28 -0
- package/mem-cli.mjs +83 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/scripts/post-tool-use.sh +19 -1
- package/scripts/setup.sh +108 -22
- package/search-scoring.mjs +69 -0
- package/server.mjs +9 -4
- package/source-files.mjs +1 -0
- package/tfidf.mjs +3 -47
- package/utils.mjs +15 -18
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
12
|
+
"version": "6.9.1",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.1",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
|
|
|
128
128
|
- **FTS integrity management** -- `mem_fts_check` tool verifies FTS5 index health or rebuilds indexes on demand, useful after database recovery or when search results seem wrong
|
|
129
129
|
- **Atomic multi-table writes** -- `saveObservation` wraps the observations + observation_files INSERTs in a single `db.transaction()`, preventing orphaned rows on crash
|
|
130
130
|
- **Modular NLP pipeline** -- Synonym maps, stop words, scoring constants, and query building extracted into focused modules (`synonyms.mjs`, `stop-words.mjs`, `scoring-sql.mjs`, `nlp.mjs`) for independent testing and maintenance
|
|
131
|
-
- **
|
|
131
|
+
- **Surface-form PRF expansion** -- `observations_fts` is built on FTS5's default `unicode61` tokenizer, so the index is **not stemmed**: a query term matches the word forms actually stored, and `crash` does not match a row that only contains `crashes`. Pseudo-relevance feedback therefore uses the Porter stemmer only to *bucket* morphological variants when judging which candidate terms are discriminative, and emits the most frequent **surface** form of each — emitting a bare stem (`cach`) would match nothing and kill expansion recall
|
|
132
132
|
|
|
133
133
|
## Platform Support
|
|
134
134
|
|
|
@@ -735,7 +735,7 @@ claude-mem-lite/
|
|
|
735
735
|
hook-semaphore.mjs # LLM concurrency control: file-based semaphore for background workers
|
|
736
736
|
schema.mjs # Database schema: single source of truth for tables, migrations, FTS5
|
|
737
737
|
tool-schemas.mjs # Shared Zod schemas for MCP tool validation
|
|
738
|
-
tfidf.mjs #
|
|
738
|
+
tfidf.mjs # the Porter stemmer (name is historical: the TF-IDF vector engine it held was removed)
|
|
739
739
|
tier.mjs # Temporal tier system: activity-based time window classification
|
|
740
740
|
utils.mjs # Re-export hub: backward-compatible surface for all utility modules
|
|
741
741
|
nlp.mjs # FTS5 query building: synonym expansion, CJK bigrams, sanitization
|
package/cli/common.mjs
CHANGED
|
@@ -83,7 +83,120 @@ export function parseArgs(argv) {
|
|
|
83
83
|
i++;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
return { positional, flags };
|
|
86
|
+
return { positional, flags: trackFlagReads(flags) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Inert selection flags ───────────────────────────────────────────────────
|
|
90
|
+
//
|
|
91
|
+
// The third cause of the harm parseArgs' docblock names twice. `--include_noise` (underscore
|
|
92
|
+
// spelling) and `--obs_type` (an MCP field name) both parsed, matched no reader, and let the
|
|
93
|
+
// command answer the unfiltered question; both are fixed above. `suggestUnknownFlags` fixes a
|
|
94
|
+
// third case, flags unknown to the whole CLI. What is left is the case where NOTHING is
|
|
95
|
+
// misspelled: `--type` is canonical and real — on `search`, `save` and `export` — so it clears
|
|
96
|
+
// the global known-flag set, and `browse` simply never reads it. Measured before the fix:
|
|
97
|
+
// `browse --type bugfix` printed rows of every type, exit 0, not a word.
|
|
98
|
+
//
|
|
99
|
+
// The check is read-tracking, not a per-command flag manifest, and that is the whole design.
|
|
100
|
+
// A manifest rots, and worse, it cannot see a flag that a command forwards wholesale into a
|
|
101
|
+
// core helper (`cmdSearch` hands the object to the pipeline, `cmdExport` to the writer) —
|
|
102
|
+
// deriving "which flags does this command read" from its own body would fire on every one of
|
|
103
|
+
// those. Watching what the object is actually ASKED for is exact in both directions.
|
|
104
|
+
//
|
|
105
|
+
// Scope is SELECTION-shaped flags only. Those are the ones whose silent drop hands back a
|
|
106
|
+
// wider set that reads as the answer, which is the harm. A `--confirm` that a short-circuited
|
|
107
|
+
// branch never reached is deliberately out of scope: nothing there is wrong, and a warning on
|
|
108
|
+
// correct usage is worse than the silence it replaces.
|
|
109
|
+
//
|
|
110
|
+
// `prompts-limit` is NOT here, and the reason generalises: `doctor --benchmark --prompts-limit`
|
|
111
|
+
// is read off raw `process.argv` in cli/doctor.mjs and never touches a flags object, so
|
|
112
|
+
// read-tracking would call a working flag inert. Anything read off argv must stay out.
|
|
113
|
+
export const FILTER_FLAGS = new Set([
|
|
114
|
+
'type',
|
|
115
|
+
'source',
|
|
116
|
+
'project',
|
|
117
|
+
'tier',
|
|
118
|
+
'since',
|
|
119
|
+
'from',
|
|
120
|
+
'to',
|
|
121
|
+
'branch',
|
|
122
|
+
'scope',
|
|
123
|
+
'importance',
|
|
124
|
+
'limit',
|
|
125
|
+
'offset',
|
|
126
|
+
'sort',
|
|
127
|
+
'days',
|
|
128
|
+
'age-days',
|
|
129
|
+
// Inclusion toggles, added after the same correct-usage sweep the first batch passed. They
|
|
130
|
+
// widen or narrow the SET rather than filter within it, and the harm runs the other way:
|
|
131
|
+
// an ignored `--include-noise` hands back FEWER rows than asked for, and "I searched and it
|
|
132
|
+
// was not there" is the worst answer a memory tool can give. Held back at first only
|
|
133
|
+
// because booleans are read inside branches and were the likelier false-alarm shape; the
|
|
134
|
+
// exit-code gate below turned out to cover that class, and the sweep reads zero.
|
|
135
|
+
'all',
|
|
136
|
+
'include-noise',
|
|
137
|
+
'include-compressed',
|
|
138
|
+
'deep',
|
|
139
|
+
'no-deep',
|
|
140
|
+
'or',
|
|
141
|
+
'rerank',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
let suppliedFlags = new Set();
|
|
145
|
+
let readFlags = new Set();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wrap a parsed flags object so every lookup is recorded.
|
|
149
|
+
*
|
|
150
|
+
* `get` and `has` are both trapped: a reader spelled `if ('tier' in flags)` must count as a
|
|
151
|
+
* read exactly like `flags.tier`. `ownKeys` deliberately is NOT — `Object.keys(flags)` is
|
|
152
|
+
* enumeration, not consumption, and `suggestUnknownFlags` does exactly that on its own parse.
|
|
153
|
+
*/
|
|
154
|
+
function trackFlagReads(flags) {
|
|
155
|
+
for (const k of Object.keys(flags)) suppliedFlags.add(k);
|
|
156
|
+
return new Proxy(flags, {
|
|
157
|
+
get(target, prop, recv) {
|
|
158
|
+
if (typeof prop === 'string') readFlags.add(prop);
|
|
159
|
+
return Reflect.get(target, prop, recv);
|
|
160
|
+
},
|
|
161
|
+
has(target, prop) {
|
|
162
|
+
if (typeof prop === 'string') readFlags.add(prop);
|
|
163
|
+
return Reflect.has(target, prop);
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Start a fresh recording. `run()` calls this per invocation so tests can drive it in a loop. */
|
|
169
|
+
export function resetFlagTracking() {
|
|
170
|
+
suppliedFlags = new Set();
|
|
171
|
+
readFlags = new Set();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Selection flags the user supplied that nothing looked at, sorted.
|
|
176
|
+
*
|
|
177
|
+
* Read AFTER the command has finished — a flag consumed late (inside a branch, or by a helper
|
|
178
|
+
* the command awaits) has still been read, and reporting it early would be a false alarm.
|
|
179
|
+
* @returns {string[]}
|
|
180
|
+
*/
|
|
181
|
+
export function inertFilterFlags() {
|
|
182
|
+
return [...suppliedFlags].filter((f) => FILTER_FLAGS.has(f) && !readFlags.has(f)).sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The sentence the user gets. Says what happened to their results, not what the parser did:
|
|
187
|
+
* "ignored" alone leaves them to work out whether the output is still the answer they asked
|
|
188
|
+
* for. It is not.
|
|
189
|
+
* @param {string} cmd
|
|
190
|
+
* @param {string[]} inert
|
|
191
|
+
* @returns {string}
|
|
192
|
+
*/
|
|
193
|
+
export function inertFilterFlagNotice(cmd, inert) {
|
|
194
|
+
const names = inert.map((f) => `--${f}`).join(' and ');
|
|
195
|
+
const verb = inert.length > 1 ? 'were' : 'was';
|
|
196
|
+
return (
|
|
197
|
+
`[mem] ${names} ${verb} ignored — \`${cmd}\` does not filter on ${inert.length > 1 ? 'them' : 'it'}, ` +
|
|
198
|
+
`so the results above are UNFILTERED. Run "claude-mem-lite help" for the flags this command reads.`
|
|
199
|
+
);
|
|
87
200
|
}
|
|
88
201
|
|
|
89
202
|
// ─── Output Helpers ──────────────────────────────────────────────────────────
|
package/cli.mjs
CHANGED
|
@@ -81,6 +81,28 @@ function fileFromError(e) {
|
|
|
81
81
|
return quoted ? quoted[1] : null;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Is this `doctor` invocation one of the DB-layer modes?
|
|
86
|
+
*
|
|
87
|
+
* DYNAMIC on purpose. A static import here would put lib/doctor-modes.mjs in the LAUNCHER's
|
|
88
|
+
* load graph, and a missing file there kills cli.mjs before any of its own error handling
|
|
89
|
+
* exists — the user gets a raw ERR_MODULE_NOT_FOUND instead of "this install is incomplete,
|
|
90
|
+
* run repair". tests/doctor-startup-closure.test.mjs caught exactly that when the import was
|
|
91
|
+
* static. Same rule as "a recovery path must not import the thing it recovers", applied to
|
|
92
|
+
* the entry point: nothing the launcher needs before it can speak may be a hard edge.
|
|
93
|
+
* install.mjs may import it statically — that failure is caught by loadInstaller() below.
|
|
94
|
+
*/
|
|
95
|
+
async function isDoctorDbMode() {
|
|
96
|
+
try {
|
|
97
|
+
const { DOCTOR_DB_MODES } = await import('./lib/doctor-modes.mjs');
|
|
98
|
+
return process.argv.slice(3).some((a) => DOCTOR_DB_MODES.some((m) => a === `--${m}`));
|
|
99
|
+
} catch {
|
|
100
|
+
// Unreadable module → treat as the plain install health check, which is the branch that
|
|
101
|
+
// can still explain a broken install.
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
async function loadInstaller() {
|
|
85
107
|
let mod;
|
|
86
108
|
try {
|
|
@@ -114,6 +136,46 @@ const INSTALL_COMMANDS = new Set([
|
|
|
114
136
|
'release',
|
|
115
137
|
]);
|
|
116
138
|
|
|
139
|
+
// A reader that leaves is not an error. `claude-mem-lite search x | head -1`,
|
|
140
|
+
// `| grep -q`, or quitting `less` closes the read end while we are still writing;
|
|
141
|
+
// Node then emits 'error' on the stdout Socket, and with no listener that is an
|
|
142
|
+
// UNHANDLED error event — a ~20-line stack ending in `outVerbatim` where the user
|
|
143
|
+
// expected the shell prompt.
|
|
144
|
+
//
|
|
145
|
+
// WHICH COMMANDS, measured rather than generalised (20 trials each, `| head -1`,
|
|
146
|
+
// pre-fix): `search`, `export`, `recent`, `stats`, `doctor`, `timeline`,
|
|
147
|
+
// `citation-stats` 20/20; `browse` 19/20; `help`, `status`, `context`, `get`,
|
|
148
|
+
// `memdir-audit` 0/20. So NOT "every stdout-bearing command" — what decides it is
|
|
149
|
+
// whether a write is still pending when the reader goes, which depends on how many
|
|
150
|
+
// lines the consumer takes and how the output is batched — NOT on the 64 KB pipe
|
|
151
|
+
// buffer, which an earlier draft of this comment blamed: pre-ship review found
|
|
152
|
+
// `stats` crashing at `head -20` on an output far under it. That output's size is
|
|
153
|
+
// corpus dependent, so no byte count is quoted here. This is also why the crash
|
|
154
|
+
// survived so long — it is invisible to exactly the pipe depths a smoke test picks.
|
|
155
|
+
//
|
|
156
|
+
// Lives HERE, at the published `bin`, and not at `cli/common.mjs`'s `out()`: the
|
|
157
|
+
// crash reproduces on `doctor` too, whose writes are `console.log` inside
|
|
158
|
+
// install.mjs, so a chokepoint fix would cover the CLI half and leave the installer
|
|
159
|
+
// half loud. One process-level listener covers both routes below.
|
|
160
|
+
//
|
|
161
|
+
// SWALLOW, DO NOT EXIT. The first cut called `process.exit(0)` here, on the
|
|
162
|
+
// reasoning that a CLI whose consumer has gone should stop rather than serialise a
|
|
163
|
+
// whole-DB `export` into a dead pipe. Pre-ship review measured what that costs:
|
|
164
|
+
// `doctor | head -1` under `pipefail` exited 0 on 10/10 runs while the same doctor
|
|
165
|
+
// exits 1 unpiped, because `runDoctor` assigns `process.exitCode = 1` AFTER its last
|
|
166
|
+
// print (install.mjs, "Diagnostic-tool exit-code contract") and the forced exit lands
|
|
167
|
+
// first. That silently turns a failing `claude-mem-lite doctor || alert` — the
|
|
168
|
+
// wrapper that contract names — into a passing one. `process.exitCode ?? 0` does not
|
|
169
|
+
// rescue it: the verdict does not exist yet at kill time. Returning instead reads
|
|
170
|
+
// exit 1 on 10/10 and keeps the crash fixed (doctor 0/10, search 0/10 EPIPE stacks).
|
|
171
|
+
// Correctness over the saved work: the process finishes into a pipe nobody reads,
|
|
172
|
+
// which is wasted effort but never a wrong answer. Non-EPIPE is rethrown — this is a
|
|
173
|
+
// classifier, not a blanket swallow, the same charter `explainBrokenInstall` follows.
|
|
174
|
+
process.stdout.on('error', (err) => {
|
|
175
|
+
if (err && err.code === 'EPIPE') return;
|
|
176
|
+
throw err;
|
|
177
|
+
});
|
|
178
|
+
|
|
117
179
|
const cmd = process.argv[2];
|
|
118
180
|
|
|
119
181
|
// `version` and `-V` are aliases, not extra syntax: the bare subcommand is what a user
|
|
@@ -129,10 +191,7 @@ if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
|
|
|
129
191
|
} else if (cmd === '--help' || cmd === '-h') {
|
|
130
192
|
const { run } = await import('./mem-cli.mjs');
|
|
131
193
|
await run(['help']);
|
|
132
|
-
} else if (
|
|
133
|
-
cmd === 'doctor' &&
|
|
134
|
-
process.argv.slice(3).some((a) => a === '--benchmark' || a === '--metrics' || a === '--session-audit')
|
|
135
|
-
) {
|
|
194
|
+
} else if (cmd === 'doctor' && (await isDoctorDbMode())) {
|
|
136
195
|
// Per #8217: the DB-layer doctor modes (--benchmark / --metrics / --session-audit,
|
|
137
196
|
// each implemented in cli/doctor.mjs) route to mem-cli. Everything else — plain
|
|
138
197
|
// `doctor`, `doctor --` (POSIX end-of-options), and `doctor --json` — stays with
|
package/format-utils.mjs
CHANGED
|
@@ -25,6 +25,36 @@ export function truncate(str, max = 80) {
|
|
|
25
25
|
return str.slice(0, end) + '\u2026';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Longest query echoed back verbatim in a result label. Long enough that no query a human
|
|
30
|
+
* or an agent actually types is touched — the shapes that exceed it are pasted stack traces,
|
|
31
|
+
* file dumps and multi-paragraph questions.
|
|
32
|
+
*/
|
|
33
|
+
export const QUERY_LABEL_MAX = 200;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A query as it should appear in output handed back to whoever asked.
|
|
37
|
+
*
|
|
38
|
+
* Every search surface labels its answer with the query (`Found N result(s) for "<query>"`),
|
|
39
|
+
* which is how a caller confirms what was actually searched. Unbounded, that made the size
|
|
40
|
+
* of the answer track the size of the question: a 50,000-character query produced 50,024
|
|
41
|
+
* characters of CLI output, and the MCP face — with no argv ceiling — carried the whole
|
|
42
|
+
* thing back into the model's context. For a tool whose purpose is to spend context
|
|
43
|
+
* carefully, returning several KB of the caller's own input is the budget it was invoked to
|
|
44
|
+
* protect.
|
|
45
|
+
*
|
|
46
|
+
* Bounded, not silently truncated: the real length travels with the prefix, so the label
|
|
47
|
+
* still answers the question it exists for. `truncate` handles the surrogate-pair boundary.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} query
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function queryLabel(query) {
|
|
53
|
+
if (typeof query !== 'string') return '';
|
|
54
|
+
if (query.length <= QUERY_LABEL_MAX) return query;
|
|
55
|
+
return `${truncate(query, QUERY_LABEL_MAX)} [query truncated; ${query.length} chars]`;
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
// Two delimiter classes are defanged here:
|
|
29
59
|
// 1. The blocks claude-mem-lite wraps injected context in (claude-mem-context /
|
|
30
60
|
// memory-context / session-handoff). User-derived text containing one LITERALLY
|
package/hook-episode.mjs
CHANGED
|
@@ -18,12 +18,21 @@ import { inferProject, EDIT_TOOLS } from './utils.mjs';
|
|
|
18
18
|
import { RUNTIME_DIR } from './hook-shared.mjs';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Read episode
|
|
21
|
+
* Read the episode buffer WITHOUT holding the lock: the dying-process salvage in hook.mjs's
|
|
22
|
+
* signal handler, and the snapshot Stop / SessionStart take before they flush.
|
|
23
|
+
*
|
|
24
|
+
* Same body as `readEpisode` on purpose — the two names record which locking contract the
|
|
25
|
+
* CALLER is under, and neither function takes a lock itself. What must not differ is the
|
|
26
|
+
* PATH, so both go through `episodeFile()`. This one used to re-spell it inline, which put
|
|
27
|
+
* the buffer's name in three places (here, `episodeFile`, and the signal handler's unlink)
|
|
28
|
+
* and left the salvage path — the one that runs while the process is dying, and the hardest
|
|
29
|
+
* to notice when it is wrong — as the only one not reading the accessor.
|
|
30
|
+
*
|
|
22
31
|
* @returns {object|null} Parsed episode or null on failure
|
|
23
32
|
*/
|
|
24
33
|
export function readEpisodeRaw() {
|
|
25
34
|
try {
|
|
26
|
-
return JSON.parse(readFileSync(
|
|
35
|
+
return JSON.parse(readFileSync(episodeFile(), 'utf8'));
|
|
27
36
|
} catch {
|
|
28
37
|
return null;
|
|
29
38
|
}
|
package/hook-llm.mjs
CHANGED
|
@@ -924,7 +924,7 @@ export async function handleLLMEpisode() {
|
|
|
924
924
|
type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
|
|
925
925
|
Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
|
|
926
926
|
importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
|
|
927
|
-
lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5
|
|
927
|
+
lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5's default tokenizer doesn't split CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
|
|
928
928
|
scope: ${SCOPE_PROMPT_LEGEND}
|
|
929
929
|
search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
|
|
930
930
|
|
package/hook.mjs
CHANGED
|
@@ -288,8 +288,13 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
|
288
288
|
if (db) {
|
|
289
289
|
try {
|
|
290
290
|
for (const sub of planEpisodeFlush(ep)) saveEpisodeImmediate(sub, db);
|
|
291
|
+
// episodeFile(), not a second spelling of `ep-<project>.json`: this is a
|
|
292
|
+
// DESTRUCTIVE step on the buffer the salvage just persisted, and a path that
|
|
293
|
+
// drifts from the accessor deletes nothing while reporting success — the next
|
|
294
|
+
// fire then salvages the same entries again. Every other flush path
|
|
295
|
+
// (PostToolUse, Stop, SessionStart) already unlinks through the accessor.
|
|
291
296
|
try {
|
|
292
|
-
unlinkSync(
|
|
297
|
+
unlinkSync(episodeFile());
|
|
293
298
|
} catch {}
|
|
294
299
|
} finally {
|
|
295
300
|
try {
|
package/install.mjs
CHANGED
|
@@ -44,6 +44,15 @@ const MEM_RUNTIME_DIR = resolveRuntimeDir(MEM_DATA_DIR);
|
|
|
44
44
|
const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
45
45
|
const OLD_DATA_DIR = join(homedir(), '.claude-mem');
|
|
46
46
|
|
|
47
|
+
// The two directories `createCliSymlink` can land the `claude-mem-lite` command in, in the
|
|
48
|
+
// order it tries them. Uninstall already swept exactly this pair as an inline literal, and
|
|
49
|
+
// `status` now has to ask the same question ("is the command installed somewhere, just not
|
|
50
|
+
// on PATH?"), so the list is named once rather than typed a third time. `createCliSymlink`
|
|
51
|
+
// itself is deliberately NOT rewritten to iterate it: its shape is primary-then-fallback
|
|
52
|
+
// with different remedies per branch, and flattening that into a loop is a refactor wearing
|
|
53
|
+
// a constant's clothes.
|
|
54
|
+
const CLI_BIN_DIRS = [join(homedir(), '.local', 'bin'), '/usr/local/bin'];
|
|
55
|
+
|
|
47
56
|
// Detect ephemeral context (npx) — files won't persist after exit
|
|
48
57
|
const IS_NPX =
|
|
49
58
|
process.env.npm_command === 'exec' || PROJECT_DIR.includes('_npx') || PROJECT_DIR.includes('.npm/_');
|
|
@@ -55,6 +64,7 @@ const HOOK_PATH = join(INSTALL_DIR, 'hook.mjs');
|
|
|
55
64
|
// P2-7: both constants and the predicate come from lib/plugin-key.mjs, which hook.mjs also
|
|
56
65
|
// imports — this pair used to be typed out in each.
|
|
57
66
|
import { MARKETPLACE_KEY, PLUGIN_KEY, PLUGIN_NAME, isPluginExplicitlyDisabled } from './lib/plugin-key.mjs';
|
|
67
|
+
import { doctorDbModeHint } from './lib/doctor-modes.mjs';
|
|
58
68
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
59
69
|
|
|
60
70
|
import {
|
|
@@ -70,7 +80,7 @@ import {
|
|
|
70
80
|
nativeBindingRepairHint,
|
|
71
81
|
isNativeBindingError,
|
|
72
82
|
} from './lib/binding-probe.mjs';
|
|
73
|
-
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
83
|
+
import { detectInstallShape, probeRuntimeRoots, hasAnyManagedCode } from './lib/install-shape.mjs';
|
|
74
84
|
import { probeSchemaCompat, schemaSkewRemedy } from './lib/schema-skew.mjs';
|
|
75
85
|
import { clearNativeBindingBreakage, readNativeBindingBreakage } from './lib/native-binding-hint.mjs';
|
|
76
86
|
import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
|
|
@@ -1270,7 +1280,7 @@ async function uninstall() {
|
|
|
1270
1280
|
if (!removedAny) warn('MCP server not found or already removed');
|
|
1271
1281
|
|
|
1272
1282
|
// 1b. Remove CLI symlink
|
|
1273
|
-
for (const binDir of
|
|
1283
|
+
for (const binDir of CLI_BIN_DIRS) {
|
|
1274
1284
|
const cliLink = join(binDir, 'claude-mem-lite');
|
|
1275
1285
|
// No try/catch: clearLinkPath swallows a permissions failure and returns false, so the
|
|
1276
1286
|
// wrapper this used to have was unreachable once the existsSync gate moved inside it.
|
|
@@ -1653,14 +1663,70 @@ async function status() {
|
|
|
1653
1663
|
push('warn', 'database', 'Database: not found', { exists: false });
|
|
1654
1664
|
}
|
|
1655
1665
|
|
|
1656
|
-
// CLI
|
|
1666
|
+
// CLI.
|
|
1667
|
+
//
|
|
1668
|
+
// The probe resolves a BARE name, so a failure conflates three different worlds and the
|
|
1669
|
+
// old single remedy ("run install again to create symlink") was correct in only one of
|
|
1670
|
+
// them. On the common one — `createCliSymlink` put a working link in ~/.local/bin, a
|
|
1671
|
+
// directory a non-login shell frequently does not have on PATH — the advice sends the
|
|
1672
|
+
// user to re-run an installer that will create the very symlink that already exists,
|
|
1673
|
+
// report ✓, and leave `status` saying the same thing. Advice that cannot converge is
|
|
1674
|
+
// worse than the silence it replaced.
|
|
1675
|
+
//
|
|
1676
|
+
// Split on `err.code`: ENOENT is "the name did not resolve" and is the only world the
|
|
1677
|
+
// symlink question applies to. Anything else means the command WAS found and then failed
|
|
1678
|
+
// or timed out (a broken native binding is the live example), where naming PATH is a
|
|
1679
|
+
// second wrong answer — report what actually happened instead.
|
|
1680
|
+
// `linked` is a NEW field on this check, and `--json` republishes every extra key
|
|
1681
|
+
// (`const { level, key, message, ...extra }` below), so it is part of that face's output,
|
|
1682
|
+
// not an internal detail. Nothing in this repo reads it; external consumers of
|
|
1683
|
+
// `status --json` now see `linked: <path>|null`.
|
|
1657
1684
|
try {
|
|
1658
1685
|
execFileSync('claude-mem-lite', ['--help'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
|
1659
1686
|
push('ok', 'cli', 'CLI: claude-mem-lite command available', { available: true });
|
|
1660
|
-
} catch {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1687
|
+
} catch (e) {
|
|
1688
|
+
if (e && e.code !== 'ENOENT') {
|
|
1689
|
+
// `e.message` already CARRIES the child's stderr — with stdio:'pipe' Node formats it
|
|
1690
|
+
// as "Command failed: <cmd>\n<stderr>", so the live example (a broken native binding,
|
|
1691
|
+
// whose `nativeBindingRepairHint` line is on stderr) reaches the user unaided.
|
|
1692
|
+
// Measured, because pre-ship review asserted the opposite and a redundant `e.stderr`
|
|
1693
|
+
// suffix was written and then withdrawn: printing both duplicates the text.
|
|
1694
|
+
// Note `e.code` is UNDEFINED for a non-zero exit — only a spawn failure sets ENOENT —
|
|
1695
|
+
// so `!== 'ENOENT'` is what routes this branch, not a truthiness check on the code.
|
|
1696
|
+
push('warn', 'cli', `CLI: on PATH but "claude-mem-lite --help" failed — ${e.message}`, {
|
|
1697
|
+
available: false,
|
|
1698
|
+
linked: null,
|
|
1699
|
+
});
|
|
1700
|
+
} else {
|
|
1701
|
+
// Two properties of `existsSync` matter here and they pull in opposite directions:
|
|
1702
|
+
// - it FOLLOWS the link, so a DANGLING one reads as absent. That is the answer we
|
|
1703
|
+
// want: a link pointing at a deleted install is the installer's problem, not
|
|
1704
|
+
// PATH's, and falls through to the reinstall remedy.
|
|
1705
|
+
// - it is also true for a DIRECTORY of that name, which would make us print
|
|
1706
|
+
// "installed at … add it to PATH" about something that can never be executed —
|
|
1707
|
+
// the exact non-converging advice this block exists to stop. Hence isFile().
|
|
1708
|
+
const isLinkedCli = (d) => {
|
|
1709
|
+
try {
|
|
1710
|
+
return statSync(join(d, 'claude-mem-lite')).isFile();
|
|
1711
|
+
} catch {
|
|
1712
|
+
return false; // ENOENT (absent or dangling), EACCES on the dir, anything else
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
const binDir = CLI_BIN_DIRS.find(isLinkedCli);
|
|
1716
|
+
if (binDir) {
|
|
1717
|
+
push(
|
|
1718
|
+
'warn',
|
|
1719
|
+
'cli',
|
|
1720
|
+
`CLI: installed at ${join(binDir, 'claude-mem-lite')} but ${binDir} is not on PATH — add it: export PATH="${binDir}:$PATH"`,
|
|
1721
|
+
{ available: false, linked: join(binDir, 'claude-mem-lite') },
|
|
1722
|
+
);
|
|
1723
|
+
} else {
|
|
1724
|
+
push('warn', 'cli', 'CLI: command not on PATH — run install again to create symlink', {
|
|
1725
|
+
available: false,
|
|
1726
|
+
linked: null,
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1664
1730
|
}
|
|
1665
1731
|
|
|
1666
1732
|
// Old system
|
|
@@ -2366,6 +2432,24 @@ async function doctor() {
|
|
|
2366
2432
|
// was ever deployed there, so every entry reads as "missing" and this reported
|
|
2367
2433
|
// `⚠ Managed files: 121 missing` + an issue on a correct install — prescribing
|
|
2368
2434
|
// a repair against a path that does not exist.
|
|
2435
|
+
// ...and a THIRD state under the same `!shape.managed`: nothing was ever deployed here.
|
|
2436
|
+
// Both checks below otherwise prescribe `repair`, which re-syncs an install from the signed
|
|
2437
|
+
// release and runs from `<INSTALL_DIR>/cli.mjs` — one of the very entry points whose absence
|
|
2438
|
+
// produced the verdict, so on this shape it hands the reader a command that cannot start.
|
|
2439
|
+
// Damaged (some of the managed files survive) and never-deployed (none do) are different
|
|
2440
|
+
// populations with opposite commands, the same conflation the plugin-only branch above fixed
|
|
2441
|
+
// once already. Declared out here because the hook-script check needs it too and a `const`
|
|
2442
|
+
// inside the try below is not in scope there.
|
|
2443
|
+
//
|
|
2444
|
+
// The population is SOURCE_FILES, not the two entry points. Asking about the entry points
|
|
2445
|
+
// alone made this verdict a claim about two files while the message it gates says "none
|
|
2446
|
+
// present" about all of them: an install holding cli.mjs and every lib/ module, with only
|
|
2447
|
+
// server.mjs and hook.mjs gone, was reported as a data directory with no install behind it
|
|
2448
|
+
// — and sent to re-`install` instead of `repair`, which was runnable from the cli.mjs
|
|
2449
|
+
// already there.
|
|
2450
|
+
const noCodeInstall =
|
|
2451
|
+
!shape.managed && !shape.activePluginVersion && !hasAnyManagedCode(INSTALL_DIR, SOURCE_FILES);
|
|
2452
|
+
const installRemedy = `node ${join(PROJECT_DIR, 'install.mjs')} install`;
|
|
2369
2453
|
try {
|
|
2370
2454
|
const skipDrift = !shape.managed && !!shape.activePluginVersion;
|
|
2371
2455
|
const { checkDevDrift } = await import('./lib/doctor-drift.mjs');
|
|
@@ -2436,9 +2520,13 @@ async function doctor() {
|
|
|
2436
2520
|
// self-updater is `self-update`. Naming the wrong one sent the user to a
|
|
2437
2521
|
// usage error at the exact moment their install was incomplete.
|
|
2438
2522
|
issueWarn(
|
|
2439
|
-
|
|
2440
|
-
`
|
|
2441
|
-
|
|
2523
|
+
noCodeInstall
|
|
2524
|
+
? `Managed files: no claude-mem-lite code is deployed in ${INSTALL_DIR} (${r.missingCount} ` +
|
|
2525
|
+
`file(s) absent, none present) — this is a data directory with no install behind it, not ` +
|
|
2526
|
+
`a damaged one. Fix: ${installRemedy}`
|
|
2527
|
+
: `Managed files: ${r.missingCount} missing (${parts.join('; ')}) — a copy install resolves ` +
|
|
2528
|
+
`imports against the install dir, so these throw at hook time. Fix: claude-mem-lite self-update ` +
|
|
2529
|
+
`(or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`,
|
|
2442
2530
|
);
|
|
2443
2531
|
}
|
|
2444
2532
|
// Complete copy install: no message — drift is a dev-install concern.
|
|
@@ -2462,7 +2550,11 @@ async function doctor() {
|
|
|
2462
2550
|
// missing files, and install.mjs is the one entry that cannot survive that —
|
|
2463
2551
|
// its static imports resolve before its first statement. cli.mjs has no static
|
|
2464
2552
|
// local imports and catches the failure (D#26). Same route, same command.
|
|
2465
|
-
|
|
2553
|
+
// Never-deployed gets the install command instead, for the reason spelled out at
|
|
2554
|
+
// `noCodeInstall` above: the `repair` route runs from an entry point that is itself absent.
|
|
2555
|
+
const scriptRemedy = noCodeInstall
|
|
2556
|
+
? installRemedy
|
|
2557
|
+
: `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'cli.mjs')} repair)`;
|
|
2466
2558
|
if (skipScripts) {
|
|
2467
2559
|
ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
|
|
2468
2560
|
} else if (!h.present) {
|
|
@@ -2671,7 +2763,17 @@ async function doctor() {
|
|
|
2671
2763
|
),
|
|
2672
2764
|
);
|
|
2673
2765
|
} else {
|
|
2674
|
-
console.log(`\n ${buildDoctorSummary(issues, warnings)}
|
|
2766
|
+
console.log(`\n ${buildDoctorSummary(issues, warnings)}`);
|
|
2767
|
+
// This run checked the INSTALL. The DB-layer modes are a different implementation reached
|
|
2768
|
+
// through the same command name, and nothing else told the user they exist -- a healthy
|
|
2769
|
+
// install with bad retrieval read "All checks passed!" and ended there. Derived from
|
|
2770
|
+
// DOCTOR_DB_MODES so it cannot become a second list to forget. Text only: the exit-code
|
|
2771
|
+
// contract `claude-mem-lite doctor || alert` depends on is untouched.
|
|
2772
|
+
// Phrased as prose, not as `doctor a | b | c`: a line that looks like a command gets
|
|
2773
|
+
// copy-pasted, and `|` is a shell pipe. See doctorDbModeHint()'s note.
|
|
2774
|
+
console.log(
|
|
2775
|
+
` Deeper checks (database layer): run \`claude-mem-lite doctor\` with ${doctorDbModeHint()}\n`,
|
|
2776
|
+
);
|
|
2675
2777
|
}
|
|
2676
2778
|
// Diagnostic-tool exit-code contract: any ✗-level finding must propagate non-zero
|
|
2677
2779
|
// so CI / wrapper scripts (`claude-mem-lite doctor || alert`) actually trip. Keeps
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// lib/doctor-modes.mjs — the DB-layer `doctor` modes, in one place.
|
|
2
|
+
//
|
|
3
|
+
// `doctor` is one command name with two implementations: install.mjs's health check, which
|
|
4
|
+
// owns `--json`, and cli/doctor.mjs's DB-layer modes. cli.mjs decides between them by looking
|
|
5
|
+
// for one of these flags, and its comment used to say so with a warning attached — "Adding a
|
|
6
|
+
// NEW DB-layer mode requires extending this list — a deliberate trade for a working --json".
|
|
7
|
+
// A mode added to cli/doctor.mjs and not to that list is answered silently by the install
|
|
8
|
+
// check instead, which is a command answering as a different command.
|
|
9
|
+
//
|
|
10
|
+
// Three consumers now read this instead of spelling the list:
|
|
11
|
+
// cli.mjs — the router condition
|
|
12
|
+
// install.mjs — the pointer plain `doctor` prints, so the modes are discoverable at all
|
|
13
|
+
// tests/doctor-mode-router-sync.test.mjs — pins it against what cli/doctor.mjs implements
|
|
14
|
+
//
|
|
15
|
+
// A zero-dependency leaf on purpose. install.mjs is a recovery path and must not import
|
|
16
|
+
// anything that drags a load graph behind it (the lesson lib/data-paths.mjs exists for).
|
|
17
|
+
export const DOCTOR_DB_MODES = ['benchmark', 'metrics', 'session-audit'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The modes as PROSE — `--benchmark, --metrics or --session-audit`.
|
|
21
|
+
*
|
|
22
|
+
* Not `a | b | c`. A line shaped like a command invites a copy-paste, and `|` is a shell
|
|
23
|
+
* pipe: pasting the first draft produced `bash: --metrics: command not found`. That is the
|
|
24
|
+
* same defect 32c8923 fixed one commit earlier in this branch (a remedy that named a binary
|
|
25
|
+
* which could not run), reintroduced two commits later in a different spelling. The wording
|
|
26
|
+
* around it has to make clear this is a list of options, not a command line.
|
|
27
|
+
*/
|
|
28
|
+
export function doctorDbModeHint() {
|
|
29
|
+
const flags = DOCTOR_DB_MODES.map((m) => `--${m}`);
|
|
30
|
+
return `${flags.slice(0, -1).join(', ')} or ${flags[flags.length - 1]}`;
|
|
31
|
+
}
|
package/lib/install-shape.mjs
CHANGED
|
@@ -116,6 +116,34 @@ export function hasManagedCodeInstall(installDir) {
|
|
|
116
116
|
return MANAGED_ENTRY_POINTS.every((f) => existsSync(join(installDir, f)));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* True when `installDir` holds ANY of `files` — i.e. code was deployed here at some point,
|
|
121
|
+
* even if it is now incomplete.
|
|
122
|
+
*
|
|
123
|
+
* The complement of `hasManagedCodeInstall` above is not one population but two, and they
|
|
124
|
+
* take OPPOSITE remedies: an install that is DAMAGED (some of it survives) is what `repair`
|
|
125
|
+
* exists for, while one that was never deployed here needs `install`. `repair` runs from
|
|
126
|
+
* `<installDir>/cli.mjs`, so prescribing it where nothing was deployed names a binary that
|
|
127
|
+
* cannot start.
|
|
128
|
+
*
|
|
129
|
+
* `files` is a PARAMETER and the caller passes the whole managed list, because this answers
|
|
130
|
+
* a question about a WIDER population than `hasManagedCodeInstall` does. Pinning both to
|
|
131
|
+
* MANAGED_ENTRY_POINTS looked tidy — one array, `every` vs `some` — and made the verdict a
|
|
132
|
+
* claim about two files while the message it gates says "none present" about all of them.
|
|
133
|
+
* An install holding cli.mjs and every lib/ module but neither entry point was reported as
|
|
134
|
+
* a data directory with no install behind it, and told to re-`install` rather than
|
|
135
|
+
* `repair` — which was runnable, sitting right there.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} installDir
|
|
138
|
+
* @param {string[]} files The managed file list to test against (SOURCE_FILES at the only
|
|
139
|
+
* call site; a narrower list answers a narrower question).
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
export function hasAnyManagedCode(installDir, files) {
|
|
143
|
+
if (!installDir || !existsSync(installDir)) return false;
|
|
144
|
+
return files.some((f) => existsSync(join(installDir, f)));
|
|
145
|
+
}
|
|
146
|
+
|
|
119
147
|
/**
|
|
120
148
|
* Plugin-cache version dirs that carry runnable code, newest first.
|
|
121
149
|
*
|