claude-mem-lite 3.86.0 → 3.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/hook-context.mjs +76 -21
- package/hook-memory.mjs +23 -13
- package/hook.mjs +8 -4
- package/lib/citation-tracker.mjs +143 -104
- package/lib/deferred-work.mjs +81 -8
- package/lib/events-injection.mjs +12 -1
- package/lib/injected-ids.mjs +25 -2
- package/lib/maintain-core.mjs +4 -2
- package/lib/save-observation.mjs +94 -12
- package/mem-cli.mjs +45 -9
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scoring-sql.mjs +11 -1
- package/scripts/pre-tool-recall.js +22 -3
- package/server.mjs +18 -6
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.88.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.88.0",
|
|
4
4
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark).",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "sdsrss"
|
package/README.md
CHANGED
|
@@ -841,7 +841,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
841
841
|
|----------|-------------|---------|
|
|
842
842
|
| `CLAUDE_MEM_NO_CITATION_TRACK` | `1` disables both the access-count bump and the decay loop — no citation bookkeeping at all. | _(enabled)_ |
|
|
843
843
|
| `MEM_DISABLE_CITATION_DECAY` | `1` disables only the decay writes, keeping access-count bumps. | _(enabled)_ |
|
|
844
|
-
| `CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD` |
|
|
844
|
+
| `CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD` | **Removed — inert.** Tuned the per-project adoption gate, which is gone (D#204). Setting it warns on stderr and changes nothing. | _(n/a)_ |
|
|
845
845
|
| `CLAUDE_MEM_NO_CITE_NUDGE` | `1` fully silences the cite-back nudge. | _(enabled)_ |
|
|
846
846
|
| `CLAUDE_MEM_CITE_NUDGE_THRESHOLD` | Cite-rate below which the nudge fires. | `0.6` |
|
|
847
847
|
| `CLAUDE_MEM_CITE_NUDGE_MIN_INJECTED` | Minimum injection volume before the ratio gate is judged at all. | `5` |
|
package/hook-context.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { basename, join } from 'path';
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
10
10
|
import {
|
|
11
11
|
estimateTokens, truncate, typeIcon, fmtTime, inferProject,
|
|
12
|
-
debugLog,
|
|
12
|
+
debugLog, neutralizeContextDelimiters,
|
|
13
13
|
DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
|
|
14
14
|
} from './utils.mjs';
|
|
15
15
|
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks, KEY_CONTEXT_LIMIT } from './hook-shared.mjs';
|
|
@@ -70,12 +70,56 @@ export function computeAdaptiveWindows(db, project) {
|
|
|
70
70
|
// however well it scores.
|
|
71
71
|
//
|
|
72
72
|
// Named rather than inline so benchmark/keyctx-pool-replay.mjs can patch a twin and
|
|
73
|
-
// price a change to them.
|
|
73
|
+
// price a change to them. Keep the `export const NAME = <int>;` shape — that ruler
|
|
74
|
+
// patches the DECLARATION by regex and throws when the anchor moves.
|
|
74
75
|
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
76
|
+
// OBS was 50 through v3.86.0 and is now an OOM backstop, not a relevance gate. What the
|
|
77
|
+
// ruler measured before the change (2026-09-02T06:11Z, 11 projects with >=20 live rows,
|
|
78
|
+
// budget 2000, AGAINST THE 50/10 TREE — reproduce with `--population --ref-obs 50
|
|
79
|
+
// --ref-sess 10` and with `--wide-obs 50 --wide-sess 10`, which runs the comparison
|
|
80
|
+
// backwards; the bare command now reports 0/11 because shipped is the wider bound):
|
|
81
|
+
// the 50-row bound truncated the pool in 3 of 11 projects, and lifting it
|
|
82
|
+
// alone moved the injected block in 2 of 11 — 8 rows newly reachable against 2 displaced,
|
|
83
|
+
// for +81 and +16 tokens. Selection here is NOT monotone, so a displaced row is a real
|
|
84
|
+
// cost and the ruler prints it as a first-class number (`--why-displaced` names the rows
|
|
85
|
+
// and the gate that dropped each).
|
|
86
|
+
//
|
|
87
|
+
// Both displaced rows lost their slot to the 3-per-type diversity cap. That is not a
|
|
88
|
+
// three-way discrimination: the token budget does not bind on this corpus (651 of 2000 in
|
|
89
|
+
// the widest arm's largest project) and the file-overlap `continue` that used to sit in
|
|
90
|
+
// the selector was UNREACHABLE and has since been deleted (D#197) — so the cap is
|
|
91
|
+
// currently the only gate that can fire. ("below" until v3.88.0; there is nothing below
|
|
92
|
+
// any more, and a reader who went looking found the sentence outliving its referent.)
|
|
93
|
+
//
|
|
94
|
+
// 200 is ~2x the largest pool observed (107). The ruler CANNOT distinguish 200 from 500
|
|
95
|
+
// on this corpus — every bound >= the largest pool is one arm, identical in both
|
|
96
|
+
// selection and cost — so this value is a headroom choice, not a measured optimum.
|
|
97
|
+
// computeAdaptiveWindows NARROWS the windows as velocity rises (tier3 60d -> 30d -> 14d),
|
|
98
|
+
// so activity counteracts pool growth instead of driving it: the largest pool here is the
|
|
99
|
+
// lowest-velocity high-volume project (1.14 obs/day, 107 rows) while the only project a
|
|
100
|
+
// band up has 2.9x the velocity and 29% of the pool. A draft of this comment had that
|
|
101
|
+
// backwards.
|
|
102
|
+
//
|
|
103
|
+
// Cost is PER PROJECT: ~2.7x where the pool is 107, ~2.0x at 59, ~1.5x at 62, and
|
|
104
|
+
// unchanged (inferred, not measured) for the eight projects whose pool never reached 50.
|
|
105
|
+
// Once per SessionStart, both arms in the low single-digit milliseconds. It is NOT a clean
|
|
106
|
+
// function of pool growth — the 59-row project grows less than the 62-row one and costs
|
|
107
|
+
// more, on two independent harnesses, because fixed per-call work (SQL fetch,
|
|
108
|
+
// estimateTokens, JSON.parse, the unchanged summary half) sets the denominator. Three
|
|
109
|
+
// drafts were wrong here in three ways: a point estimate (2.65x), a global range
|
|
110
|
+
// (2.1x-3.8x) no measurement produced, and a causal claim n=3 refutes. Absolute
|
|
111
|
+
// milliseconds are not quotable. Re-derive with `--cost --wide-obs 50 --wide-sess 10
|
|
112
|
+
// --project <p>`, which prices the widening in the correct direction.
|
|
113
|
+
//
|
|
114
|
+
// SESS stays 10 DELIBERATELY, and the discriminator is `sessDisplaced = 0` in 11 of 11
|
|
115
|
+
// projects, not the observation column. Widening it to 40 displaces no summary at all —
|
|
116
|
+
// it is PURELY ADDITIVE, so that LIMIT is a volume cap and not the D#172 shape, which is
|
|
117
|
+
// the actual reason it does not need lifting. (It also changed zero observations, but
|
|
118
|
+
// that only says the obs side is unaffected.) What widening does buy is 130 newly injected
|
|
119
|
+
// summaries, roughly tripling the emitted block on the largest projects. It truncates MORE
|
|
120
|
+
// projects than the obs bound, 5 of 11 against 3 of 11, which is what made the original
|
|
121
|
+
// review propose it first; truncation count is not harm.
|
|
122
|
+
export const KEYCTX_POOL_OBS = 200;
|
|
79
123
|
export const KEYCTX_POOL_SESS = 10;
|
|
80
124
|
|
|
81
125
|
/**
|
|
@@ -154,7 +198,6 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
154
198
|
...scoredSess.map(s => ({ ...s, _kind: 'sess' })),
|
|
155
199
|
].sort((a, b) => b.valueDensity - a.valueDensity);
|
|
156
200
|
|
|
157
|
-
const selectedFiles = new Set();
|
|
158
201
|
const selectedTypes = new Map(); // type → count for diversity constraint
|
|
159
202
|
|
|
160
203
|
for (const c of allCandidates) {
|
|
@@ -166,20 +209,32 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
166
209
|
if (typeCount >= 3) continue;
|
|
167
210
|
}
|
|
168
211
|
|
|
169
|
-
// Diversity penalty: reduce value for file overlap
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
212
|
+
// D#197: a "Diversity penalty: reduce value for file overlap" block used to sit
|
|
213
|
+
// here. It is gone, and the deletion is behaviour-preserving, because it reduced
|
|
214
|
+
// nothing. Two independent reasons, both verified rather than reasoned about:
|
|
215
|
+
//
|
|
216
|
+
// (1) Its `penalizedValue` was a local read by exactly one `continue`. Order was
|
|
217
|
+
// already fixed upstream by the raw-valueDensity sort and this greedy loop
|
|
218
|
+
// never re-sorts — so the comment's promise could not happen at all.
|
|
219
|
+
// (2) That `continue` was unreachable. penalizedValue >= 0.7 * valueDensity, and
|
|
220
|
+
// valueDensity = value / sqrt(cost) with value > 0.5 (recency > 1 x
|
|
221
|
+
// TYPE_QUALITY min 0.5 x impBoost >= 1.0 x lessonBoost >= 1.0) and cost >= 1
|
|
222
|
+
// — estimateTokens('') returns 1, checked, so there is no zero-cost row that
|
|
223
|
+
// would drive valueDensity to 0 and make it fire. Triggering needed a title
|
|
224
|
+
// costing > 122500 tokens. Measured over the 2027 live rows carrying
|
|
225
|
+
// files_modified: zero zero-cost rows, minimum valueDensity 0.1147 (80x the
|
|
226
|
+
// trigger), longest title 171 chars = 43 tokens by this code's own
|
|
227
|
+
// estimateTokens (ceil(ascii/4) + ceil(cjk/1.5)). A first version of this line
|
|
228
|
+
// said 49, which is 3.5 chars/token — a rate nothing here uses.
|
|
229
|
+
//
|
|
230
|
+
// With both gone `selectedFiles` had no reader left, so the Set and its
|
|
231
|
+
// JSON.parse went with it. Type diversity above is the only diversity constraint
|
|
232
|
+
// that was ever live, which is why the counter below no longer says "both gates".
|
|
233
|
+
//
|
|
234
|
+
// Real overlap down-weighting, if wanted, belongs in the sort key — a ranking
|
|
235
|
+
// change owing an A/B, not a revival of this block. tests/hook-context.test.mjs
|
|
236
|
+
// pins the current unpenalized order and asserts its own discriminator, so a
|
|
237
|
+
// penalty that reached the ordering turns red instead of landing silently.
|
|
183
238
|
if (c._kind === 'obs' && c.type) {
|
|
184
239
|
selectedTypes.set(c.type, (selectedTypes.get(c.type) || 0) + 1);
|
|
185
240
|
}
|
package/hook-memory.mjs
CHANGED
|
@@ -31,8 +31,11 @@ const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
|
31
31
|
* × 3.0 cite = 6.75; worst = 0.5 change × 1.0 no-lesson × 0.6 importance × 0.2 noise ×
|
|
32
32
|
* 0.4 cite = 0.024, i.e. a **281× DECLARED range**. That is an upper bound off the factor
|
|
33
33
|
* tables, not a measurement: `citeFactor = 0.4` requires `uncited_streak >= 3`, and
|
|
34
|
-
* citation-decay
|
|
35
|
-
* bounded by [0,2] (scoring-sql.mjs, citeFactorJs docblock).
|
|
34
|
+
* citation-decay rolls the streak over to 0 when it reaches 3, so the steady state is
|
|
35
|
+
* bounded by [0,2] (scoring-sql.mjs, citeFactorJs docblock). That rollover used to be
|
|
36
|
+
* paired with an `importance - 1`; D#179/D#198 removed the importance write, and the
|
|
37
|
+
* bound is unaffected because it was always the streak reset that produced it.
|
|
38
|
+
* Measured 2026-09-01 over the
|
|
36
39
|
* 2284 rows that clear `liveObsFilterSql` — the one predicate in the WHERE of BOTH SELECTs
|
|
37
40
|
* below — 0 are at streak >= 3, and recomputing the factor per row gives a REALISED range
|
|
38
41
|
* of 0.1125 … 6.750: a **60.0× spread** (81 rows hit the full best case, 0 the full worst).
|
|
@@ -513,9 +516,13 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
513
516
|
* projects on this machine the importance=3 population ALONE exceeds 50 — so every
|
|
514
517
|
* importance=2 lesson in those projects was structurally unreachable, and a
|
|
515
518
|
* citation-decay demotion 3->2 EVICTED a row from the pool instead of down-ranking it.
|
|
516
|
-
* That eviction loop is the risk D#172 was filed on
|
|
517
|
-
* plausible per-project population is what
|
|
518
|
-
*
|
|
519
|
+
* That eviction loop is the risk D#172 was filed on, and raising the bound above any
|
|
520
|
+
* plausible per-project population is what closed it. The second half of that sentence
|
|
521
|
+
* is now moot from the other end too: D#179/D#198 stopped citation-decay writing
|
|
522
|
+
* `importance` at all, so there is no 3->2 walk left for the bound to have to absorb.
|
|
523
|
+
* The bound still matters on its own terms — it is what makes importance=2 rows
|
|
524
|
+
* reachable here — but it is no longer the only thing standing between a citation and
|
|
525
|
+
* an eviction.
|
|
519
526
|
*
|
|
520
527
|
* COUNT THE POPULATION WITH THE POOL'S OWN FILTER. Those figures are
|
|
521
528
|
* `liveObsFilterSql` + the `importance >= 2` + non-empty-lesson gates, i.e. what the query
|
|
@@ -527,14 +534,17 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
527
534
|
* in lib/citation-tracker.mjs. Re-measure with `node benchmark/imperative-pool-replay.mjs
|
|
528
535
|
* --population`, never with a bare `SELECT ... WHERE importance = 3`.
|
|
529
536
|
*
|
|
530
|
-
*
|
|
531
|
-
* `COALESCE(importance, 1) >= 2`, so a row
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
537
|
+
* THE EVICTION EDGE IS CLOSED FROM THE OTHER END, AND NOT BY THIS BOUND. The pool gate is
|
|
538
|
+
* `COALESCE(importance, 1) >= 2`, so a row at 1 is out of this face's reach — that part is
|
|
539
|
+
* unchanged. What changed is that nothing in the citation loop can put it there any more:
|
|
540
|
+
* D#179/D#198 deleted the `importance` write from BOTH branches of `applyCitationDecay`
|
|
541
|
+
* along with `IMPORTANCE_FLOOR` itself, so neither a 3->2 down-rank nor a 2->1 eviction can
|
|
542
|
+
* originate from a citation. This paragraph used to read "3->2 IS NOW A DOWN-RANK; 2->1 IS
|
|
543
|
+
* STILL AN EVICTION" and cite that constant; it survived the deletion because the paragraph
|
|
544
|
+
* immediately above it was the one rewritten (pre-tag review v3.88.0, correctness S3).
|
|
545
|
+
* What can still move a row to 1 is ordinary maintenance — `demotePinned` writes 1 on a
|
|
546
|
+
* heavily-injected uncited row with no lesson, and `decayAndMarkIdle` walks `imp - 1` on a
|
|
547
|
+
* never-accessed never-injected row — so the edge exists, it just is not citation-driven.
|
|
538
548
|
*
|
|
539
549
|
* MEASURED, and reproducible: `node benchmark/imperative-pool-replay.mjs`. Over 373 real
|
|
540
550
|
* user prompts replayed against their OWN project's live corpus (85 produced a candidate
|
package/hook.mjs
CHANGED
|
@@ -1004,10 +1004,14 @@ async function handleStop() {
|
|
|
1004
1004
|
// marker. They are added to the decay set ONLY where they were
|
|
1005
1005
|
// actually cited (below), never as bare denominator: the block
|
|
1006
1006
|
// re-renders the same fixed top-10 unconditionally, so an uncited
|
|
1007
|
-
// render says nothing about relevance
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1007
|
+
// render says nothing about relevance. v3.66.0 fed them in as
|
|
1008
|
+
// denominator and that made the block eat its own contents.
|
|
1009
|
+
// The policy used to rest on a second ground as well — "since keyObs
|
|
1010
|
+
// gates on `importance >= 2`, one demotion evicts the common
|
|
1011
|
+
// importance-2 row from Key Context for good" — which D#179/D#198
|
|
1012
|
+
// retired: this loop no longer writes `importance`, so no citation
|
|
1013
|
+
// miss can evict anything. The first ground is untouched and is why
|
|
1014
|
+
// the policy stays.
|
|
1011
1015
|
const keyCtxIds = extractInjectedFromKeyContext({
|
|
1012
1016
|
runtimeDir: RUNTIME_DIR, project, sessionId: ccSessionId,
|
|
1013
1017
|
});
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -830,13 +830,14 @@ export function extractAllInjected(transcriptPath, opts = {}) {
|
|
|
830
830
|
* changed the top-1 in 3 of 78. The bound is now IMPERATIVE_POOL_BACKSTOP = 5000,
|
|
831
831
|
* documented there as an OOM backstop and not a relevance gate.
|
|
832
832
|
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
* to the
|
|
839
|
-
*
|
|
833
|
+
* The demotion half of that paragraph is now MOOT rather than merely improved:
|
|
834
|
+
* D#179/D#198 stopped this loop writing `importance` at all, so neither a 3->2 nor a
|
|
835
|
+
* 2->1 walk can happen through decay and the pool gate `>= 2` is no longer something
|
|
836
|
+
* citations move a row across. The widening still matters on its own terms — it is
|
|
837
|
+
* what makes importance=2 rows reachable by this face — but the eviction risk it used
|
|
838
|
+
* to carry is gone with the writes. `subagent` shares that pool. Still open in D#172:
|
|
839
|
+
* admitting `subagent` to the denominator, which is a separate decision needing the
|
|
840
|
+
* receiver-attributed cites merged asymmetrically.
|
|
840
841
|
*/
|
|
841
842
|
const DECAY_EXCLUDED_SURFACES = new Set();
|
|
842
843
|
|
|
@@ -906,21 +907,32 @@ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DEC
|
|
|
906
907
|
* FOUR of the five are 3->2 down-ranks (#8597, #8847 with cited_count 56, #8948,
|
|
907
908
|
* #10246). THE FIFTH WAS AN EVICTION and the first draft of this note said there were
|
|
908
909
|
* none: at 2026-08-25 18:00Z, #10716 sat at importance = 2, so its next miss would take
|
|
909
|
-
* it to
|
|
910
|
+
* it to the floor of 1 (`IMPORTANCE_FLOOR`, a constant this loop no longer has — see the
|
|
911
|
+
* correction two paragraphs down) — under the `COALESCE(importance, 1) >= 2` gate in
|
|
910
912
|
* rankImperativeCandidates, which is the candidate pool of the very face being
|
|
911
913
|
* admitted. IMPERATIVE_POOL_BACKSTOP closed the 3->2 eviction in v3.82.0 and 2->1 was
|
|
912
914
|
* always documented as still evicting; writing "down-ranks, not evictions" required
|
|
913
915
|
* assuming the marginal population was all importance = 3, which one query refutes.
|
|
914
916
|
* Count it before repeating it.
|
|
915
917
|
*
|
|
916
|
-
* THAT ROW
|
|
917
|
-
*
|
|
918
|
-
* that WROTE THIS PARAGRAPH: `#10716` occurs 21 times in that session's assistant
|
|
919
|
-
* extractCitationsFromTranscript scans assistant text for `#NN`, and
|
|
920
|
-
*
|
|
921
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
918
|
+
* THAT ROW WAS NO LONGER IN THAT STATE, AND AT THE TIME THE REASON WAS THIS LOOP (D#179).
|
|
919
|
+
* #10716 read importance 3 / uncited_streak 0 / demoted_at cleared, promoted by the
|
|
920
|
+
* session that WROTE THIS PARAGRAPH: `#10716` occurs 21 times in that session's assistant
|
|
921
|
+
* text, extractCitationsFromTranscript scans assistant text for `#NN`, and at that time
|
|
922
|
+
* applyCitationDecay raised `importance` on a hit. Discussing a memory was
|
|
923
|
+
* indistinguishable from applying it.
|
|
924
|
+
*
|
|
925
|
+
* CORRECTED (v3.88.0, D#179/D#198): that mechanism is gone. Neither branch of this loop
|
|
926
|
+
* writes `importance` any more, so citing a row cannot promote it HERE and the specific
|
|
927
|
+
* self-promotion described above can no longer occur. Two things still hold and are the
|
|
928
|
+
* reason the paragraph is kept rather than deleted. The general warning stands — anything
|
|
929
|
+
* in this file quoting live decay STATE is perturbed by being written down, since the
|
|
930
|
+
* loop still writes `cited_count`, `uncited_streak`, `demoted_at` and the session
|
|
931
|
+
* columns on a citation — so quote it with a timestamp and prefer the structural claim
|
|
932
|
+
* (the marginal population is not all importance = 3) to the row that demonstrated it.
|
|
933
|
+
* And a citation can still reach `importance` by a SECOND path this loop does not own:
|
|
934
|
+
* `bumpCitationAccess` credits `access_count`, and the `boost` maintain op raises
|
|
935
|
+
* `importance` by 1 above `access_count > 3` (D#206, open).
|
|
924
936
|
*
|
|
925
937
|
* Cross-crediting — a main-face id the main thread never cited but a subagent did — is
|
|
926
938
|
* 3 pairs. The denominator giving 0.25% is DISTINCT (session,id) across the five decay
|
|
@@ -1170,54 +1182,40 @@ export function hasMainThreadAssistantText(transcriptPath) {
|
|
|
1170
1182
|
return false;
|
|
1171
1183
|
}
|
|
1172
1184
|
|
|
1173
|
-
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1185
|
+
// IMPORTANCE_CAP (3) and IMPORTANCE_FLOOR (1) lived here until D#179/D#198 took
|
|
1186
|
+
// `importance` out of this loop entirely; both are gone rather than kept unused.
|
|
1187
|
+
// The reasoning behind the FLOOR is preserved because it is the sharpest statement
|
|
1188
|
+
// of why decay must not move this column at all: both passive injection surfaces
|
|
1189
|
+
// exclude importance 0 (pre-tool-recall.js requires >= 2, user-prompt-search.js
|
|
1190
|
+
// requires >= 1), so a row demoted to 0 could never be re-injected -> never
|
|
1191
|
+
// re-cited -> never recover. The floor bounded that one-way burial at the bottom
|
|
1192
|
+
// of the scale; it could do nothing about the same mechanism one step up, where
|
|
1193
|
+
// a 3 -> 2 evicts a row from the `>= 3` tier arm of the Key Context pool. Genuine
|
|
1194
|
+
// noise still sinks via maintain's PENDING_PURGE pipeline, which keys on
|
|
1195
|
+
// compressed_into (not importance) over injection_count = 0 rows.
|
|
1184
1196
|
const UNCITED_STREAK_THRESHOLD = 3;
|
|
1185
1197
|
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
// the project has
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
const empty = { cited: 0, seen: 0, rate: 0 };
|
|
1208
|
-
if (!db || !project) return empty;
|
|
1209
|
-
try {
|
|
1210
|
-
const row = db.prepare(`
|
|
1211
|
-
SELECT COALESCE(SUM(cited_count), 0) AS cited,
|
|
1212
|
-
COALESCE(SUM(decay_seen_count), 0) AS seen
|
|
1213
|
-
FROM observations
|
|
1214
|
-
WHERE project = ? AND superseded_at IS NULL
|
|
1215
|
-
`).get(project);
|
|
1216
|
-
const cited = row?.cited || 0;
|
|
1217
|
-
const seen = row?.seen || 0;
|
|
1218
|
-
return { cited, seen, rate: seen > 0 ? cited / seen : 0 };
|
|
1219
|
-
} catch (e) { debugCatch(e, 'computeCitationAdoption'); return empty; }
|
|
1220
|
-
}
|
|
1198
|
+
// The adoption-rate gate (P5 ②) lived here, with computeCitationAdoption feeding
|
|
1199
|
+
// it and a streak CAP as its other half. All three are gone — D#204.
|
|
1200
|
+
//
|
|
1201
|
+
// It suppressed the demote branch in a project whose cite-rate was ~0 over enough
|
|
1202
|
+
// resolutions, on the reasoning that such a project has not adopted the `#NN`
|
|
1203
|
+
// convention and a demotion there is a false negative it could never earn back.
|
|
1204
|
+
// That reasoning was entirely about `importance`, which D#179 removed from this
|
|
1205
|
+
// loop. What was left behind was inverted: the suppressed path capped
|
|
1206
|
+
// uncited_streak at threshold-1 and so never returned to 0 without a citation,
|
|
1207
|
+
// pinning those projects' rows at citeFactor 0.5x permanently, while an adopting
|
|
1208
|
+
// project's rolled back to 1.0x every third resolution. The gate written to be
|
|
1209
|
+
// gentler on non-adopting projects had become the only thing punishing them.
|
|
1210
|
+
//
|
|
1211
|
+
// The cap's stated purpose — keep the streak from climbing unbounded into the
|
|
1212
|
+
// citeFactor floor — is served by the rollover itself, and served better, since
|
|
1213
|
+
// the rollover also recovers. One path for every project.
|
|
1214
|
+
//
|
|
1215
|
+
// CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD tuned the gate. It is now inert, and
|
|
1216
|
+
// warned about rather than silently ignored (see applyCitationDecay): a setting
|
|
1217
|
+
// that is accepted but means nothing is worse than one that is unsupported.
|
|
1218
|
+
let adoptionThresholdWarned = false;
|
|
1221
1219
|
|
|
1222
1220
|
/**
|
|
1223
1221
|
* D#61: a lesson injected live and then superseded mid-session (auto-dedup /
|
|
@@ -1264,8 +1262,49 @@ export function redirectSupersededIds(db, project, ids) {
|
|
|
1264
1262
|
* Apply the citation-feedback loop for one session: for each injected obs id,
|
|
1265
1263
|
* decide cited vs uncited and mutate importance/streak/cited_count per spec.
|
|
1266
1264
|
*
|
|
1267
|
-
* - cited:
|
|
1268
|
-
* - uncited: streak += 1; if it reaches 3,
|
|
1265
|
+
* - cited: cited_count += 1, streak = 0, demoted_at cleared.
|
|
1266
|
+
* - uncited: streak += 1; if it reaches 3, streak = 0 and demoted_at is stamped.
|
|
1267
|
+
*
|
|
1268
|
+
* D#179 / D#198 — THIS LOOP NO LONGER WRITES `importance`, on any branch.
|
|
1269
|
+
* `importance` was carrying two jobs at once: a relevance prior AND a
|
|
1270
|
+
* pool-admission gate. Every injection surface gates on it — `>= 1` or `>= 2` on
|
|
1271
|
+
* the prompt faces, and hook-context's Key Context tier arms use `>= 1 / >= 2 /
|
|
1272
|
+
* >= 3` — so a decay-driven 3 -> 2 was not a down-rank, it removed the row from
|
|
1273
|
+
* the candidate POPULATION. That is the D#172 shape, confirmed on the imperative
|
|
1274
|
+
* pool (v3.82.0) and then on the Key Context pool, where 45 of ~106 pool rows sat
|
|
1275
|
+
* in the band where one demotion is an eviction (D#198).
|
|
1276
|
+
*
|
|
1277
|
+
* The ranking half of the loop is unaffected and was never the problem:
|
|
1278
|
+
* cited_count and uncited_streak still feed citeFactorClause, a BOUNDED
|
|
1279
|
+
* [0.4, 3.0] pure multiplier. So the feedback loop still responds to observed
|
|
1280
|
+
* agent behaviour — it just does so by re-ranking within the population instead
|
|
1281
|
+
* of by changing who is in it. Given that no available signal separates "acted on
|
|
1282
|
+
* this lesson" from "wrote about this lesson" (D#179; a release-note session
|
|
1283
|
+
* promotes exactly the rows it discusses, and the mention/application split
|
|
1284
|
+
* measured on the live corpus is not a bound in either direction), a bounded rank
|
|
1285
|
+
* shift is the right cost for a mis-read citation. An eviction is not.
|
|
1286
|
+
*
|
|
1287
|
+
* NOT covered by this change, and stated so nobody reads it as "citations can no
|
|
1288
|
+
* longer move importance": `bumpCitationAccess` credits `access_count`, and the
|
|
1289
|
+
* `boost` maintain op lifts `importance + 1` above `access_count > 3`. That is a
|
|
1290
|
+
* SECOND, independent citation -> importance path (obs #10911, D#206).
|
|
1291
|
+
*
|
|
1292
|
+
* Scope it correctly — an earlier version of this paragraph said "for any `#NN` in
|
|
1293
|
+
* assistant text", which has been false since v3.84.0 (f9a9eae). The credit is gated
|
|
1294
|
+
* on `buildCitationRelevanceSet`: the id must have been injected on one of the five
|
|
1295
|
+
* attachment faces, or by Key Context, or into a subagent, or typed by the user. A
|
|
1296
|
+
* bare mention of an id you were never shown credits nothing, and the revert switch
|
|
1297
|
+
* is CLAUDE_MEM_CITATION_RELEVANCE_GATE=off. What the gate does NOT ask is whether
|
|
1298
|
+
* you acted on the lesson — an injected id named only in prose is still credited —
|
|
1299
|
+
* which is why the path is open rather than closed.
|
|
1300
|
+
*
|
|
1301
|
+
* Measured before deciding to leave it (2026-09-02, live DB + 98 transcripts): 52
|
|
1302
|
+
* rows are currently boost-eligible; 25 of them are cited nowhere in the corpus, and
|
|
1303
|
+
* at most 3 could have crossed `access_count > 3` on citations even under an upper
|
|
1304
|
+
* bound that ignores the gate entirely. So the path is real and its effect is small.
|
|
1305
|
+
* It is untouched here because `access_count` has other writers (explicit recall /
|
|
1306
|
+
* get / timeline) and is also an input to noisePenaltyClause, so changing it is a
|
|
1307
|
+
* different decision with a different blast radius.
|
|
1269
1308
|
* - per-(session, obs) idempotent via last_decided_session_id; re-running for
|
|
1270
1309
|
* the same session is a no-op (Stop hook may fire more than once).
|
|
1271
1310
|
* - cross-project IDs are silently ignored by the WHERE clause.
|
|
@@ -1277,13 +1316,17 @@ export function redirectSupersededIds(db, project, ids) {
|
|
|
1277
1316
|
* 2. (cite-back) the agent edited a file a prior lesson #NN had warned about —
|
|
1278
1317
|
* unioned into citedIds by the Stop handler before this call.
|
|
1279
1318
|
* Signal 2 was added because signal 1 alone penalizes projects that act on a
|
|
1280
|
-
* lesson without typing its id. Even so, both are proxies
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1319
|
+
* lesson without typing its id. Even so, both are proxies, and nothing available
|
|
1320
|
+
* separates "acted on this lesson" from "wrote about this lesson".
|
|
1321
|
+
*
|
|
1322
|
+
* That imprecision used to be answered with a per-project adoption gate that
|
|
1323
|
+
* suppressed demotion where the cite-rate was ~0. It is gone (D#204), because the
|
|
1324
|
+
* cost it was insuring against — losing `importance`, i.e. dropping out of the
|
|
1325
|
+
* candidate pool — is gone too (D#179). What the proxies can still get wrong is
|
|
1326
|
+
* bounded on its own: a mis-read citation moves citeFactorClause within
|
|
1327
|
+
* [0.4, 3.0] and nothing else. The uncited streak rolls over at
|
|
1328
|
+
* UNCITED_STREAK_THRESHOLD in every project, so a lesson in a project that never
|
|
1329
|
+
* types `#NN` is not driven monotonically downward — it oscillates and recovers.
|
|
1287
1330
|
*
|
|
1288
1331
|
* @param {import('better-sqlite3').Database} db
|
|
1289
1332
|
* @param {string} project
|
|
@@ -1303,16 +1346,22 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
1303
1346
|
injected = redirectSupersededIds(db, project, injected);
|
|
1304
1347
|
cited = redirectSupersededIds(db, project, cited);
|
|
1305
1348
|
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1349
|
+
// D#204: the adoption gate is gone. Its env override is still READ, once, only
|
|
1350
|
+
// to say out loud that it no longer does anything — the alternative is a
|
|
1351
|
+
// setting a user can configure and watch have no effect.
|
|
1352
|
+
if (!adoptionThresholdWarned && process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD !== undefined) {
|
|
1353
|
+
adoptionThresholdWarned = true;
|
|
1354
|
+
try {
|
|
1355
|
+
process.stderr.write(
|
|
1356
|
+
'[claude-mem-lite] CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD is set but no longer has any effect — '
|
|
1357
|
+
+ 'the citation-decay adoption gate was removed (D#204). Unset it.\n'
|
|
1358
|
+
);
|
|
1359
|
+
} catch (e) { debugCatch(e, 'adoption-threshold-warn'); }
|
|
1360
|
+
}
|
|
1312
1361
|
|
|
1313
1362
|
const selectStmt = db.prepare(
|
|
1314
|
-
// superseded_at IS NULL: mirror
|
|
1315
|
-
//
|
|
1363
|
+
// superseded_at IS NULL: mirror the 4 injection SELECTs so a row superseded
|
|
1364
|
+
// mid-session (injected live, then auto-dedup supersedes it before this
|
|
1316
1365
|
// decay resolves) is not decayed/streaked/mutated — defense-in-depth parity.
|
|
1317
1366
|
'SELECT id, importance, uncited_streak, last_decided_session_id, last_cited_session_id FROM observations WHERE id = ? AND project = ? AND superseded_at IS NULL'
|
|
1318
1367
|
);
|
|
@@ -1341,8 +1390,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
1341
1390
|
// list would silently renumber if a clause were ever reordered.
|
|
1342
1391
|
const updatePromote = db.prepare(`
|
|
1343
1392
|
UPDATE observations
|
|
1344
|
-
SET
|
|
1345
|
-
cited_count = cited_count + 1,
|
|
1393
|
+
SET cited_count = cited_count + 1,
|
|
1346
1394
|
uncited_streak = 0,
|
|
1347
1395
|
demoted_at = NULL,
|
|
1348
1396
|
last_decided_session_id = @session,
|
|
@@ -1361,22 +1409,16 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
1361
1409
|
decay_seen_count = decay_seen_count + 1
|
|
1362
1410
|
WHERE id = ?
|
|
1363
1411
|
`);
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
SET uncited_streak = MIN(uncited_streak + 1, ?),
|
|
1372
|
-
last_decided_session_id = ?,
|
|
1373
|
-
decay_seen_count = decay_seen_count + 1
|
|
1374
|
-
WHERE id = ?
|
|
1375
|
-
`);
|
|
1412
|
+
// D#179/D#198: this branch no longer touches `importance`. It is the STREAK
|
|
1413
|
+
// ROLLOVER: at UNCITED_STREAK_THRESHOLD the streak resets to 0 and the moment
|
|
1414
|
+
// is stamped in demoted_at. The name and the returned `demoted` counter are
|
|
1415
|
+
// kept because both are load-bearing for callers and citation-stats; what
|
|
1416
|
+
// changed is that the rollover is now a bookkeeping event, not a change of
|
|
1417
|
+
// population membership. Resetting the streak (rather than pinning it) is what
|
|
1418
|
+
// holds citeFactorClause's documented [0, threshold-1] steady state.
|
|
1376
1419
|
const updateDemote = db.prepare(`
|
|
1377
1420
|
UPDATE observations
|
|
1378
|
-
SET
|
|
1379
|
-
uncited_streak = 0,
|
|
1421
|
+
SET uncited_streak = 0,
|
|
1380
1422
|
last_decided_session_id = ?,
|
|
1381
1423
|
demoted_at = ?,
|
|
1382
1424
|
decay_seen_count = decay_seen_count + 1
|
|
@@ -1403,7 +1445,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
1403
1445
|
// decay_seen_count and the funnel's injected_n (cite-rate would read N/2, not N/1).
|
|
1404
1446
|
const firstResolution = !decidedThisSession;
|
|
1405
1447
|
updatePromote.run({
|
|
1406
|
-
|
|
1448
|
+
session: sessionId, seenInc: firstResolution ? 1 : 0, id,
|
|
1407
1449
|
});
|
|
1408
1450
|
promoted++;
|
|
1409
1451
|
if (firstResolution) touched++;
|
|
@@ -1413,15 +1455,12 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
1413
1455
|
if (decidedThisSession) continue;
|
|
1414
1456
|
touched++;
|
|
1415
1457
|
const nextStreak = (row.uncited_streak || 0) + 1;
|
|
1416
|
-
//
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
if (nextStreak >= UNCITED_STREAK_THRESHOLD
|
|
1420
|
-
updateDemote.run(
|
|
1458
|
+
// D#204: one path for every project. The rollover both bounds the streak
|
|
1459
|
+
// (so citeFactorClause cannot sink toward its floor) and lets it recover
|
|
1460
|
+
// to 0 without requiring a citation.
|
|
1461
|
+
if (nextStreak >= UNCITED_STREAK_THRESHOLD) {
|
|
1462
|
+
updateDemote.run(sessionId, Date.now(), id);
|
|
1421
1463
|
demoted++;
|
|
1422
|
-
} else if (suppressDemotion) {
|
|
1423
|
-
// Never-demoting project: cap the streak so cite_factor can't sink to floor.
|
|
1424
|
-
updateStreakCapped.run(UNCITED_STREAK_THRESHOLD - 1, sessionId, id);
|
|
1425
1464
|
} else {
|
|
1426
1465
|
updateStreakOnly.run(sessionId, id);
|
|
1427
1466
|
}
|