claude-mem-lite 3.90.0 → 3.92.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 +1 -0
- package/README.zh-CN.md +1 -0
- package/claudemd.mjs +9 -6
- package/hook-context.mjs +9 -8
- package/hook-llm.mjs +56 -28
- package/hook-optimize.mjs +47 -28
- package/hook-shared.mjs +56 -11
- package/hook.mjs +94 -25
- package/install.mjs +9 -2
- 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/frontmatter.mjs +67 -0
- package/lib/injected-ids.mjs +93 -4
- package/lib/maintain-core.mjs +191 -5
- package/lib/observation-write.mjs +71 -13
- package/lib/registry-core.mjs +117 -1
- 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 +75 -193
- package/memdir.mjs +6 -6
- package/npm-shrinkwrap.json +39 -20
- package/package.json +9 -1
- package/registry-importer.mjs +4 -34
- package/registry.mjs +2 -1
- package/scripts/post-tool-recall.js +12 -4
- package/scripts/pre-tool-recall.js +9 -33
- package/scripts/user-prompt-search.js +13 -36
- package/search-scoring.mjs +10 -0
- package/server.mjs +68 -170
- package/source-files.mjs +2 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.92.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.92.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` |
|
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/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
|
@@ -12,8 +12,8 @@ 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
19
|
RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
|
|
@@ -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 {
|
package/hook-optimize.mjs
CHANGED
|
@@ -16,11 +16,11 @@ 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
25
|
|
|
26
26
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
@@ -49,30 +49,22 @@ export function distributeBudget(total = 15) {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Rebuild TF-IDF vector for an observation. Non-critical — swallows errors.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
52
|
+
*
|
|
53
|
+
* The SQL, the text derivation and the vocab lookup are lib/observation-write.mjs's since
|
|
54
|
+
* audit 2026-09-02 P1-4; this is the optimize path's name for it. Accepts a legacy [parts]
|
|
55
|
+
* array OR an observation row (preferred — vecTextForRow gives the same field set the save
|
|
56
|
+
* path uses, so a rebuild matches the original write).
|
|
57
|
+
*
|
|
58
|
+
* `gate: false` preserves this path's behaviour exactly: it never checked
|
|
59
|
+
* `vectorsEnabled()`. That is a redundancy rather than a hole — `getVocabulary` returns
|
|
60
|
+
* null whenever the arm is off — but this refactor is not the place to decide it.
|
|
61
|
+
*
|
|
62
|
+
* The historical drift worth remembering: this copy wrote the column as `computed_at`
|
|
63
|
+
* instead of `created_at_epoch`, and its own catch swallowed the error until an experiment
|
|
64
|
+
* surfaced it. That is what a fifth copy of a statement buys.
|
|
54
65
|
*/
|
|
55
66
|
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'); }
|
|
67
|
+
upsertObservationVector(db, obsId, textPartsOrRow, { gate: false, scope: 'optimize-vector' });
|
|
76
68
|
}
|
|
77
69
|
|
|
78
70
|
// ─── Task 1: Re-enrich ─────────────────────────────────────────────────────
|
|
@@ -676,7 +668,19 @@ Return ONLY valid JSON:
|
|
|
676
668
|
text: textField,
|
|
677
669
|
lesson_learned: lessonLearned,
|
|
678
670
|
});
|
|
679
|
-
db.transaction(() => {
|
|
671
|
+
const mergeApplied = db.transaction(() => {
|
|
672
|
+
// Re-check the keeper's liveness INSIDE the transaction (audit 2026-09-02 P0-3).
|
|
673
|
+
// findMergeCandidates selected live rows, but a Sonnet round-trip sits between that
|
|
674
|
+
// SELECT and this write (:617, BG_LLM_TIMEOUT_MS), and a concurrent SessionStart
|
|
675
|
+
// auto-dedup or `save --supersedes` can tombstone the keeper in that window. Pointing
|
|
676
|
+
// the other members at a tombstoned keeper is precisely the "buried behind a hidden
|
|
677
|
+
// parent" loss that mergeDuplicates' docblock enumerates (lib/maintain-core.mjs), and
|
|
678
|
+
// it is what that function's own isLive gate exists to prevent. Same predicate here.
|
|
679
|
+
const keeperLive = db.prepare(
|
|
680
|
+
`SELECT 1 FROM observations WHERE id = ? AND ${liveObsFilterSql('')}`
|
|
681
|
+
).get(keeper.id);
|
|
682
|
+
if (!keeperLive) return false;
|
|
683
|
+
|
|
680
684
|
// Snapshot the keeper's pre-merge row BEFORE overwriting it, so its original
|
|
681
685
|
// full text survives as a recoverable compressed_into child (mirroring
|
|
682
686
|
// compressGroup / recoverChildrenOf). The keeper is the cluster's most-
|
|
@@ -701,9 +705,16 @@ Return ONLY valid JSON:
|
|
|
701
705
|
|
|
702
706
|
const otherIds = others.map(o => o.id);
|
|
703
707
|
const ph = otherIds.map(() => '?').join(',');
|
|
704
|
-
|
|
708
|
+
// Live guard on the members too: one already compressed into ANOTHER summary S during
|
|
709
|
+
// the LLM window would be re-pointed here, silently dropping a row out of S's child set.
|
|
710
|
+
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
|
|
705
711
|
.run(keeper.id, ...otherIds);
|
|
712
|
+
return true;
|
|
706
713
|
})();
|
|
714
|
+
if (!mergeApplied) {
|
|
715
|
+
debugLog('DEBUG', 'llm-optimize', `cluster-merge aborted: keeper #${keeper.id} no longer live`);
|
|
716
|
+
return { merged: false };
|
|
717
|
+
}
|
|
707
718
|
|
|
708
719
|
rebuildVector(db, keeper.id, { title, narrative, concepts: conceptsText, lesson_learned: lessonLearned, search_aliases: keeper.search_aliases });
|
|
709
720
|
|
|
@@ -741,7 +752,11 @@ export function findSmartCompressCandidates(db, ageDays = 30, { project } = {})
|
|
|
741
752
|
const stmt = db.prepare(`
|
|
742
753
|
SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
|
|
743
754
|
FROM observations
|
|
744
|
-
|
|
755
|
+
-- liveObsFilterSql, not compressed_into alone (audit 2026-09-02 P0-3): auto-dedup losers
|
|
756
|
+
-- carry superseded_at with compressed_into=0 and match this predicate exactly (imp=1,
|
|
757
|
+
-- access 0, no lesson), so the narrower filter fed RETRACTED text to Sonnet and returned
|
|
758
|
+
-- it to live retrieval as a fresh "discovery" summary. Parity with findMergeCandidates.
|
|
759
|
+
WHERE ${liveObsFilterSql('')}
|
|
745
760
|
AND COALESCE(importance, 1) = 1
|
|
746
761
|
AND COALESCE(access_count, 0) = 0
|
|
747
762
|
-- Never auto-compress a lesson-bearing row. Smart-compress sets compressed_into
|
|
@@ -903,7 +918,11 @@ JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive sum
|
|
|
903
918
|
|
|
904
919
|
const obsIds = observations.map(o => o.id);
|
|
905
920
|
const ph = obsIds.map(() => '?').join(',');
|
|
906
|
-
|
|
921
|
+
// Live guard (audit 2026-09-02 P0-3): the candidate SELECT is separated from this write
|
|
922
|
+
// by a Sonnet round-trip, so a member may already be compressed into another summary or
|
|
923
|
+
// tombstoned. Re-pointing it here would silently remove a row from that summary's child
|
|
924
|
+
// set. Members that lost liveness stay where they are; the summary still lands.
|
|
925
|
+
db.prepare(`UPDATE observations SET compressed_into = ? WHERE id IN (${ph}) AND ${liveObsFilterSql('')}`)
|
|
907
926
|
.run(sId, ...obsIds);
|
|
908
927
|
|
|
909
928
|
return sId;
|
package/hook-shared.mjs
CHANGED
|
@@ -6,6 +6,7 @@ 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';
|
|
10
11
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
12
|
// neither imports this file) — no cycle.
|
|
@@ -104,18 +105,53 @@ export const ORPHAN_EPISODE_AGE_MS = 60 * 60 * 1000;
|
|
|
104
105
|
// stale to any current episode) while leaving every active session's file untouched.
|
|
105
106
|
export const ORPHAN_READS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
106
107
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
108
|
+
// `ep-<project>.json` — the LIVE episode buffer, one file per project — had no reclamation
|
|
109
|
+
// path at all: it is excluded from both marker-GC lists below (correctly: it holds unflushed
|
|
110
|
+
// observations, not cache) and `sweepOrphanEpisodeFiles` only ever matched `ep-flush-`.
|
|
111
|
+
// A real install on 2026-09-02 held four of them for projects deleted months earlier, the
|
|
112
|
+
// oldest 53 days (`ep-tmp--loop-testing-e2e.*.json`, 07-11).
|
|
113
|
+
//
|
|
114
|
+
// Leaving them is not neutral. `readEpisode` has no staleness gate, so `handleSessionStart`
|
|
115
|
+
// unconditionally flushes whatever it finds (hook.mjs "Flush any leftover episode buffer") —
|
|
116
|
+
// revisiting such a project injects months-old tool activity into today's memory stamped
|
|
117
|
+
// with today's date. The stale buffer is not preserved data, it is data that will be
|
|
118
|
+
// mis-dated the moment anyone touches the project again.
|
|
119
|
+
//
|
|
120
|
+
// 7 days, and the argument is that no LEGITIMATE state needs a buffer to live even one day:
|
|
121
|
+
// `EPISODE_TIME_GAP_MS` is 5 min and `SESSION_EXPIRY_MS` is 12 h, so a buffer untouched for
|
|
122
|
+
// 7 days outlived its owning session by an order of magnitude. The margin over 12 h is
|
|
123
|
+
// deliberate slack for a laptop suspended across a long weekend, not a second threshold with
|
|
124
|
+
// its own meaning. Considered and rejected: flushing on sweep instead of deleting — it would
|
|
125
|
+
// re-date the content exactly the way the revisit path does, i.e. commit the defect on
|
|
126
|
+
// purpose rather than by omission.
|
|
127
|
+
//
|
|
128
|
+
// Exported as of the pre-tag review for v3.92.0, on this constant's own stated rule ("export
|
|
129
|
+
// it the day something needs it"): the sweep alone does NOT close the harm described above.
|
|
130
|
+
// `handleSessionStart` flushes the leftover buffer in the FOREGROUND, and the sweeper runs
|
|
131
|
+
// later, in a detached auto-maintain worker — so on the revisit itself the mis-dated flush
|
|
132
|
+
// happens first and the sweeper then finds nothing. What the sweep delivers is dir-wide
|
|
133
|
+
// reclamation of OTHER projects' abandoned buffers; the same-project revisit needs the same
|
|
134
|
+
// threshold applied at the flush, which is `hook.mjs`'s importer of this symbol.
|
|
135
|
+
// `bufferAgeMs` stays a parameter so a test can pin the sweep threshold without an import.
|
|
136
|
+
export const STALE_EPISODE_BUFFER_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
137
|
+
|
|
138
|
+
// Sweep stale `ep-flush-*` / `pending-*` (older than `ageMs`, default 1h),
|
|
139
|
+
// `reads-*.txt` (older than `readsAgeMs`, default 24h) and abandoned per-project episode
|
|
140
|
+
// buffers `ep-<project>.json` (older than `bufferAgeMs`, default 7d) in `runtimeDir` by
|
|
141
|
+
// mtime. `onSweep(name, kind)` is called before each unlink so a caller can log the one
|
|
142
|
+
// deletion that discards content rather than residue ('buffer'); it is a callback rather
|
|
143
|
+
// than a debugLog here because this module is imported by every hook entry point and stays
|
|
144
|
+
// dependency-free. Returns the number of files removed. fs-only — no DB / no network. Used by
|
|
110
145
|
// handleSessionStart auto-maintain to prevent the doctor "Stale temp files" warning
|
|
111
146
|
// from accumulating across crashes; equivalent to the manual path in
|
|
112
147
|
// `node install.mjs cleanup` but age-gated so concurrent in-flight workers / active
|
|
113
148
|
// read sessions are never raced.
|
|
114
|
-
export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE_MS, readsAgeMs = ORPHAN_READS_AGE_MS, now = Date.now() } = {}) {
|
|
149
|
+
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
150
|
let entries;
|
|
116
151
|
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
117
152
|
const cutoff = now - ageMs;
|
|
118
153
|
const readsCutoff = now - readsAgeMs;
|
|
154
|
+
const bufferCutoff = now - bufferAgeMs;
|
|
119
155
|
let count = 0;
|
|
120
156
|
for (const f of entries) {
|
|
121
157
|
// Crash residue: this runtime dir writes four families of temp name, each the middle
|
|
@@ -139,15 +175,22 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
|
|
|
139
175
|
const isCrashResidue = /\.(claim|collect|trim|tmp)-[^.]*$/.test(f);
|
|
140
176
|
const isEpisode = f.startsWith('ep-flush-') || f.startsWith('pending-');
|
|
141
177
|
const isReads = f.startsWith('reads-') && f.endsWith('.txt');
|
|
142
|
-
|
|
178
|
+
// The live per-project buffer, on its own 7-day cutoff. `ep-flush-*` is also `ep-`-
|
|
179
|
+
// prefixed AND also ends in `.json`, so the exclusion is load-bearing, not defensive:
|
|
180
|
+
// without it a queued flush file would jump from the 1h cutoff to the 7d one.
|
|
181
|
+
const isStaleBuffer = f.startsWith('ep-') && !f.startsWith('ep-flush-') && f.endsWith('.json');
|
|
182
|
+
if (!isCrashResidue && !isEpisode && !isReads && !isStaleBuffer) continue;
|
|
143
183
|
const full = join(runtimeDir, f);
|
|
144
184
|
try {
|
|
145
185
|
// Residue takes the short cutoff and a live tracker takes the 24h one, with no
|
|
146
186
|
// tie-break needed: residue always APPENDS its suffix, so it never ends in `.txt`
|
|
147
187
|
// and `isReads` is already false for it. (A `&& !isCrashResidue` tie-break was
|
|
148
188
|
// written here first and no mutation could kill it — it was guarding a state the
|
|
149
|
-
// two predicates cannot both be in.)
|
|
150
|
-
|
|
189
|
+
// two predicates cannot both be in.) `isStaleBuffer` is in the same position: a
|
|
190
|
+
// residue name ends in `.tmp-<pid>`, never `.json`.
|
|
191
|
+
const fileCutoff = isReads ? readsCutoff : isStaleBuffer ? bufferCutoff : cutoff;
|
|
192
|
+
if (statSync(full).mtimeMs < fileCutoff) {
|
|
193
|
+
try { onSweep(f, isStaleBuffer ? 'buffer' : isReads ? 'reads' : isCrashResidue ? 'residue' : 'episode'); } catch { /* logging must never block the sweep */ }
|
|
151
194
|
unlinkSync(full);
|
|
152
195
|
count++;
|
|
153
196
|
}
|
|
@@ -177,15 +220,17 @@ export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
177
220
|
// Regenerated on demand; safe to lose at any time.
|
|
178
221
|
export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
179
222
|
'session-', // project → memory-session-id pointer
|
|
180
|
-
|
|
223
|
+
CITE_RECALL_FILE_PREFIX, // last session's cite-recall snapshot (nudge input)
|
|
181
224
|
'.skill-cooldown-', // suggestion throttle timestamp
|
|
182
225
|
'.skill-reco-cooldown-', // recommendation throttle timestamp
|
|
183
226
|
]);
|
|
184
227
|
|
|
185
228
|
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
186
|
-
// `pending-`/`reads-` are absent from BOTH lists on purpose:
|
|
187
|
-
//
|
|
188
|
-
//
|
|
229
|
+
// `pending-`/`reads-` are absent from BOTH lists on purpose: they all belong to
|
|
230
|
+
// sweepOrphanEpisodeFiles, on three cutoffs of their own (1h residue / 24h reads /
|
|
231
|
+
// 7d abandoned buffer). `ep-<project>.json` was the one with no cutoff at all until
|
|
232
|
+
// audit P1-12 — it is still not marker-GC-able here, because 30 days of unflushed
|
|
233
|
+
// observations is far past the point where flushing them would mis-date them.
|
|
189
234
|
export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
190
235
|
'.auto-adopt-',
|
|
191
236
|
'.deferred-block-migrated-',
|