claude-mem-lite 3.82.0 → 3.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -6
- package/README.zh-CN.md +8 -4
- package/hook-context.mjs +3 -8
- package/hook-episode.mjs +66 -8
- package/hook-llm.mjs +52 -19
- package/hook-shared.mjs +36 -4
- package/hook.mjs +306 -28
- package/lib/citation-tracker.mjs +196 -5
- package/lib/cite-back-hint.mjs +15 -8
- package/lib/cooldown-path.mjs +42 -0
- package/lib/edge-attribution.mjs +4 -7
- package/lib/events-injection.mjs +10 -3
- package/lib/hook-telemetry.mjs +20 -4
- package/lib/private-strip.mjs +37 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/project-utils.mjs +20 -1
- package/scripts/post-tool-use.sh +31 -1
- package/scripts/pre-tool-recall.js +53 -8
- package/source-files.mjs +3 -0
- package/utils.mjs +29 -9
package/hook.mjs
CHANGED
|
@@ -29,6 +29,13 @@ import {
|
|
|
29
29
|
formatErrorRecallHints,
|
|
30
30
|
MAX_HOOK_STDIN_BYTES,
|
|
31
31
|
} from './utils.mjs';
|
|
32
|
+
// Direct import (not via the utils.mjs barrel): the barrel's re-exports are a v2.21
|
|
33
|
+
// backward-compat surface that knip already lists as unused; new shared symbols go to
|
|
34
|
+
// their canonical module.
|
|
35
|
+
import { inferProjectDir } from './project-utils.mjs';
|
|
36
|
+
// Aliased: `acquireLock` from hook-episode.mjs below is the episode buffer's own
|
|
37
|
+
// (argument-less) lock — a different mutex with a different staleness policy.
|
|
38
|
+
import { acquireLock as acquireProcLock } from './lib/proc-lock.mjs';
|
|
32
39
|
import {
|
|
33
40
|
readEpisodeRaw, episodeFile,
|
|
34
41
|
acquireLock, releaseLock, readEpisode, writeEpisode,
|
|
@@ -39,7 +46,7 @@ import { cleanupClaudeMdLegacyBlock, buildSessionContextLines } from './hook-con
|
|
|
39
46
|
import { entry as preCompactEntry } from './hook-precompact.mjs';
|
|
40
47
|
import {
|
|
41
48
|
RUNTIME_DIR, EPISODE_BUFFER_SIZE, EPISODE_TIME_GAP_MS,
|
|
42
|
-
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS,
|
|
49
|
+
SESSION_EXPIRY_MS, STALE_SESSION_MS, STALE_LOCK_MS, AUTO_MAINTAIN_LOCK,
|
|
43
50
|
HANDOFF_EXPIRY_CLEAR, HANDOFF_EXPIRY_EXIT,
|
|
44
51
|
sessionFile, getSessionId, createSessionId, openDb,
|
|
45
52
|
spawnBackground, sweepOrphanEpisodeFiles, sweepStaleProjectMarkers,
|
|
@@ -56,6 +63,7 @@ import { snapshotDb } from './lib/db-backup.mjs';
|
|
|
56
63
|
import {
|
|
57
64
|
extractCitationsFromTranscript,
|
|
58
65
|
extractInjectedBySurface,
|
|
66
|
+
buildCitationRelevanceSet,
|
|
59
67
|
unionSurfaces,
|
|
60
68
|
extractInjectedFromKeyContext,
|
|
61
69
|
bumpCitationAccess,
|
|
@@ -70,6 +78,7 @@ import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attri
|
|
|
70
78
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
71
79
|
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
72
80
|
import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
81
|
+
import { upsFtsQuery } from './lib/ups-query.mjs';
|
|
73
82
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
74
83
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
75
84
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
@@ -216,27 +225,124 @@ function flushEpisode(episode, hookEventName = 'PostToolUse') {
|
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
228
|
+
// D#178 safety valve. With CLAUDE_MEM_READS_CARRY on, an insignificant flush leaves
|
|
229
|
+
// `reads-<project>.txt` in place, so a long insignificant streak keeps appending to it.
|
|
230
|
+
//
|
|
231
|
+
// THE CAP COUNTS LINES, NOT DISTINCT PATHS, and that is the whole point. The writer
|
|
232
|
+
// (`scripts/post-tool-use.sh`) appends one line per Read with no dedup, so the file grows
|
|
233
|
+
// by REPEATED lines — a session re-reading the same five files forever. The first draft
|
|
234
|
+
// compared the DISTINCT set against the cap, which is the quantity that stays tiny
|
|
235
|
+
// (measured on the live corpus: median 1, p95 6, max 21 carried paths), so the valve could
|
|
236
|
+
// not fire on the growth mode it exists to bound. That is this repo's recurring
|
|
237
|
+
// "predicate that cannot return true reports the defect as absent" shape, and the pre-tag
|
|
238
|
+
// review caught it here.
|
|
239
|
+
//
|
|
240
|
+
// Still a backstop and not a relevance bound — the same distinction
|
|
241
|
+
// IMPERATIVE_POOL_BACKSTOP documents for its pool.
|
|
242
|
+
const READS_CARRY_MAX_LINES = 20000;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Bound the reads file when an insignificant flush leaves it in place. Rewrites it only
|
|
246
|
+
* when it is over READS_CARRY_MAX_LINES raw lines, keeping the newest distinct paths.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} readsFile
|
|
249
|
+
* @returns {number} distinct paths now held; 0 when there is no reads file (the COMMON
|
|
250
|
+
* case — no Read since the last collect); -1 only when the file exists but could not be
|
|
251
|
+
* read or the trim threw. The three-way split is the point: `episode_reads` is the ruler
|
|
252
|
+
* for this flag, and folding "nothing to hold" together with "could not look" would put
|
|
253
|
+
* the normal case and the broken case on the same value. A first draft returned -1 for
|
|
254
|
+
* both, which made -1 the overwhelmingly common reading and hid the failure inside it.
|
|
255
|
+
*/
|
|
256
|
+
function trimReadsFile(readsFile) {
|
|
257
|
+
let raw;
|
|
224
258
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
259
|
+
raw = readFileSync(readsFile, 'utf8');
|
|
260
|
+
} catch (e) {
|
|
261
|
+
return e?.code === 'ENOENT' ? 0 : -1;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
265
|
+
const paths = [...new Set(lines)];
|
|
266
|
+
if (lines.length <= READS_CARRY_MAX_LINES) return paths.length;
|
|
267
|
+
const keep = paths.slice(-READS_CARRY_MAX_LINES);
|
|
268
|
+
const tmp = readsFile + `.trim-${process.pid}`;
|
|
269
|
+
writeFileSync(tmp, keep.join('\n') + '\n', { mode: 0o600 });
|
|
270
|
+
renameSync(tmp, readsFile);
|
|
271
|
+
return keep.length;
|
|
230
272
|
} catch {
|
|
231
|
-
|
|
273
|
+
return -1;
|
|
232
274
|
}
|
|
275
|
+
}
|
|
233
276
|
|
|
277
|
+
function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
234
278
|
// Split by CC session so concurrent same-project sessions flush as separate
|
|
235
279
|
// observations. planEpisodeFlush returns [episode] BY REFERENCE for the common
|
|
236
280
|
// single-session (or all-legacy) case → flushEpisodeGroup(episode) is identical
|
|
237
281
|
// to pre-grouping. Two+ interleaved sessions each get their own sub-episode.
|
|
238
282
|
const subs = planEpisodeFlush(episode);
|
|
283
|
+
|
|
284
|
+
// D#178. The reads file used to be consumed right here, unconditionally, BEFORE
|
|
285
|
+
// anything knew whether this flush would persist an observation — and an
|
|
286
|
+
// insignificant flush then dropped every path it had just swept up, leaving the
|
|
287
|
+
// next observation that DID save with files_read = []. Measured over 1122 real
|
|
288
|
+
// transcripts (benchmark/episode-flush-replay.mjs): 42.2% of the reads a flush
|
|
289
|
+
// consumed died that way, and 72.7% of significant flushes carried none at all.
|
|
290
|
+
//
|
|
291
|
+
// The fix is an ORDERING, not a buffer: explainSignificance reads only `entries`
|
|
292
|
+
// and `files`, never `filesRead`, so the verdict is available before the file is
|
|
293
|
+
// touched. An insignificant flush now leaves the file alone and the next
|
|
294
|
+
// significant one collects the union. Ages measured on the same corpus: the reads
|
|
295
|
+
// that survive attach a median 1.7 minutes and p90 10.1 minutes later than they do
|
|
296
|
+
// today, which is the whole cost — a read carried across two insignificant flushes
|
|
297
|
+
// lands on the edit it preceded rather than on nothing.
|
|
298
|
+
//
|
|
299
|
+
// ON by default since v3.83.0. `CLAUDE_MEM_READS_CARRY=0` restores the pre-D#178
|
|
300
|
+
// behavior byte for byte — kept as an off switch because this changes what a released
|
|
301
|
+
// artifact stores, and a defect here is invisible from the outside (the symptom is an
|
|
302
|
+
// absent field, which reads exactly like "there was nothing to record").
|
|
303
|
+
const carryReads = !['0', 'off', 'false', 'no'].includes(
|
|
304
|
+
String(process.env.CLAUDE_MEM_READS_CARRY ?? '').toLowerCase());
|
|
305
|
+
const willPersist = !carryReads || subs.some((s) => episodeHasSignificantContent(s));
|
|
306
|
+
|
|
307
|
+
// Collect Read file paths tracked by post-tool-use.sh
|
|
308
|
+
// Use rename to atomically collect — prevents losing concurrent appends
|
|
309
|
+
const readsFile = join(RUNTIME_DIR, `reads-${episode.project || inferProject()}.txt`);
|
|
310
|
+
const readsCollect = readsFile + `.collect-${Date.now()}`;
|
|
311
|
+
let readsHeld = 0;
|
|
312
|
+
if (willPersist) {
|
|
313
|
+
try {
|
|
314
|
+
renameSync(readsFile, readsCollect);
|
|
315
|
+
const raw = readFileSync(readsCollect, 'utf8');
|
|
316
|
+
const paths = [...new Set(raw.split('\n').filter(Boolean))];
|
|
317
|
+
episode.filesRead = paths;
|
|
318
|
+
try { unlinkSync(readsCollect); } catch {}
|
|
319
|
+
} catch {
|
|
320
|
+
episode.filesRead = episode.filesRead || [];
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
episode.filesRead = [];
|
|
324
|
+
// Not collecting means the file keeps growing across an insignificant streak, and a
|
|
325
|
+
// project that never flushes significantly would grow it without bound. Trim it.
|
|
326
|
+
//
|
|
327
|
+
// The trim's race window is WIDER than the collect path's, and saying otherwise (the
|
|
328
|
+
// first draft did) is the kind of comfortable claim that stops anyone checking: the
|
|
329
|
+
// collect path is a single atomic `renameSync`, while this is read → dedup → write tmp
|
|
330
|
+
// → rename, and a `>>` append from the bash prefilter landing inside that span is lost
|
|
331
|
+
// to the final rename. Accepted rather than fixed: the trim only runs above
|
|
332
|
+
// READS_CARRY_MAX_LINES, which no observed session approaches, so the exposure is a
|
|
333
|
+
// path or two in a session that has already read 20000 times.
|
|
334
|
+
readsHeld = trimReadsFile(readsFile);
|
|
335
|
+
}
|
|
336
|
+
// planEpisodeFlush now runs BEFORE the collection, so the multi-session branch — the
|
|
337
|
+
// one that builds fresh objects rather than returning [episode] by reference — copied
|
|
338
|
+
// whatever filesRead the buffer happened to carry, not what was just collected. The
|
|
339
|
+
// single-group path is identity and unaffected; this line is what keeps the two paths
|
|
340
|
+
// saying the same thing, and without it concurrent same-project sessions would lose
|
|
341
|
+
// their reads while a solo session kept them.
|
|
342
|
+
for (const sub of subs) if (sub !== episode) sub.filesRead = episode.filesRead;
|
|
343
|
+
|
|
239
344
|
let anySignificant = false;
|
|
345
|
+
let writefail = false;
|
|
240
346
|
for (const sub of subs) {
|
|
241
347
|
const r = flushEpisodeGroup(sub, db);
|
|
242
348
|
if (r === 'writefail') {
|
|
@@ -245,12 +351,39 @@ function flushEpisodeWithDb(db, episode, hookEventName) {
|
|
|
245
351
|
// keep the rest. The asymmetry is safe: each group's immediate obs is persisted
|
|
246
352
|
// BEFORE its flush-file write, so re-flushing the whole buffer would re-emit
|
|
247
353
|
// already-saved groups as duplicate observations.
|
|
248
|
-
if (subs.length === 1)
|
|
354
|
+
if (subs.length === 1) { writefail = true; break; }
|
|
249
355
|
continue;
|
|
250
356
|
}
|
|
251
357
|
if (r === 'significant') anySignificant = true;
|
|
252
358
|
}
|
|
253
359
|
|
|
360
|
+
// D#178 instrument, and the ruler for the flag above. With CLAUDE_MEM_READS_CARRY
|
|
361
|
+
// off, a row with `significant: false` and `readsConsumed > 0` is that many Read
|
|
362
|
+
// paths collected and dropped on the floor. With it on, those rows become
|
|
363
|
+
// `readsConsumed: 0, readsHeld: N` — the same event, now recording a deferral
|
|
364
|
+
// instead of a loss, so one query over this sink covers both arms.
|
|
365
|
+
// Emitted HERE and not in flushEpisodeGroup on purpose: planEpisodeFlush copies
|
|
366
|
+
// the SAME filesRead array into every sub, so a per-group counter double-counts
|
|
367
|
+
// the multi-session case, and the destroyed/kept decision is `anySignificant`,
|
|
368
|
+
// which only exists at this level. `writefail` is its own arm because on the SIGNIFICANT
|
|
369
|
+
// path it keeps the episode buffer for a retry the reads file can no longer serve — it
|
|
370
|
+
// was already unlinked, so the retry re-collects nothing. A writefail flush is NOT
|
|
371
|
+
// necessarily one whose significance said collect: `flushEpisodeGroup` writes its flush
|
|
372
|
+
// file outside the significance branch, so an insignificant flush can fail there too —
|
|
373
|
+
// and with the flag on that case is strictly better than before, because the reads file
|
|
374
|
+
// was never touched and the retry still finds it.
|
|
375
|
+
// Off unless CLAUDE_MEM_METRICS=1, like every other row in this sink.
|
|
376
|
+
recordMetric(join(RUNTIME_DIR, '..'), {
|
|
377
|
+
event: 'episode_reads',
|
|
378
|
+
readsConsumed: (episode.filesRead || []).length,
|
|
379
|
+
readsHeld,
|
|
380
|
+
carry: carryReads,
|
|
381
|
+
significant: anySignificant,
|
|
382
|
+
subs: subs.length,
|
|
383
|
+
writefail,
|
|
384
|
+
});
|
|
385
|
+
if (writefail) return;
|
|
386
|
+
|
|
254
387
|
// Aggregate receipt over the whole episode, gated exactly as before
|
|
255
388
|
// (isSignificant → anySignificant). v2.33.4: Stop rejects hookSpecificOutput.
|
|
256
389
|
if (anySignificant && RECEIPT_EVENTS.has(hookEventName)) {
|
|
@@ -807,10 +940,34 @@ async function handleStop() {
|
|
|
807
940
|
// applyCitationDecay checks separately.
|
|
808
941
|
try {
|
|
809
942
|
if (transcriptPath && !process.env.CLAUDE_MEM_NO_CITATION_TRACK) {
|
|
943
|
+
// D#152/D#177: the `subagent` face, collected ONCE, up front, and used twice —
|
|
944
|
+
// by the decay block below (only under CLAUDE_MEM_SUBAGENT_DECAY) and by its own
|
|
945
|
+
// metering call at the tail. It used to be collected at the tail only, with a
|
|
946
|
+
// comment saying the position was load-bearing because lib/transcript-scan.mjs
|
|
947
|
+
// memoizes ONE file and reading the sidechains evicts the parent. That constraint
|
|
948
|
+
// is real but it is not "last" — it is "not BETWEEN two parent scans". Running it
|
|
949
|
+
// FIRST parses the sidechains before anything has memoized the parent, so the
|
|
950
|
+
// parent is then parsed once and stays memoized for every scanner after it:
|
|
951
|
+
// still one parent parse per Stop, the property the tail comment was protecting.
|
|
952
|
+
let sub = { injected: new Set(), cited: new Set(), files: 0 };
|
|
953
|
+
try { sub = collectSubagentSurface(transcriptPath); }
|
|
954
|
+
catch (e) { debugCatch(e, 'handleStop-subagent-collect'); }
|
|
955
|
+
|
|
810
956
|
const ids = extractCitationsFromTranscript(transcriptPath);
|
|
811
957
|
if (ids.size > 0) {
|
|
812
|
-
|
|
813
|
-
|
|
958
|
+
// Gate the access-count channel on relevance (audit FLOW-2 / D#179). The cited
|
|
959
|
+
// set is every `#NN` in this session's assistant text and cannot tell a
|
|
960
|
+
// citation from a mention; in this repository a CHANGELOG or audit-writing
|
|
961
|
+
// session names dozens of ids in prose, and access_count > 3 promotes a row a
|
|
962
|
+
// tier via boostAccessed. The population to credit — all seven faces, and why
|
|
963
|
+
// extractAllInjected alone is the wrong five — lives in the builder.
|
|
964
|
+
const relevant = buildCitationRelevanceSet({
|
|
965
|
+
transcriptPath, runtimeDir: RUNTIME_DIR, project,
|
|
966
|
+
sessionId: ccSessionId, subagentInjected: sub.injected,
|
|
967
|
+
});
|
|
968
|
+
const n = bumpCitationAccess(db, ids, project, relevant);
|
|
969
|
+
debugLog('DEBUG', 'handleStop',
|
|
970
|
+
`citations: ${ids.size} ids scanned, ${relevant.size} relevant, ${n} obs bumped`);
|
|
814
971
|
}
|
|
815
972
|
|
|
816
973
|
// v32 citation-decay: tighter feedback loop on top of P4. Re-scan
|
|
@@ -854,7 +1011,15 @@ async function handleStop() {
|
|
|
854
1011
|
const keyCtxIds = extractInjectedFromKeyContext({
|
|
855
1012
|
runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
856
1013
|
});
|
|
857
|
-
|
|
1014
|
+
// D#177: `sub.injected` counts toward the entry gate when the face is admitted.
|
|
1015
|
+
// Without this a session whose ONLY injection was a dispatched agent's prompt
|
|
1016
|
+
// would return here with injected.size === 0 and the face would be "in the
|
|
1017
|
+
// denominator" in name only — the failure mode where a face is wired at one
|
|
1018
|
+
// level and gated out at another, which is how UPS went unmetered for a whole
|
|
1019
|
+
// minor version.
|
|
1020
|
+
const subDecayOn = !['0', 'off', 'false', 'no'].includes(
|
|
1021
|
+
String(process.env.CLAUDE_MEM_SUBAGENT_DECAY ?? '').toLowerCase());
|
|
1022
|
+
if (injected.size > 0 || keyCtxIds.size > 0 || (subDecayOn && sub.injected.size > 0)) {
|
|
858
1023
|
// Text-floor gate: skip decay on tool-only Stops. Without this,
|
|
859
1024
|
// a turn that ends on tool_use locks every injected obs as
|
|
860
1025
|
// uncited (last_decided_session_id set), so a later turn that
|
|
@@ -867,17 +1032,71 @@ async function handleStop() {
|
|
|
867
1032
|
} else {
|
|
868
1033
|
const citedMain = extractCitationsFromTranscript(transcriptPath, { mainOnly: true });
|
|
869
1034
|
for (const id of citeBackIds) citedMain.add(id);
|
|
1035
|
+
// D#177: admit the `subagent` face to the decay loop. It cannot ride the
|
|
1036
|
+
// normal path because its injection lands in a dispatched agent's PROMPT
|
|
1037
|
+
// and its citation lands in that agent's OWN transcript — so its ids enter
|
|
1038
|
+
// the denominator AND its receiver-attributed cites enter the numerator,
|
|
1039
|
+
// asymmetrically, together. Feeding only the first half would mark every
|
|
1040
|
+
// subagent-only injection uncited by construction (that is why the face was
|
|
1041
|
+
// metered-but-excluded since v3.77); feeding only the second half would
|
|
1042
|
+
// credit the main-thread faces for citations the main thread never made.
|
|
1043
|
+
//
|
|
1044
|
+
// `sub.cited` is already the per-FILE intersection with `sub.injected`
|
|
1045
|
+
// (collectSubagentSurface), so this cannot credit an id the subagent surface
|
|
1046
|
+
// did not itself inject. Measured on the live corpus (1122 transcripts, 34
|
|
1047
|
+
// subagent-bearing sessions): 33 marginal (session,id) pairs enter the
|
|
1048
|
+
// denominator, 21.2% of them cited; 21 distinct observations behind the
|
|
1049
|
+
// uncited ones, FIVE at uncited_streak = 2. Four are 3->2 down-ranks (#8597,
|
|
1050
|
+
// #8847 with cited_count 56, #8948, #10246). At 2026-08-25 18:00Z #10716 was
|
|
1051
|
+
// at importance 2 — one miss from a 2->1 eviction out of
|
|
1052
|
+
// rankImperativeCandidates' own `importance >= 2` pool, the case
|
|
1053
|
+
// IMPERATIVE_POOL_BACKSTOP does not cover, and the reason "down-ranks, not
|
|
1054
|
+
// evictions" is wrong as a blanket claim. That row has since been promoted by
|
|
1055
|
+
// the very session that documented it (D#179: this loop cannot tell writing
|
|
1056
|
+
// `#NN` from applying it), so re-check the CLASS, not the row.
|
|
1057
|
+
// Cross-crediting is 3 pairs of 1181 DISTINCT (session,id) across the five
|
|
1058
|
+
// decay faces inside subagent-bearing sessions (0.25%), or 3 of 2738 the same
|
|
1059
|
+
// way corpus-wide (0.11%) — ids the main thread never cited but a subagent did.
|
|
1060
|
+
//
|
|
1061
|
+
// ON by default since v3.83.0; `CLAUDE_MEM_SUBAGENT_DECAY=0` restores the
|
|
1062
|
+
// metered-but-never-decaying state the face sat in from v3.77 to v3.82.
|
|
1063
|
+
//
|
|
1064
|
+
// The denominator is a COPY, not a mutation of `injected`: the edge
|
|
1065
|
+
// attribution below takes `mainInjectedIds: injected` to keep sidechain-only
|
|
1066
|
+
// injections from accruing file-edge misses (review D#78), and folding the
|
|
1067
|
+
// subagent ids into that set would undo exactly that guard.
|
|
870
1068
|
// The promotion-only half: a Key Context row the agent actually
|
|
871
1069
|
// cited joins the decay set (and takes the promote branch); one
|
|
872
1070
|
// it ignored is never entered, so it cannot streak or demote.
|
|
873
1071
|
for (const id of keyCtxIds) if (citedMain.has(id)) injected.add(id);
|
|
1072
|
+
// BOTH halves of the merge are COPIES, built AFTER the keyctx promotion above
|
|
1073
|
+
// so they carry it too. When the flag is off each IS the original object, so
|
|
1074
|
+
// every consumer below is byte identical to the pre-D#177 path.
|
|
1075
|
+
//
|
|
1076
|
+
// The copies are the whole safety property. `injected` and `citedMain` have
|
|
1077
|
+
// four consumers between them and only `applyCitationDecay` should see the
|
|
1078
|
+
// subagent ids; the first draft of this change mutated `citedMain` in place
|
|
1079
|
+
// and the pre-tag review measured both leaks it caused:
|
|
1080
|
+
// • recordCitationSurfaces (below) scored a `pretool` row the main thread
|
|
1081
|
+
// never cited as a pretool HIT — `pretool.cited_n` 0 -> 1 on a
|
|
1082
|
+
// two-observation probe. That is the caliber CLAUDE.md publishes for the
|
|
1083
|
+
// funnel ("cited as #NN in the session's own MAIN-THREAD text"), so it
|
|
1084
|
+
// would have made citation_surface_log and citation-live-replay.mjs
|
|
1085
|
+
// permanently different rulers — the v3.81.0 cross-agent defect, mirrored.
|
|
1086
|
+
// • resolveEdgeAttribution gates sidechain edges on
|
|
1087
|
+
// `!mainInjected.has(id) && !cited.has(id)`, so a file edge flipped MISS
|
|
1088
|
+
// -> HIT (`miss_streak` 1 -> 0). The comment there defends the DENOMINATOR
|
|
1089
|
+
// half of that gate and says nothing about the numerator, which is exactly
|
|
1090
|
+
// how the leak got past a reading of it.
|
|
1091
|
+
const decayInjected = subDecayOn ? new Set([...injected, ...sub.injected]) : injected;
|
|
1092
|
+
const decayCited = subDecayOn ? new Set([...citedMain, ...sub.cited]) : citedMain;
|
|
874
1093
|
// D#60: the idempotency key must be the CC session UUID, NOT the
|
|
875
1094
|
// project-scoped memory sessionId — concurrent same-project CC
|
|
876
1095
|
// sessions share the latter, so the second session's decay pass
|
|
877
1096
|
// read "already decided" and silently undercounted decay_seen /
|
|
878
1097
|
// streaks / adoption denominators. Fallback keeps legacy
|
|
879
1098
|
// stdin-less invocations on the old key.
|
|
880
|
-
const r = applyCitationDecay(db, project,
|
|
1099
|
+
const r = applyCitationDecay(db, project, decayInjected, decayCited, ccSessionId || sessionId);
|
|
881
1100
|
debugLog('DEBUG', 'handleStop', `citation-decay: touched=${r.touched} promoted=${r.promoted} demoted=${r.demoted}`);
|
|
882
1101
|
// R1: persist this session's invocation→cite funnel row. touched =
|
|
883
1102
|
// obs resolved this run (denominator), promoted = obs cited this run
|
|
@@ -968,11 +1187,19 @@ async function handleStop() {
|
|
|
968
1187
|
// construction; folding its cites INTO citedMain would credit the
|
|
969
1188
|
// main-thread faces for citations the main thread never made. The
|
|
970
1189
|
// upsert key is (project, session, surface), so two calls with
|
|
971
|
-
// disjoint face sets do not collide.
|
|
972
|
-
//
|
|
1190
|
+
// disjoint face sets do not collide.
|
|
1191
|
+
//
|
|
1192
|
+
// SINCE v3.83.0 (D#177) this is no longer metering-only: the face DOES reach
|
|
1193
|
+
// applyCitationDecay, through the `decayInjected` / `decayCited` copies above.
|
|
1194
|
+
// The sentence above about folding cites into `citedMain` still holds and is the
|
|
1195
|
+
// reason those are copies — this call, `resolveEdgeAttribution` and the keyctx
|
|
1196
|
+
// promotion all keep the un-widened set. `CLAUDE_MEM_SUBAGENT_DECAY=0` returns
|
|
1197
|
+
// the face to metering-only.
|
|
973
1198
|
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
1199
|
+
// The "placed LAST" note below is now historical: `collectSubagentSurface` runs
|
|
1200
|
+
// at the HEAD of this block (the decay loop needs its result), and `sub` here is
|
|
1201
|
+
// that same object rather than a second call. The parse-count property the note
|
|
1202
|
+
// defends is unchanged — see the comment at the collection site.
|
|
976
1203
|
// earlier, this block costs ONE extra parse of the parent — the memo
|
|
977
1204
|
// re-caches on the first re-read, so it is one, not one per later
|
|
978
1205
|
// scanner — and breaks the "one parse per Stop" property the block
|
|
@@ -985,7 +1212,9 @@ async function handleStop() {
|
|
|
985
1212
|
// must not enter the funnel's session denominator either.
|
|
986
1213
|
try {
|
|
987
1214
|
if (hasMainThreadAssistantText(transcriptPath)) {
|
|
988
|
-
|
|
1215
|
+
// `sub` is the one collected at the top of this block — a second
|
|
1216
|
+
// collectSubagentSurface call here would re-parse every sidechain file and,
|
|
1217
|
+
// worse, could disagree with the set the decay loop above just scored.
|
|
989
1218
|
if (sub.injected.size > 0) {
|
|
990
1219
|
recordCitationSurfaces(db, project, ccSessionId || sessionId,
|
|
991
1220
|
{ subagent: sub.injected }, sub.cited);
|
|
@@ -1341,14 +1570,52 @@ function scheduleSessionStartAutoMaintain(project) {
|
|
|
1341
1570
|
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain', project);
|
|
1342
1571
|
}
|
|
1343
1572
|
|
|
1573
|
+
// The maintenance mutex deliberately does NOT end in `.lock`: cleanStaleLockFiles()
|
|
1574
|
+
// below unlinks every `*.lock` in RUNTIME_DIR whose age exceeds STALE_LOCK_MS (30s)
|
|
1575
|
+
// WITHOUT consulting the holder's pid — a policy written for the episode lock, whose
|
|
1576
|
+
// critical section is milliseconds. A maintenance pass is seconds to minutes (VACUUM INTO
|
|
1577
|
+
// snapshot, purge, decay, dedup over the whole DB), so that sweeper would strip this lock
|
|
1578
|
+
// mid-pass and hand the exclusion straight back to the race it exists to close.
|
|
1579
|
+
// proc-lock brings its own staleness policy (age OR provably-dead pid), which is the
|
|
1580
|
+
// correct one here.
|
|
1581
|
+
// Generous upper bound on one pass; a crashed holder is normally reclaimed sooner via the
|
|
1582
|
+
// dead-pid check, so this only matters for a holder killed on another host.
|
|
1583
|
+
const AUTO_MAINTAIN_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
1584
|
+
|
|
1344
1585
|
// Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
|
|
1345
1586
|
// pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
|
|
1346
1587
|
// gate + the compress/optimize spawns at its tail.
|
|
1588
|
+
//
|
|
1589
|
+
// Cross-process mutual exclusion (2026-08-29 audit FLOW-1). The pass is shaped
|
|
1590
|
+
// read-gate → long work → write-gate, so two Claude Code windows booting either side of
|
|
1591
|
+
// the 24h boundary both see "due" and both spawn a worker. That breaks a documented
|
|
1592
|
+
// in-process invariant: decayAndMarkIdle marks BEFORE it decays precisely so an imp-2 row
|
|
1593
|
+
// cannot be decayed 2→1 and marked COMPRESSED_PENDING_PURGE in the same pass (MED-1, see
|
|
1594
|
+
// its docblock — each importance tier is supposed to buy a grace cycle). Across two
|
|
1595
|
+
// processes the ordering is gone: worker A decays 2→1, worker B's mark-idle then sees a
|
|
1596
|
+
// qualifying imp-1 row and hides it, 37 days from a hard delete. The same overlap
|
|
1597
|
+
// double-runs the cascade below it (duplicate weekly summaries from compressGroup, whose
|
|
1598
|
+
// UPDATE has no compressed_into guard; doubled llm-optimize spend).
|
|
1599
|
+
//
|
|
1600
|
+
// Lock at the worker entry rather than around the individual ops: the cascade spawns sit
|
|
1601
|
+
// inside the pass, so one gate covers the whole family. Not acquiring is a plain no-op —
|
|
1602
|
+
// a peer is already doing exactly this work.
|
|
1347
1603
|
function handleAutoMaintain(project) {
|
|
1348
|
-
const
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1604
|
+
const release = acquireProcLock(join(RUNTIME_DIR, AUTO_MAINTAIN_LOCK), {
|
|
1605
|
+
staleMs: AUTO_MAINTAIN_LOCK_STALE_MS,
|
|
1606
|
+
});
|
|
1607
|
+
if (!release) {
|
|
1608
|
+
debugLog('DEBUG', 'auto-maintain', 'skipped — a live peer holds the maintenance lock');
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
try {
|
|
1612
|
+
const db = openDb();
|
|
1613
|
+
if (!db) return;
|
|
1614
|
+
try { runSessionStartAutoMaintain(db, project); }
|
|
1615
|
+
finally { try { db.close(); } catch { /* ignore */ } }
|
|
1616
|
+
} finally {
|
|
1617
|
+
release();
|
|
1618
|
+
}
|
|
1352
1619
|
}
|
|
1353
1620
|
|
|
1354
1621
|
function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
|
|
@@ -1477,7 +1744,11 @@ async function buildStartupDashboardText(db, project) {
|
|
|
1477
1744
|
// tests/session-start-stdout-envelope.test.mjs.
|
|
1478
1745
|
try {
|
|
1479
1746
|
const { buildDashboard } = await import('./lib/startup-dashboard.mjs');
|
|
1480
|
-
|
|
1747
|
+
// projectPath MUST come from the same place `project` does (inferProjectDir), not from
|
|
1748
|
+
// process.cwd(): otherwise the dashboard renders directory A's git state and task list
|
|
1749
|
+
// under directory B's project name whenever the hook process was not spawned at the
|
|
1750
|
+
// project root. See inferProjectDir()'s docblock for the case this closed.
|
|
1751
|
+
let dashboardText = buildDashboard({ db, project, projectPath: inferProjectDir() });
|
|
1481
1752
|
const citeNudge = buildCiteRecallNudge(project);
|
|
1482
1753
|
if (citeNudge) {
|
|
1483
1754
|
dashboardText = dashboardText ? `${citeNudge}\n${dashboardText}` : citeNudge;
|
|
@@ -1949,7 +2220,14 @@ async function handleUserPrompt() {
|
|
|
1949
2220
|
// ranking and citation extractors (bare-`#` anchored) never read an event id as
|
|
1950
2221
|
// an obs id. Nested try so an events failure can't suppress the imperative pick.
|
|
1951
2222
|
try {
|
|
1952
|
-
|
|
2223
|
+
// upsFtsQuery, not the raw prompt (audit ALGO-1). lib/ups-query.mjs declares
|
|
2224
|
+
// itself "the ONE query-cap definition for the UserPromptSubmit event", and both
|
|
2225
|
+
// OTHER legs of this same event go through it — but this leg, wired in v3.48
|
|
2226
|
+
// before that module existed, handed searchInjectableEvents the whole prompt and
|
|
2227
|
+
// let it call the uncapped sanitizeFtsQuery. Measured here: a 250KB CJK prompt
|
|
2228
|
+
// (path B's stdin cap is 256KB) costs 356ms uncapped against 5.5ms capped, all of
|
|
2229
|
+
// it synchronous, before the model sees the turn.
|
|
2230
|
+
const events = searchInjectableEvents(db, { ftsQuery: upsFtsQuery(promptText), project });
|
|
1953
2231
|
if (events.length > 0) {
|
|
1954
2232
|
const elines = ['<memory-context relevance="events">'];
|
|
1955
2233
|
for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
|