claude-mem-lite 3.38.3 → 3.39.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.38.3",
13
+ "version": "3.39.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.38.3",
3
+ "version": "3.39.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/README.md CHANGED
@@ -126,7 +126,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
126
126
  - **Cross-session handoff** -- Captures session state (request, completed work, next steps, key files) on `/clear` or `/exit`, then injects context when the next session detects continuation intent via explicit keywords or FTS5 term overlap
127
127
  - **Git-SHA continuation anchor** (v2.31.0) -- Handoff rows include `git_sha_at_handoff`; any handoff matching the current `HEAD` counts as continuation regardless of TTL. Code state is a stronger continuation signal than wall-clock time
128
128
  - **Startup dashboard** (v2.31.0) -- SessionStart hook aggregates `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + most-recent exit handoff + recent event count into a single structured block injected via `hookSpecificOutput.additionalContext`
129
- - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. Slash commands: `/lesson`, `/bug`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic
129
+ - **Activity namespace** (v2.31.0) -- Dedicated `events` table + FTS5 for non-memdir types (`bugfix`, `lesson`, `bug`, `discovery`, `refactor`, `feature`, `observation`, `decision`) that don't compete with `WHAT_NOT_TO_SAVE` semantics on the observations table. CLI: `claude-mem-lite activity save|search|recent|show`. `hook-llm` routes non-memdir summary types through `persistHaikuSummary` so upgrades from observations→events are atomic. (v3.39: the `/lesson` and `/bug` slash commands were redirected from this events table to searchable **observations** — `mem_search` never read the events table, so explicit saves were unfindable; the events table remains the auto-capture activity log.)
130
130
  - **In-place observation updates** -- `mem_update` tool modifies existing observations atomically (field update + FTS text rebuild + vector re-computation in one transaction), preserving original IDs and references
131
131
  - **Bulk export** -- `mem_export` tool exports observations as JSON or JSONL, with project/type/date filtering and 1000-row pagination cap with batch guidance
132
132
  - **FTS integrity management** -- `mem_fts_check` tool verifies FTS5 index health or rebuilds indexes on demand, useful after database recovery or when search results seem wrong
package/README.zh-CN.md CHANGED
@@ -99,7 +99,7 @@
99
99
  - **插件缓存 hook 自愈** -- Claude Code runtime 从 `~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json` 读取插件 hook,而非 marketplace 源。当 `install.mjs` 写入 `settings.json` 的 hooks 与残留 cache `hooks.json` 同时存在(例如曾装过 marketplace 版本,或插件被 Claude Code 自动升级重建 cache),runtime 会注册两套 hook → 每次 SessionStart / UserPromptSubmit 都触发两份。`install.mjs` 和 `hook-update.mjs` 现在会清理每个 cache 版本目录下的 `hooks.json`;`hook.mjs session-start` 每次启动自愈(通过 `hasInstallManagedHooks` 门控,不影响纯插件模式用户);`install.mjs status` 会报告 cache 污染状况(自 v2.31.1 / v2.31.2 起)。
100
100
  - **Git-SHA 延续锚点**(v2.31.0)-- handoff 记录包含 `git_sha_at_handoff` 字段,任何匹配当前 `HEAD` 的 handoff 都视为延续会话,不受 TTL 限制。代码状态比时钟时间更能反映上下文延续。
101
101
  - **启动面板**(v2.31.0)-- SessionStart hook 将 `git status` + `~/.claude/tasks/*.json` + `~/.claude/plans/*.md` + 最近 /exit 交接 + 最近事件数聚合为一个结构化块,通过 `hookSpecificOutput.additionalContext` 注入。
102
- - **活动命名空间**(v2.31.0)-- 为非 memdir 类型(`bugfix` / `lesson` / `bug` / `discovery` / `refactor` / `feature` / `observation` / `decision`)启用独立的 `events` 表 + FTS5,与 observations 表的 `WHAT_NOT_TO_SAVE` 语义解耦。CLI:`claude-mem-lite activity save|search|recent|show`;斜杠命令:`/lesson`、`/bug`。`hook-llm` 通过 `persistHaikuSummary` 路由非 memdir 摘要,observations → events 升级路径是事务原子的。
102
+ - **活动命名空间**(v2.31.0)-- 为非 memdir 类型(`bugfix` / `lesson` / `bug` / `discovery` / `refactor` / `feature` / `observation` / `decision`)启用独立的 `events` 表 + FTS5,与 observations 表的 `WHAT_NOT_TO_SAVE` 语义解耦。CLI:`claude-mem-lite activity save|search|recent|show`。`hook-llm` 通过 `persistHaikuSummary` 路由非 memdir 摘要,observations → events 升级路径是事务原子的。(v3.39:`/lesson`、`/bug` 斜杠命令已从此 events 表重定向到可搜索的 **observations**——`mem_search` 从不读 events 表,显式保存因此搜不到;events 表仍作自动捕获活动日志。)
103
103
 
104
104
  ## 平台支持
105
105
 
package/adopt-content.mjs CHANGED
@@ -4,10 +4,13 @@
4
4
  // claudemd.mjs primitives so the strings are testable without side effects.
5
5
  //
6
6
  // CURRENT_SENTINEL_VERSION tags the managed block as `<!-- claude-mem-lite:begin
7
- // vN -->`. needsRefresh() only checks for *inequality* against the installed tag,
8
- // so any change triggers an in-place refresh (version change = intended content
9
- // change, overwritten rather than treated as a user edit) — the value need not be
10
- // monotonic. We deliberately use `v1` (not `v2`) so the version digit differs from
7
+ // vN -->`. needsRefresh() (claudemd.mjs) compares the version tag AND the block
8
+ // body AND the detail-doc content ANY of the three differing triggers an
9
+ // in-place refresh (drift = intended content change, overwritten rather than
10
+ // treated as a user edit). So a template edit propagates on the next SessionStart
11
+ // even WITHOUT a version bump; the tag need not be monotonic and, in practice,
12
+ // need not change at all — we keep `v1` across content edits (see mem #8846).
13
+ // We deliberately use `v1` (not `v2`) so the version digit differs from
11
14
  // the sibling code-graph-mcp plugin's `<!-- code-graph-mcp:begin v2 -->` block in
12
15
  // the same CLAUDE.md; the slug already scopes the two independently (claudemd.mjs),
13
16
  // so this is a cosmetic distinguisher, not a functional one. The pre-v3.13 legacy
package/cli/activity.mjs CHANGED
@@ -21,12 +21,12 @@ function formatActivityResults(rows) {
21
21
  export async function cmdActivity(db, args) {
22
22
  const sub = args[0];
23
23
  if (!sub) {
24
- fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show|delete> ...');
24
+ fail('[mem] Usage: claude-mem-lite activity <save|search|recent|show|delete|promote> ...');
25
25
  return;
26
26
  }
27
27
 
28
28
  const { positional, flags } = parseArgs(args.slice(1));
29
- const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES } = await import('../lib/activity.mjs');
29
+ const { saveEvent, searchEvents, recentEvents, getEvent, EVENT_TYPES, promoteInsightEvents } = await import('../lib/activity.mjs');
30
30
  const VALID_EVENT_TYPES = new Set(EVENT_TYPES);
31
31
  const project = flags.project ? resolveProject(db, flags.project) : inferProject();
32
32
 
@@ -172,5 +172,26 @@ export async function cmdActivity(db, args) {
172
172
  return;
173
173
  }
174
174
 
175
+ if (sub === 'promote') {
176
+ // P2(b): promote insight-bearing events (body present, importance>=N) into
177
+ // searchable observations. Preview by default; --execute applies. One-time
178
+ // backfill — the source events are marked promoted (idempotent re-runs).
179
+ const minImp = flags['min-importance'] !== undefined ? parseInt(flags['min-importance'], 10) : 2;
180
+ if (isNaN(minImp) || minImp < 1 || minImp > 3) {
181
+ fail('[mem] activity promote: --min-importance must be 1, 2, or 3.');
182
+ return;
183
+ }
184
+ const projectFilter = flags.project ? project : null;
185
+ const execute = flags.execute === true || flags.execute === 'true';
186
+ const r = promoteInsightEvents(db, { project: projectFilter, minImportance: minImp, execute });
187
+ if (!execute) {
188
+ out(`[mem] Preview: ${r.eligible} insight-bearing event(s) (body + importance>=${minImp})${projectFilter ? ` in ${projectFilter}` : ' across all projects'} would be promoted to searchable observations.`);
189
+ out('[mem] Run with --execute to apply. Source events are kept (marked promoted).');
190
+ return;
191
+ }
192
+ out(`[mem] Promoted ${r.promoted} event(s) to observations${r.deduped ? ` (${r.deduped} already had an equivalent observation)` : ''}.`);
193
+ return;
194
+ }
195
+
175
196
  fail(`[mem] Unknown activity subcommand: ${sub}`);
176
197
  }
package/commands/bug.md CHANGED
@@ -1,14 +1,17 @@
1
1
  ---
2
2
  name: bug
3
- description: "Use when: logging a known bug + repro steps you can't fix right now. Writes to the mem events table (NOT memdir). Skip for bugs you're actively fixing in the current turn — just fix them."
3
+ description: "Use when: logging a known bug + repro steps you can't fix right now. Writes a searchable observation (findable via mem_search + surfaced by recall hooks; still NOT memdir). Skip for bugs you're actively fixing in the current turn — just fix them."
4
4
  ---
5
5
 
6
6
  # /bug
7
7
 
8
- Record a known bug + reproduction steps. Writes to the mem `events` table
9
- with `event_type='bug'`. Does NOT touch memdir. Useful for bugs you can't
10
- fix immediately but want future sessions (or yourself after `/clear`) to
11
- know about and avoid re-investigating.
8
+ Record a known bug + reproduction steps. Writes a searchable **observation**
9
+ (`type=bugfix`, description in `lesson_learned`) so future sessions can find it
10
+ via `mem_search` and the PreToolUse recall hooks warn when the affected files
11
+ are edited. Does NOT touch memdir. Useful for bugs you can't fix immediately
12
+ but want future sessions (or yourself after `/clear`) to know about and avoid
13
+ re-investigating. (Redirected v3.39: was the `events` table, which `mem_search`
14
+ never read.)
12
15
 
13
16
  ## When to use
14
17
 
@@ -40,16 +43,17 @@ Map severity to importance:
40
43
  Build the body as `<description>\n\nRepro:\n<repro-steps>` (or just
41
44
  `<description>` if `--repro` is absent).
42
45
 
43
- Run via Bash:
46
+ Run via Bash — the body is the positional content; the description goes in
47
+ `--lesson` so it lands in the high-weight `lesson_learned` field:
44
48
 
45
- node ${CLAUDE_PLUGIN_ROOT}/cli.mjs activity save \
46
- --type bug \
49
+ node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<body>" \
50
+ --type bugfix \
47
51
  --title "<first 60 chars of description>" \
48
- --body "<body>" \
49
- [--file <path> | --files f1,f2,...] \
52
+ --lesson "<description>" \
53
+ [--files f1,f2,...] \
50
54
  --importance <1|2|3>
51
55
 
52
- Confirm to user with: `Bug logged: activity #<id>`.
56
+ Confirm to user with: `Bug logged: #<id>`.
53
57
 
54
58
  ## Examples
55
59
 
@@ -1,14 +1,17 @@
1
1
  ---
2
2
  name: lesson
3
- description: "Use when: capturing a non-obvious lesson/gotcha/workaround after a tricky fix or surprising behavior. Writes to the mem events table (NOT memdir). Skip for typos, renames, or user-preference rules."
3
+ description: "Use when: capturing a non-obvious lesson/gotcha/workaround after a tricky fix or surprising behavior. Writes a searchable observation (findable via mem_search + surfaced by recall hooks; still NOT memdir/L1 prompt). Skip for typos, renames, or user-preference rules."
4
4
  ---
5
5
 
6
6
  # /lesson
7
7
 
8
- Record a lesson / gotcha / workaround. Writes to the mem `events` table
9
- with `event_type='lesson'`. Does NOT touch memdir so lessons never end up
10
- in the L1 system-prompt memory section and do not conflict with the
11
- `WHAT_NOT_TO_SAVE` semantics sdscc enforces.
8
+ Record a lesson / gotcha / workaround. Writes a searchable **observation**
9
+ (`type=discovery` with the text as `lesson_learned`) so future sessions can
10
+ find it via `mem_search` and the PreToolUse recall hooks surface it on the
11
+ relevant files. Does NOT touch memdir — so lessons never end up in the L1
12
+ system-prompt memory section and do not conflict with the `WHAT_NOT_TO_SAVE`
13
+ semantics sdscc enforces. (Redirected v3.39: was the `events` table, which
14
+ `mem_search` never read.)
12
15
 
13
16
  ## When to use
14
17
 
@@ -33,18 +36,17 @@ Don't use for trivial fixes (typos, renames), for rules that belong in memdir
33
36
 
34
37
  ## Execution
35
38
 
36
- Run via Bash — the CLI takes the lesson text as positional args and stores
37
- the full text as the title (the `activity save` command uses title-as-body
38
- when `--body` is absent):
39
+ Run via Bash — pass the lesson text as the positional content and repeat it in
40
+ `--lesson` so it lands in the high-weight `lesson_learned` field:
39
41
 
40
- node ${CLAUDE_PLUGIN_ROOT}/cli.mjs activity save \
41
- --type lesson \
42
+ node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<full text>" \
43
+ --type discovery \
42
44
  --title "<first 60 chars of text>" \
43
- --body "<full text>" \
44
- [--file <path> | --files f1,f2,...] \
45
+ --lesson "<full text>" \
46
+ [--files f1,f2,...] \
45
47
  --importance <1|2|3>
46
48
 
47
- Confirm to user with: `Lesson saved: activity #<id>`.
49
+ Confirm to user with: `Lesson saved: #<id>`.
48
50
 
49
51
  ## Examples
50
52
 
package/hook-llm.mjs CHANGED
@@ -119,6 +119,17 @@ function buildFtsTextField(obs) {
119
119
  return { conceptsText, factsText, textField: [conceptsText, factsText, aliasesText, bigramText, fallbackText].filter(Boolean).join(' ') };
120
120
  }
121
121
 
122
+ // TF-IDF vector text. Must mirror the FTS-searchable content so the vector arm and
123
+ // the BM25 arm rank on the same signal — including lesson_learned (highest FTS
124
+ // weight) and search_aliases (finding #8: previously omitted, so even with vectors
125
+ // enabled the paraphrase-bridge alias terms were invisible to cosine similarity).
126
+ export function buildVecText(obs) {
127
+ const conceptsText = Array.isArray(obs.concepts) ? obs.concepts.join(' ') : (obs.concepts || '');
128
+ const aliasesText = obs.searchAliases || '';
129
+ return [obs.title || '', obs.narrative || '', conceptsText, obs.lessonLearned || '', aliasesText]
130
+ .filter(Boolean).join(' ');
131
+ }
132
+
122
133
  /**
123
134
  * Save an observation to the database with three-tier dedup.
124
135
  * @returns {number|null} The saved observation ID, or null if deduped.
@@ -256,8 +267,7 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
256
267
  });
257
268
 
258
269
  insertObservationFiles(db, id, obs.files);
259
- const vecText = [obs.title || '', obs.narrative || '', (Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '')].filter(Boolean).join(' ');
260
- insertObservationVector(db, id, vecText);
270
+ insertObservationVector(db, id, buildVecText(obs));
261
271
 
262
272
  return id;
263
273
  })();
package/hook-optimize.mjs CHANGED
@@ -79,6 +79,27 @@ export function rebuildVector(db, obsId, textParts) {
79
79
  */
