claude-mem-lite 3.70.2 → 3.72.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/cli/activity.mjs +8 -1
- package/cli/doctor.mjs +2 -2
- package/hook-precompact.mjs +13 -7
- package/hook.mjs +13 -3
- package/install.mjs +38 -0
- package/lib/cli-project.mjs +125 -0
- package/lib/doctor-drift.mjs +67 -7
- package/lib/hook-stdout.mjs +55 -10
- package/lib/keyctx-marker.mjs +43 -1
- package/mem-cli.mjs +20 -12
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/project-utils.mjs +16 -1
- package/scripts/binding-probe-cli.mjs +26 -5
- package/scripts/launch.mjs +26 -3
- package/source-files.mjs +4 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.72.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.72.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/cli/activity.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { inferProject } from '../utils.mjs';
|
|
12
12
|
import { resolveProject } from '../project-utils.mjs';
|
|
13
|
+
import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
|
|
13
14
|
import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
|
|
14
15
|
import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
|
|
15
16
|
|
|
@@ -28,7 +29,13 @@ export async function cmdActivity(db, args) {
|
|
|
28
29
|
const { positional, flags } = parseArgs(args.slice(1));
|
|
29
30
|
const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES, promoteInsightEvents } = await import('../lib/activity.mjs');
|
|
30
31
|
const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
|
|
31
|
-
|
|
32
|
+
// `save` CREATES a row, so it keeps plain inferProject(): the DB-aware fallback is a read
|
|
33
|
+
// affordance, and applying it to a write absorbs a not-yet-born subproject's first event
|
|
34
|
+
// into the enclosing repo (pre-tag review, reproduced). Every other subcommand reads or
|
|
35
|
+
// operates on rows that already exist, where falling back is what finds them.
|
|
36
|
+
const project = flags.project
|
|
37
|
+
? resolveProject(db, flags.project)
|
|
38
|
+
: (sub === 'save' ? inferProject() : cliProject(db));
|
|
32
39
|
|
|
33
40
|
if (sub === 'save') {
|
|
34
41
|
// Reject value-less string flags before they reach saveEvent as a boolean `true`
|
package/cli/doctor.mjs
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
// for install health checks). With --benchmark or --metrics it is routed to
|
|
6
6
|
// mem-cli which delegates to this handler.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
|
|
9
9
|
import { out } from './common.mjs';
|
|
10
10
|
|
|
11
11
|
export async function cmdDoctor(db, args) {
|
|
12
12
|
if (args.includes('--benchmark')) {
|
|
13
13
|
const { runBenchmark } = await import('../lib/doctor-benchmark.mjs');
|
|
14
|
-
const project =
|
|
14
|
+
const project = cliProject(db);
|
|
15
15
|
// Sample recent user prompts so the CLI report has non-null injection_rate
|
|
16
16
|
// and hook latency. Without this, runBenchmark's prompts default of [] makes
|
|
17
17
|
// every metric 0/null — a dead command from the user's perspective. Tests
|
package/hook-precompact.mjs
CHANGED
|
@@ -21,19 +21,25 @@ import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
|
21
21
|
* @param {string} [ctx.sessionId]
|
|
22
22
|
* @returns {void}
|
|
23
23
|
*/
|
|
24
|
-
export function handlePreCompact({ db, project, sessionId }) {
|
|
24
|
+
export function handlePreCompact({ db, project, sessionId, runtimeDir = RUNTIME_DIR }) {
|
|
25
25
|
try {
|
|
26
26
|
const collector = {};
|
|
27
27
|
const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
const rendered = body && String(body).trim() !== '';
|
|
29
|
+
if (rendered) {
|
|
30
|
+
process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
|
|
31
|
+
}
|
|
32
|
+
// Recorded even when NOTHING was re-rendered, matching handleSessionStart — the two
|
|
33
|
+
// callers must describe the same set (keyctx-marker.mjs header), and the marker is an
|
|
34
|
+
// exclude-set for what is actually in context. The old empty-body early return left the
|
|
35
|
+
// PREVIOUS render's ids standing while compaction removed the block they described, so
|
|
36
|
+
// <memory-context> went on suppressing rows that were no longer shown: D#123 review C-1
|
|
37
|
+
// exactly, on the twin leg. An empty render means an empty exclude-set, not a stale one.
|
|
32
38
|
recordKeyContextInjection(db, {
|
|
33
|
-
runtimeDir
|
|
39
|
+
runtimeDir,
|
|
34
40
|
project,
|
|
35
41
|
sessionId: sessionId || null,
|
|
36
|
-
ids: collector.keyContextIds || [],
|
|
42
|
+
ids: rendered ? (collector.keyContextIds || []) : [],
|
|
37
43
|
});
|
|
38
44
|
} catch (e) {
|
|
39
45
|
debugCatch(e, 'handlePreCompact');
|
package/hook.mjs
CHANGED
|
@@ -48,7 +48,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
|
|
|
48
48
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
49
49
|
import { formatHookError } from './lib/native-binding-hint.mjs';
|
|
50
50
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
51
|
-
import { queueHookContext, flushHookStdout } from './lib/hook-stdout.mjs';
|
|
51
|
+
import { queueHookContext, queueHookSystemMessage, flushHookStdout } from './lib/hook-stdout.mjs';
|
|
52
52
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
53
53
|
import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans } from './lib/maintain-core.mjs';
|
|
54
54
|
import { snapshotDb } from './lib/db-backup.mjs';
|
|
@@ -73,7 +73,7 @@ import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs
|
|
|
73
73
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
74
74
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
75
75
|
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
76
|
-
import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
76
|
+
import { recordKeyContextInjection, touchKeyContextMarker } from './lib/keyctx-marker.mjs';
|
|
77
77
|
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
78
78
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
79
79
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
@@ -1549,7 +1549,12 @@ async function handleSessionStart() {
|
|
|
1549
1549
|
let updateCheckDue = false;
|
|
1550
1550
|
try {
|
|
1551
1551
|
const banner = getCachedUpdateBanner();
|
|
1552
|
-
|
|
1552
|
+
// The human channel, not additionalContext: "vX available" is a notice for the
|
|
1553
|
+
// USER. Folding it into additionalContext under suppressOutput:true kept its
|
|
1554
|
+
// content and lost its audience. Claude Code renders a command hook's top-level
|
|
1555
|
+
// systemMessage as its own hook_system_message, independent of the context
|
|
1556
|
+
// block — see lib/hook-stdout.mjs for the bundle evidence.
|
|
1557
|
+
if (banner) queueHookSystemMessage(String(banner));
|
|
1553
1558
|
updateCheckDue = isUpdateCheckDue();
|
|
1554
1559
|
} catch (e) { debugCatch(e, 'session-start-update'); }
|
|
1555
1560
|
|
|
@@ -1755,6 +1760,11 @@ async function handleUserPrompt() {
|
|
|
1755
1760
|
if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
|
|
1756
1761
|
keyContextIds.push(...ids);
|
|
1757
1762
|
}
|
|
1763
|
+
// The marker's validity is session-lifetime but gcStalePreRecallCooldowns sweeps it
|
|
1764
|
+
// by AGE. Stamping it on read makes that sweep mean "24h with no prompt in this
|
|
1765
|
+
// session" instead of "24h since the render" — otherwise a session running past a
|
|
1766
|
+
// day loses its own exclude-set and re-injects what Key Context is still showing.
|
|
1767
|
+
touchKeyContextMarker({ runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId });
|
|
1758
1768
|
} catch { /* no marker — nothing was injected, exclude nothing */ }
|
|
1759
1769
|
const pathAInjectedIds = [];
|
|
1760
1770
|
|
package/install.mjs
CHANGED
|
@@ -1852,6 +1852,44 @@ async function doctor() {
|
|
|
1852
1852
|
dwarn('Dev drift: check failed — ' + e.message);
|
|
1853
1853
|
}
|
|
1854
1854
|
|
|
1855
|
+
// Hook scripts: the check above grades SOURCE_FILES, which holds zero `scripts/` entries.
|
|
1856
|
+
// Hook scripts ship from the separate HOOK_SCRIPT_FILES manifest into
|
|
1857
|
+
// ~/.claude-mem-lite/scripts/, and every settings.json hook command names one of those
|
|
1858
|
+
// absolute paths — so "the tarball shipped without scripts/" (source-files.mjs:243) killed
|
|
1859
|
+
// every hook while doctor printed an all-clear. Both classes are issues here; see
|
|
1860
|
+
// checkHookScriptDrift for why the managed-files demote branch must not be copied over.
|
|
1861
|
+
try {
|
|
1862
|
+
// Same gate as the managed-files check: a plugin-only install never deploys into
|
|
1863
|
+
// ~/.claude-mem-lite, and its hooks run from ${CLAUDE_PLUGIN_ROOT}/scripts/ instead.
|
|
1864
|
+
const skipScripts = !shape.managed && !!shape.activePluginVersion;
|
|
1865
|
+
const { checkHookScriptDrift, HOOK_SCRIPT_ENTRY_POINTS } = await import('./lib/doctor-drift.mjs');
|
|
1866
|
+
const h = skipScripts ? null : checkHookScriptDrift(INSTALL_DIR, HOOK_SCRIPT_FILES);
|
|
1867
|
+
const scriptRemedy = `claude-mem-lite self-update (or: node ${join(INSTALL_DIR, 'install.mjs')} repair)`;
|
|
1868
|
+
if (skipScripts) {
|
|
1869
|
+
ok('Hook scripts: n/a (plugin-only install — hooks run from the plugin cache)');
|
|
1870
|
+
} else if (!h.present) {
|
|
1871
|
+
warn(`Hook scripts: ${join(INSTALL_DIR, 'scripts')} `
|
|
1872
|
+
+ `${h.dirSymlink ? 'is a dangling symlink' : 'is absent'} — all ${HOOK_SCRIPT_ENTRY_POINTS.size} hook `
|
|
1873
|
+
+ `commands name absolute paths under it, so no hook can fire. Fix: ${scriptRemedy}`);
|
|
1874
|
+
issues++;
|
|
1875
|
+
} else if (h.missingCount > 0) {
|
|
1876
|
+
const parts = [];
|
|
1877
|
+
if (h.missingEntryFiles.length > 0) {
|
|
1878
|
+
parts.push(`${h.missingEntryFiles.length} hook entry (${h.missingEntryFiles.join(', ')}) — the command cannot start`);
|
|
1879
|
+
}
|
|
1880
|
+
if (h.missingModuleFiles.length > 0) {
|
|
1881
|
+
parts.push(`${h.missingModuleFiles.length} imported helper (${h.missingModuleFiles.join(', ')}) — ERR_MODULE_NOT_FOUND at hook time`);
|
|
1882
|
+
}
|
|
1883
|
+
warn(`Hook scripts: ${h.missingCount} missing — ${parts.join('; ')}. Fix: ${scriptRemedy}`);
|
|
1884
|
+
issues++;
|
|
1885
|
+
} else {
|
|
1886
|
+
ok(`Hook scripts: ${HOOK_SCRIPT_FILES.length} present `
|
|
1887
|
+
+ `(${h.dirSymlink ? 'dev — scripts/ symlinked to the repo' : 'copy install'})`);
|
|
1888
|
+
}
|
|
1889
|
+
} catch (e) {
|
|
1890
|
+
dwarn('Hook scripts: check failed — ' + e.message);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1855
1893
|
// Stale temp files
|
|
1856
1894
|
try {
|
|
1857
1895
|
// hook-update + the episode workers write runtime/ + staging under DB_DIR
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// lib/cli-project.mjs — which project a terminal-invoked CLI command should read.
|
|
2
|
+
//
|
|
3
|
+
// `inferProject()` names the directory the process stands in (CLAUDE_PROJECT_DIR || PWD ||
|
|
4
|
+
// cwd). Inside a hook that is always the session root, because Claude Code sets
|
|
5
|
+
// CLAUDE_PROJECT_DIR. In a bare terminal it is not set, so the name follows the user:
|
|
6
|
+
// `cd src/auth && claude-mem-lite recent` asks for `src--auth`, which holds nothing, while
|
|
7
|
+
// the session's hooks have been writing `projects--mem`. The command answers "No recent
|
|
8
|
+
// observations" about a project full of them.
|
|
9
|
+
//
|
|
10
|
+
// Anchoring on the git work-tree root instead was tried and reverted before shipping (see
|
|
11
|
+
// project-utils.mjs): Claude Code started in `mono/packages/api` sets CLAUDE_PROJECT_DIR to
|
|
12
|
+
// the PACKAGE dir, so hooks write `packages--api` while a git anchor sends the CLI to
|
|
13
|
+
// `mono--monorepo` — the same split, mirrored. No purely path-derived rule separates the two
|
|
14
|
+
// cases, because the difference is which name the hooks actually chose. The DB knows.
|
|
15
|
+
//
|
|
16
|
+
// So: compute both candidates, prefer whichever already holds rows, cwd winning ties.
|
|
17
|
+
//
|
|
18
|
+
// READ COMMANDS ONLY. The tie rule ("can only redirect when the cwd-derived name holds
|
|
19
|
+
// NOTHING") reads like a universal safety argument, and pre-tag review reproduced why it is
|
|
20
|
+
// not: for a read, "cwd holds nothing" means there is nothing to lose; for a WRITE it is the
|
|
21
|
+
// normal precondition of a project about to be born. Applying the fallback to save /
|
|
22
|
+
// defer add / restore / import-jsonl absorbed a fresh package dir's first rows into the
|
|
23
|
+
// enclosing repo, and once the session's hooks later wrote `packages--api`, those rows were
|
|
24
|
+
// unreachable from the directory they were written in. Write paths keep plain
|
|
25
|
+
// inferProject(); callers in mem-cli.mjs and cli/activity.mjs mark which is which.
|
|
26
|
+
//
|
|
27
|
+
// Lives here, not in project-utils.mjs: that module is DB-free and on the hook hot path, and
|
|
28
|
+
// hook-side resolution must stay byte-identical (hooks already have the right answer).
|
|
29
|
+
|
|
30
|
+
import { existsSync } from 'fs';
|
|
31
|
+
import { homedir } from 'os';
|
|
32
|
+
import { dirname, join, resolve } from 'path';
|
|
33
|
+
// inferProject through the utils.mjs barrel ON PURPOSE, not projectNameFromDir directly:
|
|
34
|
+
// "what does this process call its project" must keep exactly ONE definition, so anything
|
|
35
|
+
// that later changes it (a config file, a new env var) moves both faces together instead of
|
|
36
|
+
// letting this module drift into a second answer. It is also the seam mem-cli's own tests
|
|
37
|
+
// stub, and a resolver that bypassed it would quietly stop resolving what they exercise.
|
|
38
|
+
import { inferProject } from '../utils.mjs';
|
|
39
|
+
import { projectNameFromDir } from '../project-utils.mjs';
|
|
40
|
+
|
|
41
|
+
// Bounded so a pathological path (symlink loop, very deep tree) cannot spin. 64 levels is
|
|
42
|
+
// far past any real repo checkout; the loop also stops when dirname() reaches a fixed point.
|
|
43
|
+
const MAX_WALK_DEPTH = 64;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Nearest enclosing git work-tree root, or null.
|
|
47
|
+
*
|
|
48
|
+
* Checks for a `.git` ENTRY, not a directory: a linked worktree and a submodule both put a
|
|
49
|
+
* `.git` FILE at their root, and both are work-tree roots for this purpose.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} startDir
|
|
52
|
+
* @returns {string|null}
|
|
53
|
+
*/
|
|
54
|
+
export function findGitRoot(startDir) {
|
|
55
|
+
let dir = resolve(startDir);
|
|
56
|
+
for (let i = 0; i < MAX_WALK_DEPTH; i++) {
|
|
57
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
58
|
+
const parent = dirname(dir);
|
|
59
|
+
if (parent === dir) return null; // filesystem root
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Memoized per process: every CLI command resolves once, but a single command can ask
|
|
66
|
+
// several times (search resolves for the query, the reranker and the tier window).
|
|
67
|
+
let _cache = new Map();
|
|
68
|
+
|
|
69
|
+
/** Reset the per-process memo (for tests). */
|
|
70
|
+
export function _resetCliProjectCache() { _cache = new Map(); }
|
|
71
|
+
|
|
72
|
+
// Ordered cheapest-signal-last. Each is a distinct way a directory can already BE a project:
|
|
73
|
+
// sdk_sessions — hook.mjs inserts a row on the first SessionStart, long before any
|
|
74
|
+
// observation exists. Without it, a package dir Claude Code had been
|
|
75
|
+
// running in all morning still read as "holds nothing" and the fallback
|
|
76
|
+
// fired on a directory that was already its own project (pre-tag review).
|
|
77
|
+
// deferred_work — `defer add` in a fresh repo writes no observation either.
|
|
78
|
+
// observations — the common case.
|
|
79
|
+
const ROW_PROBES = [
|
|
80
|
+
'SELECT 1 FROM observations WHERE project = ? LIMIT 1',
|
|
81
|
+
'SELECT 1 FROM sdk_sessions WHERE project = ? LIMIT 1',
|
|
82
|
+
'SELECT 1 FROM deferred_work WHERE project = ? LIMIT 1',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
function hasRows(db, project) {
|
|
86
|
+
for (const sql of ROW_PROBES) {
|
|
87
|
+
if (db.prepare(sql).get(project)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The project a CLI command should target when the user gave no --project.
|
|
94
|
+
*
|
|
95
|
+
* @param {import('better-sqlite3').Database} db
|
|
96
|
+
* @param {{dir?: string}} [opts] `dir` overrides the directory to resolve from (tests).
|
|
97
|
+
* @returns {string} Canonical project name
|
|
98
|
+
*/
|
|
99
|
+
export function resolveCliProject(db, { dir, homeDir = homedir() } = {}) {
|
|
100
|
+
const base = dir || process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
|
|
101
|
+
// `dir` is a test seam for pointing the walk at a fixture; the real path asks inferProject.
|
|
102
|
+
const cwdName = dir ? projectNameFromDir(dir) : inferProject();
|
|
103
|
+
if (_cache.has(base)) return _cache.get(base);
|
|
104
|
+
let chosen = cwdName;
|
|
105
|
+
try {
|
|
106
|
+
const root = findGitRoot(base);
|
|
107
|
+
// A dotfiles repo at ~/.git puts EVERY directory under home in one work tree, which
|
|
108
|
+
// would make an unrelated scratch dir resolve to the home project. Home is a container,
|
|
109
|
+
// not a project — unless the user is standing in it, in which case it is the ordinary
|
|
110
|
+
// case and the candidates coincide anyway.
|
|
111
|
+
const rootName = root && !(root === resolve(homeDir) && resolve(base) !== resolve(homeDir))
|
|
112
|
+
? projectNameFromDir(root)
|
|
113
|
+
: null;
|
|
114
|
+
// Only the case where cwd holds nothing can move the answer — see the tie rule above.
|
|
115
|
+
if (rootName && rootName !== cwdName && !hasRows(db, cwdName) && hasRows(db, rootName)) {
|
|
116
|
+
chosen = rootName;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Resolution runs before every command; a probe that throws (locked DB, a table an older
|
|
120
|
+
// schema lacks) must degrade to today's behaviour, never take the command down.
|
|
121
|
+
chosen = cwdName;
|
|
122
|
+
}
|
|
123
|
+
_cache.set(base, chosen);
|
|
124
|
+
return chosen;
|
|
125
|
+
}
|
package/lib/doctor-drift.mjs
CHANGED
|
@@ -19,13 +19,12 @@ import { join } from 'path';
|
|
|
19
19
|
// dead weight there — while an absent ENTRY POINT is fatal in every shape, because the
|
|
20
20
|
// command names that path directly.
|
|
21
21
|
//
|
|
22
|
-
// Scope note
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// implied here.
|
|
22
|
+
// Scope note: this classifies only what the CALLER passes in, and install.mjs passes
|
|
23
|
+
// SOURCE_FILES, which holds zero `scripts/` entries — hook scripts are installed from the
|
|
24
|
+
// separate HOOK_SCRIPT_FILES manifest. An earlier draft mapped those into this set; it
|
|
25
|
+
// could never match a single path, so it was removed rather than left as inert code
|
|
26
|
+
// implying coverage it did not have. The hook-script manifest now has its own check with
|
|
27
|
+
// its own severity rules: `checkHookScriptDrift` below.
|
|
29
28
|
const ENTRY_POINTS = new Set([
|
|
30
29
|
'cli.mjs', 'mem-cli.mjs', 'server.mjs', 'hook.mjs', 'install.mjs',
|
|
31
30
|
]);
|
|
@@ -72,3 +71,64 @@ export function checkDevDrift(installDir, sourceFiles) {
|
|
|
72
71
|
details: plainFiles.slice(0, 5),
|
|
73
72
|
};
|
|
74
73
|
}
|
|
74
|
+
|
|
75
|
+
// Hook scripts a hook COMMAND LINE names directly — install.mjs's settings.json template
|
|
76
|
+
// and hooks/hooks.json both spell these paths out. Absent ⇒ the command cannot start.
|
|
77
|
+
// `prompt-search-utils.mjs` is deliberately absent from this set: nothing invokes it, it is
|
|
78
|
+
// imported by user-prompt-search.js. The split drives the message, not the severity — see
|
|
79
|
+
// checkHookScriptDrift.
|
|
80
|
+
//
|
|
81
|
+
// Exported so the set is not a second hand-maintained copy of the hook wiring that can go
|
|
82
|
+
// stale in silence: tests/doctor-hook-script-manifest.test.mjs re-derives it from the
|
|
83
|
+
// `command` strings in hooks/hooks.json and asserts equality, so registering a new hook
|
|
84
|
+
// script without classifying it here goes red.
|
|
85
|
+
export const HOOK_SCRIPT_ENTRY_POINTS = new Set([
|
|
86
|
+
'post-tool-use.sh', 'user-prompt-search.js', 'pre-tool-recall.js',
|
|
87
|
+
'post-tool-recall.js', 'pre-skill-bridge.js', 'pre-agent-inject.js',
|
|
88
|
+
'hook-launcher.mjs',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Integrity of the HOOK_SCRIPT_FILES manifest under `<installDir>/scripts/`.
|
|
93
|
+
*
|
|
94
|
+
* Separate from checkDevDrift because the two manifests install in different SHAPES, and
|
|
95
|
+
* #10686's rule — grade by which path RESOLVES the file — lands differently for each:
|
|
96
|
+
*
|
|
97
|
+
* • dev install: install.mjs symlinks the whole `scripts/` DIRECTORY (one link), not each
|
|
98
|
+
* file. So per-file lstat sees plain files through the link, and "plain file among
|
|
99
|
+
* symlinks = drift" — the signal checkDevDrift is built on — does not exist here at all.
|
|
100
|
+
* Applying it would flag every healthy dev install with 8 phantom drifts. What a missing
|
|
101
|
+
* file means instead: the install dir IS the repo dir, so it is missing from the repo.
|
|
102
|
+
* • copy install: an entry script is named by a command line (dead hook if absent), and
|
|
103
|
+
* user-prompt-search.js resolves `./prompt-search-utils.mjs` against the install dir
|
|
104
|
+
* (ERR_MODULE_NOT_FOUND on every user prompt if absent).
|
|
105
|
+
*
|
|
106
|
+
* Both classes are therefore fatal in both shapes. The entry/module split is kept for the
|
|
107
|
+
* MESSAGE — telling the reader which consequence they have — and must NOT be re-used to
|
|
108
|
+
* demote the module class the way the managed-files check legitimately does.
|
|
109
|
+
*
|
|
110
|
+
* `present:false` covers a scripts/ dir that was never created AND a dangling symlink:
|
|
111
|
+
* existsSync follows links, and the hook commands resolve through it to the same nothing.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} installDir
|
|
114
|
+
* @param {string[]} hookScriptFiles HOOK_SCRIPT_FILES manifest
|
|
115
|
+
*/
|
|
116
|
+
export function checkHookScriptDrift(installDir, hookScriptFiles) {
|
|
117
|
+
const scriptsDir = join(installDir, 'scripts');
|
|
118
|
+
let dirSymlink = false;
|
|
119
|
+
try { dirSymlink = lstatSync(scriptsDir).isSymbolicLink(); } catch { /* absent — handled below */ }
|
|
120
|
+
const classify = (missing) => ({
|
|
121
|
+
missingCount: missing.length,
|
|
122
|
+
missingEntryFiles: missing.filter((n) => HOOK_SCRIPT_ENTRY_POINTS.has(n)),
|
|
123
|
+
missingModuleFiles: missing.filter((n) => !HOOK_SCRIPT_ENTRY_POINTS.has(n)),
|
|
124
|
+
});
|
|
125
|
+
if (!existsSync(scriptsDir)) {
|
|
126
|
+
const all = classify([...hookScriptFiles]);
|
|
127
|
+
return { present: false, dirSymlink, ...all };
|
|
128
|
+
}
|
|
129
|
+
const missing = [];
|
|
130
|
+
for (const name of hookScriptFiles) {
|
|
131
|
+
if (!existsSync(join(scriptsDir, name))) missing.push(name);
|
|
132
|
+
}
|
|
133
|
+
return { present: true, dirSymlink, ...classify(missing) };
|
|
134
|
+
}
|
package/lib/hook-stdout.mjs
CHANGED
|
@@ -26,15 +26,17 @@
|
|
|
26
26
|
|
|
27
27
|
let parts = [];
|
|
28
28
|
let queuedEvent = null;
|
|
29
|
+
let systemParts = [];
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Queue a contribution to this process's single stdout envelope.
|
|
32
33
|
*
|
|
33
34
|
* @param {string} hookEventName Event name for hookSpecificOutput.
|
|
34
35
|
* @param {string} text additionalContext contribution; empty/blank is ignored.
|
|
36
|
+
* @param {{warn?: (msg: string) => void}} [deps]
|
|
35
37
|
* @returns {void}
|
|
36
38
|
*/
|
|
37
|
-
export function queueHookContext(hookEventName, text) {
|
|
39
|
+
export function queueHookContext(hookEventName, text, deps = {}) {
|
|
38
40
|
if (!hookEventName) return;
|
|
39
41
|
const body = String(text ?? '').trim();
|
|
40
42
|
if (!body) return;
|
|
@@ -42,11 +44,46 @@ export function queueHookContext(hookEventName, text) {
|
|
|
42
44
|
// hookSpecificOutput.hookEventName does not match the event it dispatched.
|
|
43
45
|
// In practice one process serves one event; keep the first and drop the
|
|
44
46
|
// stragglers rather than emit an envelope the host rejects outright.
|
|
45
|
-
|
|
47
|
+
//
|
|
48
|
+
// The drop is NOISY on purpose. It is unreachable today (all call sites are
|
|
49
|
+
// event-consistent), but flushEpisode's hookEventName DEFAULTS to 'PostToolUse',
|
|
50
|
+
// so a future caller that omits the argument would both mis-tag its receipt and
|
|
51
|
+
// have it swallowed without a trace. Silently vanishing work is this repo's
|
|
52
|
+
// most-repeated defect class; stderr is safe here because the host never parses it
|
|
53
|
+
// as the envelope.
|
|
54
|
+
if (queuedEvent && queuedEvent !== hookEventName) {
|
|
55
|
+
const warn = deps.warn || ((m) => { try { process.stderr.write(m); } catch { /* never block on a warning */ } });
|
|
56
|
+
warn(`[claude-mem-lite] hook-stdout: dropped a ${hookEventName} contribution — this process `
|
|
57
|
+
+ `already queued ${queuedEvent}, and one envelope carries exactly one hookEventName. `
|
|
58
|
+
+ 'This is a wiring bug: the contribution is lost.\n');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
46
61
|
queuedEvent = hookEventName;
|
|
47
62
|
parts.push(body);
|
|
48
63
|
}
|
|
49
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Queue a line for the HUMAN, not the model.
|
|
67
|
+
*
|
|
68
|
+
* Claude Code renders a command hook's top-level `systemMessage` as its own
|
|
69
|
+
* `hook_system_message` conversation message, independently of
|
|
70
|
+
* `hookSpecificOutput.additionalContext` — verified in the 2.1.234 bundle
|
|
71
|
+
* (`if (G.systemMessage) { … yield { message: yc({ type: "hook_system_message", … }) } }`)
|
|
72
|
+
* and documented there as "Display a message to the user (all hooks)". One envelope
|
|
73
|
+
* can therefore carry context for the model AND a notice for the user.
|
|
74
|
+
*
|
|
75
|
+
* Needed because v3.70.0's merge folded the update banner into additionalContext with
|
|
76
|
+
* `suppressOutput: true`, which kept its content and lost its audience.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} text Notice for the user; empty/blank is ignored.
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
81
|
+
export function queueHookSystemMessage(text) {
|
|
82
|
+
const body = String(text ?? '').trim();
|
|
83
|
+
if (!body) return;
|
|
84
|
+
systemParts.push(body);
|
|
85
|
+
}
|
|
86
|
+
|
|
50
87
|
/**
|
|
51
88
|
* Write the queued contributions as one envelope. Idempotent: a second call
|
|
52
89
|
* with nothing queued writes nothing, so calling it from both the dispatcher
|
|
@@ -56,18 +93,25 @@ export function queueHookContext(hookEventName, text) {
|
|
|
56
93
|
* @returns {boolean} true when an envelope was written.
|
|
57
94
|
*/
|
|
58
95
|
export function flushHookStdout(deps = {}) {
|
|
59
|
-
|
|
96
|
+
const hasContext = queuedEvent && parts.length > 0;
|
|
97
|
+
const hasSystem = systemParts.length > 0;
|
|
98
|
+
if (!hasContext && !hasSystem) return false;
|
|
60
99
|
const write = deps.write || ((s) => process.stdout.write(s));
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
100
|
+
const envelope = { suppressOutput: true };
|
|
101
|
+
if (hasSystem) envelope.systemMessage = systemParts.join('\n');
|
|
102
|
+
// Omitted entirely when there is no model-facing context: Stop's schema REJECTS a
|
|
103
|
+
// hookSpecificOutput block, and an envelope carrying only a user notice must not
|
|
104
|
+
// invent an event name to hang one on.
|
|
105
|
+
if (hasContext) {
|
|
106
|
+
envelope.hookSpecificOutput = {
|
|
64
107
|
hookEventName: queuedEvent,
|
|
65
108
|
additionalContext: parts.join('\n\n'),
|
|
66
|
-
}
|
|
67
|
-
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
68
111
|
parts = [];
|
|
69
112
|
queuedEvent = null;
|
|
70
|
-
|
|
113
|
+
systemParts = [];
|
|
114
|
+
write(JSON.stringify(envelope) + '\n');
|
|
71
115
|
return true;
|
|
72
116
|
}
|
|
73
117
|
|
|
@@ -75,9 +119,10 @@ export function flushHookStdout(deps = {}) {
|
|
|
75
119
|
export function resetHookStdout() {
|
|
76
120
|
parts = [];
|
|
77
121
|
queuedEvent = null;
|
|
122
|
+
systemParts = [];
|
|
78
123
|
}
|
|
79
124
|
|
|
80
125
|
/** Test seam: what is queued right now. */
|
|
81
126
|
export function peekHookStdout() {
|
|
82
|
-
return { hookEventName: queuedEvent, parts: [...parts] };
|
|
127
|
+
return { hookEventName: queuedEvent, parts: [...parts], systemParts: [...systemParts] };
|
|
83
128
|
}
|
package/lib/keyctx-marker.mjs
CHANGED
|
@@ -18,11 +18,19 @@
|
|
|
18
18
|
// Never throws: a marker-write failure must not break context delivery, and it
|
|
19
19
|
// must not cost the metering either — the bump runs first.
|
|
20
20
|
|
|
21
|
-
import { writeFileSync } from 'fs';
|
|
21
|
+
import { writeFileSync, statSync, utimesSync } from 'fs';
|
|
22
22
|
import { join } from 'path';
|
|
23
23
|
import { debugCatch } from '../utils.mjs';
|
|
24
24
|
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How stale the marker's stamp must be before a read refreshes it.
|
|
28
|
+
*
|
|
29
|
+
* The reader runs on every user prompt, so an unconditional touch would be one write per
|
|
30
|
+
* prompt for no gain. One hour is far below the 24h sweep and far above prompt cadence.
|
|
31
|
+
*/
|
|
32
|
+
export const KEYCTX_TOUCH_AFTER_MS = 60 * 60 * 1000;
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* Record one Key Context render: bump the rendered rows, then persist the id
|
|
28
36
|
* list for the prompt-time exclude-set and the citation extractor.
|
|
@@ -72,3 +80,37 @@ export function recordKeyContextInjection(db, { runtimeDir, project, sessionId =
|
|
|
72
80
|
|
|
73
81
|
return { bumped, written };
|
|
74
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Refresh an existing marker's mtime so the 24h sweep in hook.mjs measures time since the
|
|
86
|
+
* session last USED it rather than time since the render.
|
|
87
|
+
*
|
|
88
|
+
* The marker's validity is session-lifetime (injected-ids.mjs), but its GC is age-based, on
|
|
89
|
+
* the policy borrowed from the cooldown / injected-ids markers — whose semantics genuinely
|
|
90
|
+
* ARE time-windowed. A session that outlives 24h therefore had its own exclude-set deleted
|
|
91
|
+
* mid-session, after which handleUserPrompt re-injects rows the Key Context block is still
|
|
92
|
+
* showing. Keying the sweep on session liveness instead does not work: hook.mjs marks any
|
|
93
|
+
* session older than STALE_SESSION_MS 'abandoned' by started_at_epoch, so the long session
|
|
94
|
+
* reads as dead there too. Still being read is the signal that separates the two.
|
|
95
|
+
*
|
|
96
|
+
* Only refreshes an EXISTING file: a missing marker means "nothing was injected, exclude
|
|
97
|
+
* nothing", and fabricating an empty one would invent an exclude-set for a session whose
|
|
98
|
+
* block may well be on screen. Never throws — it runs in the user-prompt hot path.
|
|
99
|
+
*
|
|
100
|
+
* @param {{runtimeDir: string, project: string, sessionId?: string|null}} ctx
|
|
101
|
+
* @param {number} [nowMs] injectable clock
|
|
102
|
+
* @returns {boolean} true when the stamp was moved
|
|
103
|
+
*/
|
|
104
|
+
export function touchKeyContextMarker({ runtimeDir, project, sessionId = null } = {}, nowMs = Date.now()) {
|
|
105
|
+
if (!runtimeDir || !project) return false;
|
|
106
|
+
try {
|
|
107
|
+
const p = join(runtimeDir, keyContextIdsFileName(project, sessionId));
|
|
108
|
+
if (nowMs - statSync(p).mtimeMs <= KEYCTX_TOUCH_AFTER_MS) return false;
|
|
109
|
+
const stamp = new Date(nowMs);
|
|
110
|
+
utimesSync(p, stamp, stamp);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
debugCatch(e, 'keyctx-marker-touch');
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -6,6 +6,14 @@ import { homedir } from 'os';
|
|
|
6
6
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
7
|
import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
|
|
8
8
|
import { resolveProject } from './project-utils.mjs';
|
|
9
|
+
// READ commands resolve the project DB-aware: a subdirectory whose own name holds no rows
|
|
10
|
+
// falls back to the enclosing work-tree root, so `cd src/auth && … recent` reads what the
|
|
11
|
+
// session's hooks wrote. WRITE commands (save / defer add / restore / import-jsonl) keep
|
|
12
|
+
// plain inferProject() — pre-tag review reproduced the reason: for a read, "cwd holds
|
|
13
|
+
// nothing" means there is nothing to lose, but for a write it is the normal precondition of
|
|
14
|
+
// a project about to be born, and absorbing it into the enclosing repo strands the row once
|
|
15
|
+
// the session's hooks start writing the subdirectory's own name. Hook-side is untouched.
|
|
16
|
+
import { resolveCliProject as cliProject } from './lib/cli-project.mjs';
|
|
9
17
|
import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
|
|
10
18
|
import { reRankWithContext } from './search-scoring.mjs';
|
|
11
19
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
@@ -174,7 +182,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
174
182
|
const emitDeferredTrailer = () => {
|
|
175
183
|
if (!wantDeferredTrailer) return;
|
|
176
184
|
try {
|
|
177
|
-
const rows = searchDeferredWork(db, query, project ||
|
|
185
|
+
const rows = searchDeferredWork(db, query, project || cliProject(db));
|
|
178
186
|
for (const line of formatDeferredSearchTrailer(rows, 'claude-mem-lite get D#<id>')) out(line);
|
|
179
187
|
} catch { /* trailer is best-effort; never break search */ }
|
|
180
188
|
};
|
|
@@ -236,7 +244,7 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
236
244
|
|
|
237
245
|
const res = await coreRunSearchPipeline(
|
|
238
246
|
{
|
|
239
|
-
db, currentProject: project ? null :
|
|
247
|
+
db, currentProject: project ? null : cliProject(db), env: process.env,
|
|
240
248
|
searchObservationsHybrid, deepSearch, shouldEscalateToDeep, autoDeepLlmReady,
|
|
241
249
|
reRankWithContext, llm,
|
|
242
250
|
},
|
|
@@ -249,11 +257,11 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
249
257
|
obsTypeFallback: false, // #8217 removed list-by-type fallback from the CLI
|
|
250
258
|
crossSourceEpochSortNoFts: false, // CLI never reaches cross-source with empty ftsQuery (fails earlier)
|
|
251
259
|
rerankPolicy: 'cli', // re-rank/supersede on any obs; re-sort gated on cross-source
|
|
252
|
-
rerankProject: project ||
|
|
260
|
+
rerankProject: project || cliProject(db),
|
|
253
261
|
recentListingNoFts: false,
|
|
254
262
|
tolerateMissingFts: true, // pre-FTS legacy DBs: swallow session/prompt FTS errors
|
|
255
263
|
tierPosition: 'early', // tier filter inside the obs block (before sessions/prompts)
|
|
256
|
-
tierProject: project ||
|
|
264
|
+
tierProject: project || cliProject(db),
|
|
257
265
|
}
|
|
258
266
|
);
|
|
259
267
|
const isDeep = res.isDeep;
|
|
@@ -404,7 +412,7 @@ function cmdRecent(db, args) {
|
|
|
404
412
|
const limit = isValid
|
|
405
413
|
? rawLimit
|
|
406
414
|
: parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: RECENT_MAX });
|
|
407
|
-
const project = flags.project ? resolveProject(db, flags.project) :
|
|
415
|
+
const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
|
|
408
416
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
409
417
|
|
|
410
418
|
// `recent --type bugfix` previously parsed as a silent no-op — users naturally
|
|
@@ -1061,7 +1069,7 @@ function cmdDeferAdd(db, args) {
|
|
|
1061
1069
|
|
|
1062
1070
|
function cmdDeferList(db, args) {
|
|
1063
1071
|
const { flags } = parseArgs(args);
|
|
1064
|
-
const project = flags.project ? resolveProject(db, flags.project) :
|
|
1072
|
+
const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
|
|
1065
1073
|
const limit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 10, max: 100 });
|
|
1066
1074
|
const list = listOpenWithOrdinal(db, project, limit);
|
|
1067
1075
|
if (list.length === 0) {
|
|
@@ -1100,7 +1108,7 @@ function cmdDeferDrop(db, args) {
|
|
|
1100
1108
|
// without N shell invocations.
|
|
1101
1109
|
const rawTokens = idStr.split(',').map(s => s.trim()).filter(Boolean);
|
|
1102
1110
|
const tokens = rawTokens.map(t => /^\d+$/.test(t) ? parseInt(t, 10) : t);
|
|
1103
|
-
const project = flags.project ? resolveProject(db, flags.project) :
|
|
1111
|
+
const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
|
|
1104
1112
|
|
|
1105
1113
|
let realIds;
|
|
1106
1114
|
try {
|
|
@@ -1329,7 +1337,7 @@ function cmdContext(db, args) {
|
|
|
1329
1337
|
// Generate context live from DB — same builder the SessionStart hook uses.
|
|
1330
1338
|
// Pre-v2.30 this command parsed a snapshot out of CLAUDE.md, but the hook no
|
|
1331
1339
|
// longer writes there; DB is now the single source of truth.
|
|
1332
|
-
const project = flags.project ? resolveProject(db, flags.project) :
|
|
1340
|
+
const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
|
|
1333
1341
|
const block = buildSessionContextLines(db, project).trim();
|
|
1334
1342
|
|
|
1335
1343
|
if (!block) {
|
|
@@ -1369,7 +1377,7 @@ function cmdContext(db, args) {
|
|
|
1369
1377
|
|
|
1370
1378
|
function cmdBrowse(db, args) {
|
|
1371
1379
|
const { flags } = parseArgs(args);
|
|
1372
|
-
const project = flags.project ? resolveProject(db, flags.project) :
|
|
1380
|
+
const project = flags.project ? resolveProject(db, flags.project) : cliProject(db);
|
|
1373
1381
|
const tierFilter = flags.tier || null;
|
|
1374
1382
|
if (tierFilter && !['working', 'active', 'archive'].includes(tierFilter)) {
|
|
1375
1383
|
fail(`[mem] Invalid tier: "${tierFilter}". Use: working, active, or archive`);
|
|
@@ -2783,7 +2791,7 @@ Commands:
|
|
|
2783
2791
|
(aliases: backfill search_aliases on substantive rows that
|
|
2784
2792
|
lack them — incl. lesson-bearing manual saves — adds ONLY
|
|
2785
2793
|
aliases, never rewrites title/narrative/lesson)
|
|
2786
|
-
--project P Limit to a single project (.|current =
|
|
2794
|
+
--project P Limit to a single project (.|current = the current project)
|
|
2787
2795
|
--verbose / -v Preview also dumps cluster contents + re-enrich samples
|
|
2788
2796
|
|
|
2789
2797
|
doctor Environment diagnostics and benchmarks
|
|
@@ -3130,12 +3138,12 @@ async function cmdOptimize(db, args) {
|
|
|
3130
3138
|
}
|
|
3131
3139
|
// --project <name> filters all 4 tasks to one project. Opt-in; absence
|
|
3132
3140
|
// preserves prior cross-project default. `.` or `current` auto-resolve via
|
|
3133
|
-
//
|
|
3141
|
+
// the CLI project resolver so users don't need to remember the exact name.
|
|
3134
3142
|
const projectIdx = args.indexOf('--project');
|
|
3135
3143
|
let project;
|
|
3136
3144
|
if (projectIdx >= 0 && args[projectIdx + 1]) {
|
|
3137
3145
|
const raw = args[projectIdx + 1];
|
|
3138
|
-
project = (raw === '.' || raw === 'current') ?
|
|
3146
|
+
project = (raw === '.' || raw === 'current') ? cliProject(db) : raw;
|
|
3139
3147
|
}
|
|
3140
3148
|
|
|
3141
3149
|
if (!run && !runAll) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.72.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.72.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.72.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",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"lib/startup-dashboard.mjs",
|
|
58
58
|
"lib/doctor-benchmark.mjs",
|
|
59
59
|
"lib/doctor-drift.mjs",
|
|
60
|
+
"lib/cli-project.mjs",
|
|
60
61
|
"lib/stats-quality.mjs",
|
|
61
62
|
"lib/low-signal-patterns.mjs",
|
|
62
63
|
"lib/private-strip.mjs",
|
package/project-utils.mjs
CHANGED
|
@@ -29,7 +29,22 @@ const _cache = new Map();
|
|
|
29
29
|
* @returns {string} Sanitized project identifier safe for use in filenames
|
|
30
30
|
*/
|
|
31
31
|
export function inferProject() {
|
|
32
|
-
|
|
32
|
+
return projectNameFromDir(process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The naming rule alone, applied to an arbitrary directory: "parent--basename", sanitized.
|
|
37
|
+
*
|
|
38
|
+
* Split out of inferProject() so lib/cli-project.mjs can build its second candidate (the git
|
|
39
|
+
* work-tree root) with THIS rule rather than a copy of it — two copies of the rule would let
|
|
40
|
+
* the CLI face and the hook face drift apart on the next sanitization change, which is the
|
|
41
|
+
* exact class of bug this module's candidate-selection exists to close. Still DB-free and
|
|
42
|
+
* allocation-cheap, so the hook hot path is unaffected.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} p Absolute directory path
|
|
45
|
+
* @returns {string} Sanitized project identifier safe for use in filenames
|
|
46
|
+
*/
|
|
47
|
+
export function projectNameFromDir(p) {
|
|
33
48
|
const base = basename(p);
|
|
34
49
|
const parent = basename(dirname(p));
|
|
35
50
|
const raw = parent && parent !== '.' && parent !== '/' ? `${parent}--${base}` : base;
|
|
@@ -32,6 +32,15 @@ const ROOT = process.env.PROBE_ROOT || join(dirname(fileURLToPath(import.meta.ur
|
|
|
32
32
|
// broken flag, and a helperless broken tree is repaired by the hook-launcher
|
|
33
33
|
// path instead. Out of process like every other probe here — loading a stale
|
|
34
34
|
// .node caches a dead module handle for the rest of THIS process.
|
|
35
|
+
// Output-identical twin of lib/binding-probe.mjs::flattenBindingError, kept here
|
|
36
|
+
// because bareProbe runs when lib/ could not be imported. Same 240 cap, same
|
|
37
|
+
// ellipsis, same 'unknown' floor — asserted for parity by the tests.
|
|
38
|
+
function flattenLocal(err, max = 240) {
|
|
39
|
+
const s = String(err ?? '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
if (!s) return 'unknown';
|
|
41
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
function bareProbe(root) {
|
|
36
45
|
const script =
|
|
37
46
|
'try {'
|
|
@@ -49,11 +58,23 @@ function bareProbe(root) {
|
|
|
49
58
|
//
|
|
50
59
|
// Flattening is inlined, NOT lib/binding-probe.mjs::flattenBindingError, because
|
|
51
60
|
// this function is the fallback for a tree where lib/ failed to import — `helpers`
|
|
52
|
-
// is still null on every path that reaches here.
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
// is still null on every path that reaches here.
|
|
62
|
+
//
|
|
63
|
+
// The twin must stay byte-identical in OUTPUT, and it did not: the first draft
|
|
64
|
+
// capped with a bare `.slice(0, 240)` while the shared helper appends an ellipsis,
|
|
65
|
+
// so they already disagreed at the one boundary the duplication exists to protect.
|
|
66
|
+
// A comment is not a guard, and this repo's hand-maintained twins have drifted
|
|
67
|
+
// before. tests/binding-error-diagnosis.test.mjs now drives THIS path in a
|
|
68
|
+
// lib/-less tree and asserts parity with the shared helper.
|
|
69
|
+
// Order matters: flattenLocal floors to the string 'unknown', which is truthy, so
|
|
70
|
+
// `flattenLocal(x) || fallback` would make the fallbacks unreachable and swallow a
|
|
71
|
+
// spawn error or an exit code whenever the child printed nothing. Pick the source
|
|
72
|
+
// FIRST, then flatten it.
|
|
73
|
+
const printed = String(r.stdout || '').trim();
|
|
74
|
+
const spawnErr = r.error && r.error.message;
|
|
75
|
+
const why = printed ? flattenLocal(printed)
|
|
76
|
+
: spawnErr ? flattenLocal(spawnErr)
|
|
77
|
+
: `probe exited ${r.status ?? `on signal ${r.signal}`}`;
|
|
57
78
|
process.stderr.write(`[claude-mem-lite] binding probe: ${why}\n`);
|
|
58
79
|
return false;
|
|
59
80
|
}
|
package/scripts/launch.mjs
CHANGED
|
@@ -20,9 +20,32 @@ if (!existsSync(join(ROOT, 'node_modules', 'better-sqlite3'))) {
|
|
|
20
20
|
});
|
|
21
21
|
process.stderr.write('[claude-mem-lite] Dependencies installed\n');
|
|
22
22
|
} catch (e) {
|
|
23
|
-
// Plugin-cache / multi-user / disk-full installs can fail here
|
|
24
|
-
//
|
|
25
|
-
|
|
23
|
+
// Plugin-cache / multi-user / disk-full installs can fail here, and this is not a
|
|
24
|
+
// rare path: Claude Code materializes each new plugin-cache version WITHOUT
|
|
25
|
+
// node_modules, so the guard above opens on the first MCP launch after every
|
|
26
|
+
// plugin update. Without this catch the user sees a Node stack trace.
|
|
27
|
+
//
|
|
28
|
+
// `.split('\n')[0]` is CORRECT here, unlike the four binding-error sites fixed in
|
|
29
|
+
// v3.70.2, and the difference is the `stdio` above: stderr is **inherit**, so
|
|
30
|
+
// npm's own diagnosis (`npm error code EROFS`, `path …`, `rofs EROFS: read-only
|
|
31
|
+
// file system …`) has already streamed straight to the user's terminal by the time
|
|
32
|
+
// we get here — verified by running this file against an unwritable ROOT. With
|
|
33
|
+
// stderr inherited, execSync's `e.message` holds only "Command failed: <cmd>";
|
|
34
|
+
// there is no captured diagnosis to lose. Do NOT "fix" this by piping stderr to
|
|
35
|
+
// recover it: piping is what made a compiling better-sqlite3 look hung under the
|
|
36
|
+
// 5-min bash timeout (bug audit 2026-05), which is why stderr is inherited.
|
|
37
|
+
//
|
|
38
|
+
// A pre-tag review measured `e.message` under `stdio: 'pipe'`, where stderr IS
|
|
39
|
+
// folded into the message, and concluded this line drops the diagnosis. It does
|
|
40
|
+
// not — the stdio differs. Recorded here because the same wrong conclusion is
|
|
41
|
+
// easy to reach from the code alone.
|
|
42
|
+
//
|
|
43
|
+
// `e.status` not `e.code`: execSync failures carry the exit status on `status`,
|
|
44
|
+
// so the old `|| e.code` rung was dead.
|
|
45
|
+
const detail = e?.message?.split('\n')[0]
|
|
46
|
+
|| (e?.status != null ? `npm exited ${e.status}` : '')
|
|
47
|
+
|| (e?.signal ? `npm killed by ${e.signal}` : '')
|
|
48
|
+
|| 'unknown error';
|
|
26
49
|
process.stderr.write(`[claude-mem-lite] npm install failed in ${ROOT} — ${detail}\n`);
|
|
27
50
|
process.stderr.write(`[claude-mem-lite] Likely cause: read-only directory, disk full, or network blocked.\n`);
|
|
28
51
|
process.stderr.write(`[claude-mem-lite] Repair: cd "${ROOT}" && npm install --omit=dev\n`);
|
package/source-files.mjs
CHANGED
|
@@ -45,6 +45,10 @@ export const SOURCE_FILES = [
|
|
|
45
45
|
'lib/startup-dashboard.mjs',
|
|
46
46
|
'lib/doctor-benchmark.mjs',
|
|
47
47
|
'lib/doctor-drift.mjs',
|
|
48
|
+
// DB-aware project pick for terminal-invoked CLI commands. Statically imported by
|
|
49
|
+
// mem-cli.mjs, cli/activity.mjs and cli/doctor.mjs — ship it or every CLI command
|
|
50
|
+
// throws ERR_MODULE_NOT_FOUND in installed/tarball runtimes.
|
|
51
|
+
'lib/cli-project.mjs',
|
|
48
52
|
'lib/stats-quality.mjs',
|
|
49
53
|
'lib/low-signal-patterns.mjs',
|
|
50
54
|
'lib/private-strip.mjs',
|