claude-mem-lite 2.76.0 → 2.77.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "2.76.0",
13
+ "version": "2.77.0",
14
14
  "source": "./",
15
15
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall"
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.76.0",
3
+ "version": "2.77.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code — FTS5 search, episode batching, error-triggered recall",
5
5
  "author": {
6
6
  "name": "sdsrss"
@@ -0,0 +1,123 @@
1
+ // lib/hook-telemetry.mjs — unsampled hook-error log.
2
+ //
3
+ // Distinct from lib/err-sampler.mjs: that one writes 1% of swallowed debugCatch
4
+ // errors into ${dbDir}/errors/ for *production diagnostics* — high cardinality,
5
+ // must be cheap. This one writes 100% of hook script failures into
6
+ // ${runtimeDir}/hook-errors/ for *self-observation* — low cardinality (hooks
7
+ // fail rarely), every event matters because it's the only window into
8
+ // PreToolUse / Skill-bridge / UPS failure modes (DB corruption, schema drift,
9
+ // upstream field rename). Both follow the same JSONL daily-shard layout to
10
+ // keep tooling consistent; the directory split is the contract that says
11
+ // "no sampling here, this is full fidelity".
12
+ //
13
+ // Hook scripts catch *all* errors and exit 0 (must never block Edit/Write).
14
+ // Calling this from inside each catch turns the silent path into a recorded
15
+ // path without changing behavior. All failures inside the recorder itself
16
+ // are swallowed — telemetry must never crash the host hook.
17
+ //
18
+ // Retention: 14 days. GC is lazy on append (cheap stat+unlink loop, only
19
+ // reads the dir, no parse).
20
+
21
+ import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
22
+ import { join } from 'path';
23
+
24
+ const DAY_MS = 86400000;
25
+ const RETENTION_MS = 14 * DAY_MS;
26
+ const HOOK_ERRORS_SUBDIR = 'hook-errors';
27
+
28
+ function today() {
29
+ return new Date(Date.now()).toISOString().slice(0, 10);
30
+ }
31
+
32
+ function hookErrorsDir(runtimeDir) {
33
+ return join(runtimeDir, HOOK_ERRORS_SUBDIR);
34
+ }
35
+
36
+ // Lazy GC: scan the dir, unlink shards older than RETENTION_MS. Cheap because
37
+ // it never opens files — just statSync + unlinkSync. Called from recordHookError
38
+ // after a successful write, so it amortizes over the failure cadence.
39
+ function pruneOldShards(dir) {
40
+ let entries;
41
+ try { entries = readdirSync(dir); } catch { return; }
42
+ const cutoff = Date.now() - RETENTION_MS;
43
+ for (const f of entries) {
44
+ if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
45
+ const full = join(dir, f);
46
+ try {
47
+ const st = statSync(full);
48
+ if (st.mtimeMs < cutoff) unlinkSync(full);
49
+ } catch { /* gone or unreadable — skip */ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Record one hook-script failure to the unsampled JSONL log.
55
+ *
56
+ * @param {string} scope Short label naming the hook script + failure point.
57
+ * Convention: '<script>:<step>' e.g. 'pre-recall:db-open',
58
+ * 'skill-bridge:registry-query'. Truncated to 80 chars.
59
+ * @param {unknown} err Thrown value (Error, string, undefined — all ok).
60
+ * @param {string} runtimeDir Absolute path to ${CLAUDE_MEM_DIR}/runtime/. Caller
61
+ * must resolve this — module avoids importing
62
+ * hook-shared.mjs so it stays usable from the
63
+ * lightweight standalone scripts.
64
+ * @param {object} [ctx] Optional small JSON-safe context (filePath, toolName,
65
+ * sessionId fragment). Stringified + truncated to 240
66
+ * chars to keep shard lines bounded.
67
+ */
68
+ export function recordHookError(scope, err, runtimeDir, ctx) {
69
+ try {
70
+ if (!runtimeDir || typeof runtimeDir !== 'string') return;
71
+ const dir = hookErrorsDir(runtimeDir);
72
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
73
+
74
+ const line = JSON.stringify({
75
+ ts: new Date().toISOString(),
76
+ scope: String(scope || '').slice(0, 80),
77
+ msg: String(err?.message ?? err ?? '').slice(0, 500),
78
+ stack: typeof err?.stack === 'string' ? err.stack.split('\n').slice(0, 6).join('\n') : undefined,
79
+ ctx: ctx === undefined ? undefined : JSON.stringify(ctx).slice(0, 240),
80
+ }) + '\n';
81
+
82
+ appendFileSync(join(dir, `${today()}.jsonl`), line, { mode: 0o600 });
83
+ // Amortized retention sweep: 14-day window kept clean without a cron.
84
+ pruneOldShards(dir);
85
+ } catch { /* recorder must never throw */ }
86
+ }
87
+
88
+ /**
89
+ * Count hook errors with timestamp >= sinceMs. Used by `mem-cli stats` to
90
+ * surface a single self-observation line.
91
+ *
92
+ * Reads every shard (capped at 14 by retention). Each shard is JSONL — parse
93
+ * line-by-line and tolerate malformed lines (treat as zero contribution).
94
+ * Returns a plain count; per-scope breakdown is a follow-up if cardinality
95
+ * grows.
96
+ */
97
+ export function countRecentHookErrors(runtimeDir, sinceMs) {
98
+ try {
99
+ if (!runtimeDir || typeof runtimeDir !== 'string') return 0;
100
+ const dir = hookErrorsDir(runtimeDir);
101
+ if (!existsSync(dir)) return 0;
102
+ let entries;
103
+ try { entries = readdirSync(dir); } catch { return 0; }
104
+ const cutoffIso = new Date(sinceMs).toISOString();
105
+ let count = 0;
106
+ for (const f of entries) {
107
+ if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f)) continue;
108
+ let body;
109
+ try { body = readFileSync(join(dir, f), 'utf8'); } catch { continue; }
110
+ for (const line of body.split('\n')) {
111
+ if (!line) continue;
112
+ let parsed;
113
+ try { parsed = JSON.parse(line); } catch { continue; }
114
+ // ISO 8601 lexical ordering matches chronological ordering — no Date parse.
115
+ if (typeof parsed.ts === 'string' && parsed.ts >= cutoffIso) count++;
116
+ }
117
+ }
118
+ return count;
119
+ } catch { return 0; }
120
+ }
121
+
122
+ /** Test hook — current retention window (14 days, in ms). */
123
+ export const HOOK_ERROR_RETENTION_MS = RETENTION_MS;
package/mem-cli.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // No MCP SDK or heavy deps — only imports schema.mjs and utils.mjs
4
4
 
5
5
  import { homedir } from 'os';
6
- import { ensureDb, DB_PATH, REGISTRY_DB_PATH } from './schema.mjs';
6
+ import { ensureDb, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
7
7
  import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, jaccardSimilarity, computeMinHash, estimateJaccardFromMinHash, scrubSecrets, cjkBigrams, isoWeekKey, COMPRESSED_PENDING_PURGE, SESS_BM25, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause } from './utils.mjs';
8
8
  import { cjkPrecisionOk } from './nlp.mjs';
9
9
  import { extractCjkLikePatterns } from './nlp.mjs';
@@ -29,6 +29,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
29
29
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
30
30
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints } from './cli/common.mjs';
31
31
  import { saveObservation } from './lib/save-observation.mjs';
32
+ import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
32
33
  import {
33
34
  insertDeferred, listOpenWithOrdinal, dropDeferred,
34
35
  resolveDeferredIds, closeDeferredItems,
@@ -1258,6 +1259,12 @@ async function cmdStats(db, args) {
1258
1259
  `SELECT COUNT(*) as c FROM observations WHERE superseded_at IS NOT NULL AND compressed_into IS NULL ${projectFilter}`
1259
1260
  ).get(...baseParams);
1260
1261
 
1262
+ // Hook self-observation: count PreToolUse / Skill-bridge script failures
1263
+ // recorded in the last 24h. Surfaces silent breakage (DB corruption,
1264
+ // CC upstream field rename) that would otherwise stay invisible — the
1265
+ // failure mode that left code-graph's matcher bug undetected for 10 sessions.
1266
+ const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
1267
+
1261
1268
  // Tier distribution (aligned with MCP mem_stats)
1262
1269
  const tierCtx = { now, currentProject: project || inferProject(), currentSessionId: '' };
1263
1270
  const tdParams = tierSqlParams(tierCtx);
@@ -1292,6 +1299,7 @@ async function cmdStats(db, args) {
1292
1299
  noise_ratio: Number(noiseRatio.toFixed(4)),
1293
1300
  compressed: compressedCount.c,
1294
1301
  superseded_only: supersededOnlyCount.c,
1302
+ hook_errors_24h: hookErrors24h,
1295
1303
  },
1296
1304
  tier_distribution: {
1297
1305
  working: tierMap.working ?? 0,
@@ -1326,6 +1334,7 @@ async function cmdStats(db, args) {
1326
1334
  out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
1327
1335
  out(` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1328
1336
  out(` Compressed: ${compressedCount.c}`);
1337
+ out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
1329
1338
  if (noiseRatio > 0.6) out(' ⚠️ High noise ratio — consider running mem compress');
1330
1339
  out('');
1331
1340
  // Tier counts only live (uncompressed, non-superseded) observations — surface the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.76.0",
3
+ "version": "2.77.0",
4
4
  "description": "Lightweight persistent memory system for Claude Code",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
@@ -60,6 +60,7 @@
60
60
  "lib/summary-extractor.mjs",
61
61
  "lib/id-routing.mjs",
62
62
  "lib/err-sampler.mjs",
63
+ "lib/hook-telemetry.mjs",
63
64
  "lib/metrics.mjs",
64
65
  "lib/binding-probe.mjs",
65
66
  "lib/mem-override.mjs",
@@ -6,7 +6,11 @@
6
6
  import { existsSync, readFileSync } from 'fs';
7
7
  import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
+ import { recordHookError } from '../lib/hook-telemetry.mjs';
9
10
 
11
+ // CLAUDE_MEM_DIR mirrors pre-tool-recall.js — one env var sandboxes everything.
12
+ const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
13
+ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
10
14
  const REGISTRY_DB_PATH = join(homedir(), '.claude-mem-lite', 'resource-registry.db');
11
15
  const MANAGED_BASE = join(homedir(), '.claude-mem-lite');
12
16
  const MANAGED_MARKER = '/.claude-mem-lite/managed/';
@@ -24,7 +28,10 @@ try {
24
28
  try {
25
29
  const event = JSON.parse(input);
26
30
  skillName = event.tool_input?.skill;
27
- } catch { process.exit(0); }
31
+ } catch (e) {
32
+ recordHookError('skill-bridge:json', e, RUNTIME_DIR, { inputLen: input.length });
33
+ process.exit(0);
34
+ }
28
35
 
29
36
  if (!skillName || typeof skillName !== 'string') process.exit(0);
30
37
 
@@ -37,7 +44,10 @@ try {
37
44
  try {
38
45
  db = new Database(REGISTRY_DB_PATH, { readonly: true });
39
46
  db.pragma('busy_timeout = 1000');
40
- } catch { process.exit(0); }
47
+ } catch (e) {
48
+ recordHookError('skill-bridge:db-open', e, RUNTIME_DIR);
49
+ process.exit(0);
50
+ }
41
51
 
42
52
  try {
43
53
  // Query: find by name or invocation_name, ONLY if managed path
@@ -85,11 +95,13 @@ try {
85
95
  additionalContext,
86
96
  },
87
97
  }));
88
- } catch {
89
- // Silent failure — never block Skill tool
98
+ } catch (e) {
99
+ // Silent failure — never block Skill tool, but record for self-observation.
100
+ recordHookError('skill-bridge:query', e, RUNTIME_DIR, { skillName });
90
101
  } finally {
91
102
  try { db.close(); } catch {}
92
103
  }
93
- } catch {
94
- // Top-level catch — exit 0 no matter what
104
+ } catch (e) {
105
+ // Top-level catch — exit 0 no matter what, but record what slipped past.
106
+ try { recordHookError('skill-bridge:top', e, RUNTIME_DIR); } catch {}
95
107
  }
@@ -8,6 +8,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
9
  import { homedir } from 'os';
10
10
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
+ import { recordHookError } from '../lib/hook-telemetry.mjs';
11
12
 
12
13
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
13
14
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -87,9 +88,24 @@ try {
87
88
  filePath = event.tool_input?.file_path;
88
89
  sessionId = event.session_id || null;
89
90
  toolName = event.tool_name || null;
90
- } catch { process.exit(0); }
91
+ } catch (e) {
92
+ recordHookError('pre-recall:json', e, RUNTIME_DIR, { inputLen: input.length });
93
+ process.exit(0);
94
+ }
91
95
 
92
- if (!filePath) process.exit(0);
96
+ // Upstream-shape probe: hook ran but neither field nor input shape matches the
97
+ // contract we encode (event.tool_input.file_path, event.tool_name in
98
+ // Edit|Write|NotebookEdit|Read). Distinguishes "Claude Code renamed the field"
99
+ // from "event genuinely has no file_path" — without this trace, a CC upstream
100
+ // rename silently zeroes injection like code-graph's matcher bug.
101
+ if (!filePath) {
102
+ if (toolName && !['Edit', 'Write', 'NotebookEdit', 'Read'].includes(toolName)) {
103
+ recordHookError('pre-recall:unknown-tool', new Error(`tool_name=${toolName}`), RUNTIME_DIR, { toolName });
104
+ } else if (!toolName) {
105
+ recordHookError('pre-recall:no-toolname', new Error('event missing tool_name'), RUNTIME_DIR);
106
+ }
107
+ process.exit(0);
108
+ }
93
109
 
94
110
  // v2.34.6 Gap 3: Read-side recall with asymmetric quiet-mode. Reads have
95
111
  // lower per-event information value than Edits (passive observation, may
@@ -117,7 +133,10 @@ try {
117
133
  try {
118
134
  db = new Database(DB_PATH, { readonly: true });
119
135
  db.pragma('busy_timeout = 1000');
120
- } catch { process.exit(0); }
136
+ } catch (e) {
137
+ recordHookError('pre-recall:db-open', e, RUNTIME_DIR);
138
+ process.exit(0);
139
+ }
121
140
 
122
141
  try {
123
142
  const project = inferProject();
@@ -258,11 +277,13 @@ try {
258
277
  // the per-filePath invariant that underpins Read→Edit dedup.
259
278
  cooldown[filePath] = now;
260
279
  writeCooldown(cooldownPath, cooldown, isSessionScoped);
261
- } catch {
262
- // Silent failure — never block editing
280
+ } catch (e) {
281
+ // Silent failure — never block editing, but record for self-observation.
282
+ recordHookError('pre-recall:query', e, RUNTIME_DIR, { filePath });
263
283
  } finally {
264
284
  try { db.close(); } catch {}
265
285
  }
266
- } catch {
267
- // Top-level catch — exit 0 no matter what
286
+ } catch (e) {
287
+ // Top-level catch — exit 0 no matter what, but record what slipped past.
288
+ try { recordHookError('pre-recall:top', e, RUNTIME_DIR); } catch {}
268
289
  }
package/source-files.mjs CHANGED
@@ -43,6 +43,11 @@ export const SOURCE_FILES = [
43
43
  'lib/summary-extractor.mjs',
44
44
  'lib/id-routing.mjs',
45
45
  'lib/err-sampler.mjs',
46
+ // v2.76.x: unsampled hook-script failure log. Imported by
47
+ // scripts/pre-tool-recall.js + scripts/pre-skill-bridge.js (recorder)
48
+ // and mem-cli.mjs (countRecentHookErrors for `stats`). Missing from
49
+ // manifest → tarball ships hooks that ERR_MODULE_NOT_FOUND on every fire.
50
+ 'lib/hook-telemetry.mjs',
46
51
  'lib/metrics.mjs',
47
52
  // v2.71.x: better-sqlite3 ABI probe + auto-rebuild. Shared by install.mjs
48
53
  // (post-`npm install` verify) and scripts/launch.mjs (pre-server-launch