claude-mem-lite 6.25.3 → 6.26.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.25.3",
12
+ "version": "6.26.0",
13
13
  "source": "./",
14
14
  "homepage": "https://github.com/sdsrss/claude-mem-lite",
15
15
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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)."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.3",
3
+ "version": "6.26.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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
@@ -1134,10 +1134,10 @@ claude-mem-lite.
1134
1134
  | `CLAUDE_MEM_MODEL` | LLM model for background calls (episode extraction, session summaries). Accepts `haiku` or `sonnet`. | `haiku` |
1135
1135
  | `ANTHROPIC_API_KEY` | Anthropic API key. When set, all background LLM calls go directly to the Anthropic Messages API (with prompt caching) - or to the `ANTHROPIC_BASE_URL` gateway when that is set. Highest priority. | _(unset → CLI)_ |
1136
1136
  | `ANTHROPIC_BASE_URL` | Base URL for the direct Messages API when an Anthropic-compatible gateway serves the models (Azure AI Foundry, LiteLLM, Bedrock/Vertex proxies). No `/v1` suffix — the endpoint path is appended. The `claude -p` fallback reads the same variable, so one value covers both transports. Plain `http://` is accepted only for a loopback host; anything else would send the API key unencrypted. That request always goes straight to the loopback host, even when `NODE_USE_ENV_PROXY=1` sends other requests through `HTTP_PROXY`. A value that is set but unusable skips the direct API leg, so background calls fall back to the `claude -p` CLI, and doctor warns. | `https://api.anthropic.com` |
1137
- | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Model ID or gateway deployment name for the `haiku` tier, honored on the direct API **only when `ANTHROPIC_BASE_URL` is set**. Without a base URL it stays CLI-only, so the Bedrock/Vertex form of this alias is never sent to `api.anthropic.com`. Use it when the gateway routes on deployment names rather than Anthropic model IDs (Azure Foundry deployments). The `claude -p` fallback resolves its `--model haiku` alias through it too. | built-in `claude-haiku-4-5-…` |
1137
+ | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Model ID or gateway deployment name for the `haiku` tier, honored on the direct API **only when `ANTHROPIC_BASE_URL` is set**. Without a base URL it stays CLI-only, so the Bedrock/Vertex form of this alias is never sent to `api.anthropic.com`. Use it when the gateway routes on deployment names rather than Anthropic model IDs (Azure Foundry deployments). The `claude -p` fallback resolves its `--model haiku` alias through it too. | built-in `claude-haiku-5-5` |
1138
1138
  | `ANTHROPIC_DEFAULT_SONNET_MODEL` | Same as above for the `sonnet` tier. | built-in `claude-sonnet-4-5-…` |
1139
1139
  | `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)_ |
1140
- | `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)_ |
1140
+ | `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-5.5` (haiku) or `anthropic/claude-sonnet-4.5` (sonnet). | _(tier default)_ |
1141
1141
  | `CLAUDE_MEM_DEBUG` | Enable debug logging (`1` to enable). | _(disabled)_ |
1142
1142
  | `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)_ |
1143
1143
  | `CLAUDE_MEM_SESSION_EVENTS` | `1`/`on` restores the SessionStart `### Key Events` section (recent high-importance rows from the `events` table). **Off by default since v6.13.0**: an audit of 30 events read 2 accurate and 16 wrong. The UserPromptSubmit events block and PreToolUse recall are query-matched and stay on; `mem_search` still reaches every event. | _(off)_ |
package/README.zh-CN.md CHANGED
@@ -863,10 +863,10 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
863
863
  | `CLAUDE_MEM_MODEL` | 后台 LLM 调用模型(Episode 提取、会话总结、调度)。可选 `haiku` 或 `sonnet`。 | `haiku` |
864
864
  | `ANTHROPIC_API_KEY` | Anthropic API key。设置后所有后台 LLM 调用直连 Anthropic Messages API(带 prompt caching);设了 `ANTHROPIC_BASE_URL` 时改发往那个网关。优先级最高。 | _(未设 → CLI)_ |
