claude-mem-lite 6.8.2 → 6.9.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/adopt-cli.mjs +11 -0
- package/claudemd.mjs +95 -7
- package/cli/common.mjs +114 -1
- package/cli.mjs +23 -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 +43 -6
- package/lib/db-unusable.mjs +43 -0
- package/lib/doctor-modes.mjs +31 -0
- package/lib/install-shape.mjs +28 -0
- package/mem-cli.mjs +90 -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/server.mjs +21 -5
- package/source-files.mjs +1 -0
- package/tfidf.mjs +3 -47
- package/utils.mjs +15 -18
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
|
*
|
package/mem-cli.mjs
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, CODE_DIR } from './schema.mjs';
|
|
7
7
|
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
|
|
9
|
+
import {
|
|
10
|
+
truncate,
|
|
11
|
+
queryLabel,
|
|
12
|
+
typeIcon,
|
|
13
|
+
inferProject,
|
|
14
|
+
scrubSecrets,
|
|
15
|
+
COMPRESSED_PENDING_PURGE,
|
|
16
|
+
} from './utils.mjs';
|
|
9
17
|
import { resolveProject } from './project-utils.mjs';
|
|
10
18
|
// READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
|
|
11
19
|
// falls back to the enclosing work-tree root, so `cd src/auth && … recent` reads what the
|
|
@@ -98,6 +106,9 @@ import {
|
|
|
98
106
|
rejectBareStringFlags,
|
|
99
107
|
resolvePositionalAlias,
|
|
100
108
|
suggestUnknownFlags,
|
|
109
|
+
resetFlagTracking,
|
|
110
|
+
inertFilterFlags,
|
|
111
|
+
inertFilterFlagNotice,
|
|
101
112
|
OBS_TIME_FIELDS,
|
|
102
113
|
formatObsFieldValue,
|
|
103
114
|
obsFieldLabel,
|
|
@@ -190,7 +201,7 @@ function emitRecallHint(db, query) {
|
|
|
190
201
|
const n = countRecallableByFile(db, q);
|
|
191
202
|
if (n > 0) {
|
|
192
203
|
out(`[mem] ${n} observation(s) are linked to that file — search indexes text, not file paths.`);
|
|
193
|
-
out(`[mem] Try: claude-mem-lite recall "${q}"`);
|
|
204
|
+
out(`[mem] Try: claude-mem-lite recall "${queryLabel(q)}"`);
|
|
194
205
|
}
|
|
195
206
|
} catch {
|
|
196
207
|
/* hint is best-effort; never break search */
|
|
@@ -272,12 +283,13 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
272
283
|
// Haiku call + N hybrid searches; observations-only. NOT the passive path — this
|
|
273
284
|
// is the explicit "search harder" lever for vocabulary-mismatch recall misses.
|
|
274
285
|
// --deep forces deep; --no-deep forces normal; neither = unset (env/default decide).
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
// Both are read before the precedence contest, deliberately. The ternary short-circuited,
|
|
287
|
+
// so `search --deep --no-deep` never touched `flags['no-deep']` and the inert-flag notice
|
|
288
|
+
// called it a flag `search` "does not filter on" with "results above are UNFILTERED" — both
|
|
289
|
+
// false. A flag that LOST a precedence contest was read; it just did not win.
|
|
290
|
+
const wantsDeep = flags.deep === true || flags.deep === 'true';
|
|
291
|
+
const wantsNoDeep = flags['no-deep'] === true || flags['no-deep'] === 'true';
|
|
292
|
+
const explicitDeep = wantsDeep ? true : wantsNoDeep ? false : undefined;
|
|
281
293
|
const deepMode = resolveDeepMode(explicitDeep, { surface: 'cli' });
|
|
282
294
|
|
|
283
295
|
// --rerank: opt-in LLM rerank of the fused top-20 (option C, deep-search.mjs).
|
|
@@ -325,7 +337,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
325
337
|
out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: false, results: [] }));
|
|
326
338
|
} else {
|
|
327
339
|
emitDeferredTrailer();
|
|
328
|
-
fail(`[mem] No valid search terms in "${query}"`);
|
|
340
|
+
fail(`[mem] No valid search terms in "${queryLabel(query)}"`);
|
|
329
341
|
}
|
|
330
342
|
return;
|
|
331
343
|
}
|
|
@@ -476,7 +488,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
476
488
|
}),
|
|
477
489
|
);
|
|
478
490
|
} else {
|
|
479
|
-
out(`[mem] No results for "${query}"`);
|
|
491
|
+
out(`[mem] No results for "${queryLabel(query)}"`);
|
|
480
492
|
emitRecallHint(db, query);
|
|
481
493
|
// The zero-result path is where the trailer earns its keep — the D#92
|
|
482
494
|
// failure chain was exactly "searched, found nothing, item was deferred".
|
|
@@ -514,7 +526,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
514
526
|
}),
|
|
515
527
|
);
|
|
516
528
|
} else {
|
|
517
|
-
out(`[mem] No results for "${query}" at offset ${offset}`);
|
|
529
|
+
out(`[mem] No results for "${queryLabel(query)}" at offset ${offset}`);
|
|
518
530
|
}
|
|
519
531
|
return;
|
|
520
532
|
}
|
|
@@ -575,7 +587,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
575
587
|
// Pluralize on total — "Found 1 of 44 result" reads wrong; the population (44) drives
|
|
576
588
|
// grammatical number, not the page slice (1).
|
|
577
589
|
out(
|
|
578
|
-
`[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${query}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`,
|
|
590
|
+
`[mem] Found ${countLabel} result${total !== 1 ? 's' : ''} for "${queryLabel(query)}"${fallbackHint}:${hasMixed ? ' (# observation, S# session, P# prompt, E# event)' : ''}`,
|
|
579
591
|
);
|
|
580
592
|
// `~Nt` = est. tokens to fetch this row's full body via mem_get (attachBodyTokens, paired with
|
|
581
593
|
// MCP). Conditional so a row that skipped enrichment renders cleanly, not "~undefinedt".
|
|
@@ -948,7 +960,16 @@ function cmdGet(db, args) {
|
|
|
948
960
|
sections.push(dRows.map(formatDeferredDetail).join('\n\n'));
|
|
949
961
|
totalFound += dRows.length;
|
|
950
962
|
}
|
|
951
|
-
|
|
963
|
+
// This note exists for a MIXED request: other sections still render, so without it the
|
|
964
|
+
// output looks complete while an id the caller asked for silently produced nothing.
|
|
965
|
+
// A deferred-only request that matched nothing is not that shape — the terminal branch
|
|
966
|
+
// below prints the same list plus the `defer list` hint, and running both made
|
|
967
|
+
// `get D#99` say it twice, the second line a superset of the first. Conditions checked
|
|
968
|
+
// rather than the note dropped: silencing it outright would blind the case it is for.
|
|
969
|
+
const deferredOnlyMiss =
|
|
970
|
+
dRows.length === 0 &&
|
|
971
|
+
bySrc.obs.length + bySrc.session.length + bySrc.prompt.length + bySrc.event.length === 0;
|
|
972
|
+
if (deferredMissing.length > 0 && !deferredOnlyMiss) {
|
|
952
973
|
process.stderr.write(
|
|
953
974
|
`[mem] Deferred item(s) not found: ${deferredMissing.map((i) => `D#${i}`).join(', ')}\n`,
|
|
954
975
|
);
|
|
@@ -3592,7 +3613,57 @@ import { cmdActivity } from './cli/activity.mjs';
|
|
|
3592
3613
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
3593
3614
|
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
3594
3615
|
|
|
3616
|
+
/**
|
|
3617
|
+
* Commands that read their own raw argv instead of the object parseArgs returns, so the
|
|
3618
|
+
* inert-flag notice below has no way to observe a flag being consumed and must stay quiet.
|
|
3619
|
+
* `adopt` / `unadopt` hand `cmdArgs` straight to adopt-cli.mjs; `doctor` selects its mode
|
|
3620
|
+
* with `args.includes('--x')`; `cmdOptimize` reads `--project` and `--scope` positionally
|
|
3621
|
+
* (`args.indexOf('--scope')`, and see its own note about parsing flags that way). Adding a
|
|
3622
|
+
* command here is the per-command twin of leaving a flag out of FILTER_FLAGS.
|
|
3623
|
+
*
|
|
3624
|
+
* Do not extend this by hand alone. `tests/cli-argv-parsed-commands-complete.test.mjs`
|
|
3625
|
+
* DERIVES the answer — it walks the dispatcher's switch, reads each handler's body, and
|
|
3626
|
+
* fails if a handler indexes raw argv for a FILTER_FLAGS name without being listed here.
|
|
3627
|
+
* `optimize` was the second miss on this list in one branch (`unadopt --all` was the first),
|
|
3628
|
+
* both found by sweeping invocations, and a sweep only ever covers the population somebody
|
|
3629
|
+
* remembered to enumerate.
|
|
3630
|
+
*/
|
|
3631
|
+
const ARGV_PARSED_COMMANDS = new Set(['adopt', 'unadopt', 'doctor', 'optimize']);
|
|
3632
|
+
|
|
3595
3633
|
export async function run(argv) {
|
|
3634
|
+
// The inert-selection-flag notice is emitted HERE rather than inside the dispatcher because
|
|
3635
|
+
// `runDispatch` returns from a dozen places (adopt / unadopt / memdir-audit before the DB is
|
|
3636
|
+
// even opened, plus every `fail()` path), and a notice wired at some of them is a notice that
|
|
3637
|
+
// reports a dropped filter on some commands and not others. `fail()` sets `process.exitCode`
|
|
3638
|
+
// and RETURNS rather than calling process.exit, so this `finally` covers the failure paths too.
|
|
3639
|
+
resetFlagTracking();
|
|
3640
|
+
try {
|
|
3641
|
+
return await runDispatch(argv);
|
|
3642
|
+
} finally {
|
|
3643
|
+
// Silent for commands that never hand their flags to parseArgs. `adopt` / `unadopt` pass
|
|
3644
|
+
// raw `cmdArgs` straight to adopt-cli.mjs, which reads the array itself, and `doctor`
|
|
3645
|
+
// selects its mode with `args.includes('--x')` — so read-tracking cannot see a flag being
|
|
3646
|
+
// consumed there and would call a documented, working flag inert. `unadopt --all` is
|
|
3647
|
+
// exactly that: adopt-cli.mjs's own header documents it and `unadoptAll()` implements it,
|
|
3648
|
+
// and the first version of this notice reported it as ignored. Same class as
|
|
3649
|
+
// `prompts-limit`, which FILTER_FLAGS excludes by name for the same reason — this is the
|
|
3650
|
+
// per-COMMAND half of that rule. Blindness must present as silence, never as a warning.
|
|
3651
|
+
const argvParsed = ARGV_PARSED_COMMANDS.has(argv[0]);
|
|
3652
|
+
// Only when the command SUCCEEDED. The notice is about an answer that is wider than the
|
|
3653
|
+
// one asked for; a command that failed has no answer for it to qualify, and saying "the
|
|
3654
|
+
// results above are UNFILTERED" under an error message describes results that do not
|
|
3655
|
+
// exist. Found by sweeping correct usage for false alarms: `activity search --limit 3`
|
|
3656
|
+
// (no query) fails its own usage check BEFORE anything reads `flags.limit`, so the flag
|
|
3657
|
+
// is genuinely unread and the notice was genuinely wrong. The error IS the feedback there.
|
|
3658
|
+
const inert = process.exitCode || argvParsed ? [] : inertFilterFlags();
|
|
3659
|
+
// stderr only: stdout is a data channel (`search --json | jq`, and commands/mem.md pipes
|
|
3660
|
+
// these outputs into model context), so the notice must not enter it. Exit code untouched —
|
|
3661
|
+
// the command did run, and its answer is real, just wider than the user asked for.
|
|
3662
|
+
if (inert.length > 0) process.stderr.write(inertFilterFlagNotice(argv[0], inert) + '\n');
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
async function runDispatch(argv) {
|
|
3596
3667
|
const cmd = argv[0];
|
|
3597
3668
|
const cmdArgs = argv.slice(1);
|
|
3598
3669
|
|
|
@@ -3847,6 +3918,12 @@ export async function run(argv) {
|
|
|
3847
3918
|
// agent runs the CLI, the model's context — got a raw Node stack trace. Print the
|
|
3848
3919
|
// message, keep the stack behind CLAUDE_MEM_DEBUG for whoever is actually debugging.
|
|
3849
3920
|
process.stderr.write(`[mem] ${cmd || 'command'} failed: ${(e && e.message) || e}\n`);
|
|
3921
|
+
// A damaged FTS5 index reaches here as SQLITE_CORRUPT_VTAB from the first MATCH.
|
|
3922
|
+
// `fts-check` and `doctor` touch the index too — and both already explain themselves —
|
|
3923
|
+
// so `search` is the one command that DEAD-ENDS on SQLite's sentence, while `recent` /
|
|
3924
|
+
// `recall` / `browse` / `context` / `stats` never read the index and keep working.
|
|
3925
|
+
// The remedy is lossless (see FTS_CORRUPTION_REMEDY); the exit code stays 1.
|
|
3926
|
+
if (isFtsCorruptionError(e)) process.stderr.write(`[mem] ${FTS_CORRUPTION_REMEDY}\n`);
|
|
3850
3927
|
if (process.env.CLAUDE_MEM_DEBUG) process.stderr.write(`${(e && e.stack) || ''}\n`);
|
|
3851
3928
|
process.exitCode = 1;
|
|
3852
3929
|
} finally {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.
|
|
9
|
+
"version": "6.9.0",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.9.0",
|
|
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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"lib/hook-telemetry.mjs",
|
|
92
92
|
"lib/resolve-data-dir.mjs",
|
|
93
93
|
"lib/data-paths.mjs",
|
|
94
|
+
"lib/doctor-modes.mjs",
|
|
94
95
|
"lib/export-columns.mjs",
|
|
95
96
|
"lib/file-intel.mjs",
|
|
96
97
|
"lib/reread-guard.mjs",
|
package/scripts/post-tool-use.sh
CHANGED
|
@@ -107,7 +107,25 @@ if [[ "$tool" == "Read" ]]; then
|
|
|
107
107
|
fi
|
|
108
108
|
fi
|
|
109
109
|
fi
|
|
110
|
-
|
|
110
|
+
# Mirror resolveRuntimeDir() (lib/resolve-data-dir.mjs): CLAUDE_MEM_RUNTIME_DIR wins when
|
|
111
|
+
# non-empty, and a relative value resolves against cwd. Without this the two sides of the
|
|
112
|
+
# channel disagreed whenever that override was set — bash appended to
|
|
113
|
+
# $CLAUDE_MEM_DIR/runtime while hook.mjs read (and hook-shared.mjs reaped) the override
|
|
114
|
+
# dir, which is both harms named at the top of this branch: every Read dropped from the
|
|
115
|
+
# episode, and an orphaned file nothing ever collects.
|
|
116
|
+
#
|
|
117
|
+
# The override deliberately wins over the test-containment redirect above, because the
|
|
118
|
+
# Node resolver ignores dataDir entirely once it is set. Mirroring it is the whole point;
|
|
119
|
+
# a "safer" bash rule here would be a second policy nobody reviewed.
|
|
120
|
+
#
|
|
121
|
+
# Builtins only. This is the ~5ms pre-filter — a `node -e` resolver of the kind setup.sh
|
|
122
|
+
# can afford at SessionStart costs ~27ms measured, on every tool call.
|
|
123
|
+
if [[ -n "${CLAUDE_MEM_RUNTIME_DIR:-}" ]]; then
|
|
124
|
+
runtime_dir="$CLAUDE_MEM_RUNTIME_DIR"
|
|
125
|
+
[[ "$runtime_dir" == /* ]] || runtime_dir="${PWD}/${runtime_dir}"
|
|
126
|
+
else
|
|
127
|
+
runtime_dir="${_data_dir}/runtime"
|
|
128
|
+
fi
|
|
111
129
|
# Owner-only (0700 dir / 0600 file): reads-<project>.txt lists captured file
|
|
112
130
|
# paths, so on a shared host the default umask leaked them to every local user.
|
|
113
131
|
# umask is a shell builtin — no extra process on this ~5ms per-tool-call path
|
package/scripts/setup.sh
CHANGED
|
@@ -13,7 +13,51 @@ else
|
|
|
13
13
|
ROOT="$CLAUDE_PLUGIN_ROOT"
|
|
14
14
|
fi
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# The same three locations lib/data-paths.mjs defines, under the same names. This script
|
|
17
|
+
# carried ONE variable for all three, and under CLAUDE_MEM_DIR that variable is the CODE
|
|
18
|
+
# dir — so every question about the DATABASE was asked of a directory holding none. This
|
|
19
|
+
# repo has now had that confusion three times (v6.3.0, the same fix reintroduced with the
|
|
20
|
+
# halves swapped, and here), which is why the names are spelled out rather than inferred.
|
|
21
|
+
#
|
|
22
|
+
# CODE_DIR — ALWAYS homedir. settings.json and the MCP registration bake absolute
|
|
23
|
+
# paths to server.mjs / hook.mjs under it, so it must not follow the
|
|
24
|
+
# relocation env var. Owns node_modules and the run-once install markers.
|
|
25
|
+
# DB_DIR — follows CLAUDE_MEM_DIR. Owns claude-mem-lite.db and its sidecars.
|
|
26
|
+
# RUNTIME_DIR — follows CLAUDE_MEM_RUNTIME_DIR, else DB_DIR/runtime. Owns state a hook
|
|
27
|
+
# writes and another component reads back (see .deps-broken below).
|
|
28
|
+
#
|
|
29
|
+
# ASK the shared resolver rather than re-deriving its rules (absolute-only, "undefined" /
|
|
30
|
+
# "null" rejected) in a second language. lib/resolve-data-dir.mjs is a leaf module — node:
|
|
31
|
+
# builtins only — so it still loads with node_modules missing, which is the state the
|
|
32
|
+
# .deps-broken flag exists to describe. Gated on an override actually being set: with
|
|
33
|
+
# neither var the resolver returns exactly these defaults, and SessionStart should not pay a
|
|
34
|
+
# node spawn to be told that. A resolver that is missing (truncated tree) or that throws
|
|
35
|
+
# (invalid override) leaves the defaults in place; the Node side rejects a bad value loudly
|
|
36
|
+
# enough on its own, and aborting here would fail the user's session start.
|
|
37
|
+
CODE_DIR="$HOME/.claude-mem-lite"
|
|
38
|
+
DB_DIR="$CODE_DIR"
|
|
39
|
+
RUNTIME_DIR="$CODE_DIR/runtime"
|
|
40
|
+
if [[ -n "${CLAUDE_MEM_DIR:-}" || -n "${CLAUDE_MEM_RUNTIME_DIR:-}" ]] && [[ -f "$ROOT/lib/resolve-data-dir.mjs" ]]; then
|
|
41
|
+
# shellcheck disable=SC2016 # node script single-quoted on purpose; path passed via env, not shell expansion
|
|
42
|
+
_resolved="$(RESOLVER_MOD="$ROOT/lib/resolve-data-dir.mjs" node -e '
|
|
43
|
+
const { pathToFileURL } = require("node:url");
|
|
44
|
+
import(pathToFileURL(process.env.RESOLVER_MOD).href)
|
|
45
|
+
.then((m) => {
|
|
46
|
+
const db = m.resolveDataDir(process.env.CLAUDE_MEM_DIR);
|
|
47
|
+
process.stdout.write(`${db}\n${m.resolveRuntimeDir(db)}\n`);
|
|
48
|
+
})
|
|
49
|
+
.catch(() => process.exit(1));
|
|
50
|
+
' 2>/dev/null)" || _resolved=""
|
|
51
|
+
_db="$(printf '%s\n' "$_resolved" | sed -n 1p)"
|
|
52
|
+
_rt="$(printf '%s\n' "$_resolved" | sed -n 2p)"
|
|
53
|
+
# Both or neither: a half-applied override is the split this whole block exists to close.
|
|
54
|
+
if [[ -n "$_db" && -n "$_rt" ]]; then
|
|
55
|
+
DB_DIR="$_db"
|
|
56
|
+
RUNTIME_DIR="$_rt"
|
|
57
|
+
fi
|
|
58
|
+
unset _resolved _db _rt
|
|
59
|
+
fi
|
|
60
|
+
|
|
17
61
|
OLD_UNHIDDEN_DIR="$HOME/claude-mem-lite"
|
|
18
62
|
|
|
19
63
|
# Colors
|
|
@@ -36,23 +80,38 @@ log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
|
|
|
36
80
|
log_err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
37
81
|
|
|
38
82
|
# 1. Migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
|
|
39
|
-
|
|
40
|
-
|
|
83
|
+
# CODE_DIR, not DB_DIR, and deliberately: the pre-v0.5 unhidden directory held the
|
|
84
|
+
# INSTALL — server.mjs, hook.mjs, package.json — and CODE_DIR is the one location that
|
|
85
|
+
# must never follow the relocation env var. Moving it into a relocated DB_DIR would
|
|
86
|
+
# strand every absolute path settings.json and the MCP registration baked.
|
|
87
|
+
if [[ -d "$OLD_UNHIDDEN_DIR" && ! -d "$CODE_DIR" ]]; then
|
|
88
|
+
mv "$OLD_UNHIDDEN_DIR" "$CODE_DIR"
|
|
41
89
|
log_ok "Migrated ~/claude-mem-lite/ → ~/.claude-mem-lite/"
|
|
42
90
|
fi
|
|
43
91
|
|
|
44
|
-
# 2. Ensure
|
|
45
|
-
mkdir -p "$
|
|
46
|
-
|
|
92
|
+
# 2. Ensure both locations exist (runtime created after migration check)
|
|
93
|
+
mkdir -p "$CODE_DIR"
|
|
94
|
+
mkdir -p "$DB_DIR"
|
|
95
|
+
if [[ "$DB_DIR" == "$CODE_DIR" ]]; then
|
|
96
|
+
log_ok "Data directory: $DB_DIR"
|
|
97
|
+
else
|
|
98
|
+
log_ok "Data directory: $DB_DIR (code: $CODE_DIR)"
|
|
99
|
+
fi
|
|
47
100
|
|
|
48
101
|
# 3. Legacy ~/.claude-mem/ DB is schema-v16 (no memory_session_id) with no migration bridge to
|
|
49
102
|
# the current schema — activating it FATALs on first launch ("no such column: memory_session_id")
|
|
50
103
|
# and the "! -f claude-mem-lite.db" guard would re-copy it every time the user deletes the broken
|
|
51
104
|
# DB (recovery loop). Mirror install.mjs migrateLegacyClaudeMemData: back it up (don't activate)
|
|
52
105
|
# and let a fresh DB be created. Source ~/.claude-mem/ is left intact.
|
|
106
|
+
#
|
|
107
|
+
# DB_DIR, because that guard is the whole convergence argument: it closes when the product
|
|
108
|
+
# creates claude-mem-lite.db, and the product creates it in DB_DIR. Asked of CODE_DIR under
|
|
109
|
+
# a relocation it never closed — nothing ever writes a database THERE — so this block
|
|
110
|
+
# copied the legacy database again on every single SessionStart, without bound. Measured
|
|
111
|
+
# 2026-09-14: control arm stable at 1 backup across three runs, relocated arm 1 → 3.
|
|
53
112
|
OLD_DIR="$HOME/.claude-mem"
|
|
54
|
-
if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$
|
|
55
|
-
BACKUP="$
|
|
113
|
+
if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DB_DIR/claude-mem-lite.db" && ! -f "$DB_DIR/claude-mem.db" ]]; then
|
|
114
|
+
BACKUP="$DB_DIR/claude-mem-lite.db.legacy-backup-$(date +%s)"
|
|
56
115
|
if cp "$OLD_DIR/claude-mem.db" "$BACKUP" 2>/dev/null; then
|
|
57
116
|
log_info "Legacy ~/.claude-mem/ DB is schema-incompatible; backed up to $(basename "$BACKUP") (a fresh DB will be created). Old ~/.claude-mem/ preserved."
|
|
58
117
|
else
|
|
@@ -60,16 +119,20 @@ if [[ -f "$OLD_DIR/claude-mem.db" && ! -f "$DATA_DIR/claude-mem-lite.db" && ! -f
|
|
|
60
119
|
fi
|
|
61
120
|
fi
|
|
62
121
|
|
|
63
|
-
# 4. Rename claude-mem.db → claude-mem-lite.db in same directory
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
mv "$
|
|
67
|
-
mv "$
|
|
122
|
+
# 4. Rename claude-mem.db → claude-mem-lite.db in same directory (DB_DIR: a relocated user's
|
|
123
|
+
# pre-rename database sits there, and asking CODE_DIR left it unrenamed and unopened).
|
|
124
|
+
if [[ -f "$DB_DIR/claude-mem.db" && ! -f "$DB_DIR/claude-mem-lite.db" ]]; then
|
|
125
|
+
mv "$DB_DIR/claude-mem.db" "$DB_DIR/claude-mem-lite.db"
|
|
126
|
+
mv "$DB_DIR/claude-mem.db-wal" "$DB_DIR/claude-mem-lite.db-wal" 2>/dev/null || true
|
|
127
|
+
mv "$DB_DIR/claude-mem.db-shm" "$DB_DIR/claude-mem-lite.db-shm" 2>/dev/null || true
|
|
68
128
|
log_ok "Database renamed: claude-mem.db → claude-mem-lite.db"
|
|
69
129
|
fi
|
|
70
130
|
|
|
71
|
-
# 5. Ensure runtime
|
|
72
|
-
|
|
131
|
+
# 5. Ensure runtime directories exist (after migration to not mask migration check).
|
|
132
|
+
# Both: RUNTIME_DIR carries the cross-component flag below, while CODE_DIR/runtime keeps
|
|
133
|
+
# the two run-once install markers at the bottom of this file.
|
|
134
|
+
mkdir -p "$RUNTIME_DIR"
|
|
135
|
+
mkdir -p "$CODE_DIR/runtime"
|
|
73
136
|
|
|
74
137
|
# 6. Ensure native dependencies available for hooks (ESM import needs node_modules in resolution chain)
|
|
75
138
|
# Plugin cache doesn't include node_modules — symlink from data dir or npm install on first run
|
|
@@ -81,8 +144,25 @@ mkdir -p "$DATA_DIR/runtime"
|
|
|
81
144
|
# and exit on the require() error. v2.79: write a JSON flag to runtime/.deps-broken
|
|
82
145
|
# and hook.mjs SessionStart surfaces it in the Claude context as a HIGH-VISIBILITY
|
|
83
146
|
# block; success branches remove the flag so a self-heal stays visible too.
|
|
84
|
-
|
|
85
|
-
|
|
147
|
+
#
|
|
148
|
+
# ...and that contract is a two-directory agreement, not a filename. hook.mjs renders the
|
|
149
|
+
# flag from `join(RUNTIME_DIR, '.deps-broken')`, where RUNTIME_DIR is
|
|
150
|
+
# `resolveRuntimeDir(resolveDataDir(CLAUDE_MEM_DIR))` (hook-shared.mjs). Hardcoding a
|
|
151
|
+
# homedir path here meant that under CLAUDE_MEM_DIR / CLAUDE_MEM_RUNTIME_DIR the writer and
|
|
152
|
+
# the reader named two different directories, so the one surface that says "your hooks are
|
|
153
|
+
# degraded" rendered nothing on exactly the installs that had relocated. Measured
|
|
154
|
+
# 2026-09-14: flag planted where this script wrote it → banner 0 times; planted where
|
|
155
|
+
# hook.mjs reads → 1. Same SPLIT shape lib/resolve-data-dir.mjs documents; it survived
|
|
156
|
+
# because tests/runtime-dir-single-home.test.mjs sweeps `walkShipped`, which is every
|
|
157
|
+
# shipped .mjs/.js — a bash hook is structurally outside that population. RUNTIME_DIR is
|
|
158
|
+
# resolved once at the top of this file.
|
|
159
|
+
#
|
|
160
|
+
# ONLY this marker follows the override. `.mcp-dedup-v2.78` and `.residue-warned-v2.55`
|
|
161
|
+
# below are one-shot state about THIS MACHINE's install — a ~/.claude.json edit and a
|
|
162
|
+
# settings.json warning, not state a hook hands to another component — so they stay under
|
|
163
|
+
# CODE_DIR. Read lib/resolve-data-dir.mjs's MOVES/STAYS list before relocating either:
|
|
164
|
+
# moving a run-once marker re-runs what it gated.
|
|
165
|
+
DEPS_FLAG="$RUNTIME_DIR/.deps-broken"
|
|
86
166
|
|
|
87
167
|
mark_deps_broken() {
|
|
88
168
|
local reason="$1"
|
|
@@ -112,9 +192,11 @@ mark_deps_ok() {
|
|
|
112
192
|
|
|
113
193
|
if [[ ! -d "$ROOT/node_modules/better-sqlite3" ]]; then
|
|
114
194
|
# Fast path: symlink from data dir (instant, no network needed)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
195
|
+
# CODE_DIR: node_modules belongs to the install, not to the data, and must not follow
|
|
196
|
+
# CLAUDE_MEM_DIR — install.mjs writes it under the homedir install location.
|
|
197
|
+
if [[ -d "$CODE_DIR/node_modules/better-sqlite3" ]]; then
|
|
198
|
+
if ln -sfn "$CODE_DIR/node_modules" "$ROOT/node_modules" 2>/dev/null; then
|
|
199
|
+
log_ok "Dependencies linked from $CODE_DIR"
|
|
118
200
|
fi
|
|
119
201
|
fi
|
|
120
202
|
# Slow path: npm install (first-time only, ~10-20s for native addon)
|
|
@@ -219,7 +301,9 @@ fi
|
|
|
219
301
|
# pre-v2.79.1 — extra node spawn + JSON parse on every SessionStart for a
|
|
220
302
|
# near-always no-op). Bump MCP_MIGRATION name to re-run cleanup in future
|
|
221
303
|
# versions; same shape as the .deps-broken self-heal pattern.
|
|
222
|
-
|
|
304
|
+
# CODE_DIR/runtime, not RUNTIME_DIR: this marker gates a one-shot edit of ~/.claude.json,
|
|
305
|
+
# which is machine state, not per-data-dir state. Relocating it would re-run that edit.
|
|
306
|
+
MCP_MIGRATION="$CODE_DIR/runtime/.mcp-dedup-v2.78"
|
|
223
307
|
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$MCP_MIGRATION" ]]; then
|
|
224
308
|
# shellcheck disable=SC2016 # node script single-quoted on purpose; CLAUDE_JSON passed via env, not shell expansion
|
|
225
309
|
CLAUDE_JSON="$HOME/.claude.json" node -e '
|
|
@@ -298,7 +382,9 @@ fi
|
|
|
298
382
|
# will run every hook twice (direct settings.json hooks AND plugin hooks)
|
|
299
383
|
# until they run `claude-mem-lite uninstall` to clear the settings.json
|
|
300
384
|
# entries. /plugin uninstall does not touch settings.json.
|
|
301
|
-
|
|
385
|
+
# CODE_DIR/runtime, same reason: the residue it warns about is stale hook entries in
|
|
386
|
+
# ~/.claude/settings.json — one machine, one warning, regardless of where the data lives.
|
|
387
|
+
RESIDUE_MARKER="$CODE_DIR/runtime/.residue-warned-v2.55"
|
|
302
388
|
if [[ -n "${CLAUDE_PLUGIN_ROOT:-}" && ! -f "$RESIDUE_MARKER" ]]; then
|
|
303
389
|
SETTINGS="$HOME/.claude/settings.json"
|
|
304
390
|
if [[ -f "$SETTINGS" ]]; then
|
package/server.mjs
CHANGED
|
@@ -76,7 +76,7 @@ import { formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cl
|
|
|
76
76
|
// The partial-export warning points the caller at the CLI twin, which exports the complete
|
|
77
77
|
// set by default — the invocation has to be the one that actually works on this install.
|
|
78
78
|
import { CLI_INVOKE } from './cli-path.mjs';
|
|
79
|
-
import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from './format-utils.mjs';
|
|
79
|
+
import { neutralizeContextDelimiters, neutralizeSkillDelimiters, queryLabel } from './format-utils.mjs';
|
|
80
80
|
import {
|
|
81
81
|
memSearchSchema,
|
|
82
82
|
memRecentSchema,
|
|
@@ -118,6 +118,7 @@ import { saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from '
|
|
|
118
118
|
import { applyObsUpdate } from './lib/observation-write.mjs';
|
|
119
119
|
import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
|
|
120
120
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
121
|
+
import { isFtsCorruptionError, FTS_CORRUPTION_REMEDY } from './lib/db-unusable.mjs';
|
|
121
122
|
import { fetchRecent } from './lib/recent-core.mjs';
|
|
122
123
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
123
124
|
import {
|
|
@@ -374,7 +375,17 @@ function safeHandler(fn, { verbatim = false } = {}) {
|
|
|
374
375
|
const result = await fn(args, extra);
|
|
375
376
|
return verbatim ? result : defangResult(result, { skillBlocks: true });
|
|
376
377
|
} catch (err) {
|
|
377
|
-
|
|
378
|
+
// A damaged FTS5 index arrives here as SQLITE_CORRUPT_VTAB from the first MATCH.
|
|
379
|
+
// Without this the model got SQLite's own sentence and nothing else, on a fault it
|
|
380
|
+
// could have had fixed in one command — and mem_recent / mem_recall / mem_browse
|
|
381
|
+
// keep answering, so the dead end reads as "nothing matched". Both channels carry the
|
|
382
|
+
// same string here, deliberately: unlike the file-level remedy this one is lossless
|
|
383
|
+
// (see FTS_CORRUPTION_REMEDY in lib/db-unusable.mjs).
|
|
384
|
+
const hint = isFtsCorruptionError(err) ? `\n${FTS_CORRUPTION_REMEDY}` : '';
|
|
385
|
+
return defangResult({
|
|
386
|
+
content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
|
|
387
|
+
isError: true,
|
|
388
|
+
});
|
|
378
389
|
}
|
|
379
390
|
};
|
|
380
391
|
}
|
|
@@ -409,13 +420,18 @@ function formatSearchOutput(
|
|
|
409
420
|
'This is a recall miss (the rewrite ran), not a query-syntax issue; the memory likely has no related observations.',
|
|
410
421
|
);
|
|
411
422
|
} else if (args.query && !ftsQuery) {
|
|
412
|
-
hint.push(`Query "${args.query}" was filtered (FTS5 keywords/special chars only).`);
|
|
423
|
+
hint.push(`Query "${queryLabel(args.query)}" was filtered (FTS5 keywords/special chars only).`);
|
|
413
424
|
hint.push('Tip: use content words instead of operators (AND, OR, NOT, NEAR).');
|
|
414
425
|
} else {
|
|
415
426
|
hint.push('No results found.');
|
|
416
427
|
if (args.query) {
|
|
417
428
|
const expanded = ftsQuery || args.query;
|
|
418
|
-
|
|
429
|
+
// Bounded like the other two echo sites. This one is the worst of the three: it
|
|
430
|
+
// prints the EXPANDED query, and synonym expansion makes it LARGER than the input
|
|
431
|
+
// (measured 13,691 chars out for 10,329 in, 1.33x). The first pass of this fix
|
|
432
|
+
// skipped the branch on the reasoning that it "never renders the label" — true of
|
|
433
|
+
// the label, false of the echo.
|
|
434
|
+
if (expanded !== args.query) hint.push(`Searched as: ${queryLabel(expanded)}`);
|
|
419
435
|
hint.push('Tip: check spelling, try broader terms, or use mem_stats to see available data.');
|
|
420
436
|
}
|
|
421
437
|
}
|
|
@@ -438,7 +454,7 @@ function formatSearchOutput(
|
|
|
438
454
|
);
|
|
439
455
|
// P2-6: empty/omitted query falls through to a "listing recent" path — label it explicitly
|
|
440
456
|
// so callers don't mistake BM25-less results for relevance-ranked ones.
|
|
441
|
-
const qLabel = args.query ? ` for "${args.query}"` : ' (no query — listing recent)';
|
|
457
|
+
const qLabel = args.query ? ` for "${queryLabel(args.query)}"` : ' (no query — listing recent)';
|
|
442
458
|
// Surface AND→OR fallback so callers (incl. Claude) know a strict multi-term
|
|
443
459
|
// query actually matched only a subset of the terms. Suppressed when the caller
|
|
444
460
|
// explicitly requested OR semantics — there's no "fallback" in that path.
|
package/source-files.mjs
CHANGED
|
@@ -60,6 +60,7 @@ export const SOURCE_FILES = [
|
|
|
60
60
|
// stays loadable without better-sqlite3. Missing from the manifest → auto-update leaves
|
|
61
61
|
// schema.mjs and the repair path with ERR_MODULE_NOT_FOUND on every fire.
|
|
62
62
|
'lib/data-paths.mjs',
|
|
63
|
+
'lib/doctor-modes.mjs',
|
|
63
64
|
// lib/ — statically imported by hook-llm.mjs (activity) + hook-handoff.mjs (git-state, task-reader);
|
|
64
65
|
// dynamically imported by hook.mjs (startup-dashboard) + mem-cli.mjs (doctor-benchmark, plan-reader).
|
|
65
66
|
'lib/activity.mjs',
|
package/tfidf.mjs
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
// tfidf.mjs —
|
|
1
|
+
// tfidf.mjs — the Porter stemmer.
|
|
2
2
|
//
|
|
3
3
|
// NAME IS HISTORICAL. This module was the TF-IDF vector search engine (vocabulary, vectors,
|
|
4
4
|
// cosine similarity, vector search, RRF merge). Phase-1 (v3.17.0, 2026-06-27) gated that arm
|
|
5
5
|
// off; Phase-2 removed it. What is left is the text-normalization half, which was never part
|
|
6
6
|
// of the vector arm and has live consumers on the DEFAULT retrieval path:
|
|
7
7
|
// - porterStem -> search-scoring.mjs (PRF term extraction)
|
|
8
|
-
//
|
|
8
|
+
// `tokenize` lived here too until it was moved to benchmark/adoption-cosine.mjs, beside the
|
|
9
|
+
// only callers it had left; this module is now one function under a two-word historical name.
|
|
9
10
|
// RRF_K moved to lib/rrf.mjs, its actual home. See tests/vector-arm-removed.test.mjs for the
|
|
10
11
|
// removal contract and tasks/specs/vector-arm-removal.md for the measurements behind it.
|
|
11
12
|
|
|
12
|
-
import { cjkBigrams } from './utils.mjs';
|
|
13
|
-
|
|
14
13
|
// ─── Porter Stemmer ──────────────────────────────────────────────────────────
|
|
15
14
|
// Minimal Porter stemmer (1980). It used to normalize tokens for the TF-IDF vocabulary,
|
|
16
15
|
// where query and document were both stemmed so the vector arm stayed internally
|
|
@@ -188,46 +187,3 @@ export function porterStem(w) {
|
|
|
188
187
|
|
|
189
188
|
return word;
|
|
190
189
|
}
|
|
191
|
-
|
|
192
|
-
// ─── Tokenization ───────────────────────────────────────────────────────────
|
|
193
|
-
|
|
194
|
-
const CJK_RANGE = /[\u4e00-\u9fff\u3400-\u4dbf]/;
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Tokenize text into stemmed terms.
|
|
198
|
-
* ASCII: lowercase + split + Porter stem.
|
|
199
|
-
* CJK: reuse cjkBigrams() for consistency with FTS5.
|
|
200
|
-
*
|
|
201
|
-
* Built for the TF-IDF vocabulary, which is gone; the surviving consumer is
|
|
202
|
-
* benchmark/adoption-cosine.mjs, which builds its own bags. The "aligned with FTS5's
|
|
203
|
-
* porter tokenizer" claim the old docblock made here is NOT true and was already
|
|
204
|
-
* contradicted by the stemmer's own note above — observations_fts uses unicode61 with no
|
|
205
|
-
* stemming, so these terms are stems and FTS5's are surface forms.
|
|
206
|
-
*/
|
|
207
|
-
export function tokenize(text) {
|
|
208
|
-
if (!text) return [];
|
|
209
|
-
text = String(text).toLowerCase();
|
|
210
|
-
|
|
211
|
-
const tokens = [];
|
|
212
|
-
|
|
213
|
-
// Split into ASCII and CJK segments
|
|
214
|
-
const parts = text.split(/([\u4e00-\u9fff\u3400-\u4dbf]+)/);
|
|
215
|
-
for (const part of parts) {
|
|
216
|
-
if (CJK_RANGE.test(part)) {
|
|
217
|
-
// CJK: use bigrams for consistency with FTS5 indexing
|
|
218
|
-
const bigrams = cjkBigrams(part);
|
|
219
|
-
if (bigrams) {
|
|
220
|
-
for (const t of bigrams.split(/\s+/)) {
|
|
221
|
-
if (t.length >= 2) tokens.push(t);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
} else {
|
|
225
|
-
// ASCII: split on non-alphanumeric, then Porter stem
|
|
226
|
-
for (const t of part.split(/[^a-z0-9]+/)) {
|
|
227
|
-
if (t.length >= 2) tokens.push(porterStem(t));
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return tokens;
|
|
233
|
-
}
|