claude-mem-lite 3.87.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 +31 -18
- 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 +17 -0
- 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';
|
|
@@ -86,8 +86,10 @@ export function computeAdaptiveWindows(db, project) {
|
|
|
86
86
|
//
|
|
87
87
|
// Both displaced rows lost their slot to the 3-per-type diversity cap. That is not a
|
|
88
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`
|
|
90
|
-
// (D#197) — so the cap is
|
|
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.)
|
|
91
93
|
//
|
|
92
94
|
// 200 is ~2x the largest pool observed (107). The ruler CANNOT distinguish 200 from 500
|
|
93
95
|
// on this corpus — every bound >= the largest pool is one arm, identical in both
|
|
@@ -196,7 +198,6 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
196
198
|
...scoredSess.map(s => ({ ...s, _kind: 'sess' })),
|
|
197
199
|
].sort((a, b) => b.valueDensity - a.valueDensity);
|
|
198
200
|
|
|
199
|
-
const selectedFiles = new Set();
|
|
200
201
|
const selectedTypes = new Map(); // type → count for diversity constraint
|
|
201
202
|
|
|
202
203
|
for (const c of allCandidates) {
|
|
@@ -208,20 +209,32 @@ export function selectWithTokenBudget(db, project, budget = 2000) {
|
|
|
208
209
|
if (typeCount >= 3) continue;
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
// Diversity penalty: reduce value for file overlap
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
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.
|
|
225
238
|
if (c._kind === 'obs' && c.type) {
|
|
226
239
|
selectedTypes.set(c.type, (selectedTypes.get(c.type) || 0) + 1);
|
|
227
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
|
}
|
package/lib/deferred-work.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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'
|
|
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
|
});
|
package/lib/events-injection.mjs
CHANGED
|
@@ -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 =
|
|
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}`;
|
package/lib/injected-ids.mjs
CHANGED
|
@@ -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
|
package/lib/maintain-core.mjs
CHANGED
|
@@ -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
|
|
269
|
-
//
|
|
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
|
package/lib/save-observation.mjs
CHANGED
|
@@ -18,6 +18,31 @@ import { insertObservationRow, insertObservationFiles, insertObservationVector }
|
|
|
18
18
|
const DEDUP_WINDOW_MS = 5 * 60 * 1000;
|
|
19
19
|
const DEDUP_RECENT_LIMIT = 50;
|
|
20
20
|
|
|
21
|
+
/** Human-readable cause per `supersedeSkipped` reason (D#201). */
|
|
22
|
+
const SUPERSEDE_SKIP_CAUSE = {
|
|
23
|
+
'malformed-id': 'not a positive integer id',
|
|
24
|
+
'no-such-observation': 'no observation with that id',
|
|
25
|
+
'other-project': 'belongs to a different project',
|
|
26
|
+
'already-superseded': 'already superseded (no-op)',
|
|
27
|
+
'duplicate-save': 'the save deduped, so nothing was superseded',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the D#201 warning for requested-but-not-superseded ids. Lives here
|
|
32
|
+
* rather than in either face so the CLI and the MCP tool cannot word it
|
|
33
|
+
* differently or, more to the point, so one of them cannot quietly stop
|
|
34
|
+
* rendering it.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<{id: any, reason: string}>} [skipped]
|
|
37
|
+
* @returns {string|null} null when nothing was skipped
|
|
38
|
+
*/
|
|
39
|
+
export function formatSupersedeSkipped(skipped) {
|
|
40
|
+
if (!Array.isArray(skipped) || skipped.length === 0) return null;
|
|
41
|
+
const parts = skipped.map(({ id, reason }) =>
|
|
42
|
+
`#${id} (${SUPERSEDE_SKIP_CAUSE[reason] || reason})`);
|
|
43
|
+
return `⚠ --supersedes: ${parts.length} id(s) NOT superseded — ${parts.join(', ')}.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
/**
|
|
22
47
|
* Save a new observation if it isn't a near-duplicate of one saved within the
|
|
23
48
|
* last 5 minutes (Jaccard similarity > 0.7 on title or content).
|
|
@@ -32,8 +57,18 @@ const DEDUP_RECENT_LIMIT = 50;
|
|
|
32
57
|
* @param {string[]} [params.files=[]] File paths to attach (junction table).
|
|
33
58
|
* @param {string|null} [params.lesson_learned] Caller validates ≤500 chars.
|
|
34
59
|
* @param {Date} [params.now] Override for tests.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
60
|
+
* Both result shapes carry `supersededIds` (what was actually tombstoned) and
|
|
61
|
+
* `supersedeSkipped` (requested but NOT tombstoned, each with a `reason`:
|
|
62
|
+
* `malformed-id` | `no-such-observation` | `other-project` |
|
|
63
|
+
* `already-superseded` | `duplicate-save`). Callers MUST surface a non-empty
|
|
64
|
+
* `supersedeSkipped` — that is the whole point of D#201; dropping it puts the
|
|
65
|
+
* silent failure back.
|
|
66
|
+
*
|
|
67
|
+
* @returns {{ kind: 'duplicate', existingId: number, project: string, type: string,
|
|
68
|
+
* supersededIds: number[], supersedeSkipped: Array<{id: any, reason: string}> }
|
|
69
|
+
* | { kind: 'saved', id: number, type: string, project: string, title: string,
|
|
70
|
+
* lessonCaptured: boolean, supersededIds: number[],
|
|
71
|
+
* supersedeSkipped: Array<{id: any, reason: string}> }}
|
|
37
72
|
*/
|
|
38
73
|
export function saveObservation(db, params) {
|
|
39
74
|
const now = params.now instanceof Date ? params.now : new Date();
|
|
@@ -83,12 +118,42 @@ export function saveObservation(db, params) {
|
|
|
83
118
|
ORDER BY created_at_epoch DESC LIMIT ?
|
|
84
119
|
`).all(project, dedupCutoff, DEDUP_RECENT_LIMIT);
|
|
85
120
|
|
|
121
|
+
// Requested supersession targets, normalized. Declared BEFORE the dedup
|
|
122
|
+
// short-circuit because that path reports on them too.
|
|
123
|
+
// Self-reference is filtered inside the transaction, once the new id exists.
|
|
124
|
+
//
|
|
125
|
+
// D#201: the tokens that DON'T survive normalization are kept, not dropped.
|
|
126
|
+
// A caller who names an id and gets no supersession has to be told which id
|
|
127
|
+
// and why — the previous shape reported "requested 4, superseded 0" and
|
|
128
|
+
// "requested nothing" identically, so a mistyped or wrong-table id read as a
|
|
129
|
+
// clean success. `malformed-id` is the pre-query class; the DB-level classes
|
|
130
|
+
// are decided inside the transaction.
|
|
131
|
+
const rawSupersedes = Array.isArray(params.supersedes) ? params.supersedes : [];
|
|
132
|
+
const requestedSupersedes = [...new Set(
|
|
133
|
+
rawSupersedes.map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
|
134
|
+
)];
|
|
135
|
+
const malformedSupersedes = rawSupersedes
|
|
136
|
+
.filter((t) => { const n = Number(t); return !(Number.isInteger(n) && n > 0); })
|
|
137
|
+
.map((t) => ({ id: t, reason: 'malformed-id' }));
|
|
138
|
+
|
|
86
139
|
const dupMatch = recent.find((r) =>
|
|
87
140
|
jaccardSimilarity(r.title, safeTitle) > DEDUP_JACCARD_THRESHOLD ||
|
|
88
141
|
jaccardSimilarity(r.text || '', safeContent) > DEDUP_JACCARD_THRESHOLD
|
|
89
142
|
);
|
|
90
143
|
if (dupMatch) {
|
|
91
|
-
|
|
144
|
+
// D#201: a dedup short-circuit swallows the requested supersession too — you
|
|
145
|
+
// write a correction, it reads as a near-duplicate of something saved in the
|
|
146
|
+
// last 5 minutes, and the rows you meant to retire stay live. Same sentence
|
|
147
|
+
// as the ineligible-id case ("requested, did not happen, no trace"), so it
|
|
148
|
+
// reports through the same channel rather than staying quiet.
|
|
149
|
+
return {
|
|
150
|
+
kind: 'duplicate', existingId: dupMatch.id, project, type,
|
|
151
|
+
supersededIds: [],
|
|
152
|
+
supersedeSkipped: [
|
|
153
|
+
...malformedSupersedes,
|
|
154
|
+
...requestedSupersedes.map((id) => ({ id, reason: 'duplicate-save' })),
|
|
155
|
+
],
|
|
156
|
+
};
|
|
92
157
|
}
|
|
93
158
|
|
|
94
159
|
// FTS-indexed text field includes title + content + lesson + CJK bigrams,
|
|
@@ -99,13 +164,6 @@ export function saveObservation(db, params) {
|
|
|
99
164
|
const bigramText = cjkBigrams(indexText);
|
|
100
165
|
const textField = bigramText ? safeContent + ' ' + bigramText : safeContent;
|
|
101
166
|
|
|
102
|
-
// Requested supersession targets, normalized before the transaction opens.
|
|
103
|
-
// Self-reference is filtered inside, once the new id exists.
|
|
104
|
-
const requestedSupersedes = [...new Set(
|
|
105
|
-
(Array.isArray(params.supersedes) ? params.supersedes : [])
|
|
106
|
-
.map(Number).filter((n) => Number.isInteger(n) && n > 0)
|
|
107
|
-
)];
|
|
108
|
-
|
|
109
167
|
// Atomic: observation row + observation_files junction + observation_vectors
|
|
110
168
|
// (TF-IDF) + supersession tombstones. Vector write is best-effort — vocab may be
|
|
111
169
|
// uninitialized on a fresh DB; failure must not roll back the observation.
|
|
@@ -139,6 +197,7 @@ export function saveObservation(db, params) {
|
|
|
139
197
|
// Write-the-correction and retire-its-predecessors is one unit or neither.
|
|
140
198
|
const ids = requestedSupersedes.filter((n) => n !== savedId);
|
|
141
199
|
let supersededIds = [];
|
|
200
|
+
const skipped = [];
|
|
142
201
|
if (ids.length > 0) {
|
|
143
202
|
const ph = ids.map(() => '?').join(',');
|
|
144
203
|
const eligible = db.prepare(
|
|
@@ -150,11 +209,30 @@ export function saveObservation(db, params) {
|
|
|
150
209
|
.run(now.getTime(), savedId, ...eligible);
|
|
151
210
|
supersededIds = eligible;
|
|
152
211
|
}
|
|
212
|
+
// D#201: classify the difference instead of discarding it. One extra
|
|
213
|
+
// SELECT, and only when something actually failed to land — the happy
|
|
214
|
+
// path (every id eligible) skips it entirely.
|
|
215
|
+
const landed = new Set(eligible);
|
|
216
|
+
const missed = ids.filter((n) => !landed.has(n));
|
|
217
|
+
if (missed.length > 0) {
|
|
218
|
+
const ph3 = missed.map(() => '?').join(',');
|
|
219
|
+
const rows = new Map(db.prepare(
|
|
220
|
+
`SELECT id, project, superseded_at FROM observations WHERE id IN (${ph3})`
|
|
221
|
+
).all(...missed).map((r) => [r.id, r]));
|
|
222
|
+
for (const n of missed) {
|
|
223
|
+
const row = rows.get(n);
|
|
224
|
+
// Order matters: a row can be BOTH foreign-project and already
|
|
225
|
+
// superseded, and "it isn't yours" is the more actionable of the two.
|
|
226
|
+
if (!row) skipped.push({ id: n, reason: 'no-such-observation' });
|
|
227
|
+
else if (row.project !== project) skipped.push({ id: n, reason: 'other-project' });
|
|
228
|
+
else skipped.push({ id: n, reason: 'already-superseded' });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
153
231
|
}
|
|
154
232
|
|
|
155
|
-
return { savedId, supersededIds };
|
|
233
|
+
return { savedId, supersededIds, skipped };
|
|
156
234
|
});
|
|
157
|
-
const { savedId, supersededIds } = saveTx();
|
|
235
|
+
const { savedId, supersededIds, skipped } = saveTx();
|
|
158
236
|
|
|
159
237
|
return {
|
|
160
238
|
kind: 'saved',
|
|
@@ -164,5 +242,9 @@ export function saveObservation(db, params) {
|
|
|
164
242
|
title: safeTitle,
|
|
165
243
|
lessonCaptured: Boolean(safeLesson),
|
|
166
244
|
supersededIds,
|
|
245
|
+
// D#201: requested-but-not-superseded, with a reason each. Malformed tokens
|
|
246
|
+
// are prepended because they were rejected before the query and so carry the
|
|
247
|
+
// caller's ORIGINAL token (which may not even be a number) rather than an id.
|
|
248
|
+
supersedeSkipped: [...malformedSupersedes, ...skipped],
|
|
167
249
|
};
|
|
168
250
|
}
|
package/mem-cli.mjs
CHANGED
|
@@ -55,7 +55,7 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
|
55
55
|
import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
|
|
56
56
|
import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
57
57
|
import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
58
|
-
import { saveObservation } from './lib/save-observation.mjs';
|
|
58
|
+
import { saveObservation, formatSupersedeSkipped } from './lib/save-observation.mjs';
|
|
59
59
|
import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
|
|
60
60
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
61
61
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
@@ -80,7 +80,7 @@ const SURFACE_LABELS = {
|
|
|
80
80
|
};
|
|
81
81
|
import { aggregateMetrics, readMetrics } from './lib/metrics.mjs';
|
|
82
82
|
import {
|
|
83
|
-
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
83
|
+
insertDeferred, listOpenWithOrdinal, dropDeferred, formatDropReasonHint,
|
|
84
84
|
resolveDeferredIds, closeDeferredItems,
|
|
85
85
|
getDeferredByIds, formatDeferredDetail,
|
|
86
86
|
searchDeferredWork, formatDeferredSearchTrailer,
|
|
@@ -955,8 +955,13 @@ function cmdSave(db, args) {
|
|
|
955
955
|
let supersedesIds = null;
|
|
956
956
|
if (flags.supersedes !== undefined && flags.supersedes !== false) {
|
|
957
957
|
const raw = String(flags.supersedes);
|
|
958
|
-
|
|
959
|
-
|
|
958
|
+
// Tokens go through UNPARSED so saveObservation's classifier — not parseInt — decides
|
|
959
|
+
// what is malformed. parseInt is lenient in the one direction that costs data: it read
|
|
960
|
+
// `1abc` as 1, so a typo (`875x` for `8754`) tombstoned an unrelated observation and
|
|
961
|
+
// printed a clean `Superseded: #1.` Worse, the token vanished before saveObservation
|
|
962
|
+
// saw it, which made the `malformed-id` class D#201 added unreachable from the CLI —
|
|
963
|
+
// the exact face whose silence motivated D#201. Number() rejects `1abc` as NaN.
|
|
964
|
+
supersedesIds = raw.split(',').map((t) => t.trim()).filter(Boolean);
|
|
960
965
|
if (supersedesIds.length === 0) {
|
|
961
966
|
fail('[mem] --supersedes requires at least one positive observation id (e.g. --supersedes 8754,8771)');
|
|
962
967
|
return;
|
|
@@ -984,7 +989,11 @@ function cmdSave(db, args) {
|
|
|
984
989
|
// the deferred row has transitioned out of 'open'.
|
|
985
990
|
if (r.kind === 'duplicate') return r;
|
|
986
991
|
if (closesTokens) {
|
|
987
|
-
|
|
992
|
+
// D#195: the close verb accepts a 'dropped' row and converts it to
|
|
993
|
+
// 'done'. `defer drop` used on an item that was actually fixed was
|
|
994
|
+
// otherwise a one-way gate, permanently losing the obs link. Kept in
|
|
995
|
+
// sync with the same policy in server.mjs mem_save.
|
|
996
|
+
closesIds = resolveDeferredIds(db, project, closesTokens, { allowStatuses: ['open', 'dropped'] });
|
|
988
997
|
closeDeferredItems(db, closesIds, r.id);
|
|
989
998
|
}
|
|
990
999
|
return r;
|
|
@@ -1000,6 +1009,10 @@ function cmdSave(db, args) {
|
|
|
1000
1009
|
|
|
1001
1010
|
if (result.kind === 'duplicate') {
|
|
1002
1011
|
out(`[mem] Skipped: similar to existing #${result.existingId}. Use "claude-mem-lite get ${result.existingId}" to review.`);
|
|
1012
|
+
// D#201: the dedup swallowed the requested supersession too — say so here,
|
|
1013
|
+
// because this branch returns before the note below is ever reached.
|
|
1014
|
+
const dupSkip = formatSupersedeSkipped(result.supersedeSkipped);
|
|
1015
|
+
if (dupSkip) out(`[mem] ${dupSkip}`);
|
|
1003
1016
|
return;
|
|
1004
1017
|
}
|
|
1005
1018
|
|
|
@@ -1015,6 +1028,10 @@ function cmdSave(db, args) {
|
|
|
1015
1028
|
const enrichNote = shouldQueueSaveEnrich(result) && queueSaveEnrich(result.id)
|
|
1016
1029
|
? ' (background enrichment queued)' : '';
|
|
1017
1030
|
out(`[mem] Saved #${result.id} [${result.type}] "${truncate(result.title, 80)}" (project: ${result.project})${lessonNote}${closedNote}${supersededNote}${enrichNote}${buildLessonNudge({ type: result.type, id: result.id, lessonCaptured: result.lessonCaptured, surface: 'cli' })}`);
|
|
1031
|
+
// D#201: on its OWN line, after the success line. Appending it to the success
|
|
1032
|
+
// string would put a warning inside a sentence that reads as "done".
|
|
1033
|
+
const skipNote = formatSupersedeSkipped(result.supersedeSkipped);
|
|
1034
|
+
if (skipNote) out(`[mem] ${skipNote}`);
|
|
1018
1035
|
}
|
|
1019
1036
|
|
|
1020
1037
|
// ─── cmdDefer (sub-dispatch: add | list | drop) ──────────────────────────────
|
|
@@ -1149,6 +1166,12 @@ function cmdDeferDrop(db, args) {
|
|
|
1149
1166
|
if (noop.length > 0) {
|
|
1150
1167
|
out(`[mem] No-op (not in 'open' status): ${noop.map(id => `D#${id}`).join(', ')}`);
|
|
1151
1168
|
}
|
|
1169
|
+
// D#195 (c): catch the mis-drop at the moment it happens, not months later
|
|
1170
|
+
// when the ledger can no longer tell a fixed item from a rejected one.
|
|
1171
|
+
if (dropped.length > 0) {
|
|
1172
|
+
const hint = formatDropReasonHint(reason);
|
|
1173
|
+
if (hint) out(`[mem] ${hint}`);
|
|
1174
|
+
}
|
|
1152
1175
|
}
|
|
1153
1176
|
|
|
1154
1177
|
// N-1: Quality-focused stats for R-2 A/B baseline.
|
|
@@ -2540,10 +2563,19 @@ function cmdCitationStats(db, args) {
|
|
|
2540
2563
|
LIMIT 20
|
|
2541
2564
|
`).all();
|
|
2542
2565
|
|
|
2566
|
+
// D#179/D#198, second half: the sibling `demoted` caption below was re-worded when the
|
|
2567
|
+
// decay loop stopped writing `importance`, and this one was not — the same "the copy I
|
|
2568
|
+
// fixed was not the only copy" shape the batch's own sweeps exist to prevent. Gating on
|
|
2569
|
+
// `importance >= 3` made the section structurally empty for anything the loop produces:
|
|
2570
|
+
// a row cited in ten sessions now has cited_count = 10 and whatever importance it was
|
|
2571
|
+
// saved with, so the section degenerated into "rows that were already at 3" under a
|
|
2572
|
+
// caption reading "promoted". The discriminator is now the pair the promote branch
|
|
2573
|
+
// actually writes (`cited_count + 1`, `uncited_streak = 0`); `importance` is still
|
|
2574
|
+
// SELECTed and printed, so a reader can see it is unrelated.
|
|
2543
2575
|
const promoted = db.prepare(`
|
|
2544
2576
|
SELECT id, project, type, title, importance, cited_count
|
|
2545
2577
|
FROM observations
|
|
2546
|
-
WHERE
|
|
2578
|
+
WHERE cited_count >= 1 AND COALESCE(uncited_streak, 0) = 0
|
|
2547
2579
|
AND ${liveObsFilterSql('')}
|
|
2548
2580
|
ORDER BY cited_count DESC
|
|
2549
2581
|
LIMIT 10
|
|
@@ -2665,19 +2697,23 @@ function cmdCitationStats(db, args) {
|
|
|
2665
2697
|
}
|
|
2666
2698
|
}
|
|
2667
2699
|
out('');
|
|
2668
|
-
out('Active decay queue (uncited_streak >= 2, next miss →
|
|
2700
|
+
out('Active decay queue (uncited_streak >= 2, next miss → rollover):');
|
|
2669
2701
|
if (decayQueue.length === 0) out(' (none)');
|
|
2670
2702
|
for (const r of decayQueue) {
|
|
2671
2703
|
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} imp=${r.importance} streak=${r.uncited_streak}`);
|
|
2672
2704
|
}
|
|
2673
2705
|
out('');
|
|
2674
|
-
out('Recently
|
|
2706
|
+
out('Recently cited (cited_count >= 1, streak reset; importance unaffected):');
|
|
2675
2707
|
if (promoted.length === 0) out(' (none)');
|
|
2676
2708
|
for (const r of promoted) {
|
|
2677
2709
|
out(` #${r.id} [${r.type}] ${(r.title || '').slice(0, 60)} cited ${r.cited_count}x`);
|
|
2678
2710
|
}
|
|
2679
2711
|
out('');
|
|
2680
|
-
|
|
2712
|
+
// D#179/D#198: demoted_at now stamps the UNCITED-STREAK ROLLOVER, which no
|
|
2713
|
+
// longer lowers importance. The old label ("importance ↓") would describe a
|
|
2714
|
+
// write that stopped happening while the column beside it kept printing the
|
|
2715
|
+
// row's unchanged value — a caption contradicting its own table.
|
|
2716
|
+
out(`Recently rolled over (last ${days}d, uncited streak reset; importance unaffected):`);
|
|
2681
2717
|
if (demoted.length === 0) out(' (none)');
|
|
2682
2718
|
for (const r of demoted) {
|
|
2683
2719
|
const ago = Math.round((Date.now() - r.demoted_at) / DAY_MS);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.88.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.88.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
package/scoring-sql.mjs
CHANGED
|
@@ -185,11 +185,21 @@ export function notLowSignalTitleClause(alias = 'o') {
|
|
|
185
185
|
//
|
|
186
186
|
// Closes the citation-decay → ranking loop. The Stop hook citation-decay
|
|
187
187
|
// already maintains cited_count (Promote on cite) and uncited_streak (bump on
|
|
188
|
-
// uncited; reset on cite or
|
|
188
|
+
// uncited; reset on cite or rollover-at-3). Before A1, both columns affected
|
|
189
189
|
// only the importance ±1 dial — through `(0.5 + 0.5·importance)` that's a
|
|
190
190
|
// ≤2× swing and saturates fast. This factor lets ranking respond directly to
|
|
191
191
|
// observed agent behavior on the obs itself.
|
|
192
192
|
//
|
|
193
|
+
// D#179/D#198 made this factor the ONLY thing citation-decay feeds: that loop
|
|
194
|
+
// no longer writes `importance` at all. The reason is that importance is not a
|
|
195
|
+
// ranking dial — every injection surface gates candidacy on it, so moving it
|
|
196
|
+
// changed WHO IS IN the pool rather than where they ranked. This clause is the
|
|
197
|
+
// right home for the signal precisely because it is bounded and pure-ranking:
|
|
198
|
+
// a mis-read citation costs at most a 3.0× / 0.4× rank shift and can never
|
|
199
|
+
// evict a row. (A second, independent citation → importance path still exists
|
|
200
|
+
// via bumpCitationAccess → access_count → the `boost` maintain op; see obs
|
|
201
|
+
// #10911. It is out of this clause's scope and is NOT closed.)
|
|
202
|
+
//
|
|
193
203
|
// Formula: clamp(0.4, 3.0, 1 + 0.2·cited_count − 0.25·uncited_streak)
|
|
194
204
|
// Distribution:
|
|
195
205
|
// cited=0, streak=0 → 1.0 (fresh obs, neutral)
|
|
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, mkdirSync } from 'fs';
|
|
|
8
8
|
import { basename, join } from 'path';
|
|
9
9
|
import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
|
|
10
10
|
import { atomicWriteFileSync } from '../lib/atomic-write.mjs';
|
|
11
|
-
import { injectedIdsFileName, injectedIdKey } from '../lib/injected-ids.mjs';
|
|
11
|
+
import { injectedIdsFileName, injectedIdKey, EVENT_ID_PREFIX } from '../lib/injected-ids.mjs';
|
|
12
12
|
import { liveObsFilterSql } from '../lib/inject-search-core.mjs';
|
|
13
13
|
import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
|
|
14
14
|
import { recordHookError } from '../lib/hook-telemetry.mjs';
|
|
@@ -660,17 +660,36 @@ try {
|
|
|
660
660
|
if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
|
|
661
661
|
if (hasLessons) {
|
|
662
662
|
lines.push(`[mem] Lessons for ${fname}:`);
|
|
663
|
+
// D#202: this block merges TWO TABLES and rendered both with a bare `#NN`.
|
|
664
|
+
// lib/events-injection.mjs already established the `E#` prefix for exactly
|
|
665
|
+
// this reason, and its header even enumerates the extractors the prefix
|
|
666
|
+
// protects — FYI, memory-context, error-recall. It does not name THIS face,
|
|
667
|
+
// which is the one that was breaking the invariant.
|
|
668
|
+
//
|
|
669
|
+
// Measured on the live metrics log (4227 `pretool_recall` firings,
|
|
670
|
+
// 2026-07-18 -> 2026-09-02): 44.9% of the rows injected here are
|
|
671
|
+
// event-sourced and 40.2% of firings inject events only. Two costs:
|
|
672
|
+
// * a reader cannot tell which table to follow an id into — a
|
|
673
|
+
// `--supersedes` or `mem_get` on one fails for no visible reason;
|
|
674
|
+
// * load-bearing: extractInjectedFromPreToolUse reads these ids into the
|
|
675
|
+
// citation-decay DENOMINATOR, and applyCitationDecay resolves them
|
|
676
|
+
// against `observations` alone, so an event id colliding with a live
|
|
677
|
+
// SAME-PROJECT observation streaked or promoted an unrelated memory.
|
|
678
|
+
// 198 of 5476 injectable events (3.6%) sit in that position.
|
|
679
|
+
// The `E#` prefix closes the second by construction: INJECTED_ROW_RE
|
|
680
|
+
// anchors `#` after at most six spaces, so `E#` cannot match it.
|
|
663
681
|
for (const r of allRows) {
|
|
682
|
+
const idTag = `${r.src === 'evt' ? EVENT_ID_PREFIX : '#'}${r.id}`;
|
|
664
683
|
if (r.lesson_learned) {
|
|
665
684
|
const lesson = r.lesson_learned.length > LESSON_MAX
|
|
666
685
|
? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
|
|
667
686
|
: r.lesson_learned;
|
|
668
|
-
lines.push(`
|
|
687
|
+
lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
|
|
669
688
|
} else {
|
|
670
689
|
const title = (r.title || '').length > LESSON_MAX
|
|
671
690
|
? r.title.slice(0, LESSON_MAX - 3) + '...'
|
|
672
691
|
: (r.title || '');
|
|
673
|
-
lines.push(`
|
|
692
|
+
lines.push(` ${idTag} [${r.type}] ${neutralizeContextDelimiters(title)}`);
|
|
674
693
|
}
|
|
675
694
|
}
|
|
676
695
|
// v2.98 salience: Edit/Write is the action point — close the block with an
|
package/server.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { ensureRegistryDb, collectRegistryStats, listResourcesRanked, formatRegi
|
|
|
50
50
|
import { IMPORT_STRING_FIELDS, importResource, removeResource, reindexResources } from './lib/registry-core.mjs';
|
|
51
51
|
import { searchResources } from './registry-retriever.mjs';
|
|
52
52
|
import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
|
|
53
|
-
import { saveObservation } from './lib/save-observation.mjs';
|
|
53
|
+
import { saveObservation, formatSupersedeSkipped } from './lib/save-observation.mjs';
|
|
54
54
|
import { applyObsUpdate } from './lib/observation-write.mjs';
|
|
55
55
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
56
56
|
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
@@ -58,7 +58,7 @@ import { recallByFile } from './lib/recall-core.mjs';
|
|
|
58
58
|
import { fetchRecent } from './lib/recent-core.mjs';
|
|
59
59
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
60
60
|
import {
|
|
61
|
-
insertDeferred, listOpenWithOrdinal, dropDeferred,
|
|
61
|
+
insertDeferred, listOpenWithOrdinal, dropDeferred, formatDropReasonHint,
|
|
62
62
|
resolveDeferredIds, closeDeferredItems,
|
|
63
63
|
getDeferredByIds, formatDeferredDetail,
|
|
64
64
|
searchDeferredWork, formatDeferredSearchTrailer,
|
|
@@ -853,7 +853,9 @@ server.registerTool(
|
|
|
853
853
|
// Resolve INSIDE tx + after dedup check so duplicate replays don't throw on
|
|
854
854
|
// already-closed items. Mirrors mem-cli.mjs cmdSave shape.
|
|
855
855
|
if (args.closes_deferred && args.closes_deferred.length > 0) {
|
|
856
|
-
|
|
856
|
+
// D#195: 'dropped' is closable by the close verb (kept in sync with
|
|
857
|
+
// mem-cli.mjs cmdSave — same policy, both faces).
|
|
858
|
+
closesIds = resolveDeferredIds(db, project, args.closes_deferred, { allowStatuses: ['open', 'dropped'] });
|
|
857
859
|
closeDeferredItems(db, closesIds, r.id);
|
|
858
860
|
}
|
|
859
861
|
return r;
|
|
@@ -869,7 +871,10 @@ server.registerTool(
|
|
|
869
871
|
}
|
|
870
872
|
|
|
871
873
|
if (result.kind === 'duplicate') {
|
|
872
|
-
|
|
874
|
+
// D#201: this branch returns before the note below, so it renders its own.
|
|
875
|
+
const dupSkip = formatSupersedeSkipped(result.supersedeSkipped);
|
|
876
|
+
const dupText = `Skipped: similar to existing #${result.existingId} in project "${project}". Use mem_get(ids=[${result.existingId}]) to review.`;
|
|
877
|
+
return { content: [{ type: 'text', text: dupSkip ? `${dupText}\n${dupSkip}` : dupText }] };
|
|
873
878
|
}
|
|
874
879
|
|
|
875
880
|
const lessonNote = result.lessonCaptured ? ` 💡lesson captured` : '';
|
|
@@ -884,7 +889,10 @@ server.registerTool(
|
|
|
884
889
|
// every save) — fill-only-empty, so an agent acting on the nudge still wins.
|
|
885
890
|
const enrichNote = shouldQueueSaveEnrich(result) && queueSaveEnrich(result.id)
|
|
886
891
|
? ' (background enrichment queued)' : '';
|
|
887
|
-
|
|
892
|
+
// D#201: on its own line rather than inside the success sentence.
|
|
893
|
+
const skipNote = formatSupersedeSkipped(result.supersedeSkipped);
|
|
894
|
+
const savedText = `Saved as observation #${result.id} [${result.type}] in project "${project}".${lessonNote}${closedNote}${supersededNote}${enrichNote}${nudge}`;
|
|
895
|
+
return { content: [{ type: 'text', text: skipNote ? `${savedText}\n${skipNote}` : savedText }] };
|
|
888
896
|
})
|
|
889
897
|
);
|
|
890
898
|
|
|
@@ -960,7 +968,11 @@ server.registerTool(
|
|
|
960
968
|
if (r.changed === 0) {
|
|
961
969
|
return { content: [{ type: 'text', text: `D#${realId} was not in 'open' status — drop is a no-op.` }] };
|
|
962
970
|
}
|
|
963
|
-
|
|
971
|
+
// D#195 (c): same advisory as the CLI's `defer drop`, so an agent reaching
|
|
972
|
+
// this through MCP gets the same steer toward `mem_save(closes_deferred)`.
|
|
973
|
+
const hint = formatDropReasonHint(args.reason);
|
|
974
|
+
const dropText = `Dropped D#${realId} in project "${project}". Reason: ${args.reason}`;
|
|
975
|
+
return { content: [{ type: 'text', text: hint ? `${dropText}\n${hint}` : dropText }] };
|
|
964
976
|
})
|
|
965
977
|
);
|
|
966
978
|
|