865
865
  | `ANTHROPIC_BASE_URL` | 由兼容 Anthropic 的网关(Azure AI Foundry、LiteLLM、Bedrock/Vertex 代理)提供模型时,直连 Messages API 使用的基础 URL。不要带 `/v1` 后缀,端点路径会自动拼上。`claude -p` 兜底读取同一个变量,所以一个值同时作用于两条路径。明文 `http://` 只接受回环主机,否则 API key 会以明文发送。这个请求总是直连回环主机,即使 `NODE_USE_ENV_PROXY=1` 让其他请求走 `HTTP_PROXY`。设了但不可用的值会跳过直连这一路,后台调用改走 `claude -p`,doctor 会提示。 | `https://api.anthropic.com` |
866
- | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `haiku` 档的模型 ID 或网关部署名,**仅在设了 `ANTHROPIC_BASE_URL` 时**用于直连 API。没有基础 URL 时只给 CLI 用,所以这个别名的 Bedrock/Vertex 写法不会被发往 `api.anthropic.com`。网关按部署名而不是 Anthropic 模型 ID 路由时(Azure Foundry 部署)使用。`claude -p` 兜底的 `--model haiku` 别名也经由它解析。 | 内置 `claude-haiku-4-5-…` |
866
+ | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `haiku` 档的模型 ID 或网关部署名,**仅在设了 `ANTHROPIC_BASE_URL` 时**用于直连 API。没有基础 URL 时只给 CLI 用,所以这个别名的 Bedrock/Vertex 写法不会被发往 `api.anthropic.com`。网关按部署名而不是 Anthropic 模型 ID 路由时(Azure Foundry 部署)使用。`claude -p` 兜底的 `--model haiku` 别名也经由它解析。 | 内置 `claude-haiku-5-5` |
867
867
  | `ANTHROPIC_DEFAULT_SONNET_MODEL` | 同上,用于 `sonnet` 档。 | 内置 `claude-sonnet-4-5-…` |
868
868
  | `OPENROUTER_API_KEY` | OpenRouter API key(OpenAI 兼容)。当**未设** `ANTHROPIC_API_KEY` 时用于后台 LLM 调用;两者都未设则回退到 `claude -p` CLI。 | _(未设)_ |
869
- | `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)。 | _(分层默认)_ |
869
+ | `OPENROUTER_MODEL` | 覆盖**所有**后台调用的 OpenRouter 模型 slug(如 `openai/gpt-4o-mini`、`qwen/qwen-2.5-72b-instruct`)。未设时按 `CLAUDE_MEM_MODEL` 分层映射到 `anthropic/claude-haiku-5.5`(haiku)或 `anthropic/claude-sonnet-4.5`(sonnet)。 | _(分层默认)_ |
870
870
  | `CLAUDE_MEM_DEBUG` | 启用调试日志(设为 `1` 启用)。 | _(禁用)_ |
871
871
  | `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`。** | _(禁用)_ |
872
872
  | `CLAUDE_MEM_SESSION_EVENTS` | 设为 `1`/`on` 时恢复 SessionStart 的 `### Key Events` 一节(`events` 表中最近的高重要度条目)。**v6.13.0 起默认关闭**:对 30 条 event 的核验只有 2 条属实、16 条错误。UserPromptSubmit 的 events 块与 PreToolUse 召回按查询匹配,保持开启;`mem_search` 仍可检索全部 event。 | _(关闭)_ |
package/deep-search.mjs CHANGED
@@ -269,6 +269,23 @@ export const REWRITE_SYSTEM =
269
269
  'Emit exactly 3 non-empty variants. If unsure, still emit at least the keyword form as v1.\n' +
270
270
  INJECTION_GUARD;
271
271
 
