claude-mem-lite 3.64.0 → 3.66.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/deep-search.mjs +2 -1
- package/format-utils.mjs +2 -1
- package/haiku-client.mjs +39 -6
- package/hook-context.mjs +28 -22
- package/hook-handoff.mjs +3 -4
- package/hook-llm.mjs +7 -5
- package/hook-memory.mjs +4 -3
- package/hook-optimize.mjs +15 -16
- package/hook-precompact.mjs +15 -2
- package/hook-shared.mjs +97 -5
- package/hook.mjs +59 -20
- package/lib/citation-tracker.mjs +47 -3
- package/lib/db-backup.mjs +2 -1
- package/lib/deferred-work.mjs +1 -1
- package/lib/err-sampler.mjs +1 -1
- package/lib/hook-telemetry.mjs +1 -1
- package/lib/inject-search-core.mjs +16 -13
- package/lib/injected-ids.mjs +20 -0
- package/lib/keyctx-marker.mjs +71 -0
- package/lib/maintain-core.mjs +5 -4
- package/lib/metrics.mjs +1 -1
- package/lib/recall-core.mjs +2 -2
- package/lib/recent-core.mjs +3 -1
- package/lib/save-enrich.mjs +3 -2
- package/lib/search-core.mjs +6 -4
- package/lib/stats-core.mjs +6 -4
- package/lib/stats-quality.mjs +2 -1
- package/lib/time-constants.mjs +21 -0
- package/lib/timeline-core.mjs +7 -6
- package/mem-cli.mjs +19 -19
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/registry-enricher.mjs +2 -2
- package/registry-recommend.mjs +3 -2
- package/scoring-sql.mjs +8 -7
- package/scripts/pre-tool-recall.js +2 -1
- package/scripts/user-prompt-search.js +2 -1
- package/search-scoring.mjs +4 -3
- package/server.mjs +10 -4
- package/source-files.mjs +6 -0
- package/tfidf.mjs +3 -3
- package/tier.mjs +4 -3
package/hook-shared.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { ensureDbWithWalRecovery, DB_DIR } from './schema.mjs';
|
|
|
10
10
|
// Pure-`node:`/local module (it imports only binding-probe + native-binding-hint, and
|
|
11
11
|
// neither imports this file) — no cycle.
|
|
12
12
|
import { recordHookError } from './lib/hook-telemetry.mjs';
|
|
13
|
-
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku } from './haiku-client.mjs';
|
|
13
|
+
import { getClaudePath as getClaudePathShared, resolveModel as resolveModelShared, flattenForCLI as _flattenForCLI, detectMode as detectLLMMode, callHaiku, BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
14
14
|
// Phase D: invited-memory sentinel detection. memdir.mjs/claudemd.mjs only pull in
|
|
15
15
|
// fs/path/os/crypto; adopt-content.mjs is pure strings. No circular deps —
|
|
16
16
|
// neither imports hook-shared.
|
|
@@ -18,6 +18,7 @@ import { memdirPath as _memdirPath, isAdopted as _isAdoptedMemdir } from './memd
|
|
|
18
18
|
import { isAdopted as _isAdoptedClaudeMd } from './claudemd.mjs';
|
|
19
19
|
import { PLUGIN_SLUG as _PLUGIN_SLUG } from './adopt-content.mjs';
|
|
20
20
|
|
|
21
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
21
22
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
22
23
|
|
|
23
24
|
export const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
@@ -30,8 +31,14 @@ export const SESSION_EXPIRY_MS = 12 * 60 * 60 * 1000; // 12h
|
|
|
30
31
|
export const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24h
|
|
31
32
|
export const STALE_LOCK_MS = 30000; // 30s
|
|
32
33
|
export const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min (title dedup)
|
|
33
|
-
export const RELATED_OBS_WINDOW_MS = 7 *
|
|
34
|
+
export const RELATED_OBS_WINDOW_MS = 7 * DAY_MS; // 7 days
|
|
34
35
|
export const FALLBACK_OBS_WINDOW_MS = RELATED_OBS_WINDOW_MS; // same window
|
|
36
|
+
// Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
|
|
37
|
+
// keyObs; each of the two sections then renders at most 5). The user-prompt
|
|
38
|
+
// exclude-set does NOT mirror this query — it reads the ids actually rendered
|
|
39
|
+
// from the keyctx marker (D#123 review C-1: query-mirroring suppressed
|
|
40
|
+
// <memory-context> injection on quiet/adopted projects where nothing renders).
|
|
41
|
+
export const KEY_CONTEXT_LIMIT = 10;
|
|
35
42
|
|
|
36
43
|
// Phase A (v2.31.3+): MEM_QUIET_HOOKS=1 drops descriptive hook/MCP-instruction
|
|
37
44
|
// bodies (File Lessons / Key Context headers, MCP WHEN-TO-USE & decision rules,
|
|
@@ -117,6 +124,89 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
|
|
|
117
124
|
return count;
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
// ─── Per-project marker GC (P2-15) ───────────────────────────────────────────
|
|
128
|
+
// RUNTIME_DIR had three sweeps and a hole. Per-SESSION files age out at 24h
|
|
129
|
+
// (hook.mjs) and orphaned episode/read trackers at 1h/24h (above), but the
|
|
130
|
+
// per-PROJECT markers — one file per project, written once, never revisited —
|
|
131
|
+
// had no reclamation path at all. A live install on 2026-08-16 held 253 files,
|
|
132
|
+
// 152 of them past 30 days, including entire families for test sandboxes
|
|
133
|
+
// deleted months earlier (session-tmp--sdscc-e2e-*, cite-recall-scratchpad--
|
|
134
|
+
// fixture-*) and a .skill-reco-cooldown-* family that nothing had ever swept.
|
|
135
|
+
//
|
|
136
|
+
// Deliberately a NAMED list rather than a wildcard: these markers share a shape
|
|
137
|
+
// but not a meaning. The GC-able ones are caches — delete them and the next
|
|
138
|
+
// session re-derives the state (or, for a cooldown, merely allows a suggestion
|
|
139
|
+
// sooner). The preserved ones are records of a side effect already performed;
|
|
140
|
+
// removing them re-arms it (.auto-adopt-* re-attempts a write into the user's
|
|
141
|
+
// project CLAUDE.md, the migration sentinels re-run their one-time work), which
|
|
142
|
+
// is a bad trade for the 13-45 bytes each occupies.
|
|
143
|
+
export const STALE_PROJECT_MARKER_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
144
|
+
|
|
145
|
+
// Regenerated on demand; safe to lose at any time.
|
|
146
|
+
export const GC_PROJECT_MARKER_PREFIXES = Object.freeze([
|
|
147
|
+
'session-', // project → memory-session-id pointer
|
|
148
|
+
'cite-recall-', // last session's cite-recall snapshot (nudge input)
|
|
149
|
+
'.skill-cooldown-', // suggestion throttle timestamp
|
|
150
|
+
'.skill-reco-cooldown-', // recommendation throttle timestamp
|
|
151
|
+
// These two have NO writer and NO reader left in the tree (verified by grep,
|
|
152
|
+
// 2026-08-16) — they are version-keyed one-time markers from retired code
|
|
153
|
+
// paths (live dir holds `.mcp-dedup-v2.10`, `.residue-warned-v2.55`). Nothing
|
|
154
|
+
// recreates them, so sweeping them is a one-shot cleanup, not a policy.
|
|
155
|
+
'.mcp-dedup-',
|
|
156
|
+
'.residue-warned-',
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
// Records of a completed side effect — never age out. `ep-`/`ep-flush-`/
|
|
160
|
+
// `pending-`/`reads-` are absent from BOTH lists on purpose: the first holds
|
|
161
|
+
// unflushed observations (data, not cache) and the rest already belong to
|
|
162
|
+
// sweepOrphanEpisodeFiles on tighter cutoffs.
|
|
163
|
+
export const GC_PRESERVED_MARKER_PREFIXES = Object.freeze([
|
|
164
|
+
'.auto-adopt-',
|
|
165
|
+
'.deferred-block-migrated-',
|
|
166
|
+
'.legacy-claude-md-cleaned-',
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sweep per-project runtime markers older than `ageMs`. fs-only, best-effort,
|
|
171
|
+
* never throws. Returns the number of files removed.
|
|
172
|
+
*
|
|
173
|
+
* The two prefix lists are injectable ONLY so the precedence rule below can be
|
|
174
|
+
* exercised: with the shipped lists they are disjoint, which makes the
|
|
175
|
+
* preserved check redundant today and load-bearing the moment a future family
|
|
176
|
+
* nests inside a GC-able one. Production callers pass neither.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} runtimeDir
|
|
179
|
+
* @param {{ageMs?: number, now?: number, gcPrefixes?: string[], preservedPrefixes?: string[]}} [opts]
|
|
180
|
+
* @returns {number}
|
|
181
|
+
*/
|
|
182
|
+
export function sweepStaleProjectMarkers(runtimeDir, {
|
|
183
|
+
ageMs = STALE_PROJECT_MARKER_AGE_MS,
|
|
184
|
+
now = Date.now(),
|
|
185
|
+
gcPrefixes = GC_PROJECT_MARKER_PREFIXES,
|
|
186
|
+
preservedPrefixes = GC_PRESERVED_MARKER_PREFIXES,
|
|
187
|
+
env = process.env,
|
|
188
|
+
} = {}) {
|
|
189
|
+
// Kill switch (naming mirrors SKIP_COMPRESS / SKIP_OPTIMIZE / SKIP_SAVE_ENRICH):
|
|
190
|
+
// this is the only sweep that deletes files a user might want to inspect, so a
|
|
191
|
+
// released default that reclaims state needs a documented way back out.
|
|
192
|
+
if (env.CLAUDE_MEM_SKIP_MARKER_GC === '1') return 0;
|
|
193
|
+
let entries;
|
|
194
|
+
try { entries = readdirSync(runtimeDir); } catch { return 0; }
|
|
195
|
+
const cutoff = now - ageMs;
|
|
196
|
+
let count = 0;
|
|
197
|
+
for (const f of entries) {
|
|
198
|
+
// Preserved wins on any overlap, so a future prefix added to both lists
|
|
199
|
+
// fails safe (kept) instead of deleting a side-effect record.
|
|
200
|
+
if (preservedPrefixes.some((p) => f.startsWith(p))) continue;
|
|
201
|
+
if (!gcPrefixes.some((p) => f.startsWith(p))) continue;
|
|
202
|
+
const full = join(runtimeDir, f);
|
|
203
|
+
try {
|
|
204
|
+
if (statSync(full).mtimeMs < cutoff) { unlinkSync(full); count++; }
|
|
205
|
+
} catch { /* concurrent unlink / permission / directory — ignore */ }
|
|
206
|
+
}
|
|
207
|
+
return count;
|
|
208
|
+
}
|
|
209
|
+
|
|
120
210
|
// Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
|
|
121
211
|
// (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
|
|
122
212
|
// shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
|
|
@@ -189,7 +279,7 @@ export function openDb() {
|
|
|
189
279
|
// response string (callers run parseJsonFromLLM themselves) or null.
|
|
190
280
|
// maxTokens is sized for session-summary / episode JSON (larger than the
|
|
191
281
|
// registry/optimize callers' budgets).
|
|
192
|
-
export async function callLLM(prompt, timeoutMs =
|
|
282
|
+
export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
|
|
193
283
|
if (detectLLMMode() !== 'cli') {
|
|
194
284
|
const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
|
|
195
285
|
return result?.text ?? null;
|
|
@@ -197,11 +287,13 @@ export async function callLLM(prompt, timeoutMs = 15000) {
|
|
|
197
287
|
|
|
198
288
|
const { cli: modelName } = resolveModelShared();
|
|
199
289
|
try {
|
|
200
|
-
|
|
290
|
+
// Same headless-tax flags as haiku-client.mjs#callModelCLI (rationale
|
|
291
|
+
// there): no transcript persistence, no claudemd hook fan-out.
|
|
292
|
+
const result = execFileSync(getClaudePathShared(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
201
293
|
input: _flattenForCLI(prompt),
|
|
202
294
|
timeout: timeoutMs,
|
|
203
295
|
encoding: 'utf8',
|
|
204
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
296
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
205
297
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
206
298
|
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
207
299
|
});
|
package/hook.mjs
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
|
|
43
43
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
44
44
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
45
|
-
spawnBackground, sweepOrphanEpisodeFiles,
|
|
45
|
+
spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
|
|
46
46
|
} from './hook-shared.mjs';
|
|
47
47
|
import { handleLLMEpisode, handleLLMSummary, saveObservation, buildImmediateObservation, saveEpisodeImmediate } from './hook-llm.mjs';
|
|
48
48
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
@@ -68,7 +68,8 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
|
68
68
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
69
69
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
70
70
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
71
|
-
import { injectedIdsFileName } from './lib/injected-ids.mjs';
|
|
71
|
+
import { injectedIdsFileName, keyContextIdsFileName } from './lib/injected-ids.mjs';
|
|
72
|
+
import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
72
73
|
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
73
74
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
74
75
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
@@ -90,6 +91,7 @@ async function loadCacheGuard() {
|
|
|
90
91
|
import { SKIP_TOOLS, SKIP_PREFIXES } from './skip-tools.mjs';
|
|
91
92
|
import { getVocabulary } from './tfidf.mjs';
|
|
92
93
|
|
|
94
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
93
95
|
// Prevent recursive hooks from background claude -p calls
|
|
94
96
|
// Background workers (llm-episode, llm-summary) are exempt — they're ours
|
|
95
97
|
const event = process.argv[2];
|
|
@@ -738,7 +740,12 @@ async function handleStop() {
|
|
|
738
740
|
// filter as citedMain (the numerator, below) — an obs injected only
|
|
739
741
|
// inside a subagent (sidechain) would otherwise enter the denominator
|
|
740
742
|
// but never the numerator and streak-demote despite being used there.
|
|
741
|
-
|
|
743
|
+
// runtimeDir + project enable the 5th (Key Context) face — see
|
|
744
|
+
// extractInjectedFromKeyContext: it is marker-derived, because the
|
|
745
|
+
// SessionStart block leaves no hook attachment to parse.
|
|
746
|
+
const injected = extractAllInjected(transcriptPath, {
|
|
747
|
+
mainOnly: true, runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
748
|
+
});
|
|
742
749
|
// P5 ①: cite-back signals — observations whose warned file the agent
|
|
743
750
|
// edited this session. Union into injected so they're resolved (they
|
|
744
751
|
// were injected via pre-tool-recall) and, below, into cited so the
|
|
@@ -869,7 +876,8 @@ function gcStalePreRecallCooldowns() {
|
|
|
869
876
|
// D#120: the injected-ids marker is also per-session now — same growth
|
|
870
877
|
// shape as the cooldown files, same 24h GC (dedup window is 5 min).
|
|
871
878
|
const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
|
|
872
|
-
const isInjectedMarker = name.startsWith('.claude-mem-injected-')
|
|
879
|
+
const isInjectedMarker = name.startsWith('.claude-mem-injected-')
|
|
880
|
+
|| name.startsWith('.claude-mem-keyctx-'); // D#123 Key Context marker — same per-session growth, same 24h GC
|
|
873
881
|
if (!isCooldown && !isInjectedMarker) continue;
|
|
874
882
|
try {
|
|
875
883
|
const p = join(RUNTIME_DIR, name);
|
|
@@ -889,7 +897,7 @@ function gcStalePreRecallCooldowns() {
|
|
|
889
897
|
function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
|
|
890
898
|
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
891
899
|
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
892
|
-
const autoCompressAge = Date.now() - 30 *
|
|
900
|
+
const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
|
|
893
901
|
|
|
894
902
|
db.transaction(() => {
|
|
895
903
|
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
@@ -944,7 +952,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
944
952
|
// imp=1 on these already; this just shrinks the GC latency so the
|
|
945
953
|
// projected 32.5% corpus reduction materializes within a week on live
|
|
946
954
|
// DBs instead of bleeding into the 30-day tier.
|
|
947
|
-
const noiseCompressAge = Date.now() - 7 *
|
|
955
|
+
const noiseCompressAge = Date.now() - 7 * DAY_MS;
|
|
948
956
|
const noiseCompressed = db.prepare(`
|
|
949
957
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
950
958
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
@@ -974,7 +982,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
974
982
|
} catch {}
|
|
975
983
|
if (shouldMaintain) {
|
|
976
984
|
try {
|
|
977
|
-
const STALE_AGE = Date.now() - 30 *
|
|
985
|
+
const STALE_AGE = Date.now() - 30 * DAY_MS;
|
|
978
986
|
const OP_CAP = 500;
|
|
979
987
|
// Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
|
|
980
988
|
// op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
|
|
@@ -994,7 +1002,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
994
1002
|
// children (compressed_into dangling at a deleted id). purgeStale recovers them
|
|
995
1003
|
// first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
|
|
996
1004
|
// created_at_epoch: 30d marking gate + 7d grace = 37d.
|
|
997
|
-
const purged = purgeStale(db, mctx, Date.now() - 37 *
|
|
1005
|
+
const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
|
|
998
1006
|
if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
|
|
999
1007
|
|
|
1000
1008
|
const cleaned = cleanupBroken(db, mctx);
|
|
@@ -1108,7 +1116,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1108
1116
|
DELETE FROM session_handoffs
|
|
1109
1117
|
WHERE (type = 'clear' AND created_at_epoch < ?)
|
|
1110
1118
|
OR (type != 'clear' AND created_at_epoch < ?)
|
|
1111
|
-
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR -
|
|
1119
|
+
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR - DAY_MS, Date.now() - HANDOFF_EXPIRY_EXIT - DAY_MS);
|
|
1112
1120
|
if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
|
|
1113
1121
|
} catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
|
|
1114
1122
|
|
|
@@ -1348,6 +1356,10 @@ async function handleSessionStart() {
|
|
|
1348
1356
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
1349
1357
|
// moved here from pre-tool-recall.js's hot path.
|
|
1350
1358
|
gcStalePreRecallCooldowns();
|
|
1359
|
+
// P2-15: the per-PROJECT half of the same problem — markers written once per
|
|
1360
|
+
// project and never revisited (session-/cite-recall-/skill cooldowns). Same
|
|
1361
|
+
// SessionStart cadence, 30d gate, named family list (hook-shared.mjs).
|
|
1362
|
+
try { sweepStaleProjectMarkers(RUNTIME_DIR); } catch { /* best-effort */ }
|
|
1351
1363
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1352
1364
|
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1353
1365
|
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
@@ -1471,13 +1483,31 @@ async function handleSessionStart() {
|
|
|
1471
1483
|
// token-budgeted observation pool directly from the DB.
|
|
1472
1484
|
// Pass CC session id so the Working State block is scoped to this session,
|
|
1473
1485
|
// preventing parallel sessions from seeing each other's /clear handoff.
|
|
1474
|
-
const
|
|
1486
|
+
const contextCollector = {};
|
|
1487
|
+
const fullContext = buildSessionContextLines(db, project, now, ccSessionId, contextCollector);
|
|
1475
1488
|
|
|
1476
1489
|
// Stdout is the sole context-delivery channel. The SessionStart hook output
|
|
1477
1490
|
// is injected as a <system-reminder> at session start, giving Claude the
|
|
1478
1491
|
// full summary + handoff state + observations table fresh from the DB.
|
|
1479
1492
|
process.stdout.write(`<claude-mem-context>\n${fullContext}\n</claude-mem-context>\n`);
|
|
1480
1493
|
|
|
1494
|
+
// D#123 (review C-1): persist the Key Context ids ACTUALLY rendered above so
|
|
1495
|
+
// handleUserPrompt can exclude exactly those from <memory-context> — and
|
|
1496
|
+
// nothing else. Under quiet/adopted the sections don't render, the id list is
|
|
1497
|
+
// empty, and prompt-time injection is NOT suppressed (the old query-mirroring
|
|
1498
|
+
// exclude-set blanked the same-project leg on adopted projects). Written
|
|
1499
|
+
// unconditionally (even when empty) so a resumed session can't act on a
|
|
1500
|
+
// previous session's stale marker semantics; 24h GC below.
|
|
1501
|
+
// D#124: the same call also bumps injection_count on the rendered rows —
|
|
1502
|
+
// Key Context was a shown-but-uncounted surface, so its rows could never
|
|
1503
|
+
// reach applyCitationDecay's denominator. One recorder, both writers.
|
|
1504
|
+
recordKeyContextInjection(db, {
|
|
1505
|
+
runtimeDir: RUNTIME_DIR,
|
|
1506
|
+
project,
|
|
1507
|
+
sessionId: ccSessionId,
|
|
1508
|
+
ids: contextCollector.keyContextIds || [],
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1481
1511
|
// One-time migration: remove any stale <claude-mem-context> block left in
|
|
1482
1512
|
// CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
|
|
1483
1513
|
cleanupClaudeMdLegacyBlock();
|
|
@@ -1651,13 +1681,21 @@ async function handleUserPrompt() {
|
|
|
1651
1681
|
// (mirrors CC built-in memoryTypes.ts:215). Skip both Key Context lookup
|
|
1652
1682
|
// and the <memory-context> emission for this turn.
|
|
1653
1683
|
if (!detectMemOverride(promptText)) try {
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1684
|
+
// D#123 (review C-1): the exclude-set is the Key Context ids ACTUALLY
|
|
1685
|
+
// rendered at SessionStart — read from the marker handleSessionStart wrote,
|
|
1686
|
+
// not re-derived from a query. The old query-mirroring set excluded rows
|
|
1687
|
+
// that were never shown (quiet/adopted projects render no Key Context at
|
|
1688
|
+
// all), blanking the same-project <memory-context> leg outright. Missing
|
|
1689
|
+
// or other-session marker → empty set: unknown injections must fail open
|
|
1690
|
+
// (inject, maybe duplicate) rather than fail closed (suppress).
|
|
1691
|
+
const keyContextIds = [];
|
|
1692
|
+
try {
|
|
1693
|
+
const raw = readFileSync(join(RUNTIME_DIR, keyContextIdsFileName(project, ccSessionId)), 'utf8');
|
|
1694
|
+
const { ids, session } = JSON.parse(raw);
|
|
1695
|
+
if (Array.isArray(ids) && !(session && ccSessionId && session !== ccSessionId)) {
|
|
1696
|
+
keyContextIds.push(...ids);
|
|
1697
|
+
}
|
|
1698
|
+
} catch { /* no marker — nothing was injected, exclude nothing */ }
|
|
1661
1699
|
const pathAInjectedIds = [];
|
|
1662
1700
|
|
|
1663
1701
|
// Read IDs already injected by user-prompt-search.js to avoid duplicate injection
|
|
@@ -1686,8 +1724,9 @@ async function handleUserPrompt() {
|
|
|
1686
1724
|
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
1687
1725
|
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
1688
1726
|
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
|
1689
|
-
//
|
|
1690
|
-
// the pick. The chosen id is excluded from the <memory-context>
|
|
1727
|
+
// SessionStart Key Context set, which overlaps the high-value lesson pool and
|
|
1728
|
+
// would suppress the pick. The chosen id is excluded from the <memory-context>
|
|
1729
|
+
// block below instead.
|
|
1691
1730
|
const imperativePick = taskImperativeOn
|
|
1692
1731
|
? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
|
|
1693
1732
|
: null;
|
|
@@ -1772,7 +1811,7 @@ function handleAutoCompress() {
|
|
|
1772
1811
|
if (!db) return;
|
|
1773
1812
|
|
|
1774
1813
|
try {
|
|
1775
|
-
const compressCutoff = Date.now() - 60 *
|
|
1814
|
+
const compressCutoff = Date.now() - 60 * DAY_MS; // 60 days
|
|
1776
1815
|
const compressCandidates = selectCompressionCandidates(db, { cutoff: compressCutoff, includeAutoMarked: true });
|
|
1777
1816
|
if (compressCandidates.length < 3) return;
|
|
1778
1817
|
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { debugCatch } from '../utils.mjs';
|
|
16
|
+
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
16
17
|
|
|
18
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
17
19
|
// `#123` / `#45678` at a word boundary — matches the CLAUDE.md cite pattern.
|
|
18
20
|
// Bounded to 1-7 digits to skip URL fragments, markdown anchors, etc.
|
|
19
21
|
const CITATION_RE = /#(\d{1,7})\b/g;
|
|
@@ -342,16 +344,55 @@ export function extractInjectedFromFyi(transcriptPath, opts = {}) {
|
|
|
342
344
|
return ids;
|
|
343
345
|
}
|
|
344
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Extract observation IDs rendered into the SessionStart / PreCompact
|
|
349
|
+
* `<claude-mem-context>` File Lessons + Key Context sections (D#124).
|
|
350
|
+
*
|
|
351
|
+
* The odd one out among the extractors: this surface leaves no hook attachment
|
|
352
|
+
* in the transcript — the block is written straight to stdout at SessionStart —
|
|
353
|
+
* so there is nothing to parse. It reads the per-session marker instead, the
|
|
354
|
+
* same file handleUserPrompt uses as its exclude-set, which by construction
|
|
355
|
+
* lists what was ACTUALLY rendered (empty on quiet/adopted projects where the
|
|
356
|
+
* sections never appear).
|
|
357
|
+
*
|
|
358
|
+
* Session-gated: a marker whose recorded session differs from the caller's is
|
|
359
|
+
* another window's render and must not enter this session's denominator. No
|
|
360
|
+
* coordinates → empty set, so transcript-only callers keep their old behaviour.
|
|
361
|
+
*
|
|
362
|
+
* @param {object} [ctx]
|
|
363
|
+
* @param {string} [ctx.runtimeDir]
|
|
364
|
+
* @param {string} [ctx.project]
|
|
365
|
+
* @param {string|null} [ctx.sessionId]
|
|
366
|
+
* @returns {Set<number>}
|
|
367
|
+
*/
|
|
368
|
+
export function extractInjectedFromKeyContext({ runtimeDir, project, sessionId = null } = {}) {
|
|
369
|
+
const ids = new Set();
|
|
370
|
+
if (!runtimeDir || !project) return ids;
|
|
371
|
+
try {
|
|
372
|
+
const raw = readFileSync(join(runtimeDir, keyContextIdsFileName(project, sessionId)), 'utf8');
|
|
373
|
+
const parsed = JSON.parse(raw);
|
|
374
|
+
if (sessionId && parsed?.session && parsed.session !== sessionId) return ids;
|
|
375
|
+
for (const id of Array.isArray(parsed?.ids) ? parsed.ids : []) addObsId(ids, id);
|
|
376
|
+
} catch { /* no marker / torn write → nothing was rendered, fail open */ }
|
|
377
|
+
return ids;
|
|
378
|
+
}
|
|
379
|
+
|
|
345
380
|
/**
|
|
346
381
|
* Union of every injection surface's IDs for a transcript: pre-tool-recall +
|
|
347
382
|
* UserPromptSubmit `<memory-context>` + PostToolUse error-recall + the
|
|
348
|
-
* user-prompt-search FYI block
|
|
383
|
+
* user-prompt-search FYI block + the SessionStart Key Context block. Single
|
|
384
|
+
* integration point the Stop handler calls.
|
|
349
385
|
*
|
|
350
386
|
* @param {string|null|undefined} transcriptPath
|
|
351
387
|
* @param {object} [opts]
|
|
352
388
|
* @param {boolean} [opts.mainOnly=false] Skip sidechain-injected IDs. The
|
|
353
389
|
* citation-decay caller passes true so the injected denominator matches the
|
|
354
390
|
* mainOnly cited numerator; the P4 access-bump caller omits it (broader).
|
|
391
|
+
* @param {string} [opts.runtimeDir] With `project`, enables the Key Context
|
|
392
|
+
* face. Omitted by callers that only have a transcript path (computeCiteRecall
|
|
393
|
+
* over an arbitrary file), which then see the four transcript-derived faces.
|
|
394
|
+
* @param {string} [opts.project]
|
|
395
|
+
* @param {string|null} [opts.sessionId]
|
|
355
396
|
* @returns {Set<number>}
|
|
356
397
|
*/
|
|
357
398
|
export function extractAllInjected(transcriptPath, opts = {}) {
|
|
@@ -360,6 +401,9 @@ export function extractAllInjected(transcriptPath, opts = {}) {
|
|
|
360
401
|
...extractInjectedFromUserPromptSubmit(transcriptPath, opts),
|
|
361
402
|
...extractInjectedFromErrorRecall(transcriptPath, opts),
|
|
362
403
|
...extractInjectedFromFyi(transcriptPath, opts),
|
|
404
|
+
// Marker-derived, not transcript-derived: no-op unless the caller passes
|
|
405
|
+
// runtimeDir + project (see extractInjectedFromKeyContext).
|
|
406
|
+
...extractInjectedFromKeyContext(opts),
|
|
363
407
|
]);
|
|
364
408
|
}
|
|
365
409
|
|
|
@@ -808,8 +852,8 @@ export function computeCitationFunnelTrend(db, { days = 7, limit = 10, project =
|
|
|
808
852
|
if (!db) return empty;
|
|
809
853
|
try {
|
|
810
854
|
const now = Date.now();
|
|
811
|
-
const windowStart = now - days *
|
|
812
|
-
const priorStart = now - 2 * days *
|
|
855
|
+
const windowStart = now - days * DAY_MS;
|
|
856
|
+
const priorStart = now - 2 * days * DAY_MS;
|
|
813
857
|
const projClause = project ? 'AND project = ?' : '';
|
|
814
858
|
|
|
815
859
|
const sessions = db.prepare(`
|
package/lib/db-backup.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { readdirSync, unlinkSync, statSync } from 'fs';
|
|
|
11
11
|
import { dirname, basename, join } from 'path';
|
|
12
12
|
import { debugLog } from '../utils.mjs';
|
|
13
13
|
|
|
14
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
14
15
|
// M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
|
|
15
16
|
// each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
|
|
16
17
|
// siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
|
|
@@ -35,7 +36,7 @@ export function backupBudgetBytes() {
|
|
|
35
36
|
// a per-tag exemption would make every one-shot tag's only snapshot immortal —
|
|
36
37
|
// exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
|
|
37
38
|
// convention (7d) used by stale-observation retention.
|
|
38
|
-
export const BACKUP_EVICTION_GRACE_MS = 7 *
|
|
39
|
+
export const BACKUP_EVICTION_GRACE_MS = 7 * DAY_MS;
|
|
39
40
|
|
|
40
41
|
// Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
|
|
41
42
|
// snapshotDb writes). Budget eviction deletes ONLY names matching this — a
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
1
2
|
// claude-mem-lite — deferred_work data layer
|
|
2
3
|
// Pure-data CRUD + ordinal resolver + transactional closure helper.
|
|
3
4
|
// Decoupled from observations table: different lifecycle, different scoring.
|
|
@@ -60,7 +61,6 @@ export function listOpenWithOrdinal(db, project, limit = 10) {
|
|
|
60
61
|
|
|
61
62
|
// ─── G11: list age + stale refresh hint (roadmap 2026-07-18) ─────────────────
|
|
62
63
|
|
|
63
|
-
const DAY_MS = 86_400_000;
|
|
64
64
|
// Internal const (was exported at v3.51.0 birth with zero external importers —
|
|
65
65
|
// knip-baseline discipline: un-export rather than grow the unused-exports list).
|
|
66
66
|
const DEFER_STALE_DAYS = 30;
|
package/lib/err-sampler.mjs
CHANGED
|
@@ -19,8 +19,8 @@ import { appendFileSync, mkdirSync, existsSync, readdirSync, statSync, unlinkSyn
|
|
|
19
19
|
import { join } from 'path';
|
|
20
20
|
import { scrubSecrets } from '../secret-scrub.mjs';
|
|
21
21
|
|
|
22
|
-
const DAY_MS = 86400000;
|
|
23
22
|
|
|
23
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
24
24
|
function today() {
|
|
25
25
|
// UTC date string; sharding daily keeps files bounded even at high sample rates.
|
|
26
26
|
return new Date(Date.now()).toISOString().slice(0, 10);
|
package/lib/hook-telemetry.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { join } from 'path';
|
|
|
26
26
|
import { isNativeBindingError } from './binding-probe.mjs';
|
|
27
27
|
import { recordNativeBindingBreakage } from './native-binding-hint.mjs';
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
import { DAY_MS } from './time-constants.mjs';
|
|
30
30
|
const RETENTION_MS = 14 * DAY_MS;
|
|
31
31
|
const HOOK_ERRORS_SUBDIR = 'hook-errors';
|
|
32
32
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// lib/inject-search-core.mjs — the
|
|
2
|
-
// 2026-08-14). Shared home for the three SQL
|
|
3
|
-
// hand-copied twins
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// deep-search /
|
|
7
|
-
//
|
|
1
|
+
// lib/inject-search-core.mjs — the retrieval-side shared core (P2-11, audit
|
|
2
|
+
// 2026-08-14; second cut D#123, 2026-08-16). Shared home for the three SQL
|
|
3
|
+
// atoms that kept drifting across hand-copied twins — first on the five
|
|
4
|
+
// injection-side surfaces, then extended across the remaining read surfaces
|
|
5
|
+
// (hook-context / hook-handoff / hook-optimize / mem-cli / search-scoring /
|
|
6
|
+
// tfidf / deep-search / recall-core / recent-core / timeline-core / stats-core /
|
|
7
|
+
// search-core incl. its sessions+events decay arms / maintain-core; the ledger
|
|
8
|
+
// test enforces the full list). Deliberate compressed-only singles (maintain
|
|
9
|
+
// UPDATE guards, stats noise-gauge counts, export tombstone toggles, session-own
|
|
10
|
+
// history) stay inline — see the ledger test's non-member notes:
|
|
8
11
|
//
|
|
9
12
|
// * live-row filter — the compressed+superseded pair whose omission was
|
|
10
13
|
// the superseded-invariant's recurring reopening
|
|
@@ -17,12 +20,12 @@
|
|
|
17
20
|
// behavior factors (audit M-3: wired on every auto
|
|
18
21
|
// surface, missing from the explicit-surface score)
|
|
19
22
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
23
|
+
// Each surface keeps its own deliberate pipeline composition (BM25-sort + JS
|
|
24
|
+
// scoring vs SQL full chain vs file-keyed sort — see #8786: per-surface
|
|
25
|
+
// asymmetries stay explicit); only the ATOMS are shared.
|
|
26
|
+
// tests/inject-search-core.test.mjs holds the ledger: consumer files must
|
|
27
|
+
// compose these builders, never re-inline copies (and the decay shape may not
|
|
28
|
+
// be hand-rolled anywhere, benchmark included).
|
|
26
29
|
//
|
|
27
30
|
// Lives under lib/ (not scripts/) so hook.mjs can statically import it without
|
|
28
31
|
// colliding with the installExtractedRelease scripts-dir rename (same constraint
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -27,3 +27,23 @@ export function injectedIdsFileName(project, sessionId) {
|
|
|
27
27
|
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
28
28
|
return `${base}-${safe}`;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
|
|
33
|
+
* ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
|
|
34
|
+
* sections (empty under quiet/adopted). handleUserPrompt reads it as its
|
|
35
|
+
* exclude-set — D#123 review C-1: excluding the injector's QUERY result instead
|
|
36
|
+
* of what was really shown suppressed <memory-context> injection outright on
|
|
37
|
+
* quiet/adopted projects, where Key Context never renders at all.
|
|
38
|
+
* Session-lifetime validity (no time window): the SessionStart block stays in
|
|
39
|
+
* context for the whole session. Swept with the same 24h GC as the marker above.
|
|
40
|
+
* @param {string} project - inferProject() value (already filename-safe)
|
|
41
|
+
* @param {string} [sessionId] - CC session id
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function keyContextIdsFileName(project, sessionId) {
|
|
45
|
+
const base = `.claude-mem-keyctx-${project}`;
|
|
46
|
+
if (!sessionId) return base;
|
|
47
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
48
|
+
return `${base}-${safe}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// lib/keyctx-marker.mjs — the one place the SessionStart Key Context render is
|
|
2
|
+
// recorded. Two things happen together because they must describe the SAME set:
|
|
3
|
+
//
|
|
4
|
+
// ① the per-session marker file (D#123 review C-1): the obs ids ACTUALLY
|
|
5
|
+
// rendered into <claude-mem-context>, which handleUserPrompt reads as its
|
|
6
|
+
// exclude-set. Written even when empty, so a resumed session can never act
|
|
7
|
+
// on a previous session's stale semantics.
|
|
8
|
+
// ② injection_count / last_injected_at on those same rows (D#124): before
|
|
9
|
+
// this, Key Context was a shown-but-uncounted surface — up to 10 rows per
|
|
10
|
+
// session that could accrue no denominator and therefore never promote or
|
|
11
|
+
// demote through applyCitationDecay.
|
|
12
|
+
//
|
|
13
|
+
// handleSessionStart (hook.mjs) and handlePreCompact (hook-precompact.mjs) both
|
|
14
|
+
// render the block, so both call this. Keeping the pair in one function is the
|
|
15
|
+
// point: the CLI/MCP and SessionStart/PreCompact twin pairs in this repo have
|
|
16
|
+
// drifted often enough that "two callers, one body" is the standing rule.
|
|
17
|
+
//
|
|
18
|
+
// Never throws: a marker-write failure must not break context delivery, and it
|
|
19
|
+
// must not cost the metering either — the bump runs first.
|
|
20
|
+
|
|
21
|
+
import { writeFileSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { debugCatch } from '../utils.mjs';
|
|
24
|
+
import { keyContextIdsFileName } from './injected-ids.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record one Key Context render: bump the rendered rows, then persist the id
|
|
28
|
+
* list for the prompt-time exclude-set and the citation extractor.
|
|
29
|
+
*
|
|
30
|
+
* @param {import('better-sqlite3').Database} db
|
|
31
|
+
* @param {object} ctx
|
|
32
|
+
* @param {string} ctx.runtimeDir RUNTIME_DIR of the caller
|
|
33
|
+
* @param {string} ctx.project inferProject() value (filename-safe)
|
|
34
|
+
* @param {string|null} [ctx.sessionId] CC session id
|
|
35
|
+
* @param {number[]} [ctx.ids] obs ids ACTUALLY rendered (empty on quiet projects)
|
|
36
|
+
* @returns {{bumped: number, written: boolean}}
|
|
37
|
+
*/
|
|
38
|
+
export function recordKeyContextInjection(db, { runtimeDir, project, sessionId = null, ids = [] } = {}) {
|
|
39
|
+
const clean = [];
|
|
40
|
+
for (const raw of Array.isArray(ids) ? ids : []) {
|
|
41
|
+
const id = Number(raw);
|
|
42
|
+
if (Number.isInteger(id) && id > 0 && id < 1e7) clean.push(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let bumped = 0;
|
|
46
|
+
if (db && clean.length > 0) {
|
|
47
|
+
try {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
// Mirrors hook-memory.mjs's UPS bump verbatim so the two surfaces feed the
|
|
50
|
+
// same counter with the same semantics. Per-row try/catch for FTS trigger
|
|
51
|
+
// safety (project_non_obvious.md).
|
|
52
|
+
const stmt = db.prepare(
|
|
53
|
+
'UPDATE observations SET injection_count = COALESCE(injection_count, 0) + 1, last_injected_at = ? WHERE id = ?'
|
|
54
|
+
);
|
|
55
|
+
for (const id of clean) {
|
|
56
|
+
try { stmt.run(now, id); bumped++; } catch { /* single-row failure must not drop the rest */ }
|
|
57
|
+
}
|
|
58
|
+
} catch (e) { debugCatch(e, 'keyctx-bump'); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let written = false;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(runtimeDir, keyContextIdsFileName(project, sessionId)),
|
|
65
|
+
JSON.stringify({ ids: clean, ts: Date.now(), session: sessionId || null }),
|
|
66
|
+
);
|
|
67
|
+
written = true;
|
|
68
|
+
} catch (e) { debugCatch(e, 'keyctx-marker-write'); }
|
|
69
|
+
|
|
70
|
+
return { bumped, written };
|
|
71
|
+
}
|