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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.86.0",
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.86.0",
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` | Session cite-rate below which demotion is suppressed (promotion always proceeds). | `0.02` |
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, debugCatch, neutralizeContextDelimiters,
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. Extracting them changed no value.
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
- // NOT yet widened: SessionStart injects on every start, so moving these is a
76
- // user-visible default-behaviour change to a released artifact (L3) and gets its own
77
- // round. Measured truncation as of 2026-09-01 is in the replay's header.
78
- export const KEYCTX_POOL_OBS = 50;
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 with already-selected
170
- if (c._kind === 'obs' && c.files_modified) {
171
- let cFiles;
172
- try { cFiles = JSON.parse(c.files_modified || '[]'); } catch (e) { debugCatch(e, 'budgetSelect-parseFiles'); cFiles = []; }
173
- if (cFiles.length > 0 && selectedFiles.size > 0) {
174
- const overlap = cFiles.filter(f => selectedFiles.has(f)).length;
175
- const overlapRatio = overlap / cFiles.length;
176
- const penalizedValue = c.valueDensity * (1 - 0.3 * overlapRatio);
177
- if (penalizedValue < 0.001) continue;
178
- }
179
- for (const f of cFiles) selectedFiles.add(f);
180
- }
181
-
182
- // Commit type diversity counter after both gates pass
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 resets the streak at 3 after demoting importance, so the steady state is
35
- * bounded by [0,2] (scoring-sql.mjs, citeFactorJs docblock). Measured 2026-09-01 over the
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; raising the bound above any
517
- * plausible per-project population is what closes it, because a 3->2 demotion then only
518
- * changes the row's score multiplier, which is what the decay design intends.
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
- * 3->2 IS NOW A DOWN-RANK; 2->1 IS STILL AN EVICTION. The pool gate is
531
- * `COALESCE(importance, 1) >= 2`, so a row demoted to the IMPORTANCE_FLOOR of 1 leaves
532
- * this face's reach until some other face cites it back up. Widening the bound is also
533
- * what first makes importance=2 rows reachable here (56 of projects--mem's 383 eligible),
534
- * so it creates the injections that can walk one down to 1. Measured exposure: of the
535
- * picks the widening newly surfaces, one is importance=2 `score = importance x overlap`
536
- * keeps importance=3 rows ahead nearly always so this is a known small edge, not a
537
- * closed loop.
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 and since keyObs gates on
1008
- // `importance >= 2`, one demotion evicts the common importance-2 row
1009
- // from Key Context for good. v3.66.0 fed them in as denominator and
1010
- // that made the block eat its own contents.
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
  });
@@ -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
- * A 3->2 demotion is a down-rank again. A 2->1 demotion is still an EVICTION, because
834
- * the pool gate is `>= 2` and IMPORTANCE_FLOOR is 1 and widening is what first makes
835
- * importance=2 rows reachable by this face at all, so it creates the injections that can
836
- * walk one there. Measured exposure is one row; see the constant's docblock. `subagent`
837
- * shares that pool and inherits both halves. Still open in D#172: admitting `subagent`
838
- * to the denominator, which is a separate decision needing the receiver-attributed cites
839
- * merged asymmetrically.
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 IMPORTANCE_FLOOR = 1 under the `COALESCE(importance, 1) >= 2` gate in
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 IS NO LONGER IN THAT STATE, AND THE REASON IS THIS LOOP (D#179). #10716 now
917
- * reads importance 3 / uncited_streak 0 / demoted_at cleared, promoted by the session
918
- * that WROTE THIS PARAGRAPH: `#10716` occurs 21 times in that session's assistant text,
919
- * extractCitationsFromTranscript scans assistant text for `#NN`, and applyCitationDecay
920
- * promotes on a hit. Discussing a memory is indistinguishable from applying it here.
921
- * Anything in this file that quotes live decay STATE is therefore perturbed by being
922
- * written down; quote it with a timestamp, and prefer the structural claim (the marginal
923
- * population is not all importance = 3) to the row that demonstrated it.
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
- const IMPORTANCE_CAP = 3;
1174
- // Demote floor = 1, NOT 0. Both passive injection surfaces exclude importance 0
1175
- // (pre-tool-recall.js requires >=2, user-prompt-search.js requires >=1), so a row
1176
- // demoted to 0 can never be re-injected never re-cited never recovers: a one-way
1177
- // burial that silently hides lesson-bearing rows the "lessons never auto-GC" guards
1178
- // (maintain decayAndMarkIdle + compress-core) protect on every other path. Floor 1
1179
- // keeps a decayed row on the >=1 surface with a citation-recovery path. Genuine noise
1180
- // still sinks via maintain's PENDING_PURGE pipeline, which keys on compressed_into
1181
- // (not importance) over injection_count=0 rows a disjoint population from these
1182
- // injected-but-uncited rows so noise GC is unaffected.
1183
- const IMPORTANCE_FLOOR = 1;
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
- // Adoption-rate gate (P5 ②). A project's cite-rate is SUM(cited_count) /
1187
- // SUM(decay_seen_count) over its non-superseded observations: of every decay
1188
- // resolution this project has ever produced, what fraction were citations.
1189
- // Below ADOPTION_THRESHOLD with at least ADOPTION_MIN_SEEN resolutions on record,
1190
- // the project has demonstrably not adopted the #NN convention, so we suppress
1191
- // DEMOTION (never promotion) see the construct-validity note on
1192
- // applyCitationDecay. MIN_SEEN keeps the gate dormant for low-data projects so
1193
- // the established behavior is preserved until there's enough signal to judge.
1194
- const ADOPTION_THRESHOLD = 0.02;
1195
- const ADOPTION_MIN_SEEN = 8;
1196
-
1197
- /**
1198
- * Compute a project's citation-adoption snapshot: total citations vs total decay
1199
- * resolutions on record, and their ratio. Read-only; safe to call before the
1200
- * decay transaction (the gate decision is made on the pre-mutation snapshot).
1201
- *
1202
- * @param {import('better-sqlite3').Database} db
1203
- * @param {string} project
1204
- * @returns {{cited: number, seen: number, rate: number}}
1205
- */
1206
- export function computeCitationAdoption(db, project) {
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: importance += 1 (cap 3), cited_count += 1, streak = 0.
1268
- * - uncited: streak += 1; if it reaches 3, importance -= 1 (floor 1, IMPORTANCE_FLOOR), streak = 0.
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. For a project that has
1281
- * never cited anything (cite-rate below ADOPTION_THRESHOLD over ≥ADOPTION_MIN_SEEN
1282
- * resolutions), demotion is suppressed: absent any positive signal we cannot
1283
- * distinguish "useless lesson" from "useful lesson in a project that doesn't use
1284
- * the #NN convention," and a false demotion is the costlier error. The gate trades
1285
- * missed demotions (stale lessons linger) for avoided false demotions. Promotion
1286
- * is never gated a single citation lifts the project's rate and re-enables decay.
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 poolis 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
- // Adoption gate (snapshot taken before any mutation this run). Suppress only
1307
- // demotion; promotion always proceeds. Threshold overridable via env.
1308
- const adoption = computeCitationAdoption(db, project);
1309
- const envThreshold = Number.parseFloat(process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD);
1310
- const adoptionThreshold = Number.isFinite(envThreshold) && envThreshold >= 0 ? envThreshold : ADOPTION_THRESHOLD;
1311
- const suppressDemotion = adoption.seen >= ADOPTION_MIN_SEEN && adoption.rate < adoptionThreshold;
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 computeCitationAdoption + the 4 injection SELECTs so a
1315
- // row superseded mid-session (injected live, then auto-dedup supersedes it before this
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 importance = MIN(@cap, COALESCE(importance, 1) + 1),
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
- // Suppressed (non-adopting) projects never demote, so uncited_streak would grow
1365
- // UNBOUNDED and citeFactorClause penalizes -0.25*streak (floor 0.4), pinning every
1366
- // memory at the ranking floor with no recovery path. Cap at UNCITED_STREAK_THRESHOLD-1
1367
- // to hold the [0, threshold-1] steady state the scoring header asserts (in an adopting
1368
- // project the streak resets to 0 on demote, so the STORED value never exceeds 2).
1369
- const updateStreakCapped = db.prepare(`
1370
- UPDATE observations
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 importance = MAX(?, COALESCE(importance, 1) - 1),
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
- cap: IMPORTANCE_CAP, session: sessionId, seenInc: firstResolution ? 1 : 0, id,
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
- // Demote only when the streak is up AND the project has demonstrably
1417
- // adopted citations. A non-adopting project advances the streak (idempotent
1418
- // bookkeeping) but never loses importance — see construct-validity note.
1419
- if (nextStreak >= UNCITED_STREAK_THRESHOLD && !suppressDemotion) {
1420
- updateDemote.run(IMPORTANCE_FLOOR, sessionId, Date.now(), id);
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
  }