claude-mem-lite 6.28.1 → 6.29.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.28.1",
12
+ "version": "6.29.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.28.1",
3
+ "version": "6.29.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,35 @@ 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.29.0
241
+
242
+ **Chinese text in lessons, facts, concepts and aliases becomes searchable, and the database opens
243
+ after the update fill that in without holding up a hook.** No schema-version change: 6.28.1 still
244
+ opens, searches and writes the database after this release has. Go back only after the fill
245
+ finishes: rows it has not reached stay without the Latin identifiers glued to Chinese under 6.28.1.
246
+
247
+ - **What it costs.** Each database open spends about 250 ms filling the index terms of existing
248
+ observations, newest first, each batch committed, until all are done. On a copy of the
249
+ maintainer's store (516 observations, most already filled by an earlier build of this pass)
250
+ the pass walked every row in one open of 48–49 ms. On a synthetic v6.27.0 store of 250,000
251
+ observations and 200,000 events (mostly CJK) the first open takes about 1 s (6.28.1: about 9 s,
252
+ and a 5 s hook opening it first was killed), and the fill then takes 105 opens of about 0.5 s;
253
+ an open after it takes 3 ms.
254
+ - **A lesson a model writes onto one of your explicit saves starts with `🤖`**, and `get` says
255
+ "🤖 lesson auto-written". A lesson you write is never marked.
256
+ - **Exit codes.** A flag that takes no value (`--force`, `--json`, `--confirm`, `--or`,
257
+ `--dry-run`, …) no longer swallows the next word: `delete --confirm 12` now deletes #12, and
258
+ `recent --json 5` prints JSON. `maintain`, `compress`, `optimize`, `delete`, `activity promote` /
259
+ `delete`, `import-jsonl`, `defer drop` and `fts-check rebuild`, which never read `--dry-run`,
260
+ refuse it with exit 1 on a run that would write, instead of running; `activity save`, `import-jsonl` and `restore` refuse a mistyped
261
+ `--project`; `uninstall` exits 3 when the MCP registration is left. CHANGELOG.md lists the
262
+ rest; a script that relied on the old behaviour needs a look.
263
+ - **Also in this release:** the database waits of a hook that opens through `ensureDb` all end by
264
+ its deadline, the first open's migration, the prompt search and the Agent injection included;
265
+ the unattended re-enrich raises importance to 2 at most; three more background prompts send
266
+ stored rows to the model as data, and smart-compress and cluster merge act only on an explicit
267
+ yes. Details in CHANGELOG.md.
268
+
240
269
  ## Upgrading to 6.28.1
241
270
 