272
+ /**
273
+ * The rewrite's output as a structured-output schema. Sent only to models that get the
274
+ * strict body (haiku-client's STRICT_BODY_MODELS / _SLUGS): with thinking off, Claude
275
+ * Haiku 5.5's answer gave fewer than two variants on 7 of 72 replays (the ones printed were
276
+ * prose questions about a bare one-word query, "React"), and on 0 of 72 with this schema
277
+ * (2026-10-15, docs/audits/20261015-v6.26.0-replays.md).
278
+ */
279
+ export const REWRITE_JSON_SCHEMA = {
280
+ name: 'rewrite_variants',
281
+ schema: {
282
+ type: 'object',
283
+ properties: { variants: { type: 'array', items: { type: 'string' } } },
284
+ required: ['variants'],
285
+ additionalProperties: false,
286
+ },
287
+ };
288
+
272
289
  /**
273
290
  * Build the split-form rewrite prompt. The constant instructions live in the
274
291
  * system slot; the untrusted query goes verbatim into the user/data slot so an
@@ -372,7 +389,7 @@ export function makeThrottled(llm, { intervalMs = AUTO_DEEP_THROTTLE_MS } = {})
372
389
  // never load the LLM client.
373
390
  async function callRewriteLLM(prompt, { timeout }) {
374
391
  const { callModelJSONAsync } = await import('./haiku-client.mjs');
375
- return callModelJSONAsync(prompt, 'haiku', { timeout, maxTokens: 400 });
392
+ return callModelJSONAsync(prompt, 'haiku', { timeout, maxTokens: 400, jsonSchema: REWRITE_JSON_SCHEMA });
376
393
  }
377
394
 
378
395
  // Default (explicit deep=true) provider: patient timeout, no throttle/cache.
package/haiku-client.mjs CHANGED
@@ -55,11 +55,30 @@ function cliSpawnCwd() {
55
55
  // ─── Model Resolution ────────────────────────────────────────────────────────
56
56
 
57
57
  // CLI name → API model ID mapping
58
+ // haiku moved from claude-haiku-4-5-20251001 on 2026-10-15, after
59
+ // benchmark/haiku-model-ab.mjs read usability parity on the three haiku-tier sites it
60
+ // samples (episode, rewrite, rerank) and a blind read of the kept lessons put 5.5 ahead
61
+ // (docs/measurement/rulers.md, "haiku-tier model A/B"). It is also what
62
+ // `claude -p --model haiku` resolves to on the Anthropic API since Claude Code 2.1.293
63
+ // (Bedrock, Vertex, Foundry and Claude Platform on AWS still resolve it to 4.5), so there
64
+ // all three legs run the same model.
58
65
  const MODEL_MAP = {
59
- haiku: 'claude-haiku-4-5-20251001',
66
+ haiku: 'claude-haiku-5-5',
60
67
  sonnet: 'claude-sonnet-4-5-20250929',
61
68
  };
62
69
 
70
+ // Models that get the STRICT body. Claude Haiku 5.5 answers any non-default `temperature`
71
+ // with a 400 and thinks unless told not to, so a reply can open with a thinking block, and
72
+ // thinking spends the max_tokens these short fixed-schema extractions are sized for. Every
73
+ // leg runs it with thinking off: the CLI sets MAX_THINKING_TOKENS=0, OpenRouter gets
74
+ // `reasoning: {enabled: false}` (STRICT_BODY_SLUGS), and the direct leg sends no sampling
75
+ // field and thinking disabled. With thinking off it can answer a bare one-word query with a
76
+ // prose question instead of JSON, so a caller's `jsonSchema` becomes a structured output on
77
+ // these models (rewrite replays 2026-10-15: 7/72 below two variants without it, 0/72 with
78
+ // it; docs/audits/20261015-v6.26.0-replays.md). Keyed by the ID actually sent, so an
79
+ // override naming another model keeps the old body.
80
+ const STRICT_BODY_MODELS = new Set(['claude-haiku-5-5']);
81
+
63
82
  // Claude Code's tier-alias override vars, honored on the direct-API leg too:
64
83
  // gateway deployments route on the DEPLOYMENT name (Azure Foundry rejects the
65
84
  // Anthropic model ID when it differs), and one env set then covers both the
@@ -94,6 +113,9 @@ function apiModelId(tier, base = resolveAnthropicBaseUrl()) {
94
113
  // to 0 so the provider default (~1.0) doesn't inject wording variance that breaks
95
114
  // JSON parsing or defeats the wording-sensitive MinHash near-duplicate detector.
96
115
  // A call that genuinely needs sampling can pass opts.temperature to override.
116
+ // The pin holds only where the model takes it: Claude Haiku 5.5 accepts no temperature
117
+ // but its default (the direct leg omits the field, OpenRouter drops it, the CLI never sent
118
+ // one), so on that model the extraction runs at its default sampling.
97
119
  const DEFAULT_LLM_TEMPERATURE = 0;
98
120
 
99
121
  /**
@@ -134,13 +156,21 @@ export function resolveModel() {
134
156
  // OpenRouter uses its own slug namespace (OpenAI-compatible API). Map the
135
157
  // project's haiku/sonnet tiers to the matching anthropic/* slugs so the quality
136
158
  // tiering is preserved when routing through OpenRouter. Slugs verified against
137
- // openrouter.ai (2026-06): claude-haiku-4.5 / claude-sonnet-4.5 mirror the
138
- // native MODEL_MAP IDs above.
159
+ // openrouter.ai: claude-sonnet-4.5 (2026-06) and claude-haiku-5.5 (2026-10-15) mirror
160
+ // the native MODEL_MAP IDs above.
139
161
  const OPENROUTER_MODEL_MAP = {
140
- haiku: 'anthropic/claude-haiku-4.5',
162
+ haiku: 'anthropic/claude-haiku-5.5',
141
163
  sonnet: 'anthropic/claude-sonnet-4.5',
142
164
  };
143
165
 
166
+ // STRICT_BODY_MODELS on OpenRouter's slug namespace. Measured 2026-10-15 on six rerank
167
+ // prompts, three of them picked because they had failed, three calls each (300-token
168
+ // budget): the JSON came back cut off (finish_reason "length"; the two printed spent 259 and
169
+ // 275 tokens thinking) on 8 of 18 calls, 0 of 18 with reasoning off
170
+ // (docs/audits/20261015-v6.26.0-replays.md). A one-line extraction did not think at all, so
171
+ // a single easy probe reads this as fine.
172
+ const STRICT_BODY_SLUGS = new Set(['anthropic/claude-haiku-5.5']);
173
+
144
174
  /**
145
175
  * Resolve the OpenRouter model slug for a given tier.
146
176
  * OPENROUTER_MODEL (if set, non-blank) overrides every tier with an explicit
@@ -242,11 +272,13 @@ export function flattenForCLI(input) {
242
272
  * @param {object} [opts] Options
243
273
  * @param {number} [opts.timeout=10000] Timeout in milliseconds
244
274
  * @param {number} [opts.maxTokens=500] Max tokens in response
275
+ * @param {{name: string, schema: object}|null} [opts.jsonSchema] structured output for the
276
+ * models that get the strict body (STRICT_BODY_MODELS / _SLUGS); ignored elsewhere and on the CLI
245
277
  * @returns {Promise<{text: string}|null>} Response or null on failure
246
278
  */
