claude-mem-lite 2.85.0 → 2.87.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": "2.85.0",
13
+ "version": "2.87.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. Alternative to claude-mem with 600x lower cost."
16
16
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.85.0",
3
+ "version": "2.87.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. Alternative to claude-mem with 600x lower cost.",
5
5
  "author": {
6
6
  "name": "sdsrss"
package/README.md CHANGED
@@ -105,7 +105,7 @@ How claude-mem-lite differs from the major neighbors in the LLM-memory space (ve
105
105
  - **Resource registry** -- Indexes installed skills and agents with FTS5 search, composite scoring, and invocation tracking; searchable via `mem_registry` MCP tool
106
106
  - **Unified resource discovery** -- Shared filesystem traversal layer (`resource-discovery.mjs`) used by both runtime scanner and offline indexer, supporting flat directories, plugin nesting, and loose `.md` files
107
107
  - **Domain synonym expansion** -- Registry search queries expand to domain synonyms (e.g., "fix" → debug, bugfix, troubleshoot, diagnose, repair)
108
- - **Dual LLM mode** -- Auto-detects `ANTHROPIC_API_KEY` for direct API calls; falls back to `claude -p` CLI when no key is available
108
+ - **Multi-provider LLM mode** -- Provider priority `ANTHROPIC_API_KEY` (direct Anthropic API) `OPENROUTER_API_KEY` (OpenRouter, OpenAI-compatible — point it at any model via `OPENROUTER_MODEL`) → `claude -p` CLI fallback when no key is set
109
109
  - **Lesson-learned indexing** -- `lesson_learned` field indexed in FTS5 with weight 8, making past debugging insights directly searchable
110
110
  - **Cross-source normalization** -- `mem_search` normalizes scores across observations, sessions, and prompts before merging, preventing any source from dominating results
111
111
  - **Exponential recency decay** -- Type-differentiated half-lives (decisions: 90d, discoveries: 60d, bugfixes: 14d, changes: 7d) consistently applied in all ranking paths
@@ -657,6 +657,9 @@ npm run benchmark:gate # CI gate: fails if metrics regress beyond 5% toleranc
657
657
  |----------|-------------|---------|
658
658
  | `CLAUDE_MEM_DIR` | Custom data directory. All databases, runtime files, and managed resources are stored here. | `~/.claude-mem-lite/` |
659
659
  | `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
660
+ | `ANTHROPIC_API_KEY` | Anthropic API key. When set, all background LLM calls go directly to the Anthropic Messages API (with prompt caching). Highest priority. | _(unset → CLI)_ |
661
+ | `OPENROUTER_API_KEY` | OpenRouter API key (OpenAI-compatible). Used for background LLM calls when `ANTHROPIC_API_KEY` is **not** set. If neither key is set, calls fall back to the `claude -p` CLI. | _(unset)_ |
662
+ | `OPENROUTER_MODEL` | Overrides the OpenRouter model slug for **all** background calls (e.g. `openai/gpt-4o-mini`, `qwen/qwen-2.5-72b-instruct`). When unset, the `CLAUDE_MEM_MODEL` tier maps to `anthropic/claude-haiku-4.5` (haiku) or `anthropic/claude-sonnet-4.5` (sonnet). | _(tier default)_ |
660
663
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
661
664
  | `MEM_QUIET_HOOKS` | Low-noise hooks. `1` drops the `File Lessons` / `Key Context` sections from SessionStart injection, the lesson suffix from `[mem] Related memories`, and the `WHEN TO USE` / `Decision rules` blocks from MCP server instructions. IDs and the `Recent` table still surface so `mem_get(ids=[…])` remains reachable. Intended for users running the invited-memory adopt path or who otherwise want minimal auto-injection. **Since v2.82.0 this env no longer gates auto-adopt — use `MEM_NO_AUTO_ADOPT=1` for that.** | _(disabled)_ |
662
665
  | `MEM_NO_AUTO_ADOPT` | Global opt-out for auto-adopt (v2.82.0+). `1` prevents the first-SessionStart auto-write of the invited-memory sentinel across **all** projects. For per-project opt-out use `claude-mem-lite adopt --disable` instead (writes a durable `<memdir>/.mem-no-auto-adopt` sentinel that survives marker deletion). | _(disabled)_ |
package/README.zh-CN.md CHANGED
@@ -86,7 +86,7 @@
86
86
  - **统一资源发现** -- 共享文件系统遍历层(`resource-discovery.mjs`),运行时扫描器和离线索引器共用,支持扁平目录、插件嵌套和松散 `.md` 文件
87
87
  - **领域同义词扩展** -- 注册表搜索查询自动扩展领域同义词(如 "修复" → fix, debug, bugfix, repair, error)
88
88
  - **持久化冷却机制** -- 5 分钟跨会话冷却 + 同会话去重,避免重复推荐 skill 自动加载
89
- - **双模式 LLM 调用** -- 自动检测 `ANTHROPIC_API_KEY` 直连 API;无 key 时回退到 `claude -p` CLI
89
+ - **多 provider LLM 调用** -- provider 优先级 `ANTHROPIC_API_KEY`(直连 Anthropic API)→ `OPENROUTER_API_KEY`(OpenRouter,OpenAI 兼容,可用 `OPENROUTER_MODEL` 指向任意模型)→ 无 key 时回退 `claude -p` CLI
90
90
  - **Haiku 熔断器** -- 连续 3 次 LLM 失败后,禁用 Haiku 调度 5 分钟,防止级联延迟
91
91
  - **否定意图感知** -- 正确处理 "不要测试了,先修 bug" 等复杂提示,排除被否定的意图,支持中英文混合输入
92
92
  - **可配置 LLM 模型** -- 通过 `CLAUDE_MEM_MODEL` 环境变量在 Haiku(快速/低成本)和 Sonnet(深度分析)之间切换
@@ -599,6 +599,9 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
599
599
  |------|------|--------|
600
600
  | `CLAUDE_MEM_DIR` | 自定义数据目录。所有数据库、运行时文件和托管资源均存储在此。 | `~/.claude-mem-lite/` |
601
601
  | `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
602
+ | `ANTHROPIC_API_KEY` | Anthropic API key。设置后所有后台 LLM 调用直连 Anthropic Messages API(带 prompt caching),优先级最高。 | _(未设 → CLI)_ |
603
+ | `OPENROUTER_API_KEY` | OpenRouter API key(OpenAI 兼容)。当**未设** `ANTHROPIC_API_KEY` 时用于后台 LLM 调用;两者都未设则回退到 `claude -p` CLI。 | _(未设)_ |
604
+ | `OPENROUTER_MODEL` | 覆盖**所有**后台调用的 OpenRouter 模型 slug(如 `openai/gpt-4o-mini`、`qwen/qwen-2.5-72b-instruct`)。未设时按 `CLAUDE_MEM_MODEL` 分层映射到 `anthropic/claude-haiku-4.5`(haiku)或 `anthropic/claude-sonnet-4.5`(sonnet)。 | _(分层默认)_ |
602
605
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
603
606
  | `MEM_QUIET_HOOKS` | 低噪声 hook。设为 `1` 时,SessionStart 注入去掉 `File Lessons` / `Key Context` 两节,`[mem] Related memories` 去掉 lesson 后缀,MCP server instructions 去掉 `WHEN TO USE` / `Decision rules` 两段。ID 与 `Recent` 表仍保留,`mem_get(ids=[…])` 可继续展开细节。适用于启用了 invited-memory adopt 流程或偏好最小化自动注入的用户。**v2.82.0 起此 env 不再阻挡 auto-adopt——如需关闭 auto-adopt 用 `MEM_NO_AUTO_ADOPT=1`。** | _(禁用)_ |
604
607
  | `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止首次 SessionStart 在**所有**项目自动写入邀请式 memory 哨兵。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
package/haiku-client.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  // claude-mem-lite: Unified LLM call wrapper
2
2
  // Shared by memory (hook.mjs) and dispatch modules
3
- // Auto-detects API key for direct calls, falls back to claude CLI
4
- // Model configurable via CLAUDE_MEM_MODEL env var (default: haiku)
3
+ // Provider priority: ANTHROPIC_API_KEY (direct Anthropic API)
4
+ // OPENROUTER_API_KEY (OpenRouter, OpenAI-compatible) claude CLI fallback
5
+ // Model configurable via CLAUDE_MEM_MODEL (haiku|sonnet); OpenRouter slug
6
+ // overridable via OPENROUTER_MODEL
5
7
 
6
8
  import { execFileSync } from 'child_process';
7
9
  import { readFileSync } from 'fs';
@@ -30,18 +32,47 @@ export function resolveModel() {
30
32
  return { cli, api };
31
33
  }
32
34
 
35
+ // OpenRouter uses its own slug namespace (OpenAI-compatible API). Map the
36
+ // project's haiku/sonnet tiers to the matching anthropic/* slugs so the quality
37
+ // tiering is preserved when routing through OpenRouter. Slugs verified against
38
+ // openrouter.ai (2026-06): claude-haiku-4.5 / claude-sonnet-4.5 mirror the
39
+ // native MODEL_MAP IDs above.
40
+ const OPENROUTER_MODEL_MAP = {
41
+ haiku: 'anthropic/claude-haiku-4.5',
42
+ sonnet: 'anthropic/claude-sonnet-4.5',
43
+ };
44
+
45
+ /**
46
+ * Resolve the OpenRouter model slug for a given tier.
47
+ * OPENROUTER_MODEL (if set, non-blank) overrides every tier with an explicit
48
+ * slug — this is how users point claude-mem-lite at any OpenRouter model
49
+ * (e.g. openai/gpt-4o-mini, qwen/...). Otherwise the tier maps to its default
50
+ * anthropic/* slug, falling back to the haiku slug for unknown tiers.
51
+ * @param {string} tier 'haiku' | 'sonnet'
52
+ * @returns {string} OpenRouter model slug
53
+ */
54
+ export function resolveOpenRouterModel(tier) {
55
+ const override = (process.env.OPENROUTER_MODEL || '').trim();
56
+ if (override) return override;
57
+ return OPENROUTER_MODEL_MAP[tier] || OPENROUTER_MODEL_MAP.haiku;
58
+ }
59
+
33
60
  // ─── Mode Detection ──────────────────────────────────────────────────────────
34
61
 
35
62
  let _mode = null;
36
63
 
37
64
  /**
38
- * Detect whether to use direct API or CLI for LLM calls.
39
- * Cached after first call.
40
- * @returns {'api'|'cli'} The detected mode
65
+ * Detect which provider to use for LLM calls. Priority (per user contract):
66
+ * ANTHROPIC_API_KEY direct Anthropic API ('api', native, supports prompt
67
+ * caching), else OPENROUTER_API_KEY → OpenRouter ('openrouter', OpenAI-compat),
68
+ * else fall back to the `claude` CLI ('cli'). Cached after first call.
69
+ * @returns {'api'|'openrouter'|'cli'} The detected mode
41
70
  */
42
71
  export function detectMode() {
43
72
  if (_mode) return _mode;
44
- _mode = process.env.ANTHROPIC_API_KEY ? 'api' : 'cli';
73
+ if (process.env.ANTHROPIC_API_KEY) _mode = 'api';
74
+ else if (process.env.OPENROUTER_API_KEY) _mode = 'openrouter';
75
+ else _mode = 'cli';
45
76
  const { cli } = resolveModel();
46
77
  debugLog('DEBUG', 'haiku-client', `mode: ${_mode}, model: ${cli}`);
47
78
  return _mode;
@@ -102,8 +133,9 @@ export function flattenForCLI(input) {
102
133
 
103
134
  /**
104
135
  * Call Haiku model with a prompt. Returns parsed text or null on failure.
105
- * Uses direct API when ANTHROPIC_API_KEY is available, otherwise falls back to CLI.
106
- * Never throws returns null on any error.
136
+ * Provider priority ANTHROPIC_API_KEY OPENROUTER_API_KEY CLI; if the keyed
137
+ * provider call fails (HTTP error / network throw / empty), degrades to the
138
+ * `claude -p` CLI. Never throws — returns null only when every path fails.
107
139
  *
108
140
  * @param {string|{system?: string, user: string}} prompt Prompt text, or split form
109
141
  * @param {object} [opts] Options
@@ -116,15 +148,28 @@ export async function callHaiku(prompt, { timeout = 10000, maxTokens = 500 } = {
116
148
 
117
149
  const mode = detectMode();
118
150
 
151
+ // CLI is terminal — no provider to fall back to.
152
+ if (mode === 'cli') {
153
+ try { return callHaikuCLI(prompt, { timeout }); }
154
+ catch (e) { debugCatch(e, 'callHaiku'); return null; }
155
+ }
156
+
157
+ // Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
158
+ // failure (HTTP error → null, or network/timeout throw). A region-blocked or
159
+ // out-of-credit key must not silently drop background summaries.
160
+ let primary = null;
119
161
  try {
120
- if (mode === 'api') {
121
- return await callHaikuAPI(prompt, { timeout, maxTokens });
122
- }
123
- return callHaikuCLI(prompt, { timeout });
162
+ primary = mode === 'api'
163
+ ? await callHaikuAPI(prompt, { timeout, maxTokens })
164
+ : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens });
124
165
  } catch (e) {
125
- debugCatch(e, 'callHaiku');
126
- return null;
166
+ debugCatch(e, `callHaiku:${mode}`);
127
167
  }
168
+ if (primary) return primary;
169
+
170
+ debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI`);
171
+ try { return callHaikuCLI(prompt, { timeout }); }
172
+ catch (e) { debugCatch(e, 'callHaiku:cli-fallback'); return null; }
128
173
  }
