claude-mem-lite 3.23.0 → 3.24.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/hook-memory.mjs +40 -0
- package/hook.mjs +24 -3
- package/lib/task-imperative.mjs +18 -0
- package/mem-cli.mjs +14 -0
- package/package.json +2 -1
- package/registry-github.mjs +8 -1
- package/scripts/pre-tool-recall.js +9 -4
- package/scripts/user-prompt-search.js +63 -0
- package/search-scoring.mjs +14 -2
- package/source-files.mjs +4 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.24.0",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "Persistent long-term memory for Claude Code via MCP — captures coding decisions, bugfixes, and context across sessions. Hybrid FTS5 + TF-IDF search with episode batching. Single SQLite DB, no external services. A lighter, lower-cost alternative to claude-mem (episode batching + a smaller model; cost savings are an internal estimate, not a measured benchmark)."
|
|
16
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.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/hook-memory.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, no
|
|
|
5
5
|
import { citeFactorJs } from './scoring-sql.mjs';
|
|
6
6
|
import { recordMetric } from './lib/metrics.mjs';
|
|
7
7
|
import { DB_DIR } from './schema.mjs';
|
|
8
|
+
import { extractIdents } from './lib/lesson-idents.mjs';
|
|
8
9
|
|
|
9
10
|
const MAX_MEMORY_INJECTIONS = 3;
|
|
10
11
|
const MEMORY_LOOKBACK_MS = 60 * 86400000; // 60 days
|
|
@@ -399,3 +400,42 @@ export function recallForFile(db, filePath, project) {
|
|
|
399
400
|
return [];
|
|
400
401
|
}
|
|
401
402
|
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Phase-2 task-imperative selection (spec 2026-06-29 §4.1): the single highest-value
|
|
406
|
+
* lesson relevant to THIS prompt, for delivery at the task-prompt position under the
|
|
407
|
+
* imperative template. Own gate (importance>=2 + non-empty lesson + identifier overlap
|
|
408
|
+
* with the prompt, top-1) — deliberately independent of searchRelevantMemories' coverage/
|
|
409
|
+
* BM25 filters so a high-value lesson is not dropped by the context-list scoring.
|
|
410
|
+
* @returns {{id:number, lesson_learned:string}|null}
|
|
411
|
+
*/
|
|
412
|
+
export function selectImperativeLesson(db, userPrompt, project, excludeIds = []) {
|
|
413
|
+
if (!db || !userPrompt) return null;
|
|
414
|
+
const promptIdents = new Set(extractIdents(userPrompt));
|
|
415
|
+
if (promptIdents.size === 0) return null; // no symbol anchor → no imperative (precision-first)
|
|
416
|
+
const exclude = new Set(excludeIds);
|
|
417
|
+
let rows;
|
|
418
|
+
try {
|
|
419
|
+
rows = db.prepare(`
|
|
420
|
+
SELECT id, title, lesson_learned, importance
|
|
421
|
+
FROM observations
|
|
422
|
+
WHERE project = ?
|
|
423
|
+
AND COALESCE(compressed_into, 0) = 0
|
|
424
|
+
AND COALESCE(importance, 1) >= 2
|
|
425
|
+
AND lesson_learned IS NOT NULL
|
|
426
|
+
AND TRIM(lesson_learned) != ''
|
|
427
|
+
AND LOWER(TRIM(lesson_learned)) != 'none'
|
|
428
|
+
ORDER BY importance DESC, created_at_epoch DESC
|
|
429
|
+
LIMIT 50
|
|
430
|
+
`).all(project);
|
|
431
|
+
} catch { return null; }
|
|
432
|
+
let best = null, bestScore = 0;
|
|
433
|
+
for (const r of rows) {
|
|
434
|
+
if (exclude.has(r.id)) continue;
|
|
435
|
+
const overlap = extractIdents(`${r.lesson_learned} ${r.title || ''}`).filter((id) => promptIdents.has(id)).length;
|
|
436
|
+
if (overlap === 0) continue;
|
|
437
|
+
const score = (r.importance || 2) * overlap;
|
|
438
|
+
if (score > bestScore) { bestScore = score; best = r; }
|
|
439
|
+
}
|
|
440
|
+
return best ? { id: best.id, lesson_learned: best.lesson_learned } : null;
|
|
441
|
+
}
|
package/hook.mjs
CHANGED
|
@@ -59,7 +59,8 @@ import {
|
|
|
59
59
|
hasMainThreadAssistantText,
|
|
60
60
|
} from './lib/citation-tracker.mjs';
|
|
61
61
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
62
|
-
import { searchRelevantMemories, formatMemoryLine } from './hook-memory.mjs';
|
|
62
|
+
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
63
|
+
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
63
64
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
64
65
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
65
66
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
@@ -1386,6 +1387,7 @@ async function handleUserPrompt() {
|
|
|
1386
1387
|
ORDER BY created_at_epoch DESC LIMIT 5
|
|
1387
1388
|
`).all(project);
|
|
1388
1389
|
const keyContextIds = keyObs.map(o => o.id);
|
|
1390
|
+
const pathAInjectedIds = [];
|
|
1389
1391
|
|
|
1390
1392
|
// Read IDs already injected by user-prompt-search.js to avoid duplicate injection
|
|
1391
1393
|
try {
|
|
@@ -1394,17 +1396,36 @@ async function handleUserPrompt() {
|
|
|
1394
1396
|
const { ids, ts } = JSON.parse(raw);
|
|
1395
1397
|
// Only use if written within last 10 seconds (same prompt cycle)
|
|
1396
1398
|
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)) {
|
|
1397
|
-
for (const id of ids) keyContextIds.push(id);
|
|
1399
|
+
for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
|
|
1398
1400
|
}
|
|
1399
1401
|
} catch { /* file may not exist — that's fine */ }
|
|
1400
1402
|
|
|
1401
|
-
|
|
1403
|
+
// Phase-2 task-imperative (default OFF — CLAUDE_MEM_TASK_IMPERATIVE): the single
|
|
1404
|
+
// highest-value lesson relevant to THIS prompt, delivered at the prompt position under
|
|
1405
|
+
// an imperative template. Excluded from the <memory-context> list so it is never
|
|
1406
|
+
// injected twice. Channel-isolation measure (efficacy arm U, 2026-06-29): task-prompt
|
|
1407
|
+
// 6-8/8 vs PreToolUse hook 0/8. Flipping the default ON is a separate L3 decision
|
|
1408
|
+
// gated on the live cite-recall canary.
|
|
1409
|
+
const taskImperativeOn = process.env.CLAUDE_MEM_TASK_IMPERATIVE === 'on'
|
|
1410
|
+
|| process.env.CLAUDE_MEM_TASK_IMPERATIVE === '1';
|
|
1411
|
+
// Exclude only ids path-A (user-prompt-search.js) already injected — NOT the
|
|
1412
|
+
// key-context top-5, which overlaps the high-value lesson pool and would suppress
|
|
1413
|
+
// the pick. The chosen id is excluded from the <memory-context> block below instead.
|
|
1414
|
+
const imperativePick = taskImperativeOn
|
|
1415
|
+
? selectImperativeLesson(db, promptText, project, pathAInjectedIds)
|
|
1416
|
+
: null;
|
|
1417
|
+
const contextExclude = imperativePick ? [...keyContextIds, imperativePick.id] : keyContextIds;
|
|
1418
|
+
|
|
1419
|
+
const memories = searchRelevantMemories(db, promptText, project, contextExclude);
|
|
1402
1420
|
if (memories.length > 0) {
|
|
1403
1421
|
const lines = ['<memory-context relevance="high">'];
|
|
1404
1422
|
for (const m of memories) lines.push(formatMemoryLine(m));
|
|
1405
1423
|
lines.push('</memory-context>');
|
|
1406
1424
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1407
1425
|
}
|
|
1426
|
+
if (imperativePick) {
|
|
1427
|
+
process.stdout.write(formatTaskImperative(imperativePick.lesson_learned, imperativePick.id) + '\n');
|
|
1428
|
+
}
|
|
1408
1429
|
} catch (e) { debugCatch(e, 'handleUserPrompt-memory'); }
|
|
1409
1430
|
} finally {
|
|
1410
1431
|
db.close();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// lib/task-imperative.mjs — pure formatter for the task-imperative memory line.
|
|
2
|
+
// Shared by the live UserPromptSubmit emitter (Phase 2) AND efficacy arm U (the
|
|
3
|
+
// measurement that gates Phase 2): ONE tested source of truth so the measured
|
|
4
|
+
// framing and the shipped framing cannot drift. Hot-path-shared → regex/string
|
|
5
|
+
// only, NO heavy imports (lesson #8447), mirroring lib/lesson-idents.mjs.
|
|
6
|
+
//
|
|
7
|
+
// Delivers a high-value lesson at the task-prompt position as an imperative,
|
|
8
|
+
// task-bound constraint: attribution kept (honest + #NN cite-traceable), the
|
|
9
|
+
// path-A/B softeners ("FYI", "continue your task", "NOT a new user message")
|
|
10
|
+
// dropped.
|
|
11
|
+
// Spec: docs/superpowers/specs/2026-06-29-task-imperative-memory-injection-design.md
|
|
12
|
+
|
|
13
|
+
export function formatTaskImperative(lesson, id) {
|
|
14
|
+
const body = String(lesson || '').trim().replace(/\.$/, '');
|
|
15
|
+
if (!body) return '';
|
|
16
|
+
const tag = (id === undefined || id === null || id === '') ? '' : ` (#${id})`;
|
|
17
|
+
return `Memory — a past lesson applies to THIS task. You must: ${body}.${tag}`;
|
|
18
|
+
}
|
package/mem-cli.mjs
CHANGED
|
@@ -1761,6 +1761,13 @@ function cmdRestore(db, argv) {
|
|
|
1761
1761
|
|
|
1762
1762
|
function cmdCompress(db, args) {
|
|
1763
1763
|
const { flags } = parseArgs(args);
|
|
1764
|
+
// Sibling-command flag footgun: compress executes with --execute (optimize uses
|
|
1765
|
+
// --run, maintain uses positional `execute`). A bare --run previously fell through to
|
|
1766
|
+
// a silent preview; fail fast pointing at the right flag. --execute still wins if both.
|
|
1767
|
+
if ((flags.run === true || flags.run === 'true') && flags.execute !== true && flags.execute !== 'true') {
|
|
1768
|
+
fail("[mem] compress executes with --execute, not --run (--run is optimize's flag). Re-run: claude-mem-lite compress --execute");
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1764
1771
|
const preview = flags.execute !== true && flags.execute !== 'true';
|
|
1765
1772
|
// Reject malformed --age-days explicitly. The prior fallback (`|| 30`) silently used
|
|
1766
1773
|
// the default whenever the value parsed as NaN or <1, so users typing `--age-days abc`
|
|
@@ -2857,6 +2864,13 @@ async function cmdEnrich(argv) {
|
|
|
2857
2864
|
async function cmdOptimize(db, args) {
|
|
2858
2865
|
const run = args.includes('--run');
|
|
2859
2866
|
const runAll = args.includes('--run-all');
|
|
2867
|
+
// Sibling-command flag footgun: optimize executes with --run (compress uses
|
|
2868
|
+
// --execute, maintain uses positional `execute`). A bare --execute previously fell
|
|
2869
|
+
// through to a silent preview, so the user thought they ran a mutation but didn't.
|
|
2870
|
+
if (args.includes('--execute') && !run && !runAll) {
|
|
2871
|
+
fail("[mem] optimize executes with --run, not --execute (--execute is compress's flag). Re-run: claude-mem-lite optimize --run");
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2860
2874
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
2861
2875
|
// T2-P1-D: --task accepts a single task or a comma-separated list, parity with MCP memOptimizeSchema.tasks.
|
|
2862
2876
|
const VALID_TASKS = ['re-enrich', 'normalize', 'cluster-merge', 'smart-compress'];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.24.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",
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"lib/reread-guard.mjs",
|
|
72
72
|
"lib/metrics.mjs",
|
|
73
73
|
"lib/lesson-idents.mjs",
|
|
74
|
+
"lib/task-imperative.mjs",
|
|
74
75
|
"lib/lesson-bridge.mjs",
|
|
75
76
|
"lib/binding-probe.mjs",
|
|
76
77
|
"lib/proc-lock.mjs",
|
package/registry-github.mjs
CHANGED
|
@@ -14,7 +14,14 @@ export function parseGitHubUrl(url) {
|
|
|
14
14
|
// ("main?x#y") and corrupts the GitHub API URL built from it, so the import
|
|
15
15
|
// fails with a confusing 404 on a URL that opens fine in the browser.
|
|
16
16
|
const clean = url.split(/[?#]/)[0];
|
|
17
|
-
|
|
17
|
+
// RFC 3986: scheme + host are case-insensitive, but the path (owner/repo/branch/dir)
|
|
18
|
+
// is NOT. Lowercase ONLY the scheme://github.com prefix — and only when github.com is
|
|
19
|
+
// the real host (followed by '/' or end, never as a substring of github.com.evil.com)
|
|
20
|
+
// — so a pasted "HTTPS://GitHub.com/…" (valid, opens in the browser) isn't rejected as
|
|
21
|
+
// "Invalid GitHub URL". The structural host check in the match below is unchanged, so
|
|
22
|
+
// github.com.evil.com / github.com@evil.com still fail.
|
|
23
|
+
const normalized = clean.replace(/^https?:\/\/github\.com(?=\/|$)/i, m => m.toLowerCase());
|
|
24
|
+
const match = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:\/tree\/([^/]+)(\/.*)?)?$/);
|
|
18
25
|
if (!match) return null;
|
|
19
26
|
const [, owner, repo, branch, pathRaw] = match;
|
|
20
27
|
return {
|
|
@@ -104,14 +104,19 @@ async function bridgeTopLesson(rows, changeText) {
|
|
|
104
104
|
|
|
105
105
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
106
106
|
|
|
107
|
+
// SYNC: must produce the SAME string as utils.mjs::inferProject (the path obs are SAVED
|
|
108
|
+
// under) and the bash post-tool-use.sh fast-path — recall queries the SAVE-path project.
|
|
109
|
+
// This previously used process.cwd() WITHOUT the process.env.PWD fallback the other two
|
|
110
|
+
// have, so under a symlinked project dir (PWD = logical/symlinked path, cwd = resolved)
|
|
111
|
+
// with CLAUDE_PROJECT_DIR unset it computed a DIFFERENT project than the save path and
|
|
112
|
+
// silently recalled nothing. Resolution order + sanitize + 100-char cap now match utils.
|
|
107
113
|
function inferProject() {
|
|
108
|
-
const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
114
|
+
const dir = process.env.CLAUDE_PROJECT_DIR || process.env.PWD || process.cwd();
|
|
109
115
|
const base = basename(dir);
|
|
110
116
|
const parent = basename(join(dir, '..'));
|
|
111
|
-
|
|
117
|
+
const raw = (parent && parent !== '.' && parent !== '/')
|
|
112
118
|
? `${parent}--${base}` : base;
|
|
113
|
-
|
|
114
|
-
return project;
|
|
119
|
+
return raw.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 100) || 'unknown';
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
function readCooldown(cooldownPath) {
|
|
@@ -197,6 +197,49 @@ export function hasExplicitSignal(text, { errSig, files, intent } = {}) {
|
|
|
197
197
|
return false;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// ─── Identifier-exact-match precision bypass (default OFF) ───────────────────
|
|
201
|
+
//
|
|
202
|
+
// CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1 enables it. Rationale: the score-floors below
|
|
203
|
+
// (OR_TOP_BM25_FLOOR / TOP_REL_FLOOR) drop the WHOLE FTS set when the top row's
|
|
204
|
+
// magnitude is weak. But a rare code identifier (camelCase / snake_case / CONST_CASE
|
|
205
|
+
// / kebab≥3) match has high *semantic* precision even at modest BM25 — a df=1 term
|
|
206
|
+
// like `sanitizeFtsQuery` scores only ~23 raw yet is an unambiguous hit. So an obs
|
|
207
|
+
// whose title/lesson EXACT-matches an identifier the prompt names is a precision pass
|
|
208
|
+
// — the same independent-signal rationale that already exempts sigRows (error
|
|
209
|
+
// signatures) and fileRows (file names) from these floors. When enabled, such rows are
|
|
210
|
+
// restored after the floors run.
|
|
211
|
+
//
|
|
212
|
+
// Default OFF — opt-in. benchmark/ups-ab.mjs measured it TRADEOFF on the real corpus
|
|
213
|
+
// (+3 recall recoveries / +4 injections over 12 positives / 8 hard-negatives); the
|
|
214
|
+
// "cost" was eager surfacing of on-identifier obs on non-recall-framed prompts, not
|
|
215
|
+
// off-topic noise. Kept opt-in (below the NET-POSITIVE/NEUTRAL ship bar). See #8858.
|
|
216
|
+
export const IDENTIFIER_BYPASS = process.env.CLAUDE_MEM_UPS_IDENTIFIER_BYPASS === '1';
|
|
217
|
+
const TECH_IDENTIFIER_RE_G = new RegExp(TECH_IDENTIFIER_RE.source, 'g');
|
|
218
|
+
|
|
219
|
+
// All tech-identifier tokens in `text`, lowercased + de-duped (for case-insensitive
|
|
220
|
+
// row matching). Empty array when none — callers treat that as "no bypass candidates".
|
|
221
|
+
export function extractTechIdentifiers(text) {
|
|
222
|
+
return [...new Set((String(text || '').match(TECH_IDENTIFIER_RE_G) || []).map(s => s.toLowerCase()))];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// True when the obs row's title or lesson contains any of `idsLower` as a standalone
|
|
226
|
+
// token (not embedded in a longer identifier). Title/lesson only — the searchByFts
|
|
227
|
+
// SELECT carries no narrative, and requiring the hit in the title/lesson is the
|
|
228
|
+
// stricter, higher-precision condition (intentional).
|
|
229
|
+
export function rowMatchesIdentifier(row, idsLower) {
|
|
230
|
+
if (!idsLower || idsLower.length === 0) return false;
|
|
231
|
+
const hay = `${row.title || ''} ${row.lesson_learned || ''}`.toLowerCase();
|
|
232
|
+
const isWordChar = (c) => c !== undefined && /[a-z0-9_]/.test(c);
|
|
233
|
+
return idsLower.some((id) => {
|
|
234
|
+
let from = 0, i;
|
|
235
|
+
while ((i = hay.indexOf(id, from)) >= 0) {
|
|
236
|
+
if (!isWordChar(hay[i - 1]) && !isWordChar(hay[i + id.length])) return true;
|
|
237
|
+
from = i + 1;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
200
243
|
// ─── DB Query Functions ─────────────────────────────────────────────────────
|
|
201
244
|
|
|
202
245
|
// Returns { rows, mode } where mode is 'AND' (initial pass), 'OR' (fallback
|
|
@@ -557,6 +600,9 @@ async function main() {
|
|
|
557
600
|
const signalPresent = hasExplicitSignal(promptText, {
|
|
558
601
|
errSig, files: filesForGate, intent,
|
|
559
602
|
});
|
|
603
|
+
// Identifier tokens the prompt names (for the precision bypass below). Empty
|
|
604
|
+
// unless CLAUDE_MEM_UPS_IDENTIFIER_BYPASS=1, so this is a no-op when disabled.
|
|
605
|
+
const promptIdentifiers = IDENTIFIER_BYPASS ? extractTechIdentifiers(promptText) : [];
|
|
560
606
|
|
|
561
607
|
if (intent?.useRecent) {
|
|
562
608
|
// Recall intent: show recent observations
|
|
@@ -588,6 +634,14 @@ async function main() {
|
|
|
588
634
|
typeof r.relevance === 'number' && Math.abs(r.relevance) >= bm25Floor
|
|
589
635
|
);
|
|
590
636
|
|
|
637
|
+
// Identifier-exact-match precision bypass (default off — see IDENTIFIER_BYPASS).
|
|
638
|
+
// Capture rows that exact-match a prompt identifier BEFORE the set-floors below;
|
|
639
|
+
// they carry independent precision signal (sigRows/fileRows rationale) and are
|
|
640
|
+
// restored after the floors so a low top-score can't drop a named-identifier hit.
|
|
641
|
+
const bypassRows = (IDENTIFIER_BYPASS && promptIdentifiers.length > 0)
|
|
642
|
+
? ftsRows.filter(r => rowMatchesIdentifier(r, promptIdentifiers))
|
|
643
|
+
: [];
|
|
644
|
+
|
|
591
645
|
// v2.43.x: OR-mode raw-BM25 floor. In OR-fallback mode the composite
|
|
592
646
|
// TOP_REL_FLOOR below is inflated by importance × type_quality × decay
|
|
593
647
|
// multipliers — a weak single-stem hit on an importance=3 bugfix obs
|
|
@@ -611,6 +665,15 @@ async function main() {
|
|
|
611
665
|
ftsRows = [];
|
|
612
666
|
}
|
|
613
667
|
|
|
668
|
+
// Restore identifier-matched precision rows the set-floors above dropped.
|
|
669
|
+
// No-op when the bypass is off (bypassRows is []) or when the floors kept the
|
|
670
|
+
// rows anyway (dedup by id). Re-sort so the merged set stays relevance-ordered.
|
|
671
|
+
if (bypassRows.length > 0) {
|
|
672
|
+
const kept = new Set(ftsRows.map(r => r.id));
|
|
673
|
+
for (const r of bypassRows) if (!kept.has(r.id)) ftsRows.push(r);
|
|
674
|
+
ftsRows.sort((a, b) => (a.relevance ?? 0) - (b.relevance ?? 0));
|
|
675
|
+
}
|
|
676
|
+
|
|
614
677
|
// Merge: FTS results first, then file results, deduplicated
|
|
615
678
|
const seen = new Set(ftsRows.map(r => r.id));
|
|
616
679
|
rows = [...ftsRows];
|
package/search-scoring.mjs
CHANGED
|
@@ -130,7 +130,8 @@ export function reRankWithContext(db, results, project) {
|
|
|
130
130
|
/**
|
|
131
131
|
* Mark older, lower-importance observations as superseded when multiple touch the same file.
|
|
132
132
|
* Mutates result objects in-place by adding superseded=true flag.
|
|
133
|
-
* @param {object[]} results Array of search result objects with source, files_modified,
|
|
133
|
+
* @param {object[]} results Array of search result objects with source, files_modified,
|
|
134
|
+
* created_at_epoch (or created_at / legacy date), importance
|
|
134
135
|
*/
|
|
135
136
|
export function markSuperseded(results) {
|
|
136
137
|
if (!results || results.length === 0) return;
|
|
@@ -150,7 +151,18 @@ export function markSuperseded(results) {
|
|
|
150
151
|
// Persistent superseding belongs in mem_maintain/mem_compress write paths.
|
|
151
152
|
for (const [, obsForFile] of fileMap) {
|
|
152
153
|
if (obsForFile.length < 2) continue;
|
|
153
|
-
|
|
154
|
+
// Sort newest-first by recency. The production search pipeline supplies
|
|
155
|
+
// created_at_epoch (numeric) + created_at (ISO) — NOT `date`; the prior
|
|
156
|
+
// `b.date` sort silently no-op'd there (every key undefined → localeCompare 0),
|
|
157
|
+
// so "newest" degraded to relevance order and a current top-importance obs got
|
|
158
|
+
// a false [SUPERSEDED] tag (#8821). Prefer numeric epoch, fall back to the ISO
|
|
159
|
+
// string (lexically chronological), then legacy `date` for older callers/tests.
|
|
160
|
+
obsForFile.sort((a, b) => {
|
|
161
|
+
const ka = a.created_at_epoch ?? a.created_at ?? a.date ?? '';
|
|
162
|
+
const kb = b.created_at_epoch ?? b.created_at ?? b.date ?? '';
|
|
163
|
+
if (typeof ka === 'number' && typeof kb === 'number') return kb - ka;
|
|
164
|
+
return String(kb).localeCompare(String(ka));
|
|
165
|
+
});
|
|
154
166
|
const newest = obsForFile[0];
|
|
155
167
|
for (let i = 1; i < obsForFile.length; i++) {
|
|
156
168
|
if ((obsForFile[i].importance ?? 1) <= (newest.importance ?? 1)) {
|
package/source-files.mjs
CHANGED
|
@@ -66,6 +66,10 @@ export const SOURCE_FILES = [
|
|
|
66
66
|
// are present in the pre-edit file (component 2). Imported ONLY by
|
|
67
67
|
// scripts/pre-tool-recall.js; kept here for the same reason as file-intel.mjs.
|
|
68
68
|
'lib/lesson-idents.mjs',
|
|
69
|
+
// Phase-2 task-imperative framing helper (2026-06-29): formatTaskImperative, the single
|
|
70
|
+
// source of the imperative line. Statically imported by hook.mjs (live emitter, gated by
|
|
71
|
+
// CLAUDE_MEM_TASK_IMPERATIVE) — must ship even with the flag off.
|
|
72
|
+
'lib/task-imperative.mjs',
|
|
69
73
|
// comprehension-bridge forcing-function (CLAUDE_MEM_SALIENCE=bridge): rewrites
|
|
70
74
|
// a recalled lesson into a check bound to the change hunk. Dynamic-imported by
|
|
71
75
|
// scripts/pre-tool-recall.js ONLY under the flag, but must still ship so the
|