247
279
  export async function callHaiku(
248
280
  prompt,
249
- { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
281
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
250
282
  ) {
251
283
  if (!prompt) return null;
252
284
 
@@ -275,8 +307,13 @@ export async function callHaiku(
275
307
  // the difference between 1.4s and 13.5s.
276
308
  primary =
277
309
  mode === 'api'
278
- ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
279
- : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
310
+ ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature, jsonSchema })
311
+ : await callOpenRouterAPI(prompt, resolveModel().cli, {
312
+ timeout,
313
+ maxTokens,
314
+ temperature,
315
+ jsonSchema,
316
+ });
280
317
  } catch (e) {
281
318
  debugCatch(e, `callHaiku:${mode}`);
282
319
  }
@@ -323,14 +360,15 @@ export async function callHaikuJSON(prompt, opts) {
323
360
  * Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
324
361
  * 15s / 1000: a caller that omits opts must get the sync twin's budget.
325
362
  * @param {string|{system?:string,user:string}} prompt
326
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
363
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
364
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
327
365
  * @returns {Promise<object|null>} Parsed JSON or null
328
366
  */
329
367
  export async function callHaikuJSONAsync(
330
368
  prompt,
331
- { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
369
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
332
370
  ) {
333
- return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
371
+ return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature, jsonSchema });
334
372
  }
