claude-mem-lite 6.25.3 → 6.26.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "claude-mem-lite",
12
- "version": "6.25.3",
12
+ "version": "6.26.1",
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.1",
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,51 @@ 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. The
73
+ // keyed legs run it with thinking off: OpenRouter gets `reasoning: {enabled: false}`
74
+ // (STRICT_BODY_SLUGS), and the direct leg sends no sampling field and thinking disabled.
75
+ // The CLI leg does NOT: Claude Code 2.1.301 sends `thinking: {type: "adaptive"}` and effort
76
+ // medium for `--model haiku` whether or not MAX_THINKING_TOKENS=0 is set (request bodies
77
+ // captured at a stub base URL, 2026-10-15; D#334). Its max_tokens is Claude Code's own, so
78
+ // nothing is cut off there. With thinking off it can answer a bare one-word query with a
79
+ // prose question instead of JSON, so a caller's `jsonSchema` becomes a structured output on
80
+ // these models (rewrite replays 2026-10-15: 7/72 below two variants without it, 0/72 with
81
+ // it; docs/audits/20261015-v6.26.0-replays.md). Keyed by the ID actually sent, read through
82
+ // strictBodyKey, so an override naming another model keeps the old body.
83
+ const STRICT_BODY_MODELS = new Set(['claude-haiku-5-5']);
84
+
85
+ /**
86
+ * The model's own name inside the routing decoration around it, for the STRICT_BODY_*
87
+ * lookups: an OpenRouter variant suffix (`anthropic/claude-haiku-5.5:nitro`, `:floor`) and
88
+ * the Bedrock form a gateway may take (`anthropic.claude-haiku-5-5`, with or without a
89
+ * cross-region prefix such as `us.`, `global.` or GovCloud's `us-gov.`). Haiku 5.5 named
90
+ * either way was sent the old body: thinking on, no structured output and, on the direct
91
+ * leg, temperature 0, which it answers with a 400 (OpenRouter drops the field in transit).
92
+ * A gateway deployment name says nothing about the model behind it and is not guessed at.
93
+ * (D#321)
94
+ * @param {string} id model ID or OpenRouter slug as sent
95
+ * @returns {string}
96
+ */
97
+ function strictBodyKey(id) {
98
+ return String(id)
99
+ .replace(/:[a-z0-9-]+$/, '')
100
+ .replace(/^(?:[a-z]{2,6}(?:-[a-z]+)?\.)?anthropic\./, '');
101
+ }
102
+
63
103
  // Claude Code's tier-alias override vars, honored on the direct-API leg too:
64
104
  // gateway deployments route on the DEPLOYMENT name (Azure Foundry rejects the
65
105
  // Anthropic model ID when it differs), and one env set then covers both the
