claude-mem-lite 3.33.1 → 3.35.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.33.1",
13
+ "version": "3.35.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.33.1",
3
+ "version": "3.35.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/cli/common.mjs CHANGED
@@ -89,6 +89,72 @@ export function rejectBareStringFlags(flags, keys) {
89
89
  return false;
90
90
  }
91
91
 
92
+ // ─── Unknown-flag typo guard ─────────────────────────────────────────────────
93
+
94
+ /**
95
+ * Union of every flag name any CLI command reads (parseArgs silently drops the rest).
96
+ * Over-inclusive BY DESIGN: a flag listed here that a given command ignores just means
97
+ * "no typo warning for it" — harmless. The only real risk is OMITTING a valid flag, and
98
+ * the edit-distance gate in suggestUnknownFlags() makes even that non-fatal (a distinct
99
+ * real flag rarely lands within distance 2 of another). Add new flags here when adding
100
+ * them to a command — same maintenance contract as JSON_SUPPORTED_CMDS in mem-cli.
101
+ */
102
+ export const KNOWN_CLI_FLAGS = new Set([
103
+ 'after', 'age-days', 'all', 'anchor', 'batch', 'before', 'benchmark', 'body', 'branch',
104
+ 'capability-summary', 'category', 'closes-deferred', 'concepts', 'confirm', 'days', 'deep',
105
+ 'detail', 'domain-tags', 'dry-run', 'enrich', 'execute', 'fields', 'file', 'files', 'floors',
106
+ 'force', 'format', 'from', 'has', 'help', 'importance', 'include-compressed', 'include-noise',
107
+ 'intent-tags', 'invocation-name', 'json', 'key', 'keywords', 'lesson', 'lesson-learned', 'limit',
108
+ 'local-path', 'margins', 'max', 'memdir', 'merge-ids', 'metrics', 'name', 'narrative', 'no-deep',
109
+ 'offset', 'ops', 'or', 'out', 'priority', 'project', 'quality', 'query', 'reason', 'repo-url',
110
+ 'rerank', 'resource-type', 'retain-days', 'retry', 'run', 'run-all', 'scope', 'session-audit',
111
+ 'sidechain', 'since', 'sort', 'source', 'status', 'sweep', 'task', 'tech-stack', 'tier', 'title',
112
+ 'to', 'trigger-patterns', 'type', 'use-cases', 'verbose',
113
+ ]);
114
+
115
+ /** Levenshtein distance, early-exit past `max` (cheap enough for a handful of flags). */
116
+ function editDistance(a, b, max = 2) {
117
+ const m = a.length, n = b.length;
118
+ if (Math.abs(m - n) > max) return max + 1;
119
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
120
+ for (let i = 1; i <= m; i++) {
121
+ const cur = [i];
122
+ let rowMin = i;
123
+ for (let j = 1; j <= n; j++) {
124
+ const d = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0));
125
+ cur[j] = d;
126
+ if (d < rowMin) rowMin = d;
127
+ }
128
+ if (rowMin > max) return max + 1; // whole row already past budget → give up
129
+ prev = cur;
130
+ }
131
+ return prev[n];
132
+ }
133
+
134
+ /**
135
+ * Detect likely-typo flags: names NOT in KNOWN_CLI_FLAGS but within edit distance 2 of
136
+ * a known flag. parseArgs silently drops unknown flags, so `save --improtance 3` used to
137
+ * persist the DEFAULT importance and `recent --projcte X` silently queried the inferred
138
+ * project — a typo produced a wrong result with zero signal. Returns [{flag, suggestion}].
139
+ * Unknown flags with NO close match are omitted: they may be a valid flag we didn't
140
+ * catalog, so silence beats a false alarm. Warning-only by contract — never fails.
141
+ * @param {object} flags Parsed flags from parseArgs.
142
+ * @returns {Array<{flag: string, suggestion: string}>}
143
+ */
144
+ export function suggestUnknownFlags(flags) {
145
+ const result = [];
146
+ for (const key of Object.keys(flags)) {
147
+ if (!key || KNOWN_CLI_FLAGS.has(key)) continue;
148
+ let best = null, bestDist = 3;
149
+ for (const known of KNOWN_CLI_FLAGS) {
150
+ const d = editDistance(key, known);
151
+ if (d < bestDist) { bestDist = d; best = known; }
152
+ }
153
+ if (best && bestDist <= 2) result.push({ flag: key, suggestion: best });
154
+ }
155
+ return result;
156
+ }
157
+
92
158
  // ─── Time Formatting ─────────────────────────────────────────────────────────
