claude-mem-lite 6.23.3 → 6.25.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.23.3",
12
+ "version": "6.25.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.23.3",
3
+ "version": "6.25.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
@@ -237,6 +237,44 @@ rm -rf ~/claude-mem-lite/ # pre-v0.5 unhidden (if not auto-moved)
237
237
  repos/ # Shallow-cloned source repos
238
238
  ```
239
239
 
240
+ ## Upgrading to 6.25.0
241
+
242
+ **Every session gets the same first line on a file-recall block again.** No schema-version
243
+ change: 6.24.0 still opens the database after this release has.
244
+
245
+ - **What changes.** Since 6.16.0 half of sessions got a plain statement of where the notes come
246
+ from instead of "system-injected context, continue your planned action", so the two could be
247
+ compared by how often the agent cited a lesson. On the maintainer's own sessions the comparison
248
+ found no difference it could detect, and a scan of the agent's replies found neither line
249
+ surfaced to the user, so the split is closed: unset now means the older line in every session.
250
+ `CLAUDE_MEM_RECALL_FRAMING=factual` keeps the plain statement; `=ab` restores the split.
251
+ - **Also in this release:** when the data directory cannot be read, `doctor` says "not checked"
252
+ for what it could not look at and `status` names the directory, instead of reporting a missing
253
+ or empty store. `maintain execute --ops dedup` names the merge pairs it skips. Details in
254
+ CHANGELOG.md.
255
+
256
+ ## Upgrading to 6.24.0
257
+
258
+ **If you set `CLAUDE_CONFIG_DIR`, the installer, updates and hooks now use that directory, as
259
+ Claude Code does.** No schema-version change: 6.23.3 still opens the database after this
260
+ release has. Without `CLAUDE_CONFIG_DIR` nothing in this item changes.
261
+
262
+ - **Who is affected.** Claude Code keeps `settings.json`, `plugins/` and `.claude.json` in
263
+ `CLAUDE_CONFIG_DIR` when it is set. The plugin kept using `~/.claude` and `~/.claude.json`:
264
+ `install` wrote its hooks where Claude Code never reads them, and with two profiles the
265
+ session-start setup and `self-update` pruned the other profile's plugin cache and edited its
266
+ `.claude.json`.
267
+ - **What to do.** Installed with npm or npx: run `claude-mem-lite install` once more with the
268
+ variable set, so the hooks land in your config directory. Plugin installs need nothing. Hook
269
+ entries an older version wrote into `~/.claude/settings.json` stay there; if no other profile
270
+ uses `~/.claude`, `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` removes them.
271
+ - **Also in this release:** `npm install -g claude-mem-lite` and `npx` no longer install the
272
+ development toolchain. As a dependency install it went from 298 packages / 541 MB to 96 / 57 MB;
273
+ after the release, `-g` and `npx` measured the same 96 packages. A confirmed
274
+ `maintain execute --ops purge_stale` now deletes every row its preview counted in one run,
275
+ behind one snapshot. `doctor` warns about a failing update check and about an
276
+ `ANTHROPIC_BASE_URL` that ends in `/v1`. Details in CHANGELOG.md.
277
+
240
278
  ## Upgrading to 6.23.1
241
279
 
242
280
  **If you set `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY`, background LLM calls now go to that
@@ -568,9 +606,13 @@ depends on the Claude Code version. Allow the server once in the `settings.json`
568
606
  { "permissions": { "allow": ["mcp__plugin_claude-mem-lite_mem-lite"] } }
569
607
  ```
570
608
 
571
- That is the server's name under a plugin install. An npx or git-clone install registers it as
609
+ For a single run, `claude -p … --allowedTools "mcp__plugin_claude-mem-lite_mem-lite"` does the
610
+ same (both forms checked on 2.1.292, 2026-10-07: no refusal, and `mem_search` answered). That is
611
+ the server's name under a plugin install. An npx or git-clone install registers it as
572
612
  `mem-lite`, so the rule there is `mcp__mem-lite`. An interactive session asks instead; answering