80
80
  export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
81
81
  const projectClause = project ? 'AND project = ?' : '';
82
+ if (scope === 'aliases') {
83
+ // P1 alias backfill: substantive rows missing search_aliases, REGARDLESS of
84
+ // lesson. Targets lesson-bearing manual saves (mem_save writes no aliases →
85
+ // paraphrase-unfindable) that narrow (needs lesson NULL) and wide (needs
86
+ // lesson NULL) both skip. Idempotent via search_aliases becoming non-null —
87
+ // deliberately NOT gated on optimized_at, so a lesson-less row can still be
88
+ // picked up by wide scope for lesson enrichment afterward.
89
+ const stmt = db.prepare(`
90
+ SELECT id, title, narrative, type, subtitle, concepts, facts, text, search_aliases, project
91
+ FROM observations
92
+ WHERE COALESCE(compressed_into, 0) = 0
93
+ AND superseded_at IS NULL
94
+ AND (search_aliases IS NULL OR search_aliases = '')
95
+ AND LENGTH(COALESCE(narrative, '')) > 100
96
+ AND ${notLowSignalTitleClause('')}
97
+ ${projectClause}
98
+ ORDER BY created_at_epoch DESC
99
+ LIMIT ?
100
+ `);
101
+ return project ? stmt.all(project, limit) : stmt.all(limit);
102
+ }
82
103
  if (scope === 'wide') {
83
104
  const stmt = db.prepare(`
84
105
  SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, project
@@ -126,6 +147,33 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
126
147
  if (!gotSlot) { skipped++; continue; }
127
148
 
128
149
  try {
150
+ if (scope === 'aliases') {
151
+ // Alias-only backfill: generate search_aliases and APPEND them (plus any
152
+ // CJK bigrams) to the EXISTING FTS text. Never rebuild text from
153
+ // concepts/facts (empty on manual saves → would drop the original
154
+ // narrative terms and regress recall) and never touch the user's curated
155
+ // title / narrative / lesson / type / importance.
156
+ const aliasPrompt = `Generate alternative search terms so this memory is findable by paraphrase, synonym, or cross-language queries. Return ONLY valid JSON, no markdown fences.
157
+
158
+ Title: ${truncate(cand.title || '(untitled)', 200)}
159
+ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
160
+
161
+ JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
162
+ Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
163
+ const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: 15000, maxTokens: 300 });
164
+ const aliasArr = parsed && Array.isArray(parsed.search_aliases)
165
+ ? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
166
+ : [];
167
+ if (!aliasArr.length) { skipped++; continue; }
168
+ const searchAliases = aliasArr.slice(0, 6).join(' ');
169
+ const aliasBigrams = cjkBigrams(searchAliases);
170
+ const appendedText = [cand.text || '', searchAliases, aliasBigrams].filter(Boolean).join(' ');
171
+ const safe = scrubRecord('observations', { text: appendedText, search_aliases: searchAliases });
172
+ db.prepare(`UPDATE observations SET search_aliases = ?, text = ? WHERE id = ?`)
173
+ .run(safe.search_aliases, safe.text, cand.id);
174
+ processed++;
175
+ continue;
176
+ }
129
177
  const prompt = `Re-enrich this observation with structured metadata. Return ONLY valid JSON, no markdown fences.
130
178
 
131
179
  Title: ${truncate(cand.title || '(untitled)', 200)}
@@ -780,6 +828,9 @@ export function optimizePreview(db, { project, detail = false } = {}) {
780
828
  // R-7: also report the widened-scope candidate count so users can see how many
781
829
  // bugfix/refactor/feature/decision observations are eligible for lesson backfill.
782
830
  const reenrichWide = findReenrichCandidates(db, 5000, { scope: 'wide', project }).length;
831
+ // P1: alias-backfill eligibility — substantive rows missing search_aliases
832
+ // (incl. lesson-bearing manual saves) that narrow+wide both skip.
833
+ const reenrichAliases = findReenrichCandidates(db, 5000, { scope: 'aliases', project }).length;
783
834
 
784
835
  const concepts = extractUniqueConcepts(db, 500, { project });
785
836
  const normalizeReady = shouldRunNormalize() && concepts.length >= 5;
@@ -794,6 +845,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
794
845
  const result = {
795
846
  reenrich,
796
847
  reenrichWide,
848
+ reenrichAliases,
797
849
  normalize: normalizeReady ? concepts.length : 0,
798
850
  normalizeGateOpen: shouldRunNormalize(),
799
851
  clusterMerge,
@@ -822,8 +874,10 @@ export function optimizePreview(db, { project, detail = false } = {}) {
822
874
  * would silently waste 60% of the requested budget.
823
875
  * @param {number} [opts.maxItems=15] Total item budget across all selected tasks.
824
876
  * @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
825
- * @param {'narrow'|'wide'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
877
+ * @param {'narrow'|'wide'|'aliases'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
826
878
  * 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
879
+ * 'aliases' (P1) backfills search_aliases on substantive alias-less rows regardless
880
+ * of lesson (lesson-bearing manual saves) — adds ONLY aliases, never rewrites content.
827
881
  * @param {string} [opts.project] Filter all tasks to a single project. Opt-in;
828
882
  * absence preserves the prior all-projects default.
829
883
  */
package/lib/activity.mjs CHANGED
@@ -6,6 +6,12 @@
6
6
 
7
7
  import { sanitizeFtsQuery } from '../utils.mjs';
8
8
  import { scrubRecord } from './scrub-record.mjs';
9
+ import { saveObservation } from './save-observation.mjs';
10
+
11
+ // Observation types (mirrors the observations.type enum) — events carry a wider
12
+ // set, so promotion maps the extras (lesson/bug/observation) onto valid obs types.
13
+ const OBS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
14
+ const EVENT_TO_OBS_TYPE = { bug: 'bugfix', lesson: 'discovery', observation: 'discovery' };
9
15
 
10
16
  /**
11
17
  * Canonical event_type enum — mirrors the events.event_type CHECK constraint.
@@ -118,3 +124,51 @@ export function recentEvents(db, { project, type = null, limit = 20 } = {}) {
118
124
  const params = type ? [project, type, limit] : [project, limit];
119
125
  return db.prepare(sql).all(...params);
120
126
  }
127
+
128
+ /**
129
+ * P2(b) one-time backfill: promote insight-bearing events (body present,
130
+ * importance>=minImportance) into searchable observations. mem_search / passive
131
+ * injection never read the events table, so explicit /bug /lesson history and
132
+ * high-value auto-captured events were unfindable. Each promoted event is marked
133
+ * (superseded_at_epoch + superseded_by_id → the new obs id) so re-runs are
134
+ * idempotent. The source event row is kept (activity log intact), just retired.
135
+ *
136
+ * @returns {{eligible:number, promoted:number, deduped:number}}
137
+ */
138
+ export function promoteInsightEvents(db, { project = null, minImportance = 2, execute = false, limit = 5000 } = {}) {
139
+ const projClause = project ? 'AND project = ?' : '';
140
+ const sql = `
141
+ SELECT id, project, event_type, title, body, file_paths, importance, created_at_epoch
142
+ FROM events
143
+ WHERE body IS NOT NULL AND TRIM(body) != '' AND importance >= ?
144
+ AND superseded_at_epoch IS NULL ${projClause}
145
+ ORDER BY created_at_epoch DESC LIMIT ?
146
+ `;
147
+ const rows = project ? db.prepare(sql).all(minImportance, project, limit) : db.prepare(sql).all(minImportance, limit);
148
+ if (!execute) return { eligible: rows.length, promoted: 0, deduped: 0 };
149
+
150
+ const mark = db.prepare('UPDATE events SET superseded_at_epoch = ?, superseded_by_id = ? WHERE id = ?');
151
+ let promoted = 0;
152
+ let deduped = 0;
153
+ for (const ev of rows) {
154
+ const type = OBS_TYPES.has(ev.event_type) ? ev.event_type : (EVENT_TO_OBS_TYPE[ev.event_type] || 'discovery');
155
+ let files = [];
156
+ try { const p = JSON.parse(ev.file_paths || '[]'); if (Array.isArray(p)) files = p; } catch { /* keep [] */ }
157
+ const r = saveObservation(db, {
158
+ content: ev.body,
159
+ title: ev.title || undefined,
160
+ type,
161
+ importance: ev.importance,
162
+ project: ev.project,
163
+ files,
164
+ // The event body IS the insight → also seed lesson_learned (capped like the
165
+ // manual-save contract) so it lands in the high-weight FTS field.
166
+ lesson_learned: ev.body.slice(0, 500),
167
+ now: new Date(ev.created_at_epoch),
168
+ });
169
+ const linkId = r.kind === 'saved' ? r.id : r.existingId;
170
+ mark.run(Date.now(), linkId, ev.id);
171
+ if (r.kind === 'saved') promoted++; else deduped++;
172
+ }
173
+ return { eligible: rows.length, promoted, deduped };
174
+ }
@@ -125,6 +125,11 @@ function cooldownPathFor(sessionId, runtimeDir) {
125
125
  // turning per-episode hints into cross-session pressure.
126
126
  const UNSAVED_BUGFIX_LITERAL = 'Unsaved bugfix-shape';
127
127
  const ACTIVITY_SAVE_LESSON_RE = /activity\s+save\s+--type\s+(lesson|bugfix)\b/;
128
+ // P2(a): /bug and /lesson were redirected from `activity save` (events) to
129
+ // `cli.mjs save … --lesson` (searchable observations). Recognize that shape too,
130
+ // or the unsaved-bugfix nudge over-fires after an explicit save. Anchored on the
131
+ // mem CLI + the --lesson flag both skills always pass.
132
+ const OBS_INSIGHT_SAVE_RE = /(?:cli\.mjs|claude-mem-lite)['"]?\s+save\b[^\n]*--lesson(?:-learned)?\b/;
128
133
  const MEM_SAVE_TOOL_NAMES = new Set([
129
134
  'mem_save',
130
135
  'mcp__claude_mem_lite__mem_save',
@@ -165,7 +170,7 @@ export function countUnsavedBugfixShape(transcriptPath) {
165
170
  }
166
171
  if (block.name === 'Bash') {
167
172
  const cmd = block.input?.command || '';
168
- if (ACTIVITY_SAVE_LESSON_RE.test(cmd)) saved++;
173
+ if (ACTIVITY_SAVE_LESSON_RE.test(cmd) || OBS_INSIGHT_SAVE_RE.test(cmd)) saved++;
169
174
  }
170
175
  }
171
176
  }
@@ -119,6 +119,31 @@ export function saveObservation(db, params) {
119
119
  });
120
120
  const savedId = saveTx();
121
121
 
122
+ // P4 explicit supersession: tombstone + link prior observations this save
123
+ // overturns. Only same-project, currently-live rows are eligible — never
124
+ // tombstone another project's memory or re-stamp an already-superseded row —
125
+ // and never supersede the row we just wrote. superseded_at drops the row out of
126
+ // live search (all queries filter superseded_at IS NULL); superseded_by records
127
+ // WHICH observation replaced it (the missing link in finding #4). The column
128
+ // already exists (schema.mjs), so no migration is required.
129
+ let supersededIds = [];
130
+ const requested = Array.isArray(params.supersedes) ? params.supersedes : [];
131
+ const ids = [...new Set(
132
+ requested.map(Number).filter((n) => Number.isInteger(n) && n > 0 && n !== savedId)
133
+ )];
134
+ if (ids.length > 0) {
135
+ const ph = ids.map(() => '?').join(',');
136
+ const eligible = db.prepare(
137
+ `SELECT id FROM observations WHERE id IN (${ph}) AND project = ? AND superseded_at IS NULL`
138
+ ).all(...ids, project).map((r) => r.id);
139
+ if (eligible.length > 0) {
140
+ const ph2 = eligible.map(() => '?').join(',');
141
+ db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = ? WHERE id IN (${ph2})`)
142
+ .run(now.getTime(), savedId, ...eligible);
143
+ supersededIds = eligible;
144
+ }
145
+ }
146
+
122
147
  return {
123
148
  kind: 'saved',
124
149
  id: savedId,
@@ -126,5 +151,6 @@ export function saveObservation(db, params) {
126
151
  project,
127
152
  title: safeTitle,
128
153
  lessonCaptured: Boolean(safeLesson),
154
+ supersededIds,
129
155
  };
130
156
  }
