claude-mem-lite 3.91.0 → 3.93.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 +14 -0
- package/README.zh-CN.md +1 -0
- package/claudemd.mjs +9 -6
- package/hash-utils.mjs +12 -0
- package/hook-context.mjs +9 -8
- package/hook-llm.mjs +96 -37
- package/hook-optimize.mjs +51 -29
- package/hook-shared.mjs +62 -12
- package/hook.mjs +139 -56
- package/install.mjs +24 -13
- package/lib/atomic-write.mjs +14 -3
- package/lib/cite-back-hint.mjs +3 -3
- package/lib/cite-recall-path.mjs +52 -0
- package/lib/compress-core.mjs +31 -15
- package/lib/export-columns.mjs +45 -0
- package/lib/frontmatter.mjs +67 -0
- package/lib/get-core.mjs +17 -0
- package/lib/hook-stdin.mjs +134 -0
- package/lib/injected-ids.mjs +93 -4
- package/lib/maintain-core.mjs +191 -5
- package/lib/observation-write.mjs +71 -13
- package/lib/plugin-key.mjs +44 -0
- package/lib/registry-core.mjs +165 -1
- package/lib/resolve-data-dir.mjs +58 -0
- package/lib/save-enrich.mjs +10 -6
- package/lib/save-observation.mjs +44 -0
- package/lib/transcript-scan.mjs +59 -8
- package/mem-cli.mjs +95 -229
- package/memdir.mjs +6 -6
- package/npm-shrinkwrap.json +20 -2
- package/package.json +11 -1
- package/registry-importer.mjs +4 -34
- package/registry-recommend.mjs +2 -2
- package/registry.mjs +2 -1
- package/schema.mjs +18 -1
- package/scripts/hook-launcher.mjs +10 -0
- package/scripts/post-tool-recall.js +19 -8
- package/scripts/pre-agent-inject.js +12 -4
- package/scripts/pre-skill-bridge.js +9 -5
- package/scripts/pre-tool-recall.js +44 -40
- package/scripts/user-prompt-search.js +37 -60
- package/search-scoring.mjs +10 -0
- package/server.mjs +95 -215
- package/source-files.mjs +4 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.93.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.93.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF 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
|
@@ -823,6 +823,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
823
823
|
| `CLAUDE_MEM_ERROR_RECALL_BM25_MIN` | Relevance floor for the error-recall surface (memories injected after a failed Bash command). **Off by default.** Setting it to `10.5` (the calibrated value) makes the surface stay silent when its best-matching memory is not actually about the failure — the whole set is dropped, never trimmed row-by-row. **It is a real trade, not a free win:** measured on a live database at that threshold, injections fall ~37% and ~39% of firings go silent, concentrated in projects with few memories. Off by default because nothing shows the dropped rows were noise. Explore with `node benchmark/error-recall-suite.mjs --sweep`. | `0` (off) |
|
|
824
824
|
| `CLAUDE_MEM_ERROR_RECALL_RERANK` | `off` restores the flat keyword ordering of the error-recall surface. **On by default**, and unlike the floor above it removes nothing: memories that share only the failed command's vocabulary are demoted below memories that mention the failure itself, and when a project has none of the latter the result is unchanged. Measured on a live database over 52 real failing commands × 15 projects: the lead memory matched no error term in 42.3% of firings before, 21.5% after, with the injected row count identical. | _(on)_ |
|
|
825
825
|
| `CLAUDE_MEM_ERROR_RECALL_ON_FAILURE` | `off` stops the plugin from recalling memories when a Bash command **fails at the host level**. On by default. Claude Code delivers failed tool calls to a separate `PostToolUseFailure` hook event, so before this the surface only ever saw commands that exited `0` while printing error-ish text — a genuinely failing build recalled nothing. Denials from your own guardrails (sandbox, policy hooks, declined permission prompts) and commands you interrupted are never recalled for. | _(on)_ |
|
|
826
|
+
| `CLAUDE_MEM_REGISTRY_CONFINE` | `off` lets registry enrichment read a resource whose `local_path` lies outside the managed data directory. **On by default.** Enrichment reads the file at `resources.local_path` and sends it to an LLM; the confinement check used to guard one of the four code paths that do this (`mem_registry(action="enrich")`) and not the other three, so `enrich <name>`, `enrich --all` and `import --enrich` read any path the row happened to hold. All four are gated now. Turn it off only if you deliberately registered resources outside `CLAUDE_MEM_DIR` and want them enriched; only `off` disables it — case-insensitively and ignoring surrounding whitespace — so a typo leaves the guard on. | _(on)_ |
|
|
826
827
|
| `CLAUDE_MEM_UPS_IDENTIFIER_BYPASS` | `0` disables the bypass that lets an exact identifier match skip the score floors. | _(on)_ |
|
|
827
828
|
| `CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT` | How many past-prompt rows the fallback arm may return. | `1` |
|
|
828
829
|
| `MEM_COVERAGE_THRESHOLD` | Fraction of query terms a memory must cover to qualify (∈ [0,1]). | `0.4` |
|
|
@@ -894,6 +895,19 @@ Set by the tool or by the test harness. Setting these by hand is not supported:
|
|
|
894
895
|
`CLAUDE_MEM_DB_PATH`, `CLAUDE_MEM_RUNTIME_DIR`, `MEM_DISABLE_SPAWN_LOG`.
|
|
895
896
|
`CLAUDE_PLUGIN_ROOT` is set by Claude Code itself.
|
|
896
897
|
|
|
898
|
+
The last two are worth one more sentence each, because they are the ones a harness reaches
|
|
899
|
+
for. `CLAUDE_MEM_RUNTIME_DIR` relocates the runtime directory for hook-written state — markers,
|
|
900
|
+
cooldowns, hook-error telemetry, the native-binding breakage marker, `metrics/`, episode
|
|
901
|
+
buffers. Before v3.93.0 it was honoured by some readers and ignored by others, so setting it
|
|
902
|
+
split the runtime rather than moving it. Installation-identity state (`install.lock`,
|
|
903
|
+
`update-state.json`, update residue) deliberately stays under `CLAUDE_MEM_DIR`: two
|
|
904
|
+
installers pointed at different override directories would otherwise each take their own
|
|
905
|
+
lock and both proceed. **`CLAUDE_MEM_DB_PATH` still has the split shape**, and more narrowly
|
|
906
|
+
than it looks: exactly ONE component reads it — `scripts/pre-tool-recall.js` — so setting it
|
|
907
|
+
aims that single hook at one database and leaves the other four hook faces, the CLI and the
|
|
908
|
+
MCP server on the default. Use `CLAUDE_MEM_DIR` — the only override
|
|
909
|
+
every component respects, including the bash pre-filter — to isolate state.
|
|
910
|
+
|
|
897
911
|
Three more are set by `vitest.config.mjs` / `tests/global-setup.mjs` and exist only to
|
|
898
912
|
keep a test run off the live database: `CLAUDE_MEM_TEST_GUARD` (`1` arms the guard, `off`
|
|
899
913
|
opts a test out), `CLAUDE_MEM_TEST_REALDIR` (the live data dir, captured before the suite
|
package/README.zh-CN.md
CHANGED
|
@@ -620,6 +620,7 @@ npm run benchmark:gate # CI 门控:指标回退超过 5% 容差时失败
|
|
|
620
620
|
| `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`。** | _(禁用)_ |
|
|
621
621
|
| `MEM_NO_AUTO_ADOPT` | auto-adopt 全局关闭开关(v2.82.0+)。设为 `1` 阻止每次 SessionStart 在**所有**项目自动写入 `CLAUDE.md` 托管块。项目级关闭走 `claude-mem-lite adopt --disable`(写 `<memdir>/.mem-no-auto-adopt` 哨兵,存活于 marker 删除)。 | _(禁用)_ |
|
|
622
622
|
| `MEM_NO_ADOPT_HINT` | 静音当前项目未 adopt 时 SessionStart 追加的那一行 "Invited-memory 未启用…" 提示。v2.82.1 起任何安装路径每次 SessionStart 都自动 adopt,所以该提示一般只在你显式 opt out(`MEM_NO_AUTO_ADOPT=1` 或 `claude-mem-lite adopt --disable`)的项目才会出现。 | _(禁用)_ |
|
|
623
|
+
| `CLAUDE_MEM_REGISTRY_CONFINE` | 设为 `off` 允许 registry enrich 读取 `local_path` 位于托管数据目录之外的资源。**默认开启。** enrich 会读取 `resources.local_path` 指向的文件并发给 LLM;此前这道路径限制只接在四条读取路径中的一条(`mem_registry(action="enrich")`)上,`enrich <name>`、`enrich --all` 和 `import --enrich` 三条都是裸读。现在四条同门。仅当你确实把资源注册在 `CLAUDE_MEM_DIR` 之外并希望对其 enrich 时才关闭;只有精确值 `off` 会关闭它,拼错则守卫保持开启。 | _(开启)_ |
|
|
623
624
|
|
|
624
625
|
## 许可证
|
|
625
626
|
|
package/claudemd.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
//
|
|
17
17
|
// See docs/CLAUDE-MD-STEERING-PLAN.md for rationale + migration.
|
|
18
18
|
|
|
19
|
-
import { readFileSync,
|
|
19
|
+
import { readFileSync, existsSync, unlinkSync, mkdirSync, rmdirSync, readdirSync } from 'fs';
|
|
20
|
+
import { atomicWriteFileSync as atomicWrite } from './lib/atomic-write.mjs';
|
|
20
21
|
import { join } from 'path';
|
|
21
22
|
import { createHash } from 'crypto';
|
|
22
23
|
import { memdirPath, removePluginSection, removePluginDoc, isAdopted as memdirIsAdopted } from './memdir.mjs';
|
|
@@ -54,11 +55,13 @@ function renderBlock(slug, version, body) {
|
|
|
54
55
|
|
|
55
56
|
function sha256(s) { return createHash('sha256').update(s).digest('hex'); }
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// `atomicWriteFileSync`, not a local temp+rename (audit 2026-09-02 P0-5). The local twin
|
|
59
|
+
// renamed onto the PATH; when a project's CLAUDE.md is a symlink into a dotfiles repo
|
|
60
|
+
// (chezmoi/stow/yadm) or a monorepo's shared root, that REPLACES the link with a regular
|
|
61
|
+
// file — silently, on the first SessionStart, with no user-visible signal beyond a git
|
|
62
|
+
// typechange. The shared writer lstats first and writes THROUGH to the real target. It has
|
|
63
|
+
// been in this repo, shipped and used by install.mjs for ~/.claude/settings.json, since the
|
|
64
|
+
// day that failure mode was first written down in its own docblock.
|
|
62
65
|
|
|
63
66
|
function writeState(cwd, slug, state) {
|
|
64
67
|
const dir = dotClaudeDir(cwd);
|
package/hash-utils.mjs
CHANGED
|
@@ -68,6 +68,18 @@ export function estimateJaccardFromMinHash(sig1, sig2) {
|
|
|
68
68
|
if (sig1.length !== sig2.length) return 0;
|
|
69
69
|
const numHashes = sig1.length / 8;
|
|
70
70
|
if (numHashes === 0) return 0;
|
|
71
|
+
// NEGATIVE RESULT, kept so nobody re-proposes it (audit 2026-09-02 P2-13 suggested an
|
|
72
|
+
// "allocation-free comparison"). `slice()` does allocate two 8-char strings per band, and
|
|
73
|
+
// the caller is a full nested pair loop — ~125k pairs at the 500-row scan bound. A
|
|
74
|
+
// charCodeAt inner loop was written and measured against this form over all 124,750 pairs
|
|
75
|
+
// of a 500-title fixture: identical results (0 mismatches, so the rewrite was correct) and
|
|
76
|
+
// NO time difference — 0.77× / 1.08× / 1.03× across three passes, i.e. the first pass was
|
|
77
|
+
// slower and the rest were noise. V8 handles short slices well enough that the byte loop
|
|
78
|
+
// buys nothing, and the whole pass is 0.6 ms.
|
|
79
|
+
//
|
|
80
|
+
// So the slice form stays: it is the more readable of two equally fast implementations,
|
|
81
|
+
// and shipping the other would be churn with a performance claim behind it that the
|
|
82
|
+
// measurement does not support.
|
|
71
83
|
let matches = 0;
|
|
72
84
|
for (let i = 0; i < numHashes; i++) {
|
|
73
85
|
const offset = i * 8;
|
package/hook-context.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// Handles adaptive time windows, token-budgeted selection, and legacy CLAUDE.md cleanup.
|
|
7
7
|
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
10
|
+
import { atomicWriteFileSync } from './lib/atomic-write.mjs';
|
|
10
11
|
import {
|
|
11
12
|
estimateTokens, truncate, typeIcon, fmtTime, inferProject,
|
|
12
13
|
debugLog, neutralizeContextDelimiters,
|
|
@@ -351,16 +352,16 @@ export function cleanupClaudeMdLegacyBlock() {
|
|
|
351
352
|
|
|
352
353
|
if (normalized === content) { dropMarker(); return; }
|
|
353
354
|
|
|
354
|
-
//
|
|
355
|
-
// project
|
|
356
|
-
//
|
|
357
|
-
|
|
355
|
+
// atomicWriteFileSync keeps the per-pid temp (two concurrent first-run SessionStarts in
|
|
356
|
+
// one project must not rename each other's half-written temp onto the user's tracked
|
|
357
|
+
// CLAUDE.md) AND adds the lstat the local twin lacked: renaming onto a symlink NAME
|
|
358
|
+
// replaces the link with a regular file, orphaning a dotfiles-managed CLAUDE.md
|
|
359
|
+
// (audit 2026-09-02 P0-5). Its temp lands beside the RESOLVED target, so the rename
|
|
360
|
+
// stays same-device even when the real file lives on another mount.
|
|
358
361
|
try {
|
|
359
|
-
|
|
360
|
-
renameSync(tmp, claudeMdPath);
|
|
362
|
+
atomicWriteFileSync(claudeMdPath, normalized);
|
|
361
363
|
dropMarker();
|
|
362
364
|
} catch (e) {
|
|
363
|
-
try { unlinkSync(tmp); } catch {}
|
|
364
365
|
debugLog('ERROR', 'cleanupClaudeMdLegacyBlock', `CLAUDE.md write failed: ${e.message}`);
|
|
365
366
|
// Intentionally do NOT drop the marker on write failure — retry next session.
|
|
366
367
|
}
|
package/hook-llm.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// claude-mem-lite: Background LLM workers for episode extraction and session summaries
|
|
2
2
|
// Extracted from hook.mjs for testability and reduced complexity
|
|
3
3
|
|
|
4
|
-
import { basename } from 'path';
|
|
5
|
-
import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
4
|
+
import { basename, join } from 'path';
|
|
5
|
+
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
|
|
6
6
|
import {
|
|
7
7
|
jaccardSimilarity, truncate, clampImportance, computeRuleImportance,
|
|
8
8
|
inferProject, parseJsonFromLLM, scrubSecrets,
|
|
@@ -12,11 +12,11 @@ import {
|
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
13
|
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
14
14
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
|
|
15
|
+
import { vecTextForRow } from './tfidf.mjs';
|
|
16
|
+
import { insertObservationRow, insertObservationFiles, insertObservationVector, upsertObservationVector, normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
|
|
17
17
|
import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
18
18
|
import {
|
|
19
|
-
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
19
|
+
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS, ORPHAN_EPISODE_AGE_MS,
|
|
20
20
|
sessionFile, getSessionId, openDb, callLLM, sleep,
|
|
21
21
|
} from './hook-shared.mjs';
|
|
22
22
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
@@ -26,6 +26,47 @@ import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
|
26
26
|
|
|
27
27
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
28
28
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
29
|
+
import { recoverChildrenOf } from './lib/maintain-core.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Retract a pre-saved observation this worker created moments ago, after the Haiku
|
|
33
|
+
* round-trip decided the row is not worth keeping.
|
|
34
|
+
*
|
|
35
|
+
* Three call sites used to be three DELETEs and only ONE of them had the guard (audit
|
|
36
|
+
* 2026-09-02 P0-6). Seconds pass between the pre-save and the LLM verdict, and in that
|
|
37
|
+
* window auto-dedup or `save --supersedes` can make the row a keeper or a tombstone:
|
|
38
|
+
* - not live -> not ours to hard-delete any more (a keeper may have absorbed it, and
|
|
39
|
+
* children can point at it through compressed_into). Leave it; the
|
|
40
|
+
* maintenance path deletes with recovery.
|
|
41
|
+
* - live -> still recover any children FIRST, the same order lib/delete-core.mjs
|
|
42
|
+
* uses, so nothing dangles behind a now-missing parent.
|
|
43
|
+
* Exported for a direct test only (no importer in production). Same call as `inertMarkerIds`
|
|
44
|
+
* in lib/patha-exclude-meter.mjs: the rule it encodes is one a review just caught missing on
|
|
45
|
+
* two of three sites, so it gets a test of its own rather than being reachable only through
|
|
46
|
+
* two mocked LLM workers.
|
|
47
|
+
* @returns {boolean} true when the row was actually removed
|
|
48
|
+
*/
|
|
49
|
+
export function retractPreSavedObs(db, obsId, where) {
|
|
50
|
+
// Liveness is checked BEFORE the recovery, not folded into the DELETE's WHERE. A first
|
|
51
|
+
// cut ran recoverChildrenOf unconditionally and let the guard live only on the DELETE:
|
|
52
|
+
// on the not-live branch the DELETE was then a no-op while the children had already been
|
|
53
|
+
// un-hidden, so a dedup that had legitimately folded #M into #N was silently undone and
|
|
54
|
+
// the debug line still said the row was "left in place" (true of #N, false of #M).
|
|
55
|
+
// Both statements run in one transaction so a crash between them cannot leave that state
|
|
56
|
+
// either; nesting under persistHaikuSummary's transaction is safe (better-sqlite3 uses
|
|
57
|
+
// savepoints).
|
|
58
|
+
return db.transaction(() => {
|
|
59
|
+
const live = db.prepare(
|
|
60
|
+
`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
61
|
+
).get(obsId);
|
|
62
|
+
if (!live) {
|
|
63
|
+
debugLog('DEBUG', 'llm-episode', `${where}: pre-saved obs #${obsId} no longer live — left in place`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
recoverChildrenOf(db, [obsId]);
|
|
67
|
+
return db.prepare('DELETE FROM observations WHERE id = ?').run(obsId).changes > 0;
|
|
68
|
+
})();
|
|
69
|
+
}
|
|
29
70
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
30
71
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
31
72
|
const EVENT_TYPE_SET = new Set(EVENT_TYPES);
|
|
@@ -358,18 +399,10 @@ export function persistHaikuSummary(db, summary, ctx) {
|
|
|
358
399
|
|
|
359
400
|
if (ctx.preSavedObsId) {
|
|
360
401
|
const id = db.transaction(() => {
|
|
361
|
-
// Same live-row guard as the in-place upgrade (FLOW-7)
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
// recovers children before deleting; the event still gets written either way.
|
|
366
|
-
const removed = db.prepare(
|
|
367
|
-
`DELETE FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
368
|
-
).run(ctx.preSavedObsId).changes;
|
|
369
|
-
if (removed === 0) {
|
|
370
|
-
debugLog('DEBUG', 'llm-episode',
|
|
371
|
-
`upgrade-delete: pre-saved obs #${ctx.preSavedObsId} no longer live — left in place`);
|
|
372
|
-
}
|
|
402
|
+
// Same live-row guard as the in-place upgrade (FLOW-7); the event gets written
|
|
403
|
+
// either way. Shared helper since audit 2026-09-02 P0-6 — this was the only one
|
|
404
|
+
// of the three retraction sites that carried the guard.
|
|
405
|
+
retractPreSavedObs(db, ctx.preSavedObsId, 'upgrade-delete');
|
|
373
406
|
return insertEvent();
|
|
374
407
|
})();
|
|
375
408
|
return { table: 'events', id };
|
|
@@ -846,7 +879,7 @@ ${actionList}`;
|
|
|
846
879
|
if (episode.savedId) {
|
|
847
880
|
const ddb = openDb();
|
|
848
881
|
if (ddb) {
|
|
849
|
-
try { ddb
|
|
882
|
+
try { retractPreSavedObs(ddb, episode.savedId, 'low-value-discard'); }
|
|
850
883
|
finally { ddb.close(); }
|
|
851
884
|
}
|
|
852
885
|
}
|
|
@@ -971,7 +1004,7 @@ ${actionList}`;
|
|
|
971
1004
|
if (episode.savedId) {
|
|
972
1005
|
const ddb = openDb();
|
|
973
1006
|
if (ddb) {
|
|
974
|
-
try { ddb
|
|
1007
|
+
try { retractPreSavedObs(ddb, episode.savedId, 'low-yield-change-drop'); }
|
|
975
1008
|
finally { ddb.close(); }
|
|
976
1009
|
}
|
|
977
1010
|
}
|
|
@@ -1076,18 +1109,13 @@ ${actionList}`;
|
|
|
1076
1109
|
savedTable = 'observations';
|
|
1077
1110
|
debugLog('DEBUG', 'llm-episode', `upgraded pre-saved obs #${savedId}`);
|
|
1078
1111
|
|
|
1079
|
-
// Update TF-IDF vector with enriched content
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
db.prepare('INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch) VALUES (?, ?, ?, ?)')
|
|
1087
|
-
.run(savedId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
} catch (e) { debugCatch(e, 'handleLLMEpisode-vector'); }
|
|
1112
|
+
// Update TF-IDF vector with enriched content. SQL + text derivation are
|
|
1113
|
+
// lib/observation-write.mjs's (audit 2026-09-02 P1-4); `gate: false` keeps this
|
|
1114
|
+
// path's prior behaviour, which never consulted vectorsEnabled().
|
|
1115
|
+
upsertObservationVector(db, savedId, {
|
|
1116
|
+
title: obs.title, narrative: obs.narrative, concepts: conceptsText,
|
|
1117
|
+
lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
|
|
1118
|
+
}, { gate: false, scope: 'handleLLMEpisode-vector' });
|
|
1091
1119
|
}
|
|
1092
1120
|
}
|
|
1093
1121
|
} else {
|
|
@@ -1119,13 +1147,44 @@ ${actionList}`;
|
|
|
1119
1147
|
export async function handleLLMSummary() {
|
|
1120
1148
|
const parsed = parseInt(process.env.CLAUDE_MEM_FLUSH_TIMEOUT, 10);
|
|
1121
1149
|
const flushTimeout = Number.isNaN(parsed) ? 15 : parsed;
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1150
|
+
|
|
1151
|
+
// Wait for a DEFINED SET of flush files, not for "the directory is empty" (audit
|
|
1152
|
+
// 2026-09-02 P1-7). RUNTIME_DIR is shared by every project on the machine, and the old
|
|
1153
|
+
// predicate was `readdirSync(RUNTIME_DIR).some(f => f.startsWith('ep-flush-'))` — so:
|
|
1154
|
+
//
|
|
1155
|
+
// • ONE crashed llm-episode worker leaves a file nothing will ever delete, and from
|
|
1156
|
+
// then on EVERY project's summary burns the full 15 s on every Stop until the next
|
|
1157
|
+
// maintain run sweeps it. Orphan cleanup lives behind a 24 h gate, so "until then"
|
|
1158
|
+
// is up to a day.
|
|
1159
|
+
// • A flush spawned by an unrelated project WHILE this summary is waiting extends the
|
|
1160
|
+
// wait, for work this summary will never read.
|
|
1161
|
+
//
|
|
1162
|
+
// The set is snapshotted at entry and filtered to files young enough to belong to a live
|
|
1163
|
+
// worker. A file that appears after this point is somebody else's; a file older than
|
|
1164
|
+
// ORPHAN_EPISODE_AGE_MS is nobody's. Both were previously indistinguishable from work in
|
|
1165
|
+
// progress.
|
|
1166
|
+
//
|
|
1167
|
+
// Not narrowed to this project: the flush filename is `ep-flush-<ts>-<rand>.json` and
|
|
1168
|
+
// carries no project. Widening it is a marker-format change with in-flight files during
|
|
1169
|
+
// an upgrade, and the two filters above already remove the unbounded cases — what is left
|
|
1170
|
+
// is a bounded overlap with genuinely concurrent work.
|
|
1171
|
+
let pending;
|
|
1172
|
+
try {
|
|
1173
|
+
const cutoff = Date.now() - ORPHAN_EPISODE_AGE_MS;
|
|
1174
|
+
pending = readdirSync(RUNTIME_DIR)
|
|
1175
|
+
.filter((f) => f.startsWith('ep-flush-'))
|
|
1176
|
+
.filter((f) => {
|
|
1177
|
+
try { return statSync(join(RUNTIME_DIR, f)).mtimeMs >= cutoff; } catch { return false; }
|
|
1178
|
+
});
|
|
1179
|
+
} catch { pending = []; }
|
|
1180
|
+
|
|
1181
|
+
for (let i = 0; i < flushTimeout && pending.length > 0; i++) {
|
|
1128
1182
|
await sleep(1000);
|
|
1183
|
+
pending = pending.filter((f) => existsSync(join(RUNTIME_DIR, f)));
|
|
1184
|
+
debugLog('DEBUG', 'llm-summary', `waiting for ${pending.length} flush file(s) (${i + 1}/${flushTimeout})`);
|
|
1185
|
+
}
|
|
1186
|
+
if (pending.length > 0) {
|
|
1187
|
+
debugLog('DEBUG', 'llm-summary', `gave up waiting on ${pending.length} flush file(s) after ${flushTimeout}s: ${pending.join(', ')}`);
|
|
1129
1188
|
}
|
|
1130
1189
|
|
|
1131
1190
|
const db = openDb();
|
package/hook-optimize.mjs
CHANGED
|
@@ -16,15 +16,18 @@ import {
|
|
|
16
16
|
import { callModelJSONAsync, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
17
17
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
18
18
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
19
|
-
import { getVocabulary, computeVector, cosineSimilarity
|
|
19
|
+
import { getVocabulary, computeVector, cosineSimilarity } from './tfidf.mjs';
|
|
20
20
|
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
21
21
|
import { DB_DIR } from './schema.mjs';
|
|
22
22
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
23
|
-
import { normalizeScope, SCOPE_PROMPT_LEGEND } from './lib/observation-write.mjs';
|
|
23
|
+
import { normalizeScope, SCOPE_PROMPT_LEGEND, upsertObservationVector } from './lib/observation-write.mjs';
|
|
24
24
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
25
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
25
26
|
|
|
26
27
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
27
|
-
|
|
28
|
+
// P1-14: same resolver as hook-shared.mjs — this was the second module that had never
|
|
29
|
+
// heard of CLAUDE_MEM_RUNTIME_DIR, and a third hand-written copy of the join().
|
|
30
|
+
const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
|
|
28
31
|
|
|
29
32
|
// ─── Budget ─────────────────────────────────────────────────────────────────
|
|
30
33
|
|
|
@@ -49,30 +52,22 @@ export function distributeBudget(total = 15) {
|
|
|
49
52
|
|
|
50
53
|
/**
|
|
51
54
|
* Rebuild TF-IDF vector for an observation. Non-critical — swallows errors.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
55
|
+
*
|
|
56
|
+
* The SQL, the text derivation and the vocab lookup are lib/observation-write.mjs's since
|
|
57
|
+
* audit 2026-09-02 P1-4; this is the optimize path's name for it. Accepts a legacy [parts]
|
|
58
|
+
* array OR an observation row (preferred — vecTextForRow gives the same field set the save
|
|
59
|
+
* path uses, so a rebuild matches the original write).
|
|
60
|
+
*
|
|
61
|
+
* `gate: false` preserves this path's behaviour exactly: it never checked
|
|
62
|
+
* `vectorsEnabled()`. That is a redundancy rather than a hole — `getVocabulary` returns
|
|
63
|
+
* null whenever the arm is off — but this refactor is not the place to decide it.
|
|
64
|
+
*
|
|
65
|
+
* The historical drift worth remembering: this copy wrote the column as `computed_at`
|
|
66
|
+
* instead of `created_at_epoch`, and its own catch swallowed the error until an experiment
|
|
67
|
+
* surfaced it. That is what a fifth copy of a statement buys.
|
|
54
68
|
*/
|
|
55
69
|
export function rebuildVector(db, obsId, textPartsOrRow) {
|
|
56
|
-
|
|
57
|
-
const vocab = getVocabulary(db);
|
|
58
|
-
if (!vocab) return;
|
|
59
|
-
// Accept a legacy [parts] array OR an observation row (preferred — single-source field set
|
|
60
|
-
// incl. lesson_learned/search_aliases via vecTextForRow, so rebuilds match the save path).
|
|
61
|
-
const text = Array.isArray(textPartsOrRow)
|
|
62
|
-
? textPartsOrRow.filter(Boolean).join(' ')
|
|
63
|
-
: vecTextForRow(textPartsOrRow);
|
|
64
|
-
const vec = computeVector(text, vocab);
|
|
65
|
-
if (vec) {
|
|
66
|
-
// Bug #1 fix: column is `created_at_epoch`, not `computed_at`. Every other
|
|
67
|
-
// INSERT callsite (server.mjs, hook-llm.mjs, mem-cli.mjs) uses the correct
|
|
68
|
-
// name; this was the only drift, silently caught by the catch below until
|
|
69
|
-
// the R-7 experiment surfaced it.
|
|
70
|
-
db.prepare(`
|
|
71
|
-
INSERT OR REPLACE INTO observation_vectors (observation_id, vector, vocab_version, created_at_epoch)
|
|
72
|
-
VALUES (?, ?, ?, ?)
|
|
73
|
-
`).run(obsId, Buffer.from(vec.buffer), vocab.version, Date.now());
|
|
74
|
-
}
|
|
75
|
-
} catch (e) { debugCatch(e, 'optimize-vector'); }
|
|
70
|
+
upsertObservationVector(db, obsId, textPartsOrRow, { gate: false, scope: 'optimize-vector' });
|
|
76
71
|
}
|
|
77
72
|
|
|
78
73
|
// ─── Task 1: Re-enrich ─────────────────────────────────────────────────────
|
|
@@ -676,7 +671,19 @@ Return ONLY valid JSON:
|
|
|
676
671
|
text: textField,
|
|
677
672
|
lesson_learned: lessonLearned,
|
|
678
673
|
});
|
|
679
|
-
db.transaction(() => {
|
|
674
|
+
const mergeApplied = db.transaction(() => {
|
|
675
|
+
// Re-check the keeper's liveness INSIDE the transaction (audit 2026-09-02 P0-3).
|
|
676
|
+
// findMergeCandidates selected live rows, but a Sonnet round-trip sits between that
|
|
677
|
+
// SELECT and this write (:617, BG_LLM_TIMEOUT_MS), and a concurrent SessionStart
|
|
678
|
+
// auto-dedup or `save --supersedes` can tombstone the keeper in that window. Pointing
|
|
679
|
+
// the other members at a tombstoned keeper is precisely the "buried behind a hidden
|
|
680
|
+
// parent" loss that mergeDuplicates' docblock enumerates (lib/maintain-core.mjs), and
|
|
681
|
+
// it is what that function's own isLive gate exists to prevent. Same predicate here.
|
|
682
|
+
const keeperLive = db.prepare(
|
|
683
|
+
`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`
|
|
684
|
+
).get(keeper.id);
|
|
685
|
+
if (!keeperLive) return false;
|
|
686
|
+
|
|
680
687
|
// Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
|
|
681
688
|
// full text survives as a recoverable compressed_into child (mirroring
|
|
682
689
|
// compressGroup / recoverChildrenOf). The keeper is the cluster's most-
|
|
@@ -701,9 +708,16 @@ Return ONLY valid JSON:
|
|
|
701
708
|
|
|
702
709
|
const otherIds = others.map(o => o.id);
|
|
703
710
|
const ph = otherIds.map(() => '?').join(',');
|
|
704
|
-
|
|
711
|
+
// Live guard on the members too: one already compressed into ANOTHER summary S during
|
|
712
|
+
// the LLM window would be re-pointed here, silently dropping a row out of S's child set.
|
|
713
|
+
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
|
|
705
714
|
.run(keeper.id, ...otherIds);
|
|
715
|
+
return true;
|
|
706
716
|
})();
|
|
717
|
+
if (!mergeApplied) {
|
|
718
|
+
debugLog('DEBUG', 'llm-optimize', `cluster-merge aborted: keeper #${keeper.id} no longer live`);
|
|
719
|
+
return { merged: false };
|
|
720
|
+
}
|
|
707
721
|
|
|
708
722
|
rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
|
|
709
723
|
|
|
@@ -741,7 +755,11 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
|
|
|
741
755
|
const stmt = db.prepare(`
|
|
742
756
|
SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
|
|
743
757
|
FROM observations
|
|
744
|
-
|
|
758
|
+
-- liveObsFilterSql, not compressed_into alone (audit 2026-09-02 P0-3): auto-dedup losers
|
|
759
|
+
-- carry superseded_at with compressed_into=0 and match this predicate exactly (imp=1,
|
|
760
|
+
-- access 0, no lesson), so the narrower filter fed RETRACTED text to Sonnet and returned
|
|
761
|
+
-- it to live retrieval as a fresh "discovery" summary. Parity with findMergeCandidates.
|
|
762
|
+
WHERE ${liveObsFilterSql('')}
|
|
745
763
|
AND COALESCE(importance, 1) = 1
|
|
746
764
|
AND COALESCE(access_count, 0) = 0
|
|
747
765
|
-- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
|
|
@@ -903,7 +921,11 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
903
921
|
|
|
904
922
|
const obsIds = observations.map(o => o.id);
|
|
905
923
|
const ph = obsIds.map(() => '?').join(',');
|
|
906
|
-
|
|
924
|
+
// Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
|
|
925
|
+
// by a Sonnet round-trip, so a member may already be compressed into another summary or
|
|
926
|
+
// tombstoned. Re-pointing it here would silently remove a row from that summary's child
|
|
927
|
+
// set. Members that lost liveness stay where they are; the summary still lands.
|
|
928
|
+
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
|
|
907
929
|
.run(sId, ...obsIds);
|
|
908
930
|
|
|
909
931
|
return sId;
|
package/hook-shared.mjs
CHANGED
|
@@ -6,7 +6,9 @@ import { randomUUID } from 'crypto';
|
|
|
6
6
|
import { join } from 'path';
|
|
7
7
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync, statSync, unlinkSync, chmodSync } from 'fs';
|
|
8
8
|
import { inferProject, debugCatch } from './utils.mjs';
|
|
9
|
+
import { CITE_RECALL_FILE_PREFIX } from './lib/cite-recall-path.mjs';
|
|
9
10
|
import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
11
|
+
import { resolveRuntimeDir } from './lib/resolve-data-dir.mjs';
|
|
10
12
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
13
|
// neither imports this file) — no cycle.
|
|
12
14
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
@@ -21,7 +23,11 @@ import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
|
|
|
21
23
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
22
24
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
// P1-14: one resolver, so this module honours CLAUDE_MEM_RUNTIME_DIR like the five
|
|
27
|
+
// standalone hook scripts already did. It did not, and hook.mjs / server.mjs /
|
|
28
|
+
// hook-context.mjs / hook-episode.mjs all take RUNTIME_DIR from here — so the override
|
|
29
|
+
// split the runtime dir in half instead of relocating it.
|
|
30
|
+
export const RUNTIME_DIR = resolveRuntimeDir(DB_DIR);
|
|
25
31
|
export const SCRIPT_PATH = process.argv[1];
|
|
26
32
|
|
|
27
33
|
// Timing constants
|
|
@@ -104,18 +110,53 @@ export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
|
104
110
|
// stale to any current episode) while leaving every active session's file untouched.
|
|
105
111
|
export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
106
112
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
113
|
+
// `ep-<project>.json` — the LIVE episode buffer, one file per project — had no reclamation
|
|
114
|
+
// path at all: it is excluded from both marker-GC lists below (correctly: it holds unflushed
|
|
115
|
+
// observations, not cache) and `sweepOrphanEpisodeFiles` only ever matched `ep-flush-`.
|
|
116
|
+
// A real install on 2026-09-02 held four of them for projects deleted months earlier, the
|
|
117
|
+
// oldest 53 days (`ep-tmp--loop-testing-e2e.*.json`, 07-11).
|
|
118
|
+
//
|
|
119
|
+
// Leaving them is not neutral. `readEpisode` has no staleness gate, so `handleSessionStart`
|
|
120
|
+
// unconditionally flushes whatever it finds (hook.mjs "Flush any leftover episode buffer") —
|
|
121
|
+
// revisiting such a project injects months-old tool activity into today's memory stamped
|
|
122
|
+
// with today's date. The stale buffer is not preserved data, it is data that will be
|
|
123
|
+
// mis-dated the moment anyone touches the project again.
|
|
124
|
+
//
|
|
125
|
+
// 7 days, and the argument is that no LEGITIMATE state needs a buffer to live even one day:
|
|
126
|
+
// `EPISODE_TIME_GAP_MS` is 5 min and `SESSION_EXPIRY_MS` is 12 h, so a buffer untouched for
|
|
127
|
+
// 7 days outlived its owning session by an order of magnitude. The margin over 12 h is
|
|
128
|
+
// deliberate slack for a laptop suspended across a long weekend, not a second threshold with
|
|
129
|
+
// its own meaning. Considered and rejected: flushing on sweep instead of deleting — it would
|
|
130
|
+
// re-date the content exactly the way the revisit path does, i.e. commit the defect on
|
|
131
|
+
// purpose rather than by omission.
|
|
132
|
+
//
|
|
133
|
+
// Exported as of the pre-tag review for v3.92.0, on this constant's own stated rule ("export
|
|
134
|
+
// it the day something needs it"): the sweep alone does NOT close the harm described above.
|
|
135
|
+
// `handleSessionStart` flushes the leftover buffer in the FOREGROUND, and the sweeper runs
|
|
136
|
+
// later, in a detached auto-maintain worker — so on the revisit itself the mis-dated flush
|
|
137
|
+
// happens first and the sweeper then finds nothing. What the sweep delivers is dir-wide
|
|
138
|
+
// reclamation of OTHER projects' abandoned buffers; the same-project revisit needs the same
|
|
139
|
+
// threshold applied at the flush, which is `hook.mjs`'s importer of this symbol.
|
|
140
|
+
// `bufferAgeMs` stays a parameter so a test can pin the sweep threshold without an import.
|
|
141
|
+
export const STALE_EPISODE_BUFFER_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
142
|
+
|
|
143
|
+
// Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h),
|
|
144
|
+
// `reads-*.txt` (older than `readsAgeMs`, default 24h) and abandoned per-project episode
|
|
145
|
+
// buffers `ep-<project>.json` (older than `bufferAgeMs`, default 7d) in `runtimeDir` by
|
|
146
|
+
// mtime. `onSweep(name, kind)` is called before each unlink so a caller can log the one
|
|
147
|
+
// deletion that discards content rather than residue ('buffer'); it is a callback rather
|
|
148
|
+
// than a debugLog here because this module is imported by every hook entry point and stays
|
|
149
|
+
// dependency-free. Returns the number of files removed. fs-only — no DB / no network. Used by
|
|
110
150
|
// handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
|
|
111
151
|
// from accumulating across crashes; equivalent to the manual path in
|
|
112
152
|
// `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
|
|
113
153
|
// read sessions are never raced.
|
|
114
|
-
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
|
|
154
|
+
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, bufferAgeMs = STALE_EPISODE_BUFFER_AGE_MS, now = Date.now(), onSweep = () => {} } = {}) {
|
|
115
155
|
let entries;
|
|
116
156
|
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
117
157
|
const cutoff = now - ageMs;
|
|
118
158
|
const readsCutoff = now - readsAgeMs;
|
|
159
|
+
const bufferCutoff = now - bufferAgeMs;
|
|
119
160
|
let count = 0;
|
|
120
161
|
for (const f of entries) {
|
|
121
162
|
// Crash residue: this runtime dir writes four families of temp name, each the middle
|
|
@@ -139,15 +180,22 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
|
|
|
139
180
|
const isCrashResidue = /\.(claim|collect|trim|tmp)-[^.]*$/.test(f);
|
|
140
181
|
const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
|
|
141
182
|
const isReads = f.startsWith('reads-') && f.endsWith('.txt');
|
|
142
|
-
|
|
183
|
+
// The live per-project buffer, on its own 7-day cutoff. `ep-flush-*` is also `ep-`-
|
|
184
|
+
// prefixed AND also ends in `.json`, so the exclusion is load-bearing, not defensive:
|
|
185
|
+
// without it a queued flush file would jump from the 1h cutoff to the 7d one.
|
|
186
|
+
const isStaleBuffer = f.startsWith('ep-') && !f.startsWith('ep-flush-') && f.endsWith('.json');
|
|
187
|
+
if (!isCrashResidue && !isEpisode && !isReads && !isStaleBuffer) continue;
|
|
143
188
|
const full = join(runtimeDir, f);
|
|
144
189
|
try {
|
|
145
190
|
// Residue takes the short cutoff and a live tracker takes the 24h one, with no
|
|
146
191
|
// tie-break needed: residue always APPENDS its suffix, so it never ends in `.txt`
|
|
147
192
|
// and `isReads` is already false for it. (A `&& !isCrashResidue` tie-break was
|
|
148
193
|
// written here first and no mutation could kill it — it was guarding a state the
|
|
149
|
-
// two predicates cannot both be in.)
|
|
150
|
-
|
|
194
|
+
// two predicates cannot both be in.) `isStaleBuffer` is in the same position: a
|
|
195
|
+
// residue name ends in `.tmp-<pid>`, never `.json`.
|
|
196
|
+
const fileCutoff = isReads ? readsCutoff : isStaleBuffer ? bufferCutoff : cutoff;
|
|
197
|
+
if (statSync(full).mtimeMs < fileCutoff) {
|
|
198
|
+
try { onSweep(f, isStaleBuffer ? 'buffer' : isReads ? 'reads' : isCrashResidue ? 'residue' : 'episode'); } catch { /* logging must never block the sweep */ }
|
|
151
199
|
unlinkSync(full);
|
|
152
200
|
count++;
|
|
153
201
|
}
|
|
@@ -177,15 +225,17 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
177
225
|
// Regenerated on demand; safe to lose at any time.
|
|
178
226
|
export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
179
227
|
'session-', // project → memory-session-id pointer
|
|
180
|
-
|
|
228
|
+
CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
|
|
181
229
|
'.skill-cooldown-', // suggestion throttle timestamp
|
|
182
230
|
'.skill-reco-cooldown-', // recommendation throttle timestamp
|
|
183
231
|
]);
|
|
184
232
|
|
|
185
233
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
186
|
-
// `pending-`/`reads-` are absent from BOTH lists on purpose:
|
|
187
|
-
//
|
|
188
|
-
//
|
|
234
|
+
// `pending-`/`reads-` are absent from BOTH lists on purpose: they all belong to
|
|
235
|
+
// sweepOrphanEpisodeFiles, on three cutoffs of their own (1h residue / 24h reads /
|
|
236
|
+
// 7d abandoned buffer). `ep-<project>.json` was the one with no cutoff at all until
|
|
237
|
+
// audit P1-12 — it is still not marker-GC-able here, because 30 days of unflushed
|
|
238
|
+
// observations is far past the point where flushing them would mis-date them.
|
|
189
239
|
export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
190
240
|
'.auto-adopt-',
|
|
191
241
|
'.deferred-block-migrated-',
|