claude-mem-lite 5.4.0 → 5.5.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 -0
- package/hook-optimize.mjs +229 -26
- 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.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": "5.
|
|
3
|
+
"version": "5.5.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
|
@@ -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/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 = ?' : '';
|
|
@@ -154,6 +154,39 @@ export function findReenrichCandidates(db, limit = 10, { scope = 'narrow', proje
|
|
|
154
154
|
`);
|
|
155
155
|
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
156
156
|
}
|
|
157
|
+
if (scope === 'concepts') {
|
|
158
|
+
// D#6 concepts backfill: substantive rows with no concepts, REGARDLESS of lesson,
|
|
159
|
+
// aliases or scope. Same shape and same reason as the two pools above, one column
|
|
160
|
+
// over — and this one exists because the P1-2 fix created it. save-enrich fires on
|
|
161
|
+
// every successful manual save and writes search_aliases (always) + lesson_learned
|
|
162
|
+
// (bugfix/decision) + scope, which are precisely narrow's, wide's, aliases' and
|
|
163
|
+
// scopes' predicates, so a save-enriched row matches NONE of the four and never
|
|
164
|
+
// receives concepts. Measured on the real DB 2026-09-07: 14/14 live observations
|
|
165
|
+
// conceptless, 14/14 with aliases, 0/14 with optimized_at, all four pools empty.
|
|
166
|
+
//
|
|
167
|
+
// Keyed on `concepts` ALONE, not on concepts+facts: idempotency here is "the column
|
|
168
|
+
// this pass fills becomes non-empty", the same contract aliases and scopes carry. A
|
|
169
|
+
// facts term in the predicate would re-select forever every row whose narrative
|
|
170
|
+
// yields no extractable fact.
|
|
171
|
+
//
|
|
172
|
+
// Deliberately NOT gated on optimized_at, for the reason the alias branch gives and
|
|
173
|
+
// one more: the general pass preserves-on-empty, so a re-enrich whose model returned
|
|
174
|
+
// no concepts leaves the row stamped AND conceptless. Gating on the stamp would
|
|
175
|
+
// strand exactly those rows — the R10 P2-2 shape, where one pass's bookkeeping
|
|
176
|
+
// evicts a row from a backfill it never visited.
|
|
177
|
+
const stmt = db.prepare(`
|
|
178
|
+
SELECT id, title, narrative, type, subtitle, concepts, facts, text, importance, project
|
|
179
|
+
FROM observations
|
|
180
|
+
WHERE ${liveObsFilterSql('')}
|
|
181
|
+
AND (concepts IS NULL OR concepts = '')
|
|
182
|
+
AND LENGTH(COALESCE(narrative, '')) > 100
|
|
183
|
+
AND ${notLowSignalTitleClause('')}
|
|
184
|
+
${projectClause}
|
|
185
|
+
ORDER BY created_at_epoch DESC
|
|
186
|
+
LIMIT ?
|
|
187
|
+
`);
|
|
188
|
+
return project ? stmt.all(project, limit) : stmt.all(limit);
|
|
189
|
+
}
|
|
157
190
|
if (scope === 'wide') {
|
|
158
191
|
const stmt = db.prepare(`
|
|
159
192
|
SELECT id, title, narrative, type, subtitle, concepts, facts, search_aliases, importance, project
|
|
@@ -298,9 +331,23 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
298
331
|
// scope rides this call for free (D#135 P3). COALESCE, not a plain set:
|
|
299
332
|
// an omitted or off-enum value normalizes to null and must not erase a
|
|
300
333
|
// classification an earlier face already wrote.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
334
|
+
// D#12: the live-row guard, on the WHERE and not merely on the SELECT that chose
|
|
335
|
+
// the row. The Haiku call above is up to BG_LLM_TIMEOUT_MS (45 s), long enough for
|
|
336
|
+
// a concurrent hook to supersede or auto-compress this row — R10 P3-3's finding,
|
|
337
|
+
// fixed then on the general branch only and carried by the concepts branch since
|
|
338
|
+
// D#6. This was the one branch of the four without it. `changes === 0` is a SKIP,
|
|
339
|
+
// not a success: it must not count as processed and must not rebuild a vector for
|
|
340
|
+
// a row that is no longer live.
|
|
341
|
+
const res = db
|
|
342
|
+
.prepare(
|
|
343
|
+
`UPDATE observations SET search_aliases = ?, text = ?, scope = COALESCE(?, scope)
|
|
344
|
+
WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
345
|
+
)
|
|
346
|
+
.run(safe.search_aliases, safe.text, normalizeScope(parsed.scope), cand.id);
|
|
347
|
+
if (res.changes === 0) {
|
|
348
|
+
skipped++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
304
351
|
// Refresh the TF-IDF vector from the just-updated FTS text so the new
|
|
305
352
|
// aliases reach the vector arm too — the narrow/wide branch rebuilds, this
|
|
306
353
|
// one must as well. No-ops when the vector arm is off / vocab unbuilt.
|
|
@@ -308,6 +355,69 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
308
355
|
processed++;
|
|
309
356
|
continue;
|
|
310
357
|
}
|
|
358
|
+
if (scope === 'concepts') {
|
|
359
|
+
// Concepts-only backfill (D#6). Writes concepts + facts and APPENDS them to the
|
|
360
|
+
// existing FTS text — never rebuilds it, for the reason the alias branch gives:
|
|
361
|
+
// a rebuild from concepts/facts drops the original narrative and alias terms and
|
|
362
|
+
// regresses recall. Never touches the user's curated title / narrative / lesson /
|
|
363
|
+
// type / importance, and never stamps optimized_at, so the wide pass keeps its
|
|
364
|
+
// own candidates exactly as the alias and scopes passes leave them.
|
|
365
|
+
const conceptsPrompt = `Extract search concepts and concrete facts from this coding memory. Return ONLY valid JSON, no markdown fences.
|
|
366
|
+
|
|
367
|
+
Title: ${truncate(cand.title || '(untitled)', 200)}
|
|
368
|
+
Narrative: ${truncate(cand.narrative || '(no narrative)', 500)}
|
|
369
|
+
|
|
370
|
+
JSON: {"concepts":["kw1","kw2"],"facts":["specific fact 1","specific fact 2"]}
|
|
371
|
+
concepts: 3-8 short keyword phrases naming what this memory is ABOUT (systems, components, error classes, techniques).
|
|
372
|
+
facts: 1-4 specific, checkable statements the narrative actually asserts. Omit rather than invent.`;
|
|
373
|
+
const parsed = await callModelJSONAsync(conceptsPrompt, 'haiku', {
|
|
374
|
+
timeout: BG_LLM_TIMEOUT_MS,
|
|
375
|
+
maxTokens: 300,
|
|
376
|
+
});
|
|
377
|
+
const pickStrings = (v) =>
|
|
378
|
+
Array.isArray(v) ? v.filter((s) => typeof s === 'string' && s.trim().length > 0) : [];
|
|
379
|
+
const conceptArr = pickStrings(parsed && parsed.concepts);
|
|
380
|
+
// No concepts is a SKIP, not an empty write: writing '' would leave the row in
|
|
381
|
+
// this pool forever, and the pass would burn one Haiku call per cycle on it.
|
|
382
|
+
if (!conceptArr.length) {
|
|
383
|
+
skipped++;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const factArr = pickStrings(parsed && parsed.facts);
|
|
387
|
+
const conceptsOnly = conceptArr.slice(0, 10).join(' ');
|
|
388
|
+
const factsOnly = factArr.slice(0, 10).join(' ');
|
|
389
|
+
const appendedText = [
|
|
390
|
+
cand.text || '',
|
|
391
|
+
conceptsOnly,
|
|
392
|
+
factsOnly,
|
|
393
|
+
cjkBigrams(`${conceptsOnly} ${factsOnly}`),
|
|
394
|
+
]
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.join(' ');
|
|
397
|
+
const safe = scrubRecord('observations', {
|
|
398
|
+
concepts: conceptsOnly,
|
|
399
|
+
facts: factsOnly,
|
|
400
|
+
text: appendedText,
|
|
401
|
+
});
|
|
402
|
+
// Fill-only-empty on `concepts` plus the live-row guard, both on the WHERE and
|
|
403
|
+
// not merely on the SELECT: the round-trip above is up to 45 s, long enough for a
|
|
404
|
+
// concurrent hook to supersede or compress the row (R10 P3-3) or for save-enrich
|
|
405
|
+
// to fill it. `facts` rides along with preserve-on-empty for the same reason the
|
|
406
|
+
// general pass preserves it — a partial answer must not wipe a filled column.
|
|
407
|
+
const res = db
|
|
408
|
+
.prepare(
|
|
409
|
+
`UPDATE observations SET concepts = ?, facts = COALESCE(NULLIF(?, ''), facts), text = ?
|
|
410
|
+
WHERE id = ? AND (concepts IS NULL OR concepts = '') AND ${liveObsFilterSql('')}`,
|
|
411
|
+
)
|
|
412
|
+
.run(safe.concepts, safe.facts, safe.text, cand.id);
|
|
413
|
+
if (res.changes === 0) {
|
|
414
|
+
skipped++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
rebuildVector(db, cand.id, [safe.text]);
|
|
418
|
+
processed++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
311
421
|
const prompt = `Re-enrich this observation with structured metadata. Return ONLY valid JSON, no markdown fences.
|
|
312
422
|
|
|
313
423
|
Title: ${truncate(cand.title || '(untitled)', 200)}
|
|
@@ -336,9 +446,24 @@ scope: ${SCOPE_PROMPT_LEGEND}`;
|
|
|
336
446
|
// hide a real observation until manual surgery. In wide scope, fall through and let
|
|
337
447
|
// clampImportance floor it to 1 (kept visible, low-ranked) instead of hiding.
|
|
338
448
|
if ((parsed.importance === 0 || parsed.importance === '0') && scope !== 'wide') {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
449
|
+
// D#12, and this one is not a stale-write guard — it is a POINTER guard.
|
|
450
|
+
// `compressed_into` is the child -> keeper link, and COMPRESSED_AUTO is -1. If a
|
|
451
|
+
// concurrent cluster-merge or smart-compress adopts this row during the 45 s Haiku
|
|
452
|
+
// call, it holds a POSITIVE keeper id; overwriting that with -1 does not merely
|
|
453
|
+
// stamp a dead row, it destroys the link — lib/maintain-core.mjs:316 recovers
|
|
454
|
+
// orphans with `compressed_into > 0`, and recoverChildrenOf follows the same id.
|
|
455
|
+
// The sibling write in lib/maintain-core.mjs:631 already carries this predicate,
|
|
456
|
+
// so the codebase had decided the question and this site had not been updated.
|
|
457
|
+
const res = db
|
|
458
|
+
.prepare(
|
|
459
|
+
`UPDATE observations SET compressed_into = ${COMPRESSED_AUTO}, optimized_at = ?
|
|
460
|
+
WHERE id = ? AND ${liveObsFilterSql('')}`,
|
|
461
|
+
)
|
|
462
|
+
.run(Date.now(), cand.id);
|
|
463
|
+
if (res.changes === 0) {
|
|
464
|
+
skipped++;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
342
467
|
processed++;
|
|
343
468
|
continue;
|
|
344
469
|
}
|
|
@@ -1016,6 +1141,52 @@ export function clusterForCompression(candidates, db) {
|
|
|
1016
1141
|
return clusters;
|
|
1017
1142
|
}
|
|
1018
1143
|
|
|
1144
|
+
/**
|
|
1145
|
+
* The smart-compress prompt. Exported so a RULER can measure the shipped text.
|
|
1146
|
+
*
|
|
1147
|
+
* Extracted for benchmark/compress-veto-rate.mjs (D#10). It has to be one string in one
|
|
1148
|
+
* place: a ruler that retypes the prompt measures its own copy, which is exactly how
|
|
1149
|
+
* tests/handoff-simulation.test.mjs came to assert on a re-implementation while the real
|
|
1150
|
+
* hook emitted a block no user had ever seen.
|
|
1151
|
+
*
|
|
1152
|
+
* D#10. This prompt used to OPEN with "Summarize these related code memory observations",
|
|
1153
|
+
* asserting the premise it should have been testing, and the only bail was a missing title
|
|
1154
|
+
* — so the model had no way to refuse. The sibling executeMergeCluster has had
|
|
1155
|
+
* `should_merge` since it was written; these two LLM cluster paths disagreed about whether
|
|
1156
|
+
* the model may say no, and this is the one that HIDES its inputs (compressed_into removes
|
|
1157
|
+
* them from every injection and search surface and puts them out of recoverBuriedLessons'
|
|
1158
|
+
* reach).
|
|
1159
|
+
*
|
|
1160
|
+
* It matters because the upstream relatedness check is not always on:
|
|
1161
|
+
* clusterForCompression only computes cosine similarity when getVocabulary returns a
|
|
1162
|
+
* vocabulary, and that is null whenever the vector arm is off — which is the default
|
|
1163
|
+
* (CLAUDE_MEM_VECTORS !== '1'). The else branch groups by a 14-day window ALONE. Measured
|
|
1164
|
+
* with a control arm 2026-09-07: three unrelated observations over 12 days form 1 cluster
|
|
1165
|
+
* with the arm off and 0 with it on. Until that branch is decided (D#10 option a), this
|
|
1166
|
+
* veto is the only thing standing between the heuristic and an unattended write that hides
|
|
1167
|
+
* real rows.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {Array<object>} observations cluster members
|
|
1170
|
+
* @returns {string}
|
|
1171
|
+
*/
|
|
1172
|
+
export function buildCompressPrompt(observations) {
|
|
1173
|
+
const obsDescriptions = observations
|
|
1174
|
+
.map(
|
|
1175
|
+
(o, i) =>
|
|
1176
|
+
`${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)}` : ''}`,
|
|
1177
|
+
)
|
|
1178
|
+
.join('\n');
|
|
1179
|
+
|
|
1180
|
+
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.
|
|
1181
|
+
|
|
1182
|
+
Observations:
|
|
1183
|
+
${obsDescriptions}
|
|
1184
|
+
|
|
1185
|
+
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"]}
|
|
1186
|
+
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.
|
|
1187
|
+
When true: preserve all important decisions, lessons, and specific facts.`;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1019
1190
|
export async function executeSmartCompressCluster(db, observations, project) {
|
|
1020
1191
|
if (observations.length < 3) return { compressed: false };
|
|
1021
1192
|
|
|
@@ -1023,25 +1194,18 @@ export async function executeSmartCompressCluster(db, observations, project) {
|
|
|
1023
1194
|
if (!gotSlot) return { compressed: false };
|
|
1024
1195
|
|
|
1025
1196
|
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"]}`;
|
|
1197
|
+
const prompt = buildCompressPrompt(observations);
|
|
1039
1198
|
|
|
1040
1199
|
const parsed = await callModelJSONAsync(prompt, 'sonnet', {
|
|
1041
1200
|
timeout: BG_LLM_TIMEOUT_MS,
|
|
1042
1201
|
maxTokens: 1000,
|
|
1043
1202
|
});
|
|
1044
|
-
|
|
1203
|
+
// Fail CLOSED, exactly as `should_merge` does: an omitted verdict refuses. The two
|
|
1204
|
+
// failure directions are not symmetric — refusing wrongly means a compression did not
|
|
1205
|
+
// happen, proceeding wrongly means unrelated observations were hidden from every
|
|
1206
|
+
// surface. On a path that hides its inputs, silence is not consent.
|
|
1207
|
+
if (!parsed || !parsed.should_compress) return { compressed: false };
|
|
1208
|
+
if (!parsed.title) return { compressed: false };
|
|
1045
1209
|
|
|
1046
1210
|
// Scrub BEFORE truncate (see re-enrich note): boundary cut on scrubbed text.
|
|
1047
1211
|
const title = truncate(scrubSecrets(parsed.title || ''), 120);
|
|
@@ -1193,6 +1357,11 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1193
1357
|
// lesson_learned per row, so counting by materialising was the one place this
|
|
1194
1358
|
// round pulled megabytes to print an integer. (pre-tag review NOTE 11)
|
|
1195
1359
|
const reenrichScopes = countReenrichCandidates(db, 'scopes', project);
|
|
1360
|
+
// D#6: the concepts-backfill backlog. Reported for the same reason as the three
|
|
1361
|
+
// above — a pool whose size is invisible cannot be sized for a one-shot drain
|
|
1362
|
+
// (`optimize --run --task re-enrich --scope concepts --max N`), and this pool is
|
|
1363
|
+
// the one that holds every save-enriched manual save.
|
|
1364
|
+
const reenrichConcepts = findReenrichCandidates(db, 5000, { scope: 'concepts', project }).length;
|
|
1196
1365
|
|
|
1197
1366
|
const concepts = extractUniqueConcepts(db, 500, { project });
|
|
1198
1367
|
const normalizeReady = shouldRunNormalize(project) && concepts.length >= 5;
|
|
@@ -1209,6 +1378,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1209
1378
|
reenrichWide,
|
|
1210
1379
|
reenrichAliases,
|
|
1211
1380
|
reenrichScopes,
|
|
1381
|
+
reenrichConcepts,
|
|
1212
1382
|
normalize: normalizeReady ? concepts.length : 0,
|
|
1213
1383
|
normalizeGateOpen: shouldRunNormalize(project),
|
|
1214
1384
|
clusterMerge,
|
|
@@ -1240,7 +1410,7 @@ export function optimizePreview(db, { project, detail = false } = {}) {
|
|
|
1240
1410
|
* is budgeted separately, up to the re-enrich slice again — see the rationale at
|
|
1241
1411
|
* the call site. Its calls are enum-classification only (maxTokens 60).
|
|
1242
1412
|
* @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.
|
|
1413
|
+
* @param {'narrow'|'wide'|'aliases'|'concepts'} [opts.reenrichScope='narrow'] Scope for the re-enrich task.
|
|
1244
1414
|
* 'wide' targets bugfix/refactor/feature/decision with narrative but no lesson (R-7).
|
|
1245
1415
|
* 'aliases' (P1) backfills search_aliases on substantive alias-less rows regardless
|
|
1246
1416
|
* of lesson (lesson-bearing manual saves) — adds ONLY aliases, never rewrites content.
|
|
@@ -1295,16 +1465,32 @@ export async function optimizeRun(
|
|
|
1295
1465
|
// mis-prices it by an order of magnitude.
|
|
1296
1466
|
// Cap is budget.reenrich, so the daily pass adds at most that many cheap
|
|
1297
1467
|
// classification calls and an empty pool still costs nothing.
|
|
1468
|
+
//
|
|
1469
|
+
// D#6 adds a FOURTH claimant, 'concepts', and it SHARES the aliases half
|
|
1470
|
+
// rather than taking one of its own. Sharing keeps the boundary this comment
|
|
1471
|
+
// already describes: the main scope still gets at least half the budget, so
|
|
1472
|
+
// adding a pool cannot starve the lesson enrichment that is the point of the
|
|
1473
|
+
// pass. Aliases is served FIRST out of that shared half, on a stated
|
|
1474
|
+
// ordering: an alias-less row is paraphrase-UNFINDABLE (a recall zero),
|
|
1475
|
+
// while a conceptless row is findable and merely ranks worse — measured at
|
|
1476
|
+
// +0.0846 R@10 on the benchmark fixture, which is real but is not a zero.
|
|
1477
|
+
// Both pools drain (each is idempotent via the column it fills), so the
|
|
1478
|
+
// ordering decides which drains first, not which gets served at all.
|
|
1298
1479
|
const half = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
1299
1480
|
const aliasBudget = Math.min(
|
|
1300
1481
|
half,
|
|
1301
1482
|
findReenrichCandidates(db, half, { scope: 'aliases', project }).length,
|
|
1302
1483
|
);
|
|
1484
|
+
const conceptsBudget = Math.min(
|
|
1485
|
+
half - aliasBudget,
|
|
1486
|
+
findReenrichCandidates(db, Math.max(0, half - aliasBudget), { scope: 'concepts', project })
|
|
1487
|
+
.length,
|
|
1488
|
+
);
|
|
1303
1489
|
const scopesBudget = Math.min(
|
|
1304
1490
|
budget.reenrich,
|
|
1305
1491
|
findReenrichCandidates(db, budget.reenrich, { scope: 'scopes', project }).length,
|
|
1306
1492
|
);
|
|
1307
|
-
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, {
|
|
1493
|
+
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget - conceptsBudget, {
|
|
1308
1494
|
scope: reenrichScope,
|
|
1309
1495
|
project,
|
|
1310
1496
|
});
|
|
@@ -1312,14 +1498,31 @@ export async function optimizeRun(
|
|
|
1312
1498
|
aliasBudget > 0
|
|
1313
1499
|
? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
|
|
1314
1500
|
: { processed: 0, skipped: 0 };
|
|
1501
|
+
const conceptsRes =
|
|
1502
|
+
conceptsBudget > 0
|
|
1503
|
+
? await executeReenrich(db, conceptsBudget, { scope: 'concepts', project })
|
|
1504
|
+
: { processed: 0, skipped: 0 };
|
|
1315
1505
|
const scopesRes =
|
|
1316
1506
|
scopesBudget > 0
|
|
1317
1507
|
? await executeReenrich(db, scopesBudget, { scope: 'scopes', project })
|
|
1318
1508
|
: { processed: 0, skipped: 0 };
|
|
1319
1509
|
results.reenrich = {
|
|
1320
|
-
processed:
|
|
1321
|
-
|
|
1322
|
-
|
|
1510
|
+
processed:
|
|
1511
|
+
(mainRes.processed || 0) +
|
|
1512
|
+
(aliasRes.processed || 0) +
|
|
1513
|
+
(conceptsRes.processed || 0) +
|
|
1514
|
+
(scopesRes.processed || 0),
|
|
1515
|
+
skipped:
|
|
1516
|
+
(mainRes.skipped || 0) +
|
|
1517
|
+
(aliasRes.skipped || 0) +
|
|
1518
|
+
(conceptsRes.skipped || 0) +
|
|
1519
|
+
(scopesRes.skipped || 0),
|
|
1520
|
+
byScope: {
|
|
1521
|
+
[reenrichScope]: mainRes,
|
|
1522
|
+
aliases: aliasRes,
|
|
1523
|
+
concepts: conceptsRes,
|
|
1524
|
+
scopes: scopesRes,
|
|
1525
|
+
},
|
|
1323
1526
|
};
|
|
1324
1527
|
} else {
|
|
1325
1528
|
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.0",
|
|
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.0",
|
|
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.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",
|
|
@@ -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.
|