242
271
  **After the update, events are re-indexed so Chinese text in them is searchable** (the Han ranges
package/README.zh-CN.md CHANGED
@@ -199,6 +199,15 @@ rm -rf ~/claude-mem-lite/ # v0.5 前的非隐藏目录(如未自动迁移)
199
199
  repos/ # 浅克隆的源代码仓库
200
200
  ```
201
201
 
202
+ ## 升级到 6.29.0
203
+
204
+ **lesson、facts、concepts 和 aliases 里的中文变得可以搜到;更新后的数据库打开会逐步补上这些索引,不会拖住钩子。** 没有 schema 版本变化:本版打开过的数据库,6.28.1 仍能打开、搜索和写入。要退回,请等补完之后再退:还没补到的行,在 6.28.1 下会缺少粘在中文上的英文标识符。
205
+
206
+ - **代价。** 每次打开数据库约花 250 ms 为已有的 observation 补索引词,从最新的开始,每批单独提交,直到补完。维护者的库的副本(516 条 observation,大部分已由这一遍的早期版本补过)上,这一遍一次打开(48–49 ms)就走完。在 25 万条 observation、20 万条 event(大部分含中文)的合成 v6.27.0 库上,首次打开约 1 s(6.28.1 约要 9 s,先打开它的 5 s 钩子 3 次都被杀掉),之后补完需要 105 次打开、每次约 0.5 s;补完后的一次打开 3 ms。
207
+ - **模型写在你显式保存的记录上的 lesson 以 `🤖` 开头**,`get` 的标题行注明 "🤖 lesson auto-written"。你自己写的 lesson 不会被标记。
208
+ - **退出码。** 不带值的 flag(`--force`、`--json`、`--confirm`、`--or`、`--dry-run` 等)不再吞掉后面的词:`delete --confirm 12` 现在会删除 #12,`recent --json 5` 输出 JSON。`maintain`、`compress`、`optimize`、`delete`、`activity promote` / `delete`、`import-jsonl`、`defer drop` 和 `fts-check rebuild` 从来不读 `--dry-run`,在会写入的运行中遇到它会以 exit 1 拒绝,而不是照常执行;`activity save`、`import-jsonl` 和 `restore` 拒绝拼错的 `--project`;MCP 注册没删掉时 `uninstall` 以 exit 3 结束。其余见 CHANGELOG.md;依赖旧行为的脚本需要检查一下。
209
+ - **本版还有:** 经 `ensureDb` 打开数据库的钩子,所有数据库等待都在截止时间前结束,包括首次打开的迁移、提示词搜索和 Agent 注入;无人值守的 re-enrich 最多把 importance 提到 2;又有三个后台提示把存储的行作为数据交给模型;smart-compress 和 cluster merge 只在明确同意时才动手。详见 CHANGELOG.md。
210
+
202
211
  ## 升级到 6.28.1
203
212
 
204
213
  **更新后会给事件重建索引,事件里的中文从此能搜到**(切分 U+4E00–9FFF 和 U+3400–4DBF 两段汉字;假名、韩文和更少见的汉字扩展区不切分)。
package/cli/activity.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // events_fts via triggers.
10
10
 
11
11
  import { inferProject } from '../utils.mjs';
12
- import { resolveProject } from '../project-utils.mjs';
12
+ import { resolveProject, checkWriteProject } from '../project-utils.mjs';
13
13
  import { resolveCliProject as cliProject } from '../lib/cli-project.mjs';
14
14
  import { parseArgs, out, fail, rejectBareStringFlags } from './common.mjs';
15
15
  import { parseIntFlag, isNumericToken } from '../lib/cli-flags.mjs';
@@ -42,9 +42,11 @@ export async function cmdActivity(db, args) {
42
42
  // `save` CREATES a row, so it keeps plain inferProject(): the DB-aware fallback is a read
43
43
  // affordance, and applying it to a write absorbs a not-yet-born subproject's first event
44
44
  // into the enclosing repo (pre-tag review, reproduced). Every other subcommand reads or
45
- // operates on rows that already exist, where falling back is what finds them.
45
+ // operates on rows that already exist, where falling back is what finds them. A named
46
+ // project is resolved for `save` the way `save` and `defer add` resolve theirs: exact
47
+ // matching only, then checkWriteProject below.
46
48
  const project = flags.project
47
- ? resolveProject(db, flags.project)
49
+ ? resolveProject(db, flags.project, sub === 'save' ? { mode: 'write' } : undefined)
48
50
  : sub === 'save'
49
51
  ? inferProject()
50
52
  : cliProject(db);
@@ -86,6 +88,13 @@ export async function cmdActivity(db, args) {
86
88
  fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
87
89
  return;
88
90
  }
91
+ // A near miss of an existing project is refused, a new name is noted: the check `save` and
92
+ // `defer add` make (pre-tag review 2026-10-16 P3-4). stdout stays the JSON line.
93
+ const projectCheck = flags.project ? checkWriteProject(db, project) : { ok: true, note: '' };
94
+ if (!projectCheck.ok) {
95
+ fail(`[mem] ${projectCheck.message}`);
96
+ return;
97
+ }
89
98
  const id = saveEvent(db, {
90
99
  project,
91
100
  event_type: type,
@@ -95,6 +104,7 @@ export async function cmdActivity(db, args) {
95
104
  file_paths,
96
105
  });
97
106
  out(JSON.stringify({ ok: true, id }));
107
+ if (projectCheck.note) process.stderr.write(`[mem] Saved event E#${id}${projectCheck.note}\n`);
98
108
  return;
99
109
  }