package/mem-cli.mjs CHANGED
@@ -747,7 +747,7 @@ function cmdSave(db, args) {
747
747
  const { positional, flags } = parseArgs(args);
748
748
  const text = positional.join(' ');
749
749
  if (!text.trim()) {
750
- fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42]');
750
+ fail('[mem] Usage: claude-mem-lite save "<text>" [--type T] [--title T] [--importance N] [--project P] [--files f1,f2] [--lesson T] [--closes-deferred 1,D#42] [--supersedes 8754,8771]');
751
751
  return;
752
752
  }
753
753
 
@@ -804,6 +804,20 @@ function cmdSave(db, args) {
804
804
  }
805
805
  }
806
806
 
807
+ // --supersedes: comma-separated observation ids this save overturns. On save they
808
+ // are tombstoned (drop out of live search) + linked (superseded_by = the new id).
809
+ // Only same-project live rows are affected (enforced in saveObservation).
810
+ let supersedesIds = null;
811
+ if (flags.supersedes !== undefined && flags.supersedes !== false) {
812
+ const raw = String(flags.supersedes);
813
+ supersedesIds = raw.split(',').map(t => t.trim()).filter(Boolean)
814
+ .map(t => parseInt(t, 10)).filter(n => Number.isInteger(n) && n > 0);
815
+ if (supersedesIds.length === 0) {
816
+ fail('[mem] --supersedes requires at least one positive observation id (e.g. --supersedes 8754,8771)');
817
+ return;
818
+ }
819
+ }
820
+
807
821
  let result;
