claude-mem-lite 3.34.0 → 3.35.1

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.34.0",
13
+ "version": "3.35.1",
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.34.0",
3
+ "version": "3.35.1",
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.mjs CHANGED
@@ -33,8 +33,10 @@ if (cmd === '--version' || cmd === '-v') {
33
33
  const { existsSync } = await import('fs');
34
34
  const { join } = await import('path');
35
35
  // D#29: honor CLAUDE_MEM_DIR so the install-vs-CLI help routing is correct on
36
- // relocated installs (matches schema.mjs DB_DIR; HOME fallback when env unset).
37
- const dataDir = process.env.CLAUDE_MEM_DIR || join(process.env.HOME || '', '.claude-mem-lite');
36
+ // relocated installs (matches schema.mjs DB_DIR via the shared resolver, which
37
+ // also fixes the HOME-unset relative-path fallback this branch used to have).
38
+ const { resolveDataDir } = await import('./lib/resolve-data-dir.mjs');
39
+ const dataDir = resolveDataDir(process.env.CLAUDE_MEM_DIR);
38
40
  const dbPath = join(dataDir, 'claude-mem-lite.db');
39
41
  if (existsSync(dbPath)) {
40
42
  const { run } = await import('./mem-cli.mjs');
package/format-utils.mjs CHANGED
@@ -37,12 +37,16 @@ export function truncate(str, max = 80) {
37
37
  // block would smuggle a privileged-channel instruction / fake tool-call narrative
38
38
  // into the model's context. It can't escape its wrapper (class 1 closers are
39
39
  // defanged), but a nested forged authority/tool tag is still an indirect-prompt-
40
- // injection vector; strip the brackets so it reads as inert text. Other tags
41
- // (<other-tag>) \u2014 and the attribute-bearing <invoke \u2026>/<parameter \u2026> forms \u2014 are
42
- // deliberately left intact.
40
+ // injection vector; strip the brackets so it reads as inert text. Tool-call tags
41
+ // (<invoke \u2026>/<parameter \u2026>, bare + antml:-namespaced) are included: a prior turn's
42
+ // malformed tool-XML replayed through a handoff corrupted the continuation surface
43
+ // (mid-token truncation + model confusion), so replayed tool-XML must defang too.
44
+ // These carry attributes, so the match allows an optional attribute tail before the
45
+ // closing `>` \u2014 which also catches an attribute-bearing forgery of an authority tag
46
+ // (<system-reminder foo="\u2026">). Unrelated tags (<other-tag>) are left intact.
43
47
  // Reachable by editing files that contain these tokens \u2014 e.g. developing claude-mem-lite
44
48
  // itself, where source/observations carry the delimiter names.
45
- const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results)>/gi;
49
+ const CONTEXT_DELIMITER_RE = /<\/?(?:claude-mem-context|memory-context|session-handoff|system-reminder|task-notification|(?:antml:)?function_calls|(?:antml:)?function_results|(?:antml:)?invoke|(?:antml:)?parameter)(?:\s[^>]*)?>/gi;
46
50
 
47
51
  /**
48
52
  * Defang the literal context-block delimiter tags in user-derived text. Strips just the
package/hook-handoff.mjs CHANGED
@@ -478,9 +478,12 @@ function renderHandoffFromRow(handoff, db, project) {
478
478
  if (summary && (summary.completed || summary.next_steps || summary.remaining_items)) {
479
479
  lines.push('');
480
480
  lines.push('<session-summary source="haiku">');
481
- if (summary.completed) lines.push(summary.completed);
482
- if (summary.remaining_items) lines.push(`Remaining: ${summary.remaining_items}`);
483
- if (summary.next_steps) lines.push(`Next steps: ${summary.next_steps}`);
481
+ // Defang: these come from session_summaries, populated by Haiku OR by
482
+ // extractStructuredSummary over the assistant transcript tail — replayed text that can
483
+ // carry tool-XML / forged authority tags, same class as working_on above (audit MED-4).
484
+ if (summary.completed) lines.push(neutralizeContextDelimiters(summary.completed));
485
+ if (summary.remaining_items) lines.push(`Remaining: ${neutralizeContextDelimiters(summary.remaining_items)}`);
486
+ if (summary.next_steps) lines.push(`Next steps: ${neutralizeContextDelimiters(summary.next_steps)}`);
484
487
  lines.push('</session-summary>');
485
488
  }
486
489
  } catch {}
package/hook-llm.mjs CHANGED
@@ -146,6 +146,22 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
146
146
  return null;
147
147
  }
148
148
 
149
+ // Paired-gate DROP at the auto-capture choke-point (v3.35). isLowYieldChangeObs
150
+ // (type=change + imp<2 + no real lesson — the substantive-title band isNoiseObservation
151
+ // misses) previously ran ONLY on the LLM-success path (handleLLMEpisode). The pre-save
152
+ // write here and the LLM-failure fallback (which keeps the pre-saved row) both bypassed
153
+ // it, so template change-rows survived on LLM failure. Gate covers all three write paths.
154
+ // Runs BEFORE capNoiseImportance: the pre-assigned importance IS the rule signal for a
155
+ // provisional pre-save — an imp>=2 rule-triggered episode (config/error change) must land
156
+ // so it stays visible immediately, survives a total LLM failure (the keep-pre-saved
157
+ // branch), and keeps its original created_at for the later in-place upgrade. (A dropped
158
+ // pre-save is not lost on LLM success — that path clean-inserts a fresh row — but it loses
159
+ // those three.) capNoiseImportance then caps any title-noise survivor to imp=1 as before.
160
+ if (isLowYieldChangeObs(obs)) {
161
+ debugLog('saveObservation', `dropped low-yield change: ${truncate(obs.title || '', 60)}`);
162
+ return null;
163
+ }
164
+
149
165
  // v2.47 P0-3: importance cap for LOW_SIGNAL titles that kept the drop gate
150
166
  // open via importance>=2 but carry no lesson/facts signal. 341 rows in live
151
167
  // DB had imp=3 under these conditions (99.4% noise). Cap to 1 so they
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, recoverOrphanedChildren } from './lib/maintain-core.mjs';
50
+ import { cleanupBroken, decayAndMarkIdle, boostAccessed, selectFuzzyDedupeIds, hardDeleteCandidateCount, purgeStale, recoverOrphanedChildren, recoverBuriedLessons } from './lib/maintain-core.mjs';
51
51
  import { snapshotDb } from './lib/db-backup.mjs';
52
52
  import {
53
53
  extractCitationsFromTranscript,
@@ -62,6 +62,7 @@ import { extractTailAssistantText, extractStructuredSummary } from './lib/summar
62
62
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
63
63
  import { formatTaskImperative } from './lib/task-imperative.mjs';
64
64
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
65
+ import { gcOldMetricShards } from './lib/metrics.mjs';
65
66
  import { detectMemOverride } from './lib/mem-override.mjs';
66
67
  import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
67
68
  import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
@@ -795,6 +796,11 @@ function runSessionStartAutoMaintain(db) {
795
796
  const orphansRecovered = recoverOrphanedChildren(db, mctx);
796
797
  if (orphansRecovered > 0) debugLog('DEBUG', 'auto-maintain', `recovered ${orphansRecovered} orphaned compression children`);
797
798
 
799
+ // Heal lesson rows citation-decay buried at importance 0 under the old floor=0.
800
+ // Non-destructive (0→1 on lesson-bearing rows only); idempotent no-op once none remain.
801
+ const lessonsHealed = recoverBuriedLessons(db, mctx);
802
+ if (lessonsHealed > 0) debugLog('DEBUG', 'auto-maintain', `healed ${lessonsHealed} lesson rows buried at importance 0`);
803
+
798
804
  const { decayed, idleMarked } = decayAndMarkIdle(db, mctx);
799
805
  if (decayed > 0) debugLog('DEBUG', 'auto-maintain', `decayed ${decayed} stale observations`);
800
806
  if (idleMarked > 0) debugLog('DEBUG', 'auto-maintain', `marked ${idleMarked} idle as pending-purge`);
@@ -1098,6 +1104,9 @@ async function handleSessionStart() {
1098
1104
  gcStalePreRecallCooldowns();
1099
1105
  // Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
1100
1106
  try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
1107
+ // Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
1108
+ // metrics are disabled, so shards left by a since-toggled-off run still get pruned.
1109
+ try { gcOldMetricShards(join(RUNTIME_DIR, '..')); } catch { /* best-effort */ }
1101
1110
 
1102
1111
  // Plugin cache self-heal: Claude Code auto-updates the marketplace plugin can
1103
1112
  // re-populate cache/<ver>/hooks/hooks.json, reintroducing duplicate hook
package/install.mjs CHANGED
@@ -7,6 +7,7 @@ import { join, resolve, dirname, isAbsolute } from 'path';
7
7
  import { homedir, tmpdir } from 'os';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { createRequire } from 'node:module';
10
+ import { resolveDataDir } from './lib/resolve-data-dir.mjs';
10
11
 
11
12
  const PROJECT_DIR = resolve(import.meta.dirname ?? dirname(fileURLToPath(import.meta.url)));
12
13
  const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
@@ -20,7 +21,7 @@ const DATA_DIR = join(homedir(), '.claude-mem-lite');
20
21
  // the runtime/data layer READS it (pre-fix: installer wrote homedir, runtime read
21
22
  // the relocated dir → preinstalled skills silently vanished, doctor read the wrong
22
23
  // DB). Equals DATA_DIR when CLAUDE_MEM_DIR is unset (the common case).
23
- const MEM_DATA_DIR = process.env.CLAUDE_MEM_DIR || DATA_DIR;
24
+ const MEM_DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
24
25
  const DB_PATH = join(MEM_DATA_DIR, 'claude-mem-lite.db');
25
26
  const OLD_DATA_DIR = join(homedir(), '.claude-mem');
26
27
 
@@ -472,7 +472,16 @@ export function hasMainThreadAssistantText(transcriptPath) {
472
472
  }
473
473
 
474
474
  const IMPORTANCE_CAP = 3;
475
- const IMPORTANCE_FLOOR = 0;
475
+ // Demote floor = 1, NOT 0. Both passive injection surfaces exclude importance 0
476
+ // (pre-tool-recall.js requires >=2, user-prompt-search.js requires >=1), so a row
477
+ // demoted to 0 can never be re-injected → never re-cited → never recovers: a one-way
478
+ // burial that silently hides lesson-bearing rows the "lessons never auto-GC" guards
479
+ // (maintain decayAndMarkIdle + compress-core) protect on every other path. Floor 1
480
+ // keeps a decayed row on the >=1 surface with a citation-recovery path. Genuine noise
481
+ // still sinks via maintain's PENDING_PURGE pipeline, which keys on compressed_into
482
+ // (not importance) over injection_count=0 rows — a disjoint population from these
483
+ // injected-but-uncited rows — so noise GC is unaffected.
484
+ const IMPORTANCE_FLOOR = 1;
476
485
  const UNCITED_STREAK_THRESHOLD = 3;
477
486
 
478
487
  // Adoption-rate gate (P5 ②). A project's cite-rate is SUM(cited_count) /
@@ -117,6 +117,29 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
117
117
  `).run(...baseParams).changes;
118
118
  }
119
119
 
120
+ // Heal lesson-bearing rows that citation-decay buried at importance 0 under the old
121
+ // IMPORTANCE_FLOOR=0 (fixed in citation-tracker.mjs → floor 1). All passive injection
122
+ // surfaces exclude importance 0 (pre-tool-recall >=2, user-prompt-search >=1, memory-context
123
+ // >=1), so a lesson demoted there is invisible AND — being injection_count>0 by construction
124
+ // — sits in no GC queue either (decayAndMarkIdle only marks injection_count=0 rows): stranded
125
+ // out of reach with its distilled lesson. Lifting to 1 restores >=1-surface visibility + a
126
+ // citation-recovery path. NON-DESTRUCTIVE (only 0→1 on lesson-bearing rows, never
127
+ // deletes/hides), idempotent (a no-op once no imp-0 lesson rows remain), so safe to run
128
+ // unconditionally alongside recoverOrphanedChildren. `superseded_at IS NULL` mirrors the
129
+ // injection surfaces' own filter (pre-tool-recall:368, memory-context:217) — a de-dup loser
130
+ // (auto-dedup sets superseded_at but leaves compressed_into=0) must NOT be lifted back into
131
+ // injectability. Non-lesson imp-0 rows are left buried (low-value, not worth resurfacing).
132
+ export function recoverBuriedLessons(db, { projectFilter = '', baseParams = [] } = {}) {
133
+ return db.prepare(`
134
+ UPDATE observations SET importance = 1
135
+ WHERE COALESCE(compressed_into, 0) = 0
136
+ AND COALESCE(importance, 1) = 0
137
+ AND superseded_at IS NULL
138
+ AND lesson_learned IS NOT NULL AND lesson_learned <> '' AND lower(lesson_learned) <> 'none'
139
+ ${projectFilter}
140
+ `).run(...baseParams).changes;
141
+ }
142
+
120
143
  export function cleanupBroken(db, { projectFilter, baseParams, opCap = OP_CAP }) {
121
144
  const doomed = db.prepare(`
122
145
  SELECT id FROM observations
package/lib/metrics.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  // skip malformed lines silently. The module imports nothing heavy so hook
17
17
  // cold-start pays near-zero when metrics are disabled.
18
18
 
19
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs';
19
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'fs';
20
20
  import { join } from 'path';
21
21
 
22
22
  const DAY_MS = 86400000;
@@ -68,6 +68,33 @@ export function timed(dbDir, event, fn, extra = {}) {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * Prune metric daily shards older than `retainDays`. recordMetric writes one
73
+ * YYYY-MM-DD.jsonl per day with no GC, so a long-lived CLAUDE_MEM_METRICS=1 install
74
+ * grows the dir unbounded. 90d keeps a full quarter for aggregate windows while
75
+ * bounding the dir. Runs regardless of the enable flag so a user who toggles metrics
76
+ * OFF still gets old shards cleaned. Shard date read from the filename (ISO dates sort
77
+ * lexicographically = chronologically). Best-effort, never throws — called from the
78
+ * SessionStart GC sweep (mirrors registry-recommend.gcOldShadowShards).
79
+ * @returns {number} shards removed
80
+ */
81
+ export function gcOldMetricShards(dbDir, retainDays = 90) {
82
+ try {
83
+ if (!dbDir) return 0;
84
+ const dir = join(dbDir, 'metrics');
85
+ if (!existsSync(dir)) return 0;
86
+ const cutoff = new Date(Date.now() - retainDays * DAY_MS).toISOString().slice(0, 10);
87
+ let removed = 0;
88
+ for (const name of readdirSync(dir)) {
89
+ const m = /^(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
90
+ if (m && m[1] < cutoff) {
91
+ try { unlinkSync(join(dir, name)); removed++; } catch { /* per-entry, silent */ }
92
+ }
93
+ }
94
+ return removed;
95
+ } catch { return 0; }
96
+ }
97
+
71
98
  /** Count of days to aggregate by default in readMetrics / aggregate. */
72
99
  export const DEFAULT_WINDOW_DAYS = 7;
73
100
 
@@ -0,0 +1,35 @@
1
+ // Single source of truth for resolving the CLAUDE_MEM_DIR data directory.
2
+ // Zero runtime deps (node:path + node:os only) so hot-path hook scripts can
3
+ // import it without pulling in better-sqlite3.
4
+ //
5
+ // The env var is a RELOCATION knob: unset → ~/.claude-mem-lite (the common,
6
+ // non-relocated case); set → an absolute path on a larger/faster volume.
7
+ // Anything else is a mistake we must reject LOUDLY rather than turn into a
8
+ // stray directory:
9
+ // - The literal strings "undefined"/"null" arise when a caller interpolates
10
+ // a JS nullish value into a shell env string (e.g. `CLAUDE_MEM_DIR='${x}'`
11
+ // with x === undefined). They are truthy, so `env || default` accepts them
12
+ // and better-sqlite3 then creates a relative `undefined/` dir at cwd.
13
+ // - A relative path silently resolves against each process's cwd, scattering
14
+ // state across directories.
15
+ // Falsy (unset/empty) is the only non-absolute value we treat as "use default".
16
+ import { homedir } from 'node:os';
17
+ import { join, isAbsolute } from 'node:path';
18
+
19
+ /**
20
+ * @param {string|undefined|null} raw Typically process.env.CLAUDE_MEM_DIR.
21
+ * @returns {string} An absolute data directory.
22
+ * @throws if `raw` is a non-empty, non-absolute value (incl. "undefined"/"null").
23
+ */
24
+ export function resolveDataDir(raw) {
25
+ if (raw === undefined || raw === null || raw === '') {
26
+ return join(homedir(), '.claude-mem-lite');
27
+ }
28
+ if (typeof raw !== 'string' || raw === 'undefined' || raw === 'null' || !isAbsolute(raw)) {
29
+ throw new Error(
30
+ `CLAUDE_MEM_DIR must be an absolute path; got ${JSON.stringify(raw)}. ` +
31
+ `Leave it unset to use ~/.claude-mem-lite.`
32
+ );
33
+ }
34
+ return raw;
35
+ }
@@ -8,7 +8,7 @@
8
8
  import { readGitState } from './git-state.mjs';
9
9
  import { readProjectTasks } from './task-reader.mjs';
10
10
  import { recentPlans } from './plan-reader.mjs';
11
- import { isAdoptedHere } from '../hook-shared.mjs';
11
+ import { isAdoptedHere, HANDOFF_EXPIRY_EXIT } from '../hook-shared.mjs';
12
12
 
13
13
  function ageStr(ms) {
14
14
  const diff = Date.now() - ms;
@@ -22,11 +22,16 @@ function ageStr(ms) {
22
22
 
23
23
  function readRecentHandoff(db, project) {
24
24
  try {
25
+ // Only surface a handoff the UserPromptSubmit injection could actually deliver: apply
26
+ // the SAME expiry the injection's pickHandoffToInject enforces (HANDOFF_EXPIRY_EXIT).
27
+ // Without this the dashboard promised "context injects on your next message" for an
28
+ // exit handoff >7d old that pickHandoffToInject filters as expired → the promise could
29
+ // never be kept (batch2-reviewer Finding 1).
25
30
  return db.prepare(`
26
31
  SELECT created_at_epoch, working_on FROM session_handoffs
27
- WHERE project = ? AND type = 'exit'
32
+ WHERE project = ? AND type = 'exit' AND created_at_epoch > ?
28
33
  ORDER BY created_at_epoch DESC LIMIT 1
29
- `).get(project);
34
+ `).get(project, Date.now() - HANDOFF_EXPIRY_EXIT);
30
35
  } catch { return null; }
31
36
  }
32
37
 
@@ -82,10 +87,16 @@ export function buildDashboard({ db, project, projectPath = process.cwd(), stubs
82
87
  }
83
88
 
84
89
  if (handoff) {
85
- parts.push(`🔄 Continuation (/exit ${ageStr(handoff.created_at_epoch)}):`);
86
- if (handoff.working_on) {
87
- parts.push(` - Working on: ${handoff.working_on.slice(0, 160)}`);
88
- }
90
+ // Continuation POINTER only. The working_on CONTENT is delivered once — by the
91
+ // UserPromptSubmit <session-handoff> block on a continuation-intent prompt
92
+ // (renderHandoffInjection). Emitting the teaser here too double-injected working_on
93
+ // into the model's context on every resume (post-defang: redundant, not unsafe).
94
+ // Wording is CONDITIONAL ("resume it to restore"), not a promise of automatic
95
+ // injection: the injection is gated on continuation-intent, so on a genuinely new
96
+ // first task the gate stays silent (stale working_on correctly not injected) and the
97
+ // pointer must not claim otherwise. readRecentHandoff already drops handoffs older than
98
+ // the injection's own expiry, so a shown pointer is always deliverable on resume.
99
+ parts.push(`🔄 Continuation available (/exit ${ageStr(handoff.created_at_epoch)}) — resume it to restore the full context.`);
89
100
  }
90
101
 
91
102
  if (eventCount > 0) {
package/mem-cli.mjs CHANGED
@@ -18,7 +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
+ recoverOrphanedChildren, recoverBuriedLessons,
22
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
23
23
  recoverChildrenOf, hardDeleteCandidateCount,
24
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
@@ -1972,6 +1972,10 @@ function cmdMaintain(db, args) {
1972
1972
  // unreachable children. Non-destructive — un-hide only, no delete.
1973
1973
  const orphans = recoverOrphanedChildren(db, mctx);
1974
1974
  if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1975
+ // Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
1976
+ // lesson-bearing rows only; idempotent no-op once none remain.
1977
+ const lessonsHealed = recoverBuriedLessons(db, mctx);
1978
+ if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
1975
1979
  }
1976
1980
 
1977
1981
  if (ops.includes('decay')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.34.0",
3
+ "version": "3.35.1",
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",
@@ -67,6 +67,7 @@
67
67
  "lib/id-routing.mjs",
68
68
  "lib/err-sampler.mjs",
69
69
  "lib/hook-telemetry.mjs",
70
+ "lib/resolve-data-dir.mjs",
70
71
  "lib/file-intel.mjs",
71
72
  "lib/reread-guard.mjs",
72
73
  "lib/metrics.mjs",
@@ -5,7 +5,7 @@
5
5
  // invocations/recommend_count. Live injection is Phase 2. `off` skips all work.
6
6
  import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, appendFileSync, readdirSync, unlinkSync } from 'fs';
7
7
  import { join } from 'path';
8
- import { homedir } from 'os';
8
+ import { resolveDataDir } from './lib/resolve-data-dir.mjs';
9
9
  import { searchResources, cjkIntentTokens } from './registry-retriever.mjs';
10
10
 
11
11
  const VALID_MODES = new Set(['shadow', 'live', 'off']);
@@ -26,7 +26,7 @@ const RECO_COOLDOWN_MS = 300_000; // 5 min, mirrors T4 SKILL_COOLDOWN_MS
26
26
  // DB_DIR formula) so tests sandbox via env without ESM-cache gymnastics, and prod reads
27
27
  // the same dir as the rest of the app.
28
28
  function recoRuntimeDir() {
29
- return join(process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite'), 'runtime');
29
+ return join(resolveDataDir(process.env.CLAUDE_MEM_DIR), 'runtime');
30
30
  }
31
31
 
32
32
  const TOKEN_SPLIT = /[^a-z0-9一-鿿]+/;
package/schema.mjs CHANGED
@@ -7,10 +7,11 @@ import { homedir } from 'os';
7
7
  import { join } from 'path';
8
8
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, chmodSync } from 'fs';
9
9
  import { OBS_FTS_COLUMNS, debugCatch } from './utils.mjs';
10
+ import { resolveDataDir } from './lib/resolve-data-dir.mjs';
10
11
 
11
12
  // DATA location — DB, managed resources, registry DB, runtime/. Honors
12
13
  // CLAUDE_MEM_DIR so users can relocate state to a larger/faster volume.
13
- export const DB_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
14
+ export const DB_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
14
15
  export const DB_PATH = join(DB_DIR, 'claude-mem-lite.db');
15
16
  export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
16
17
  // CODE / install location — server.mjs, hook.mjs, cli.mjs, package.json live
@@ -26,15 +26,20 @@
26
26
 
27
27
  import { existsSync, mkdirSync, writeFileSync, statSync, unlinkSync, readFileSync } from 'node:fs';
28
28
  import { spawnSync } from 'node:child_process';
29
- import { dirname, join } from 'node:path';
29
+ import { dirname, join, isAbsolute } from 'node:path';
30
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
31
  import { homedir } from 'node:os';
32
32
 
33
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
34
  const INSTALL_DIR = join(__dirname, '..');
35
- const RUNTIME_DIR = process.env.CLAUDE_MEM_DIR
36
- ? join(process.env.CLAUDE_MEM_DIR, 'runtime')
37
- : join(homedir(), '.claude-mem-lite', 'runtime');
35
+ // A bogus CLAUDE_MEM_DIR ("undefined"/"null"/relative from a mis-quoted env
36
+ // interpolation) must degrade to the default here, not become a stray dir. The
37
+ // launcher's pure-node charter (above) forbids importing lib/resolve-data-dir.mjs,
38
+ // and its fail-open duty forbids throwing, so mirror that guard inline + lenient:
39
+ // non-absolute → default. Data-writing paths import that module and throw instead.
40
+ const MEM_DIR = process.env.CLAUDE_MEM_DIR;
41
+ const DATA_DIR = MEM_DIR && isAbsolute(MEM_DIR) ? MEM_DIR : join(homedir(), '.claude-mem-lite');
42
+ const RUNTIME_DIR = join(DATA_DIR, 'runtime');
38
43
  const HEAL_MARKER = join(RUNTIME_DIR, 'hook-launcher-lastheal');
39
44
  const HEAL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
40
45
  // Observable breakage state: written when the launcher degrades a broken install
@@ -15,11 +15,11 @@
15
15
 
16
16
  import { existsSync, readFileSync } from 'fs';
17
17
  import { basename, join } from 'path';
18
- import { homedir } from 'os';
18
+ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
19
19
 
20
20
  const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
21
21
 
22
- const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
22
+ const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
23
23
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
24
24
  const LEGACY_COOLDOWN_PATH = join(RUNTIME_DIR, 'pre-recall-cooldown.json');
25
25
 
@@ -68,4 +68,13 @@ async function main() {
68
68
  }));
69
69
  }
70
70
 
71
- main().catch(() => {}).finally(() => process.exit(0));
71
+ // No forced process.exit(0): main() consumes stdin to EOF (or early-returns without
72
+ // touching it) and holds no open handles (no DB, no timers), so the event loop drains
73
+ // and the process exits 0 on its own — which FLUSHES the stdout nudge above. A forced
74
+ // exit could drop that pending async write on a piped stdout (the v3.33.1 gotcha the
75
+ // payload-bearing sibling hooks avoid). catch() keeps the exit code 0.
76
+ // Swallow EPIPE: if Claude Code closes the read end before the async write drains, the
77
+ // stream emits 'error' — without the (now-removed) forced exit, an unhandled one would
78
+ // surface as a non-zero exit + stack. A hook must never fail loud on a dropped pipe.
79
+ process.stdout.on('error', () => {});
80
+ main().catch(() => {});
@@ -7,9 +7,10 @@ import { existsSync, readFileSync } from 'fs';
7
7
  import { join, resolve, sep } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import { recordHookError } from '../lib/hook-telemetry.mjs';
10
+ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
11
 
11
12
  // 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 DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
13
14
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
14
15
  // D#29: all data artifacts follow DATA_DIR (CLAUDE_MEM_DIR-aware), not a hardcoded
15
16
  // homedir — previously REGISTRY_DB_PATH/MANAGED_BASE/MARKER pinned homedir while line 12
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
8
8
  import { basename, join } from 'path';
9
- import { homedir } from 'os';
9
+ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
11
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
12
  import { citeFactorClause } from '../scoring-sql.mjs';
@@ -18,7 +18,7 @@ import { presentIdents } from '../lib/lesson-idents.mjs';
18
18
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
19
19
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
20
20
  // per-component overrides for tests that mix isolated + real paths.
21
- const DATA_DIR = process.env.CLAUDE_MEM_DIR || join(homedir(), '.claude-mem-lite');
21
+ const DATA_DIR = resolveDataDir(process.env.CLAUDE_MEM_DIR);
22
22
  const DB_PATH = process.env.CLAUDE_MEM_DB_PATH || join(DATA_DIR, 'claude-mem-lite.db');
23
23
  const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtime');
24
24
  // A3 (v2.83): cross-hook dedup window — must mirror DEDUP_STALE_MS in
@@ -4,7 +4,7 @@
4
4
  // Lightweight: only imports schema.mjs and utils.mjs, no MCP SDK
5
5
 
6
6
  import { ensureDb, DB_DIR, REGISTRY_DB_PATH } from '../schema.mjs';
7
- import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate } from '../utils.mjs';
7
+ import { sanitizeFtsQuery, relaxFtsQueryToOr, truncate, typeIcon, inferProject, OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE, notLowSignalTitleClause, noisePenaltyClause, stripPrivate, neutralizeContextDelimiters } from '../utils.mjs';
8
8
  import { citeFactorClause } from '../scoring-sql.mjs';
9
9
  import { cjkPrecisionOk } from '../nlp.mjs';
10
10
  import { writeFileSync, readFileSync, existsSync, renameSync } from 'fs';
@@ -265,7 +265,7 @@ export function rowMatchesIdentifier(row, idsLower) {
265
265
  // Each row includes `bm25_raw` (pre-multiplier bm25 magnitude) alongside the
266
266
  // composite `relevance`, so callers can distinguish raw-match strength from
267
267
  // importance/type/decay inflation.
268
- function searchByFts(db, queryText, project, limit, typeFilter) {
268
+ export function searchByFts(db, queryText, project, limit, typeFilter) {
269
269
  const ftsQuery = sanitizeFtsQuery(queryText);
270
270
  if (!ftsQuery) return { rows: [], mode: null };
271
271
 
@@ -299,6 +299,7 @@ function searchByFts(db, queryText, project, limit, typeFilter) {
299
299
  AND o.importance >= 1
300
300
  AND o.created_at_epoch > ?
301
301
  AND COALESCE(o.compressed_into, 0) = 0
302
+ AND o.superseded_at IS NULL
302
303
  AND ${notLowSignalTitleClause('o')}
303
304
  ${typeClause}
304
305
  ORDER BY relevance
@@ -345,6 +346,7 @@ function searchByFile(db, files, project, limit) {
345
346
  WHERE o.project = ?
346
347
  AND o.importance >= 1
347
348
  AND COALESCE(o.compressed_into, 0) = 0
349
+ AND o.superseded_at IS NULL
348
350
  AND o.created_at_epoch > ?
349
351
  AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
350
352
  AND ${notLowSignalTitleClause('o')}
@@ -420,6 +422,7 @@ function searchRecent(db, project, limit) {
420
422
  WHERE project = ?
421
423
  AND importance >= 1
422
424
  AND COALESCE(compressed_into, 0) = 0
425
+ AND superseded_at IS NULL
423
426
  AND created_at_epoch > ?
424
427
  AND ${notLowSignalTitleClause('')}
425
428
  ORDER BY created_at_epoch DESC
@@ -464,8 +467,10 @@ function formatResults(rows) {
464
467
  const lines = ['[mem] FYI — Related memories (continue your task):'];
465
468
  for (const r of rows) {
466
469
  const icon = typeIcon(r.type);
467
- const title = truncate(r.title || '', 70);
468
- const lesson = !QUIET_HOOKS && r.lesson_learned ? ` ${truncate(r.lesson_learned, 50)}` : '';
470
+ // Defang replayed obs text before truncation: a poisoned title/lesson carrying tool-XML
471
+ // or a forged authority tag must not render as a live delimiter in this injected block.
472
+ const title = truncate(neutralizeContextDelimiters(r.title || ''), 70);
473
+ const lesson = !QUIET_HOOKS && r.lesson_learned ? ` — ${truncate(neutralizeContextDelimiters(r.lesson_learned), 50)}` : '';
469
474
  lines.push(`#${r.id} ${icon} ${title}${lesson}`);
470
475
  }
471
476
  return lines.join('\n');
@@ -479,7 +484,10 @@ function formatPromptResults(rows) {
479
484
  if (!rows || rows.length === 0) return null;
480
485
  const lines = ['[mem] FYI — Past similar questions (continue your task):'];
481
486
  for (const r of rows) {
482
- const text = truncate((r.prompt_text || '').replace(/\s+/g, ' '), 80);
487
+ // prompt_text is a raw prior USER prompt — the highest-risk replayed class (this is
488
+ // exactly the column that carried malformed tool-XML into the handoff bug). Defang the
489
+ // delimiters, then collapse whitespace + truncate.
490
+ const text = truncate(neutralizeContextDelimiters(r.prompt_text || '').replace(/\s+/g, ' '), 80);
483
491
  lines.push(`P#${r.id} 💬 ${text}`);
484
492
  }
485
493
  return lines.join('\n');
package/server.mjs CHANGED
@@ -17,7 +17,7 @@ import { resolveAnchorToken, formatAnchorError, resolveQueryAnchor, fetchRecentT
17
17
  import { buildSearchFtsQuery, parseDateBounds, parseDuration, coreRunSearchPipeline } from './lib/search-core.mjs';
18
18
  import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
- recoverOrphanedChildren,
20
+ recoverOrphanedChildren, recoverBuriedLessons,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
22
  recoverChildrenOf, hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS,
@@ -1139,6 +1139,10 @@ server.registerTool(
1139
1139
  // resurface unreachable children. Non-destructive — un-hide only, no delete.
1140
1140
  const orphans = recoverOrphanedChildren(db, mctx);
1141
1141
  if (orphans > 0) results.push(`Recovered ${orphans} orphaned compression children`);
1142
+ // Heal lesson rows citation-decay buried at importance 0 (pre floor=1). 0→1 on
1143
+ // lesson-bearing rows only; idempotent no-op once none remain.
1144
+ const lessonsHealed = recoverBuriedLessons(db, mctx);
1145
+ if (lessonsHealed > 0) results.push(`Healed ${lessonsHealed} lesson rows buried at importance 0`);
1142
1146
  }
1143
1147
 
1144
1148
  if (ops.includes('decay')) {
package/source-files.mjs CHANGED
@@ -29,6 +29,12 @@ export const SOURCE_FILES = [
29
29
  'tier.mjs', 'tfidf.mjs',
30
30
  'nlp.mjs', 'synonyms.mjs', 'scoring-sql.mjs', 'stop-words.mjs', 'project-utils.mjs',
31
31
  'secret-scrub.mjs', 'format-utils.mjs', 'hash-utils.mjs', 'bash-utils.mjs',
32
+ // Single source of truth for the CLAUDE_MEM_DIR → data-dir resolver (rejects a
33
+ // stringified "undefined"/"null"/relative env instead of creating a stray dir).
34
+ // Statically imported by schema.mjs / cli.mjs / install.mjs / registry-recommend.mjs
35
+ // AND hook scripts (pre-tool-recall / post-tool-recall / pre-skill-bridge) — ship it
36
+ // or auto-update leaves schema + every hook with ERR_MODULE_NOT_FOUND on each fire.
37
+ 'lib/resolve-data-dir.mjs',
32
38
  // lib/ — statically imported by hook-llm.mjs (activity) + hook-handoff.mjs (git-state, task-reader);
33
39
  // dynamically imported by hook.mjs (startup-dashboard) + mem-cli.mjs (doctor-benchmark, plan-reader).
34
40
  'lib/activity.mjs',