573
- "always allow" there records a rule of its own.
613
+ "always allow" there records a rule of its own. The plugin does not tell the model to fall back
614
+ to the CLI when a call is refused: the refusal is Claude Code's permission check for a tool you
615
+ have not allowed, and routing the same action through Bash would sidestep it.
574
616
 
575
617
  ### Skill Commands (in Claude Code chat)
576
618
 
@@ -1209,7 +1251,7 @@ and names can change between releases.
1209
1251
  |----------|-------------|---------|
1210
1252
  | `CLAUDE_MEM_TASK_IMPERATIVE` | `on`/`1` injects the single most relevant lesson at prompt position under an imperative template. | _(off)_ |
1211
1253
  | `CLAUDE_MEM_SUBAGENT_INJECT` | Dispatch-time memory injection for subagents. | _(off)_ |
1212
- | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. `ab` gives each session one of two wordings, the older "system-injected context, continue your planned action" or a plain statement of source, so their cite-rates can be compared in one run (`benchmark/citation-live-replay.mjs --by-framing`); `legacy` / `factual` pin one. | `ab` |
1254
+ | `CLAUDE_MEM_RECALL_FRAMING` | First line of a PreToolUse / PostToolUse recall block. Unset or `legacy` = "system-injected context, continue your planned action"; `factual` = a plain statement of where the notes come from; `ab` = each session gets one of the two, for `benchmark/citation-live-replay.mjs --by-framing`. Any other value is `legacy`. | `legacy` |
1213
1255
  | `CLAUDE_MEM_SALIENCE` | Selects how the pre-edit lesson line asks for a response: unset = name a lesson's `#NN` only where it changed the edit; `verdict` = the pre-6.17 per-lesson `applied` / `n/a` verdict (the adoption row in CLAUDE.md and the detail doc keep the new wording, and since 6.17.1 the directive says it overrides them); `bind` / `bridge` = comprehension-bridge arms (`bridge` keeps the pre-6.17 verdict wording as its fallback; neither says it overrides the adopted text, so their measured wording stays fixed); `legacy` = no directive. | _(unset)_ |
1214
1256
  | `CLAUDE_MEM_EDGE_DECAY` | Enables decay of file↔observation edges. | _(off)_ |
1215
1257
  | `CLAUDE_MEM_EDGE_DECAY_K` | Edge-decay threshold when the flag above is on (clamped to ≥1). | `3` |
package/README.zh-CN.md CHANGED
@@ -199,6 +199,35 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.25.0
203
+
204
+ **文件召回块的第一行,所有会话重新用同一种写法。** 没有 schema 版本变更:本版本打开过的数据库,6.24.0 仍能打开。
205
+
206
+ - **变了什么。** 从 6.16.0 起,一半会话的第一行是说明笔记来源的事实陈述,另一半保留 "system-injected context,
207
+ continue your planned action",用来比较 agent 引用教训的频率。在维护者自己的会话上,两种写法看不出差别,
208
+ 按回复文本扫描,两组里 agent 也都没有把这一行当作可疑内容告诉用户,所以结束分组:
209
+ 不设变量时,所有会话都用旧的那一行。
210
+ `CLAUDE_MEM_RECALL_FRAMING=factual` 保留事实陈述的写法,`=ab` 恢复分组。
211
+ - **本版本还有:** 数据目录读不了时,`doctor` 对看不到的项目显示“not checked”,`status` 直接指出这个目录
212
+ 无法访问,不再报成数据库缺失或为空。`maintain execute --ops dedup` 会列出跳过的合并对。详见 CHANGELOG.md。
213
+
214
+ ## 升级到 6.24.0
215
+
216
+ **如果你设置了 `CLAUDE_CONFIG_DIR`,安装程序、更新和 hook 现在都使用这个目录,和 Claude Code 一致。**
217
+ 没有 schema 版本变更:本版本打开过的数据库,6.23.3 仍能打开。没设 `CLAUDE_CONFIG_DIR` 时,这一条不带来任何变化。
218
+
219
+ - **受影响的是谁。** 设了 `CLAUDE_CONFIG_DIR` 时,Claude Code 把 `settings.json`、`plugins/` 和 `.claude.json`
220
+ 都放在那个目录里,插件却一直用 `~/.claude` 和 `~/.claude.json`:`install` 把 hook 写到了 Claude Code 不读的
221
+ 地方;有两个配置目录时,会话启动的整理步骤和 `self-update` 会清掉另一个配置目录的插件缓存,并改动它的
222
+ `.claude.json`。
223
+ - **要做什么。** 用 npm 或 npx 安装的:在设好这个变量的情况下再运行一次 `claude-mem-lite install`,让 hook 写进
224
+ 你的配置目录。插件安装不用做什么。旧版本写进 `~/.claude/settings.json` 的 hook 条目会留在那里;如果没有别的
225
+ 配置目录在用 `~/.claude`,可以用 `env -u CLAUDE_CONFIG_DIR claude-mem-lite cleanup-hooks` 清掉。
226
+ - **本版本还有:** `npm install -g claude-mem-lite` 和 `npx` 不再安装开发工具链。按依赖方式安装,从 298 个包 /
227
+ 541 MB 降到 96 个包 / 57 MB;发布后实测 `-g` 和 `npx` 也是同样的 96 个包。确认执行的
228
+ `maintain execute --ops purge_stale` 现在一次运行就删掉预览里统计的全部行,只做一次快照。`doctor` 会对失败的
229
+ 更新检查、以及以 `/v1` 结尾的 `ANTHROPIC_BASE_URL` 发出警告。详见 CHANGELOG.md。
230
+
202
231
  ## 升级到 6.23.1
