claude-mem-lite 3.95.0 → 3.96.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 +6 -2
- package/cli.mjs +5 -1
- package/hook-update.mjs +43 -0
- package/hook.mjs +11 -2
- package/install.mjs +109 -13
- package/lib/import-jsonl.mjs +10 -0
- package/lib/recall-core.mjs +31 -0
- package/mem-cli.mjs +51 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/plugin-cache-guard.mjs +104 -0
- package/scripts/prompt-search-utils.mjs +13 -0
- package/scripts/setup.sh +7 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.96.0",
|
|
14
14
|
"source": "./",
|
|
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)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.96.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
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -370,8 +370,12 @@ Slash commands `/adopt` and `/unadopt` wrap the same CLI.
|
|
|
370
370
|
runtime-gated on sentinel presence, so projects without adoption get the
|
|
371
371
|
full verbose output.
|
|
372
372
|
|
|
373
|
-
See
|
|
374
|
-
|
|
373
|
+
See [the invited-memory design][invited-memory] for the full design (including the
|
|
374
|
+
reusable template other plugins can follow). It is a development-time document and
|
|
375
|
+
is no longer in the repository at HEAD, so that link is pinned to `v3.95.0`, the
|
|
376
|
+
last release that carried it.
|
|
377
|
+
|
|
378
|
+
[invited-memory]: https://github.com/sdsrss/claude-mem-lite/blob/v3.95.0/docs/plans/2026-04-16-invited-memory-pattern.md
|
|
375
379
|
|
|
376
380
|
## Database Schema
|
|
377
381
|
|
package/cli.mjs
CHANGED
|
@@ -4,7 +4,11 @@ const INSTALL_COMMANDS = new Set(['install', 'uninstall', 'status', 'doctor', 'c
|
|
|
4
4
|
|
|
5
5
|
const cmd = process.argv[2];
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// `version` and `-V` are aliases, not extra syntax: the bare subcommand is what a user
|
|
8
|
+
// types first (`claude-mem-lite version`), and it is far enough from every real command
|
|
9
|
+
// name that the edit-distance suggester below fell through to the generic
|
|
10
|
+
// "Run help / Run install" line — a wrong answer to a question the CLI can answer.
|
|
11
|
+
if (cmd === '--version' || cmd === '-v' || cmd === '-V' || cmd === 'version') {
|
|
8
12
|
const { readFileSync } = await import('fs');
|
|
9
13
|
const { fileURLToPath } = await import('url');
|
|
10
14
|
const { dirname, join } = await import('path');
|
package/hook-update.mjs
CHANGED
|
@@ -1053,7 +1053,50 @@ function copyReleaseIntoStaging(sourceDir, stagingDir, manifest = { SOURCE_FILES
|
|
|
1053
1053
|
// ── Cache hook residue clearing ────────────────────────────
|
|
1054
1054
|
// Inline (does not import plugin-cache-guard.mjs) so hook-update.mjs keeps working
|
|
1055
1055
|
// even if plugin-cache-guard.mjs is missing on disk in degraded installs.
|
|
1056
|
+
|
|
1057
|
+
// Mirror of plugin-cache-guard.hasInstallManagedHooks, inlined for the reason above.
|
|
1058
|
+
// Kept string-identical in its match rule (`.claude-mem-lite/` or `/claude-mem-lite/`
|
|
1059
|
+
// appearing in a serialized hooks block) so the two cannot disagree about whether
|
|
1060
|
+
// settings.json owns the hooks.
|
|
1061
|
+
function hasInstallManagedSettingsHooks() {
|
|
1062
|
+
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
1063
|
+
if (!existsSync(settingsPath)) return false;
|
|
1064
|
+
let s;
|
|
1065
|
+
try { s = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { return false; }
|
|
1066
|
+
const serialized = JSON.stringify(s.hooks || {});
|
|
1067
|
+
if (!(serialized.includes('.claude-mem-lite/') || serialized.includes('/claude-mem-lite/'))) return false;
|
|
1068
|
+
// Liveness, mirroring plugin-cache-guard.hasLiveInstallManagedHooks (see its docblock).
|
|
1069
|
+
// The string test alone says settings.json MENTIONS a path of ours, not that the path
|
|
1070
|
+
// still exists — and a stale entry left by a removed global install fires nothing while
|
|
1071
|
+
// making this function authorise emptying the plugin manifest that does. Narrow by
|
|
1072
|
+
// construction: only a command we parsed a path out of, ALL of whose paths are gone,
|
|
1073
|
+
// flips the answer; an unfamiliar shape yields no path and keeps the old result.
|
|
1074
|
+
let checked = 0;
|
|
1075
|
+
for (const matchers of Object.values(s?.hooks || {})) {
|
|
1076
|
+
if (!Array.isArray(matchers)) continue;
|
|
1077
|
+
for (const m of matchers) {
|
|
1078
|
+
for (const h of (Array.isArray(m?.hooks) ? m.hooks : [])) {
|
|
1079
|
+
const c = typeof h?.command === 'string' ? h.command : '';
|
|
1080
|
+
if (!(c.includes('.claude-mem-lite/') || c.includes('/claude-mem-lite/'))) continue;
|
|
1081
|
+
let paths = [...c.matchAll(/"([^"]+)"/g)].map(x => x[1]).filter(p => p.startsWith('/'));
|
|
1082
|
+
if (paths.length === 0) paths = c.split(/\s+/).filter(t => t.startsWith('/'));
|
|
1083
|
+
for (const p of paths) {
|
|
1084
|
+
checked++;
|
|
1085
|
+
if (existsSync(p)) return true;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return checked === 0;
|
|
1091
|
+
}
|
|
1056
1092
|
export function clearCacheHookResidue() {
|
|
1093
|
+
// Same precondition plugin-cache-guard.mjs documents and hook.mjs's self-heal
|
|
1094
|
+
// honours: this is a DEDUP against install.mjs-managed settings.json entries.
|
|
1095
|
+
// With no such entries the cache manifest is the ONLY hook registration, and
|
|
1096
|
+
// "clearing residue" unregisters all seven events — invisibly, because
|
|
1097
|
+
// status/doctor then see the shape of a healthy plugin-only install. Inlined
|
|
1098
|
+
// here for the same reason the rest of this function is (see header).
|
|
1099
|
+
if (!hasInstallManagedSettingsHooks()) return 0;
|
|
1057
1100
|
const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'claude-mem-lite');
|
|
1058
1101
|
if (!existsSync(cacheBase)) return 0;
|
|
1059
1102
|
let cleared = 0;
|
package/hook.mjs
CHANGED
|
@@ -1855,12 +1855,21 @@ async function handleSessionStart() {
|
|
|
1855
1855
|
// Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
|
|
1856
1856
|
// re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
|
|
1857
1857
|
// registration alongside install.mjs-managed settings.json entries. Silently
|
|
1858
|
-
// clear — gated
|
|
1858
|
+
// clear — gated to avoid breaking plugin-only users.
|
|
1859
|
+
//
|
|
1860
|
+
// The gate is hasLiveInstallManagedHooks, not the bare hasInstallManagedHooks: this
|
|
1861
|
+
// branch EMPTIES the manifest, which is a dedup only while settings.json is really the
|
|
1862
|
+
// other registration. A settings.json entry naming a deleted `~/.claude-mem-lite`
|
|
1863
|
+
// launcher (global install removed by hand, plugin kept) satisfies the string test and
|
|
1864
|
+
// fires nothing — so the self-heal read a dead registration as live and wiped the one
|
|
1865
|
+
// that worked, every SessionStart. `?? hasInstallManagedHooks` keeps a guard module
|
|
1866
|
+
// that predates the predicate working exactly as before.
|
|
1859
1867
|
// Dynamic-import fallback: if plugin-cache-guard.mjs is missing (pre-2.31.2
|
|
1860
1868
|
// auto-upgrade install), skip self-heal instead of crashing the entire hook.
|
|
1861
1869
|
try {
|
|
1862
1870
|
const guard = await loadCacheGuard();
|
|
1863
|
-
|
|
1871
|
+
const ownsHooks = guard.hasLiveInstallManagedHooks ?? guard.hasInstallManagedHooks;
|
|
1872
|
+
if (ownsHooks && ownsHooks()) {
|
|
1864
1873
|
const cleared = guard.clearPluginCacheHooks({
|
|
1865
1874
|
reason: 'Auto-healed by hook.mjs session-start — install.mjs-managed hooks active in settings.json',
|
|
1866
1875
|
});
|
package/install.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import { MARKETPLACE_KEY, PLUGIN_KEY, isPluginExplicitlyDisabled } from './lib/p
|
|
|
44
44
|
const NPM_INSTALL_CMD = 'npm install --omit=dev --no-audit --no-fund';
|
|
45
45
|
|
|
46
46
|
import { RESOURCE_METADATA } from './install-metadata.mjs';
|
|
47
|
-
import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
|
|
47
|
+
import { scanPluginCacheHookPollution, hasInstallManagedHooks, pluginCacheHookEvents } from './plugin-cache-guard.mjs';
|
|
48
48
|
import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
|
|
49
49
|
import { probeBetterSqlite3Binding, ensureBetterSqlite3Working, NATIVE_BINDING_REBUILD_CMD } from './lib/binding-probe.mjs';
|
|
50
50
|
import { detectInstallShape, probeRuntimeRoots } from './lib/install-shape.mjs';
|
|
@@ -319,6 +319,40 @@ export function bumpJsonField(filePath, keyPath, newVal) {
|
|
|
319
319
|
return { changed: true, prev };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// CLAUDE.md's `- **Version**: x.y.z` line, patched to a new version.
|
|
323
|
+
//
|
|
324
|
+
// Replaces the version TOKEN, not the whole line. The line carries a trailing
|
|
325
|
+
// annotation ("— **this exact string is a release guard.**") and the previous
|
|
326
|
+
// whole-line form deleted it on the first release after that annotation was
|
|
327
|
+
// written. Every gate stayed green through the deletion — publish.yml greps the
|
|
328
|
+
// `^- **Version**: <semver>` prefix and install-e2e asserts the same substring,
|
|
329
|
+
// so neither can see a truncated tail. Pure + exported for the same reason
|
|
330
|
+
// bumpJsonField is: syncVersions gets one testable point of truth per file shape.
|
|
331
|
+
//
|
|
332
|
+
// @returns patched text, or null when the line is absent (caller warns + skips).
|
|
333
|
+
export function patchClaudeMdVersion(text, version) {
|
|
334
|
+
const versionLine = /^(- \*\*Version\*\*: )\d+\.\d+\.\d+(.*)$/m;
|
|
335
|
+
if (!versionLine.test(text)) return null;
|
|
336
|
+
return text.replace(versionLine, (_m, head, tail) => `${head}${version}${tail}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Repair instruction for an unregistered hook manifest.
|
|
340
|
+
//
|
|
341
|
+
// The obvious advice — copy the marketplace clone over the cache copy — is a SILENT
|
|
342
|
+
// NO-OP in one real sequence (pre-ship review, finding 3): `install` empties the
|
|
343
|
+
// marketplace manifest too, so after `install` + `cleanup-hooks` BOTH files are
|
|
344
|
+
// `{"hooks":{}}` and the cp exits 0 having changed nothing, leaving the user staring
|
|
345
|
+
// at the same red line. Claude Code also seeds a NEW cache version from that same
|
|
346
|
+
// emptied clone. So check the source before prescribing it, and fall back to a
|
|
347
|
+
// reinstall — which re-clones the manifest from the repo — when it is empty too.
|
|
348
|
+
export function hookManifestRepairHint(cacheRoot, marketplaceRoot) {
|
|
349
|
+
const src = join(marketplaceRoot, 'hooks', 'hooks.json');
|
|
350
|
+
const dst = join(cacheRoot, 'hooks', 'hooks.json');
|
|
351
|
+
return pluginCacheHookEvents(marketplaceRoot).ok
|
|
352
|
+
? `cp "${src}" "${dst}" && restart Claude Code`
|
|
353
|
+
: `no usable marketplace copy to restore from — reinstall the plugin (/plugin uninstall then /plugin install), then restart Claude Code`;
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
// Doctor's final summary line. Pure function so the 4-way contract
|
|
323
357
|
// (clean / warnings-only / issues / mixed) is unit-testable without spinning
|
|
324
358
|
// up the full doctor pipeline. `issues` are ✗-level (action required);
|
|
@@ -597,7 +631,7 @@ if (pluginHandlesMcp) {
|
|
|
597
631
|
}
|
|
598
632
|
}
|
|
599
633
|
|
|
600
|
-
function dedupePluginCacheAndHooks() {
|
|
634
|
+
function dedupePluginCacheAndHooks({ managedHooks } = {}) {
|
|
601
635
|
// 3b. Deduplicate: if marketplace plugin also registers MCP + hooks,
|
|
602
636
|
// clear them to prevent double execution. install.mjs hooks (in settings.json)
|
|
603
637
|
// point to ~/.claude-mem-lite/ (latest code in dev mode via symlinks),
|
|
@@ -609,6 +643,32 @@ function dedupePluginCacheAndHooks() {
|
|
|
609
643
|
const pluginDir = join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY);
|
|
610
644
|
const pluginHooksPath = join(pluginDir, 'hooks', 'hooks.json');
|
|
611
645
|
|
|
646
|
+
// Clearing is a DEDUP, and a dedup with only one registration left is a delete.
|
|
647
|
+
// Both clearers below empty a file Claude Code reads hooks from; that is correct
|
|
648
|
+
// only while settings.json ALSO registers them. On a plugin-only install (no
|
|
649
|
+
// install.mjs-managed entries) the cache manifest is the sole registration, so
|
|
650
|
+
// clearing it silently unregisters all seven events — and status/doctor then read
|
|
651
|
+
// "settings.json holds none" as the healthy plugin shape. plugin-cache-guard.mjs
|
|
652
|
+
// has documented this precondition since it was written and hook.mjs's self-heal
|
|
653
|
+
// honours it; these two sites did not.
|
|
654
|
+
//
|
|
655
|
+
// `managedHooks` comes from the caller rather than a bare hasInstallManagedHooks()
|
|
656
|
+
// call, and that is the whole point: install() runs configureHooks() first, so a
|
|
657
|
+
// self-read here is ALWAYS true and the guard would be decorative — the real
|
|
658
|
+
// protection would be the call ORDER, which nothing pins and a future reorder
|
|
659
|
+
// would silently revert (pre-ship review, finding 1). Passing the value makes the
|
|
660
|
+
// dependency data, not sequence. Explicit `false` is honoured; omitted → self-read,
|
|
661
|
+
// for any caller that has not just written settings.json.
|
|
662
|
+
const settingsOwnsHooks = managedHooks ?? hasInstallManagedHooks();
|
|
663
|
+
|
|
664
|
+
// Scope note (pre-ship review, finding 2): the gate covers the two hook-CLEARING
|
|
665
|
+
// blocks only. The launch.mjs / launch-preflight.mjs sync below it is not dedup —
|
|
666
|
+
// it is issue #15's dev-mode MCP routing fix — and an early return out of the whole
|
|
667
|
+
// function would silently stop shipping it to plugin-cache users.
|
|
668
|
+
if (!settingsOwnsHooks) {
|
|
669
|
+
log('Plugin cache: hooks left in place (plugin-only install — the cache manifest is the only registration)');
|
|
670
|
+
}
|
|
671
|
+
|
|
612
672
|
if (existsSync(pluginDir)) {
|
|
613
673
|
// NOTE: Do NOT clear marketplace .mcp.json — Claude Code copies from
|
|
614
674
|
// marketplace clone → plugin cache on updates. Clearing it causes the
|
|
@@ -617,7 +677,7 @@ if (existsSync(pluginDir)) {
|
|
|
617
677
|
|
|
618
678
|
// Clear plugin hooks to prevent double hook execution
|
|
619
679
|
try {
|
|
620
|
-
if (existsSync(pluginHooksPath)) {
|
|
680
|
+
if (settingsOwnsHooks && existsSync(pluginHooksPath)) {
|
|
621
681
|
const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
|
|
622
682
|
if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
|
|
623
683
|
// Atomic (audit 2026-09-02 P1-10): a torn hooks.json is not a fail-open marker —
|
|
@@ -659,7 +719,7 @@ if (existsSync(pluginDir)) {
|
|
|
659
719
|
|
|
660
720
|
// Clear cached hooks.json (runtime reads here, not marketplace source)
|
|
661
721
|
const cachedHooksPath = join(verDir, 'hooks', 'hooks.json');
|
|
662
|
-
if (existsSync(cachedHooksPath)) {
|
|
722
|
+
if (settingsOwnsHooks && existsSync(cachedHooksPath)) {
|
|
663
723
|
try {
|
|
664
724
|
const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
|
|
665
725
|
if (h.hooks && Object.keys(h.hooks).length > 0) {
|
|
@@ -862,6 +922,11 @@ writeSettings(settings);
|
|
|
862
922
|
// kept saying five after the map changed, which is how a missing registration reads as
|
|
863
923
|
// a successful one.
|
|
864
924
|
ok(`Hooks configured (${Object.keys(hookConfigs).join(', ')})`);
|
|
925
|
+
// Returned so dedupePluginCacheAndHooks gates on a VALUE this function produced
|
|
926
|
+
// rather than re-reading settings.json — see the `managedHooks` note there. This
|
|
927
|
+
// function writes all seven events unconditionally, so the answer is always true;
|
|
928
|
+
// returning it keeps that fact in the caller's dataflow instead of in call order.
|
|
929
|
+
return true;
|
|
865
930
|
}
|
|
866
931
|
|
|
867
932
|
function backupLegacyClaudeMemData() {
|
|
@@ -1231,8 +1296,14 @@ async function install() {
|
|
|
1231
1296
|
await installDependencies(IS_DEV);
|
|
1232
1297
|
createCliSymlink();
|
|
1233
1298
|
registerMcpServer();
|
|
1234
|
-
|
|
1235
|
-
|
|
1299
|
+
// configureHooks BEFORE dedupe, and its result feeds the dedup gate: dedupe now
|
|
1300
|
+
// refuses to clear a hooks manifest unless install.mjs-managed hooks exist in
|
|
1301
|
+
// settings.json, and on a first install those entries do not exist until
|
|
1302
|
+
// configureHooks writes them. Passing the value (rather than letting dedupe
|
|
1303
|
+
// re-read settings.json) is what keeps a future reorder from silently turning the
|
|
1304
|
+
// dedup off — the dependency is data, not sequence.
|
|
1305
|
+
const managedHooks = configureHooks();
|
|
1306
|
+
dedupePluginCacheAndHooks({ managedHooks });
|
|
1236
1307
|
backupLegacyClaudeMemData();
|
|
1237
1308
|
await installPreinstalledResources();
|
|
1238
1309
|
verifyDatabase();
|
|
@@ -1452,7 +1523,15 @@ async function status() {
|
|
|
1452
1523
|
} else if (pluginDisabled) {
|
|
1453
1524
|
push('ok', 'hooks', 'Hooks: not configured', { configured: false });
|
|
1454
1525
|
} else if (pluginProvides) {
|
|
1455
|
-
|
|
1526
|
+
// Open the manifest being credited. Trusting `settings.json holds none` alone
|
|
1527
|
+
// reported all-green over an emptied cache manifest — zero hooks registered.
|
|
1528
|
+
const manifest = pluginCacheHookEvents(shape.activePluginVersion.root);
|
|
1529
|
+
if (manifest.ok) {
|
|
1530
|
+
push('ok', 'hooks', `Hooks: provided by the plugin manifest (v${shape.activePluginVersion.version} hooks/hooks.json, ${manifest.events.length} events) — settings.json correctly holds none`, { configured: false, via: 'plugin', events: manifest.events });
|
|
1531
|
+
} else {
|
|
1532
|
+
const repair = hookManifestRepairHint(shape.activePluginVersion.root, join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY));
|
|
1533
|
+
push('fail', 'hooks', `Hooks: plugin manifest v${shape.activePluginVersion.version} registers NO hooks (${manifest.reason}) and settings.json holds none — every hook is unregistered. Repair: ${repair}`, { configured: false, via: 'plugin', events: [], manifest_reason: manifest.reason });
|
|
1534
|
+
}
|
|
1456
1535
|
} else {
|
|
1457
1536
|
push('fail', 'hooks', 'Hooks: not configured', { configured: false });
|
|
1458
1537
|
}
|
|
@@ -1688,8 +1767,17 @@ async function doctor() {
|
|
|
1688
1767
|
} else if (shape.activePluginVersion) {
|
|
1689
1768
|
// Plugin-only: hooks come from the cache's hooks/hooks.json, and an EMPTY
|
|
1690
1769
|
// settings.json hooks block is the correct state — warning about it told a
|
|
1691
|
-
// correctly-installed user their hooks were missing.
|
|
1692
|
-
|
|
1770
|
+
// correctly-installed user their hooks were missing. But "correct state" is
|
|
1771
|
+
// only half the question: read the manifest too, or an emptied one passes as
|
|
1772
|
+
// the healthy shape (same false green as status).
|
|
1773
|
+
const manifest = pluginCacheHookEvents(shape.activePluginVersion.root);
|
|
1774
|
+
if (manifest.ok) {
|
|
1775
|
+
ok(`Plugin lifecycle: hooks served by the plugin manifest (v${shape.activePluginVersion.version}, ${manifest.events.length} events); settings.json correctly holds none`);
|
|
1776
|
+
} else {
|
|
1777
|
+
fail(`Plugin lifecycle: plugin manifest v${shape.activePluginVersion.version} registers NO hooks (${manifest.reason}) and settings.json holds none — every hook is unregistered`);
|
|
1778
|
+
log(` Repair: ${hookManifestRepairHint(shape.activePluginVersion.root, join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY))}`);
|
|
1779
|
+
issues++;
|
|
1780
|
+
}
|
|
1693
1781
|
} else {
|
|
1694
1782
|
dwarn('Plugin lifecycle: hooks not configured');
|
|
1695
1783
|
}
|
|
@@ -1781,7 +1869,16 @@ async function doctor() {
|
|
|
1781
1869
|
// `validate` job (where the "old processes" were vitest's own workers) and it
|
|
1782
1870
|
// reddens doctor-install-shape-e2e's "instead of going red forever" case on any
|
|
1783
1871
|
// dev box with a previous-version session still open.
|
|
1784
|
-
|
|
1872
|
+
//
|
|
1873
|
+
// `dwarn`, not the bare `warn`: the first cut called the bare one, which prints the
|
|
1874
|
+
// ⚠ line but never touches the `warnings` counter — so a doctor run whose ONLY
|
|
1875
|
+
// finding was a stale launcher printed the ⚠ and then closed with
|
|
1876
|
+
// "All checks passed!". That is the exact sentence buildDoctorSummary's docblock
|
|
1877
|
+
// says must not lie, and the exact case tests/doctor-summary.test.mjs pins at the
|
|
1878
|
+
// pure-function level; the counter simply never reached it from here. `dwarn`
|
|
1879
|
+
// increments `warnings` only — `issues` stays 0, so the paragraph above still
|
|
1880
|
+
// holds and `doctor` still exits 0.
|
|
1881
|
+
dwarn(`Old processes running${currentVersion ? ` (current: v${currentVersion})` : ''}:\n ` + stale.join('\n '));
|
|
1785
1882
|
} else {
|
|
1786
1883
|
ok('No stale processes');
|
|
1787
1884
|
}
|
|
@@ -2452,9 +2549,8 @@ function syncVersions() {
|
|
|
2452
2549
|
const claudeMdPath = join(PROJECT_DIR, 'CLAUDE.md');
|
|
2453
2550
|
if (existsSync(claudeMdPath)) {
|
|
2454
2551
|
const orig = readFileSync(claudeMdPath, 'utf8');
|
|
2455
|
-
const
|
|
2456
|
-
if (
|
|
2457
|
-
const patched = orig.replace(versionLine, `- **Version**: ${version}`);
|
|
2552
|
+
const patched = patchClaudeMdVersion(orig, version);
|
|
2553
|
+
if (patched !== null) {
|
|
2458
2554
|
if (patched !== orig) {
|
|
2459
2555
|
writeFileSync(claudeMdPath, patched);
|
|
2460
2556
|
ok(`CLAUDE.md: → ${version}`);
|
package/lib/import-jsonl.mjs
CHANGED
|
@@ -58,6 +58,16 @@ function importPrompt(db, ev, project, seenPrompts) {
|
|
|
58
58
|
? ev.message.content.filter(c => c?.type === 'text').map(c => c.text).join('\n')
|
|
59
59
|
: '');
|
|
60
60
|
if (!text) return false;
|
|
61
|
+
// Same sentinel the two LIVE writers refuse on (hook.mjs handleUserPrompt,
|
|
62
|
+
// scripts/user-prompt-search.js): <task-notification> is Claude Code protocol, not user
|
|
63
|
+
// input. Backfill is the third input boundary into user_prompts and was the only one
|
|
64
|
+
// persisting them — so a cold-start import seeded rows the live path would never write,
|
|
65
|
+
// which every reader then has to filter back out (`prompt_text NOT LIKE
|
|
66
|
+
// '<task-notification>%'` in search-core, search-engine and the UPS fallback). A reader
|
|
67
|
+
// that forgets the filter — `get P#N` and the timeline P# anchor do not have it — hands
|
|
68
|
+
// the agent protocol chatter as recalled context. Counted as `skipped`, which is what it
|
|
69
|
+
// is; the import stays idempotent because a skipped row was never inserted to re-match.
|
|
70
|
+
if (text.startsWith('<task-notification>')) return false;
|
|
61
71
|
const sessionId = ev.sessionId || 'imported';
|
|
62
72
|
const ts = ev.timestamp || new Date().toISOString();
|
|
63
73
|
const safe = scrubSecrets(text.slice(0, 10000));
|
package/lib/recall-core.mjs
CHANGED
|
@@ -54,3 +54,34 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
54
54
|
|
|
55
55
|
return { filename, rows };
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Does a file-keyed recall have anything for `file`? COUNT only — no rows, and
|
|
60
|
+
* deliberately NO access_count/last_accessed_at bump.
|
|
61
|
+
*
|
|
62
|
+
* The bump in recallByFile above is correct there because a recall IS engagement and the
|
|
63
|
+
* tier/decay system feeds on it. This helper exists for the opposite situation: `search`
|
|
64
|
+
* wants to know, on a zero-result query that looks like a path, whether `recall` would
|
|
65
|
+
* have answered — a question ABOUT the store, asked on the user's behalf but not by them.
|
|
66
|
+
* Answering it through recallByFile would push the counters of rows nobody read, i.e. a
|
|
67
|
+
* measurement writing to what it measures. It shares the predicate rather than re-typing
|
|
68
|
+
* it, so `search`'s hint and `recall`'s answer can never disagree about what matches.
|
|
69
|
+
*
|
|
70
|
+
* `superseded_at IS NULL` and the LOW_SIGNAL filter come along for the same reason: a hint
|
|
71
|
+
* must promise only what the default `recall` will actually print.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('better-sqlite3').Database} db
|
|
74
|
+
* @param {string} file Path or filename, same forms recallByFile accepts.
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
export function countRecallableByFile(db, file) {
|
|
78
|
+
const { c = 0 } = db.prepare(`
|
|
79
|
+
SELECT COUNT(DISTINCT o.id) AS c
|
|
80
|
+
FROM observations o
|
|
81
|
+
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
82
|
+
WHERE ${liveObsFilterSql('o')}
|
|
83
|
+
AND ${fileMatchClause('of2')}
|
|
84
|
+
AND ${notLowSignalTitleClause('o')}
|
|
85
|
+
`).get(...fileMatchParams(file)) || {};
|
|
86
|
+
return c;
|
|
87
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -58,7 +58,7 @@ import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdT
|
|
|
58
58
|
import { saveObservation, saveWithClosures, formatSupersedeSkipped, formatSupersededNote } from './lib/save-observation.mjs';
|
|
59
59
|
import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
|
|
60
60
|
import { EXPORT_COLUMNS_SQL, buildExportWhere } from './lib/export-columns.mjs';
|
|
61
|
-
import { recallByFile } from './lib/recall-core.mjs';
|
|
61
|
+
import { recallByFile, countRecallableByFile } from './lib/recall-core.mjs';
|
|
62
62
|
import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
|
|
63
63
|
import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentTimeline, fetchTimelineWindow } from './lib/timeline-core.mjs';
|
|
64
64
|
import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
|
|
@@ -90,6 +90,35 @@ import { shouldQueueSaveEnrich, queueSaveEnrich } from './lib/save-enrich.mjs';
|
|
|
90
90
|
|
|
91
91
|
// ─── Commands ────────────────────────────────────────────────────────────────
|
|
92
92
|
|
|
93
|
+
// A path query is not a text query, and `search` cannot tell the user so.
|
|
94
|
+
//
|
|
95
|
+
// OBS_FTS_COLUMNS (scoring-sql.mjs) indexes title/narrative/lesson/aliases/concepts — it
|
|
96
|
+
// does NOT index `files`. File association lives in the observation_files junction, which
|
|
97
|
+
// is `recall`'s table and only `recall`'s. So a save that named `src/payments/webhook.ts`
|
|
98
|
+
// in --files and never mentioned it in prose is reachable by `recall` and unreachable by
|
|
99
|
+
// `search`, and the user typing the path they were just editing gets a flat
|
|
100
|
+
// "No results" — a true statement about the FTS index that reads as a false one about the
|
|
101
|
+
// store. This is the one zero-result shape the CLI can positively disprove, so it does,
|
|
102
|
+
// with the exact command that answers it.
|
|
103
|
+
//
|
|
104
|
+
// Cheap and quiet: one COUNT, only on a zero-result query that is a single whitespace-free
|
|
105
|
+
// token shaped like a path or filename, and silent when that count is 0 (the ordinary case
|
|
106
|
+
// — an ordinary prose query never reaches the COUNT at all). countRecallableByFile does
|
|
107
|
+
// not bump access counters, so offering the hint cannot inflate the engagement signal of
|
|
108
|
+
// rows the user has not read.
|
|
109
|
+
function emitRecallHint(db, query) {
|
|
110
|
+
const q = String(query || '').trim();
|
|
111
|
+
if (!q || /\s/.test(q)) return;
|
|
112
|
+
if (!(q.includes('/') || q.includes('\\') || /\.[A-Za-z0-9]{1,8}$/.test(q))) return;
|
|
113
|
+
try {
|
|
114
|
+
const n = countRecallableByFile(db, q);
|
|
115
|
+
if (n > 0) {
|
|
116
|
+
out(`[mem] ${n} observation(s) are linked to that file — search indexes text, not file paths.`);
|
|
117
|
+
out(`[mem] Try: claude-mem-lite recall "${q}"`);
|
|
118
|
+
}
|
|
119
|
+
} catch { /* hint is best-effort; never break search */ }
|
|
120
|
+
}
|
|
121
|
+
|
|
93
122
|
async function cmdSearch(db, args, { llm } = {}) {
|
|
94
123
|
const { positional, flags } = parseArgs(args);
|
|
95
124
|
|
|
@@ -295,6 +324,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
295
324
|
out(JSON.stringify({ query, total: 0, returned: 0, offset, limit, deep: isDeep, variants: isDeep ? deepVariants : undefined, results: [] }));
|
|
296
325
|
} else {
|
|
297
326
|
out(`[mem] No results for "${query}"`);
|
|
327
|
+
emitRecallHint(db, query);
|
|
298
328
|
// The zero-result path is where the trailer earns its keep — the D#92
|
|
299
329
|
// failure chain was exactly "searched, found nothing, item was deferred".
|
|
300
330
|
emitDeferredTrailer();
|
|
@@ -1696,9 +1726,24 @@ function cmdExport(db, args) {
|
|
|
1696
1726
|
// truncated backup that lost rows on restore, and `--limit 5000` was REJECTED back
|
|
1697
1727
|
// to 200 (can't back up >1000 at all). Now: omit --limit → LIMIT -1 (SQLite = no
|
|
1698
1728
|
// limit); pass --limit N → honor any positive N (a backup may exceed 1000).
|
|
1729
|
+
//
|
|
1730
|
+
// The invalid-value branch has to land on that same -1, not on the sibling commands'
|
|
1731
|
+
// `defaultValue: 200`. `parseIntFlag`'s warn-and-default contract is right for `search`
|
|
1732
|
+
// and `recent`, where the default is a display width; here the default is COMPLETENESS,
|
|
1733
|
+
// and defaulting a backup to 200 rows reopens the truncation the paragraph above closed
|
|
1734
|
+
// — through the invalid door instead of the absent one. It is the same failure shape as
|
|
1735
|
+
// the bare `--to` guard at the top of this function: `export --limit "$N" > backup.json`
|
|
1736
|
+
// with `$N` unset or typo'd writes 200 rows, warns on a stderr the redirect usually
|
|
1737
|
+
// discards, and exits 0. Recovering to the complete set is the only direction that
|
|
1738
|
+
// cannot lose a row on restore.
|
|
1699
1739
|
const limitGiven = flags.limit !== undefined && flags.limit !== null && flags.limit !== '';
|
|
1700
1740
|
const limit = limitGiven
|
|
1701
|
-
? parseIntFlag(flags.limit, {
|
|
1741
|
+
? parseIntFlag(flags.limit, {
|
|
1742
|
+
name: '--limit',
|
|
1743
|
+
defaultValue: -1,
|
|
1744
|
+
warn: () => process.stderr.write(
|
|
1745
|
+
`[mem] Invalid --limit "${flags.limit}" (must be an integer ≥ 1); exporting the COMPLETE matching set instead\n`),
|
|
1746
|
+
})
|
|
1702
1747
|
: -1;
|
|
1703
1748
|
const format = flags.format || 'json';
|
|
1704
1749
|
if (!['json', 'jsonl'].includes(format)) {
|
|
@@ -1742,7 +1787,10 @@ function cmdExport(db, args) {
|
|
|
1742
1787
|
outVerbatim(JSON.stringify(rows, null, 2));
|
|
1743
1788
|
}
|
|
1744
1789
|
|
|
1745
|
-
|
|
1790
|
+
// `limit > 0`, not just `limitGiven`: an invalid `--limit` now recovers to the complete
|
|
1791
|
+
// set (-1), and `rows.length >= -1` is always true — so the guard as written announced
|
|
1792
|
+
// "Results capped at -1" on the one path that is guaranteed NOT to be capped.
|
|
1793
|
+
if (limitGiven && limit > 0 && rows.length >= limit) {
|
|
1746
1794
|
process.stderr.write(`[mem] Note: Results capped at ${limit}. Raise --limit or narrow --from/--to to export more.\n`);
|
|
1747
1795
|
}
|
|
1748
1796
|
// Fidelity caveat at backup-creation time (mirrors the restore-side note). stderr,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.96.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.96.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
12
12
|
"better-sqlite3": "^12.11.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.96.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",
|
package/plugin-cache-guard.mjs
CHANGED
|
@@ -64,6 +64,36 @@ export function clearPluginCacheHooks(opts) {
|
|
|
64
64
|
return cleared.sort();
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* What a cache version's hooks/hooks.json ACTUALLY registers.
|
|
69
|
+
*
|
|
70
|
+
* The counterpart to hasInstallManagedHooks: on a plugin-only install this file
|
|
71
|
+
* is the sole hook registration, so an EMPTY one means every hook is dead. Both
|
|
72
|
+
* status and doctor used to credit "the plugin manifest serves the hooks" from
|
|
73
|
+
* `settings.json holds none` + `an active plugin version exists` alone, never
|
|
74
|
+
* opening the manifest — and an emptied manifest looks exactly like a healthy
|
|
75
|
+
* npm-shape install from those two facts. That false green is what let a cleared
|
|
76
|
+
* cache run for a full session reporting all-green with zero hooks firing.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} root Cache version dir (shape.activePluginVersion.root)
|
|
79
|
+
* @returns {{ok: boolean, events: string[], reason: string|null}}
|
|
80
|
+
* ok=false with reason 'no-manifest' | 'unreadable' | 'empty'
|
|
81
|
+
*/
|
|
82
|
+
export function pluginCacheHookEvents(root) {
|
|
83
|
+
const p = join(root, 'hooks', 'hooks.json');
|
|
84
|
+
if (!existsSync(p)) return { ok: false, events: [], reason: 'no-manifest' };
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, events: [], reason: 'unreadable' };
|
|
90
|
+
}
|
|
91
|
+
const events = Object.keys(parsed?.hooks || {});
|
|
92
|
+
return events.length > 0
|
|
93
|
+
? { ok: true, events, reason: null }
|
|
94
|
+
: { ok: false, events: [], reason: 'empty' };
|
|
95
|
+
}
|
|
96
|
+
|
|
67
97
|
export function hasInstallManagedHooks(opts) {
|
|
68
98
|
const home = opts?.home || homedir();
|
|
69
99
|
const plugin = opts?.plugin || DEFAULT_PLUGIN;
|
|
@@ -75,3 +105,77 @@ export function hasInstallManagedHooks(opts) {
|
|
|
75
105
|
return serialized.includes(`.${plugin}/`) || serialized.includes(`/${plugin}/`);
|
|
76
106
|
} catch { return false; }
|
|
77
107
|
}
|
|
108
|
+
|
|
109
|
+
/** Every `command` string under settings.json `hooks`, in registration order. */
|
|
110
|
+
function settingsHookCommands(home) {
|
|
111
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
112
|
+
if (!existsSync(settingsPath)) return [];
|
|
113
|
+
let s;
|
|
114
|
+
try { s = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { return []; }
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const matchers of Object.values(s?.hooks || {})) {
|
|
117
|
+
if (!Array.isArray(matchers)) continue;
|
|
118
|
+
for (const m of matchers) {
|
|
119
|
+
for (const h of (Array.isArray(m?.hooks) ? m.hooks : [])) {
|
|
120
|
+
if (typeof h?.command === 'string') out.push(h.command);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Absolute paths a hook command names. install.mjs writes `node "<abs>" …` /
|
|
129
|
+
* `bash "<abs>"`, so the quoted form is the shipped shape; the unquoted arm covers
|
|
130
|
+
* hand-edited and pre-quoting entries.
|
|
131
|
+
*/
|
|
132
|
+
function commandPaths(command) {
|
|
133
|
+
const paths = [];
|
|
134
|
+
for (const m of command.matchAll(/"([^"]+)"/g)) if (m[1].startsWith('/')) paths.push(m[1]);
|
|
135
|
+
if (paths.length === 0) {
|
|
136
|
+
for (const tok of command.split(/\s+/)) if (tok.startsWith('/')) paths.push(tok);
|
|
137
|
+
}
|
|
138
|
+
return paths;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Does settings.json register hooks we manage that can ACTUALLY RUN?
|
|
143
|
+
*
|
|
144
|
+
* `hasInstallManagedHooks` answers a string question — "does settings.json mention a
|
|
145
|
+
* path of ours" — and that is the right question for install(), which has just written
|
|
146
|
+
* those entries itself. It is the wrong question for the SessionStart self-heal, whose
|
|
147
|
+
* action is DESTRUCTIVE: clearing the plugin cache manifest is a dedup only while
|
|
148
|
+
* settings.json really is the other registration. A user who installed globally, later
|
|
149
|
+
* switched to the plugin, and removed `~/.claude-mem-lite` by hand (or by an
|
|
150
|
+
* `npm uninstall -g` that never ran our `uninstall`) leaves entries that name a deleted
|
|
151
|
+
* launcher. They fire nothing — and on that state the self-heal read them as a live
|
|
152
|
+
* registration and emptied the ONE manifest that was working, on every single
|
|
153
|
+
* SessionStart. v3.95.1 taught status/doctor to SEE that end state; this is the half
|
|
154
|
+
* that stops producing it.
|
|
155
|
+
*
|
|
156
|
+
* Deliberately narrow: a `false` is returned only when at least one managed command was
|
|
157
|
+
* parsed AND none of the paths it names exist. An unparseable or unfamiliar command shape
|
|
158
|
+
* keeps the old answer, so this can only ever remove the destructive branch from a case
|
|
159
|
+
* we positively verified as dead — never add it to one.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} [opts]
|
|
162
|
+
* @param {string} [opts.home]
|
|
163
|
+
* @param {string} [opts.plugin]
|
|
164
|
+
* @returns {boolean}
|
|
165
|
+
*/
|
|
166
|
+
export function hasLiveInstallManagedHooks(opts) {
|
|
167
|
+
if (!hasInstallManagedHooks(opts)) return false;
|
|
168
|
+
const home = opts?.home || homedir();
|
|
169
|
+
const plugin = opts?.plugin || DEFAULT_PLUGIN;
|
|
170
|
+
const managed = settingsHookCommands(home)
|
|
171
|
+
.filter(c => c.includes(`.${plugin}/`) || c.includes(`/${plugin}/`));
|
|
172
|
+
let checked = 0;
|
|
173
|
+
for (const c of managed) {
|
|
174
|
+
for (const p of commandPaths(c)) {
|
|
175
|
+
checked++;
|
|
176
|
+
if (existsSync(p)) return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// No path we could check → keep hasInstallManagedHooks' answer (see docblock).
|
|
180
|
+
return checked === 0;
|
|
181
|
+
}
|
|
@@ -76,6 +76,19 @@ export const INTENTS = [
|
|
|
76
76
|
{ pattern: /implement|feature\b|add\s+(?:a\s+)?new|实现|添加|新功能|新增|开发|编写|创建|构建|做一个|加一个|写一个/i, type: null, limit: 3 },
|
|
77
77
|
// Recall/history intent (catch-all temporal, lowest priority)
|
|
78
78
|
// CJK: 刚才/历史/回顾 from real prompts; 碰到过|遇到过|见过|同样的问题 from spoken CN
|
|
79
|
+
//
|
|
80
|
+
// MEASURED AND REJECTED — do not re-add `remind me` here without a ruler (2026-09-05,
|
|
81
|
+
// 10-row typed corpus, sandbox install). The reasoning that it belongs is seductive and
|
|
82
|
+
// wrong: `remember` is already in this arm, `remind me` is its imperative twin, and its
|
|
83
|
+
// absence is the sole reason "remind me what we decided about session cookies" reaches
|
|
84
|
+
// hasExplicitSignal with no error signature, no file, no identifier and no CJK, and is
|
|
85
|
+
// dropped before FTS runs. Added, the prompt does fire — and this arm carries
|
|
86
|
+
// useRecent + limit 5, so when topical FTS comes back empty (it does: the OR floor
|
|
87
|
+
// drops a long multi-topic prompt whose best row shares only "session"/"cookies") the
|
|
88
|
+
// recency fallback spends FIVE injection slots on the five newest rows, and on the
|
|
89
|
+
// measured corpus the session-cookies decision was NOT among them. Five noise rows and
|
|
90
|
+
// no answer is worse than the silence it replaced. Any future attempt needs
|
|
91
|
+
// benchmark/citation-live-replay.mjs on the `fyi` face, not this intuition.
|
|
79
92
|
{ pattern: /before|previously|last time|remember|seen this|same\s+issue|之前|上次|以前|记得|刚才|历史|回顾|碰到过|遇到过|见过|同样的问题|类似的问题/i, type: null, limit: 5, useRecent: true },
|
|
80
93
|
];
|
|
81
94
|
|
package/scripts/setup.sh
CHANGED
|
@@ -26,7 +26,13 @@ fi
|
|
|
26
26
|
log_ok() { echo -e "${GREEN}✓${NC} $*" >&2; }
|
|
27
27
|
log_info() { echo -e "${BLUE}ℹ${NC} $*" >&2; }
|
|
28
28
|
log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
|
|
29
|
-
# shellcheck disable=SC2317 # kept for API symmetry with log_ok/log_info/log_warn
|
|
29
|
+
# shellcheck disable=SC2317,SC2329 # kept for API symmetry with log_ok/log_info/log_warn
|
|
30
|
+
# Both codes on purpose: shellcheck 0.10.0 split "this function is never invoked" out of
|
|
31
|
+
# SC2317 (unreachable command) into its own SC2329. The lone SC2317 stopped matching, so
|
|
32
|
+
# 0.11.0 flags this line and `npx eslint`-style local runs exit 1 while CI stays green —
|
|
33
|
+
# the ubuntu-latest runner still ships a pre-0.10 shellcheck. That is a version skew, not
|
|
34
|
+
# a disagreement about the code: this job gates on exit 0, so it turns red on its own the
|
|
35
|
+
# day GitHub bumps the runner image. Keep SC2317 for anyone on an older shellcheck.
|
|
30
36
|
log_err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
31
37
|
|
|
32
38
|
# 1. Migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
|