claude-mem-lite 3.87.0 → 3.89.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.
@@ -654,6 +654,39 @@ export function extractInjectedBySurface(transcriptPath, opts = {}) {
654
654
  return out;
655
655
  }
656
656
 
657
+ /**
658
+ * Per-face count of how many DISTINCT hook attachments injected each id (D#193).
659
+ *
660
+ * `extractInjectedBySurface` returns Sets, which is right for every consumer that asks
661
+ * "was this id injected" — and useless for the one question the path-A exclude set exists
662
+ * to answer, which is "was it injected AGAIN". This is the same walk and the same
663
+ * SURFACE_MATCHERS table, deliberately not a second copy of either: the repo's standing
664
+ * defect here is a ruler that re-implements the shipped extractor and then measures its
665
+ * own twin (`benchmark/cite-recall.mjs`'s hand-copied markers, v3.81.0).
666
+ *
667
+ * Counting is per ATTACHMENT, not per occurrence within one: one block listing `#42`
668
+ * twice is one injection of #42, and the exclude set would not have suppressed it.
669
+ *
670
+ * @param {string|null|undefined} transcriptPath
671
+ * @param {{mainOnly?: boolean}} [opts]
672
+ * @returns {Record<string, Map<number, number>>} face -> (id -> attachments carrying it)
673
+ */
674
+ export function countInjectedBySurface(transcriptPath, opts = {}) {
675
+ const out = {};
676
+ for (const face of ATTACHMENT_SURFACES) out[face] = new Map();
677
+ eachHookAttachment(transcriptPath, (ctx) => {
678
+ for (const face of ATTACHMENT_SURFACES) {
679
+ const matcher = SURFACE_MATCHERS[face];
680
+ if (!matcher.accepts(ctx)) continue;
681
+ // Per-attachment set first, so two mentions inside ONE block count once.
682
+ const here = new Set();
683
+ matcher.collect(ctx.text, (raw) => addObsId(here, raw));
684
+ for (const id of here) out[face].set(id, (out[face].get(id) || 0) + 1);
685
+ }
686
+ }, opts);
687
+ return out;
688
+ }
689
+
657
690
  // Per-face extractors: thin wrappers over the shared table, kept as named
658
691
  // exports because callers and tests address individual faces.
