claude-mem-lite 3.22.0 → 3.24.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": "3.22.0",
13
+ "version": "3.24.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.22.0",
3
+ "version": "3.24.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/bash-utils.mjs CHANGED
@@ -13,6 +13,16 @@ const CMD_WRAPPERS = new Set(['sudo', 'doas', 'env', 'time', 'command', 'nice',
13
13
  // git read subcommands whose output contains commit/log/match text, not failures.
14
14
  const GIT_READ_SUBCMDS = new Set(['grep', 'log', 'show', 'diff', 'blame', 'ls-files', 'cat-file', 'whatchanged', 'shortlog', 'reflog', 'status']);
15
15
 
16
+ // Hard failure fingerprints — a real crash / thrown exception / non-zero-exit marker,
17
+ // as opposed to output that merely CONTAINS the word "error" (search results, log
18
+ // scans, prose). Deliberately strong/narrow: a JS stack frame (`\n at fn (…)`),
19
+ // panic/traceback/segfault, ENOENT/command-not-found, AssertionError, or a *named*
20
+ // error class (TypeError:/ReferenceError:/…). Generic `Error:`/`exception` are
21
+ // intentionally excluded — they appear too often in benign search/log output. Gates
22
+ // the bugfix-shape save-nudge (lib/cite-back-hint.mjs) so `node cli.mjs search "error"`
23
+ // + an edit in the same episode no longer looks like an unsaved fix.
24
+ const HARD_ERROR_RE = /\bERR!|\bpanic\b|traceback|segfault|core dumped|\benoent\b|command not found|assertion\s?error|\n\s+at\s+\S|(?:type|reference|range|syntax|eval|uri)error:/i;
25
+
16
26
  // True when the command's PRIMARY operation (left of the first pipe, past any
17
27
  // env-assignments / wrapper like `sudo`/`env`/`time`) is a read/search — including
18
28
  // `git grep`/`git log`. Anchoring on the primary command (not "search verb appears
@@ -69,6 +79,10 @@ export function detectBashSignificance(input, response) {
69
79
  const hasHardErrorSignal = hasGreenTestSummary
70
80
  && /\bERR!|panic|traceback|enoent|command not found|exception|AssertionError|TypeError:|SyntaxError:/i.test(response);
71
81
  const isError = looksLikeError && !(hasGreenTestSummary && !hasHardErrorSignal);
82
+ // Strict subset of isError: a genuine failure fingerprint, not just the word "error"
83
+ // in benign output. Consumers that must avoid false positives (the bugfix-shape
84
+ // save-nudge) gate on this instead of isError.
85
+ const isHardError = isError && HARD_ERROR_RE.test(response);
72
86
  // Match actual test runner invocations, not commands that merely reference "test" as a keyword
73
87
  const isTest = /\b(npm\s+test|npm\s+run\s+test|yarn\s+test|pnpm\s+test|pnpm\s+run\s+test|bun\s+test|go\s+test|cargo\s+test)\b/i.test(cmd)
74
88
  || /\b(jest|pytest|vitest|mocha|cypress|playwright)\b/i.test(cmd);
@@ -85,7 +99,7 @@ export function detectBashSignificance(input, response) {
85
99
  || /\bgh\s+release\s+(?:create|edit|upload|delete)\b/i.test(cmd)
86
100
  || /\btwine\s+upload\b/i.test(cmd);
87
101
  return {
88
- isError, isTest, isBuild, isGit, isDeploy,
102
+ isError, isHardError, isTest, isBuild, isGit, isDeploy,
89
103
  isSignificant: isError || isTest || isBuild || isGit || isDeploy,
90
104
  };
91
105
  }
package/hook-llm.mjs CHANGED
@@ -803,9 +803,16 @@ ${actionList}`;
803
803
  // retry's entire purpose (a recovered bugfix lesson would silently drop
804
804
  // out of --importance 2 searches and the working tier). Gate the cap on
805
805
  // the *effective* low-signal state, not the pre-retry flag.
806
+ // v3.23: cap the FILE-PATH heuristic's contribution at 2. computeRuleImportance
807
+ // returns 3 for any entry touching schema./migration/prisma/.env/.key paths; via the
808
+ // Math.max below that force-promoted ordinary has-lesson episodes to "critical" imp=3
809
+ // even when Haiku judged them 1-2 (audit: auto imp=3 = 34.8%, e.g. a thin-lesson edit
810
+ // to schema.mjs). Haiku's OWN importance can still reach 3 (genuine judgment); only the
811
+ // path heuristic is capped. The isLessonLowSignal branch still floors no-lesson
812
+ // non-decision autos at ≤1; manual mem_save uses a different path and is unaffected.
806
813
  importance: isLessonLowSignal && !retryRecovered && parsed.type !== 'decision'
807
814
  ? Math.min(ruleImportance, 1)
808
- : Math.max(ruleImportance, clampImportance(parsed.importance)),
815
+ : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
809
816
  lessonLearned,
810
817
  searchAliases,
811
818
  };
package/hook-memory.mjs CHANGED
@@ -5,6 +5,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, no
5
5
  import { citeFactorJs } from './scoring-sql.mjs';
6
6
  import { recordMetric } from './lib/metrics.mjs';
7
7
  import { DB_DIR } from './schema.mjs';
8
+ import { extractIdents } from './lib/lesson-idents.mjs';
8
9
 
9
10
  const MAX_MEMORY_INJECTIONS = 3;
10
11
  const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
@@ -399,3 +400,42 @@ export function recallForFile(db, filePath, project) {
399
400
  return [];
400
401
  }
401
402
  }
403
+
404
+ /**
405
+ * Phase-2 task-imperative selection (spec 2026-06-29 §4.1): the single highest-value
406
+ * lesson relevant to THIS prompt, for delivery at the task-prompt position under the
407
+ * imperative template. Own gate (importance>=2 + non-empty lesson + identifier overlap
408
+ * with the prompt, top-1) — deliberately independent of searchRelevantMemories' coverage/
409
+ * BM25 filters so a high-value lesson is not dropped by the context-list scoring.
410
+ * @returns {{id:number, lesson_learned:string}|null}
411
+ */
412
+ export function selectImperativeLesson(db, userPrompt, project, excludeIds = []) {
413
+ if (!db || !userPrompt) return null;
414
+ const promptIdents = new Set(extractIdents(userPrompt));
415
+ if (promptIdents.size === 0) return null; // no symbol anchor → no imperative (precision-first)
416
+ const exclude = new Set(excludeIds);
417
+ let rows;
418
+ try {
419
+ rows = db.prepare(`
420
+ SELECT id, title, lesson_learned, importance
421
+ FROM observations
422
+ WHERE project = ?
423
+ AND COALESCE(compressed_into, 0) = 0
424
+ AND COALESCE(importance, 1) >= 2
425
+ AND lesson_learned IS NOT NULL
426
+ AND TRIM(lesson_learned) != ''
427
+ AND LOWER(TRIM(lesson_learned)) != 'none'
428
+ ORDER BY importance DESC, created_at_epoch DESC
429
+ LIMIT 50
430
+ `).all(project);
431
+ } catch { return null; }
432
+ let best = null, bestScore = 0;
433
+ for (const r of rows) {
434
+ if (exclude.has(r.id)) continue;
435
+ const overlap = extractIdents(`${r.lesson_learned} ${r.title || ''}`).filter((id) => promptIdents.has(id)).length;
436
+ if (overlap === 0) continue;
437
+ const score = (r.importance || 2) * overlap;
438
+ if (score > bestScore) { bestScore = score; best = r; }
439
+ }
440
+ return best ? { id: best.id, lesson_learned: best.lesson_learned } : null;
441
+ }
package/hook.mjs CHANGED
@@ -47,7 +47,7 @@ import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObse
47
47
  import { scrubRecord } from './lib/scrub-record.mjs';
48
48
  import { formatHookError } from './lib/native-binding-hint.mjs';
49
49
  import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
50
- import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale } from './lib/maintain-core.mjs';
50
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren } from './lib/maintain-core.mjs';
51
51
  import { snapshotDb } from './lib/db-backup.mjs';
52
52
  import {
53
53
  extractCitationsFromTranscript,
@@ -59,7 +59,8 @@ import {
59
59
  hasMainThreadAssistantText,
60
60
  } from './lib/citation-tracker.mjs';
61
61
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
62
- import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
62
+ import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
63
+ import { formatTaskImperative } from './lib/task-imperative.mjs';
63
64
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
64
65
  import { detectMemOverride } from './lib/mem-override.mjs';
65
66
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
@@ -286,6 +287,9 @@ async function handlePostToolUse() {
286
287
  files,
287
288
  ts: Date.now(),
288
289
  isError: bashSig?.isError || false,
290
+ // isHardError gates the bugfix-shape save-nudge (lib/cite-back-hint.mjs): a real
291
+ // failure fingerprint, not just "error" appearing in search/log output.
292
+ isHardError: bashSig?.isHardError || false,
289
293
  isSignificant: EDIT_TOOLS.has(tool_name) ||
290
294
  bashSig?.isSignificant || false,
291
295
  bashSig: bashSig || null,
@@ -711,6 +715,12 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
711
715
  WHERE COALESCE(compressed_into, 0) = 0
712
716
  AND COALESCE(importance, 1) <= 1
713
717
  AND COALESCE(injection_count, 0) = 0
718
+ -- v3.23: never auto-hide a row that carries a real lesson. compression folds
719
+ -- sources into a title-only summary (lesson lost), and COMPRESSED_AUTO hides the
720
+ -- row from search entirely. A low-importance obs whose lesson_learned is the
721
+ -- distilled value must stay findable (audit: 62 lessons buried this way). The 7d
722
+ -- noise block below already excludes lessons; this 30d block had drifted.
723
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
714
724
  AND created_at_epoch < ?
715
725
  AND project = ?
716
726
  `).run(autoCompressAge, project);
@@ -779,6 +789,12 @@ function runSessionStartAutoMaintain(db) {
779
789
  const cleaned = cleanupBroken(db, mctx);
780
790
  if (cleaned > 0) debugLog('DEBUG', 'auto-maintain', `cleaned ${cleaned} broken observations`);
781
791
 
792
+ // Self-heal legacy orphans: children whose compression keeper was hard-deleted
793
+ // before recoverChildrenOf existed are hidden + queue-less (unreachable). Resurface
794
+ // them so normal decay/GC handles them on merit. Non-destructive (un-hide only).
795
+ const orphansRecovered = recoverOrphanedChildren(db, mctx);
796
+ if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
797
+
782
798
  const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
783
799
  if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
784
800
  if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
@@ -1371,6 +1387,7 @@ async function handleUserPrompt() {
1371
1387
  ORDER BY created_at_epoch DESC LIMIT 5
1372
1388
  `).all(project);
1373
1389
  const keyContextIds = keyObs.map(o => o.id);
1390
+ const pathAInjectedIds = [];
1374
1391
 
1375
1392
  // Read IDs already injected by user-prompt-search.js to avoid duplicate injection
1376
1393
  try {
@@ -1379,17 +1396,36 @@ async function handleUserPrompt() {
1379
1396
  const { ids, ts } = JSON.parse(raw);
1380
1397
  // Only use if written within last 10 seconds (same prompt cycle)
1381
1398
  if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
1382
- for (const id of ids) keyContextIds.push(id);
1399
+ for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
1383
1400
  }
1384
1401
  } catch { /* file may not exist — that's fine */ }
1385
1402
 
1386
- const memories = searchRelevantMemories(db, promptText, project, keyContextIds);
1403
+ // Phase-2 task-imperative (default OFF CLAUDE_MEM_TASK_IMPERATIVE): the single
1404
+ // highest-value lesson relevant to THIS prompt, delivered at the prompt position under
1405
+ // an imperative template. Excluded from the <memory-context> list so it is never
1406
+ // injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29): task-prompt
1407
+ // 6-8/8 vs PreToolUse hook 0/8. Flipping the default ON is a separate L3 decision
1408
+ // gated on the live cite-recall canary.
1409
+ const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
1410
+ || process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
1411
+ // Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
1412
+ // key-context top-5, which overlaps the high-value lesson pool and would suppress
1413
+ // the pick. The chosen id is excluded from the <memory-context> block below instead.
1414
+ const imperativePick = taskImperativeOn
1415
+ ? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
1416
+ : null;
1417
+ const contextExclude = imperativePick ? [...keyContextIds, imperativePick.id] : keyContextIds;
1418
+
1419
+ const memories = searchRelevantMemories(db, promptText, project, contextExclude);
1387
1420
  if (memories.length > 0) {
1388
1421
  const lines = ['<memory-context relevance="high">'];
1389
1422
  for (const m of memories) lines.push(formatMemoryLine(m));
1390
1423
  lines.push('</memory-context>');
1391
1424
  process.stdout.write(lines.join('\n') + '\n');
1392
1425
  }
1426
+ if (imperativePick) {
1427
+ process.stdout.write(formatTaskImperative(imperativePick.lesson_learned, imperativePick.id) + '\n');
1428
+ }
1393
1429
  } catch (e) { debugCatch(e, 'handleUserPrompt-memory'); }
1394
1430
  } finally {
1395
1431
  db.close();
@@ -68,11 +68,17 @@ export function buildCiteBackHint(episode, cooldown) {
68
68
  // the lib so all save-prompt hints share one home + the same wording rules.
69
69
  //
70
70
  // Detection (mirrors pre-v2.83 hook.mjs:194 heuristic):
71
- // • has at least one entry with isError=true
71
+ // • has at least one entry with a HARD failure fingerprint (isHardError) — NOT just
72
+ // isError. `node cli.mjs search "error"`, `npx vitest` green output, or any command
73
+ // whose output merely MENTIONS "error"/"failed" sets isError=true but is not a fix;
74
+ // gating on isError nagged on non-fixes (audit: fired on a read-only session + a
75
+ // scratch write). isHardError requires a real crash/exception/stack (bash-utils.mjs).
72
76
  // • has at least one entry using an edit tool
73
77
  // • entries.length >= 3 (rules out single-typo fixes that don't need a lesson)
74
78
  // Returns null when any condition fails or when no edited files are recoverable
75
79
  // from the entry list (defensive — episodes flushed mid-tool can have empties).
80
+ // Legacy fallback: entries written before isHardError existed lack the field, so fall
81
+ // back to isError for them rather than silently regress the nudge to never-fire.
76
82
  const MIN_BUGFIX_ENTRIES = 3;
77
83
  const MAX_DISPLAY_FILES = 3;
78
84
 
@@ -86,7 +92,7 @@ export function buildUnsavedBugfixHint(episode) {
86
92
  const editedFiles = new Set();
87
93
  for (const e of entries) {
88
94
  if (!e) continue;
89
- if (e.isError) hasError = true;
95
+ if (e.isHardError !== undefined ? e.isHardError : e.isError) hasError = true;
90
96
  if (EDIT_TOOLS.has(e.tool)) {
91
97
  hasEdit = true;
92
98
  for (const f of e.files || []) editedFiles.add(f);
@@ -40,6 +40,10 @@ export function selectCompressionCandidates(db, { cutoff, project = null, includ
40
40
  FROM observations
41
41
  WHERE COALESCE(importance, 1) <= 1
42
42
  AND COALESCE(access_count, 0) = 0
43
+ -- v3.23: exclude rows carrying a real lesson — folding them into a title-only
44
+ -- weekly summary discards the lesson (the distilled value of a lessons store).
45
+ -- Mirrors the hook auto-compress lesson guard so neither path buries a lesson.
46
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
43
47
  AND created_at_epoch < ?
44
48
  ${compressedFilter}
45
49
  ${projectFilter}
@@ -98,6 +98,25 @@ export function recoverChildrenOf(db, ids) {
98
98
  ).run(...ids, ...ids).changes;
99
99
  }
100
100
 
101
+ // Resurface children orphaned by a keeper hard-deleted BEFORE recoverChildrenOf existed
102
+ // (legacy data). recoverChildrenOf only fires at delete time for the keepers being deleted
103
+ // in that call; rows whose keeper vanished in a past release are missed forever. A child
104
+ // with compressed_into = <positive keeperId> whose keeper row no longer exists is hidden
105
+ // from every COALESCE(compressed_into,0)=0 view AND sits in no maintenance queue (not
106
+ // COMPRESSED_AUTO, not COMPRESSED_PENDING_PURGE), so nothing ever resurfaces or GCs it —
107
+ // it leaks its full narrative out of reach. Setting compressed_into = NULL makes it live
108
+ // again; normal decay/GC then handles it on merit. `compressed_into > 0` excludes the
109
+ // negative sentinels (intentional states, not orphans). NON-DESTRUCTIVE: only un-hides
110
+ // rows, never deletes — safe to run unconditionally, no snapshot needed.
111
+ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [] } = {}) {
112
+ return db.prepare(`
113
+ UPDATE observations SET compressed_into = NULL
114
+ WHERE compressed_into > 0
115
+ AND NOT EXISTS (SELECT 1 FROM observations k WHERE k.id = observations.compressed_into)
116
+ ${projectFilter}
117
+ `).run(...baseParams).changes;
118
+ }
119
+
101
120
  export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
102
121
  const doomed = db.prepare(`
103
122
  SELECT id FROM observations
@@ -133,6 +152,11 @@ export function decayAndMarkIdle(db, { projectFilter, baseParams, staleAge, opCa
133
152
  AND COALESCE(importance, 1) = 1
134
153
  AND COALESCE(access_count, 0) = 0
135
154
  AND COALESCE(injection_count, 0) = 0
155
+ -- v3.23: never mark a lesson-bearing row idle→pending-purge. A lesson is the
156
+ -- distilled value of a lessons store; auto-GC must not silently purge it (parity
157
+ -- with the compress lesson guards in hook.mjs + compress-core.mjs). Truly stale
158
+ -- lessons are removed by explicit delete, not background decay.
159
+ AND (lesson_learned IS NULL OR lesson_learned = '' OR lesson_learned = 'none')
136
160
  AND created_at_epoch < ?
137
161
  ${projectFilter} LIMIT ${opCap}
138
162
  )
@@ -0,0 +1,18 @@
1
+ // lib/task-imperative.mjs — pure formatter for the task-imperative memory line.
2
+ // Shared by the live UserPromptSubmit emitter (Phase 2) AND efficacy arm U (the
3
+ // measurement that gates Phase 2): ONE tested source of truth so the measured
4
+ // framing and the shipped framing cannot drift. Hot-path-shared → regex/string
5
+ // only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
6
+ //
7
+ // Delivers a high-value lesson at the task-prompt position as an imperative,
8
+ // task-bound constraint: attribution kept (honest + #NN cite-traceable), the
9
+ // path-A/B softeners ("FYI", "continue your task", "NOT a new user message")
10
+ // dropped.
11
+ // Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
12
+
13
+ export function formatTaskImperative(lesson, id) {
14
+ const body = String(lesson || '').trim().replace(/\.$/, '');
15
+ if (!body) return '';
16
+ const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
17
+ return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
18
+ }
package/mem-cli.mjs CHANGED
@@ -18,6 +18,7 @@ import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from '
18
18
  import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
19
19
  import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
+ recoverOrphanedChildren,
21
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
23
  recoverChildrenOf, hardDeleteCandidateCount,
23
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
@@ -1096,9 +1097,17 @@ async function cmdStats(db, args) {
1096
1097
  `SELECT AVG(COALESCE(importance,1)) as v FROM observations WHERE 1=1 ${projectFilter}`
1097
1098
  ).get(...baseParams);
1098
1099
  const thirtyDaysAgo = now - 30 * 86400000;
1100
+ // v3.23 noise-gauge de-blinding: the prior `importance = 1` predicate was
1101
+ // structurally blind to imp=0 — decay's floor + the LLM low-signal filter push
1102
+ // dormant rows to 0, which on a real store is ~half the live corpus. The gauge
1103
+ // therefore reported "0.0% noise" while the store was dormant-heavy. `<= 1` makes
1104
+ // imp=0 visible; injection_count=0 mirrors decay's NEVER-INJECTED guard so an
1105
+ // injected-but-decayed row (pinned noise, tracked separately) is not miscounted
1106
+ // as "never used".
1099
1107
  const lowVal = db.prepare(`
1100
1108
  SELECT COUNT(*) as c FROM observations
1101
- WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
1109
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
1110
+ AND COALESCE(injection_count,0) = 0
1102
1111
  AND COALESCE(compressed_into, 0) = 0
1103
1112
  AND created_at_epoch < ? ${projectFilter}
1104
1113
  `).get(thirtyDaysAgo, ...baseParams);
@@ -1197,7 +1206,7 @@ async function cmdStats(db, args) {
1197
1206
  out('Data Health:');
1198
1207
  out(` Est. tokens: ${tokenEst.t ?? 0}`);
1199
1208
  out(` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`);
1200
- out(` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1209
+ out(` Low-value (imp1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`);
1201
1210
  out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
1202
1211
  out(` Compressed: ${compressedCount.c}`);
1203
1212
  out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
@@ -1752,6 +1761,13 @@ function cmdRestore(db, argv) {
1752
1761
 
1753
1762
  function cmdCompress(db, args) {
1754
1763
  const { flags } = parseArgs(args);
1764
+ // Sibling-command flag footgun: compress executes with --execute (optimize uses
1765
+ // --run, maintain uses positional `execute`). A bare --run previously fell through to
1766
+ // a silent preview; fail fast pointing at the right flag. --execute still wins if both.
1767
+ if ((flags.run === true || flags.run === 'true') && flags.execute !== true && flags.execute !== 'true') {
1768
+ fail("[mem] compress executes with --execute, not --run (--run is optimize's flag). Re-run: claude-mem-lite compress --execute");
1769
+ return;
1770
+ }
1755
1771
  const preview = flags.execute !== true && flags.execute !== 'true';
1756
1772
  // Reject malformed --age-days explicitly. The prior fallback (`|| 30`) silently used
1757
1773
  // the default whenever the value parsed as NaN or <1, so users typing `--age-days abc`
@@ -1944,6 +1960,10 @@ function cmdMaintain(db, args) {
1944
1960
  if (ops.includes('cleanup')) {
1945
1961
  const deleted = cleanupBroken(db, mctx);
1946
1962
  results.push(`Cleaned up ${deleted} broken observations${capHint(deleted)}`);
1963
+ // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf): resurface
1964
+ // unreachable children. Non-destructive — un-hide only, no delete.
1965
+ const orphans = recoverOrphanedChildren(db, mctx);
1966
+ if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1947
1967
  }
1948
1968
 
1949
1969
  if (ops.includes('decay')) {
@@ -2844,6 +2864,13 @@ async function cmdEnrich(argv) {
2844
2864
  async function cmdOptimize(db, args) {
2845
2865
  const run = args.includes('--run');
2846
2866
  const runAll = args.includes('--run-all');
2867
+ // Sibling-command flag footgun: optimize executes with --run (compress uses
2868
+ // --execute, maintain uses positional `execute`). A bare --execute previously fell
2869
+ // through to a silent preview, so the user thought they ran a mutation but didn't.
2870
+ if (args.includes('--execute') && !run && !runAll) {
2871
+ fail("[mem] optimize executes with --run, not --execute (--execute is compress's flag). Re-run: claude-mem-lite optimize --run");
2872
+ return;
2873
+ }
2847
2874
  const verbose = args.includes('--verbose') || args.includes('-v');
2848
2875
  // T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
2849
2876
  const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.22.0",
3
+ "version": "3.24.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",
@@ -71,6 +71,7 @@
71
71
  "lib/reread-guard.mjs",
72
72
  "lib/metrics.mjs",
73
73
  "lib/lesson-idents.mjs",
74
+ "lib/task-imperative.mjs",
74
75
  "lib/lesson-bridge.mjs",
75
76
  "lib/binding-probe.mjs",
76
77
  "lib/proc-lock.mjs",
@@ -14,7 +14,14 @@ export function parseGitHubUrl(url) {
14
14
  // ("main?x#y") and corrupts the GitHub API URL built from it, so the import
15
15
  // fails with a confusing 404 on a URL that opens fine in the browser.
16
16
  const clean = url.split(/[?#]/)[0];
17
- const match = clean.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:\/tree\/([^/]+)(\/.*)?)?$/);
17
+ // RFC 3986: scheme + host are case-insensitive, but the path (owner/repo/branch/dir)
18
+ // is NOT. Lowercase ONLY the scheme://github.com prefix — and only when github.com is
19
+ // the real host (followed by '/' or end, never as a substring of github.com.evil.com)
20
+ // — so a pasted "HTTPS://GitHub.com/…" (valid, opens in the browser) isn't rejected as
21
+ // "Invalid GitHub URL". The structural host check in the match below is unchanged, so
22
+ // github.com.evil.com / github.com@evil.com still fail.
23
+ const normalized = clean.replace(/^https?:\/\/github\.com(?=\/|$)/i, m => m.toLowerCase());
24
+ const match = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:\/tree\/([^/]+)(\/.*)?)?$/);
18
25
  if (!match) return null;
19
26
  const [, owner, repo, branch, pathRaw] = match;
20
27
  return {
@@ -104,14 +104,19 @@ async function bridgeTopLesson(rows, changeText) {
104
104
 
105
105
  // ─── Helpers ────────────────────────────────────────────────────────────────
106
106
 
107
+ // SYNC: must produce the SAME string as utils.mjs::inferProject (the path obs are SAVED
108
+ // under) and the bash post-tool-use.sh fast-path — recall queries the SAVE-path project.
109
+ // This previously used process.cwd() WITHOUT the process.env.PWD fallback the other two
110
+ // have, so under a symlinked project dir (PWD = logical/symlinked path, cwd = resolved)
111
+ // with CLAUDE_PROJECT_DIR unset it computed a DIFFERENT project than the save path and
112
+ // silently recalled nothing. Resolution order + sanitize + 100-char cap now match utils.
107
113
  function inferProject() {
108
- const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
114
+ const dir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
109
115
  const base = basename(dir);
110
116
  const parent = basename(join(dir, '..'));
111
- let project = (parent && parent !== '.' && parent !== '/')
117
+ const raw = (parent && parent !== '.' && parent !== '/')
112
118
  ? `${parent}--${base}` : base;
113
- project = project.replace(/[^a-zA-Z0-9_.-]/g, '-') || 'unknown';
114
- return project;
119
+ return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100) || 'unknown';
115
120
  }
116
121
 
117
122
  function readCooldown(cooldownPath) {
@@ -197,6 +197,49 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
197
197
  return false;
198
198
  }
199
199
 
200
+ // ─── Identifier-exact-match precision bypass (default OFF) ───────────────────
201
+ //
202
+ // CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1 enables it. Rationale: the score-floors below
203
+ // (OR_TOP_BM25_FLOOR / TOP_REL_FLOOR) drop the WHOLE FTS set when the top row's
204
+ // magnitude is weak. But a rare code identifier (camelCase / snake_case / CONST_CASE
205
+ // / kebab≥3) match has high *semantic* precision even at modest BM25 — a df=1 term
206
+ // like `sanitizeFtsQuery` scores only ~23 raw yet is an unambiguous hit. So an obs
207
+ // whose title/lesson EXACT-matches an identifier the prompt names is a precision pass
208
+ // — the same independent-signal rationale that already exempts sigRows (error
209
+ // signatures) and fileRows (file names) from these floors. When enabled, such rows are
210
+ // restored after the floors run.
211
+ //
212
+ // Default OFF — opt-in. benchmark/ups-ab.mjs measured it TRADEOFF on the real corpus
213
+ // (+3 recall recoveries / +4 injections over 12 positives / 8 hard-negatives); the
214
+ // "cost" was eager surfacing of on-identifier obs on non-recall-framed prompts, not
215
+ // off-topic noise. Kept opt-in (below the NET-POSITIVE/NEUTRAL ship bar). See #8858.
216
+ export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS === '1';
217
+ const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
218
+
219
+ // All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
220
+ // row matching). Empty array when none — callers treat that as "no bypass candidates".
221
+ export function extractTechIdentifiers(text) {
222
+ return [...new Set((String(text || '').match(TECH_IDENTIFIER_RE_G) || []).map(s => s.toLowerCase()))];
223
+ }
224
+
225
+ // True when the obs row's title or lesson contains any of `idsLower` as a standalone
226
+ // token (not embedded in a longer identifier). Title/lesson only — the searchByFts
227
+ // SELECT carries no narrative, and requiring the hit in the title/lesson is the
228
+ // stricter, higher-precision condition (intentional).
229
+ export function rowMatchesIdentifier(row, idsLower) {
230
+ if (!idsLower || idsLower.length === 0) return false;
231
+ const hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
232
+ const isWordChar = (c) => c !== undefined && /[a-z0-9_]/.test(c);
233
+ return idsLower.some((id) => {
234
+ let from = 0, i;
235
+ while ((i = hay.indexOf(id, from)) >= 0) {
236
+ if (!isWordChar(hay[i - 1]) && !isWordChar(hay[i + id.length])) return true;
237
+ from = i + 1;
238
+ }
239
+ return false;
240
+ });
241
+ }
242
+
200
243
  // ─── DB Query Functions ─────────────────────────────────────────────────────
201
244
 
202
245
  // Returns { rows, mode } where mode is 'AND' (initial pass), 'OR' (fallback
@@ -557,6 +600,9 @@ async function main() {
557
600
  const signalPresent = hasExplicitSignal(promptText, {
558
601
  errSig, files: filesForGate, intent,
559
602
  });
603
+ // Identifier tokens the prompt names (for the precision bypass below). Empty
604
+ // unless CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1, so this is a no-op when disabled.
605
+ const promptIdentifiers = IDENTIFIER_BYPASS ? extractTechIdentifiers(promptText) : [];
560
606
 
561
607
  if (intent?.useRecent) {
562
608
  // Recall intent: show recent observations
@@ -588,6 +634,14 @@ async function main() {
588
634
  typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor
589
635
  );
590
636
 
637
+ // Identifier-exact-match precision bypass (default off — see IDENTIFIER_BYPASS).
638
+ // Capture rows that exact-match a prompt identifier BEFORE the set-floors below;
639
+ // they carry independent precision signal (sigRows/fileRows rationale) and are
640
+ // restored after the floors so a low top-score can't drop a named-identifier hit.
641
+ const bypassRows = (IDENTIFIER_BYPASS && promptIdentifiers.length > 0)
642
+ ? ftsRows.filter(r => rowMatchesIdentifier(r, promptIdentifiers))
643
+ : [];
644
+
591
645
  // v2.43.x: OR-mode raw-BM25 floor. In OR-fallback mode the composite
592
646
  // TOP_REL_FLOOR below is inflated by importance × type_quality × decay
593
647
  // multipliers — a weak single-stem hit on an importance=3 bugfix obs
@@ -611,6 +665,15 @@ async function main() {
611
665
  ftsRows = [];
612
666
  }
613
667
 
668
+ // Restore identifier-matched precision rows the set-floors above dropped.
669
+ // No-op when the bypass is off (bypassRows is []) or when the floors kept the
670
+ // rows anyway (dedup by id). Re-sort so the merged set stays relevance-ordered.
671
+ if (bypassRows.length > 0) {
672
+ const kept = new Set(ftsRows.map(r => r.id));
673
+ for (const r of bypassRows) if (!kept.has(r.id)) ftsRows.push(r);
674
+ ftsRows.sort((a, b) => (a.relevance ?? 0) - (b.relevance ?? 0));
675
+ }
676
+
614
677
  // Merge: FTS results first, then file results, deduplicated
615
678
  const seen = new Set(ftsRows.map(r => r.id));
616
679
  rows = [...ftsRows];
@@ -130,7 +130,8 @@ export function reRankWithContext(db, results, project) {
130
130
  /**
131
131
  * Mark older, lower-importance observations as superseded when multiple touch the same file.
132
132
  * Mutates result objects in-place by adding superseded=true flag.
133
- * @param {object[]} results Array of search result objects with source, files_modified, date, importance
133
+ * @param {object[]} results Array of search result objects with source, files_modified,
134
+ * created_at_epoch (or created_at / legacy date), importance
134
135
  */
135
136
  export function markSuperseded(results) {
136
137
  if (!results || results.length === 0) return;
@@ -150,7 +151,18 @@ export function markSuperseded(results) {
150
151
  // Persistent superseding belongs in mem_maintain/mem_compress write paths.
151
152
  for (const [, obsForFile] of fileMap) {
152
153
  if (obsForFile.length < 2) continue;
153
- obsForFile.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
154
+ // Sort newest-first by recency. The production search pipeline supplies
155
+ // created_at_epoch (numeric) + created_at (ISO) — NOT `date`; the prior
156
+ // `b.date` sort silently no-op'd there (every key undefined → localeCompare 0),
157
+ // so "newest" degraded to relevance order and a current top-importance obs got
158
+ // a false [SUPERSEDED] tag (#8821). Prefer numeric epoch, fall back to the ISO
159
+ // string (lexically chronological), then legacy `date` for older callers/tests.
160
+ obsForFile.sort((a, b) => {
161
+ const ka = a.created_at_epoch ?? a.created_at ?? a.date ?? '';
162
+ const kb = b.created_at_epoch ?? b.created_at ?? b.date ?? '';
163
+ if (typeof ka === 'number' && typeof kb === 'number') return kb - ka;
164
+ return String(kb).localeCompare(String(ka));
165
+ });
154
166
  const newest = obsForFile[0];
155
167
  for (let i = 1; i < obsForFile.length; i++) {
156
168
  if ((obsForFile[i].importance ?? 1) <= (newest.importance ?? 1)) {
package/server.mjs CHANGED
@@ -17,6 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
17
17
  import { buildSearchFtsQuery, parseDateBounds, coreRunSearchPipeline } from './lib/search-core.mjs';
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
+ recoverOrphanedChildren,
20
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
21
22
  recoverChildrenOf, hardDeleteCandidateCount,
22
23
  OP_CAP, STALE_AGE_MS,
@@ -863,9 +864,14 @@ server.registerTool(
863
864
  `).get(...baseParams);
864
865
 
865
866
  const thirtyDaysAgo = Date.now() - 30 * 86400000;
867
+ // v3.23 noise-gauge de-blinding (mirrors mem-cli cmdStats): `<= 1` makes the
868
+ // imp=0 dormant population visible (decay floor + LLM low-signal filter push
869
+ // ~half the live corpus to 0); injection_count=0 mirrors decay's NEVER-INJECTED
870
+ // guard so injected-but-decayed pinned noise isn't miscounted as "never used".
866
871
  const lowVal = db.prepare(`
867
872
  SELECT COUNT(*) as c FROM observations
868
- WHERE COALESCE(importance,1) = 1 AND COALESCE(access_count,0) = 0
873
+ WHERE COALESCE(importance,1) <= 1 AND COALESCE(access_count,0) = 0
874
+ AND COALESCE(injection_count,0) = 0
869
875
  AND COALESCE(compressed_into, 0) = 0
870
876
  AND created_at_epoch < ? ${projectFilter}
871
877
  `).get(thirtyDaysAgo, ...baseParams);
@@ -915,7 +921,7 @@ server.registerTool(
915
921
  'Data Health:',
916
922
  ` Est. tokens: ${tokenEst.t ?? 0}`,
917
923
  ` Avg importance: ${(avgImp.v ?? 1).toFixed(2)}`,
918
- ` Low-value (imp=1, never accessed, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
924
+ ` Low-value (imp1, never used, >30d): ${lowVal.c} (${(noiseRatio * 100).toFixed(1)}% noise)`,
919
925
  ` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`,
920
926
  ` Compressed: ${compressedCount.c}`,
921
927
  ...((noiseRatio > 0.6 || lowSignalRatio > 0.3) ? [' ⚠️ High noise ratio — consider running mem_compress / maintain'] : []),
@@ -1109,6 +1115,10 @@ server.registerTool(
1109
1115
  if (ops.includes('cleanup')) {
1110
1116
  const deleted = cleanupBroken(db, mctx);
1111
1117
  results.push(`Cleaned up ${deleted} broken observations` + (deleted >= OP_CAP ? ' (cap reached, re-run for more)' : ''));
1118
+ // Self-heal legacy orphans (keeper hard-deleted pre-recoverChildrenOf):
1119
+ // resurface unreachable children. Non-destructive — un-hide only, no delete.
1120
+ const orphans = recoverOrphanedChildren(db, mctx);
1121
+ if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1112
1122
  }
1113
1123
 
1114
1124
  if (ops.includes('decay')) {
package/source-files.mjs CHANGED
@@ -66,6 +66,10 @@ export const SOURCE_FILES = [
66
66
  // are present in the pre-edit file (component 2). Imported ONLY by
67
67
  // scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
68
68
  'lib/lesson-idents.mjs',
69
+ // Phase-2 task-imperative framing helper (2026-06-29): formatTaskImperative, the single
70
+ // source of the imperative line. Statically imported by hook.mjs (live emitter, gated by
71
+ // CLAUDE_MEM_TASK_IMPERATIVE) — must ship even with the flag off.
72
+ 'lib/task-imperative.mjs',
69
73
  // comprehension-bridge forcing-function (CLAUDE_MEM_SALIENCE=bridge): rewrites
70
74
  // a recalled lesson into a check bound to the change hunk. Dynamic-imported by
71
75
  // scripts/pre-tool-recall.js ONLY under the flag, but must still ship so the