claude-mem-lite 3.84.1 → 3.85.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.
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.85.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": "3.
|
|
3
|
+
"version": "3.85.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/hook-memory.mjs
CHANGED
|
@@ -13,6 +13,96 @@ import { formatSubagentContext } from './lib/task-imperative.mjs';
|
|
|
13
13
|
import { DAY_MS } from './lib/time-constants.mjs';
|
|
14
14
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
15
15
|
const MEMORY_LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Candidate-pool bounds for the `fyi` injection face (searchRelevantMemories).
|
|
19
|
+
*
|
|
20
|
+
* READ THESE AS REACHABILITY BOUNDS, NOT AS RANKING GATES — the same distinction
|
|
21
|
+
* D#172 cost us on IMPERATIVE_POOL_BACKSTOP, found again here by the 2026-08-29 audit
|
|
22
|
+
* (ALGO-3). The two SELECTs below `ORDER BY` RAW bm25, but the row that actually gets
|
|
23
|
+
* injected is chosen by the JS composite in `scored` (type quality × lesson bonus ×
|
|
24
|
+
* importance × cross-project × OR × noise × cite). So whatever these numbers are, a row
|
|
25
|
+
* outside the window cannot be picked however high its composite score would have been.
|
|
26
|
+
*
|
|
27
|
+
* The window has to be wide because the composite spread is wide — 281× by the tables,
|
|
28
|
+
* 60.0× as realised over the rows this pool can actually return. Multiplying the extremes
|
|
29
|
+
* of the JS factors
|
|
30
|
+
* (same-project, AND mode): best = 1.5 decision × 1.5 lesson × 1.0 importance × 1.0 noise
|
|
31
|
+
* × 3.0 cite = 6.75; worst = 0.5 change × 1.0 no-lesson × 0.6 importance × 0.2 noise ×
|
|
32
|
+
* 0.4 cite = 0.024, i.e. a **281× DECLARED range**. That is an upper bound off the factor
|
|
33
|
+
* tables, not a measurement: `citeFactor = 0.4` requires `uncited_streak >= 3`, and
|
|
34
|
+
* citation-decay resets the streak at 3 after demoting importance, so the steady state is
|
|
35
|
+
* bounded by [0,2] (scoring-sql.mjs, citeFactorJs docblock). Measured 2026-09-01 over the
|
|
36
|
+
* 2284 rows that clear `liveObsFilterSql` — the one predicate in the WHERE of BOTH SELECTs
|
|
37
|
+
* below — 0 are at streak >= 3, and recomputing the factor per row gives a REALISED range
|
|
38
|
+
* of 0.1125 … 6.750: a **60.0× spread** (81 rows hit the full best case, 0 the full worst).
|
|
39
|
+
* Each leg then narrows further and the spread survives the narrowing, which is why one
|
|
40
|
+
* number is quotable: live+`importance >= 1` n=2249 and +`notLowSignalTitleClause` n=2245
|
|
41
|
+
* both still read 60.00×. The CROSS leg is the exception — its own population
|
|
42
|
+
* (`type IN ('decision','discovery') AND importance >= 2`) is n=444 at **17.31×**, so if
|
|
43
|
+
* you are reasoning about `RERANK_POOL_CROSS_PROJECT` specifically, 60× is the wrong figure.
|
|
44
|
+
*
|
|
45
|
+
* COUNT THAT POPULATION WITH THE POOL'S OWN FILTER. Over the raw `observations` table it
|
|
46
|
+
* reads 0.0780 … 6.750 = 86.5×, and that is the number the first draft of this comment
|
|
47
|
+
* shipped: 1458 of 3742 rows (39.0%) are compressed or superseded, the row supplying the
|
|
48
|
+
* 0.0780 minimum (`id 10239`) carries `compressed_into = 10713`, and no such row can enter
|
|
49
|
+
* the pool, be scored, or be an endpoint of a range describing what the LIMIT cuts. Same
|
|
50
|
+
* error as v3.82.0's raw `importance = 3` count, overstating by 44% instead of a third.
|
|
51
|
+
*
|
|
52
|
+
* Quote whichever population you mean, and say which. Either is wide enough that a row
|
|
53
|
+
* ranked below the window on raw bm25 can outscore the window's contents by a wide margin.
|
|
54
|
+
* (The audit estimated ">10×".)
|
|
55
|
+
*
|
|
56
|
+
* HONEST LIMIT OF THIS FIX: because the spread is wide and bm25 magnitude decays slowly
|
|
57
|
+
* across a top-N window, NO finite pool size proves sufficiency. 30/15 makes the bound
|
|
58
|
+
* loose; it does not remove it. And it is bought, not free — the first draft of this
|
|
59
|
+
* comment claimed "cost stays flat" in the same breath as a parenthetical saying the pool
|
|
60
|
+
* is the expensive term, which is its own refutation. Measured instead:
|
|
61
|
+
* `node benchmark/rerank-pool-replay.mjs --cost` reads **+5% to +16% depending on caliber,
|
|
62
|
+
* and +6% to +10% with this one**. Whole-corpus runs of `--cost` on this machine: 1.058,
|
|
63
|
+
* 1.068, 1.078, 1.080, 1.083, 1.102 — same code, same corpus, pure machine variance, and
|
|
64
|
+
* the absolute ms/prompt moved 3.04 -> 1.80 across the same runs. Other calibers:
|
|
65
|
+
* 1.054–1.065 with the arm order held fixed, 1.063–1.156 with each arm alone in its own
|
|
66
|
+
* process (the closest shape to production).
|
|
67
|
+
*
|
|
68
|
+
* **Quote the range, re-measure, and never quote the absolute ms** — they vary by 2x with
|
|
69
|
+
* load while the ratio holds. The first draft of this comment quoted a flat 1.058x and said
|
|
70
|
+
* it reproduced to three digits; it does not, and every later run came in above it. See
|
|
71
|
+
* `costCompare`'s docblock for which caliber biases which way. Timing the SELECT alone
|
|
72
|
+
* reports ~1.00x and misses the JS scoring that the widened pool feeds — a different
|
|
73
|
+
* question, not a better answer.
|
|
74
|
+
*
|
|
75
|
+
* The bound is REMOVABLE, and deliberately was not removed. Ordering both SELECTs by the
|
|
76
|
+
* composite instead of raw bm25 is close to expressible in SQL, but "every factor already
|
|
77
|
+
* has a clause" overstated it: of the SEVEN factors, three have named clauses
|
|
78
|
+
* (TYPE_QUALITY_CASE / noisePenaltyClause / citeFactorClause); two more — the 1.5× lesson
|
|
79
|
+
* bonus and the `importance >= 2` step — still need one written, because the SQL forms
|
|
80
|
+
* that exist encode different weights and shapes (`1.0 + 0.3·lesson` and
|
|
81
|
+
* `0.5 + 0.5·importance` in search-engine.mjs's FULL_SCORE); and the last two,
|
|
82
|
+
* cross-project and OR, are constant WITHIN EACH SELECT — they differ between the
|
|
83
|
+
* same-project and cross-project legs, so they are not per-CALL constants, but they never
|
|
84
|
+
* vary among the rows any one LIMIT cuts, which is the only thing this argument needs.
|
|
85
|
+
* That would make LIMIT a true ranking bound. It is not done here
|
|
86
|
+
* because `lib/inject-search-core.mjs:23-25` records this surface's "BM25-sort + JS
|
|
87
|
+
* scoring" composition as a deliberate per-surface asymmetry (#8786), and this face is
|
|
88
|
+
* one `benchmark/denoise-ab.mjs` is structurally blind to (its suites drive the
|
|
89
|
+
* search-engine, not this function) — re-ranking an unmeasurable face is how this
|
|
90
|
+
* project has repeatedly shipped regressions. Widening is monotone and provable;
|
|
91
|
+
* re-ranking needs a ruler that does not exist yet.
|
|
92
|
+
*
|
|
93
|
+
* WHY WIDENING IS SAFE: in practice the old window is a PREFIX of the new one (same plan,
|
|
94
|
+
* same ORDER BY, larger LIMIT), so the new candidate set is a superset. "Strict" would be
|
|
95
|
+
* overclaiming — `ORDER BY bm25(...)` carries no tiebreaker, and this release's own
|
|
96
|
+
* fixture lesson is that a degenerate corpus makes `bm25()` return 0.000 for every row and
|
|
97
|
+
* ranking fall to rowid. What is measured rather than argued: `rerank-pool-replay.mjs`
|
|
98
|
+
* reports nonEmptyToEmpty = 0 across the whole corpus, i.e. no prompt loses its injection
|
|
99
|
+
* to the widening. `scored` sorts by composite and
|
|
100
|
+
* the threshold filter is monotone in that score, so every row returned is at least as
|
|
101
|
+
* good as the row it displaced. The only non-monotone stage is the term-coverage filter,
|
|
102
|
+
* which is exactly why the pool needs slack rather than just `MAX_MEMORY_INJECTIONS`.
|
|
103
|
+
*/
|
|
104
|
+
const RERANK_POOL_SAME_PROJECT = 30;
|
|
105
|
+
const RERANK_POOL_CROSS_PROJECT = 15;
|
|
16
106
|
// Type weights come from scoring-sql.mjs — this was a hand-copy kept equal by an
|
|
17
107
|
// "aligned with (R2)" comment (audit 2026-08-22, P2-10).
|
|
18
108
|
// lesson_learned boost (1.5×) stacks for entries with a real takeaway.
|
|
@@ -225,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
225
315
|
AND ${liveObsFilterSql('o')}
|
|
226
316
|
AND ${notLowSignalTitleClause('o')}
|
|
227
317
|
ORDER BY ${OBS_BM25}
|
|
228
|
-
LIMIT
|
|
318
|
+
LIMIT ${RERANK_POOL_SAME_PROJECT}
|
|
229
319
|
`);
|
|
230
320
|
let rows = selectStmt.all(ftsQuery, project, cutoff);
|
|
231
321
|
let usedOrFallback = false;
|
|
@@ -280,7 +370,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
280
370
|
AND ${liveObsFilterSql('o')}
|
|
281
371
|
AND ${notLowSignalTitleClause('o')}
|
|
282
372
|
ORDER BY ${OBS_BM25}
|
|
283
|
-
LIMIT
|
|
373
|
+
LIMIT ${RERANK_POOL_CROSS_PROJECT}
|
|
284
374
|
`);
|
|
285
375
|
crossRows = crossStmt.all(ftsQuery, project, cutoff);
|
|
286
376
|
if (crossRows.length === 0) {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.85.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.85.1",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.85.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",
|
|
@@ -61,6 +61,31 @@ const RUNTIME_DIR = process.env.CLAUDE_MEM_RUNTIME_DIR || join(DATA_DIR, 'runtim
|
|
|
61
61
|
// which already imports lib modules here, and the inlined value silently encoded the
|
|
62
62
|
// same premise twice.
|
|
63
63
|
import { DEDUP_STALE_MS as CROSS_HOOK_DEDUP_MS } from './prompt-search-utils.mjs';
|
|
64
|
+
// Upper bound on the over-fetch the cross-hook dedup buys itself (ALGO-4). The dedup
|
|
65
|
+
// runs in JS after the SELECTs, so each LIMIT is raised by the seen-set size to keep the
|
|
66
|
+
// dedup a re-ranking rather than a truncation. This cap exists for one reason only: the
|
|
67
|
+
// seen-set is read from a file on disk, and a number off disk must never size a query.
|
|
68
|
+
//
|
|
69
|
+
// It is NOT a sufficiency argument, and the first version of this comment claimed one —
|
|
70
|
+
// "bounded by UPS's own per-prompt budget in practice (MAX_RESULTS 3)". That premise is
|
|
71
|
+
// false. `crossHookInjectedFile` is a UNION across hooks and calls inside the staleness
|
|
72
|
+
// window: `mergeCrossHookInjected` unions new ids into the old ones, UPS contributes up
|
|
73
|
+
// to MAX_RESULTS per prompt and this script contributes up to `mergeCap` per trigger, so
|
|
74
|
+
// nothing holds it at 3. Measured on this machine's `runtime/.claude-mem-injected-*`
|
|
75
|
+
// markers (2026-09-01): id-count histogram 1x9, 2x1, 3x2, 16x1 over n=13, and 1x11, 2x1,
|
|
76
|
+
// 3x1, 15x1 over n=14 an hour later. Read that as "3 is not a bound", not as a
|
|
77
|
+
// distribution: it is one developer machine, the tail entry is a single long agent session
|
|
78
|
+
// (on the re-measure the top entry was the measuring session itself), and the count is of
|
|
79
|
+
// ids IN THE FILE while `readCrossHookInjected` returns an EMPTY set for a payload whose
|
|
80
|
+
// `ts` is outside DEDUP_STALE_MS — file size and runtime seen-set size are not the same
|
|
81
|
+
// quantity.
|
|
82
|
+
//
|
|
83
|
+
// The residual failure mode that premise was hiding, derived from the arithmetic and NOT
|
|
84
|
+
// observed in the wild: at a seen-set of 16 the slack still caps at 5, so a Read fetches
|
|
85
|
+
// obsLimit = 6, and if all 6 are in the seen-set the face goes silent again — the exact
|
|
86
|
+
// failure ALGO-4 exists to fix. The cap is right (an unbounded LIMIT is worse), the
|
|
87
|
+
// reassurance was wrong.
|
|
88
|
+
const CROSS_HOOK_DEDUP_SLACK_MAX = 5;
|
|
64
89
|
// v2.33.1: cooldown path is session-scoped so same-file-twice within one
|
|
65
90
|
// session never re-injects (was: global file, 5-min window). Cross-session:
|
|
66
91
|
// fresh file, fresh nudges — this is intended. No session_id → fall back to
|
|
@@ -451,7 +476,21 @@ try {
|
|
|
451
476
|
(o.lesson_learned IS NOT NULL AND o.lesson_learned != '')
|
|
452
477
|
OR (o.type IN ('bugfix', 'decision') AND ${notLowSignalSql})
|
|
453
478
|
)`;
|
|
454
|
-
|
|
479
|
+
// Cross-hook dedup slack (audit 2026-08-29 ALGO-4, the D#172 shape again). The
|
|
480
|
+
// dedup below drops rows UPS already injected this prompt, and it used to run
|
|
481
|
+
// DOWNSTREAM of these LIMITs — so a deduped row left its slot EMPTY instead of
|
|
482
|
+
// yielding it to the next candidate, i.e. "dedup" was implemented as "shrink".
|
|
483
|
+
// On a Read (obsLimit 1 / eventsLimit 1) one dedup hit silenced the whole face.
|
|
484
|
+
// Read the seen-set FIRST and over-fetch by its size so the dedup removes rows
|
|
485
|
+
// from a pool that still has enough left to fill the cap. Capped at
|
|
486
|
+
// CROSS_HOOK_DEDUP_SLACK_MAX purely because the seen-set is read off disk and a
|
|
487
|
+
// number off disk must not size a query — NOT because the seen-set is small. It is
|
|
488
|
+
// a cross-hook union over the staleness window and was measured at up to 16 ids on
|
|
489
|
+
// this machine, so with the slack saturated a Read can still fetch fewer rows than
|
|
490
|
+
// the seen-set holds and go silent. See the constant's docblock.
|
|
491
|
+
const crossHookSeen = readCrossHookInjected(project, sessionId);
|
|
492
|
+
const dedupSlack = Math.min(crossHookSeen.size, CROSS_HOOK_DEDUP_SLACK_MAX);
|
|
493
|
+
const obsLimit = (isRead ? 1 : 2) + dedupSlack;
|
|
455
494
|
// A1.5 (v2.83.2): cite_factor as a tertiary sort key. When multiple file-
|
|
456
495
|
// matching lessons exist, the one with proven cite history outranks the
|
|
457
496
|
// merely-most-recent one. Single-match files unchanged (obsLimit=1 Read /
|
|
@@ -521,7 +560,7 @@ try {
|
|
|
521
560
|
? "AND body IS NOT NULL AND body != ''"
|
|
522
561
|
: `AND ((body IS NOT NULL AND body != '')
|
|
523
562
|
OR (event_type IN ('bugfix', 'decision', 'lesson') AND ${buildNotLowSignalSql('')}))`;
|
|
524
|
-
const eventsLimit = isRead ? 1 : 2;
|
|
563
|
+
const eventsLimit = (isRead ? 1 : 2) + dedupSlack;
|
|
525
564
|
let eventRows = [];
|
|
526
565
|
try {
|
|
527
566
|
eventRows = db.prepare(`
|
|
@@ -548,7 +587,7 @@ try {
|
|
|
548
587
|
// P1 (D#78): tag each row's source table — events share the numeric id
|
|
549
588
|
// space with observations, and the Stop-side edge attribution must never
|
|
550
589
|
// feed an event id into observation_files updates.
|
|
551
|
-
|
|
590
|
+
// (crossHookSeen is read above, before the two SELECTs — it sizes their LIMITs.)
|
|
552
591
|
const sourcedRows = [
|
|
553
592
|
...rows.map(r => ({ ...r, src: 'obs' })),
|
|
554
593
|
...eventRows.map(r => ({ ...r, src: 'evt' })),
|
|
@@ -50,6 +50,12 @@ const LOOKBACK_MS = 60 * DAY_MS; // 60 days
|
|
|
50
50
|
// quality gate — only BM25 ordering — so additional rows inflate noise without
|
|
51
51
|
// improving signal. Env-overridable for projects that want broader prompt recall.
|
|
52
52
|
const PROMPT_FALLBACK_LIMIT = Number(process.env.CLAUDE_MEM_UPS_PROMPT_FALLBACK_LIMIT || 1);
|
|
53
|
+
// Over-fetch factor for that cap. searchByUserPrompts filters rows in JS (cjkPrecisionOk)
|
|
54
|
+
// AFTER the SQL LIMIT, so the LIMIT bounds reachability, not just output width — see the
|
|
55
|
+
// comment at the query. These size the pool only; the function still returns at most
|
|
56
|
+
// PROMPT_FALLBACK_LIMIT rows, so widening them cannot inflate the injection budget.
|
|
57
|
+
const PROMPT_FALLBACK_POOL_FACTOR = 5;
|
|
58
|
+
const PROMPT_FALLBACK_POOL_MAX = 25;
|
|
53
59
|
|
|
54
60
|
// T3 (v2.31): per-row BM25 magnitude floor. OBS_BM25 (in scoring-sql.mjs)
|
|
55
61
|
// returns the raw bm25() value — negative, smaller = better. Multiplied by
|
|
@@ -263,6 +269,13 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
|
|
|
263
269
|
// constants here is what would let them drift apart again.
|
|
264
270
|
|
|
265
271
|
export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS !== '0';
|
|
272
|
+
// How far past the main LIMIT the bypass may look, and how many rows it may pull from
|
|
273
|
+
// there (ALGO-2). These size the CANDIDATE POOL only — the injected set is still capped
|
|
274
|
+
// by MAX_RESULTS downstream, so neither widens the injection budget. Kept small on
|
|
275
|
+
// purpose: rows this deep matched the prompt weakly overall, and the identifier hit is
|
|
276
|
+
// the only reason they are admitted at all.
|
|
277
|
+
const IDENTIFIER_BYPASS_POOL_EXTRA = 7;
|
|
278
|
+
const IDENTIFIER_BYPASS_DEEP_MAX = 2;
|
|
266
279
|
const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
|
|
267
280
|
|
|
268
281
|
// All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
|
|
@@ -426,12 +439,20 @@ function searchByUserPrompts(db, queryText, project, limit) {
|
|
|
426
439
|
LIMIT ?
|
|
427
440
|
`;
|
|
428
441
|
|
|
429
|
-
|
|
442
|
+
// Over-fetch, because the cjkPrecisionOk filter below runs in JS (audit 2026-08-29
|
|
443
|
+
// ALGO-5, the D#172 shape). At the shipped PROMPT_FALLBACK_LIMIT of 1 the SQL LIMIT
|
|
444
|
+
// was a REACHABILITY bound sitting upstream of a relevance filter: dropping one row
|
|
445
|
+
// dropped the whole face, so a CJK prompt whose best BM25 match happened to be a
|
|
446
|
+
// false bigram hit injected NOTHING even when rank 2 was a real match. Fetch a pool,
|
|
447
|
+
// filter, then take `limit` — same ORDER BY, so the old result is a prefix of this
|
|
448
|
+
// one and a row can only be added, never displaced by something worse.
|
|
449
|
+
const poolLimit = Math.min(limit * PROMPT_FALLBACK_POOL_FACTOR, PROMPT_FALLBACK_POOL_MAX);
|
|
450
|
+
let rows = db.prepare(sql).all(ftsQuery, project, cutoff, poolLimit);
|
|
430
451
|
|
|
431
452
|
if (rows.length === 0) {
|
|
432
453
|
const orQuery = relaxFtsQueryToOr(ftsQuery);
|
|
433
454
|
if (orQuery) {
|
|
434
|
-
try { rows = db.prepare(sql).all(orQuery, project, cutoff,
|
|
455
|
+
try { rows = db.prepare(sql).all(orQuery, project, cutoff, poolLimit); } catch {}
|
|
435
456
|
}
|
|
436
457
|
}
|
|
437
458
|
|
|
@@ -439,7 +460,7 @@ function searchByUserPrompts(db, queryText, project, limit) {
|
|
|
439
460
|
// FTS degrades CJK bigram queries to single-char AND, letting any prose
|
|
440
461
|
// sharing common chars leak through. Drop rows that miss < 20% of query
|
|
441
462
|
// bigrams/keywords as contiguous substrings. Non-CJK queries bypass.
|
|
442
|
-
return rows.filter(r => cjkPrecisionOk(queryText, r.prompt_text));
|
|
463
|
+
return rows.filter(r => cjkPrecisionOk(queryText, r.prompt_text)).slice(0, limit);
|
|
443
464
|
}
|
|
444
465
|
|
|
445
466
|
function searchRecent(db, project, limit) {
|
|
@@ -752,12 +773,24 @@ async function main() {
|
|
|
752
773
|
} else {
|
|
753
774
|
// FTS search: use the prompt as query, optionally type-filtered
|
|
754
775
|
const files = filesForGate;
|
|
755
|
-
|
|
776
|
+
const mainLimit = intent?.limit || MAX_RESULTS;
|
|
777
|
+
// Over-fetch ONLY to feed the identifier bypass below (audit 2026-08-29 ALGO-2,
|
|
778
|
+
// the D#172 shape). The bypass used to select from `ftsRows`, i.e. from the same
|
|
779
|
+
// LIMIT-`mainLimit` window it exists to rescue rows into — so it could only ever
|
|
780
|
+
// recover a row the composite sort had ALREADY ranked top-3, and the df=1
|
|
781
|
+
// identifier row its own docblock argues for (rare token, so BM25-strong on the
|
|
782
|
+
// term but easily out-ranked by rows matching more of the prompt) was unreachable.
|
|
783
|
+
// `ftsRows` stays the exact old head slice, so the main path is byte-identical;
|
|
784
|
+
// only the bypass sees deeper. Pool sizing is bypass-only: with the bypass off
|
|
785
|
+
// this is the old query verbatim.
|
|
786
|
+
const poolLimit = IDENTIFIER_BYPASS ? mainLimit + IDENTIFIER_BYPASS_POOL_EXTRA : mainLimit;
|
|
787
|
+
let ftsResult = searchByFts(db, promptText, project, poolLimit, intent?.type || null);
|
|
756
788
|
// Fallback: if typed search returned nothing, retry without type filter
|
|
757
789
|
if (ftsResult.rows.length === 0 && intent?.type) {
|
|
758
|
-
ftsResult = searchByFts(db, promptText, project,
|
|
790
|
+
ftsResult = searchByFts(db, promptText, project, poolLimit, null);
|
|
759
791
|
}
|
|
760
|
-
|
|
792
|
+
const ftsPool = ftsResult.rows;
|
|
793
|
+
let ftsRows = ftsPool.slice(0, mainLimit);
|
|
761
794
|
const ftsMode = ftsResult.mode;
|
|
762
795
|
const fileRows = files.length > 0 ? searchByFile(db, files, project, 2) : [];
|
|
763
796
|
|
|
@@ -775,9 +808,41 @@ async function main() {
|
|
|
775
808
|
// Capture rows that exact-match a prompt identifier BEFORE the set-floors below;
|
|
776
809
|
// they carry independent precision signal (sigRows/fileRows rationale) and are
|
|
777
810
|
// restored after the floors so a low top-score can't drop a named-identifier hit.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
811
|
+
// STRICTLY ADDITIVE to the pre-ALGO-2 behaviour: `head` is what this expression
|
|
812
|
+
// used to return (the post-floor rows of the old LIMIT window that match an
|
|
813
|
+
// identifier), and `deep` adds at most IDENTIFIER_BYPASS_DEEP_MAX rows from
|
|
814
|
+
// beyond that window. The audit prescribed a standalone LIMIT-2 SELECT; a capped
|
|
815
|
+
// tail of the SAME query is the same reach with one fewer FTS scan, and it cannot
|
|
816
|
+
// regress the head — a flat cap of 2 over the merged set could have, by evicting
|
|
817
|
+
// a third head row that ships today.
|
|
818
|
+
//
|
|
819
|
+
// "Additive" scopes to THIS SET, not to what finally ships. Downstream the merge
|
|
820
|
+
// appends `fileRows` after `ftsRows` (dedup by id) and then slices to MAX_RESULTS, and
|
|
821
|
+
// `deep` rows sort LAST within `ftsRows` (weaker |bm25|, ascending sort) — so a deep
|
|
822
|
+
// row takes a slot ahead of a file-recall row whenever `|head| < MAX_RESULTS` AND
|
|
823
|
+
// `|head| + |deep| + |fileRows| > MAX_RESULTS`, with at least one deep row and one
|
|
824
|
+
// fileRow present. The first condition is not redundant: `mainLimit` is
|
|
825
|
+
// `intent?.limit || MAX_RESULTS`, so head can already be 3 — and at head=3 the fileRow
|
|
826
|
+
// never boarded in the first place, so nothing is displaced. At head=2/deep=1/
|
|
827
|
+
// fileRows=1 the output is [h1, h2, d1] where it was [h1, h2, f1]; at
|
|
828
|
+
// head=1/deep=1/fileRows=1 nothing is displaced. The trade is one
|
|
829
|
+
// "filename matched, presumed deliberate" row for one "weak overall bm25, admitted
|
|
830
|
+
// only on an identifier hit" row. It is a real quality judgement and it is UNMEASURED
|
|
831
|
+
// — denoise-ab is structurally blind to this face. v3.85.0's release note called the
|
|
832
|
+
// whole change "strictly additive"; true of the bypass set, false of the output.
|
|
833
|
+
const bypassFloorOk = (r) => typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor;
|
|
834
|
+
let bypassRows = [];
|
|
835
|
+
if (IDENTIFIER_BYPASS && promptIdentifiers.length > 0) {
|
|
836
|
+
const head = ftsPool.slice(0, mainLimit)
|
|
837
|
+
.filter(bypassFloorOk)
|
|
838
|
+
.filter(r => rowMatchesIdentifier(r, promptIdentifiers));
|
|
839
|
+
const headIds = new Set(head.map(r => r.id));
|
|
840
|
+
const deep = ftsPool.slice(mainLimit)
|
|
841
|
+
.filter(bypassFloorOk)
|
|
842
|
+
.filter(r => !headIds.has(r.id) && rowMatchesIdentifier(r, promptIdentifiers))
|
|
843
|
+
.slice(0, IDENTIFIER_BYPASS_DEEP_MAX);
|
|
844
|
+
bypassRows = [...head, ...deep];
|
|
845
|
+
}
|
|
781
846
|
|
|
782
847
|
// v2.43.x: OR-mode raw-BM25 floor. In OR-fallback mode the composite
|
|
783
848
|
// TOP_REL_FLOOR below is inflated by importance × type_quality × decay
|