93
159
 
94
160
  /** "just now" / "5m ago" / "3h ago" / "2d ago" relative to now. */
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
@@ -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
 
@@ -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,
@@ -37,7 +37,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
37
37
  // v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
38
38
  // router + remaining-command bodies during the incremental split. Future work:
39
39
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
40
- import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
40
+ import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
41
41
  import { saveObservation } from './lib/save-observation.mjs';
42
42
  import { rebuildObservationDerived } from './lib/observation-write.mjs';
43
43
  import { recallByFile } from './lib/recall-core.mjs';
@@ -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')) {
@@ -3007,6 +3011,15 @@ export async function run(argv) {
3007
3011
  return;
3008
3012
  }
3009
3013
 
3014
+ // Typo guard: parseArgs silently DROPS unknown flags, so `save --improtance 3` used to
3015
+ // persist the DEFAULT importance and `recent --projcte X` silently queried the inferred
3016
+ // project — a misspelled flag changed results with zero signal. Warn (stderr, non-fatal)
3017
+ // when a flag looks like a misspelling of a real one; stdout + exit code stay untouched,
3018
+ // so JSON/text consumers are unaffected. Mirrors the unknown-COMMAND suggester in cli.mjs.
3019
+ for (const { flag, suggestion } of suggestUnknownFlags(parseArgs(cmdArgs).flags)) {
3020
+ process.stderr.write(`[mem] Unknown flag --${flag}; did you mean --${suggestion}?\n`);
3021
+ }
3022
+
3010
3023
  // adopt / unadopt do pure filesystem work on ~/.claude/projects/<encoded>/memory/ —
3011
3024
  // no DB needed. Route them before ensureDb() so an unbootable DB doesn't block.
3012
3025
  if (cmd === 'adopt') { cmdAdopt(cmdArgs); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.33.1",
3
+ "version": "3.35.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/project-utils.mjs CHANGED
@@ -32,6 +32,17 @@ export function resolveProject(db, name) {
32
32
  ).get(`%--${name}`);
33
33
  if (suffixed) { _cache.set(name, suffixed.project); return suffixed.project; }
34
34
 
35
+ // 1.5) Exact-name match: a project literally named "p" (e.g. inferProject() at a
36
+ // filesystem-root cwd yields no "--", or a manually-saved bare name). MUST beat the
37
+ // fuzzy prefix/substring fallbacks below — otherwise `%p%` matches every "projects--*"
38
+ // row and ORDER BY COUNT(*) returns the biggest UNRELATED project, making the exact
39
+ // project permanently unreachable via --project. Ranks below step 1 only, preserving
40
+ // the documented "prefer canonical parent--name over a stray short name" intent.
41
+ const exact = db.prepare(
42
+ 'SELECT project FROM observations WHERE project = ? LIMIT 1'
43
+ ).get(name);
44
+ if (exact) { _cache.set(name, exact.project); return exact.project; }
45
+
35
46
  // 2) Prefix-in-suffix match: "code-graph" → "projects--code-graph-mcp"
36
47
  const prefixed = db.prepare(
37
48
  'SELECT project FROM observations WHERE project LIKE ? GROUP BY project ORDER BY COUNT(*) DESC LIMIT 1'
@@ -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(() => {});
@@ -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')) {