808
822
  let closesIds = null;
809
823
  try {
@@ -816,6 +830,7 @@ function cmdSave(db, args) {
816
830
  project,
817
831
  files: saveFiles,
818
832
  lesson_learned: rawLesson,
833
+ supersedes: supersedesIds || undefined,
819
834
  });
820
835
  // Skip closure on dedup short-circuit — the obs row already exists, so
821
836
  // the deferred item should NOT be re-closed by a duplicate save call.
@@ -847,7 +862,10 @@ function cmdSave(db, args) {
847
862
  const closedNote = closesIds && closesIds.length > 0
848
863
  ? ` Closed: ${closesIds.map(i => `D#${i}`).join(', ')}.`
849
864
  : '';
850
- out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}`);
865
+ const supersededNote = result.supersededIds && result.supersededIds.length > 0
866
+ ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
867
+ : '';
868
+ out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}`);
851
869
  }
852
870
 
853
871
  // ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
@@ -2634,7 +2652,10 @@ Commands:
2634
2652
  --run-all Execute bypassing gates
2635
2653
  --task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
2636
2654
  --max N Max items per task (1-100, default 15)
2637
- --scope S re-enrich scope: narrow (default) or wide
2655
+ --scope S re-enrich scope: narrow (default) | wide | aliases
2656
+ (aliases: backfill search_aliases on substantive rows that
2657
+ lack them — incl. lesson-bearing manual saves — adds ONLY
2658
+ aliases, never rewrites title/narrative/lesson)
2638
2659
  --project P Limit to a single project (.|current = inferProject())
