claude-mem-lite 3.65.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/format-utils.mjs +2 -1
- package/haiku-client.mjs +39 -6
- package/hook-context.mjs +5 -4
- package/hook-llm.mjs +7 -5
- package/hook-memory.mjs +4 -3
- package/hook-optimize.mjs +11 -10
- package/hook-precompact.mjs +12 -12
- package/hook-shared.mjs +91 -5
- package/hook.mjs +28 -14
- 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/keyctx-marker.mjs +71 -0
- package/lib/maintain-core.mjs +2 -1
- package/lib/metrics.mjs +1 -1
- package/lib/save-enrich.mjs +3 -2
- package/lib/search-core.mjs +2 -1
- package/lib/stats-core.mjs +4 -3
- package/lib/stats-quality.mjs +2 -1
- package/lib/time-constants.mjs +21 -0
- package/mem-cli.mjs +6 -5
- 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 +2 -1
- package/server.mjs +10 -4
- package/source-files.mjs +6 -0
- package/tier.mjs +4 -3
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.66.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.66.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/format-utils.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
1
2
|
// claude-mem-lite: String formatting and display utilities
|
|
2
3
|
// Extracted from utils.mjs for focused responsibility
|
|
3
4
|
|
|
@@ -231,7 +232,7 @@ export function isoWeekKey(epochMs) {
|
|
|
231
232
|
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
232
233
|
tmp.setUTCDate(tmp.getUTCDate() + 4 - (tmp.getUTCDay() || 7));
|
|
233
234
|
const yearStart = new Date(Date.UTC(tmp.getUTCFullYear(), 0, 1));
|
|
234
|
-
const weekNum = Math.ceil(((tmp - yearStart) /
|
|
235
|
+
const weekNum = Math.ceil(((tmp - yearStart) / DAY_MS + 1) / 7);
|
|
235
236
|
const isoYear = tmp.getUTCFullYear();
|
|
236
237
|
return `${isoYear}-W${String(weekNum).padStart(2, '0')}`;
|
|
237
238
|
}
|
package/haiku-client.mjs
CHANGED
|
@@ -100,6 +100,28 @@ const MODEL_MAP = {
|
|
|
100
100
|
// A call that genuinely needs sampling can pass opts.temperature to override.
|
|
101
101
|
const DEFAULT_LLM_TEMPERATURE = 0;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Timeout budget for BACKGROUND LLM work (detached enrich/optimize/summary
|
|
105
|
+
* workers, registry indexing) — the calls with no latency budget at all.
|
|
106
|
+
*
|
|
107
|
+
* Every dispatcher below degrades to `claude -p` when the keyed provider fails,
|
|
108
|
+
* and the CLI leg pays a full Claude Code boot before inference: measured
|
|
109
|
+
* 8.1s / 9.2s / 11.7s / 13.4s on an idle machine for a 400-token JSON reply
|
|
110
|
+
* (2026-08-16), against API-leg latencies under 2s. Callers that sized their
|
|
111
|
+
* timeout for the API leg (15–20s) were therefore killing the fallback
|
|
112
|
+
* mid-flight — save-enrich's 15s budget left 1.6s of headroom over the worst
|
|
113
|
+
* sample, which is how 6/57 (10.5%) of instrumented runs landed on
|
|
114
|
+
* reason:'llm-null' and why manual saves stopped getting search_aliases.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately NOT applied as a floor inside callModelCLI / callHaikuCLI /
|
|
117
|
+
* callModelCLIAsync: those are also reached from latency-bound callers — the
|
|
118
|
+
* lesson bridge's 2.5s fail-open budget on the PreToolUse hook, and deep-search
|
|
119
|
+
* rerank on the MCP request path — where failing fast beats blocking a user for
|
|
120
|
+
* 45s. The allowance is caller-side policy, not a clamp.
|
|
121
|
+
* Pinned both ways by `tests/llm-timeout-budget.test.mjs`.
|
|
122
|
+
*/
|
|
123
|
+
export const BG_LLM_TIMEOUT_MS = 45000;
|
|
124
|
+
|
|
103
125
|
/**
|
|
104
126
|
* Resolve the LLM model to use for background calls.
|
|
105
127
|
* Reads CLAUDE_MEM_MODEL env var, defaults to 'haiku'.
|
|
@@ -409,11 +431,20 @@ async function callModelAPI(prompt, model, { timeout, maxTokens, temperature = D
|
|
|
409
431
|
function callModelCLI(prompt, model, { timeout }) {
|
|
410
432
|
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
411
433
|
try {
|
|
412
|
-
|
|
434
|
+
// --no-session-persistence + DISABLE_CLAUDEMD_HOOKS (2026-08-16): these
|
|
435
|
+
// headless calls were paying the full interactive-session tax — 1,004
|
|
436
|
+
// transcripts piled up in ~/.claude/projects/-tmp/, and every spawn ran
|
|
437
|
+
// the claudemd plugin's whole hook fan-out (its SessionStart banner alone
|
|
438
|
+
// logged 682 rows in 3 days, drowning that project's telemetry). The
|
|
439
|
+
// persistence flag is OAuth-safe (probed); `--bare`/CLAUDE_CODE_SIMPLE are
|
|
440
|
+
// NOT (they hard-require ANTHROPIC_API_KEY — "Not logged in" on OAuth
|
|
441
|
+
// machines). The user's global CLAUDE.md injection has no OAuth-safe
|
|
442
|
+
// opt-out; accepted (haiku + prompt caching keeps it cheap).
|
|
443
|
+
const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
413
444
|
input: flattenForCLI(prompt),
|
|
414
445
|
timeout,
|
|
415
446
|
encoding: 'utf8',
|
|
416
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
447
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
417
448
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
418
449
|
cwd: '/tmp',
|
|
419
450
|
});
|
|
@@ -453,8 +484,9 @@ export function callModelCLIAsync(prompt, model, { timeout }) {
|
|
|
453
484
|
const modelName = MODEL_MAP[model] ? model : 'haiku';
|
|
454
485
|
let child;
|
|
455
486
|
try {
|
|
456
|
-
|
|
457
|
-
|
|
487
|
+
// Same headless-tax flags as callModelCLI (rationale there).
|
|
488
|
+
child = spawn(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
489
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
458
490
|
cwd: '/tmp',
|
|
459
491
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
460
492
|
});
|
|
@@ -603,11 +635,12 @@ async function callOpenRouterAPI(prompt, tier, { timeout, maxTokens, temperature
|
|
|
603
635
|
function callHaikuCLI(prompt, { timeout }) {
|
|
604
636
|
const { cli: modelName } = resolveModel();
|
|
605
637
|
try {
|
|
606
|
-
|
|
638
|
+
// Same headless-tax flags as callModelCLI (rationale there).
|
|
639
|
+
const result = execFileSync(getClaudePath(), ['-p', '--model', modelName, '--no-session-persistence'], {
|
|
607
640
|
input: flattenForCLI(prompt),
|
|
608
641
|
timeout,
|
|
609
642
|
encoding: 'utf8',
|
|
610
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
643
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
611
644
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
612
645
|
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
613
646
|
});
|
package/hook-context.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
|
17
17
|
import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
18
18
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
19
19
|
|
|
20
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
20
21
|
/**
|
|
21
22
|
* Infer the project directory from environment variables or cwd.
|
|
22
23
|
* @returns {string} Absolute path to the project directory
|
|
@@ -43,7 +44,7 @@ function mdCell(s) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function computeAdaptiveWindows(db, project) {
|
|
46
|
-
const sevenDaysAgo = Date.now() - 7 *
|
|
47
|
+
const sevenDaysAgo = Date.now() - 7 * DAY_MS;
|
|
47
48
|
const row = db.prepare(`
|
|
48
49
|
SELECT COUNT(*) as c FROM observations
|
|
49
50
|
WHERE project = ? AND created_at_epoch > ? AND COALESCE(compressed_into, 0) = 0
|
|
@@ -52,13 +53,13 @@ export function computeAdaptiveWindows(db, project) {
|
|
|
52
53
|
|
|
53
54
|
if (velocity > 10) {
|
|
54
55
|
// High velocity: tighter windows, focus on very recent
|
|
55
|
-
return { tier1: 12 * 3600000, tier2: 3 *
|
|
56
|
+
return { tier1: 12 * 3600000, tier2: 3 * DAY_MS, tier3: 14 * DAY_MS, sessWindow: 3 * DAY_MS };
|
|
56
57
|
} else if (velocity >= 3) {
|
|
57
58
|
// Medium velocity: default windows
|
|
58
|
-
return { tier1: 24 * 3600000, tier2: 7 *
|
|
59
|
+
return { tier1: 24 * 3600000, tier2: 7 * DAY_MS, tier3: 30 * DAY_MS, sessWindow: 7 * DAY_MS };
|
|
59
60
|
} else {
|
|
60
61
|
// Low velocity: wider windows, older data still relevant
|
|
61
|
-
return { tier1: 48 * 3600000, tier2: 14 *
|
|
62
|
+
return { tier1: 48 * 3600000, tier2: 14 * DAY_MS, tier3: 60 * DAY_MS, sessWindow: 14 * DAY_MS };
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
package/hook-llm.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getCurrentBranch, notLowSignalTitleClause,
|
|
11
11
|
} from './utils.mjs';
|
|
12
12
|
import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
|
|
13
|
+
import { BG_LLM_TIMEOUT_MS } from './haiku-client.mjs';
|
|
13
14
|
import { scrubRecord } from './lib/scrub-record.mjs';
|
|
14
15
|
import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
|
|
15
16
|
import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope } from './lib/observation-write.mjs';
|
|
@@ -23,6 +24,7 @@ import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './l
|
|
|
23
24
|
import { episodeHasSignificantContent } from './hook-episode.mjs';
|
|
24
25
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
25
26
|
|
|
27
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
26
28
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
27
29
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
28
30
|
const EVENT_TYPE_SET = new Set(EVENT_TYPES);
|
|
@@ -84,7 +86,7 @@ export function recordRetryAttempt(db, recovered, bucket = dateBucketUtc()) {
|
|
|
84
86
|
* YYYY-MM-DD lexicographic order).
|
|
85
87
|
*/
|
|
86
88
|
export function readRetryStats(db, days = 30) {
|
|
87
|
-
const cutoff = new Date(Date.now() - days *
|
|
89
|
+
const cutoff = new Date(Date.now() - days * DAY_MS);
|
|
88
90
|
return db.prepare(
|
|
89
91
|
`SELECT date_bucket, attempts, recovered FROM lesson_retry_stats
|
|
90
92
|
WHERE date_bucket >= ? ORDER BY date_bucket DESC`
|
|
@@ -205,8 +207,8 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
|
|
|
205
207
|
// 3-day Jaccard catches near-duplicates without blocking legitimately new observations
|
|
206
208
|
const LOW_SIGNAL = LOW_SIGNAL_TITLE;
|
|
207
209
|
if (obs.title && LOW_SIGNAL.test(obs.title)) {
|
|
208
|
-
const sevenDaysAgo = now.getTime() - 7 *
|
|
209
|
-
const threeDaysAgo = now.getTime() - 3 *
|
|
210
|
+
const sevenDaysAgo = now.getTime() - 7 * DAY_MS;
|
|
211
|
+
const threeDaysAgo = now.getTime() - 3 * DAY_MS;
|
|
210
212
|
// Phase 1: exact title match within 7 days
|
|
211
213
|
const exactDup = db.prepare(`
|
|
212
214
|
SELECT 1 FROM observations
|
|
@@ -856,7 +858,7 @@ ${actionList}`;
|
|
|
856
858
|
const retrySlot = await acquireLLMSlot();
|
|
857
859
|
try {
|
|
858
860
|
const retryPrompt = buildLessonRetryPrompt(episode, parsed);
|
|
859
|
-
const retryRaw = retrySlot ? await callLLM(retryPrompt,
|
|
861
|
+
const retryRaw = retrySlot ? await callLLM(retryPrompt, BG_LLM_TIMEOUT_MS) : null;
|
|
860
862
|
if (retryRaw) {
|
|
861
863
|
const retry = parseJsonFromLLM(retryRaw);
|
|
862
864
|
const retryLesson = typeof retry?.lesson === 'string' ? retry.lesson.trim() : '';
|
|
@@ -1135,7 +1137,7 @@ ${obsList}`;
|
|
|
1135
1137
|
|
|
1136
1138
|
let raw, llmParsed;
|
|
1137
1139
|
try {
|
|
1138
|
-
raw = await callLLM(prompt,
|
|
1140
|
+
raw = await callLLM(prompt, BG_LLM_TIMEOUT_MS);
|
|
1139
1141
|
llmParsed = parseJsonFromLLM(raw);
|
|
1140
1142
|
} finally {
|
|
1141
1143
|
releaseLLMSlot();
|
package/hook-memory.mjs
CHANGED
|
@@ -9,8 +9,9 @@ import { DB_DIR } from './schema.mjs';
|
|
|
9
9
|
import { extractIdents } from './lib/lesson-idents.mjs';
|
|
10
10
|
import { formatSubagentContext } from './lib/task-imperative.mjs';
|
|
11
11
|
|
|
12
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
12
13
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
13
|
-
const MEMORY_LOOKBACK_MS = 60 *
|
|
14
|
+
const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
14
15
|
// Aligned with TYPE_QUALITY_CASE in scoring-sql.mjs (R2 rebalance).
|
|
15
16
|
// Weights calibrated to empirical avg access_count:
|
|
16
17
|
// decision 6.05, discovery 3.32, bugfix 2.24, feature 2.04, change 0.93, refactor 0.54.
|
|
@@ -98,7 +99,7 @@ function candidateCoverage(row, queryTerms) {
|
|
|
98
99
|
return hits / queryTerms.length;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const FILE_RECALL_LOOKBACK_MS = 60 *
|
|
102
|
+
const FILE_RECALL_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
102
103
|
const MAX_FILE_RECALL = 2;
|
|
103
104
|
|
|
104
105
|
// P1: stale-obs verify-before-use threshold. An injected obs older than this
|
|
@@ -107,7 +108,7 @@ const MAX_FILE_RECALL = 2;
|
|
|
107
108
|
// renamed since capture. Pure-decision/architecture obs (no file_paths)
|
|
108
109
|
// don't get the hint: their drift is text-only and Claude already verifies
|
|
109
110
|
// at consumption time per the project mem-usage contract.
|
|
110
|
-
const STALE_OBS_THRESHOLD_MS = 30 *
|
|
111
|
+
const STALE_OBS_THRESHOLD_MS = 30 * DAY_MS;
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
114
|
* Format a single line for the <memory-context> block emitted by
|
package/hook-optimize.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
computeMinHash, estimateJaccardFromMinHash, jaccardSimilarity, clampImportance, cjkBigrams,
|
|
14
14
|
notLowSignalTitleClause, scrubSecrets,
|
|
15
15
|
} from './utils.mjs';
|
|
16
|
-
import { callModelJSON } from './haiku-client.mjs';
|
|
16
|
+
import { callModelJSON, 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
19
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
@@ -22,6 +22,7 @@ import { DB_DIR } from './schema.mjs';
|
|
|
22
22
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
23
23
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
24
24
|
|
|
25
|
+
import { DAY_MS } from './lib/time-constants.mjs';
|
|
25
26
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
26
27
|
|
|
27
28
|
// ─── Budget ─────────────────────────────────────────────────────────────────
|
|
@@ -174,7 +175,7 @@ Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
|
174
175
|
|
|
175
176
|
JSON: {"search_aliases":["alt phrasing","synonym","spelled-out jargon","CJK term if the domain word has one"]}
|
|
176
177
|
Give 3-6 aliases: words a user might search for the SAME concept but that are NOT already in the title (synonyms, the spelled-out form of an acronym, the jargon term for a described symptom, a CJK translation of a key domain term).`;
|
|
177
|
-
const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout:
|
|
178
|
+
const parsed = await callModelJSON(aliasPrompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 300 });
|
|
178
179
|
const aliasArr = parsed && Array.isArray(parsed.search_aliases)
|
|
179
180
|
? parsed.search_aliases.filter((a) => typeof a === 'string' && a.trim().length > 0)
|
|
180
181
|
: [];
|
|
@@ -203,7 +204,7 @@ importance: 0=no value, 1=routine, 2=notable non-obvious insight, 3=critical. De
|
|
|
203
204
|
lesson_learned: State what was learned. If routine, write "none".
|
|
204
205
|
search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
205
206
|
|
|
206
|
-
const parsed = await callModelJSON(prompt, 'haiku', { timeout:
|
|
207
|
+
const parsed = await callModelJSON(prompt, 'haiku', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 500 });
|
|
207
208
|
if (!parsed || !parsed.title) { skipped++; continue; }
|
|
208
209
|
|
|
209
210
|
// Auto-hide on importance:0 targets fully-degraded NARROW rows (this branch predates
|
|
@@ -288,7 +289,7 @@ search_aliases: 2-6 alternative search terms (include CJK if applicable).`;
|
|
|
288
289
|
// ─── Task 2: Normalize ─────────────────────────────────────────────────────
|
|
289
290
|
|
|
290
291
|
const NORMALIZE_GATE_FILE = join(RUNTIME_DIR, 'last-normalize.json');
|
|
291
|
-
const NORMALIZE_INTERVAL_MS = 7 *
|
|
292
|
+
const NORMALIZE_INTERVAL_MS = 7 * DAY_MS; // 7 days
|
|
292
293
|
|
|
293
294
|
// Pure gate decision (no IO) — exported for testing. Fail-OPEN on a
|
|
294
295
|
// malformed-but-valid-JSON gate: a missing/non-numeric `epoch` makes
|
|
@@ -355,7 +356,7 @@ Rules:
|
|
|
355
356
|
- Include CJK ↔ English equivalents if present
|
|
356
357
|
- Skip terms that have no synonyms in the list`;
|
|
357
358
|
|
|
358
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
359
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
359
360
|
if (!parsed?.groups || !Array.isArray(parsed.groups)) return [];
|
|
360
361
|
return parsed.groups.filter(g => g.canonical && Array.isArray(g.aliases) && g.aliases.length > 0);
|
|
361
362
|
} catch (e) {
|
|
@@ -447,7 +448,7 @@ export async function executeNormalize(db, force = false, { project } = {}) {
|
|
|
447
448
|
|
|
448
449
|
// ─── Task 3: Cluster-merge ─────────────────────────────────────────────────
|
|
449
450
|
|
|
450
|
-
const MERGE_TIME_WINDOW_MS = 30 *
|
|
451
|
+
const MERGE_TIME_WINDOW_MS = 30 * DAY_MS;
|
|
451
452
|
// Merge-review band [MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD): titles in this
|
|
452
453
|
// Jaccard range are LLM-reviewed for merge; at/above AUTO_MERGE_THRESHOLD they'd
|
|
453
454
|
// already auto-merge elsewhere, below MERGE_JACCARD_LOW they're too dissimilar.
|
|
@@ -523,7 +524,7 @@ Return ONLY valid JSON:
|
|
|
523
524
|
- If they should NOT be merged: {"should_merge":false}
|
|
524
525
|
- If they SHOULD be merged: {"should_merge":true,"merged_title":"≤120 char comprehensive title","merged_narrative":"comprehensive ≤800 char summary preserving all key details","merged_concepts":["kw1","kw2"],"merged_facts":["specific fact 1"],"merged_lesson":"synthesized non-obvious lesson or null","importance":2}`;
|
|
525
526
|
|
|
526
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
527
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
527
528
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
528
529
|
|
|
529
530
|
// Keeper = highest importance, then highest access_count. Previously access_count
|
|
@@ -641,11 +642,11 @@ export async function executeClusterMerge(db, maxClusters = 5, { project } = {})
|
|
|
641
642
|
|
|
642
643
|
// ─── Task 4: Smart-compress ────────────────────────────────────────────────
|
|
643
644
|
|
|
644
|
-
const COMPRESS_TIME_SPLIT_MS = 14 *
|
|
645
|
+
const COMPRESS_TIME_SPLIT_MS = 14 * DAY_MS;
|
|
645
646
|
const COMPRESS_COSINE_THRESHOLD = 0.3;
|
|
646
647
|
|
|
647
648
|
export function findSmartCompressCandidates(db, ageDays = 30, { project } = {}) {
|
|
648
|
-
const cutoff = Date.now() - ageDays *
|
|
649
|
+
const cutoff = Date.now() - ageDays * DAY_MS;
|
|
649
650
|
const projectClause = project ? 'AND project = ?' : '';
|
|
650
651
|
const stmt = db.prepare(`
|
|
651
652
|
SELECT id, title, narrative, lesson_learned, project, type, created_at_epoch
|
|
@@ -757,7 +758,7 @@ ${obsDescriptions}
|
|
|
757
758
|
|
|
758
759
|
JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
|
|
759
760
|
|
|
760
|
-
const parsed = await callModelJSON(prompt, 'sonnet', { timeout:
|
|
761
|
+
const parsed = await callModelJSON(prompt, 'sonnet', { timeout: BG_LLM_TIMEOUT_MS, maxTokens: 1000 });
|
|
761
762
|
if (!parsed || !parsed.title) return { compressed: false };
|
|
762
763
|
|
|
763
764
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
package/hook-precompact.mjs
CHANGED
|
@@ -5,17 +5,15 @@
|
|
|
5
5
|
// Differs from SessionStart-on-compact (which fires AFTER compaction):
|
|
6
6
|
// PreCompact ensures memory survives the compaction step itself.
|
|
7
7
|
|
|
8
|
-
import { writeFileSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
8
|
import { buildSessionContextLines } from './hook-context.mjs';
|
|
11
9
|
import { inferProject, debugCatch, debugLog } from './utils.mjs';
|
|
12
10
|
import { RUNTIME_DIR } from './hook-shared.mjs';
|
|
13
|
-
import {
|
|
11
|
+
import { recordKeyContextInjection } from './lib/keyctx-marker.mjs';
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
* Build + emit the memory context block on stdout.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Build + emit the memory context block on stdout. Writes the Key Context ids
|
|
15
|
+
* the re-emitted block renders (refreshing handleUserPrompt's exclude-set — see
|
|
16
|
+
* D#123 in hook.mjs) and bumps injection_count on those rows (D#124).
|
|
19
17
|
*
|
|
20
18
|
* @param {object} ctx
|
|
21
19
|
* @param {import('better-sqlite3').Database} ctx.db
|
|
@@ -29,12 +27,14 @@ export function handlePreCompact({ db, project, sessionId }) {
|
|
|
29
27
|
const body = buildSessionContextLines(db, project, new Date(), sessionId || null, collector);
|
|
30
28
|
if (!body || String(body).trim() === '') return;
|
|
31
29
|
process.stdout.write(`<claude-mem-context>\n${body}\n</claude-mem-context>\n`);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
// Same recorder as handleSessionStart: marker + injection_count bump (D#124).
|
|
31
|
+
// A re-render into a compacted context is a fresh injection of those rows.
|
|
32
|
+
recordKeyContextInjection(db, {
|
|
33
|
+
runtimeDir: RUNTIME_DIR,
|
|
34
|
+
project,
|
|
35
|
+
sessionId: sessionId || null,
|
|
36
|
+
ids: collector.keyContextIds || [],
|
|
37
|
+
});
|
|
38
38
|
} catch (e) {
|
|
39
39
|
debugCatch(e, 'handlePreCompact');
|
|
40
40
|
}
|
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,7 +31,7 @@ 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
|
|
35
36
|
// Candidate rows the SessionStart Key Context surface considers (hook-context.mjs
|
|
36
37
|
// keyObs; each of the two sections then renders at most 5). The user-prompt
|
|
@@ -123,6 +124,89 @@ export function sweepOrphanEpisodeFiles(runtimeDir, { ageMs = ORPHAN_EPISODE_AGE
|
|
|
123
124
|
return count;
|
|
124
125
|
}
|
|
125
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
|
+
|
|
126
210
|
// Ensure runtime directory exists AND is owner-only (0700), matching the DB dir
|
|
127
211
|
// (schema.mjs). Runtime aux files carry captured file paths + scrubbed activity; on a
|
|
128
212
|
// shared host a 0755 dir would let another local user read them. hardenRuntimeFiles()
|
|
@@ -195,7 +279,7 @@ export function openDb() {
|
|
|
195
279
|
// response string (callers run parseJsonFromLLM themselves) or null.
|
|
196
280
|
// maxTokens is sized for session-summary / episode JSON (larger than the
|
|
197
281
|
// registry/optimize callers' budgets).
|
|
198
|
-
export async function callLLM(prompt, timeoutMs =
|
|
282
|
+
export async function callLLM(prompt, timeoutMs = BG_LLM_TIMEOUT_MS) {
|
|
199
283
|
if (detectLLMMode() !== 'cli') {
|
|
200
284
|
const result = await callHaiku(prompt, { timeout: timeoutMs, maxTokens: 2000 });
|
|
201
285
|
return result?.text ?? null;
|
|
@@ -203,11 +287,13 @@ export async function callLLM(prompt, timeoutMs = 15000) {
|
|
|
203
287
|
|
|
204
288
|
const { cli: modelName } = resolveModelShared();
|
|
205
289
|
try {
|
|
206
|
-
|
|
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'], {
|
|
207
293
|
input: _flattenForCLI(prompt),
|
|
208
294
|
timeout: timeoutMs,
|
|
209
295
|
encoding: 'utf8',
|
|
210
|
-
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1' },
|
|
296
|
+
env: { ...process.env, CLAUDE_MEM_HOOK_RUNNING: '1', DISABLE_CLAUDEMD_HOOKS: '1' },
|
|
211
297
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
212
298
|
cwd: '/tmp', // Prevent ghost sessions in user's /resume list
|
|
213
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';
|
|
@@ -69,6 +69,7 @@ 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
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
|
|
@@ -890,7 +897,7 @@ function gcStalePreRecallCooldowns() {
|
|
|
890
897
|
function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now }) {
|
|
891
898
|
// ── DB mutations in a transaction (crash-safe consistency) ──
|
|
892
899
|
const staleSessionCutoff = Date.now() - STALE_SESSION_MS;
|
|
893
|
-
const autoCompressAge = Date.now() - 30 *
|
|
900
|
+
const autoCompressAge = Date.now() - 30 * DAY_MS; // 30 days (accelerated from 90)
|
|
894
901
|
|
|
895
902
|
db.transaction(() => {
|
|
896
903
|
// Ensure session exists in DB (INSERT OR IGNORE avoids race condition)
|
|
@@ -945,7 +952,7 @@ function runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now
|
|
|
945
952
|
// imp=1 on these already; this just shrinks the GC latency so the
|
|
946
953
|
// projected 32.5% corpus reduction materializes within a week on live
|
|
947
954
|
// DBs instead of bleeding into the 30-day tier.
|
|
948
|
-
const noiseCompressAge = Date.now() - 7 *
|
|
955
|
+
const noiseCompressAge = Date.now() - 7 * DAY_MS;
|
|
949
956
|
const noiseCompressed = db.prepare(`
|
|
950
957
|
UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}
|
|
951
958
|
WHERE COALESCE(compressed_into, 0) = 0
|
|
@@ -975,7 +982,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
975
982
|
} catch {}
|
|
976
983
|
if (shouldMaintain) {
|
|
977
984
|
try {
|
|
978
|
-
const STALE_AGE = Date.now() - 30 *
|
|
985
|
+
const STALE_AGE = Date.now() - 30 * DAY_MS;
|
|
979
986
|
const OP_CAP = 500;
|
|
980
987
|
// Shared maintenance context (whole-DB, cap 500) — used by every maintain-core
|
|
981
988
|
// op below AND the MED-2 snapshot guard. injection_count>0 protection lives in
|
|
@@ -995,7 +1002,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
995
1002
|
// children (compressed_into dangling at a deleted id). purgeStale recovers them
|
|
996
1003
|
// first and caps at opCap. Schema has no marked_at_epoch, so retention anchors on
|
|
997
1004
|
// created_at_epoch: 30d marking gate + 7d grace = 37d.
|
|
998
|
-
const purged = purgeStale(db, mctx, Date.now() - 37 *
|
|
1005
|
+
const purged = purgeStale(db, mctx, Date.now() - 37 * DAY_MS);
|
|
999
1006
|
if (purged > 0) debugLog('DEBUG', 'auto-maintain', `purged ${purged} stale observations`);
|
|
1000
1007
|
|
|
1001
1008
|
const cleaned = cleanupBroken(db, mctx);
|
|
@@ -1109,7 +1116,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1109
1116
|
DELETE FROM session_handoffs
|
|
1110
1117
|
WHERE (type = 'clear' AND created_at_epoch < ?)
|
|
1111
1118
|
OR (type != 'clear' AND created_at_epoch < ?)
|
|
1112
|
-
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR -
|
|
1119
|
+
`).run(Date.now() - HANDOFF_EXPIRY_CLEAR - DAY_MS, Date.now() - HANDOFF_EXPIRY_EXIT - DAY_MS);
|
|
1113
1120
|
if (gc.changes > 0) debugLog('DEBUG', 'auto-maintain', `gc'd ${gc.changes} expired session_handoffs`);
|
|
1114
1121
|
} catch (e) { debugCatch(e, 'auto-maintain-handoff-gc'); }
|
|
1115
1122
|
|
|
@@ -1349,6 +1356,10 @@ async function handleSessionStart() {
|
|
|
1349
1356
|
// GC stale per-session cooldown files. Cheap (<5ms typical) and idempotent;
|
|
1350
1357
|
// moved here from pre-tool-recall.js's hot path.
|
|
1351
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 */ }
|
|
1352
1363
|
// Bound the shadow-recommendation log (daily JSONL shards, no GC at write time).
|
|
1353
1364
|
try { gcOldShadowShards(); } catch { /* best-effort, never blocks SessionStart */ }
|
|
1354
1365
|
// Same for the opt-in metrics sink (RUNTIME_DIR's parent is DB_DIR). Runs even when
|
|
@@ -1487,12 +1498,15 @@ async function handleSessionStart() {
|
|
|
1487
1498
|
// exclude-set blanked the same-project leg on adopted projects). Written
|
|
1488
1499
|
// unconditionally (even when empty) so a resumed session can't act on a
|
|
1489
1500
|
// previous session's stale marker semantics; 24h GC below.
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
+
});
|
|
1496
1510
|
|
|
1497
1511
|
// One-time migration: remove any stale <claude-mem-context> block left in
|
|
1498
1512
|
// CLAUDE.md by pre-v2.30 installs. Idempotent no-op afterwards.
|
|
@@ -1797,7 +1811,7 @@ function handleAutoCompress() {
|
|
|
1797
1811
|
if (!db) return;
|
|
1798
1812
|
|
|
1799
1813
|
try {
|
|
1800
|
-
const compressCutoff = Date.now() - 60 *
|
|
1814
|
+
const compressCutoff = Date.now() - 60 * DAY_MS; // 60 days
|
|
1801
1815
|
const compressCandidates = selectCompressionCandidates(db, { cutoff: compressCutoff, includeAutoMarked: true });
|
|
1802
1816
|
if (compressCandidates.length < 3) return;
|
|
1803
1817
|
|