claude-mem-lite 3.38.4 → 3.39.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/cli/activity.mjs +23 -2
- package/commands/bug.md +15 -11
- package/commands/lesson.md +15 -13
- package/hook-llm.mjs +12 -2
- package/hook-optimize.mjs +55 -1
- package/lib/activity.mjs +74 -0
- package/lib/cite-back-hint.mjs +6 -1
- package/lib/save-observation.mjs +26 -0
- package/mem-cli.mjs +28 -7
- package/package.json +1 -1
- package/server.mjs +5 -1
- package/tool-schemas.mjs +10 -0
- package/utils.mjs +7 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.39.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.
|
|
3
|
+
"version": "3.39.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/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`.
|
|
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
|
|
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/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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
46
|
-
--type
|
|
49
|
+
node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<body>" \
|
|
50
|
+
--type bugfix \
|
|
47
51
|
--title "<first 60 chars of description>" \
|
|
48
|
-
--
|
|
49
|
-
[--
|
|
52
|
+
--lesson "<description>" \
|
|
53
|
+
[--files f1,f2,...] \
|
|
50
54
|
--importance <1|2|3>
|
|
51
55
|
|
|
52
|
-
Confirm to user with: `Bug logged:
|
|
56
|
+
Confirm to user with: `Bug logged: #<id>`.
|
|
53
57
|
|
|
54
58
|
## Examples
|
|
55
59
|
|
package/commands/lesson.md
CHANGED
|
@@ -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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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 —
|
|
37
|
-
|
|
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
|
|
41
|
-
--type
|
|
42
|
+
node ${CLAUDE_PLUGIN_ROOT}/cli.mjs save "<full text>" \
|
|
43
|
+
--type discovery \
|
|
42
44
|
--title "<first 60 chars of text>" \
|
|
43
|
-
--
|
|
44
|
-
[--
|
|
45
|
+
--lesson "<full text>" \
|
|
46
|
+
[--files f1,f2,...] \
|
|
45
47
|
--importance <1|2|3>
|
|
46
48
|
|
|
47
|
-
Confirm to user with: `Lesson saved:
|
|
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
|
-
|
|
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,13 @@
|
|
|
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
|
+
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
11
|
+
|
|
12
|
+
// Observation types (mirrors the observations.type enum) — events carry a wider
|
|
13
|
+
// set, so promotion maps the extras (lesson/bug/observation) onto valid obs types.
|
|
14
|
+
const OBS_TYPES = new Set(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
15
|
+
const EVENT_TO_OBS_TYPE = { bug: 'bugfix', lesson: 'discovery', observation: 'discovery' };
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
18
|
* Canonical event_type enum — mirrors the events.event_type CHECK constraint.
|
|
@@ -118,3 +125,70 @@ export function recentEvents(db, { project, type = null, limit = 20 } = {}) {
|
|
|
118
125
|
const params = type ? [project, type, limit] : [project, limit];
|
|
119
126
|
return db.prepare(sql).all(...params);
|
|
120
127
|
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* P2(b) one-time backfill: promote insight-bearing events (body present,
|
|
131
|
+
* importance>=minImportance) into searchable observations. mem_search / passive
|
|
132
|
+
* injection never read the events table, so explicit /bug /lesson history and
|
|
133
|
+
* high-value auto-captured events were unfindable. Each promoted event is marked
|
|
134
|
+
* (superseded_at_epoch + superseded_by_id → the new obs id) so re-runs are
|
|
135
|
+
* idempotent. The source event row is kept (activity log intact), just retired.
|
|
136
|
+
*
|
|
137
|
+
* @returns {{eligible:number, promoted:number, deduped:number}}
|
|
138
|
+
*/
|
|
139
|
+
export function promoteInsightEvents(db, { project = null, minImportance = 2, execute = false, limit = 5000 } = {}) {
|
|
140
|
+
const projClause = project ? 'AND project = ?' : '';
|
|
141
|
+
// Exclude low-signal titles (Modified X / Error while … / Worked on X / raw tool
|
|
142
|
+
// logs): those are the activity-log noise the events split was meant to contain,
|
|
143
|
+
// so "lesson-bearing" must not sweep them into search. Same canonical clause the
|
|
144
|
+
// re-enrich candidate query uses.
|
|
145
|
+
const sql = `
|
|
146
|
+
SELECT id, project, event_type, title, body, file_paths, importance, created_at_epoch
|
|
147
|
+
FROM events
|
|
148
|
+
WHERE body IS NOT NULL AND TRIM(body) != '' AND importance >= ?
|
|
149
|
+
AND superseded_at_epoch IS NULL
|
|
150
|
+
AND ${notLowSignalTitleClause('')}
|
|
151
|
+
${projClause}
|
|
152
|
+
ORDER BY created_at_epoch DESC LIMIT ?
|
|
153
|
+
`;
|
|
154
|
+
const rows = project ? db.prepare(sql).all(minImportance, project, limit) : db.prepare(sql).all(minImportance, limit);
|
|
155
|
+
if (!execute) return { eligible: rows.length, promoted: 0, deduped: 0, skipped: 0 };
|
|
156
|
+
|
|
157
|
+
// Mark the source event promoted. Only `superseded_at_epoch` — NOT
|
|
158
|
+
// superseded_by_id: that column is a self-FK (REFERENCES events(id)), so storing
|
|
159
|
+
// an observation id there fails a FOREIGN KEY constraint on the real DB (the
|
|
160
|
+
// :memory: test had FK enforcement off, which masked it). The marker alone gives
|
|
161
|
+
// idempotency (superseded_at_epoch IS NULL selects unpromoted rows).
|
|
162
|
+
const mark = db.prepare('UPDATE events SET superseded_at_epoch = ? WHERE id = ?');
|
|
163
|
+
// Each event's (observation insert + mark) is one transaction so a failure can
|
|
164
|
+
// never leave an orphan observation with an unmarked source event.
|
|
165
|
+
const promoteOne = db.transaction((ev, type, files) => {
|
|
166
|
+
const r = saveObservation(db, {
|
|
167
|
+
content: ev.body,
|
|
168
|
+
title: ev.title || undefined,
|
|
169
|
+
type,
|
|
170
|
+
importance: ev.importance,
|
|
171
|
+
project: ev.project,
|
|
172
|
+
files,
|
|
173
|
+
// The event body IS the insight → also seed lesson_learned (capped like the
|
|
174
|
+
// manual-save contract) so it lands in the high-weight FTS field.
|
|
175
|
+
lesson_learned: ev.body.slice(0, 500),
|
|
176
|
+
now: new Date(ev.created_at_epoch),
|
|
177
|
+
});
|
|
178
|
+
mark.run(Date.now(), ev.id);
|
|
179
|
+
return r;
|
|
180
|
+
});
|
|
181
|
+
let promoted = 0;
|
|
182
|
+
let deduped = 0;
|
|
183
|
+
let skipped = 0;
|
|
184
|
+
for (const ev of rows) {
|
|
185
|
+
const type = OBS_TYPES.has(ev.event_type) ? ev.event_type : (EVENT_TO_OBS_TYPE[ev.event_type] || 'discovery');
|
|
186
|
+
let files = [];
|
|
187
|
+
try { const p = JSON.parse(ev.file_paths || '[]'); if (Array.isArray(p)) files = p; } catch { /* keep [] */ }
|
|
188
|
+
try {
|
|
189
|
+
const r = promoteOne(ev, type, files);
|
|
190
|
+
if (r.kind === 'saved') promoted++; else deduped++;
|
|
191
|
+
} catch { skipped++; } // a single bad event must not abort the whole backfill
|
|
192
|
+
}
|
|
193
|
+
return { eligible: rows.length, promoted, deduped, skipped };
|
|
194
|
+
}
|
package/lib/cite-back-hint.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/save-observation.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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.
|
|
3
|
+
"version": "3.39.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",
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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;
|