335
373
 
336
374
  // ─── Model-Selectable API ────────────────────────────────────────────────────
@@ -345,12 +383,14 @@ export async function callHaikuJSONAsync(
345
383
  * @param {object} [opts] Options
346
384
  * @param {number} [opts.timeout=15000] Timeout in milliseconds
347
385
  * @param {number} [opts.maxTokens=1000] Max tokens in response
386
+ * @param {{name: string, schema: object}|null} [opts.jsonSchema] structured output for the
387
+ * models that get the strict body (STRICT_BODY_MODELS / _SLUGS); ignored elsewhere and on the CLI
348
388
  * @returns {Promise<{text: string}|null>} Response or null on failure
349
389
  */
350
390
  export async function callLLMWithModel(
351
391
  prompt,
352
392
  model = 'haiku',
353
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
393
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
354
394
  ) {
355
395
  if (!prompt) return null;
356
396
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -372,8 +412,8 @@ export async function callLLMWithModel(
372
412
  try {
373
413
  primary =
374
414
  mode === 'api'
375
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
376
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
415
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
416
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
377
417
  } catch (e) {
378
418
  debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
379
419
  }
@@ -401,13 +441,14 @@ export async function callLLMWithModel(
401
441
  * salvage. Only the CLI transport differs.
402
442
  * @param {string|{system?:string,user:string}} prompt
403
443
  * @param {'haiku'|'sonnet'} model
404
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
444
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
445
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
405
446
  * @returns {Promise<{text: string}|null>} Response or null on failure
406
447
  */
407
448
  export async function callLLMWithModelAsync(
408
449
  prompt,
409
450
  model = 'haiku',
410
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
451
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
411
452
  ) {
412
453
  if (!prompt) return null;
413
454
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -420,8 +461,8 @@ export async function callLLMWithModelAsync(
420
461
  try {
421
462
  primary =
422
463
  mode === 'api'
423
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
424
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
464
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
465
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
425
466
  } catch (e) {
426
467
  debugCatch(e, `callLLMWithModelAsync:${mode}:${resolvedModel}`);
427
468
  }
@@ -457,13 +498,14 @@ export async function callModelJSON(prompt, model = 'haiku', opts) {
457
498
  * throws; returns parsed JSON or null.
458
499
  * @param {string|{system?:string,user:string}} prompt
459
500
  * @param {'haiku'|'sonnet'} model
460
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
501
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
502
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
461
503
  * @returns {Promise<object|null>}
462
504
  */
463
505
  export async function callModelJSONAsync(
464
506
  prompt,
465
507
  model = 'haiku',
466
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
508
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
467
509
  ) {
468
510
  if (!prompt) return null;
469
511
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -480,8 +522,8 @@ export async function callModelJSONAsync(
480
522
  try {
481
523
  primary =
482
524
  mode === 'api'
483
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
484
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
525
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
526
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
485
527
  } catch (e) {
486
528
  debugCatch(e, `callModelJSONAsync:${mode}:${resolvedModel}`);
487
529
  }
@@ -501,7 +543,11 @@ export function _resetTemperatureCompat() {
501
543
  _temperatureDeprecated.clear();
502
544
  }
503
545
 
504
- async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
546
+ async function callModelAPI(
547
+ prompt,
548
+ model,
549
+ { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null },
550
+ ) {
505
551
  const apiKey = process.env.ANTHROPIC_API_KEY;
506
552
  if (!apiKey) return null;
507
553
 
@@ -535,8 +581,11 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
535
581
  // field outright (Azure Foundry's claude-sonnet-5 answers 400
536
582
  // "`temperature` is deprecated for this model."). Keep sending it until a
537
583
  // model refuses, then drop it for that model only - the retry at the bottom
538
- // pays for itself once.
539
- if (!_temperatureDeprecated.has(modelId)) body.temperature = temperature;
584
+ // pays for itself once. A model known to refuse it never gets it.
585
+ if (STRICT_BODY_MODELS.has(modelId)) {
586
+ body.thinking = { type: 'disabled' };
587
+ if (jsonSchema) body.output_config = { format: { type: 'json_schema', schema: jsonSchema.schema } };
588
+ } else if (!_temperatureDeprecated.has(modelId)) body.temperature = temperature;
540
589
  // System slot is constant per call type (instructions, schema, type taxonomy)
541
590
  // — mark it cache_control:ephemeral so repeated calls within the 5-min cache
542
591
  // window pay the cached-input rate (~0.10× base). Sub-1024-token systems still
@@ -624,7 +673,17 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
624
673
  }
625
674
 
626
675
  const data = await res.json();
627
- const text = data.content?.[0]?.text;
676
+ // A safety classifier's decline is a 200 that may carry no text or part of an answer.
677
+ // null hands it to the CLI fallback like any other failure; the category, logged only
678
+ // under CLAUDE_MEM_DEBUG, is the only trace of why.
679
+ if (data.stop_reason === 'refusal') {
680
+ debugLog('WARN', `${model}-api`, `refusal (${data.stop_details?.category ?? 'no category'})`);
681
+ return null;
682
+ }
683
+ // By content, not position: a thinking block (no `text`) can come first.
684
+ const text = Array.isArray(data.content)
685
+ ? data.content.find((b) => typeof b?.text === 'string')?.text
686
+ : undefined;
628
687
  return text ? { text } : null;
629
688
  } finally {
630
689
  clearTimeout(timer);
@@ -1014,7 +1073,7 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
1014
1073
  async function callOpenRouterAPI(
1015
1074
  prompt,
1016
1075
  tier,
1017
- { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE },
1076
+ { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null },
1018
1077
  ) {
1019
1078
  const apiKey = process.env.OPENROUTER_API_KEY;
1020
1079
  if (!apiKey) return null;
@@ -1036,7 +1095,17 @@ async function callOpenRouterAPI(
1036
1095
  // Optional OpenRouter attribution headers (ignored by the API if absent).
1037
1096
  'X-Title': 'claude-mem-lite',
1038
1097
  };
1039
- const reqBody = JSON.stringify({ model, max_tokens: maxTokens, temperature, messages });
1098
+ const payload = { model, max_tokens: maxTokens, temperature, messages };
1099
+ if (STRICT_BODY_SLUGS.has(model)) {
1100
+ payload.reasoning = { enabled: false };
1101
+ if (jsonSchema) {
1102
+ payload.response_format = {
1103
+ type: 'json_schema',
1104
+ json_schema: { name: jsonSchema.name, strict: true, schema: jsonSchema.schema },
1105
+ };
1106
+ }
1107
+ }
1108
+ const reqBody = JSON.stringify(payload);
1040
1109
  // Native fetch ignores HTTP(S)_PROXY; when a proxy is configured, tunnel the
1041
1110
  // request through it — a direct fetch to openrouter.ai times out behind one.
1042
1111
  const proxy = httpConnectProxyFor(url);
package/hook-llm.mjs CHANGED
@@ -933,6 +933,64 @@ ${diagnosisBlock(diag)}`;
933
933
 
934
934
  // ─── Background: LLM Episode Extraction (Tier 2 F) ──────────────────────────
935
935
 
936
+ /**
937
+ * The episode-extraction prompt, `{ system, user }`. Exported so
938
+ * benchmark/haiku-model-ab.mjs sends the SHIPPED prompt rather than a retyped copy
939
+ * (buildCompressPrompt was extracted for the same reason).
940
+ * @param {object} episode
941
+ * @param {string[]} [diag] episodeDiagnosis(episode); the handler passes the one it keeps
942
+ * @returns {{system: string, user: string}}
943
+ */
944
+ export function buildEpisodePrompt(episode, diag = episodeDiagnosis(episode)) {
945
+ // Same guard as the handler: an older-format tmp file can omit `files`.
946
+ const episodeFiles = Array.isArray(episode.files) ? episode.files : [];
947
+ const fileList = episodeFiles.map((f) => basename(f)).join(', ') || '(multiple)';
948
+
949
+ // Defense-in-depth (cso F#4): split static instructions (system) from
950
+ // per-call data (user). Episode descriptions and file paths come from tool
951
+ // events; treating them as a separate role + boundary marker reduces the
952
+ // attack surface for memory poisoning via crafted file content.
953
+ const SHARED_OBS_SCHEMA_TAIL = `${MEMORY_INPUT_GUARD}
954
+ Grounding: state only outcomes the user message shows. An action description may be cut short ("…"); if its result is not visible, say what was run, not how it turned out. Never write that something passed, failed, was verified or confirmed unless that result appears in the user message.
955
+ type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause; a test that failed only until this episode's implementation landed (written first, then made to pass) is TDD, not a bugfix — type that feature or refactor. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
956
+ Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
957
+ importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
958
+ lesson_learned: The non-obvious insight a future session would benefit from, resting ONLY on the DIAGNOSIS lines: copy at least 4 consecutive words verbatim, in double quotes, from one DIAGNOSIS line, and claim no mechanism that line does not state. Example: FTS5's "default tokenizer doesn't split CJK" — index bigrams instead. No DIAGNOSIS line, or none that states a cause → output JSON null; a lesson without such a quote is discarded. Do NOT invent a lesson. A mutation/probe run (a file changed on purpose, a checksum, a restore) and the agent's own script errors are not product bugs. Do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
959
+ scope: ${SCOPE_PROMPT_LEGEND}
960
+ search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
961
+
962
+ if (episode.entries.length === 1) {
963
+ const e = episode.entries[0];
964
+ const system = `Extract a structured observation from this code change. Return ONLY valid JSON, no markdown fences.
965
+
966
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
967
+ ${SHARED_OBS_SCHEMA_TAIL}`;
968
+ const user = `Tool: ${e.tool}
969
+ File: ${episodeFiles.join(', ') || 'unknown'}
970
+ Action: ${e.desc}
971
+ Error: ${e.isError ? 'yes' : 'no'}
972
+
973
+ ${diagnosisBlock(diag)}`;
974
+ return { system, user };
975
+ }
976
+
977
+ const actionList = episode.entries
978
+ .map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
979
+ .join('\n');
980
+
981
+ const system = `Summarize this coding episode as ONE coherent observation. Return ONLY valid JSON, no markdown fences.
982
+
983
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
984
+ ${SHARED_OBS_SCHEMA_TAIL}`;
985
+ const user = `Project: ${episode.project}
986
+ Files: ${fileList}
987
+ Actions (${episode.entries.length} total):
988
+ ${actionList}
989
+
990
+ ${diagnosisBlock(diag)}`;
991
+ return { system, user };
992
+ }
993
+
936
994
  export async function handleLLMEpisode() {
937
995
  const tmpFile = process.argv[3];
938
996
  if (!tmpFile) return;
@@ -974,51 +1032,8 @@ export async function handleLLMEpisode() {
974
1032
  const fileList = episodeFiles.map((f) => basename(f)).join(', ') || '(multiple)';
975
1033
  const edgeFiles = splitEpisodeFiles(episode);
976
1034
 
977
- // Defense-in-depth (cso F#4): split static instructions (system) from
978
- // per-call data (user). Episode descriptions and file paths come from tool
979
- // events; treating them as a separate role + boundary marker reduces the
980
- // attack surface for memory poisoning via crafted file content.
981
- const SHARED_OBS_SCHEMA_TAIL = `${MEMORY_INPUT_GUARD}
982
- Grounding: state only outcomes the user message shows. An action description may be cut short ("…"); if its result is not visible, say what was run, not how it turned out. Never write that something passed, failed, was verified or confirmed unless that result appears in the user message.
983
- type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y because Z" / rejected an approach (e.g. "Rejected schema migration — single-source module + sync test instead"; "Heterogeneous hook events → heterogeneous context budgets"). bugfix = prior-failing path fixed with a named root cause; a test that failed only until this episode's implementation landed (written first, then made to pass) is TDD, not a bugfix — type that feature or refactor. feature = new user-visible capability. refactor = behavior unchanged but structure improved. discovery = learned how a system works (read-heavy, no writes). change = routine edit with no new principle (default if unsure and nothing else fits).
984
- Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
985
- importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
986
- lesson_learned: The non-obvious insight a future session would benefit from, resting ONLY on the DIAGNOSIS lines: copy at least 4 consecutive words verbatim, in double quotes, from one DIAGNOSIS line, and claim no mechanism that line does not state. Example: FTS5's "default tokenizer doesn't split CJK" — index bigrams instead. No DIAGNOSIS line, or none that states a cause → output JSON null; a lesson without such a quote is discarded. Do NOT invent a lesson. A mutation/probe run (a file changed on purpose, a checksum, a restore) and the agent's own script errors are not product bugs. Do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
987
- scope: ${SCOPE_PROMPT_LEGEND}
988
- search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
989
-
990
1035
  const diag = episodeDiagnosis(episode);
991
- let prompt;
992
- if (episode.entries.length === 1) {
993
- const e = episode.entries[0];
994
- const system = `Extract a structured observation from this code change. Return ONLY valid JSON, no markdown fences.
995
-
996
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
997
- ${SHARED_OBS_SCHEMA_TAIL}`;
998
- const user = `Tool: ${e.tool}
999
- File: ${episodeFiles.join(', ') || 'unknown'}
1000
- Action: ${e.desc}
1001
- Error: ${e.isError ? 'yes' : 'no'}
1002
-
1003
- ${diagnosisBlock(diag)}`;
1004
- prompt = { system, user };
1005
- } else {
1006
- const actionList = episode.entries
1007
- .map((e, i) => `${i + 1}. [${e.tool}] ${e.desc}${e.isError ? ' (ERROR)' : ''}`)
1008
- .join('\n');
1009
-
1010
- const system = `Summarize this coding episode as ONE coherent observation. Return ONLY valid JSON, no markdown fences.
1011
-
1012
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
1013
- ${SHARED_OBS_SCHEMA_TAIL}`;
1014
- const user = `Project: ${episode.project}
1015
- Files: ${fileList}
1016
- Actions (${episode.entries.length} total):
1017
- ${actionList}
1018
-
1019
- ${diagnosisBlock(diag)}`;
1020
- prompt = { system, user };
1021
- }
1036
+ const prompt = buildEpisodePrompt(episode, diag);
1022
1037
 
1023
1038
  const ruleImportance = computeRuleImportance(episode);
1024
1039
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.3",
3
+ "version": "6.26.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-mem-lite",
9
- "version": "6.25.3",
9
+ "version": "6.26.0",
10
10
  "os": [
11
11
  "darwin",
12
12
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.3",
3
+ "version": "6.26.0",
4
4
  "description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. FTS5 BM25 keyword 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",