claude-mem-lite 6.7.0 → 6.7.2
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/hook-context.mjs +2 -1
- package/lib/file-edge-match.mjs +128 -0
- package/lib/import-jsonl.mjs +62 -23
- package/lib/recall-core.mjs +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/scripts/post-tool-recall.js +2 -1
- package/scripts/pre-tool-recall.js +2 -1
- package/scripts/user-prompt-search.js +48 -3
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"plugins": [
|
|
10
10
|
{
|
|
11
11
|
"name": "claude-mem-lite",
|
|
12
|
-
"version": "6.7.
|
|
12
|
+
"version": "6.7.2",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"homepage": "https://github.com/sdsrss/claude-mem-lite",
|
|
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)."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.7.
|
|
3
|
+
"version": "6.7.2",
|
|
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-context.mjs
CHANGED
|
@@ -580,7 +580,8 @@ export function buildSessionContextLines(
|
|
|
580
580
|
SELECT o.id, o.type, o.title, o.lesson_learned, o.files_modified FROM observations o
|
|
581
581
|
WHERE o.project = ? AND ${liveObsFilterSql('o')}
|
|
582
582
|
AND COALESCE(o.importance, 1) >= 2
|
|
583
|
-
|
|
583
|
+
AND ${notLowSignalTitleClause('o')}
|
|
584
|
+
ORDER BY o.created_at_epoch DESC, o.id DESC LIMIT ${KEY_CONTEXT_LIMIT}
|
|
584
585
|
`,
|
|
585
586
|
)
|
|
586
587
|
.all(project);
|
package/lib/file-edge-match.mjs
CHANGED
|
@@ -105,6 +105,134 @@ export function jsonArrayLikeNeedle(s) {
|
|
|
105
105
|
return likeLiteral(JSON.stringify(String(s ?? '')).slice(1, -1));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* How much a token LOOKS like a file path, from its shape alone. Higher wins.
|
|
110
|
+
*
|
|
111
|
+
* Only ever used to ORDER candidates, never to drop one — see rankFileCandidates.
|
|
112
|
+
* Every term below is a character-class property of the token, not a list of
|
|
113
|
+
* known-bad spellings: a blacklist is what the next unusual version string walks
|
|
114
|
+
* around, and this repo has thrown three hand-drawn classes away already.
|
|
115
|
+
*/
|
|
116
|
+
function fileShapeScore(token) {
|
|
117
|
+
const raw = String(token ?? '');
|
|
118
|
+
const name = basenameAnySep(raw);
|
|
119
|
+
const dot = name.lastIndexOf('.');
|
|
120
|
+
// `dot === 0` is a DOTFILE (`.env`, `.gitignore`, `.npmrc`), not an
|
|
121
|
+
// extensionless token — score `env` as the extension. The first cut rejected
|
|
122
|
+
// it with `dot <= 0`, which scored every dotfile 0, i.e. BELOW the version
|
|
123
|
+
// numbers this function exists to demote. Not theoretical: `extractFiles`
|
|
124
|
+
// emits `src/.env`, and six version tokens then evicted it from a window the
|
|
125
|
+
// pre-ranking code reached (pre-ship review, 2026-09-11).
|
|
126
|
+
if (dot < 0 || dot === name.length - 1) return 0;
|
|
127
|
+
const ext = name.slice(dot + 1);
|
|
128
|
+
let score = 0;
|
|
129
|
+
// A real extension starts with a LETTER. Version numbers ('v4.0.1' -> '1'),
|
|
130
|
+
// decimals and timestamp fragments ('…39.602Z' -> '602Z') do not — and those
|
|
131
|
+
// three are what `extractFiles`' regex actually emits on this corpus.
|
|
132
|
+
if (/^[A-Za-z]/.test(ext)) score += 2;
|
|
133
|
+
// Real extensions are short. A member expression borrows the letter-initial
|
|
134
|
+
// shape but not the length ('JSON.stringify', 'e.target_id').
|
|
135
|
+
if (ext.length <= 5) score += 1;
|
|
136
|
+
// A separator is the strongest evidence available without touching disk.
|
|
137
|
+
// Ask the token, and strip TRAILING separators first: comparing against the
|
|
138
|
+
// basename says yes for `foo.mjs/` too, because basenameAnySep strips those,
|
|
139
|
+
// so a token with no internal separator collected the bonus.
|
|
140
|
+
if (/[/\\]/.test(raw.replace(/[/\\]+$/, ''))) score += 2;
|
|
141
|
+
return score;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Order file candidates most-path-like first and drop exact repeats, for the
|
|
146
|
+
* callers that can only afford to probe the first few.
|
|
147
|
+
*
|
|
148
|
+
* `searchByFile` runs one prepared-statement execution per candidate on the
|
|
149
|
+
* UserPromptSubmit hot path, so it caps at FILE_PROBE_CAP — and it used to take
|
|
150
|
+
* the ones `extractFiles` happened to match FIRST, which is text order.
|
|
151
|
+
*
|
|
152
|
+
* Measured in one run over 216 live user_prompts (2026-09-11), denominator = the
|
|
153
|
+
* 50 prompts naming at least one file the corpus can reach, each lever isolated
|
|
154
|
+
* at the cap that ships:
|
|
155
|
+
*
|
|
156
|
+
* all reachable lost >=1 lost
|
|
157
|
+
* text order, cap 3 (pre-fix) 14 (28.0%) 34 (68.0%)
|
|
158
|
+
* text order, cap 6 7 (14.0%) 30 (60.0%)
|
|
159
|
+
* ranked, cap 6 (shipped) 2 ( 4.0%) 25 (50.0%)
|
|
160
|
+
*
|
|
161
|
+
* Neither lever alone gets there, and at cap 6 the ORDERING is the larger of
|
|
162
|
+
* the two. An earlier revision quoted "ranking alone 28.0% -> 24.0%", which was
|
|
163
|
+
* measured at cap 3 with a scorer that ranked dotfiles last: the wrong ablation
|
|
164
|
+
* on a window too small for ordering to matter.
|
|
165
|
+
*
|
|
166
|
+
* Oracle: a candidate counts as reachable when its basename matches one in
|
|
167
|
+
* `observation_files`. That is a strict SUPERSET of what the shipped query can
|
|
168
|
+
* return — it ignores the project, lookback, importance and low-signal
|
|
169
|
+
* predicates — measured at 26 of 152 candidates over-counted, with no false
|
|
170
|
+
* negatives (pre-ship review). Applied identically to every arm, so the
|
|
171
|
+
* direction holds; the exact percentages are not properties of the shipped
|
|
172
|
+
* query, and "flat beyond 6" is true of this oracle only (under the strict one
|
|
173
|
+
* the reviewer measured, the curve drops again at 10).
|
|
174
|
+
*
|
|
175
|
+
* The noise was never buying precision either: only 1 prompt reached
|
|
176
|
+
* `hasExplicitSignal` via extractFiles alone — that figure is from the audit's
|
|
177
|
+
* separate 533-prompt transcript corpus, NOT from the 216 rows above.
|
|
178
|
+
*
|
|
179
|
+
* SORT, not filter, and that is the safety property — with one caveat the
|
|
180
|
+
* caller owns: downstream of a `.slice(cap)` a position IS a candidate, so a
|
|
181
|
+
* wrong score can still evict. That is why fileShapeScore leans on structural
|
|
182
|
+
* properties and never on a list of known-bad spellings.
|
|
183
|
+
*
|
|
184
|
+
* Dedup folds ASCII A-Z ONLY, because that is the alphabet the SQL folds:
|
|
185
|
+
* SQLite's `COLLATE NOCASE` and `LIKE` are ASCII-case-insensitive (this file's
|
|
186
|
+
* own header, arm 1/2). JS `toLowerCase()` folds the whole Unicode table, so it
|
|
187
|
+
* collapsed `Ä.mjs` and `ä.mjs` into one probe while SQLite returns distinct
|
|
188
|
+
* rows for each — a DROP, which this function contractually never does.
|
|
189
|
+
* Unreachable through today's only caller (`extractFiles`' class is `[\w./-]`
|
|
190
|
+
* and `\w` is ASCII without the `u` flag), fixed because the exported contract
|
|
191
|
+
* is what the next caller reads.
|
|
192
|
+
*
|
|
193
|
+
* The index tiebreak keeps text order inside a tier explicitly rather than
|
|
194
|
+
* leaning on sort stability.
|
|
195
|
+
*/
|
|
196
|
+
export function rankFileCandidates(files) {
|
|
197
|
+
// A non-array would be iterated by character (`'a.mjs'` -> five candidates)
|
|
198
|
+
// or throw. One caller exists and it passes an array; fail closed for the next.
|
|
199
|
+
if (!Array.isArray(files)) return [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const uniq = [];
|
|
202
|
+
for (const f of files) {
|
|
203
|
+
const s = String(f ?? '');
|
|
204
|
+
if (!s) continue;
|
|
205
|
+
const key = s.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
206
|
+
if (seen.has(key)) continue;
|
|
207
|
+
seen.add(key);
|
|
208
|
+
uniq.push(s);
|
|
209
|
+
}
|
|
210
|
+
return uniq
|
|
211
|
+
.map((f, i) => ({ f, i, score: fileShapeScore(f) }))
|
|
212
|
+
.sort((a, b) => b.score - a.score || a.i - b.i)
|
|
213
|
+
.map((x) => x.f);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The path a tool-use touched, whichever key the tool spells it with.
|
|
218
|
+
*
|
|
219
|
+
* `Edit` / `Write` / `Read` carry `file_path`; `NotebookEdit` carries
|
|
220
|
+
* `notebook_path` and NEVER `file_path`. That one rule had three separate
|
|
221
|
+
* spellings in this repo (an inline `??` in each recall script, a regex
|
|
222
|
+
* alternation in lib/hook-stdin.mjs) and a FOURTH site that simply did not know
|
|
223
|
+
* it — lib/import-jsonl.mjs gated its file edges on `file_path` alone, so every
|
|
224
|
+
* imported notebook edit built no (obs,file) edge at all and was unreachable
|
|
225
|
+
* through the recall path this module exists to serve. A second copy is exactly
|
|
226
|
+
* what produced R12 B-1; this is the home.
|
|
227
|
+
*
|
|
228
|
+
* @param {object|null|undefined} input a tool-use `input` / `tool_input` object
|
|
229
|
+
* @returns {string|undefined} the path, or undefined when the shape carries none
|
|
230
|
+
*/
|
|
231
|
+
export function toolEditPath(input) {
|
|
232
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
233
|
+
return input.file_path ?? input.notebook_path;
|
|
234
|
+
}
|
|
235
|
+
|
|
108
236
|
/** Bind values for fileMatchClause, in placeholder order. */
|
|
109
237
|
export function fileMatchParams(filePath) {
|
|
110
238
|
const fname = basenameAnySep(filePath);
|
package/lib/import-jsonl.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import { readFileSync, statSync } from 'fs';
|
|
|
18
18
|
import { createHash } from 'crypto';
|
|
19
19
|
import { scrubSecrets } from '../secret-scrub.mjs';
|
|
20
20
|
import { scrubRecord } from './scrub-record.mjs';
|
|
21
|
+
import { toolEditPath } from './file-edge-match.mjs';
|
|
22
|
+
import { insertObservationFiles } from './observation-write.mjs';
|
|
21
23
|
|
|
22
24
|
const TOOL_TO_TYPE = {
|
|
23
25
|
Edit: 'change',
|
|
@@ -130,11 +132,16 @@ function importToolPair(db, toolUse, toolResult, project) {
|
|
|
130
132
|
? toolResult.content
|
|
131
133
|
: JSON.stringify(toolResult?.content ?? '').slice(0, 4000);
|
|
132
134
|
|
|
135
|
+
// D#35: this gated on `input.file_path` alone, and `NotebookEdit` carries
|
|
136
|
+
// `notebook_path` and never `file_path` — so the branch named NotebookEdit
|
|
137
|
+
// while being unable to fire for it, and every imported notebook edit built
|
|
138
|
+
// no (obs,file) edge. `toolEditPath` is the single home for that rule.
|
|
139
|
+
const editedPath = toolEditPath(toolUse.input);
|
|
133
140
|
const filesModified =
|
|
134
|
-
(toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') &&
|
|
135
|
-
? [
|
|
141
|
+
(toolName === 'Edit' || toolName === 'Write' || toolName === 'NotebookEdit') && editedPath
|
|
142
|
+
? [editedPath]
|
|
136
143
|
: [];
|
|
137
|
-
const filesRead = toolName === 'Read' &&
|
|
144
|
+
const filesRead = toolName === 'Read' && editedPath ? [editedPath] : [];
|
|
138
145
|
|
|
139
146
|
// `narrative` carries the body and `text` is the derived search blob
|
|
140
147
|
// (lib/observation-write.mjs rebuildObservationDerived). Writing the payload to `text`
|
|
@@ -144,7 +151,18 @@ function importToolPair(db, toolUse, toolResult, project) {
|
|
|
144
151
|
// write directly.
|
|
145
152
|
const body = `${inputJson}\n---\n${resultText}`;
|
|
146
153
|
const safe = scrubRecord('observations', {
|
|
147
|
-
|
|
154
|
+
// This string IS the cross-run dedup key: tryImportToolPair synthesizes it
|
|
155
|
+
// byte-for-byte from the same expression to decide whether a row was already
|
|
156
|
+
// imported, so the two sites must be widened TOGETHER or every import
|
|
157
|
+
// duplicates. An earlier cut of this round kept the title on `file_path`
|
|
158
|
+
// alone to avoid the migration — and shipped `NotebookEdit: ` with an empty
|
|
159
|
+
// label for exactly the rows D#35 had just made reachable. Pre-ship review:
|
|
160
|
+
// "the surface the round unblocked renders a row that identifies nothing."
|
|
161
|
+
//
|
|
162
|
+
// The cost is real and bounded: NotebookEdit rows imported before v6.7.2
|
|
163
|
+
// carry the old key, so the next import re-adds each of them ONCE and then
|
|
164
|
+
// matches forever. Stated in the CHANGELOG rather than absorbed silently.
|
|
165
|
+
title: `${toolName}: ${(toolUse.input?.command || toolEditPath(toolUse.input) || '').slice(0, 80)}`,
|
|
148
166
|
subtitle: '',
|
|
149
167
|
text: body,
|
|
150
168
|
narrative: body,
|
|
@@ -154,28 +172,49 @@ function importToolPair(db, toolUse, toolResult, project) {
|
|
|
154
172
|
search_aliases: null,
|
|
155
173
|
});
|
|
156
174
|
|
|
157
|
-
db
|
|
158
|
-
|
|
175
|
+
const inserted = db
|
|
176
|
+
.prepare(
|
|
177
|
+
`
|
|
159
178
|
INSERT INTO observations
|
|
160
179
|
(memory_session_id, project, text, type, title, subtitle, narrative, concepts, facts, files_read, files_modified, importance, created_at, created_at_epoch)
|
|
161
180
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
162
181
|
`,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
)
|
|
183
|
+
.run(
|
|
184
|
+
memId(sessionId),
|
|
185
|
+
project,
|
|
186
|
+
safe.text,
|
|
187
|
+
type,
|
|
188
|
+
safe.title,
|
|
189
|
+
safe.subtitle,
|
|
190
|
+
safe.narrative,
|
|
191
|
+
safe.concepts,
|
|
192
|
+
safe.facts,
|
|
193
|
+
JSON.stringify(filesRead),
|
|
194
|
+
JSON.stringify(filesModified),
|
|
195
|
+
1,
|
|
196
|
+
ts,
|
|
197
|
+
Date.parse(ts) || Date.now(),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Import wrote `files_modified` as a JSON column and stopped there, so no
|
|
201
|
+
// imported observation had a row in the `observation_files` junction — and
|
|
202
|
+
// that junction is what the file-recall paths JOIN. Two of them can now return
|
|
203
|
+
// an imported row: `recallByFile` (CLI `recall` + `mem_recall`) and
|
|
204
|
+
// `searchByFile` (the UserPromptSubmit leg). The pre-tool recall leg JOINs it
|
|
205
|
+
// too but still CANNOT — pre-ship review measured three independent structural
|
|
206
|
+
// gates, two of them literals: `importance >= 2` against the `1` written
|
|
207
|
+
// below, and `lesson_learned` non-empty OR `type IN ('bugfix','decision')`
|
|
208
|
+
// against a NULL lesson and a type that is only ever `change`/`discovery`.
|
|
209
|
+
// Do not list it as a beneficiary without changing one of those.
|
|
210
|
+
//
|
|
211
|
+
// Measured before the fix: an
|
|
212
|
+
// `Edit` with `file_path` set produced `files_modified=["/repo/alpha.mjs"]`
|
|
213
|
+
// and ZERO junction rows, so the defect was never NotebookEdit-specific —
|
|
214
|
+
// D#35 named a symptom of it. Same call and same list as the canonical save
|
|
215
|
+
// path (lib/save-observation.mjs:324), which is why `insertObs` in the test
|
|
216
|
+
// helpers mirrors it and no existing test could see the gap.
|
|
217
|
+
insertObservationFiles(db, Number(inserted.lastInsertRowid), filesModified);
|
|
179
218
|
return true;
|
|
180
219
|
}
|
|
181
220
|
|
|
@@ -256,7 +295,7 @@ export async function importJsonl(db, path, { project }) {
|
|
|
256
295
|
// Cross-call dedup: synthesize the title the previous run would have
|
|
257
296
|
// written and check the seenObs set seeded from the DB.
|
|
258
297
|
const toolName = useEv.name || 'unknown';
|
|
259
|
-
const titlePreview = `${toolName}: ${(useEv.input?.command || useEv.input
|
|
298
|
+
const titlePreview = `${toolName}: ${(useEv.input?.command || toolEditPath(useEv.input) || '').slice(0, 80)}`;
|
|
260
299
|
const ts = useEv.timestamp || new Date().toISOString();
|
|
261
300
|
// Match the storage convention from importToolPair (memId-prefixed) so
|
|
262
301
|
// the seenObs entries seeded from the DB can be matched on a re-run.
|
package/lib/recall-core.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function recallByFile(db, file, { limit = 10, includeNoise = false } = {}
|
|
|
42
42
|
WHERE ${liveObsFilterSql('o')}
|
|
43
43
|
AND ${fileMatchClause('of2')}
|
|
44
44
|
${noiseClause}
|
|
45
|
-
ORDER BY o.created_at_epoch DESC
|
|
45
|
+
ORDER BY o.importance DESC, o.created_at_epoch DESC, o.id DESC
|
|
46
46
|
LIMIT ?
|
|
47
47
|
`,
|
|
48
48
|
)
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "6.7.
|
|
3
|
+
"version": "6.7.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "6.7.
|
|
9
|
+
"version": "6.7.2",
|
|
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": "6.7.
|
|
3
|
+
"version": "6.7.2",
|
|
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,7 @@ import { queueHookContext, flushHookStdout } from '../lib/hook-stdout.mjs';
|
|
|
30
30
|
// P1-9: one bounded stdin reader. Import-free, like hook-stdout.mjs beside it.
|
|
31
31
|
import { readHookStdin, TOOL_INPUT_FILE_MAX_BYTES } from '../lib/hook-stdin.mjs';
|
|
32
32
|
import { cooldownPathFor as sharedCooldownPathFor } from '../lib/cooldown-path.mjs';
|
|
33
|
+
import { toolEditPath } from '../lib/file-edge-match.mjs';
|
|
33
34
|
|
|
34
35
|
const SALIENCE_BIND = process.env.CLAUDE_MEM_SALIENCE === 'bind';
|
|
35
36
|
|
|
@@ -59,7 +60,7 @@ async function main() {
|
|
|
59
60
|
// v6.7.0 and this leg did not, which is the repo's most repeated failure shape:
|
|
60
61
|
// a fix that closes ONE of the inputs reaching the same line. Caught in pre-ship
|
|
61
62
|
// review of that very round.
|
|
62
|
-
filePath = e.tool_input
|
|
63
|
+
filePath = toolEditPath(e.tool_input);
|
|
63
64
|
sessionId = e.session_id || null;
|
|
64
65
|
} catch {
|
|
65
66
|
return;
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
fileMatchParams,
|
|
26
26
|
basenameAnySep,
|
|
27
27
|
jsonArrayLikeNeedle,
|
|
28
|
+
toolEditPath,
|
|
28
29
|
} from '../lib/file-edge-match.mjs';
|
|
29
30
|
import { fileIntelFor } from '../lib/file-intel.mjs';
|
|
30
31
|
import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
|
|
@@ -406,7 +407,7 @@ try {
|
|
|
406
407
|
// additionalProperties:false. Reading only `file_path` made this hook a
|
|
407
408
|
// no-op on every .ipynb edit (R12 audit, partition B-2). utils.mjs's
|
|
408
409
|
// `case 'NotebookEdit'` already knew the shape differs; this leg did not.
|
|
409
|
-
filePath = event.tool_input
|
|
410
|
+
filePath = toolEditPath(event.tool_input);
|
|
410
411
|
sessionId = event.session_id || null;
|
|
411
412
|
toolName = event.tool_name || null;
|
|
412
413
|
const off = event.tool_input?.offset;
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
import { readHookStdin } from '../lib/hook-stdin.mjs';
|
|
19
19
|
import { resolveRuntimeDir } from '../lib/resolve-data-dir.mjs';
|
|
20
20
|
import { liveObsFilterSql, injectionRelevanceSql } from '../lib/inject-search-core.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
fileMatchClause,
|
|
23
|
+
fileMatchParams,
|
|
24
|
+
basenameAnySep,
|
|
25
|
+
rankFileCandidates,
|
|
26
|
+
} from '../lib/file-edge-match.mjs';
|
|
22
27
|
import { cjkPrecisionOk } from '../nlp.mjs';
|
|
23
28
|
import { upsFtsQuery } from '../lib/ups-query.mjs';
|
|
24
29
|
import { corpusFloorScale } from '../lib/relevance-floor.mjs';
|
|
@@ -465,6 +470,41 @@ export function searchByFts(
|
|
|
465
470
|
return { rows, mode };
|
|
466
471
|
}
|
|
467
472
|
|
|
473
|
+
/**
|
|
474
|
+
* How many candidates the file leg probes, one prepared-statement execution each.
|
|
475
|
+
*
|
|
476
|
+
* Was 3, chosen when the leg was written and never measured.
|
|
477
|
+
*
|
|
478
|
+
* R12 B-4 named both halves of the mechanism in its own title —
|
|
479
|
+
* `files.slice(0, 3)` AND `extractFiles` text order — but its prescribed REMEDY
|
|
480
|
+
* is ordering only, and ordering alone does not get there. Measured over 216
|
|
481
|
+
* live user_prompts (2026-09-11), denominator 50 (prompts naming >=1 reachable
|
|
482
|
+
* file), all-reachable-lost, one run, each lever isolated:
|
|
483
|
+
*
|
|
484
|
+
* text order, cap 3 (pre-fix) 28.0% text order, cap 6 14.0%
|
|
485
|
+
* ranked, cap 6 (shipped) 4.0%
|
|
486
|
+
*
|
|
487
|
+
* Decomposing the residue at cap 3 showed why ordering alone stalls: all 12
|
|
488
|
+
* still-harmed prompts were blocked by other file-SHAPED candidates and NONE by
|
|
489
|
+
* noise, and the 50-prompt denominator names a median of 3 distinct reachable
|
|
490
|
+
* files (mean 3.04) — more than the window held. Sweeping the cap, ranked arm:
|
|
491
|
+
*
|
|
492
|
+
* cap 3 -> 28.0% 4 -> 20.0% 5 -> 6.0% 6 -> 4.0% 8/10/12 -> 4.0%
|
|
493
|
+
*
|
|
494
|
+
* Six is the knee under this oracle (see rankFileCandidates for what the oracle
|
|
495
|
+
* over-counts, and why flatness past 6 is oracle-dependent). The residual two
|
|
496
|
+
* prompts name their shallowest reachable candidate 23 and 13 deep.
|
|
497
|
+
*
|
|
498
|
+
* Cost is linear in probes, and the ceiling is not the expectation: only 20.0%
|
|
499
|
+
* of candidate-bearing prompts have more than 3 unique candidates, so the mean
|
|
500
|
+
* is 0.56 extra probes per UserPromptSubmit (~31µs), against a ceiling of 3
|
|
501
|
+
* (+0.156ms measured on the live 41-observation store). On a synthetic
|
|
502
|
+
* 3747-observation store per-probe cost ranged 50-489µs, so the worst case is
|
|
503
|
+
* ~+1.5ms; that spread is an artifact of how the fixture was generated and is
|
|
504
|
+
* quoted as a bound, not as a property of any real corpus.
|
|
505
|
+
*/
|
|
506
|
+
const FILE_PROBE_CAP = 6;
|
|
507
|
+
|
|
468
508
|
function searchByFile(db, files, project, limit) {
|
|
469
509
|
if (files.length === 0) return [];
|
|
470
510
|
|
|
@@ -484,11 +524,16 @@ function searchByFile(db, files, project, limit) {
|
|
|
484
524
|
AND o.created_at_epoch > ?
|
|
485
525
|
AND ${fileMatchClause('of2')}
|
|
486
526
|
AND ${notLowSignalTitleClause('o')}
|
|
487
|
-
ORDER BY o.created_at_epoch DESC
|
|
527
|
+
ORDER BY o.importance DESC, o.created_at_epoch DESC, o.id DESC
|
|
488
528
|
LIMIT ?
|
|
489
529
|
`);
|
|
490
530
|
|
|
491
|
-
|
|
531
|
+
// Rank before capping (R12 B-4). `files` arrives in the order `extractFiles`
|
|
532
|
+
// matched it, which is TEXT order — so three version tokens ahead of the file
|
|
533
|
+
// the prompt is about evicted it from this window entirely. rankFileCandidates
|
|
534
|
+
// reorders and de-duplicates; it never drops, so the cap still sees every
|
|
535
|
+
// candidate it used to, just best-first.
|
|
536
|
+
for (const file of rankFileCandidates(files).slice(0, FILE_PROBE_CAP)) {
|
|
492
537
|
// Shared predicate (pre-tag review of v3.76.2, SF-1/S2). This leg used
|
|
493
538
|
// `file.split('/').pop()` — weaker than node:path `basename`, since it misses '\'
|
|
494
539
|
// even ON a Windows host — plus a bare `%<basename>` suffix LIKE with no path
|