@@ -94,6 +134,9 @@ function apiModelId(tier, base = resolveAnthropicBaseUrl()) {
94
134
  // to 0 so the provider default (~1.0) doesn't inject wording variance that breaks
95
135
  // JSON parsing or defeats the wording-sensitive MinHash near-duplicate detector.
96
136
  // A call that genuinely needs sampling can pass opts.temperature to override.
137
+ // The pin holds only where the model takes it: Claude Haiku 5.5 accepts no temperature
138
+ // but its default (the direct leg omits the field, OpenRouter drops it, the CLI never sent
139
+ // one), so on that model the extraction runs at its default sampling.
97
140
  const DEFAULT_LLM_TEMPERATURE = 0;
98
141
 
99
142
  /**
@@ -134,13 +177,21 @@ export function resolveModel() {
134
177
  // OpenRouter uses its own slug namespace (OpenAI-compatible API). Map the
135
178
  // project's haiku/sonnet tiers to the matching anthropic/* slugs so the quality
136
179
  // 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.
180
+ // openrouter.ai: claude-sonnet-4.5 (2026-06) and claude-haiku-5.5 (2026-10-15) mirror
181
+ // the native MODEL_MAP IDs above.
139
182
  const OPENROUTER_MODEL_MAP = {
140
- haiku: 'anthropic/claude-haiku-4.5',
183
+ haiku: 'anthropic/claude-haiku-5.5',
141
184
  sonnet: 'anthropic/claude-sonnet-4.5',
142
185
  };
143
186
 
187
+ // STRICT_BODY_MODELS on OpenRouter's slug namespace. Measured 2026-10-15 on six rerank
188
+ // prompts, three of them picked because they had failed, three calls each (300-token
189
+ // budget): the JSON came back cut off (finish_reason "length"; the two printed spent 259 and
190
+ // 275 tokens thinking) on 8 of 18 calls, 0 of 18 with reasoning off
191
+ // (docs/audits/20261015-v6.26.0-replays.md). A one-line extraction did not think at all, so
192
+ // a single easy probe reads this as fine.
193
+ const STRICT_BODY_SLUGS = new Set(['anthropic/claude-haiku-5.5']);
194
+
144
195
  /**
145
196
  * Resolve the OpenRouter model slug for a given tier.
146
197
  * OPENROUTER_MODEL (if set, non-blank) overrides every tier with an explicit
@@ -242,11 +293,13 @@ export function flattenForCLI(input) {
242
293
  * @param {object} [opts] Options
243
294
  * @param {number} [opts.timeout=10000] Timeout in milliseconds
244
295
  * @param {number} [opts.maxTokens=500] Max tokens in response
296
+ * @param {{name: string, schema: object}|null} [opts.jsonSchema] structured output for the
297
+ * models that get the strict body (STRICT_BODY_MODELS / _SLUGS); ignored elsewhere and on the CLI
245
298
  * @returns {Promise<{text: string}|null>} Response or null on failure
246
299
  */
247
300
  export async function callHaiku(
248
301
  prompt,
249
- { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
302
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
250
303
  ) {
251
304
  if (!prompt) return null;
252
305
 
@@ -275,8 +328,13 @@ export async function callHaiku(
275
328
  // the difference between 1.4s and 13.5s.
276
329
  primary =
277
330
  mode === 'api'
278
- ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature })
279
- : await callOpenRouterAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
331
+ ? await callModelAPI(prompt, resolveModel().cli, { timeout, maxTokens, temperature, jsonSchema })
332
+ : await callOpenRouterAPI(prompt, resolveModel().cli, {
333
+ timeout,
334
+ maxTokens,
335
+ temperature,
336
+ jsonSchema,
337
+ });
280
338
  } catch (e) {
281
339
  debugCatch(e, `callHaiku:${mode}`);
282
340
  }
@@ -323,14 +381,15 @@ export async function callHaikuJSON(prompt, opts) {
323
381
  * Defaults also mirror callHaiku (10s / 500 tokens), not callModelJSONAsync's
324
382
  * 15s / 1000: a caller that omits opts must get the sync twin's budget.
325
383
  * @param {string|{system?:string,user:string}} prompt
326
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
384
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
385
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
327
386
  * @returns {Promise<object|null>} Parsed JSON or null
328
387
  */
329
388
  export async function callHaikuJSONAsync(
330
389
  prompt,
331
- { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE } = {},
390
+ { timeout = 10000, maxTokens = 500, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
332
391
  ) {
333
- return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature });
392
+ return callModelJSONAsync(prompt, resolveModel().cli, { timeout, maxTokens, temperature, jsonSchema });
334
393
  }
335
394
 
336
395
  // ─── Model-Selectable API ────────────────────────────────────────────────────
@@ -345,12 +404,14 @@ export async function callHaikuJSONAsync(
345
404
  * @param {object} [opts] Options
346
405
  * @param {number} [opts.timeout=15000] Timeout in milliseconds
347
406
  * @param {number} [opts.maxTokens=1000] Max tokens in response
407
+ * @param {{name: string, schema: object}|null} [opts.jsonSchema] structured output for the
408
+ * models that get the strict body (STRICT_BODY_MODELS / _SLUGS); ignored elsewhere and on the CLI
348
409
  * @returns {Promise<{text: string}|null>} Response or null on failure
349
410
  */
350
411
  export async function callLLMWithModel(
351
412
  prompt,
352
413
  model = 'haiku',
353
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
414
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
354
415
  ) {
355
416
  if (!prompt) return null;
356
417
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -372,8 +433,8 @@ export async function callLLMWithModel(
372
433
  try {
373
434
  primary =
374
435
  mode === 'api'
375
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
376
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
436
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
437
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
377
438
  } catch (e) {
378
439
  debugCatch(e, `callLLMWithModel:${mode}:${resolvedModel}`);
379
440
  }
@@ -401,13 +462,14 @@ export async function callLLMWithModel(
401
462
  * salvage. Only the CLI transport differs.
402
463
  * @param {string|{system?:string,user:string}} prompt
403
464
  * @param {'haiku'|'sonnet'} model
404
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
465
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
466
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
405
467
  * @returns {Promise<{text: string}|null>} Response or null on failure
406
468
  */
407
469
  export async function callLLMWithModelAsync(
408
470
  prompt,
409
471
  model = 'haiku',
410
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
472
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
411
473
  ) {
412
474
  if (!prompt) return null;
413
475
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -420,8 +482,8 @@ export async function callLLMWithModelAsync(
420
482
  try {
421
483
  primary =
422
484
  mode === 'api'
423
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
424
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
485
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
486
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
425
487
  } catch (e) {
426
488
  debugCatch(e, `callLLMWithModelAsync:${mode}:${resolvedModel}`);
427
489
  }
@@ -457,13 +519,14 @@ export async function callModelJSON(prompt, model = 'haiku', opts) {
457
519
  * throws; returns parsed JSON or null.
458
520
  * @param {string|{system?:string,user:string}} prompt
459
521
  * @param {'haiku'|'sonnet'} model
460
- * @param {{timeout?:number,maxTokens?:number,temperature?:number}} [opts]
522
+ * @param {{timeout?:number,maxTokens?:number,temperature?:number,jsonSchema?:{name:string,schema:object}|null}} [opts]
523
+ * jsonSchema: structured output for strict-body models (see callLLMWithModel)
461
524
  * @returns {Promise<object|null>}
462
525
  */
463
526
  export async function callModelJSONAsync(
464
527
  prompt,
465
528
  model = 'haiku',
466
- { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE } = {},
529
+ { timeout = 15000, maxTokens = 1000, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null } = {},
467
530
  ) {
468
531
  if (!prompt) return null;
469
532
  const resolvedModel = MODEL_MAP[model] ? model : 'haiku';
@@ -480,8 +543,8 @@ export async function callModelJSONAsync(
480
543
  try {
481
544
  primary =
482
545
  mode === 'api'
483
- ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature })
484
- : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature });
546
+ ? await callModelAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema })
547
+ : await callOpenRouterAPI(prompt, resolvedModel, { timeout, maxTokens, temperature, jsonSchema });
485
548
  } catch (e) {
486
549
  debugCatch(e, `callModelJSONAsync:${mode}:${resolvedModel}`);
487
550
  }
@@ -501,7 +564,11 @@ export function _resetTemperatureCompat() {
501
564
  _temperatureDeprecated.clear();
502
565
  }
503
566
 
504
- async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE }) {
567
+ async function callModelAPI(
568
+ prompt,
569
+ model,
570
+ { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null },
571
+ ) {
505
572
  const apiKey = process.env.ANTHROPIC_API_KEY;
506
573
  if (!apiKey) return null;
507
574
 
@@ -535,8 +602,11 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
535
602
  // field outright (Azure Foundry's claude-sonnet-5 answers 400
536
603
  // "`temperature` is deprecated for this model."). Keep sending it until a
537
604
  // 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;
605
+ // pays for itself once. A model known to refuse it never gets it.
606
+ if (STRICT_BODY_MODELS.has(strictBodyKey(modelId))) {
607
+ body.thinking = { type: 'disabled' };
608
+ if (jsonSchema) body.output_config = { format: { type: 'json_schema', schema: jsonSchema.schema } };
609
+ } else if (!_temperatureDeprecated.has(modelId)) body.temperature = temperature;
540
610
  // System slot is constant per call type (instructions, schema, type taxonomy)
541
611
  // — mark it cache_control:ephemeral so repeated calls within the 5-min cache
542
612
  // window pay the cached-input rate (~0.10× base). Sub-1024-token systems still
@@ -624,7 +694,29 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
624
694
  }
625
695
 
626
696
  const data = await res.json();
627
- const text = data.content?.[0]?.text;
697
+ // A safety classifier's decline is a 200 that may carry no text or part of an answer.
698
+ // null hands it to the CLI fallback like any other failure; the category, logged only
699
+ // under CLAUDE_MEM_DEBUG, is the only trace of why.
700
+ if (data.stop_reason === 'refusal') {
701
+ debugLog('WARN', `${model}-api`, `refusal (${data.stop_details?.category ?? 'no category'})`);
702
+ return null;
703
+ }
704
+ // The callers parse a fixed-schema JSON answer out of this text (the opt-in lesson bridge
705
+ // keeps its first line), so a reply cut off at max_tokens is a failed call too: the CLI
706
+ // fallback has no such cap. A whole JSON answer followed by cut-off prose is discarded with
707
+ // it, although parseJsonFromLLM would have recovered it (D#322; D#338).
708
+ if (data.stop_reason === 'max_tokens') {
709
+ debugLog('WARN', `${model}-api`, 'reply cut off at max_tokens');
710
+ return null;
711
+ }
712
+ // By content, not position: a thinking block (no `text`) can come first. Every text
713
+ // block, joined, so an empty one cannot hide the answer after it (D#324).
714
+ const text = Array.isArray(data.content)
715
+ ? data.content
716
+ .filter((b) => typeof b?.text === 'string')
717
+ .map((b) => b.text)
718
+ .join('')
719
+ : '';
628
720
  return text ? { text } : null;
629
721
  } finally {
630
722
  clearTimeout(timer);
@@ -698,7 +790,10 @@ function claudeArgs(modelName) {
698
790
  /**
699
791
  * The environment every headless spawn runs with. MAX_THINKING_TOKENS=0: these are
700
792
  * structured extraction calls, and the thinking the CLI enables by default was most of
701
- * their output tokens and latency (measured above).
793
+ * their output tokens and latency (measured above, 2026-09-29; the model the CLI ran then was
794
+ * not recorded). Claude Code 2.1.301 does not change its request for Claude Haiku 5.5 on this
795
+ * variable: the body carries adaptive thinking either way (D#334). Earlier versions were not
796
+ * checked.
702
797
  */
703
798
  function cliSpawnEnv() {
704
799
  return {
@@ -1014,7 +1109,7 @@ export async function callModelCLIAsync(prompt, model, { timeout }) {
1014
1109
  async function callOpenRouterAPI(
1015
1110
  prompt,
1016
1111
  tier,
1017
- { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE },
1112
+ { timeout, maxTokens, temperature = DEFAULT_LLM_TEMPERATURE, jsonSchema = null },
1018
1113
  ) {
1019
1114
  const apiKey = process.env.OPENROUTER_API_KEY;
1020
1115
  if (!apiKey) return null;
@@ -1036,7 +1131,24 @@ async function callOpenRouterAPI(
1036
1131
  // Optional OpenRouter attribution headers (ignored by the API if absent).
1037
1132
  'X-Title': 'claude-mem-lite',
1038
1133
  };
1039
- const reqBody = JSON.stringify({ model, max_tokens: maxTokens, temperature, messages });
1134
+ // A strict slug gets no temperature, as on the direct leg. No OpenRouter endpoint serving
1135
+ // Claude Haiku 5.5 lists it (endpoints API, 2026-10-15), so it was dropped in transit,
1136
+ // and it made `provider.require_parameters` match no endpoint at all (72/72 HTTP 404 on
1137
+ // the rewrite prompts, 0/72 without it; docs/audits/20261015-d330-openrouter-routing.md).
1138
+ const strict = STRICT_BODY_SLUGS.has(strictBodyKey(model));
1139
+ const payload = strict
1140
+ ? { model, max_tokens: maxTokens, messages }
1141
+ : { model, max_tokens: maxTokens, temperature, messages };
1142
+ if (strict) {
1143
+ payload.reasoning = { enabled: false };
1144
+ if (jsonSchema) {
1145
+ payload.response_format = {
1146
+ type: 'json_schema',
1147
+ json_schema: { name: jsonSchema.name, strict: true, schema: jsonSchema.schema },
1148
+ };
1149
+ }
1150
+ }
1151
+ const reqBody = JSON.stringify(payload);
1040
1152
  // Native fetch ignores HTTP(S)_PROXY; when a proxy is configured, tunnel the
1041
1153
  // request through it — a direct fetch to openrouter.ai times out behind one.
1042
1154
  const proxy = httpConnectProxyFor(url);
@@ -1050,7 +1162,27 @@ async function callOpenRouterAPI(
1050
1162
  }
1051
1163
 
1052
1164
  const data = await res.json();
1053
- const text = data.choices?.[0]?.message?.content;
1165
+ const choice = data.choices?.[0];
1166
+ // OpenRouter normalises finish_reason to stop / length / content_filter / tool_calls /
1167
+ // error and passes the provider's own value as native_finish_reason. A cut-off, filtered
1168
+ // or refused reply can still carry part of an answer; none is one. (D#322, D#323)
1169
+ const finish = choice?.finish_reason;
1170
+ const native = choice?.native_finish_reason;
1171
+ if (
1172
+ finish === 'length' ||
1173
+ finish === 'content_filter' ||
1174
+ finish === 'error' ||
1175
+ native === 'refusal' ||
1176
+ choice?.error
1177
+ ) {
1178
+ debugLog(
1179
+ 'WARN',
1180
+ `${tier}-openrouter`,
1181
+ `no answer (finish_reason ${finish}, native ${native ?? 'none'})`,
1182
+ );
1183
+ return null;
1184
+ }
1185
+ const text = choice?.message?.content;
1054
1186
  return text ? { text } : null;
1055
1187
  } finally {
1056
1188
  clearTimeout(timer);
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
 
package/hook.mjs CHANGED
@@ -128,8 +128,7 @@ import {
128
128
  import { writePlainHookText, resetPlainHookText, idsShownWhole } from './lib/hook-text-cap.mjs';
129
129
  import { shouldRecallOnFailure } from './lib/tool-refusal.mjs';
130
130
  import {
131
- entryInputTags,
132
- extractDiagnosis,
131
+ workerEntryFields,
133
132
  filterSummaryInput,
134
133
  episodeInputFilterEnabled,
135
134
  } from './lib/episode-input-filter.mjs';
@@ -781,28 +780,16 @@ async function handlePostToolUse() {
781
780
  // Tier 1 B: Detect significant Bash commands
782
781
  const bashSig = tool_name === 'Bash' ? detectBashSignificance(toolInput, resp) : null;
783
782
 
784
- // D#69: tags and diagnosis lines need the FULL command and output, which the entry
785
- // does not keep, so they are computed here once. The output is windowed head + tail:
786
- // a runner prints its failures at the END, and a multi-megabyte stdout should not be
787
- // regex-scanned on every PostToolUse.
788
- const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
789
-
790
- // `diagOut` = the diagnosis lines read from tool OUTPUT: a lesson quoting one stays
791
- // under every injection floor (D#100(3)). Always present on a Bash entry, [] included, so
792
- // the worker can tell a new entry with no output lines from one buffered before the field.
793
- const diagnosis = extractDiagnosis(tool_name, toolInput, respWindow, {
794
- isError: bashSig?.isError || false,
795
- writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
796
- scrub: scrubSecrets,
797
- });
798
-
799
- // Build episode entry
783
+ // Build episode entry. The worker fields (input tags, DIAGNOSIS lines, Bash's diagOut)
784
+ // need the full command and output, which the entry does not keep (D#69).
800
785
  const entry = {
801
786
  tool: tool_name,
802
787
  desc: scrubSecrets(makeEntryDesc(tool_name, toolInput, resp, bashSig)),
803
- inputTags: entryInputTags(tool_name, toolInput, respWindow),
804
- diag: diagnosis.lines,
805
- ...(tool_name === 'Bash' ? { diagOut: diagnosis.output } : {}),
788
+ ...workerEntryFields(tool_name, toolInput, resp, {
789
+ isError: bashSig?.isError || false,
790
+ writesFiles: tool_name === 'Bash' && bashWrites.length > 0,
791
+ scrub: scrubSecrets,
792
+ }),
806
793
  files,
807
794
  ...(tool_name === 'Bash' && bashWrites.length ? { bashWrites } : {}),
808
795
  ts: Date.now(),
@@ -386,6 +386,33 @@ export function extractDiagnosis(
386
386
  return { lines: [...clean(lines), ...out], output: out };
387
387
  }
388
388
 
389
+ /**
390
+ * The fields an episode entry carries for the llm-episode worker: the input filter's tags,
391
+ * the prompt's DIAGNOSIS lines and, on Bash, `diagOut`, the lines read from tool OUTPUT (a
392
+ * lesson quoting one stays under every injection floor, D#100(3); always present on a Bash
393
+ * entry, [] included, so the worker can tell a new entry with no output lines from one
394
+ * buffered before the field). They need the FULL command and output, which the entry does
395
+ * not keep, so hook.mjs computes them once per PostToolUse. The output is windowed head +
396
+ * tail: a runner prints its failures at the END, and a multi-megabyte stdout should not be
397
+ * regex-scanned on every call (D#69). benchmark/episode-flush-replay.mjs calls this too, so
398
+ * the model A/B it feeds sees the entries production builds (D#326).
399
+ * @param {string} tool
400
+ * @param {object} input tool_input
401
+ * @param {string} resp normalized tool response
402
+ * @param {{isError?: boolean, writesFiles?: boolean, scrub?: (s: string) => string}} [opts]
403
+ * as extractDiagnosis
404
+ * @returns {{inputTags: string[], diag: string[], diagOut?: string[]}}
405
+ */
406
+ export function workerEntryFields(tool, input, resp, opts) {
407
+ const respWindow = resp.length > 65536 ? resp.slice(0, 32768) + '\n' + resp.slice(-32768) : resp;
408
+ const diagnosis = extractDiagnosis(tool, input, respWindow, opts);
409
+ return {
410
+ inputTags: entryInputTags(tool, input, respWindow),
411
+ diag: diagnosis.lines,
412
+ ...(tool === 'Bash' ? { diagOut: diagnosis.output } : {}),
413
+ };
414
+ }
415
+
389
416
  // ─── Grounding check ────────────────────────────────────────────────────────
390
417
 
391
418
  const GROUND_NGRAM = 4;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "6.25.3",
3
+ "version": "6.26.1",
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.1",
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.1",
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",