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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -0
- package/README.zh-CN.md +9 -0
- package/cli/activity.mjs +13 -3
- package/cli/common.mjs +46 -1
- package/hook-episode.mjs +22 -11
- package/hook-llm.mjs +95 -31
- package/hook-optimize.mjs +57 -31
- package/hook-shared.mjs +9 -10
- package/hook-update.mjs +23 -18
- package/hook.mjs +8 -3
- package/install.mjs +68 -20
- package/lib/compress-core.mjs +9 -2
- package/lib/hook-prune.mjs +13 -5
- package/lib/import-jsonl.mjs +6 -2
- package/lib/install-shape.mjs +17 -1
- package/lib/memory-input-guard.mjs +9 -5
- package/lib/observation-write.mjs +9 -12
- package/lib/provenance.mjs +15 -1
- package/lib/save-enrich.mjs +19 -10
- package/lib/time-constants.mjs +19 -11
- package/mem-cli.mjs +82 -9
- package/nlp.mjs +28 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/plugin-cache-guard.mjs +21 -16
- package/project-utils.mjs +29 -11
- package/schema.mjs +299 -49
- package/scripts/pre-agent-inject.js +5 -1
- package/scripts/user-prompt-search.js +4 -2
- package/server.mjs +35 -7
- package/tool-schemas.mjs +4 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
1673
|
-
// session can finish out of order (P3-6); the later Stop's worker reads the
|
|
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.
|