2639
2660
  --verbose / -v Preview also dumps cluster contents + re-enrich samples
2640
2661
 
@@ -2965,8 +2986,8 @@ async function cmdOptimize(db, args) {
2965
2986
  let reenrichScope = 'narrow';
2966
2987
  if (scopeIdx >= 0 && args[scopeIdx + 1] !== undefined) {
2967
2988
  const raw = args[scopeIdx + 1];
2968
- if (raw !== 'narrow' && raw !== 'wide') {
2969
- fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide`);
2989
+ if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases') {
2990
+ fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases`);
2970
2991
  return;
2971
2992
  }
2972
2993
  reenrichScope = raw;
@@ -2985,7 +3006,7 @@ async function cmdOptimize(db, args) {
2985
3006
  const preview = optimizePreview(db, { project, detail: verbose });
2986
3007
  out('[mem] 🔍 LLM Optimization Preview:');
2987
3008
  if (project) out(` Project filter: ${project}`);
2988
- out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}`);
3009
+ out(` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}`);
2989
3010
  out(` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`);
2990
3011
  out(` Cluster-merge: ${preview.clusterMerge} clusters`);
2991
3012
  out(` Smart-compress: ${preview.smartCompress} clusters`);
@@ -3019,7 +3040,7 @@ async function cmdOptimize(db, args) {
3019
3040
  return;
3020
3041
  }
3021
3042
 
3022
- out(`[mem] Running LLM optimization${reenrichScope === 'wide' ? ' (scope: wide)' : ''}${project ? ` (project: ${project})` : ''}...`);
3043
+ out(`[mem] Running LLM optimization${reenrichScope !== 'narrow' ? ` (scope: ${reenrichScope})` : ''}${project ? ` (project: ${project})` : ''}...`);
3023
3044
  const results = await optimizeRun(db, { tasks, maxItems, force: runAll, reenrichScope, project });
3024
3045
 
3025
3046
  if (results.reenrich) out(` Re-enrich: ${results.reenrich.processed || 0} processed, ${results.reenrich.skipped || 0} skipped`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.38.3",
3
+ "version": "3.39.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",
@@ -32,7 +32,7 @@ const INSTRUCTIONS_BASE = [
32
32
  ` ${CLI_INVOKE} get 42,43 — full details by ID`,
33
33
  ` ${CLI_INVOKE} timeline --anchor 42 — chronological context`,
34
34
  '',
35
- 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline. If already loaded, call directly (warm server, fastest path). In tool-heavy sessions these are deferred behind ToolSearch — if using one would cost a ToolSearch load first, run the Bash CLI above instead: one call, not two. Neither needs a PATH/CLI install.',
35
+ 'MCP tools: mem_search, mem_recent, mem_save, mem_get, mem_recall, mem_timeline (plus mem_defer/mem_defer_list/mem_defer_drop for cross-session TODOs). If already loaded, call directly (warm server, fastest path). In tool-heavy sessions these are deferred behind ToolSearch — if using one would cost a ToolSearch load first, run the Bash CLI above instead: one call, not two. Neither needs a PATH/CLI install.',
36
36
  'mem_save: Save non-obvious insights (bugfix lessons, architecture decisions).',
37
37
  'Search tips: short keywords (2-3 words), filter with obs_type when relevant.',
38
38
  ];
@@ -46,6 +46,7 @@ const INSTRUCTIONS_VERBOSE = [
46
46
  ' • Starting new feature → mem_search(query="feature area") for prior art & patterns',
47
47
  ' • After fixing a tricky bug → mem_save(type="bugfix", lesson_learned="root cause & fix")',
48
48
  ' • After architecture decision → mem_save(type="decision", lesson_learned="rationale")',
49
+ ' • Deferring work to a future session → mem_defer(title, priority, detail); when fixed, mem_save(..., closes_deferred=[N])',
49
50
  ' • Hook-injected context mentions #ID → mem_get(ids=[ID]) for full details',
50
51
  '',
51
52
  'Decision rules (use INSTEAD OF multi-step search):',
package/server.mjs CHANGED
@@ -710,6 +710,7 @@ server.registerTool(
710
710
  project,
711
711
  files: args.files || [],
712
712
  lesson_learned: args.lesson_learned,
713
+ supersedes: args.supersedes,
713
714
  });
714
715
  if (r.kind === 'duplicate') return r; // dedup short-circuits BEFORE resolver — replay is idempotent
715
716
  // Resolve INSIDE tx + after dedup check so duplicate replays don't throw on
@@ -738,7 +739,10 @@ server.registerTool(
738
739
  const closedNote = closesIds && closesIds.length > 0
739
740
  ? ` Closed deferred: ${closesIds.map(i => `D#${i}`).join(', ')}.`
740
741
  : '';
741
- return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}` }] };
742
+ const supersededNote = result.supersededIds && result.supersededIds.length > 0
743
+ ? ` Superseded: ${result.supersededIds.map(i => `#${i}`).join(', ')}.`
744
+ : '';
745
+ return { content: [{ type: 'text', text: `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}` }] };
742
746
  })