100
110
 
package/cli/common.mjs CHANGED
@@ -13,6 +13,47 @@ import { neutralizeContextDelimiters, neutralizeSkillDelimiters } from '../forma
13
13
 
14
14
  // ─── Argument Parsing ────────────────────────────────────────────────────────
15
15
 
16
+ /**
17
+ * Flags that take no value: every reader tests them against true / 'true' (or for presence in
18
+ * argv), so the word after one is a positional. parseArgs read every `--key next` as key=value,
19
+ * and `save --type bugfix --title X --force 'text'` parsed `force: 'text'` and no content (the
20
+ * usage error) while the text-first spelling saved; the swallowed word did not even turn the
21
+ * flag on, so `search --or redis` lost its query and its --or (D#385, same in v6.27.0). An
22
+ * explicit `--json true` / `--force false` still parses as before.
23
+ * tests/cli-boolean-flag-parsing.test.mjs derives the boolean readers from the sources and
24
+ * fails on one missing here.
25
+ */
26
+ export const BOOLEAN_CLI_FLAGS = new Set([
27
+ 'all',
28
+ 'apply',
29
+ 'benchmark',
30
+ 'confirm',
31
+ 'deep',
32
+ 'disable',
33
+ 'dry-run',
34
+ 'enable',
35
+ 'execute',
36
+ 'force',
37
+ 'help',
38
+ 'include-compressed',
39
+ 'include-noise',
40
+ 'json',
41
+ 'metrics',
42
+ 'no-deep',
43
+ 'or',
44
+ 'print-project',
45
+ 'quality',
46
+ 'rerank',
47
+ 'retry',
48
+ 'run',
49
+ 'run-all',
50
+ 'search-telemetry',
51
+ 'session-audit',
52
+ 'sidechain',
53
+ 'status',
54
+ 'verbose',
55
+ ]);
56
+
16
57
  /**
17
58
  * Parse argv-style array into { positional, flags }.
18
59
  * `--key value` → flags.key = value; `--flag` (no value) → flags.key = true.
@@ -76,7 +117,11 @@ export function parseArgs(argv, { track = true } = {}) {
76
117
  }
77
118
  const key = body;
78
119
  const next = argv[i + 1];
79
- if (next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next))) {
120
+ // A flag that takes no value leaves the next word alone, except an explicit true/false.
121
+ const takes = BOOLEAN_CLI_FLAGS.has(canonicalFlag(key))
122
+ ? next === 'true' || next === 'false'
123
+ : next !== undefined && !next.startsWith('--') && (!next.startsWith('-') || /^-\d/.test(next));
124
+ if (takes) {
80
125
  setFlag(key, next);
81
126
  i += 2;
82
127
  } else {
package/hook-episode.mjs CHANGED
@@ -16,7 +16,15 @@ import {
16
16
  constants as fsConstants,
17
17
  } from 'fs';
18
18
  import { inferProject, isEditEntry } from './utils.mjs';
19
- import { RUNTIME_DIR, hostScopeSuffix, deadHostFiles, isOtherLiveHost } from './hook-shared.mjs';
19
+ import {
20
+ RUNTIME_DIR,
21
+ hostScopeSuffix,
22
+ deadHostFiles,
23
+ isOtherLiveHost,
24
+ processAlive,
25
+ STALE_LOCK_MS,
26
+ ABANDONED_LOCK_MS,
27
+ } from './hook-shared.mjs';
20
28
  import { inferProjectDir } from './project-utils.mjs';
21
29
  import { legacyProjectNameFromDir } from './lib/project-rekey.mjs';
22
30
 
@@ -124,8 +132,12 @@ export function lockFile() {
124
132
  */