129
174
 
130
175
  /**
@@ -143,8 +188,8 @@ export async function callHaikuJSON(prompt, opts) {
143
188
 
144
189
  /**
145
190
  * Call LLM with explicit model selection. Supports 'haiku' and 'sonnet'.
146
- * Reuses existing API/CLI dual-mode infrastructure.
147
- * Never throws — returns null on any error.
191
+ * Same provider priority + failure fallback to CLI as callHaiku.
192
+ * Never throws — returns null only when every path fails.
148
193
  *
149
194
  * @param {string} prompt The prompt text
150
195
  * @param {'haiku'|'sonnet'} model Model to use (default: 'haiku')
@@ -158,15 +203,27 @@ export async function callLLMWithModel(prompt, model = 'haiku', { timeout = 1500
158
203
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
159
204
  const mode = detectMode();
160
205
 
206
+ // CLI is terminal — no provider to fall back to.
207
+ if (mode === 'cli') {
208
+ try { return callModelCLI(prompt, resolvedModel, { timeout }); }
209
+ catch (e) { debugCatch(e, `callLLMWithModel:${resolvedModel}`); return null; }
210
+ }
211
+
212
+ // Keyed provider (api/openrouter): attempt it, then degrade to the CLI on any
213
+ // failure so a region-blocked / out-of-credit key still produces output.
214
+ let primary = null;
161
215
  try {
162
- if (mode === 'api') {
163
- return await callModelAPI(prompt, resolvedModel, { timeout, maxTokens });
164
- }
165
- return callModelCLI(prompt, resolvedModel, { timeout });
216
+ primary = mode === 'api'
217
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens })
218
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens });
166
219
  } catch (e) {
167
- debugCatch(e, `callLLMWithModel:${resolvedModel}`);
168
- return null;
220
+ debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
169
221
  }
222
+ if (primary) return primary;
223
+
224
+ debugLog('WARN', 'haiku-client', `${mode} call failed, falling back to claude CLI (${resolvedModel})`);
225
+ try { return callModelCLI(prompt, resolvedModel, { timeout }); }
226
+ catch (e) { debugCatch(e, `callLLMWithModel:cli-fallback:${resolvedModel}`); return null; }
170
227
  }
171
228
 
172
229
  /**
@@ -299,6 +356,54 @@ async function callHaikuAPI(prompt, { timeout, maxTokens }) {
299
356
  }
300
357
  }
301
358
 
359
+ // ─── OpenRouter Mode ─────────────────────────────────────────────────────────
360
+
361
+ // OpenRouter exposes an OpenAI-compatible chat-completions API (NOT the
362
+ // Anthropic Messages format), so the request/response shapes differ from
363
+ // callHaikuAPI/callModelAPI: Bearer auth, `messages` with a system-role entry,
364
+ // and the reply lives at choices[0].message.content. Anthropic's prompt-cache
365
+ // `cache_control` field has no OpenAI-format equivalent and is omitted.
366
+ // `tier` is the resolved model tier ('haiku'|'sonnet'); OPENROUTER_MODEL can
367
+ // override the resulting slug entirely (see resolveOpenRouterModel).
368
+ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens }) {
369
+ const apiKey = process.env.OPENROUTER_API_KEY;
370
+ if (!apiKey) return null;
371
+
372
+ const model = resolveOpenRouterModel(tier);
373
+ const controller = new AbortController();
374
+ const timer = setTimeout(() => controller.abort(), timeout);
375
+
376
+ try {
377
+ const { system, user } = splitPrompt(prompt);
378
+ const messages = [];
379
+ if (system) messages.push({ role: 'system', content: system });
380
+ messages.push({ role: 'user', content: user });
381
+
382
+ const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
383
+ method: 'POST',
384
+ headers: {
385
+ 'Content-Type': 'application/json',
386
+ 'Authorization': `Bearer ${apiKey}`,
387
+ // Optional OpenRouter attribution headers (ignored by the API if absent).
388
+ 'X-Title': 'claude-mem-lite',
389
+ },
390
+ body: JSON.stringify({ model, max_tokens: maxTokens, messages }),
391
+ signal: controller.signal,
392
+ });
393
+
394
+ if (!res.ok) {
395
+ debugLog('WARN', `${tier}-openrouter`, `HTTP ${res.status}`);
396
+ return null;
397
+ }
398
+
399
+ const data = await res.json();
400
+ const text = data.choices?.[0]?.message?.content;
401
+ return text ? { text } : null;
402
+ } finally {
403
+ clearTimeout(timer);
404
+ }
405
+ }
406
+
302
407
  // ─── CLI Mode ────────────────────────────────────────────────────────────────
303
408
 
304
409
  function callHaikuCLI(prompt, { timeout }) {
package/hook-llm.mjs CHANGED
@@ -674,7 +674,7 @@ ${actionList}`;
674
674
  if (gotSlot) {
675
675
  let raw, parsed;
676
676
  try {
677
- raw = callLLM(prompt);
677
+ raw = await callLLM(prompt);
678
678
  parsed = parseJsonFromLLM(raw);
679
679
  } finally {
680
680
  releaseLLMSlot();
@@ -721,7 +721,7 @@ ${actionList}`;
721
721
  retryAttempted = true;
722
722
  try {
723
723
  const retryPrompt = buildLessonRetryPrompt(episode, parsed);
724
- const retryRaw = callLLM(retryPrompt, 10000);
724
+ const retryRaw = await callLLM(retryPrompt, 10000);
725
725
  if (retryRaw) {
726
726
  const retry = parseJsonFromLLM(retryRaw);
727
727
  const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
@@ -974,7 +974,7 @@ ${obsList}`;
974
974
 
975
975
  let raw, llmParsed;
976
976
  try {
977
- raw = callLLM(prompt, 20000);
977
+ raw = await callLLM(prompt, 20000);
978
978
  llmParsed = parseJsonFromLLM(raw);
979
979
  } finally {
980
980
  releaseLLMSlot();
package/hook-shared.mjs CHANGED
@@ -7,7 +7,7 @@ import { join } from 'path';
7
7
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync } from 'fs';
8
8
  import { inferProject, debugCatch } from './utils.mjs';
9
9
  import { ensureDb, DB_DIR } from './schema.mjs';
10
- import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI } from './haiku-client.mjs';
10
+ import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
11
11
  // Phase D: invited-memory sentinel detection. memdir.mjs only pulls in fs/path/os/crypto;
12
12
  // adopt-content.mjs is pure strings. No circular deps — memdir doesn't import hook-shared.
13
13
  import { memdirPath as _memdirPath, isAdopted as _isAdopted } from './memdir.mjs';
@@ -130,13 +130,26 @@ export function openDb() {
130
130
  }
131
131
  }
132
132
 
133
- // ─── LLM via claude CLI ─────────────────────────────────────────────────────
133
+ // ─── LLM (provider-routed: Anthropic API → OpenRouter → claude CLI) ─────────
134
134
 
135
135
  // Accepts either a plain string (legacy) or {system, user} (defense-in-depth
136
136
  // against prompt injection from poisoned user_prompts content — cso F#4 fix).
137
- // CLI mode renders the {system, user} form via flattenForCLI which inserts an
138
- // explicit data-boundary marker; API mode uses the system role natively.
139
- export function callLLM(prompt, timeoutMs = 15000) {
137
+ // Provider priority mirrors haiku-client (ANTHROPIC_API_KEY > OPENROUTER_API_KEY
138
+ // > CLI): when a key is present, delegate to callHaiku — it owns the Anthropic
139
+ // Messages / OpenRouter chat-completions request shapes, uses the system role
140
+ // natively, AND degrades to the `claude -p` CLI internally if the keyed provider
141
+ // fails (so a region-blocked / out-of-credit key still yields a summary). The
142
+ // keyless case shells out to `claude -p` directly here, where flattenForCLI
143
+ // renders {system, user} with an explicit data-boundary marker. Returns the raw
144
+ // response string (callers run parseJsonFromLLM themselves) or null.
145
+ // maxTokens is sized for session-summary / episode JSON (larger than the
146
+ // registry/optimize callers' budgets).
147
+ export async function callLLM(prompt, timeoutMs = 15000) {
148
+ if (detectLLMMode() !== 'cli') {
149
+ const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
150
+ return result?.text ?? null;
151
+ }
152
+
140
153
  const { cli: modelName } = resolveModelShared();
141
154
  try {
142
155
  const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "2.85.0",
3
+ "version": "2.87.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. Alternative to claude-mem with 600x lower cost.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@10.9.2",
package/schema.mjs CHANGED
@@ -49,7 +49,12 @@ export const REGISTRY_DB_PATH = join(DB_DIR, 'resource-registry.db');
49
49
  // cited_count, last_decided_session_id. Stop hook resolves injected obs as
50
50
  // cited|uncited; 3 consecutive uncited → importance -1 (floor 0); 1 cited → +1
51
51
  // (cap 3). last_decided_session_id makes Stop idempotent across multi-fire.
52
- export const CURRENT_SCHEMA_VERSION = 34;
52
+ // v35 (v2.87.0): no DDL — version bumped only to force one full migration pass on
53
+ // existing DBs, which runs the one-shot observation_files orphan cleanup (and
54
+ // re-runs the v28 observation_vectors cleanup) to clear the backlog leaked while
55
+ // the warm-start fast-path left foreign_keys OFF. LATEST_MIGRATION_COLUMN is
56
+ // unchanged (no new column) — decay_seen_count still exists at v35.
57
+ export const CURRENT_SCHEMA_VERSION = 35;
53
58
 
54
59
  // Sentinel column for the LATEST migration set. The fast-path uses this to
55
60
  // self-heal half-migrated DBs — schema_version bumped but column ALTERs rolled
@@ -212,7 +217,16 @@ export function initSchema(db) {
212
217
  // Self-heal: version-row says CURRENT but latest migration column may be
213
218
  // absent (rolled back / never applied). Fall through to migration apply
214
219
  // when the sentinel is missing — duplicates are caught in the loop.
215
- if (row.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) return db;
220
+ if (row.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) {
221
+ // Warm-start post-condition: ensureDb() opened this handle with
222
+ // foreign_keys=OFF (early migrations require cascade disabled). The full
223
+ // migration path re-enables it at the end; this fast-path return must
224
+ // match that post-condition, else every DELETE on the returned handle
225
+ // skips ON DELETE CASCADE and silently orphans junction rows. Safe here:
226
+ // no transaction is open yet (BEGIN IMMEDIATE is below).
227
+ db.pragma('foreign_keys = ON');
228
+ return db;
229
+ }
216
230
  if (row.version > CURRENT_SCHEMA_VERSION) {
217
231
  throw new Error(
218
232
  `DB schema is v${row.version} but this claude-mem-lite binary supports up to v${CURRENT_SCHEMA_VERSION}. ` +
@@ -237,6 +251,10 @@ export function initSchema(db) {
237
251
  const underlock = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
238
252
  if (underlock && underlock.version === CURRENT_SCHEMA_VERSION && hasLatestMigrationColumn(db)) {
239
253
  db.exec('COMMIT');
254
+ // COMMIT closed the transaction, so this PRAGMA takes effect (no-op inside a txn).
255
+ // Same FK post-condition as the fast-path above: a peer completed init while we
256
+ // were blocked, so we skip migrations and must still restore cascade enforcement.
257
+ db.pragma('foreign_keys = ON');
240
258
  return db;
241
259
  }
242
260
  } catch { /* table absent — proceed */ }
@@ -472,6 +490,19 @@ export function initSchema(db) {
472
490
  }
473
491
  } catch { /* non-critical — migration can retry on next open */ }
474
492
 
493
+ // v35 (v2.87.0) P1: one-shot cleanup of orphaned observation_files. Same root
494
+ // cause as the v28 observation_vectors cleanup below — until the warm-start
495
+ // fast-path FK fix (initSchema early returns now restore foreign_keys=ON),
496
+ // ensureDb() handles ran with FK OFF, so ON DELETE CASCADE never fired and
497
+ // junction rows leaked (live DB: 6440/9569 = 67% orphans). Idempotent (NOT IN
498
+ // is empty on a clean DB); runs once per version bump via the fast-path gate.
499
+ try {
500
+ db.prepare(`
501
+ DELETE FROM observation_files
502
+ WHERE obs_id NOT IN (SELECT id FROM observations)
503
+ `).run();
504
+ } catch { /* non-critical — table-missing path handled by earlier CREATE */ }
505
+
475
506
  // Observation vectors table for TF-IDF vector search
476
507
  db.exec(`
477
508
  CREATE TABLE IF NOT EXISTS observation_vectors (