claude-mem-lite 5.4.0 → 5.5.1
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 -0
- package/deep-search.mjs +11 -5
- package/hook-optimize.mjs +289 -36
- package/lib/search-core.mjs +64 -0
- package/mem-cli.mjs +18 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +3 -1
- package/scoring-sql.mjs +46 -16
- package/server.mjs +15 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "5.
|
|
13
|
+
"version": "5.5.1",
|
|
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": "5.
|
|
3
|
+
"version": "5.5.1",
|
|
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
|
@@ -802,6 +802,7 @@ benchmark and A/B harness are calibrated against — changing them invalidates t
|
|
|
802
802
|
| `CLAUDE_MEM_CJK_PREC_MIN` | Precision floor for CJK segmentation candidates. | `0.2` |
|
|
803
803
|
| `CLAUDE_MEM_AUTO_DEEP` | `0` disables automatic deep-search escalation (one Haiku call rewriting a weak query into keyword/concept/HyDE variants). Explicit `deep: true` still works. | _(auto)_ |
|
|
804
804
|
| `CLAUDE_MEM_DEEP_DISCLOSURE` | `off` suppresses the one-line caveat appended to a multi-variant deep result. The caveat exists because deep search fills the page even when the corpus cannot answer — measured at 10 of 10 slots on queries whose answers had been removed (`benchmark/deep-search-holdout.mjs`) — and `deep` is AUTO by default on the MCP surface, i.e. it escalates precisely when the honest answer is "nothing". It does not change retrieval, ranking, or which rows are returned. | _(on)_ |
|
|
805
|
+
| `CLAUDE_MEM_REACH_DISCLOSURE` | `off` suppresses the one-line note that fires when a search's reported `total` exceeds what its pagination can hand back. The candidate pool is sized from `limit` alone and deliberately does not grow with `offset` (D#30 — an offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and gapped), while `total` is the full match count. Measured on a 128-row corpus: at the default limit of 20 the last non-empty offset is 59, so 60 of 128 rows are unreachable at any offset. The note reports that; it does not change retrieval, ranking, or which rows are returned. | _(on)_ |
|
|
805
806
|
| `CLAUDE_MEM_AUTO_DEEP_CLI` | `0` disables the same auto-escalation on the CLI path only. | _(auto)_ |
|
|
806
807
|
| `CLAUDE_MEM_VECTORS` | `1` re-enables the persisted TF-IDF vector arm (off by default; also needs a vector rebuild via `maintain`). | _(off)_ |
|
|
807
808
|
| `CLAUDE_MEM_SCOPE_FILTER` | `1` stops environment-scoped observations from firing on file-triggered recall. They stay reachable via search. **Leave it off**: on the face it gates, `environment` is not the low-relevance class its premise assumes — it cites at least as well as `project` (47.5% vs 44.3%, intervals overlapping), and an earlier measurement left 173 recall groups empty with it on. | _(off)_ |
|
package/deep-search.mjs
CHANGED
|
@@ -191,12 +191,18 @@ export function resolveDeepMode(explicitDeep, { surface, env = process.env } = {
|
|
|
191
191
|
* D#3. benchmark/deep-search-holdout.mjs asks the suite's own queries of a corpus with
|
|
192
192
|
* their relevant_ids deleted, so the correct answer is zero rows and every returned row is
|
|
193
193
|
* a false positive by construction. It reads mean FP@10 = 10.00 across 12/12 queries: deep
|
|
194
|
-
* fills every slot, every time.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
194
|
+
* fills every slot, every time. THE FLOOD IS NOT THE PARAPHRASE UNION, and an earlier
|
|
195
|
+
* version of this paragraph said it was ("the single-query baseline returns 1-2 rows on the
|
|
196
|
+
* same negatives"). Measured 2026-09-07, same fixture: the single-variant baseline already
|
|
197
|
+
* returns mean 9.42 of 10 (min 5, max 10, n=12), so fusion adds about half a slot to a page
|
|
198
|
+
* that was already full. A counterfactual names the real source — disabling the AND->OR
|
|
199
|
+
* fallback in search-engine.mjs takes mean FP@10 from 10.00 to 0.08, with 0/12 queries
|
|
200
|
+
* flooded instead of 12/12. Read that as a MECHANISM PROBE, not a candidate fix: the same
|
|
201
|
+
* fallback IS the vocab-mismatch recall win, and suppressing it on rewrites takes deep R@10
|
|
202
|
+
* from 0.7383 to 0.3962. Three gates were tested against both arms and rejected. rrfFuseN
|
|
199
203
|
* fuses by RANK, so no magnitude signal survives the merge for a downstream floor to read.
|
|
204
|
+
* The same counterfactual explains why auto-escalation never fires here (D#8): with the
|
|
205
|
+
* fallback off, plain hits drop to min 0 and the escalation reach goes 0/12 -> 12/12.
|
|
200
206
|
*
|
|
201
207
|
* The discrimination is not available at this layer, so the honest move is to hand the
|
|
202
208
|
* caller what the caller cannot otherwise see. Two things were missing:
|
package/hook-optimize.mjs
CHANGED
|
@@ -102,7 +102,7 @@ export function rebuildVector(db, obsId, textPartsOrRow) {
|
|
|
102
102
|
*
|
|
103
103
|
* @param {object} db better-sqlite3 database handle
|
|
104
104
|
* @param {number} limit max candidates to return
|
|
105
|
-
* @param {{ scope?: 'narrow' | 'wide' | 'aliases' | 'scopes', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
|
|
105
|
+
* @param {{ scope?: 'narrow' | 'wide' | 'aliases' | 'scopes' | 'concepts', project?: string }} [opts] Optional project filter (e.g. inferProject()-resolved name) narrows candidates to a single project — opt-in to preserve prior cross-project default.
|
|
106
106
|
*/
|
|
107
107
|
export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', project } = {}) {
|
|
108
108
|
const projectClause = project ? 'AND project = ?' : '';
|
|
@@ -129,7 +129,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
129
129
|
${projectClause}
|
|
130
130
|
ORDER BY
|
|
131
131
|
CASE WHEN lesson_learned IS NOT NULL AND lesson_learned != '' THEN 0 ELSE 1 END,
|
|
132
|
-
created_at_epoch DESC
|
|
132
|
+
created_at_epoch DESC,
|
|
133
|
+
id DESC
|
|
133
134
|
LIMIT ?
|
|
134
135
|
`);
|
|
135
136
|
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
@@ -149,12 +150,48 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
149
150
|
AND LENGTH(COALESCE(narrative, '')) > 100
|
|
150
151
|
AND ${notLowSignalTitleClause('')}
|
|
151
152
|
${projectClause}
|
|
152
|
-
ORDER BY created_at_epoch DESC
|
|
153
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
154
|
+
LIMIT ?
|
|
155
|
+
`);
|
|
156
|
+
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
157
|
+
}
|
|
158
|
+
if (scope === 'concepts') {
|
|
159
|
+
// D#6 concepts backfill: substantive rows with no concepts, REGARDLESS of lesson,
|
|
160
|
+
// aliases or scope. Same shape and same reason as the two pools above, one column
|
|
161
|
+
// over — and this one exists because the P1-2 fix created it. save-enrich fires on
|
|
162
|
+
// every successful manual save and writes search_aliases (always) + lesson_learned
|
|
163
|
+
// (bugfix/decision) + scope, which are precisely narrow's, wide's, aliases' and
|
|
164
|
+
// scopes' predicates, so a save-enriched row matches NONE of the four and never
|
|
165
|
+
// receives concepts. Measured on the real DB 2026-09-07: 14/14 live observations
|
|
166
|
+
// conceptless, 14/14 with aliases, 0/14 with optimized_at, all four pools empty.
|
|
167
|
+
//
|
|
168
|
+
// Keyed on `concepts` ALONE, not on concepts+facts: idempotency here is "the column
|
|
169
|
+
// this pass fills becomes non-empty", the same contract aliases and scopes carry. A
|
|
170
|
+
// facts term in the predicate would re-select forever every row whose narrative
|
|
171
|
+
// yields no extractable fact.
|
|
172
|
+
//
|
|
173
|
+
// Deliberately NOT gated on optimized_at, for the reason the alias branch gives and
|
|
174
|
+
// one more: the general pass preserves-on-empty, so a re-enrich whose model returned
|
|
175
|
+
// no concepts leaves the row stamped AND conceptless. Gating on the stamp would
|
|
176
|
+
// strand exactly those rows — the R10 P2-2 shape, where one pass's bookkeeping
|
|
177
|
+
// evicts a row from a backfill it never visited.
|
|
178
|
+
const stmt = db.prepare(`
|
|
179
|
+
SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project
|
|
180
|
+
FROM observations
|
|
181
|
+
WHERE ${liveObsFilterSql('')}
|
|
182
|
+
AND (concepts IS NULL OR concepts = '')
|
|
183
|
+
AND LENGTH(COALESCE(narrative, '')) > 100
|
|
184
|
+
AND ${notLowSignalTitleClause('')}
|
|
185
|
+
${projectClause}
|
|
186
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
153
187
|
LIMIT ?
|
|
154
188
|
`);
|
|
155
189
|
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
156
190
|
}
|
|
157
191
|
if (scope === 'wide') {
|
|
192
|
+
// This pool's ORDER BY leads with a CASE term and spans lines, which is exactly why the
|
|
193
|
+
// first pass of the D#9 tiebreaker missed it. The full note lives in the default pool at
|
|
194
|
+
// the bottom of this function -- read it before touching any ORDER BY here.
|
|
158
195
|
const stmt = db.prepare(`
|
|
159
196
|
SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
|
|
160
197
|
FROM observations
|
|
@@ -167,7 +204,8 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
167
204
|
${projectClause}
|
|
168
205
|
ORDER BY
|
|
169
206
|
CASE type WHEN 'decision' THEN 0 WHEN 'bugfix' THEN 1 WHEN 'refactor' THEN 2 ELSE 3 END,
|
|
170
|
-
created_at_epoch DESC
|
|
207
|
+
created_at_epoch DESC,
|
|
208
|
+
id DESC
|
|
171
209
|
LIMIT ?
|
|
172
210
|
`);
|
|
173
211
|
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
@@ -182,7 +220,36 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
182
220
|
AND search_aliases IS NULL
|
|
183
221
|
AND optimized_at IS NULL
|
|
184
222
|
${projectClause}
|
|
185
|
-
|
|
223
|
+
-- D#9: the id term is a REACHABILITY guard, not cosmetics. Every pool in this file is
|
|
224
|
+
-- ORDER BY created_at_epoch DESC LIMIT n feeding JS-side work, so a tie AT THE
|
|
225
|
+
-- BOUNDARY decides pool MEMBERSHIP. Measured 2026-09-07: two inserts land in the same
|
|
226
|
+
-- millisecond 272/300 times, and on a tie SQLite returns ASCENDING rowid -- the exact
|
|
227
|
+
-- opposite of the "newest first" this clause states -- so the newest rows fell out of
|
|
228
|
+
-- the pool whenever the clock had not ticked. SQLite's tie order is deterministic here
|
|
229
|
+
-- (8 rows on one epoch, 200 queries, one returned order), so this is not defending
|
|
230
|
+
-- against a varying plan; it is making the stated order total.
|
|
231
|
+
--
|
|
232
|
+
-- TWO DIFFERENT COUNTS, AND AN EARLIER DRAFT OF THIS COMMENT CONFLATED THEM. This
|
|
233
|
+
-- function, findReenrichCandidates, holds FIVE pools -- five db.prepare blocks:
|
|
234
|
+
-- 'scopes', 'aliases', 'concepts', 'wide', and this default 'narrow' one. The FILE
|
|
235
|
+
-- holds SEVEN "ORDER BY ... created_at_epoch DESC" sites: those five plus
|
|
236
|
+
-- extractUniqueConcepts and findMergeCandidates. All seven now carry the id term.
|
|
237
|
+
-- DO NOT GREP FOR THE ONE-LINE FORM: two of the seven ('scopes' and 'wide') lead with
|
|
238
|
+
-- a CASE ... term and span several lines, so grepping the joined
|
|
239
|
+
-- "created_at_epoch DESC, id DESC" spelling sees only five. That is how the first pass
|
|
240
|
+
-- read six and shipped 'wide' untiebroken -- the pool the DAILY unattended path passes
|
|
241
|
+
-- explicitly, on a budget of 6, where a boundary tie decides which rows reach the LLM
|
|
242
|
+
-- on a given run. Caught later by a test driving scope 'wide'; the original boundary
|
|
243
|
+
-- case drove only 'narrow', so nothing went red.
|
|
244
|
+
-- NOT FIXED, AND NAMED SO THE COMPLETENESS CLAIM IS TRUE: findSmartCompressCandidates
|
|
245
|
+
-- carries an eighth ordering, "ORDER BY project, created_at_epoch" -- ASCENDING, no id
|
|
246
|
+
-- term, no LIMIT. It is outside the seven by construction and is left alone under Iron
|
|
247
|
+
-- Law #1: it feeds clusterForCompression, whose vector branch seeds clusters in SQL
|
|
248
|
+
-- order, so a tie could move cluster membership -- but that branch needs
|
|
249
|
+
-- CLAUDE_MEM_VECTORS=1 and is off by default, and no failing case has been built.
|
|
250
|
+
-- Unjudged, not cleared.
|
|
251
|
+
-- This comment is INSIDE a template literal, so it must never contain a backtick.
|
|
252
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
186
253
|
LIMIT ?
|
|
187
254
|
`);
|
|
188
255
|
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
@@ -298,9 +365,23 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
298
365
|
// scope rides this call for free (D#135 P3). COALESCE, not a plain set:
|
|
299
366
|
// an omitted or off-enum value normalizes to null and must not erase a
|
|
300
367
|
// classification an earlier face already wrote.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
368
|
+
// D#12: the live-row guard, on the WHERE and not merely on the SELECT that chose
|
|
369
|
+
// the row. The Haiku call above is up to BG_LLM_TIMEOUT_MS (45 s), long enough for
|
|
370
|
+
// a concurrent hook to supersede or auto-compress this row — R10 P3-3's finding,
|
|
371
|
+
// fixed then on the general branch only and carried by the concepts branch since
|
|
372
|
+
// D#6. This was the one branch of the four without it. `changes === 0` is a SKIP,
|
|
373
|
+
// not a success: it must not count as processed and must not rebuild a vector for
|
|
374
|
+
// a row that is no longer live.
|
|
375
|
+
const res = db
|
|
376
|
+
.prepare(
|
|
377
|
+
`UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope)
|
|
378
|
+
WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
379
|
+
)
|
|
380
|
+
.run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
|
|
381
|
+
if (res.changes === 0) {
|
|
382
|
+
skipped++;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
304
385
|
// Refresh the TF-IDF vector from the just-updated FTS text so the new
|
|
305
386
|
// aliases reach the vector arm too — the narrow/wide branch rebuilds, this
|
|
306
387
|
// one must as well. No-ops when the vector arm is off / vocab unbuilt.
|
|
@@ -308,6 +389,69 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
308
389
|
processed++;
|
|
309
390
|
continue;
|
|
310
391
|
}
|
|
392
|
+
if (scope === 'concepts') {
|
|
393
|
+
// Concepts-only backfill (D#6). Writes concepts + facts and APPENDS them to the
|
|
394
|
+
// existing FTS text — never rebuilds it, for the reason the alias branch gives:
|
|
395
|
+
// a rebuild from concepts/facts drops the original narrative and alias terms and
|
|
396
|
+
// regresses recall. Never touches the user's curated title / narrative / lesson /
|
|
397
|
+
// type / importance, and never stamps optimized_at, so the wide pass keeps its
|
|
398
|
+
// own candidates exactly as the alias and scopes passes leave them.
|
|
399
|
+
const conceptsPrompt = `Extract search concepts and concrete facts from this coding memory. Return ONLY valid JSON, no markdown fences.
|
|
400
|
+
|
|
401
|
+
Title: ${truncate(cand.title || '(untitled)', 200)}
|
|
402
|
+
Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
403
|
+
|
|
404
|
+
JSON: {"concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"]}
|
|
405
|
+
concepts: 3-8 short keyword phrases naming what this memory is ABOUT (systems, components, error classes, techniques).
|
|
406
|
+
facts: 1-4 specific, checkable statements the narrative actually asserts. Omit rather than invent.`;
|
|
407
|
+
const parsed = await callModelJSONAsync(conceptsPrompt, 'haiku', {
|
|
408
|
+
timeout: BG_LLM_TIMEOUT_MS,
|
|
409
|
+
maxTokens: 300,
|
|
410
|
+
});
|
|
411
|
+
const pickStrings = (v) =>
|
|
412
|
+
Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s.trim().length > 0) : [];
|
|
413
|
+
const conceptArr = pickStrings(parsed && parsed.concepts);
|
|
414
|
+
// No concepts is a SKIP, not an empty write: writing '' would leave the row in
|
|
415
|
+
// this pool forever, and the pass would burn one Haiku call per cycle on it.
|
|
416
|
+
if (!conceptArr.length) {
|
|
417
|
+
skipped++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const factArr = pickStrings(parsed && parsed.facts);
|
|
421
|
+
const conceptsOnly = conceptArr.slice(0, 10).join(' ');
|
|
422
|
+
const factsOnly = factArr.slice(0, 10).join(' ');
|
|
423
|
+
const appendedText = [
|
|
424
|
+
cand.text || '',
|
|
425
|
+
conceptsOnly,
|
|
426
|
+
factsOnly,
|
|
427
|
+
cjkBigrams(`${conceptsOnly} ${factsOnly}`),
|
|
428
|
+
]
|
|
429
|
+
.filter(Boolean)
|
|
430
|
+
.join(' ');
|
|
431
|
+
const safe = scrubRecord('observations', {
|
|
432
|
+
concepts: conceptsOnly,
|
|
433
|
+
facts: factsOnly,
|
|
434
|
+
text: appendedText,
|
|
435
|
+
});
|
|
436
|
+
// Fill-only-empty on `concepts` plus the live-row guard, both on the WHERE and
|
|
437
|
+
// not merely on the SELECT: the round-trip above is up to 45 s, long enough for a
|
|
438
|
+
// concurrent hook to supersede or compress the row (R10 P3-3) or for save-enrich
|
|
439
|
+
// to fill it. `facts` rides along with preserve-on-empty for the same reason the
|
|
440
|
+
// general pass preserves it — a partial answer must not wipe a filled column.
|
|
441
|
+
const res = db
|
|
442
|
+
.prepare(
|
|
443
|
+
`UPDATE observations SET concepts = ?, facts = COALESCE(NULLIF(?, ''), facts), text = ?
|
|
444
|
+
WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}`,
|
|
445
|
+
)
|
|
446
|
+
.run(safe.concepts, safe.facts, safe.text, cand.id);
|
|
447
|
+
if (res.changes === 0) {
|
|
448
|
+
skipped++;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
rebuildVector(db, cand.id, [safe.text]);
|
|
452
|
+
processed++;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
311
455
|
const prompt = `Re-enrich this observation with structured metadata. Return ONLY valid JSON, no markdown fences.
|
|
312
456
|
|
|
313
457
|
Title: ${truncate(cand.title || '(untitled)', 200)}
|
|
@@ -336,9 +480,24 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
336
480
|
// hide a real observation until manual surgery. In wide scope, fall through and let
|
|
337
481
|
// clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
|
|
338
482
|
if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
483
|
+
// D#12, and this one is not a stale-write guard — it is a POINTER guard.
|
|
484
|
+
// `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
|
|
485
|
+
// concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
|
|
486
|
+
// call, it holds a POSITIVE keeper id; overwriting that with -1 does not merely
|
|
487
|
+
// stamp a dead row, it destroys the link — lib/maintain-core.mjs:316 recovers
|
|
488
|
+
// orphans with `compressed_into > 0`, and recoverChildrenOf follows the same id.
|
|
489
|
+
// The sibling write in lib/maintain-core.mjs:631 already carries this predicate,
|
|
490
|
+
// so the codebase had decided the question and this site had not been updated.
|
|
491
|
+
const res = db
|
|
492
|
+
.prepare(
|
|
493
|
+
`UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
|
|
494
|
+
WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
495
|
+
)
|
|
496
|
+
.run(Date.now(), cand.id);
|
|
497
|
+
if (res.changes === 0) {
|
|
498
|
+
skipped++;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
342
501
|
processed++;
|
|
343
502
|
continue;
|
|
344
503
|
}
|
|
@@ -507,7 +666,7 @@ export function extractUniqueConcepts(db, limit = 500, { project } = {}) {
|
|
|
507
666
|
WHERE ${liveObsFilterSql('')}
|
|
508
667
|
AND concepts IS NOT NULL AND concepts != ''
|
|
509
668
|
${projectClause}
|
|
510
|
-
ORDER BY created_at_epoch DESC
|
|
669
|
+
ORDER BY created_at_epoch DESC, id DESC -- D#9: total order, see findReenrichCandidates
|
|
511
670
|
LIMIT 2000
|
|
512
671
|
`);
|
|
513
672
|
const rows = project ? stmt.all(project) : stmt.all();
|
|
@@ -682,7 +841,10 @@ export function findMergeCandidates(db, maxClusters = 5, { project } = {}) {
|
|
|
682
841
|
AND title IS NOT NULL AND title != ''
|
|
683
842
|
AND created_at_epoch > ?
|
|
684
843
|
${projectClause}
|
|
685
|
-
|
|
844
|
+
-- D#9: this pool's head is what the keeper reduce falls back to on a full tie, so an
|
|
845
|
+
-- arbitrary tie order decides WHICH DUPLICATE SURVIVES a merge. Same-episode rows are
|
|
846
|
+
-- exactly that tie (same project, same importance, access_count 0, same millisecond).
|
|
847
|
+
ORDER BY created_at_epoch DESC, id DESC
|
|
686
848
|
LIMIT 200
|
|
687
849
|
`);
|
|
688
850
|
const rows = project ? stmt.all(cutoff, project) : stmt.all(cutoff);
|
|
@@ -751,14 +913,27 @@ Return ONLY valid JSON:
|
|
|
751
913
|
});
|
|
752
914
|
if (!parsed || !parsed.should_merge) return { merged: false };
|
|
753
915
|
|
|
754
|
-
// Keeper = highest importance, then highest access_count. Previously
|
|
755
|
-
// alone, so a critical (importance=3) but never-accessed observation lost
|
|
756
|
-
// role to a trivial (importance=1) accessed one and was compressed away.
|
|
916
|
+
// Keeper = highest importance, then highest access_count, then highest id. Previously
|
|
917
|
+
// access_count alone, so a critical (importance=3) but never-accessed observation lost
|
|
918
|
+
// the keeper role to a trivial (importance=1) accessed one and was compressed away.
|
|
919
|
+
//
|
|
920
|
+
// D#9: the third term is the one that makes this TOTAL. Without it a full tie fell
|
|
921
|
+
// through to `cluster[0]` — the SQL head — and same-episode duplicates are exactly a
|
|
922
|
+
// full tie: same project, same importance, access_count 0, and a created_at_epoch in
|
|
923
|
+
// the same millisecond 272 times out of 300 (measured 2026-09-07). On a tie SQLite
|
|
924
|
+
// returns ASCENDING rowid while an untied pool returns the newest first, so which
|
|
925
|
+
// duplicate survived flipped on whether two writes straddled a millisecond. Ordering
|
|
926
|
+
// the pool alone would not have been enough: this reduce is exported to callers that
|
|
927
|
+
// build their own cluster, so it has to be total on its own. Highest id = written last
|
|
928
|
+
// = the version whose content the merged summary should be anchored on.
|
|
757
929
|
const keeper = cluster.reduce((best, o) => {
|
|
758
930
|
const oi = o.importance || 1,
|
|
759
931
|
bi = best.importance || 1;
|
|
760
932
|
if (oi !== bi) return oi > bi ? o : best;
|
|
761
|
-
|
|
933
|
+
const oa = o.access_count || 0,
|
|
934
|
+
ba = best.access_count || 0;
|
|
935
|
+
if (oa !== ba) return oa > ba ? o : best;
|
|
936
|
+
return (o.id || 0) > (best.id || 0) ? o : best;
|
|
762
937
|
}, cluster[0]);
|
|
763
938
|
const others = cluster.filter((o) => o.id !== keeper.id);
|
|
764
939
|
// Floor the merged importance at the cluster max — merging must never silently
|
|
@@ -1016,6 +1191,52 @@ export function clusterForCompression(candidates, db) {
|
|
|
1016
1191
|
return clusters;
|
|
1017
1192
|
}
|
|
1018
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* The smart-compress prompt. Exported so a RULER can measure the shipped text.
|
|
1196
|
+
*
|
|
1197
|
+
* Extracted for benchmark/compress-veto-rate.mjs (D#10). It has to be one string in one
|
|
1198
|
+
* place: a ruler that retypes the prompt measures its own copy, which is exactly how
|
|
1199
|
+
* tests/handoff-simulation.test.mjs came to assert on a re-implementation while the real
|
|
1200
|
+
* hook emitted a block no user had ever seen.
|
|
1201
|
+
*
|
|
1202
|
+
* D#10. This prompt used to OPEN with "Summarize these related code memory observations",
|
|
1203
|
+
* asserting the premise it should have been testing, and the only bail was a missing title
|
|
1204
|
+
* — so the model had no way to refuse. The sibling executeMergeCluster has had
|
|
1205
|
+
* `should_merge` since it was written; these two LLM cluster paths disagreed about whether
|
|
1206
|
+
* the model may say no, and this is the one that HIDES its inputs (compressed_into removes
|
|
1207
|
+
* them from every injection and search surface and puts them out of recoverBuriedLessons'
|
|
1208
|
+
* reach).
|
|
1209
|
+
*
|
|
1210
|
+
* It matters because the upstream relatedness check is not always on:
|
|
1211
|
+
* clusterForCompression only computes cosine similarity when getVocabulary returns a
|
|
1212
|
+
* vocabulary, and that is null whenever the vector arm is off — which is the default
|
|
1213
|
+
* (CLAUDE_MEM_VECTORS !== '1'). The else branch groups by a 14-day window ALONE. Measured
|
|
1214
|
+
* with a control arm 2026-09-07: three unrelated observations over 12 days form 1 cluster
|
|
1215
|
+
* with the arm off and 0 with it on. Until that branch is decided (D#10 option a), this
|
|
1216
|
+
* veto is the only thing standing between the heuristic and an unattended write that hides
|
|
1217
|
+
* real rows.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {Array<object>} observations cluster members
|
|
1220
|
+
* @returns {string}
|
|
1221
|
+
*/
|
|
1222
|
+
export function buildCompressPrompt(observations) {
|
|
1223
|
+
const obsDescriptions = observations
|
|
1224
|
+
.map(
|
|
1225
|
+
(o, i) =>
|
|
1226
|
+
`${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`,
|
|
1227
|
+
)
|
|
1228
|
+
.join('\n');
|
|
1229
|
+
|
|
1230
|
+
return `These code memory observations were grouped by a heuristic that may be wrong. FIRST decide whether they are one story worth collapsing into a single memory. Return ONLY valid JSON.
|
|
1231
|
+
|
|
1232
|
+
Observations:
|
|
1233
|
+
${obsDescriptions}
|
|
1234
|
+
|
|
1235
|
+
JSON: {"should_compress":true,"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}
|
|
1236
|
+
should_compress: false when these are about unrelated systems, files or problems, or when a merged summary would lose more than it saves. Compressing HIDES the originals from search, so refuse when in doubt. When false, the other fields are ignored.
|
|
1237
|
+
When true: preserve all important decisions, lessons, and specific facts.`;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1019
1240
|
export async function executeSmartCompressCluster(db, observations, project) {
|
|
1020
1241
|
if (observations.length < 3) return { compressed: false };
|
|
1021
1242
|
|
|
@@ -1023,25 +1244,18 @@ export async function executeSmartCompressCluster(db, observations, project) {
|
|
|
1023
1244
|
if (!gotSlot) return { compressed: false };
|
|
1024
1245
|
|
|
1025
1246
|
try {
|
|
1026
|
-
const
|
|
1027
|
-
.map(
|
|
1028
|
-
(o, i) =>
|
|
1029
|
-
`${i + 1}. [${o.type || 'change'}] "${truncate(o.title || '(untitled)', 200)}" — ${truncate(o.narrative || '(no narrative)', 500)}${o.lesson_learned ? ` | Lesson: ${truncate(o.lesson_learned, 200)}` : ''}`,
|
|
1030
|
-
)
|
|
1031
|
-
.join('\n');
|
|
1032
|
-
|
|
1033
|
-
const prompt = `Summarize these related code memory observations into ONE comprehensive summary. Preserve all important decisions, lessons, and specific facts. Return ONLY valid JSON.
|
|
1034
|
-
|
|
1035
|
-
Observations:
|
|
1036
|
-
${obsDescriptions}
|
|
1037
|
-
|
|
1038
|
-
JSON: {"title":"descriptive summary ≤120 chars","narrative":"comprehensive summary ≤800 chars preserving key decisions and lessons","concepts":["kw1","kw2"],"facts":["all specific facts preserved"],"lesson_learned":"most important synthesized lesson or 'none'","search_aliases":["alt search 1","alt search 2"]}`;
|
|
1247
|
+
const prompt = buildCompressPrompt(observations);
|
|
1039
1248
|
|
|
1040
1249
|
const parsed = await callModelJSONAsync(prompt, 'sonnet', {
|
|
1041
1250
|
timeout: BG_LLM_TIMEOUT_MS,
|
|
1042
1251
|
maxTokens: 1000,
|
|
1043
1252
|
});
|
|
1044
|
-
|
|
1253
|
+
// Fail CLOSED, exactly as `should_merge` does: an omitted verdict refuses. The two
|
|
1254
|
+
// failure directions are not symmetric — refusing wrongly means a compression did not
|
|
1255
|
+
// happen, proceeding wrongly means unrelated observations were hidden from every
|
|
1256
|
+
// surface. On a path that hides its inputs, silence is not consent.
|
|
1257
|
+
if (!parsed || !parsed.should_compress) return { compressed: false };
|
|
1258
|
+
if (!parsed.title) return { compressed: false };
|
|
1045
1259
|
|
|
1046
1260
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
|
1047
1261
|
const title = truncate(scrubSecrets(parsed.title || ''), 120);
|
|
@@ -1193,6 +1407,11 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1193
1407
|
// lesson_learned per row, so counting by materialising was the one place this
|
|
1194
1408
|
// round pulled megabytes to print an integer. (pre-tag review NOTE 11)
|
|
1195
1409
|
const reenrichScopes = countReenrichCandidates(db, 'scopes', project);
|
|
1410
|
+
// D#6: the concepts-backfill backlog. Reported for the same reason as the three
|
|
1411
|
+
// above — a pool whose size is invisible cannot be sized for a one-shot drain
|
|
1412
|
+
// (`optimize --run --task re-enrich --scope concepts --max N`), and this pool is
|
|
1413
|
+
// the one that holds every save-enriched manual save.
|
|
1414
|
+
const reenrichConcepts = findReenrichCandidates(db, 5000, { scope: 'concepts', project }).length;
|
|
1196
1415
|
|
|
1197
1416
|
const concepts = extractUniqueConcepts(db, 500, { project });
|
|
1198
1417
|
const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
|
|
@@ -1209,6 +1428,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1209
1428
|
reenrichWide,
|
|
1210
1429
|
reenrichAliases,
|
|
1211
1430
|
reenrichScopes,
|
|
1431
|
+
reenrichConcepts,
|
|
1212
1432
|
normalize: normalizeReady ? concepts.length : 0,
|
|
1213
1433
|
normalizeGateOpen: shouldRunNormalize(project),
|
|
1214
1434
|
clusterMerge,
|
|
@@ -1240,7 +1460,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1240
1460
|
* is budgeted separately, up to the re-enrich slice again — see the rationale at
|
|
1241
1461
|
* the call site. Its calls are enum-classification only (maxTokens 60).
|
|
1242
1462
|
* @param {boolean} [opts.force=false] Bypass time-based gates (e.g. normalize interval).
|
|
1243
|
-
* @param {'narrow'|'wide'|'aliases'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
|
|
1463
|
+
* @param {'narrow'|'wide'|'aliases'|'concepts'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
|
|
1244
1464
|
* 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
|
|
1245
1465
|
* 'aliases' (P1) backfills search_aliases on substantive alias-less rows regardless
|
|
1246
1466
|
* of lesson (lesson-bearing manual saves) — adds ONLY aliases, never rewrites content.
|
|
@@ -1295,16 +1515,32 @@ export async function optimizeRun(
|
|
|
1295
1515
|
// mis-prices it by an order of magnitude.
|
|
1296
1516
|
// Cap is budget.reenrich, so the daily pass adds at most that many cheap
|
|
1297
1517
|
// classification calls and an empty pool still costs nothing.
|
|
1518
|
+
//
|
|
1519
|
+
// D#6 adds a FOURTH claimant, 'concepts', and it SHARES the aliases half
|
|
1520
|
+
// rather than taking one of its own. Sharing keeps the boundary this comment
|
|
1521
|
+
// already describes: the main scope still gets at least half the budget, so
|
|
1522
|
+
// adding a pool cannot starve the lesson enrichment that is the point of the
|
|
1523
|
+
// pass. Aliases is served FIRST out of that shared half, on a stated
|
|
1524
|
+
// ordering: an alias-less row is paraphrase-UNFINDABLE (a recall zero),
|
|
1525
|
+
// while a conceptless row is findable and merely ranks worse — measured at
|
|
1526
|
+
// +0.0846 R@10 on the benchmark fixture, which is real but is not a zero.
|
|
1527
|
+
// Both pools drain (each is idempotent via the column it fills), so the
|
|
1528
|
+
// ordering decides which drains first, not which gets served at all.
|
|
1298
1529
|
const half = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
1299
1530
|
const aliasBudget = Math.min(
|
|
1300
1531
|
half,
|
|
1301
1532
|
findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
|
|
1302
1533
|
);
|
|
1534
|
+
const conceptsBudget = Math.min(
|
|
1535
|
+
half - aliasBudget,
|
|
1536
|
+
findReenrichCandidates(db, Math.max(0, half - aliasBudget), { scope: 'concepts', project })
|
|
1537
|
+
.length,
|
|
1538
|
+
);
|
|
1303
1539
|
const scopesBudget = Math.min(
|
|
1304
1540
|
budget.reenrich,
|
|
1305
1541
|
findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
|
|
1306
1542
|
);
|
|
1307
|
-
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, {
|
|
1543
|
+
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget - conceptsBudget, {
|
|
1308
1544
|
scope: reenrichScope,
|
|
1309
1545
|
project,
|
|
1310
1546
|
});
|
|
@@ -1312,14 +1548,31 @@ export async function optimizeRun(
|
|
|
1312
1548
|
aliasBudget > 0
|
|
1313
1549
|
? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
|
|
1314
1550
|
: { processed: 0, skipped: 0 };
|
|
1551
|
+
const conceptsRes =
|
|
1552
|
+
conceptsBudget > 0
|
|
1553
|
+
? await executeReenrich(db, conceptsBudget, { scope: 'concepts', project })
|
|
1554
|
+
: { processed: 0, skipped: 0 };
|
|
1315
1555
|
const scopesRes =
|
|
1316
1556
|
scopesBudget > 0
|
|
1317
1557
|
? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
|
|
1318
1558
|
: { processed: 0, skipped: 0 };
|
|
1319
1559
|
results.reenrich = {
|
|
1320
|
-
processed:
|
|
1321
|
-
|
|
1322
|
-
|
|
1560
|
+
processed:
|
|
1561
|
+
(mainRes.processed || 0) +
|
|
1562
|
+
(aliasRes.processed || 0) +
|
|
1563
|
+
(conceptsRes.processed || 0) +
|
|
1564
|
+
(scopesRes.processed || 0),
|
|
1565
|
+
skipped:
|
|
1566
|
+
(mainRes.skipped || 0) +
|
|
1567
|
+
(aliasRes.skipped || 0) +
|
|
1568
|
+
(conceptsRes.skipped || 0) +
|
|
1569
|
+
(scopesRes.skipped || 0),
|
|
1570
|
+
byScope: {
|
|
1571
|
+
[reenrichScope]: mainRes,
|
|
1572
|
+
aliases: aliasRes,
|
|
1573
|
+
concepts: conceptsRes,
|
|
1574
|
+
scopes: scopesRes,
|
|
1575
|
+
},
|
|
1323
1576
|
};
|
|
1324
1577
|
} else {
|
|
1325
1578
|
results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
|
package/lib/search-core.mjs
CHANGED
|
@@ -465,6 +465,67 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
|
|
|
465
465
|
});
|
|
466
466
|
}
|
|
467
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Tell the caller when `total` promises rows this pagination can never hand back.
|
|
470
|
+
*
|
|
471
|
+
* D#5. `computePerSourceWindow` is offset-INDEPENDENT by design (D#30: an
|
|
472
|
+
* offset-scaled pool re-ranks its own prefix under RRF, so pages overlapped and
|
|
473
|
+
* gapped on a vector-populated DB). The bound is right and stays. What was never
|
|
474
|
+
* adjusted is the REPORTED NUMBER: `countSearchTotal` re-derives the full
|
|
475
|
+
* MATCH+filter population, so a search over 128 matching rows prints
|
|
476
|
+
* "Found 10 of 128" at offset 50 and "No results at offset 60" — with nothing
|
|
477
|
+
* saying that offsets past the candidate pool are empty by construction.
|
|
478
|
+
* Measured 2026-09-07 on a 128-row sandbox corpus: the last non-empty offset is
|
|
479
|
+
* 59 / 59 / 89 for limits 10 / 20 / 30, i.e. at the default `mem_search` limit of
|
|
480
|
+
* 20, 60 of 128 rows (46.9%) are unreachable at ANY offset.
|
|
481
|
+
*
|
|
482
|
+
* `reachable` is the pre-slice candidate count (`preFinalizeCount` =
|
|
483
|
+
* `results.length`), NOT a re-derived `max(limit*3, 60)`. That matters and is not
|
|
484
|
+
* a style choice: `perSourceLimit` is PER SOURCE, so a cross-source search fuses
|
|
485
|
+
* up to four such pools and its real ceiling is several times the formula. A note
|
|
486
|
+
* quoting the formula would understate the reach of every cross-source query.
|
|
487
|
+
*
|
|
488
|
+
* Silent for deep (explicit or auto-escalated): there `total` IS the fused variant
|
|
489
|
+
* set already in `results`, so `total > reachable` cannot hold and a note would be
|
|
490
|
+
* describing a bound that is not the one in force.
|
|
491
|
+
*
|
|
492
|
+
* Off switch: CLAUDE_MEM_REACH_DISCLOSURE=off (mirrors CLAUDE_MEM_DEEP_DISCLOSURE).
|
|
493
|
+
*
|
|
494
|
+
* @param {object} [opts]
|
|
495
|
+
* @param {number} [opts.total] the reported population (countSearchTotal)
|
|
496
|
+
* @param {number} [opts.reachable] pre-slice candidate count (preFinalizeCount)
|
|
497
|
+
* @param {number} [opts.offset] the offset this page asked for
|
|
498
|
+
* @param {boolean} [opts.isDeep]
|
|
499
|
+
* @param {object} [opts.env]
|
|
500
|
+
* @returns {string} the note, or '' when it should not be shown
|
|
501
|
+
*/
|
|
502
|
+
export function reachabilityNote({
|
|
503
|
+
total = 0,
|
|
504
|
+
reachable = 0,
|
|
505
|
+
offset = 0,
|
|
506
|
+
isDeep = false,
|
|
507
|
+
env = process.env,
|
|
508
|
+
} = {}) {
|
|
509
|
+
if (String(env.CLAUDE_MEM_REACH_DISCLOSURE || '').toLowerCase() === 'off') return '';
|
|
510
|
+
if (isDeep) return '';
|
|
511
|
+
if (!Number.isFinite(total) || !Number.isFinite(reachable)) return '';
|
|
512
|
+
// Nothing came back AT ALL is a different question from "this page is past the
|
|
513
|
+
// pool" — a tier filter that dropped every candidate leaves total > 0 with
|
|
514
|
+
// reachable 0, and answering that with a pagination note would misattribute it.
|
|
515
|
+
// The CLI's own zero-result branch owns that case; D#5 is about pagination reach.
|
|
516
|
+
if (!(reachable > 0)) return '';
|
|
517
|
+
if (!(total > reachable)) return '';
|
|
518
|
+
const tail =
|
|
519
|
+
'The candidate pool is sized from `limit` alone and deliberately does not grow with ' +
|
|
520
|
+
'`offset`, so same-limit pages stay stable and disjoint (D#30). Raise the limit to widen ' +
|
|
521
|
+
'the pool, or narrow the query.';
|
|
522
|
+
return offset >= reachable
|
|
523
|
+
? `[search: offset ${offset} is past this query's reach — ${total} rows match but only the ` +
|
|
524
|
+
`first ${reachable} are pageable. ${tail}]`
|
|
525
|
+
: `[search: ${total} rows match but only the first ${reachable} are pageable; offsets at or ` +
|
|
526
|
+
`past ${reachable} return empty. ${tail}]`;
|
|
527
|
+
}
|
|
528
|
+
|
|
468
529
|
/**
|
|
469
530
|
* Finalize a merged, scored result set into one page: compute the TRUE
|
|
470
531
|
* (limit/offset-invariant) population, slice the requested page, and attach the
|
|
@@ -485,6 +546,9 @@ export function applyTierFilter(db, results, { tier, sourceKey, currentProject }
|
|
|
485
546
|
* expansion (concept co-occurrence / PRF / vector), and `offset` is applied
|
|
486
547
|
* exactly ONCE here (the per-source SQL always saw offset 0).
|
|
487
548
|
*
|
|
549
|
+
* `total` therefore reports a population LARGER than this call can ever hand back —
|
|
550
|
+
* see reachabilityNote, which is what tells the caller so.
|
|
551
|
+
*
|
|
488
552
|
* @returns {{ total: number, page: object[] }}
|
|
489
553
|
*/
|
|
490
554
|
export function finalizeSearchPage(
|
package/mem-cli.mjs
CHANGED
|
@@ -120,6 +120,7 @@ import {
|
|
|
120
120
|
parseDateBounds,
|
|
121
121
|
parseDuration,
|
|
122
122
|
coreRunSearchPipeline,
|
|
123
|
+
reachabilityNote,
|
|
123
124
|
} from './lib/search-core.mjs';
|
|
124
125
|
import { AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
125
126
|
import { countRecentHookErrors } from './lib/hook-telemetry.mjs';
|
|
@@ -479,6 +480,19 @@ async function cmdSearch(db, args, { llm } = {}) {
|
|
|
479
480
|
return;
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
// D#5. Same channel and same reasoning as the deep disclosure above: `total` is the
|
|
484
|
+
// real population, the candidate pool is offset-independent by design (D#30), and
|
|
485
|
+
// nothing else tells the caller that offsets past the pool are empty by construction.
|
|
486
|
+
// Emitted BEFORE the two return paths below so it covers both the past-the-pool page
|
|
487
|
+
// and a normal page whose total is unreachable — one call, not two wordings.
|
|
488
|
+
const reachNote = reachabilityNote({
|
|
489
|
+
total,
|
|
490
|
+
reachable: res.preFinalizeCount,
|
|
491
|
+
offset,
|
|
492
|
+
isDeep,
|
|
493
|
+
});
|
|
494
|
+
if (reachNote) process.stderr.write(`${reachNote}\n`);
|
|
495
|
+
|
|
482
496
|
if (paged.length === 0) {
|
|
483
497
|
if (jsonOutput) {
|
|
484
498
|
out(
|
|
@@ -3202,7 +3216,7 @@ Commands:
|
|
|
3202
3216
|
--run-all Execute bypassing gates
|
|
3203
3217
|
--task T Comma-separated: re-enrich,normalize,cluster-merge,smart-compress
|
|
3204
3218
|
--max N Max items per task (1-100, default 15)
|
|
3205
|
-
--scope S re-enrich scope: narrow (default) | wide | aliases | scopes
|
|
3219
|
+
--scope S re-enrich scope: narrow (default) | wide | aliases | scopes | concepts
|
|
3206
3220
|
(aliases: backfill search_aliases on substantive rows that
|
|
3207
3221
|
lack them — incl. lesson-bearing manual saves — adds ONLY
|
|
3208
3222
|
aliases, never rewrites title/narrative/lesson)
|
|
@@ -3477,8 +3491,8 @@ async function cmdOptimize(db, args) {
|
|
|
3477
3491
|
let reenrichScope = 'narrow';
|
|
3478
3492
|
if (scopeIdx >= 0 && args[scopeIdx + 1] !== undefined) {
|
|
3479
3493
|
const raw = args[scopeIdx + 1];
|
|
3480
|
-
if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases' && raw !== 'scopes') {
|
|
3481
|
-
fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases, scopes`);
|
|
3494
|
+
if (raw !== 'narrow' && raw !== 'wide' && raw !== 'aliases' && raw !== 'scopes' && raw !== 'concepts') {
|
|
3495
|
+
fail(`[mem] Invalid --scope "${raw}". Use: narrow, wide, aliases, scopes, concepts`);
|
|
3482
3496
|
return;
|
|
3483
3497
|
}
|
|
3484
3498
|
reenrichScope = raw;
|
|
@@ -3502,7 +3516,7 @@ async function cmdOptimize(db, args) {
|
|
|
3502
3516
|
out('[mem] 🔍 LLM Optimization Preview:');
|
|
3503
3517
|
if (project) out(` Project filter: ${project}`);
|
|
3504
3518
|
out(
|
|
3505
|
-
` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}${preview.reenrichScopes ? ` (scopes scope: ${preview.reenrichScopes})` : ''}`,
|
|
3519
|
+
` Re-enrich candidates: ${preview.reenrich}${preview.reenrichWide !== undefined && preview.reenrichWide !== null ? ` (wide scope: ${preview.reenrichWide})` : ''}${preview.reenrichAliases ? ` (aliases scope: ${preview.reenrichAliases})` : ''}${preview.reenrichScopes ? ` (scopes scope: ${preview.reenrichScopes})` : ''}${preview.reenrichConcepts ? ` (concepts scope: ${preview.reenrichConcepts})` : ''}`,
|
|
3506
3520
|
);
|
|
3507
3521
|
out(
|
|
3508
3522
|
` Normalize: ${preview.normalizeGateOpen ? `${preview.normalize} unique concepts` : 'gate closed (7-day interval)'}`,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "5.
|
|
9
|
+
"version": "5.5.1",
|
|
10
10
|
"os": [
|
|
11
11
|
"darwin",
|
|
12
12
|
"linux"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.1",
|
|
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",
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
"test:coverage": "vitest run --coverage",
|
|
31
31
|
"benchmark": "node benchmark/benchmark.mjs",
|
|
32
32
|
"benchmark:gate": "node benchmark/ci-gate.mjs",
|
|
33
|
+
"benchmark:multipliers": "node benchmark/multiplier-discrimination.mjs",
|
|
34
|
+
"benchmark:multipliers:gate": "node benchmark/multiplier-discrimination.mjs --self-check",
|
|
33
35
|
"audit:inventory": "node scripts/audit-metrics.mjs --inventory",
|
|
34
36
|
"audit:deps": "node scripts/audit-metrics.mjs --deps",
|
|
35
37
|
"audit:selfcheck": "node scripts/audit-metrics.mjs --self-check",
|
package/scoring-sql.mjs
CHANGED
|
@@ -10,25 +10,55 @@ import { DAY_MS } from './lib/time-constants.mjs';
|
|
|
10
10
|
// multipliers below encode PRODUCT PRIORS: recent / same-project / important /
|
|
11
11
|
// high-signal-type / frequently-cited memories are more relevant to the CURRENT
|
|
12
12
|
// dev session. A periodic audit tends to flag them as "0-lift dead weight" —
|
|
13
|
-
// resist that
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
13
|
+
// resist that, and reach for the right instrument instead of the aggregate one.
|
|
14
|
+
//
|
|
15
|
+
// ALL EIGHT ARE ALIVE AND CARRY THE MAGNITUDE DECLARED HERE. Measured
|
|
16
|
+
// 2026-09-07 at `main` @ f25e8ae with benchmark/multiplier-discrimination.mjs,
|
|
17
|
+
// which ranks pairs of rows with byte-identical indexed text differing in one
|
|
18
|
+
// column, so BM25 ties and the score quotient IS the multiplier:
|
|
19
|
+
// decay 1.9770 type 1.8333 project 2.0000 importance 2.0000
|
|
20
|
+
// access 1.5004 lesson 1.3000 noise 5.0000 cite 2.0000
|
|
21
|
+
// Each matches its declared ratio to 4 decimals, hybrid ranks the preferred row
|
|
22
|
+
// 12/12, and removing the term drops that to 6/12 — a coin flip.
|
|
23
|
+
//
|
|
24
|
+
// THE AGGREGATE MATRIX CANNOT SEE THAT, and its zeros must not be read as death.
|
|
25
|
+
// Same tree, `benchmark.mjs --matrix`: bm25_only ALONE reads R@10 0.8996 /
|
|
26
|
+
// P@10 0.9731 / nDCG 0.9728, so the fixture is saturated and all eight
|
|
27
|
+
// multipliers together buy +0.0002 R@10. Five ablation arms (project, access,
|
|
28
|
+
// lesson, noise, cite) read 0 on all four metrics, and dropping importance reads
|
|
29
|
+
// BETTER (ΔnDCG -0.0019). Why each zero, corrected 2026-09-07 — an earlier
|
|
30
|
+
// version of this note said "the fixture is single-project", which is false
|
|
31
|
+
// (seed-data.json is 5 projects x 40 rows):
|
|
32
|
+
// * project — 29 of 30 queries set no project, and the one that does also
|
|
33
|
+
// FILTERS on it, which makes the boost a constant over the survivors and
|
|
34
|
+
// therefore rank-invariant. The harness used to pass the filter value as the
|
|
35
|
+
// boost; it now mirrors search-engine.mjs:606 and disables the boost under a
|
|
36
|
+
// filter, which is rank-invariant on the matrix (verified: all 11 delta
|
|
37
|
+
// blocks byte-identical across the change).
|
|
38
|
+
// * access / lesson / noise / cite — seed-data.json carries no access_count,
|
|
39
|
+
// no lesson_learned and no injection/cite counters at all, so those four
|
|
40
|
+
// columns are constant and the terms are 1.0x on every row.
|
|
41
|
+
// A multiplier reading 0 there is a benchmark-MISMATCH artifact, NOT dead weight.
|
|
21
42
|
// * longmemeval.mjs --temporal (n=500, real dates): bit-identical to uniform —
|
|
22
43
|
// LongMemEval-S windows (mean 27.9d, 74% <30d) are far shorter than these
|
|
23
44
|
// half-lives, so decay moves no rank there either.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
45
|
+
//
|
|
46
|
+
// DO NOT TRUST THE CI GATE TO CATCH A CHANGE HERE. The `hybrid_over_bm25 >= -0.05`
|
|
47
|
+
// floor does NOT cover the chain, measured 2026-09-07 by mutating the real tree
|
|
48
|
+
// and reverting it: neutering the importance multiplier left the gate at exit 0
|
|
49
|
+
// with all four checks PASS and `hybrid_over_bm25` going UP (R 0.0002 -> 0.0019),
|
|
50
|
+
// because importance is a negative contributor on that fixture; changing lesson's
|
|
51
|
+
// 0.3 to 0.5 left the gate's output byte-identical. The eight per-term ablation
|
|
52
|
+
// deltas the matrix prints are gated by nothing. Retune a constant in this file
|
|
53
|
+
// and re-run benchmark/multiplier-discrimination.mjs, which reports MISMATCH on
|
|
54
|
+
// exactly that shape — the aggregate gate will not.
|
|
55
|
+
// D#121: cite + noise joined the matrix MULT_EXPR after M-3 put them in
|
|
56
|
+
// FULL_SCORE; their real-SQL direction pins live in
|
|
57
|
+
// benchmark/events-pipeline-probes.mjs.
|
|
58
|
+
// Still open, and NOT what the ruler above answers: whether these priors help a
|
|
59
|
+
// REAL user. "Wired up with the declared magnitude" is a different question from
|
|
60
|
+
// "correctly calibrated", and the second one still needs a labeled
|
|
61
|
+
// real-dev-memory eval.
|
|
32
62
|
|
|
33
63
|
// ─── Type-Differentiated Recency Decay ──────────────────────────────────────
|
|
34
64
|
|
package/server.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
parseDateBounds,
|
|
31
31
|
parseDuration,
|
|
32
32
|
coreRunSearchPipeline,
|
|
33
|
+
reachabilityNote,
|
|
33
34
|
} from './lib/search-core.mjs';
|
|
34
35
|
import {
|
|
35
36
|
runMaintainOps,
|
|
@@ -595,6 +596,20 @@ async function runSearchPipeline(db, args, { llm, rerankLlm } = {}) {
|
|
|
595
596
|
});
|
|
596
597
|
if (disclosure) output.content[0].text += `\n\n${disclosure}`;
|
|
597
598
|
}
|
|
599
|
+
// D#5. Same split as the deep note: an MCP client reads the tool RESULT, not stderr,
|
|
600
|
+
// so a caller that pages past the candidate pool has to be told inside the payload or
|
|
601
|
+
// it reads "0 results" against a total it was just handed and concludes the corpus is
|
|
602
|
+
// empty. `reachable` is r.preFinalizeCount — the pre-slice candidate count — because
|
|
603
|
+
// perSourceLimit is PER SOURCE and a cross-source query fuses several of those pools.
|
|
604
|
+
if (output.content?.[0]?.type === 'text') {
|
|
605
|
+
const reachNote = reachabilityNote({
|
|
606
|
+
total: r.total,
|
|
607
|
+
reachable: r.preFinalizeCount,
|
|
608
|
+
offset,
|
|
609
|
+
isDeep: r.isDeep,
|
|
610
|
+
});
|
|
611
|
+
if (reachNote) output.content[0].text += `\n\n${reachNote}`;
|
|
612
|
+
}
|
|
598
613
|
appendDeferredTrailer(output);
|
|
599
614
|
|
|
600
615
|
// Expose structured fields for tests + the MCP content blob.
|