659
692
  function extractOneSurface(face, transcriptPath, opts) {
@@ -830,13 +863,14 @@ export function extractAllInjected(transcriptPath, opts = {}) {
830
863
  * changed the top-1 in 3 of 78. The bound is now IMPERATIVE_POOL_BACKSTOP = 5000,
831
864
  * documented there as an OOM backstop and not a relevance gate.
832
865
  *
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.
866
+ * The demotion half of that paragraph is now MOOT rather than merely improved:
867
+ * D#179/D#198 stopped this loop writing `importance` at all, so neither a 3->2 nor a
868
+ * 2->1 walk can happen through decay and the pool gate `>= 2` is no longer something
869
+ * citations move a row across. The widening still matters on its own terms — it is
870
+ * what makes importance=2 rows reachable by this face — but the eviction risk it used
871
+ * to carry is gone with the writes. `subagent` shares that pool. Still open in D#172:
872
+ * admitting `subagent` to the denominator, which is a separate decision needing the
873
+ * receiver-attributed cites merged asymmetrically.
840
874
  */
841
875
  const DECAY_EXCLUDED_SURFACES = new Set();
842
876
 
@@ -906,21 +940,32 @@ export const DECAY_DENOMINATOR_SURFACES = ATTACHMENT_SURFACES.filter((f) => !DEC
906
940
  * FOUR of the five are 3->2 down-ranks (#8597, #8847 with cited_count 56, #8948,
907
941
  * #10246). THE FIFTH WAS AN EVICTION and the first draft of this note said there were
908
942
  * 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
943
+ * it to the floor of 1 (`IMPORTANCE_FLOOR`, a constant this loop no longer has — see the
944
+ * correction two paragraphs down) — under the `COALESCE(importance, 1) >= 2` gate in
910
945
  * rankImperativeCandidates, which is the candidate pool of the very face being
911
946
  * admitted. IMPERATIVE_POOL_BACKSTOP closed the 3->2 eviction in v3.82.0 and 2->1 was
912
947
  * always documented as still evicting; writing "down-ranks, not evictions" required
913
948
  * assuming the marginal population was all importance = 3, which one query refutes.
914
949
  * Count it before repeating it.
915
950
  *
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.
951
+ * THAT ROW WAS NO LONGER IN THAT STATE, AND AT THE TIME THE REASON WAS THIS LOOP (D#179).
952
+ * #10716 read importance 3 / uncited_streak 0 / demoted_at cleared, promoted by the
953
+ * session that WROTE THIS PARAGRAPH: `#10716` occurs 21 times in that session's assistant
954
+ * text, extractCitationsFromTranscript scans assistant text for `#NN`, and at that time
955
+ * applyCitationDecay raised `importance` on a hit. Discussing a memory was
956
+ * indistinguishable from applying it.
957
+ *
958
+ * CORRECTED (v3.88.0, D#179/D#198): that mechanism is gone. Neither branch of this loop
959
+ * writes `importance` any more, so citing a row cannot promote it HERE and the specific
960
+ * self-promotion described above can no longer occur. Two things still hold and are the
961
+ * reason the paragraph is kept rather than deleted. The general warning stands — anything
962
+ * in this file quoting live decay STATE is perturbed by being written down, since the
963
+ * loop still writes `cited_count`, `uncited_streak`, `demoted_at` and the session
964
+ * columns on a citation — so quote it with a timestamp and prefer the structural claim
965
+ * (the marginal population is not all importance = 3) to the row that demonstrated it.
966
+ * And a citation can still reach `importance` by a SECOND path this loop does not own:
967
+ * `bumpCitationAccess` credits `access_count`, and the `boost` maintain op raises
968
+ * `importance` by 1 above `access_count > 3` (D#206, open).
924
969
  *
925
970
  * Cross-crediting — a main-face id the main thread never cited but a subagent did — is
926
971
  * 3 pairs. The denominator giving 0.25% is DISTINCT (session,id) across the five decay
@@ -1170,54 +1215,40 @@ export function hasMainThreadAssistantText(transcriptPath) {
1170
1215
  return false;
1171
1216
  }
1172
1217
 
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;
1218
+ // IMPORTANCE_CAP (3) and IMPORTANCE_FLOOR (1) lived here until D#179/D#198 took
1219
+ // `importance` out of this loop entirely; both are gone rather than kept unused.
1220
+ // The reasoning behind the FLOOR is preserved because it is the sharpest statement
1221
+ // of why decay must not move this column at all: both passive injection surfaces
1222
+ // exclude importance 0 (pre-tool-recall.js requires >= 2, user-prompt-search.js
1223
+ // requires >= 1), so a row demoted to 0 could never be re-injected -> never
1224
+ // re-cited -> never recover. The floor bounded that one-way burial at the bottom
1225
+ // of the scale; it could do nothing about the same mechanism one step up, where
1226
+ // a 3 -> 2 evicts a row from the `>= 3` tier arm of the Key Context pool. Genuine
1227
+ // noise still sinks via maintain's PENDING_PURGE pipeline, which keys on
1228
+ // compressed_into (not importance) over injection_count = 0 rows.
1184
1229
  const UNCITED_STREAK_THRESHOLD = 3;
1185
1230
 
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
- }
1231
+ // The adoption-rate gate (P5 ②) lived here, with computeCitationAdoption feeding
1232
+ // it and a streak CAP as its other half. All three are gone — D#204.
1233
+ //
1234
+ // It suppressed the demote branch in a project whose cite-rate was ~0 over enough
1235
+ // resolutions, on the reasoning that such a project has not adopted the `#NN`
1236
+ // convention and a demotion there is a false negative it could never earn back.
1237
+ // That reasoning was entirely about `importance`, which D#179 removed from this
1238
+ // loop. What was left behind was inverted: the suppressed path capped
1239
+ // uncited_streak at threshold-1 and so never returned to 0 without a citation,
1240
+ // pinning those projects' rows at citeFactor 0.5x permanently, while an adopting
1241
+ // project's rolled back to 1.0x every third resolution. The gate written to be
1242
+ // gentler on non-adopting projects had become the only thing punishing them.
1243
+ //
1244
+ // The cap's stated purpose — keep the streak from climbing unbounded into the
1245
+ // citeFactor floor — is served by the rollover itself, and served better, since
1246
+ // the rollover also recovers. One path for every project.
1247
+ //
1248
+ // CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD tuned the gate. It is now inert, and
1249
+ // warned about rather than silently ignored (see applyCitationDecay): a setting
1250
+ // that is accepted but means nothing is worse than one that is unsupported.
1251
+ let adoptionThresholdWarned = false;
1221
1252
 
1222
1253
  /**
1223
1254
  * D#61: a lesson injected live and then superseded mid-session (auto-dedup /
@@ -1264,8 +1295,49 @@ export function redirectSupersededIds(db, project, ids) {
1264
1295
  * Apply the citation-feedback loop for one session: for each injected obs id,
1265
1296
  * decide cited vs uncited and mutate importance/streak/cited_count per spec.
1266
1297
  *
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.
1298
+ * - cited: cited_count += 1, streak = 0, demoted_at cleared.
1299
+ * - uncited: streak += 1; if it reaches 3, streak = 0 and demoted_at is stamped.
1300
+ *
1301
+ * D#179 / D#198 — THIS LOOP NO LONGER WRITES `importance`, on any branch.
1302
+ * `importance` was carrying two jobs at once: a relevance prior AND a
1303
+ * pool-admission gate. Every injection surface gates on it — `>= 1` or `>= 2` on
1304
+ * the prompt faces, and hook-context's Key Context tier arms use `>= 1 / >= 2 /
1305
+ * >= 3` — so a decay-driven 3 -> 2 was not a down-rank, it removed the row from
1306
+ * the candidate POPULATION. That is the D#172 shape, confirmed on the imperative
1307
+ * pool (v3.82.0) and then on the Key Context pool, where 45 of ~106 pool rows sat
1308
+ * in the band where one demotion is an eviction (D#198).
1309
+ *
1310
+ * The ranking half of the loop is unaffected and was never the problem:
1311
+ * cited_count and uncited_streak still feed citeFactorClause, a BOUNDED
1312
+ * [0.4, 3.0] pure multiplier. So the feedback loop still responds to observed
1313
+ * agent behaviour — it just does so by re-ranking within the population instead
1314
+ * of by changing who is in it. Given that no available signal separates "acted on
1315
+ * this lesson" from "wrote about this lesson" (D#179; a release-note session
1316
+ * promotes exactly the rows it discusses, and the mention/application split
1317
+ * measured on the live corpus is not a bound in either direction), a bounded rank
1318
+ * shift is the right cost for a mis-read citation. An eviction is not.
1319
+ *
1320
+ * NOT covered by this change, and stated so nobody reads it as "citations can no
1321
+ * longer move importance": `bumpCitationAccess` credits `access_count`, and the
1322
+ * `boost` maintain op lifts `importance + 1` above `access_count > 3`. That is a
1323
+ * SECOND, independent citation -> importance path (obs #10911, D#206).
1324
+ *
1325
+ * Scope it correctly — an earlier version of this paragraph said "for any `#NN` in
1326
+ * assistant text", which has been false since v3.84.0 (f9a9eae). The credit is gated
1327
+ * on `buildCitationRelevanceSet`: the id must have been injected on one of the five
1328
+ * attachment faces, or by Key Context, or into a subagent, or typed by the user. A
1329
+ * bare mention of an id you were never shown credits nothing, and the revert switch
1330
+ * is CLAUDE_MEM_CITATION_RELEVANCE_GATE=off. What the gate does NOT ask is whether
1331
+ * you acted on the lesson — an injected id named only in prose is still credited —
1332
+ * which is why the path is open rather than closed.
1333
+ *
1334
+ * Measured before deciding to leave it (2026-09-02, live DB + 98 transcripts): 52
1335
+ * rows are currently boost-eligible; 25 of them are cited nowhere in the corpus, and
1336
+ * at most 3 could have crossed `access_count > 3` on citations even under an upper
1337
+ * bound that ignores the gate entirely. So the path is real and its effect is small.
1338
+ * It is untouched here because `access_count` has other writers (explicit recall /
1339
+ * get / timeline) and is also an input to noisePenaltyClause, so changing it is a
1340
+ * different decision with a different blast radius.
1269
1341
  * - per-(session, obs) idempotent via last_decided_session_id; re-running for
1270
1342
  * the same session is a no-op (Stop hook may fire more than once).
1271
1343
  * - cross-project IDs are silently ignored by the WHERE clause.
@@ -1277,13 +1349,17 @@ export function redirectSupersededIds(db, project, ids) {
1277
1349
  * 2. (cite-back) the agent edited a file a prior lesson #NN had warned about —
1278
1350
  * unioned into citedIds by the Stop handler before this call.
1279
1351
  * 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.
1352
+ * lesson without typing its id. Even so, both are proxies, and nothing available
1353
+ * separates "acted on this lesson" from "wrote about this lesson".
1354
+ *
1355
+ * That imprecision used to be answered with a per-project adoption gate that
1356
+ * suppressed demotion where the cite-rate was ~0. It is gone (D#204), because the
1357
+ * cost it was insuring against — losing `importance`, i.e. dropping out of the
1358
+ * candidate pool — is gone too (D#179). What the proxies can still get wrong is
1359
+ * bounded on its own: a mis-read citation moves citeFactorClause within
1360
+ * [0.4, 3.0] and nothing else. The uncited streak rolls over at
1361
+ * UNCITED_STREAK_THRESHOLD in every project, so a lesson in a project that never
1362
+ * types `#NN` is not driven monotonically downward — it oscillates and recovers.
1287
1363
  *
1288
1364
  * @param {import('better-sqlite3').Database} db
1289
1365
  * @param {string} project
@@ -1303,16 +1379,22 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
1303
1379
  injected = redirectSupersededIds(db, project, injected);
1304
1380
  cited = redirectSupersededIds(db, project, cited);
1305
1381
 
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;
1382
+ // D#204: the adoption gate is gone. Its env override is still READ, once, only
1383
+ // to say out loud that it no longer does anything — the alternative is a
1384
+ // setting a user can configure and watch have no effect.
1385
+ if (!adoptionThresholdWarned && process.env.CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD !== undefined) {
1386
+ adoptionThresholdWarned = true;
1387
+ try {
1388
+ process.stderr.write(
1389
+ '[claude-mem-lite] CLAUDE_MEM_CITATION_ADOPTION_THRESHOLD is set but no longer has any effect — '
1390
+ + 'the citation-decay adoption gate was removed (D#204). Unset it.\n'
1391
+ );
1392
+ } catch (e) { debugCatch(e, 'adoption-threshold-warn'); }
1393
+ }
1312
1394
 
1313
1395
  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
1396
+ // superseded_at IS NULL: mirror the 4 injection SELECTs so a row superseded
1397
+ // mid-session (injected live, then auto-dedup supersedes it before this
1316
1398
  // decay resolves) is not decayed/streaked/mutated — defense-in-depth parity.
1317
1399
  '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
1400
  );
@@ -1341,8 +1423,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
1341
1423
  // list would silently renumber if a clause were ever reordered.
1342
1424
  const updatePromote = db.prepare(`
1343
1425
  UPDATE observations
1344
- SET importance = MIN(@cap, COALESCE(importance, 1) + 1),
1345
- cited_count = cited_count + 1,
1426
+ SET cited_count = cited_count + 1,
1346
1427
  uncited_streak = 0,
1347
1428
  demoted_at = NULL,
1348
1429
  last_decided_session_id = @session,
@@ -1361,22 +1442,16 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
1361
1442
  decay_seen_count = decay_seen_count + 1
1362
1443
  WHERE id = ?
1363
1444
  `);
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
- `);
1445
+ // D#179/D#198: this branch no longer touches `importance`. It is the STREAK
1446
+ // ROLLOVER: at UNCITED_STREAK_THRESHOLD the streak resets to 0 and the moment
1447
+ // is stamped in demoted_at. The name and the returned `demoted` counter are
1448
+ // kept because both are load-bearing for callers and citation-stats; what
1449
+ // changed is that the rollover is now a bookkeeping event, not a change of
1450
+ // population membership. Resetting the streak (rather than pinning it) is what
1451
+ // holds citeFactorClause's documented [0, threshold-1] steady state.
1376
1452
  const updateDemote = db.prepare(`
1377
1453
  UPDATE observations
1378
- SET importance = MAX(?, COALESCE(importance, 1) - 1),
1379
- uncited_streak = 0,
1454
+ SET uncited_streak = 0,
1380
1455
  last_decided_session_id = ?,
1381
1456
  demoted_at = ?,
1382
1457
  decay_seen_count = decay_seen_count + 1
@@ -1403,7 +1478,7 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
1403
1478
  // decay_seen_count and the funnel's injected_n (cite-rate would read N/2, not N/1).
1404
1479
  const firstResolution = !decidedThisSession;
1405
1480
  updatePromote.run({
1406
- cap: IMPORTANCE_CAP, session: sessionId, seenInc: firstResolution ? 1 : 0, id,
1481
+ session: sessionId, seenInc: firstResolution ? 1 : 0, id,
1407
1482
  });
1408
1483
  promoted++;
1409
1484
  if (firstResolution) touched++;
@@ -1413,15 +1488,12 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
1413
1488
  if (decidedThisSession) continue;
1414
1489
  touched++;
1415
1490
  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);
1491
+ // D#204: one path for every project. The rollover both bounds the streak
1492
+ // (so citeFactorClause cannot sink toward its floor) and lets it recover
1493
+ // to 0 without requiring a citation.
1494
+ if (nextStreak >= UNCITED_STREAK_THRESHOLD) {
1495
+ updateDemote.run(sessionId, Date.now(), id);
1421
1496
  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
1497
  } else {
1426
1498
  updateStreakOnly.run(sessionId, id);
1427
1499
  }
@@ -119,6 +119,55 @@ export function dropDeferred(db, id, reason) {
119
119
  return { changed: r.changes };
120
120
  }
121
121
 
122
+ /**
123
+ * Reasons that mean "this item was FIXED", for which `defer drop` is the wrong
124
+ * verb — dropping loses the closed_by_obs_id link that `save --closes-deferred`
125
+ * would have written, and leaves the row indistinguishable from a genuinely
126
+ * rejected one (D#195; v3.86.0 dropped six fixed items this way).
127
+ *
128
+ * Deliberately a POSITIVE pattern for the fixed-shape, not a stop-list of
129
+ * rejection wordings: the set of ways to say "no longer relevant" is open-ended,
130
+ * the set of ways to say "done" is small. Advisory only — the drop still
131
+ * succeeds, so a false positive costs one line of output.
132
+ */
133
+ // `closed` and `landed` are here because of the six real v3.86.0 mis-drops this
134
+ // hint exists to prevent: their reason was "closed this round; fix + mutation-
135
+ // verified binding test landed", which an earlier draft anchored on `fixed` and
136
+ // `closed by` and therefore MISSED — the motivating case fell through its own
137
+ // predicate. Bare `fix` is deliberately NOT here: "waiting for an upstream fix"
138
+ // is a legitimate rejection and carries no veto word in English.
139
+ const DROP_REASON_FIXED_RE = /\b(fixed|implemented|shipped|resolved|done|closed|landed)\b|修复|已实现|已完成|完成了|已发布|已解决/i;
140
+
141
+ /**
142
+ * Negative-sense veto, applied to the WHOLE reason before the positive pattern.
143
+ *
144
+ * A lookbehind cannot do this job: the sense-carrying word is not adjacent to the
145
+ * keyword. "等待上游修复" (waiting for an upstream fix) puts 游 immediately before
146
+ * 修复, so `(?<![待未需])修复` still fires on it — measured, which is why the
147
+ * predicate is two-stage rather than one clever pattern. Suppression is the safe
148
+ * direction here: a missed hint costs nothing, a wrong hint trains the reader to
149
+ * ignore the line.
150
+ */
151
+ // `wontfix` is spelled with no boundary after `wont`, so a plain `won'?t\b` misses the
152
+ // single most common English way of writing this rejection and the hint then fires on
153
+ // "resolved as wontfix" — the positive arm's `resolved` winning over a veto that never
154
+ // ran. Matched explicitly rather than by loosening the boundary, which would also admit
155
+ // `wonton`. (pre-tag review v3.88.0, correctness N4)
156
+ const DROP_REASON_NOT_YET_RE = /\bwon'?tfix\b|\b(not|won'?t|cannot|can'?t|unable|pending|todo|blocked|waiting|obsolete|superseded|duplicate|irrelevant|refuted)\b|待|未|尚|需|无法|暂不|过时|重复|取代/i;
157
+
158
+ /**
159
+ * @param {string} reason The drop reason about to be recorded.
160
+ * @returns {string|null} Advisory hint, or null when the reason does not look
161
+ * like a completion.
162
+ */
163
+ export function formatDropReasonHint(reason) {
164
+ if (typeof reason !== 'string') return null;
165
+ if (DROP_REASON_NOT_YET_RE.test(reason)) return null;
166
+ if (!DROP_REASON_FIXED_RE.test(reason)) return null;
167
+ return '⚠ that reason reads like the item was FIXED — prefer `save --closes-deferred D#<id>`, '
168
+ + 'which records status=done plus the closing observation id. `drop` records a rejection.';
169
+ }
170
+
122
171
  /**
123
172
  * Fetch full deferred_work rows by raw id — ANY status, input order preserved,
124
173
  * missing ids omitted. This is the read half of the D# surface: `defer list`
@@ -161,6 +210,11 @@ export function formatDeferredDetail(row) {
161
210
  if (row.created_at_epoch) lines.push(`created: ${new Date(row.created_at_epoch).toISOString()}`);
162
211
  if (row.status === 'dropped' && row.drop_reason) lines.push(`drop_reason: ${row.drop_reason}`);
163
212
  if (row.status === 'done' && row.closed_by_obs_id) lines.push(`closed_by: #${row.closed_by_obs_id}`);
213
+ // D#195: a row re-closed out of 'dropped' keeps its drop_reason. Render it under
214
+ // a distinct label rather than dropping it from the view — the mis-drop is the
215
+ // part a later reader needs, and a `drop_reason:` line on a done row would read
216
+ // as a contradiction.
217
+ if (row.status === 'done' && row.drop_reason) lines.push(`previously_dropped: ${row.drop_reason}`);
164
218
  return lines.join('\n');
165
219
  }
166
220
 
@@ -258,18 +312,31 @@ export function formatDeferredSearchTrailer(rows, invokeHint) {
258
312
 
259
313
  /**
260
314
  * Resolve mixed ordinal (int) + raw-id ("D#<n>") tokens to real deferred_work
261
- * ids, validated against caller project + status='open'.
315
+ * ids, validated against caller project + an allowed status set.
262
316
  *
263
317
  * - bare integer N → ordinal-within-project (uses same ROW_NUMBER as listOpenWithOrdinal)
264
- * - "D#<n>" string → raw deferred_work.id; must belong to caller project AND be open
318
+ * - "D#<n>" string → raw deferred_work.id; must belong to caller project AND
319
+ * carry an allowed status
320
+ *
321
+ * `allowStatuses` defaults to open-only, which is the DROP verb's policy and the
322
+ * historical behaviour of every caller. The CLOSE verb (`save --closes-deferred`)
323
+ * passes `['open', 'dropped']` per D#195: dropping an item that was actually
324
+ * fixed used to be a one-way gate, permanently losing the closed_by_obs_id link.
325
+ * 'done' is never allowed under either policy — re-closing would overwrite an
326
+ * existing obs link with a different one.
327
+ *
328
+ * Ordinals stay open-only under every policy: the ROW_NUMBER that defines them
329
+ * is computed over open rows, so a dropped row simply has no ordinal to name.
330
+ * Reaching one requires the explicit `D#<n>` form.
265
331
  *
266
332
  * @param {Database} db
267
333
  * @param {string} project Caller project (FK guard)
268
334
  * @param {Array<number|string>} tokens Mixed input
335
+ * @param {{allowStatuses?: string[]}} [opts]
269
336
  * @returns {number[]} Real deferred_work ids in input order
270
337
  * @throws {Error} On unresolvable input — error message names the offending token
271
338
  */
272
- export function resolveDeferredIds(db, project, tokens) {
339
+ export function resolveDeferredIds(db, project, tokens, { allowStatuses = ['open'] } = {}) {
273
340
  if (!Array.isArray(tokens)) throw new Error('tokens must be an array');
274
341
  // Pre-load open list once for ordinal resolution (ROW_NUMBER snapshot stable
275
342
  // within this call so [1, 2] resolves consistently).
@@ -300,10 +367,11 @@ export function resolveDeferredIds(db, project, tokens) {
300
367
  if (row.project !== project) {
301
368
  throw new Error(`D#${id} belongs to project "${row.project}", not "${project}"`);
302
369
  }
303
- if (row.status !== 'open') {
370
+ if (!allowStatuses.includes(row.status)) {
304
371
  // Verb-neutral: resolveDeferredIds is shared by close (save --closes-deferred)
305
372
  // AND drop (mem_defer_drop), so "cannot close" mis-described the drop path.
306
- throw new Error(`D#${id} status is "${row.status}" — only 'open' items can be closed or dropped`);
373
+ const allowed = allowStatuses.map(s => `'${s}'`).join(' or ');
374
+ throw new Error(`D#${id} status is "${row.status}" — only ${allowed} items are accepted here`);
307
375
  }
308
376
  } else {
309
377
  throw new Error(`invalid token type ${typeof t} — expected D#N or integer ordinal`);
@@ -327,7 +395,7 @@ export function resolveDeferredIds(db, project, tokens) {
327
395
  * @param {Database} db
328
396
  * @param {number[]} ids Already-resolved real ids (use resolveDeferredIds first)
329
397
  * @param {number} closingObsId observations.id that proves closure
330
- * @throws {Error} If any id is not currently open (lookup-based safety net)
398
+ * @throws {Error} If any id is neither 'open' nor 'dropped' (lookup-based safety net)
331
399
  */
332
400
  export function closeDeferredItems(db, ids, closingObsId) {
333
401
  if (!Array.isArray(ids) || ids.length === 0) return;
@@ -337,17 +405,22 @@ export function closeDeferredItems(db, ids, closingObsId) {
337
405
  // Defense-in-depth: even if caller already validated via resolveDeferredIds,
338
406
  // re-check status here (caller may have done resolution earlier in the same
339
407
  // transaction without holding a lock).
408
+ //
409
+ // D#195: 'dropped' is closable. drop_reason is intentionally NOT cleared — the
410
+ // row's history is what makes a mis-drop auditable, and formatDeferredDetail
411
+ // renders it as `previously_dropped:` once the status is 'done'. 'done' stays
412
+ // excluded so a second close cannot overwrite an existing closed_by_obs_id.
340
413
  const stmt = db.prepare(`
341
414
  UPDATE deferred_work
342
415
  SET status='done', closed_at_epoch=?, closed_by_obs_id=?
343
- WHERE id=? AND status='open'
416
+ WHERE id=? AND status IN ('open', 'dropped')
344
417
  `);
345
418
  const now = Date.now();
346
419
  const tx = db.transaction((idList) => {
347
420
  for (const id of idList) {
348
421
  const r = stmt.run(now, closingObsId, id);
349
422
  if (r.changes !== 1) {
350
- throw new Error(`closeDeferredItems: id ${id} was not in 'open' status (changes=${r.changes})`);
423
+ throw new Error(`closeDeferredItems: id ${id} was not in a closable status ('open' or 'dropped') (changes=${r.changes})`);
351
424
  }
352
425
  }
353
426
  });
@@ -20,9 +20,20 @@
20
20
  // row is never mis-attributed to an observation id (which would let citation decay
21
21
  // mutate an unrelated observation sharing that id). Events carry no citation columns,
22
22
  // so they inject as reference-only — reachability, not decay bookkeeping.
23
+ //
24
+ // D#202: that paragraph was true of the faces it names and FALSE of the one it does
25
+ // not. `scripts/pre-tool-recall.js` renders its own merged obs+event rows and used a
26
+ // bare `#` for both, so nearly half of that channel's injected rows (44.9%, measured
27
+ // over 4227 firings) were event ids entering the observation decay denominator —
28
+ // exactly the mis-attribution this prefix exists to prevent. The invariant was stated
29
+ // here while the face that broke it lived elsewhere and was not enumerated. The prefix
30
+ // is now a shared constant (lib/injected-ids.mjs, a leaf so the hot PreToolUse path
31
+ // need not pull this file's search-core chain) and
32
+ // tests/pretool-event-id-namespace.test.mjs sweeps both renderers.
23
33
 
24
34
  import { searchEventsFts } from './search-core.mjs';
25
35
  import { neutralizeContextDelimiters } from '../format-utils.mjs';
36
+ import { EVENT_ID_PREFIX } from './injected-ids.mjs';
26
37
 
27
38
  const DEFAULT_LIMIT = 3;
28
39
  const DEFAULT_MIN_IMPORTANCE = 2;
@@ -98,7 +109,7 @@ export function renderInjectableEvent(row) {
98
109
  // hook-llm.mjs behind `if (EVENT_TYPE_SET.has(summary.type))`), so it carries no
99
110
  // injection markers and is intentionally NOT defanged — mirrors the un-defanged
100
111
  // `[type]` for observations. If an unvalidated event writer is ever added, defang it.
101
- const head = `E#${row.id} [${row.type}] ${title}`;
112
+ const head = `${EVENT_ID_PREFIX}${row.id} [${row.type}] ${title}`;
102
113
  if (row.lesson_learned) {
103
114
  const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
104
115
  if (lesson) return `${head} — ${lesson}`;
@@ -95,6 +95,23 @@ export function injectedIdKey(id, src = 'obs') {
95
95
  return src === 'evt' ? `E${id}` : String(id);
96
96
  }
97
97
 
98
+ /**
99
+ * DISPLAY prefix for an event id rendered into an injected line, as opposed to
100
+ * `injectedIdKey` above, which namespaces the same id inside the marker FILE.
101
+ * Two forms of one convention: the marker key is `E<id>` (no `#`, it is not
102
+ * citable text), the rendered token is `E#<id>` (the `#` is what makes an id
103
+ * look like an id to a reader).
104
+ *
105
+ * It lives in this leaf module — rather than beside either renderer — because
106
+ * D#202 was exactly the two renderers not agreeing. lib/events-injection.mjs
107
+ * had the `E#` convention and a header explaining it; scripts/pre-tool-recall.js
108
+ * rendered its own merged obs+event rows with a bare `#`, putting 44.9% of that
109
+ * channel's ids into the observation citation-decay denominator. Importing from
110
+ * here also keeps the hot PreToolUse path off events-injection.mjs's
111
+ * search-core.mjs dependency chain.
112
+ */
113
+ export const EVENT_ID_PREFIX = 'E#';
114
+
98
115
  /**
99
116
  * Runtime-dir FILE NAME for the SessionStart Key Context marker: the obs ids
100
117
  * ACTUALLY rendered into the <claude-mem-context> File Lessons / Key Context
@@ -265,8 +265,10 @@ export function recoverOrphanedChildren(db, { projectFilter = '', baseParams = [
265
265
  `).run(...baseParams).changes;
266
266
  }
267
267
 
268
- // Heal lesson-bearing rows that citation-decay buried at importance 0 under the old
269
- // IMPORTANCE_FLOOR=0 (fixed in citation-tracker.mjs → floor 1). All passive injection
268
+ // Heal lesson-bearing rows that citation-decay buried at importance 0 back when its floor
269
+ // was 0. That loop was later given a floor of 1, and as of D#179/D#198 it does not write
270
+ // `importance` on any branch at all — so this op no longer has an active producer and only
271
+ // drains the historical backlog. Keep it: nothing else lifts a stranded 0. All passive injection
270
272
  // surfaces exclude importance 0 (pre-tool-recall >=2, user-prompt-search >=1, memory-context
271
273
  // >=1), so a lesson demoted there is invisible AND — being injection_count>0 by construction
272
274
  // — sits in no GC queue either (decayAndMarkIdle only marks injection_count=0 rows): stranded
@@ -21,7 +21,7 @@
21
21
  // property: it only `createRequire`s better-sqlite3 lazily inside its functions,
22
22
  // so importing it never dlopen's the very binding this module reports on.
23
23
 
24
- import { join } from 'node:path';
24
+ import { join, dirname } from 'node:path';
25
25
  import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { isNativeBindingError, flattenBindingError } from './binding-probe.mjs';
@@ -42,7 +42,10 @@ export const NATIVE_BINDING_BROKEN_MARKER = 'native-binding-broken';
42
42
  // `repair`: repair re-downloads and Ed25519-verifies a whole GitHub release and
43
43
  // fails closed offline — the wrong (often impossible) tool for recompiling one
44
44
  // native module against the running Node. (review #3)
45
- const CLI_REBUILD_BINDING = `node ${fileURLToPath(new URL('../cli.mjs', import.meta.url))} rebuild-binding`;
45
+ // D#207: join(), not `new URL('../cli.mjs', …)` — that form makes knip drop the named
46
+ // module out of its unused-export report. cli.mjs is a knip entry point so nothing was
47
+ // lost here, but the rule is enforced for the class rather than per-file.
48
+ const CLI_REBUILD_BINDING = `node ${join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.mjs')} rebuild-binding`;
46
49
 
47
50
  // Stable-ish identity of a fault so DISTINCT failures get DISTINCT cooldown
48
51
  // windows: the same fault → same key (suppressed within the window), a different