203
232
 
204
233
  **如果你设置了 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_API_KEY`,后台 LLM 调用现在会发往那个网关。**
package/bash-utils.mjs CHANGED
@@ -632,6 +632,28 @@ function withoutDiffHunks(text) {
632
632
  // Paths excluded from observation capture (ephemeral / virtual filesystems) — applied
633
633
  // uniformly to both command-parsed paths and direct file_path/path/filePath fields.
634
634
 
635
+ // A line a test runner prints for one test (`# Subtest:` heads a failing one too): it carries the
636
+ // test's name, not a summary.
637
+ const PASSED_TEST_LINE_RE = /^[ \t]*(?:✔|✓|ok[ \t]+\d+[ \t]+-|# Subtest:)/;
638
+
639
+ /**
640
+ * Whether test-runner output carries a red summary or failure banner (detectBashSignificance's
641
+ * veto on a green "0 fail" marker; D#179).
642
+ * @param {string} scan
643
+ * @returns {boolean}
644
+ */
645
+ function hasRedTestSummary(scan) {
646
+ const lines = scan
647
+ .split('\n')
648
+ .filter((l) => !PASSED_TEST_LINE_RE.test(l))
649
+ .join('\n');
650
+ return (
651
+ /\b[1-9]\d*[ \t]+(?:fail|failed|failures|failing|errors?)\b(?=[ \t]*(?:$|[,;|(]|\.(?:[ \t]|$)|in[ \t]+\S))|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
652
+ lines,
653
+ ) || /^[ \t]*(?:--- )?FAIL(?:ED)?(?::|[ \t]|$)/m.test(lines)
654
+ );
655
+ }
656
+
635
657
  /**
636
658
  * Detect significance signals in a Bash command and its response.
637
659
  * Checks for errors, test runs, builds, git operations, and deployments.
@@ -675,12 +697,23 @@ export function detectBashSignificance(input, response) {
675
697
  // suites prints one summary each, and a node:test run whose failures were all timeouts says
676
698
  // "fail 0" next to "cancelled 1" (pre-tag defect review, item 7 F1). Red = a nonzero fail /
677
699
  // cancelled count in either summary form.
700
+ // D#179: a count-first red must sit where a summary puts it — end of line, or before `,` `;`
701
+ // `|` `(` `. ` or `in …` — on ONE line, and never on a line a runner prints for a PASSED test
702
+ // (node:test `✔ name (1ms)`, vitest / bun `✓ name`, TAP `ok 1 - name` and its `# Subtest:`),
703
+ // which carries the test's NAME. Matched anywhere and across newlines, a passing run whose test
704
+ // name or log said "3 failed" / "2 failed attempts" read red, and "want 2\nFAIL" read as
705
+ // "2 FAIL". `errors?` is red too (pytest "1 passed, 1 error", tsc "Found 1 error in …",
706
+ // eslint "(2 errors, 0 warnings)"). go / jest / vitest / shell-suite failures carry a
707
+ // case-sensitive FAIL / FAILED banner at line start ("--- FAIL:", "FAILED: 3 case(s)", pytest
708
+ // "FAILED test_x.py::…"), read on its own. The green marker still
709
+ // spans lines on purpose: made one-line, 17 of this machine's 77,819 recorded results flipped
710
+ // to errors, and 15 of them were passing batteries, file reads and reports that only ever
711
+ // matched "0\nFAIL" by accident; 2 were real failures (measured 2026-10-07, recounted by the
712
+ // pre-tag delta review).
678
713
  const hasGreenTestSummary =
679
714
  looksLikeError &&
680
715
  /\b0\s+(fail|failed|failures)\b|^[ \t]*(?:ℹ|#)[ \t]*fail[ \t]+0[ \t]*$/im.test(scan) &&
681
- !/\b[1-9]\d*\s+(fail|failed|failures|failing)\b|^[ \t]*(?:ℹ|#)[ \t]*(?:fail|cancelled)[ \t]+[1-9]\d*[ \t]*$/im.test(
682
- scan,
683
- );
716
+ !hasRedTestSummary(scan);
684
717
  // NOTE: do not add `\bFAIL\s` here — with /i flag it would re-match the
685
718
  // very `0 fail\n` token green-summary is trying to exempt. A real test
686
719
  // failure produces "N fail" (N≥1) which never triggers hasGreenTestSummary,
package/cli/activity.mjs CHANGED
@@ -188,7 +188,16 @@ export async function cmdActivity(db, args) {
188
188
  return;
189
189
  }
190
190
 
191
- const result = db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
191
+ // The opt-in search telemetry keeps a title per returned result; an explicit delete drops the
192
+ // deleted events' titles, as lib/delete-core.mjs does for observations (D#245 N6).
193
+ const result = db.transaction(() => {
194
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'search_results'").get()) {
195
+ db.prepare(
196
+ `UPDATE search_results SET snapshot_label = NULL WHERE source = 'event' AND result_id IN (${placeholders})`,
197
+ ).run(...ids);
198
+ }
199
+ return db.prepare(`DELETE FROM events WHERE id IN (${placeholders})`).run(...ids);
200
+ })();
192
201
  out(`[mem] Deleted ${result.changes} event(s).`);
193
202
  return;
194
203
  }
package/haiku-client.mjs CHANGED
@@ -20,6 +20,7 @@ import { randomUUID } from 'crypto';
20
20
  import { debugLog, debugCatch, parseJsonFromLLM } from './utils.mjs';
21
21
  import { DB_DIR } from './schema.mjs';
22
22
  import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
23
+ import { workerDirGone } from './lib/worker-data-dir.mjs';
23
24
  import { httpConnectProxyFor, postViaConnectProxy, postDirectHttp } from './lib/proxy-fetch.mjs';
24
25
  import { resolveAnthropicBaseUrl } from './lib/anthropic-base-url.mjs';
25
26
 
@@ -40,6 +41,9 @@ import { resolveAnthropicBaseUrl } from './lib/anthropic-base-url.mjs';
40
41
  */
41
42
  function cliSpawnCwd() {
42
43
  const dir = join(resolveRuntimeDir(DB_DIR), 'cli-cwd');
44
+ // A background worker whose data dir was removed during its API attempt must not re-create
45
+ // it here (D#265); the spawn then fails on the missing cwd, and the worker gives up.
46
+ if (workerDirGone(DB_DIR)) return dir;
43
47
  try {
44
48
  mkdirSync(dir, { recursive: true, mode: 0o700 });
45
49
  } catch {
@@ -555,8 +559,8 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
555
559
  };
556
560
  // Plain http is loopback-only (resolveAnthropicBaseUrl), and loopback must stay on this
557
561
  // machine: native fetch would hand it, x-api-key in cleartext, to HTTP_PROXY under
558
- // NODE_USE_ENV_PROXY=1. (D#250) Parsed, not a prefix test: the resolver accepts the scheme
559
- // in any case (`HTTP://127.0.0.1`) and returns the raw text.
562
+ // NODE_USE_ENV_PROXY=1. (D#250) Parsed, not a prefix test: a prefix test let `HTTP://127.0.0.1`
563
+ // through to the proxy when the resolver still returned the raw text (b59e0cec, D#259).
560
564
  const plainHttp = new URL(apiUrl).protocol === 'http:';
561
565
  const send = (payload) => {
562
566
  const json = JSON.stringify(payload);
package/hook-optimize.mjs CHANGED
@@ -484,8 +484,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
484
484
  // clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
485
485
  // A compression keeper is never hidden: hiding it hides every member compressed into it
486
486
  // (the same rule NOT_COMPRESSION_KEEPER_SQL enforces on the maintenance writers). It falls
487
- // through to the normal update instead: floored to importance 1 and stamped, so it is not
488
- // re-sent. In narrow scope that update still replaces its title and narrative.
487
+ // through instead: in narrow scope to the stamp-only write below (floored to importance 1
488
+ // and stamped, so it is not re-sent), in wide scope to the normal update.
489
489
  const isKeeper = !!db
490
490
  .prepare('SELECT 1 FROM observations WHERE compressed_into = ? LIMIT 1')
491
491
  .get(cand.id);
@@ -494,12 +494,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
494
494
  const humanSet =
495
495
  (db.prepare('SELECT importance_set_at FROM observations WHERE id = ?').get(cand.id)
496
496
  ?.importance_set_at ?? null) !== null;
497
- if (
498
- (parsed.importance === 0 || parsed.importance === '0') &&
499
- scope !== 'wide' &&
500
- !isKeeper &&
501
- !humanSet
502
- ) {
497
+ const scoredZero = parsed.importance === 0 || parsed.importance === '0';
498
+ if (scoredZero && scope !== 'wide' && !isKeeper && !humanSet) {
503
499
  // D#12, and this one is not a stale-write guard — it is a POINTER guard.
504
500
  // `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
505
501
  // concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
@@ -511,7 +507,8 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
511
507
  const res = db
512
508
  .prepare(
513
509
  `UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
514
- WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL AND ${NOT_COMPRESSION_KEEPER_SQL}`,
510
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL AND ${NOT_COMPRESSION_KEEPER_SQL}
511
+ AND importance_set_at IS NULL`,
515
512
  )
516
513
  .run(Date.now(), cand.id);
517
514
  if (res.changes === 0) {
@@ -521,6 +518,31 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
521
518
  processed++;
522
519
  continue;
523
520
  }
521
+ // A narrow 0 reply reaches this point only for a row that may not be hidden (a compression
522
+ // keeper, or an importance a person set). It judged the row worthless, so it is no basis for
523
+ // any of the row's text either: the stored title and narrative stay (D#207: 'Weekly summary:
524
+ // auth refactor' became 'Weekly summary', narrative 'x'), and so do type, lesson, concepts,
525
+ // facts, aliases and scope (D#268). Written instead: what hiding would have written bar the
526
+ // hide, i.e. the stamp, so the row is not re-sent, plus the importance floor below. Wide
527
+ // scope stays on the normal path: its rows are substantive by construction, so a 0 there
528
+ // reads as a misjudgment of importance, not of the row.
529
+ if (scoredZero && scope !== 'wide') {
530
+ const res = db
531
+ .prepare(
532
+ `UPDATE observations SET
533
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
534
+ ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
535
+ optimized_at = ?
536
+ WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL`,
537
+ )
538
+ .run(clampImportance(parsed.importance), Date.now(), cand.id);
539
+ if (res.changes === 0) {
540
+ skipped++;
541
+ continue;
542
+ }
543
+ processed++;
544
+ continue;
545
+ }
524
546
 
525
547
  // Enrichment ("add a lesson") must not reclassify a specific type down to the generic
526
548
  // 'change' (lower TYPE_QUALITY + faster decay); keep the stored type on that downgrade.
@@ -562,8 +584,9 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
562
584
  // fallback — truncating the row's own stored narrative to buy nothing was the same
563
585
  // bug in miniature.
564
586
  const isWide = scope === 'wide';
565
- const title = isWide ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
566
- const narrative = isWide
587
+ const keepStoredText = isWide;
588
+ const title = keepStoredText ? cand.title : truncate(scrubSecrets(parsed.title || ''), 120);
589
+ const narrative = keepStoredText
567
590
  ? cand.narrative
568
591
  : parsed.narrative
569
592
  ? truncate(scrubSecrets(parsed.narrative), 500)
@@ -571,9 +594,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
571
594
  // Floor at the stored importance: re-enrich adds a lesson, it must never silently downgrade
572
595
  // a user-set/promoted importance (the UPDATE also sets optimized_at → the loss is permanent).
573
596
  // Upgrades are still honored — except over an importance a person set (D10), which stays.
574
- const importance = humanSet
575
- ? cand.importance
576
- : Math.max(clampImportance(parsed.importance), cand.importance || 1);
597
+ // Decided in the UPDATE itself, on the row as it is then: the candidate's importance was read
598
+ // before the model call, and writing it back undid a person's change made during the call
599
+ // (D#206).
600
+ const modelImportance = clampImportance(parsed.importance);
577
601
 
578
602
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
579
603
  const textField = [conceptsText, factsText, searchAliases || '', bigramText].filter(Boolean).join(' ');
@@ -607,10 +631,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
607
631
  // Narrow replaces title and narrative with model text, so an explicit save it rewrites (one
608
632
  // whose save-time enrich failed) moves to the re-enrich writer's id, as a cluster-merge
609
633
  // keeper does (D#146, D#138). Wide keeps the stored title and narrative (it fills the lesson
610
- // and the side fields), so the row stays an explicit save. The writer's session row is
611
- // best-effort.
634
+ // and the side fields), so the row stays an explicit save; a narrow 0 reply never gets here.
635
+ // The writer's session row is best-effort.
612
636
  let rewriteSessionId = null;
613
- if (!isWide) {
637
+ if (!keepStoredText) {
614
638
  const cur = db.prepare('SELECT memory_session_id FROM observations WHERE id = ?').get(cand.id);
615
639
  if (cur?.memory_session_id?.startsWith(MANUAL_SESSION_ID_PREFIX)) {
616
640
  const enrichSessionId = writerSessionId('enrich-', cand.project);
@@ -629,7 +653,10 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
629
653
  .prepare(
630
654
  `
631
655
  UPDATE observations SET type=?, title=?, narrative=?, concepts=?, facts=?,
632
- text=?, importance=?, lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
656
+ text=?,
657
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance
658
+ ELSE MAX(?, COALESCE(NULLIF(importance, 0), 1)) END,
659
+ lesson_learned=?, search_aliases=?, minhash_sig=?, optimized_at=?,
633
660
  scope=COALESCE(?, scope), memory_session_id = COALESCE(?, memory_session_id)
634
661
  WHERE id = ? AND ${liveObsFilterSql('')} AND optimized_at IS NULL
635
662
  `,
@@ -641,7 +668,7 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
641
668
  safe.concepts,
642
669
  safe.facts,
643
670
  safe.text,
644
- importance,
671
+ modelImportance,
645
672
  safe.lesson_learned,
646
673
  safe.search_aliases,
647
674
  minhashSig,
@@ -1300,6 +1327,8 @@ Return ONLY valid JSON:
1300
1327
  const bigramText = cjkBigrams((title || '') + ' ' + (narrative || ''));
1301
1328
  const textField = [conceptsText, factsText, bigramText].filter(Boolean).join(' ');
1302
1329
  const minhashSig = computeMinHash((title || '') + ' ' + (narrative || ''));
1330
+ // An importance a person set on the keeper stays (D10); the UPDATE decides that on the row as
1331
+ // it is then, since a person can set it while this call waits on the model (D#198).
1303
1332
  const importance = Math.max(clampImportance(parsed.importance || 2), maxClusterImportance);
1304
1333
 
1305
1334
  // Scrub LLM-output cluster-merge text fields at the UPDATE boundary.
@@ -1382,7 +1411,8 @@ Return ONLY valid JSON:
1382
1411
  db.prepare(
1383
1412
  `
1384
1413
  UPDATE observations SET title=?, narrative=?, concepts=?, facts=?, text=?,
1385
- importance=?, lesson_learned=?, minhash_sig=?, optimized_at=?,
1414
+ importance = CASE WHEN importance_set_at IS NOT NULL THEN importance ELSE ? END,
1415
+ lesson_learned=?, minhash_sig=?, optimized_at=?,
1386
1416
  memory_session_id = COALESCE(?, memory_session_id)
1387
1417
  WHERE id = ?
1388
1418
  `,
package/hook-shared.mjs CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from './lib/schema-skew.mjs';
31
31
  import { isDbUnusableError, DB_UNUSABLE_MARKER_PREFIX } from './lib/db-unusable.mjs';
32
32
  import { shouldRecordOnce } from './lib/record-once.mjs';
33
+ import { backgroundWorkerEnv, workerDirGone } from './lib/worker-data-dir.mjs';
33
34
  import { hookSessionId } from './lib/provenance.mjs';
34
35
  import { PROJECT_REKEY_MARKER_PREFIX } from './lib/project-rekey.mjs';
35
36
  // Audit 2026-09-05 P1-2 (carried from 2026-09-02 P2-9): `callLLM`, the quiet/adoption
@@ -280,7 +281,8 @@ export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
280
281
  // out. Both are version-keyed one-shot migration sentinels written by
281
282
  // scripts/setup.sh, and their gate is `! -f <marker>` — deleting one re-runs
282
283
  // its migration. `.mcp-dedup-v2.78` gates a block that removes
283
- // mcpServers.mem / mcpServers["mem-lite"] from the user's ~/.claude.json with
284
+ // mcpServers.mem / mcpServers["mem-lite"] from the config home's .claude.json (one marker per
285
+ // .claude.json since D#270, the same prefix) with
284
286
  // a raw writeFileSync (no tmp+rename, no backup), which the repo's own test
285
287
  // documents as intentionally one-shot: "If a user later runs `claude mcp add
286
288
  // mem ...` themselves, the gate intentionally lets it stand." A 30-day sweep
@@ -378,9 +380,11 @@ export function sweepStaleProjectMarkers(
378
380
  // exists, so harden here too: create 0700, and chmod a pre-existing dir a prior version
379
381
  // created at the default umask. A 0700 dir blocks traversal to every file inside,
380
382
  // current and future, regardless of individual file mode (audit sec P3-2 2026-07-24).
383
+ // A background worker whose data dir was removed while it ran does not re-create it (D#265).
381
384
  try {
382
- if (!existsSync(RUNTIME_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
383
- else chmodSync(RUNTIME_DIR, 0o700);
385
+ if (!existsSync(RUNTIME_DIR)) {
386
+ if (!workerDirGone(DB_DIR)) mkdirSync(RUNTIME_DIR, { recursive: true, mode: 0o700 });
387
+ } else chmodSync(RUNTIME_DIR, 0o700);
384
388
  } catch {}
385
389
 
386
390
  // ─── Session ID Management ───────────────────────────────────────────────────
@@ -604,7 +608,7 @@ export function spawnBackground(bgEvent, ...extraArgs) {
604
608
  const child = spawn(process.execPath, args, {
605
609
  detached: true,
606
610
  stdio: 'ignore',
607
- env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
611
+ env: backgroundWorkerEnv(),
608
612
  });
609
613
  child.on('error', (err) => {
610
614
  debugCatch(err, 'spawnBackground');