125
133
  export function acquireLock(maxWaitMs = 500) {
126
134
  const lf = lockFile();
127
- const deadline = Date.now() + maxWaitMs;
128
- while (Date.now() < deadline) {
135
+ // The wait is measured on this process's own clock (performance.now()), which a step of the
136
+ // system clock does not move, as the hook's DB deadline is (setHookDbDeadline). On Date.now() a
137
+ // backward step kept a hook spinning here until the host killed it, and a forward one gave up
138
+ // at once (D#398).
139
+ const start = performance.now();
140
+ while (performance.now() - start < maxWaitMs) {
129
141
  try {
130
142
  let fd;
131
143
  try {
@@ -141,15 +153,14 @@ export function acquireLock(maxWaitMs = 500) {
141
153
  try {
142
154
  const raw = readFileSync(lf, 'utf8');
143
155
  const info = JSON.parse(raw);
156
+ // On the wall clock: the stamp is another process's, and only that clock is shared.
144
157
  const age = Date.now() - (info.ts || 0);
145
- let stale = age > 30000; // >30s = stale
146
- if (!stale && info.pid) {
147
- try {
148
- process.kill(info.pid, 0);
149
- } catch (killErr) {
150
- stale = killErr.code === 'ESRCH'; // Only stale if process truly gone
151
- }
152
- }
158
+ // A recorded holder is asked first. A gone one's lock is stale at once; a running one keeps
159
+ // it until ABANDONED_LOCK_MS, the bound cleanStaleLockFiles and proc-lock give a live
160
+ // holder, past which its pid is taken to be recycled. Age alone (> STALE_LOCK_MS) took a
161
+ // running holder's lock without asking once a forward clock step or a suspended machine
162
+ // made it look 30 s old, and two hooks then rewrote the buffer at once (D#398).
163
+ const stale = info.pid ? !processAlive(info.pid) || age > ABANDONED_LOCK_MS : age > STALE_LOCK_MS;
153
164
  if (stale) {
154
165
  try {
155
166
  unlinkSync(lf);
package/hook-llm.mjs CHANGED
@@ -246,7 +246,13 @@ function buildFtsTextField(obs) {
246
246
  const conceptsText = Array.isArray(obs.concepts) ? obs.concepts.join(' ') : '';
247
247
  const factsText = Array.isArray(obs.facts) ? obs.facts.join(' ') : '';
248
248
  const aliasesText = obs.searchAliases || '';
249
- const bigramText = cjkIndexTerms((obs.title || '') + ' ' + (obs.narrative || ''));
249
+ // Every field the row is searched by, not title + narrative alone: unicode61 keeps a CJK run as
250
+ // one token, so CJK in a lesson, fact, concept or alias was unreachable without these (D#374).
251
+ const bigramText = cjkIndexTerms(
252
+ [obs.title, obs.narrative, conceptsText, factsText, aliasesText, obs.lessonLearned]
253
+ .filter(Boolean)
254
+ .join(' '),
255
+ );
250
256
 
251
257
  // Degraded fallback: when LLM enrichment is missing, extract lightweight keywords
252
258
  // from title + narrative so degraded observations remain FTS-searchable
@@ -449,7 +455,9 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
449
455
  minhash_sig: minhashSig,
450
456
  lesson_learned: safe.lesson_learned,
451
457
  search_aliases: safe.search_aliases,
452
- branch: getCurrentBranch(),
458
+ // An episode worker passes the branch its flush file names, null included (P3-5); every
459
+ // other writer runs in the session's own cwd.
460
+ branch: obs.branch === undefined ? getCurrentBranch() : obs.branch,
453
461
  created_at: now.toISOString(),
454
462
  created_at_epoch: now.getTime(),
455
463
  // P3 (D#78): re-validate at the write boundary — saveObservation is also
@@ -500,7 +508,9 @@ function obsToSummary(obs) {
500
508
  //
501
509
  // Input shape (matches the v2.31 MVP plan's stable interface):
502
510
  // summary: { type, title, lesson_learned?, narrative?, importance?, files_modified? }
503
- // ctx: { project, session_id, preSavedObsId? }
511
+ // ctx: { project, session_id, preSavedObsId?, branch? }
512
+ // (`branch`: the episode's, observations only — events have no branch column; omitted, the
513
+ // row takes this process's cwd branch)
504
514
  //
505
515
  // Returns { table: 'events'|'observations', id: number|null }. Callers inspect
506
516
  // `table` to decide whether follow-up observations-only logic (linking, vector
@@ -563,6 +573,7 @@ export function persistHaikuSummary(db, summary, ctx) {
563
573
  lessonLearned: summary.lesson_learned || null,
564
574
  searchAliases: summary.search_aliases || null,
565
575
  scope: summary.scope ?? null,
576
+ branch: ctx.branch,
566
577
  },
567
578
  ctx.project,
568
579
  ctx.session_id,
@@ -1033,6 +1044,22 @@ export function episodeWorkerMaxMs() {
1033
1044
  return EPISODE_DELAY_MAX_MS + 2 * (LLM_SEM_TIMEOUT + BG_LLM_TIMEOUT_MS) + EPISODE_WORKER_SLACK_MS;
1034
1045
  }
1035
1046
 
1047
+ /**
1048
+ * episodeWorkerMaxMs() for a worker of v6.27.0 or older, which never claims its flush file
1049
+ * (claimFlushFile is v6.28.0's) and whose keyed-provider fallback gave the CLI a fresh full
1050
+ * timeout, so each of its two calls could take 2 x BG_LLM_TIMEOUT_MS: 365 s today. The age an
1051
+ * unclaimed file must reach before recoverOrphanEpisodeFlushes hands it on (pre-tag review
1052
+ * 2026-10-16 P3-4): during the update from such a version its worker can still be saving the
1053
+ * episode at 275 s, and a second worker saved it again. A worker of this version claims its file
1054
+ * before its delay, so an unclaimed file is otherwise one whose worker never started, and it is
1055
+ * handed on 90 s later than a claimed one.
1056
+ *
1057
+ * @returns {number} ms
1058
+ */
1059
+ export function unclaimedFlushMinAgeMs() {
1060
+ return EPISODE_DELAY_MAX_MS + 2 * (LLM_SEM_TIMEOUT + 2 * BG_LLM_TIMEOUT_MS) + EPISODE_WORKER_SLACK_MS;
1061
+ }
1062
+
1036
1063
  /** The ending a flush file takes when it is handed to a second worker. It is never handed on again. */
1037
1064
  export const RECOVERED_FLUSH_SUFFIX = '.recovered.json';
1038
1065
  // Per SessionStart: LLM_SEM_MAX workers can call the model at once, and a backlog after a reboot
@@ -1078,7 +1105,9 @@ export function claimFlushFile(path, pid = process.pid) {
1078
1105
  * worker paused past that bound by a suspended machine finishes and saves once the machine
1079
1106
  * wakes, so handing its file on too stored the episode twice (D#383: events have no
1080
1107
  * duplicate check). A pid reused by an unrelated process reads as alive, and that file is
1081
- * left to the 1 h sweep, as every flush file was before this hand-off existed.
1108
+ * left to the 1 h sweep, as every flush file was before this hand-off existed. An unclaimed
1109
+ * file waits for unclaimedFlushMinAgeMs() instead: it can be a pre-claim worker's, whose
1110
+ * bound is longer.
1082
1111
  * - One taker: the file is renamed before the spawn, and of two SessionStarts only one rename
1083
1112
  * succeeds; of a SessionStart and a worker claiming the same file, likewise.
1084
1113
  * - No loop: the new name carries `.recovered`, which this never hands on, claimed or not. If
@@ -1106,6 +1135,7 @@ export function recoverOrphanEpisodeFlushes(
1106
1135
  return [];
1107
1136
  }
1108
1137
  const minAge = episodeWorkerMaxMs();
1138
+ const unclaimedMinAge = unclaimedFlushMinAgeMs();
1109
1139
  const candidates = [];
1110
1140
  for (const f of names) {
1111
1141
  const m = FLUSH_NAME_RE.exec(f);
@@ -1117,7 +1147,8 @@ export function recoverOrphanEpisodeFlushes(
1117
1147
  continue; // its worker finished meanwhile
1118
1148
  }
1119
1149
  const age = now - mtimeMs;
1120
- if (age > minAge && age < ORPHAN_EPISODE_AGE_MS) candidates.push({ f, mtimeMs });
1150
+ if (age > (m[3] ? minAge : unclaimedMinAge) && age < ORPHAN_EPISODE_AGE_MS)
1151
+ candidates.push({ f, mtimeMs });
1121
1152
  }
1122
1153
  candidates.sort((a, b) => a.mtimeMs - b.mtimeMs || (a.f < b.f ? -1 : 1));
1123
1154
  const handed = [];
@@ -1471,6 +1502,10 @@ export async function handleLLMEpisode() {
1471
1502
  try {
1472
1503
  let savedId;
1473
1504
  let savedTable;
1505
+ // One context for every save below. `branch` is the one the flushing hook read in the
1506
+ // session's cwd (P3-5); a file written before that stamp has none, and the row takes this
1507
+ // worker's cwd branch, as before.
1508
+ const ctx = { project: episode.project, session_id: episode.sessionId, branch: episode.branch };
1474
1509
 
1475
1510
  if (episode.savedId && obs) {
1476
1511
  if (EVENT_TYPE_SET.has(obs.type)) {
@@ -1478,11 +1513,7 @@ export async function handleLLMEpisode() {
1478
1513
  // classified by Haiku as an event type. Delete the stale observations
1479
1514
  // row and insert into events instead. Dispatcher handles the atomic
1480
1515
  // swap via transaction.
1481
- const result = persistHaikuSummary(db, obsToSummary(obs), {
1482
- project: episode.project,
1483
- session_id: episode.sessionId,
1484
- preSavedObsId: episode.savedId,
1485
- });
1516
+ const result = persistHaikuSummary(db, obsToSummary(obs), { ...ctx, preSavedObsId: episode.savedId });
1486
1517
  savedId = result.id;
1487
1518
  savedTable = result.table;
1488
1519
  debugLog('DEBUG', 'llm-episode', `upgrade-delete: obs #${episode.savedId} → event #${savedId}`);
@@ -1549,10 +1580,7 @@ export async function handleLLMEpisode() {
1549
1580
  'llm-episode',
1550
1581
  `pre-saved obs #${episode.savedId} no longer live — saving enrichment as a fresh row`,
1551
1582
  );
1552
- const result = persistHaikuSummary(db, obsToSummary(obs), {
1553
- project: episode.project,
1554
- session_id: episode.sessionId,
1555
- });
1583
+ const result = persistHaikuSummary(db, obsToSummary(obs), ctx);
1556
1584
  savedId = result.id;
1557
1585
  savedTable = result.table;
1558
1586
  } else {
@@ -1563,10 +1591,7 @@ export async function handleLLMEpisode() {
1563
1591
  }
1564
1592
  } else {
1565
1593
  // Clean insert (no pre-save) — dispatcher routes by type.
1566
- const result = persistHaikuSummary(db, obsToSummary(obs), {
1567
- project: episode.project,
1568
- session_id: episode.sessionId,
1569
- });
1594
+ const result = persistHaikuSummary(db, obsToSummary(obs), ctx);
1570
1595
  savedId = result.id;
1571
1596
  savedTable = result.table;
1572
1597
  }
@@ -1591,15 +1616,34 @@ export async function handleLLMEpisode() {
1591
1616
 
1592
1617
  // ─── Background: LLM Session Summary ────────────────────────────────────────
1593
1618
 
1594
- export async function handleLLMSummary() {
1595
- // Seconds. The default spans an episode worker's whole bound (E2E 2026-10-16 P2-4): it was 15,
1596
- // below one CLI-leg episode (2-5 s delay + 8-13 s call, + the lesson retry on a bugfix), so the
1597
- // summary read the DB before the episode's observation existed, found nothing, and the session
1598
- // kept its Stop-time baseline. A longer default costs nothing when the flush clears early: the
1599
- // loop below exits on the first tick that finds this summary's files gone.
1600
- const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
1601
- const flushTimeout = Number.isNaN(parsed) ? Math.ceil(episodeWorkerMaxMs() / 1000) : parsed;
1619
+ /**
1620
+ * The stem of a flush file whose episode a worker can still save, or null: a name that is not a
1621
+ * flush file's, or a file whose claiming worker (`.w<pid>`, claimFlushFile) is gone. Such a file
1622
+ * waits for SessionStart's hand-off, which takes it only once it is older than
1623
+ * episodeWorkerMaxMs(), so a summary waiting on it waited its whole default for nothing (pre-tag
1624
+ * review 2026-10-16 P3-3: ~82 MB resident for up to 275 s, one per Stop). A pid reused by another
1625
+ * process reads as alive, and its file is waited on as before.
1626
+ *
1627
+ * @param {string} name
1628
+ * @returns {string|null}
1629
+ */
1630
+ function inFlightFlushStem(name) {
1631
+ const m = FLUSH_NAME_RE.exec(name);
1632
+ if (!m || (m[3] && !processAlive(Number(m[3])))) return null;
1633
+ return m[1];
1634
+ }
1602
1635
 
1636
+ /**
1637
+ * Wait up to `flushTimeout` seconds for the episode flushes in flight when it is called, so the
1638
+ * summary reads the observations they save. Returns true as soon as `superseded()` says this
1639
+ * summary's reply would be dropped anyway (checked before every tick): Stop spawns one summary
1640
+ * worker per turn, and each waited out the flush after a later Stop had made it moot (P3-3).
1641
+ *
1642
+ * @param {number} flushTimeout seconds
1643
+ * @param {() => boolean} superseded
1644
+ * @returns {Promise<boolean>}
1645
+ */
1646
+ async function waitForEpisodeFlushes(flushTimeout, superseded) {
1603
1647
  // Wait for a DEFINED SET of flush files, not for "the directory is empty" (audit
1604
1648
  // 2026-09-02 P1-7). RUNTIME_DIR is shared by every project on the machine, and the old
1605
1649
  // predicate was `readdirSync(RUNTIME_DIR).some(f => f.startsWith('ep-flush-'))` — so:
@@ -1623,14 +1667,15 @@ export async function handleLLMSummary() {
1623
1667
  // is a bounded overlap with genuinely concurrent work.
1624
1668
  //
1625
1669
  // Tracked by stem (D#383): the worker renames its file when it claims it, and a hand-off renames
1626
- // it again, so the flush is in flight until no file of that stem is left.
1670
+ // it again, so the flush is in flight until no file of that stem is left, or only one whose
1671
+ // claiming worker is gone (inFlightFlushStem).
1627
1672
  let pending;
1628
1673
  try {
1629
1674
  const cutoff = Date.now() - episodeWorkerMaxMs();
1630
1675
  pending = [
1631
1676
  ...new Set(
1632
1677
  readdirSync(RUNTIME_DIR)
1633
- .filter((f) => FLUSH_NAME_RE.test(f))
1678
+ .filter((f) => inFlightFlushStem(f) !== null)
1634
1679
  .filter((f) => {
1635
1680
  try {
1636
1681
  return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff;
@@ -1646,6 +1691,7 @@ export async function handleLLMSummary() {
1646
1691
  }
1647
1692
 
1648
1693
  for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
1694
+ if (superseded()) return true;
1649
1695
  await sleep(1000);
1650
1696
  let names;
1651
1697
  try {
@@ -1653,7 +1699,7 @@ export async function handleLLMSummary() {
1653
1699
  } catch {
1654
1700
  names = [];
1655
1701
  }
1656
- pending = pending.filter((stem) => names.some((f) => f.startsWith(`${stem}.`)));
1702
+ pending = pending.filter((stem) => names.some((f) => inFlightFlushStem(f) === stem));
1657
1703
  debugLog(
1658
1704
  'DEBUG',
1659
1705
  'llm-summary',
@@ -1667,10 +1713,22 @@ export async function handleLLMSummary() {
1667
1713
  `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`,
1668
1714
  );
1669
1715
  }
1716
+ return false;
1717
+ }
1718
+
1719
+ export async function handleLLMSummary() {
1720
+ // Seconds. The default spans an episode worker's whole bound (E2E 2026-10-16 P2-4): it was 15,
1721
+ // below one CLI-leg episode (2-5 s delay + 8-13 s call, + the lesson retry on a bugfix), so the
1722
+ // summary read the DB before the episode's observation existed, found nothing, and the session
1723
+ // kept its Stop-time baseline. A longer default costs nothing when the flush clears early: the
1724
+ // wait exits on the first tick that finds this summary's files gone.
1725
+ const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
1726
+ const flushTimeout = Number.isNaN(parsed) ? Math.ceil(episodeWorkerMaxMs() / 1000) : parsed;
1670
1727
 
1671
1728
  // The spawning Stop's epoch (absent for the /clear spawn and pre-upgrade workers). Checked
1672
- // before the model call and again in the write's transaction, because two workers of one
1673
- // session can finish out of order (P3-6); the later Stop's worker reads the newer window.
1729
+ // during the flush wait, before the model call and again in the write's transaction, because
1730
+ // two workers of one session can finish out of order (P3-6); the later Stop's worker reads the
1731
+ // newer window.
1674
1732
  const parsedEpoch = Number(process.argv[5]);
1675
1733
  const spawnEpoch = Number.isFinite(parsedEpoch) && parsedEpoch > 0 ? parsedEpoch : null;
1676
1734
  // One `summary_worker` metric row per exit (CLAUDE_MEM_METRICS=1), carrying the session and
@@ -1688,6 +1746,7 @@ export async function handleLLMSummary() {
1688
1746
  ...(llmMs === undefined ? {} : { llmMs }),
1689
1747
  });
1690
1748
 
1749
+ // Opened before the flush wait, which reads the session's latest Stop before every tick.
1691
1750
  const db = openDb();
1692
1751
  if (!db) return outcome('no-db');
1693
1752
 
@@ -1695,6 +1754,11 @@ export async function handleLLMSummary() {
1695
1754
  const sessionId = process.argv[3] || getSessionId();
1696
1755
  const project = process.argv[4] || inferProject();
1697
1756
 
1757
+ if (await waitForEpisodeFlushes(flushTimeout, () => summarySuperseded(db, sessionId, spawnEpoch))) {
1758
+ debugLog('DEBUG', 'llm-summary', 'a later Stop owns this session summary, stopped waiting');
1759
+ return outcome('superseded-before-call');
1760
+ }
1761
+
1698
1762
  // Exclude LOW_SIGNAL hook-llm fallback titles ("Error: files +2 more: ...",
1699
1763
  // "Modified X", "Worked on X", etc.) from the Haiku summary input — they
1700
1764
  // pollute the `completed` field and mislead session-resume context.