743
747
  );
744
748
 
package/tool-schemas.mjs CHANGED
@@ -171,6 +171,15 @@ const coerceDeferredTokens = z.preprocess(
171
171
  ])).min(1).max(20)
172
172
  );
173
173
 
174
+ // Coerce supersedes input — array of positive observation ids (accept numeric
175
+ // strings from MCP bridges that JSON-stringify ints). Empty/other shapes reject.
176
+ const coerceSupersedes = z.preprocess(
177
+ (v) => (Array.isArray(v)
178
+ ? v.map(x => (typeof x === 'string' && /^\d+$/.test(x.trim()) ? parseInt(x.trim(), 10) : x))
179
+ : v),
180
+ z.array(z.number().int().positive()).min(1).max(20)
181
+ );
182
+
174
183
  export const memSaveSchema = {
175
184
  content: z.string().min(1).max(50000).describe('Memory content to save'),
176
185
  title: z.string().optional().describe('Short title'),
@@ -180,6 +189,7 @@ export const memSaveSchema = {
180
189
  files: coerceStringArray.optional().describe('File paths associated with this observation'),
181
190
  lesson_learned: z.string().max(500).optional().describe('Key lesson or takeaway (for bugfix: root cause & fix; for decision: rationale)'),
182
191
  closes_deferred: coerceDeferredTokens.optional().describe('Close one or more deferred_work items in the same project. Mixed array: bare integer = ordinal-within-project, "D#<n>" string = raw id. Transactional with the obs insert — a single invalid id rolls back the whole save.'),
192
+ supersedes: coerceSupersedes.optional().describe('Observation ids (same project) that this save overturns. They are marked superseded — dropped from live search — and linked to the new row (superseded_by). Use ONLY when this genuinely replaces a prior conclusion; do NOT use for merely-related or updated-but-still-valid memories.'),
183
193
  };
184
194
 
185
195
  export const memStatsSchema = {
package/utils.mjs CHANGED
@@ -122,8 +122,13 @@ export function computeRuleImportance(episode) {
122
122
  lastWasError = entry.isError || sig?.isError;
123
123
 
124
124
  if (sig?.isError && (sig?.isTest || sig?.isBuild)) { importance = 3; break; }
125
- if (files.some(f => /\.(env|pem|key)$|\/auth\.|\/credential|\/password/i.test(f))) { importance = 3; break; }
126
- if (files.some(f => /migration|schema\.|prisma|alembic/i.test(f))) { importance = 3; break; }
125
+ // Sensitive-file critical only when the file was EDITED, not merely read or
126
+ // referenced in a bash command (finding #7): reading auth.js / .env / schema.mjs
127
+ // incidentally during an unrelated task must not promote the whole memory to
128
+ // imp=3 and outrank genuine memories in top-K injection.
129
+ const isEdit = EDIT_TOOLS.has(entry.tool);
130
+ if (isEdit && files.some(f => /\.(env|pem|key)$|\/auth\.|\/credential|\/password/i.test(f))) { importance = 3; break; }
131
+ if (isEdit && files.some(f => /migration|schema\.|prisma|alembic/i.test(f))) { importance = 3; break; }
127
132
  if (sig?.isError && importance < 2) importance = 2;
128
133
  if (sig?.isGit && importance < 2) importance = 2;
129
134
  if (sig?.isDeploy && importance < 2) importance = 2;