claude-mem-lite 3.63.0 → 3.64.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 +6 -9
- package/hook.mjs +24 -21
- package/lib/browse-core.mjs +67 -0
- package/lib/delete-core.mjs +18 -1
- package/lib/get-core.mjs +33 -0
- package/lib/inject-search-core.mjs +84 -0
- package/lib/injected-ids.mjs +29 -0
- package/lib/observation-write.mjs +38 -2
- package/lib/search-core.mjs +10 -1
- package/mem-cli.mjs +62 -127
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/pre-skill-bridge.js +3 -1
- package/scripts/pre-tool-recall.js +13 -9
- package/scripts/prompt-search-utils.mjs +3 -0
- package/scripts/user-prompt-search.js +26 -25
- package/search-engine.mjs +21 -15
- package/server.mjs +43 -112
- package/source-files.mjs +14 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.64.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.64.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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { sanitizeFtsQuery, relaxFtsQueryToOr, debugCatch, truncate, OBS_BM25, notLowSignalTitleClause, noisePenaltyClause, tokenizeHandoff, HANDOFF_STOP_WORDS, extractCjkKeywords, neutralizeContextDelimiters, basenameAnySep } from './utils.mjs';
|
|
5
5
|
import { citeFactorJs } from './scoring-sql.mjs';
|
|
6
|
+
import { liveObsFilterSql } from './lib/inject-search-core.mjs';
|
|
6
7
|
import { recordMetric } from './lib/metrics.mjs';
|
|
7
8
|
import { DB_DIR } from './schema.mjs';
|
|
8
9
|
import { extractIdents } from './lib/lesson-idents.mjs';
|
|
@@ -218,8 +219,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
218
219
|
AND o.project = ?
|
|
219
220
|
AND o.importance >= 1
|
|
220
221
|
AND o.created_at_epoch > ?
|
|
221
|
-
AND
|
|
222
|
-
AND o.superseded_at IS NULL
|
|
222
|
+
AND ${liveObsFilterSql('o')}
|
|
223
223
|
AND ${notLowSignalTitleClause('o')}
|
|
224
224
|
ORDER BY ${OBS_BM25}
|
|
225
225
|
LIMIT 10
|
|
@@ -266,8 +266,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
266
266
|
AND o.type IN ('decision', 'discovery')
|
|
267
267
|
AND o.importance >= 2
|
|
268
268
|
AND o.created_at_epoch > ?
|
|
269
|
-
AND
|
|
270
|
-
AND o.superseded_at IS NULL
|
|
269
|
+
AND ${liveObsFilterSql('o')}
|
|
271
270
|
AND ${notLowSignalTitleClause('o')}
|
|
272
271
|
ORDER BY ${OBS_BM25}
|
|
273
272
|
LIMIT 5
|
|
@@ -316,7 +315,7 @@ export function searchRelevantMemories(db, userPrompt, project, excludeIds = [])
|
|
|
316
315
|
// Adaptive threshold: scales with corpus size to filter noise.
|
|
317
316
|
// Each result must individually exceed the threshold (not just the top one).
|
|
318
317
|
const obsCount = db.prepare(
|
|
319
|
-
|
|
318
|
+
`SELECT COUNT(*) as c FROM observations WHERE project = ? AND ${liveObsFilterSql('')}`,
|
|
320
319
|
).get(project)?.c || 0;
|
|
321
320
|
const { TINY, SMALL, MEDIUM, LARGE } = BM25_THRESHOLD;
|
|
322
321
|
const threshold = obsCount < 5 ? TINY : obsCount < 100 ? SMALL : obsCount < 500 ? MEDIUM : LARGE;
|
|
@@ -389,8 +388,7 @@ export function recallForFile(db, filePath, project) {
|
|
|
389
388
|
JOIN observation_files of2 ON of2.obs_id = o.id
|
|
390
389
|
WHERE o.project = ?
|
|
391
390
|
AND o.importance >= 2
|
|
392
|
-
AND
|
|
393
|
-
AND o.superseded_at IS NULL
|
|
391
|
+
AND ${liveObsFilterSql('o')}
|
|
394
392
|
AND o.created_at_epoch > ?
|
|
395
393
|
AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
|
|
396
394
|
ORDER BY o.created_at_epoch DESC
|
|
@@ -431,8 +429,7 @@ export function rankImperativeCandidates(db, userPrompt, project, excludeIds = [
|
|
|
431
429
|
SELECT id, title, lesson_learned, importance
|
|
432
430
|
FROM observations
|
|
433
431
|
WHERE project = ?
|
|
434
|
-
AND
|
|
435
|
-
AND superseded_at IS NULL
|
|
432
|
+
AND ${liveObsFilterSql('')}
|
|
436
433
|
AND COALESCE(importance, 1) >= 2
|
|
437
434
|
AND lesson_learned IS NOT NULL
|
|
438
435
|
AND TRIM(lesson_learned) != ''
|
package/hook.mjs
CHANGED
|
@@ -68,6 +68,8 @@ import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
|
68
68
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
69
69
|
import { gcOldMetricShards, recordMetric } from './lib/metrics.mjs';
|
|
70
70
|
import { detectMemOverride } from './lib/mem-override.mjs';
|
|
71
|
+
import { injectedIdsFileName } from './lib/injected-ids.mjs';
|
|
72
|
+
import { liveObsFilterSql, recencyDecaySql } from './lib/inject-search-core.mjs';
|
|
71
73
|
import { buildAndSaveHandoff, detectContinuationIntent, renderHandoffInjection, pickHandoffToInject, extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
72
74
|
import { checkForUpdate, getCachedUpdateBanner, isUpdateCheckDue } from './hook-update.mjs';
|
|
73
75
|
import { handleLLMOptimize } from './hook-optimize.mjs';
|
|
@@ -491,15 +493,12 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
491
493
|
-- only for symmetry: the block's own footer is a mem_get(ids=...) pointer, and a
|
|
492
494
|
-- COMPRESSED_PENDING_PURGE row is queued for deletion by maintain purge_stale,
|
|
493
495
|
-- so that pointer would resolve to nothing.
|
|
494
|
-
AND
|
|
495
|
-
AND o.superseded_at IS NULL
|
|
496
|
+
AND ${liveObsFilterSql('o')}
|
|
496
497
|
AND ${notLowSignalTitleClause('o')}
|
|
497
|
-
--
|
|
498
|
-
--
|
|
499
|
-
-- arbitrary epochs) made the exponent large-positive → EXP overflow → that row
|
|
500
|
-
-- pinned #1 for every error until its "future" passed (audit 2026-08-14 M-1).
|
|
498
|
+
-- Decay via the shared core (P2-11): the M-1 MAX(0,…) age clamp lives there.
|
|
499
|
+
-- Fixed 14d half-life (error recency matters more than obs type here).
|
|
501
500
|
ORDER BY ${OBS_BM25}
|
|
502
|
-
* (
|
|
501
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
|
|
503
502
|
LIMIT 3
|
|
504
503
|
`).all(ftsQuery, project, nowR);
|
|
505
504
|
|
|
@@ -857,16 +856,21 @@ function buildCiteRecallNudge(project) {
|
|
|
857
856
|
return libBuildCiteRecallNudge(project, RUNTIME_DIR);
|
|
858
857
|
}
|
|
859
858
|
|
|
860
|
-
// GC
|
|
861
|
-
// (
|
|
862
|
-
//
|
|
863
|
-
//
|
|
859
|
+
// GC stale per-session runtime files older than 24h: pre-recall cooldowns AND
|
|
860
|
+
// (D#120) the per-session injected-ids markers — both grow one file per session.
|
|
861
|
+
// Pulled out of pre-tool-recall.js (where it ran on every Edit, costing 15-30
|
|
862
|
+
// disk stats per call on long-lived projects) and consolidated here — once per
|
|
863
|
+
// SessionStart is enough to keep RUNTIME_DIR from growing unbounded.
|
|
864
864
|
const PRE_RECALL_COOLDOWN_STALE_MS = 24 * 60 * 60 * 1000;
|
|
865
865
|
function gcStalePreRecallCooldowns() {
|
|
866
866
|
try {
|
|
867
867
|
const now = Date.now();
|
|
868
868
|
for (const name of readdirSync(RUNTIME_DIR)) {
|
|
869
|
-
|
|
869
|
+
// D#120: the injected-ids marker is also per-session now — same growth
|
|
870
|
+
// shape as the cooldown files, same 24h GC (dedup window is 5 min).
|
|
871
|
+
const isCooldown = name.startsWith('pre-recall-cooldown-') && name.endsWith('.json');
|
|
872
|
+
const isInjectedMarker = name.startsWith('.claude-mem-injected-');
|
|
873
|
+
if (!isCooldown && !isInjectedMarker) continue;
|
|
870
874
|
try {
|
|
871
875
|
const p = join(RUNTIME_DIR, name);
|
|
872
876
|
const st = statSync(p);
|
|
@@ -1036,10 +1040,8 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1036
1040
|
JOIN observations b ON a.title = b.title AND a.project = b.project
|
|
1037
1041
|
AND a.id < b.id
|
|
1038
1042
|
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
1039
|
-
AND
|
|
1040
|
-
AND
|
|
1041
|
-
AND a.superseded_at IS NULL
|
|
1042
|
-
AND b.superseded_at IS NULL
|
|
1043
|
+
AND ${liveObsFilterSql('a')}
|
|
1044
|
+
AND ${liveObsFilterSql('b')}
|
|
1043
1045
|
LIMIT 20
|
|
1044
1046
|
`).all();
|
|
1045
1047
|
if (dupPairs.length > 0) {
|
|
@@ -1062,8 +1064,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1062
1064
|
const recent = db.prepare(`
|
|
1063
1065
|
SELECT id, title, importance, created_at_epoch, narrative, text
|
|
1064
1066
|
FROM observations
|
|
1065
|
-
WHERE
|
|
1066
|
-
AND superseded_at IS NULL
|
|
1067
|
+
WHERE ${liveObsFilterSql('')}
|
|
1067
1068
|
AND created_at_epoch > ?
|
|
1068
1069
|
AND title IS NOT NULL AND title != ''
|
|
1069
1070
|
ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
|
|
@@ -1661,12 +1662,14 @@ async function handleUserPrompt() {
|
|
|
1661
1662
|
|
|
1662
1663
|
// Read IDs already injected by user-prompt-search.js to avoid duplicate injection
|
|
1663
1664
|
try {
|
|
1664
|
-
|
|
1665
|
+
// D#120: the marker file is session-keyed (no ccSessionId → legacy
|
|
1666
|
+
// project-keyed name), so a concurrent session's write can no longer
|
|
1667
|
+
// replace this session's payload between the UPS write and this read.
|
|
1668
|
+
const injectedFile = join(RUNTIME_DIR, injectedIdsFileName(project, ccSessionId));
|
|
1665
1669
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
1666
1670
|
const { ids, ts, session } = JSON.parse(raw);
|
|
1667
1671
|
// Only use if written within last 10 seconds (same prompt cycle) AND by this
|
|
1668
|
-
// CC session
|
|
1669
|
-
// would otherwise dedup-suppress OUR injection (M-6, audit 2026-08-14).
|
|
1672
|
+
// CC session (M-6 payload gate, still load-bearing for legacy files).
|
|
1670
1673
|
// Legacy payloads without `session` keep the old time-window-only behavior.
|
|
1671
1674
|
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
|
|
1672
1675
|
&& !(session && ccSessionId && session !== ccSessionId)) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/browse-core.mjs — shared data collection for the CLI `browse` / MCP
|
|
2
|
+
// `mem_browse` twin (P2-12, audit 2026-08-14). The tier count + row queries were
|
|
3
|
+
// duplicated and had already drifted (the CLI SELECT carried `importance`, the
|
|
4
|
+
// MCP one had dropped it). Collection lives here with the superset column shape;
|
|
5
|
+
// each face keeps its own rendering (text dashboard vs --json vs MCP text).
|
|
6
|
+
|
|
7
|
+
import { TIER_CASE_SQL, tierSqlParams } from '../tier.mjs';
|
|
8
|
+
import { liveObsFilterSql } from './inject-search-core.mjs';
|
|
9
|
+
|
|
10
|
+
export const BROWSE_TIERS = ['working', 'active', 'archive'];
|
|
11
|
+
export const BROWSE_TIER_LABELS = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
|
|
12
|
+
|
|
13
|
+
/** Newest active memory_session_id for the project ('' when none) — the tier
|
|
14
|
+
* classifier's "current session" input, needed identically by both faces. */
|
|
15
|
+
export function getActiveMemorySessionId(db, project) {
|
|
16
|
+
const row = db.prepare(
|
|
17
|
+
"SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
|
|
18
|
+
).get(project);
|
|
19
|
+
return row?.memory_session_id ?? '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collect per-tier counts + rows for the memory dashboard.
|
|
24
|
+
* Archive keeps its count but skips row fetch in the unfiltered view (both faces'
|
|
25
|
+
* documented behavior — the archive tail is reachable via `browse --tier archive`).
|
|
26
|
+
* @returns {{showTiers: string[], tierData: object, tierCounts: object, grandTotal: number}}
|
|
27
|
+
*/
|
|
28
|
+
export function collectBrowseTiers(db, { project, tierFilter, limit, now, currentSessionId }) {
|
|
29
|
+
const ctx = { now, currentProject: project, currentSessionId };
|
|
30
|
+
const params = tierSqlParams(ctx);
|
|
31
|
+
const showTiers = tierFilter ? [tierFilter] : BROWSE_TIERS;
|
|
32
|
+
|
|
33
|
+
const tierData = {};
|
|
34
|
+
const tierCounts = {};
|
|
35
|
+
let grandTotal = 0;
|
|
36
|
+
|
|
37
|
+
for (const tier of showTiers) {
|
|
38
|
+
const countRow = db.prepare(`
|
|
39
|
+
SELECT COUNT(*) as c FROM (
|
|
40
|
+
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
41
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
42
|
+
) WHERE tier = ?
|
|
43
|
+
`).get(...params, project, tier);
|
|
44
|
+
const count = countRow?.c ?? 0;
|
|
45
|
+
tierCounts[tier] = count;
|
|
46
|
+
grandTotal += count;
|
|
47
|
+
|
|
48
|
+
const skipRows = tier === 'archive' && !tierFilter;
|
|
49
|
+
if (count === 0 || skipRows) {
|
|
50
|
+
tierData[tier] = { count, rows: [] };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rows = db.prepare(`
|
|
55
|
+
SELECT * FROM (
|
|
56
|
+
SELECT id, type, title, importance, created_at, created_at_epoch, ${TIER_CASE_SQL} as tier
|
|
57
|
+
FROM observations
|
|
58
|
+
WHERE project = ? AND ${liveObsFilterSql('')}
|
|
59
|
+
) WHERE tier = ?
|
|
60
|
+
ORDER BY created_at_epoch DESC
|
|
61
|
+
LIMIT ?
|
|
62
|
+
`).all(...params, project, tier, limit);
|
|
63
|
+
tierData[tier] = { count, rows };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { showTiers, tierData, tierCounts, grandTotal };
|
|
67
|
+
}
|
package/lib/delete-core.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// merged/compressed-child recovery, and the delete transaction.
|
|
8
8
|
import { snapshotDb } from './db-backup.mjs';
|
|
9
9
|
import { recoverChildrenOf } from './maintain-core.mjs';
|
|
10
|
-
import { debugCatch } from '../utils.mjs';
|
|
10
|
+
import { debugCatch, truncate } from '../utils.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Hard-delete the given observation ids with full orchestration. The CALLER owns
|
|
@@ -70,3 +70,20 @@ export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {})
|
|
|
70
70
|
const result = deleteTx();
|
|
71
71
|
return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shared delete-preview body (P2-12): fetch the doomed rows and format the
|
|
76
|
+
* per-row lines both faces print between their own header ("Preview: N …")
|
|
77
|
+
* and footer (--confirm vs confirm=true remedy). The SELECT and the row shape
|
|
78
|
+
* were duplicated in mem-cli.mjs + server.mjs and are the exact place a
|
|
79
|
+
* preview/execute drift would hide.
|
|
80
|
+
* @param {import('better-sqlite3').Database} db
|
|
81
|
+
* @param {number[]} ids
|
|
82
|
+
* @returns {{rows: Array<{id:number,type:string,title:string,project:string}>, lines: string[]}}
|
|
83
|
+
*/
|
|
84
|
+
export function previewDeleteRows(db, ids) {
|
|
85
|
+
const ph = ids.map(() => '?').join(',');
|
|
86
|
+
const rows = db.prepare(`SELECT id, type, title, project FROM observations WHERE id IN (${ph})`).all(...ids);
|
|
87
|
+
const lines = rows.map((r) => ` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
|
|
88
|
+
return { rows, lines };
|
|
89
|
+
}
|
package/lib/get-core.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// lib/get-core.mjs — shared core for the CLI `get` / MCP `mem_get` twin (P2-12,
|
|
2
|
+
// audit 2026-08-14). The 23-element OBS_FIELDS array was duplicated verbatim in
|
|
3
|
+
// mem-cli.mjs and server.mjs (the 16-vs-24-column export data-loss incident's
|
|
4
|
+
// precursor shape), and the session detail field sets had ALREADY diverged
|
|
5
|
+
// (MCP 13 fields vs CLI 6 — a `remaining_items` FTS hit was a dead end in the
|
|
6
|
+
// CLI detail view). Field sets + the access-bump fetch live here; each face
|
|
7
|
+
// keeps its own header/label rendering conventions.
|
|
8
|
+
|
|
9
|
+
import { autoBoostIfNeeded } from '../search-scoring.mjs';
|
|
10
|
+
|
|
11
|
+
/** Every observation column `get --fields` accepts, in render order. */
|
|
12
|
+
export const OBS_FIELDS = ['id', 'type', 'title', 'subtitle', 'narrative', 'text', 'facts', 'concepts', 'lesson_learned', 'search_aliases', 'files_read', 'files_modified', 'project', 'created_at', 'memory_session_id', 'prompt_number', 'importance', 'related_ids', 'access_count', 'branch', 'superseded_at', 'superseded_by', 'last_accessed_at'];
|
|
13
|
+
|
|
14
|
+
/** Session-summary detail render set — the FULL set (both faces). The CLI's old
|
|
15
|
+
* 6-field subset made notes/remaining_items/files_* searchable-but-unrenderable. */
|
|
16
|
+
export const SESSION_DETAIL_FIELDS = ['id', 'request', 'investigated', 'learned', 'completed', 'next_steps', 'remaining_items', 'files_read', 'files_edited', 'notes', 'project', 'created_at', 'memory_session_id', 'prompt_number'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch observation detail rows: bump access_count/last_accessed_at (reading a
|
|
20
|
+
* detail IS an access signal — feeds noisePenalty's ratio guard), run the
|
|
21
|
+
* auto-boost heuristic, and return rows oldest-first.
|
|
22
|
+
* @param {import('better-sqlite3').Database} db
|
|
23
|
+
* @param {number[]} ids
|
|
24
|
+
* @returns {object[]} full observation rows (SELECT *), created order
|
|
25
|
+
*/
|
|
26
|
+
export function fetchObsDetail(db, ids) {
|
|
27
|
+
const ph = ids.map(() => '?').join(',');
|
|
28
|
+
try {
|
|
29
|
+
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${ph})`).run(Date.now(), ...ids);
|
|
30
|
+
autoBoostIfNeeded(db, ids);
|
|
31
|
+
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
32
|
+
return db.prepare(`SELECT * FROM observations WHERE id IN (${ph}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
33
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// lib/inject-search-core.mjs — the injection-side shared core (P2-11, audit
|
|
2
|
+
// 2026-08-14). Shared home for the three SQL atoms that kept drifting across
|
|
3
|
+
// hand-copied twins on the INJECTION-side retrieval surfaces (the five consumer
|
|
4
|
+
// files the ledger test enforces — NOT yet the whole read surface: recall-core /
|
|
5
|
+
// recent-core / timeline-core / hook-context / hook-handoff / hook-optimize /
|
|
6
|
+
// deep-search / stats and the sessions/events decay arms in lib/search-core
|
|
7
|
+
// still inline their own copies; extending them is the deferred second cut):
|
|
8
|
+
//
|
|
9
|
+
// * live-row filter — the compressed+superseded pair whose omission was
|
|
10
|
+
// the superseded-invariant's recurring reopening
|
|
11
|
+
// (7th occurrence fixed in v3.63 H-1, write-side)
|
|
12
|
+
// * clamped recency decay — the MAX(0,…) age clamp (audit M-1: a far-future
|
|
13
|
+
// created_at from restore/import-jsonl EXP-overflowed
|
|
14
|
+
// and pinned that row #1; the fix reached
|
|
15
|
+
// search-engine but not the UPS/error-recall twins)
|
|
16
|
+
// * injection relevance — the full multiplicative chain incl. cite/noise
|
|
17
|
+
// behavior factors (audit M-3: wired on every auto
|
|
18
|
+
// surface, missing from the explicit-surface score)
|
|
19
|
+
//
|
|
20
|
+
// Consumers: scripts/user-prompt-search.js, scripts/pre-tool-recall.js,
|
|
21
|
+
// hook-memory.mjs, hook.mjs (error-recall), search-engine.mjs. Each surface keeps
|
|
22
|
+
// its own deliberate pipeline composition (BM25-sort + JS scoring vs SQL full
|
|
23
|
+
// chain vs file-keyed sort — see #8786: per-surface asymmetries stay explicit);
|
|
24
|
+
// only the ATOMS are shared. tests/inject-search-core.test.mjs holds the ledger:
|
|
25
|
+
// the five consumer files must compose these builders, never re-inline copies.
|
|
26
|
+
//
|
|
27
|
+
// Lives under lib/ (not scripts/) so hook.mjs can statically import it without
|
|
28
|
+
// colliding with the installExtractedRelease scripts-dir rename (same constraint
|
|
29
|
+
// as lib/mem-override.mjs). Dependency-light by design: pre-tool-recall's
|
|
30
|
+
// standalone fast-path (#8447) already imports scoring-sql.mjs.
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
OBS_BM25, TYPE_DECAY_CASE, TYPE_QUALITY_CASE,
|
|
34
|
+
noisePenaltyClause, citeFactorClause,
|
|
35
|
+
} from '../scoring-sql.mjs';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Live-row filter: rows a model-facing retrieval surface may return. Excludes
|
|
39
|
+
* compression tombstones (positive keeper ids AND -2 pending-purge) and
|
|
40
|
+
* superseded rows (a retracted lesson must never outrank its correction).
|
|
41
|
+
* @param {string} [alias='o'] table alias; '' for unqualified single-table queries
|
|
42
|
+
* @returns {string} SQL boolean expression (no leading AND)
|
|
43
|
+
*/
|
|
44
|
+
export function liveObsFilterSql(alias = 'o') {
|
|
45
|
+
const a = alias ? `${alias}.` : '';
|
|
46
|
+
return `COALESCE(${a}compressed_into, 0) = 0 AND ${a}superseded_at IS NULL`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clamped recency-decay factor: (1 + EXP(-ln2 · age / halfLife)), age >= 0.
|
|
51
|
+
* The MAX(0,…) clamp is the M-1 fix — restore/import-jsonl accept arbitrary
|
|
52
|
+
* epochs, and an unclamped future timestamp makes the exponent large-positive →
|
|
53
|
+
* EXP overflows to +Infinity → that row sorts #1 for every query (and its score
|
|
54
|
+
* serializes as null). A future row reads as age 0 = max finite recency instead.
|
|
55
|
+
* Binds one `?` placeholder: the caller passes `now` (epoch ms) at that position.
|
|
56
|
+
* @param {object} opts
|
|
57
|
+
* @param {string} opts.tsExpr - SQL expression for the row's reference timestamp
|
|
58
|
+
* (e.g. 'o.created_at_epoch', or the created/last-accessed MAX search-engine uses)
|
|
59
|
+
* @param {string} [opts.halfLifeSql=TYPE_DECAY_CASE] - SQL expression for the
|
|
60
|
+
* half-life in ms (constant or per-type CASE; TYPE_DECAY_CASE assumes alias 'o')
|
|
61
|
+
* @returns {string} SQL numeric expression (parenthesized)
|
|
62
|
+
*/
|
|
63
|
+
export function recencyDecaySql({ tsExpr, halfLifeSql = TYPE_DECAY_CASE }) {
|
|
64
|
+
return `(1.0 + EXP(-0.693 * MAX(0, ? - ${tsExpr}) / ${halfLifeSql}))`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Injection relevance: the full multiplicative chain the prompt-time injection
|
|
69
|
+
* surface (UPS searchByFts) ranks by — BM25 × clamped type-decay × type-quality
|
|
70
|
+
* × importance × noise penalty × cite factor. Alias is fixed to 'o' because
|
|
71
|
+
* TYPE_DECAY_CASE / TYPE_QUALITY_CASE bake that alias in.
|
|
72
|
+
* Binds one `?` placeholder (the decay `now`), first in parameter order.
|
|
73
|
+
* @param {string} [alias='o'] must be 'o'
|
|
74
|
+
* @returns {string} SQL numeric expression
|
|
75
|
+
*/
|
|
76
|
+
export function injectionRelevanceSql(alias = 'o') {
|
|
77
|
+
if (alias !== 'o') throw new Error('injectionRelevanceSql: alias must be "o" (TYPE_*_CASE bake it in)');
|
|
78
|
+
return `${OBS_BM25}
|
|
79
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch' })}
|
|
80
|
+
* ${TYPE_QUALITY_CASE}
|
|
81
|
+
* (0.5 + 0.5 * COALESCE(o.importance, 1))
|
|
82
|
+
* ${noisePenaltyClause('o')}
|
|
83
|
+
* ${citeFactorClause('o')}`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// lib/injected-ids.mjs — file-name derivation for the cross-hook injected-ids
|
|
2
|
+
// dedup marker. Single source of truth for user-prompt-search.js (writer),
|
|
3
|
+
// pre-tool-recall.js (read/merge), and hook.mjs (path-A reader): all three must
|
|
4
|
+
// derive the same name or cross-hook dedup silently goes blind.
|
|
5
|
+
//
|
|
6
|
+
// D#120: M-6 session-keyed the marker's PAYLOAD but kept ONE file per project,
|
|
7
|
+
// so two concurrent CC windows full-replaced each other's marker — no dedup
|
|
8
|
+
// between them and `count` reset on every alternation (MAX_SESSION_INJECTIONS
|
|
9
|
+
// unreachable). One file per SESSION instead, mirroring
|
|
10
|
+
// pre-recall-cooldown-<session>.json in the same runtime dir. GC: session-start
|
|
11
|
+
// sweep in hook.mjs (24h mtime, same policy as the cooldown files).
|
|
12
|
+
//
|
|
13
|
+
// Lives under lib/ (not scripts/) so hook.mjs can statically import it without
|
|
14
|
+
// colliding with the scripts/ directory rename in installExtractedRelease —
|
|
15
|
+
// same constraint as lib/mem-override.mjs.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runtime-dir FILE NAME for the injected-ids marker (no directory component).
|
|
19
|
+
* No sessionId → legacy project-keyed name (env-less harnesses, old callers).
|
|
20
|
+
* @param {string} project - inferProject() value (already filename-safe)
|
|
21
|
+
* @param {string} [sessionId] - CC session id
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function injectedIdsFileName(project, sessionId) {
|
|
25
|
+
const base = `.claude-mem-injected-${project}`;
|
|
26
|
+
if (!sessionId) return base;
|
|
27
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
|
|
28
|
+
return `${base}-${safe}`;
|
|
29
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// + vector writes in one db.transaction so a failure can't leave a partial row).
|
|
10
10
|
|
|
11
11
|
import { getVocabulary, computeVector, vectorsEnabled } from '../tfidf.mjs';
|
|
12
|
-
import { debugCatch, cjkBigrams } from '../utils.mjs';
|
|
12
|
+
import { debugCatch, cjkBigrams, scrubSecrets } from '../utils.mjs';
|
|
13
13
|
|
|
14
14
|
// Canonical column order — must mirror the observations schema (schema.mjs).
|
|
15
15
|
const OBS_COLUMNS = [
|
|
@@ -84,7 +84,9 @@ export function insertObservationVector(db, obsId, vecText) {
|
|
|
84
84
|
* block — the same drift class #8614/#8639 closed for compress/maintain. Caller
|
|
85
85
|
* owns the transaction (vector write is internally non-critical).
|
|
86
86
|
*/
|
|
87
|
-
|
|
87
|
+
// P2-12: internal-only since applyObsUpdate became the single update choke point
|
|
88
|
+
// (both faces previously imported this directly; un-exported per knip discipline).
|
|
89
|
+
function rebuildObservationDerived(db, obsId) {
|
|
88
90
|
const row = db.prepare('SELECT title, subtitle, narrative, concepts, facts, lesson_learned, search_aliases FROM observations WHERE id = ?').get(obsId);
|
|
89
91
|
if (!row) return;
|
|
90
92
|
const base = [row.title, row.subtitle, row.narrative, row.concepts, row.facts, row.lesson_learned, row.search_aliases].filter(Boolean).join(' ');
|
|
@@ -93,3 +95,37 @@ export function rebuildObservationDerived(db, obsId) {
|
|
|
93
95
|
db.prepare('UPDATE observations SET text = ? WHERE id = ?').run(textField, obsId);
|
|
94
96
|
insertObservationVector(db, obsId, textField);
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
// P2-12 (audit 2026-08-14): shared update mutation for the CLI `update` / MCP
|
|
100
|
+
// `mem_update` twin. Both faces previously built the SET list + ran the
|
|
101
|
+
// transaction + rebuild inline (byte-equivalent copies); each keeps its own
|
|
102
|
+
// validation front-end (CLI flag guards / MCP zod) and passes only the fields
|
|
103
|
+
// it accepted. String values are secret-scrubbed here — the single choke point,
|
|
104
|
+
// so a new face can't forget it (concepts had already slipped through once).
|
|
105
|
+
const UPDATABLE_OBS_COLS = ['title', 'narrative', 'type', 'importance', 'lesson_learned', 'concepts'];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apply a validated field patch to one observation: UPDATE + derived-column
|
|
109
|
+
* rebuild (FTS text + vector) in one transaction.
|
|
110
|
+
* @param {import('better-sqlite3').Database} db
|
|
111
|
+
* @param {number} id - observation id (caller has verified existence)
|
|
112
|
+
* @param {object} fields - subset of {title, narrative, type, importance, lesson_learned, concepts}
|
|
113
|
+
* @returns {string[]} column names actually updated ([] = nothing to do, no write)
|
|
114
|
+
*/
|
|
115
|
+
export function applyObsUpdate(db, id, fields) {
|
|
116
|
+
const updates = [];
|
|
117
|
+
const params = [];
|
|
118
|
+
for (const col of UPDATABLE_OBS_COLS) {
|
|
119
|
+
if (fields[col] !== undefined) {
|
|
120
|
+
updates.push(`${col} = ?`);
|
|
121
|
+
params.push(typeof fields[col] === 'string' ? scrubSecrets(fields[col]) : fields[col]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (updates.length === 0) return [];
|
|
125
|
+
params.push(id);
|
|
126
|
+
db.transaction(() => {
|
|
127
|
+
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
128
|
+
rebuildObservationDerived(db, id);
|
|
129
|
+
})();
|
|
130
|
+
return updates.map((u) => u.split(' =')[0]);
|
|
131
|
+
}
|
package/lib/search-core.mjs
CHANGED
|
@@ -258,7 +258,16 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
258
258
|
// obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
|
|
259
259
|
// project-boost), so the ratio is meaningful there; small-scale sources (prompts,
|
|
260
260
|
// bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
|
|
261
|
-
// direction.
|
|
261
|
+
// direction.
|
|
262
|
+
// D#121 (2026-08-16): obs FULL_SCORE additionally carries citeFactor (0.4–3.0) ×
|
|
263
|
+
// noisePenalty (0.2–1.0) — behavior state events/sessions never have. A heavily-
|
|
264
|
+
// cited obs can therefore widen globalMaxAbs and demote a lone event's band: this
|
|
265
|
+
// is ACCEPTED direction (cite is genuine relevance evidence), and it is BOUNDED —
|
|
266
|
+
// citeFactor caps at 3.0, so a lone hit that was the raw max keeps ratio >= 1/3
|
|
267
|
+
// and never sinks below the -0.5 neutral mid; noise only shrinks obs (can only
|
|
268
|
+
// IMPROVE the lone hit's band). Real-SQL pins: benchmark/events-pipeline-probes.mjs
|
|
269
|
+
// (cite-widening-bounded-3x / decisive-lone-event-survives-max-cite / noise-shrink).
|
|
270
|
+
// Bands (ratio = |lone| / globalMaxAbs, first match wins):
|
|
262
271
|
// ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
|
|
263
272
|
// strictly ahead of every normalized -1 (ties in raw
|
|
264
273
|
// strength resolve toward the lone hit — the [-1, 0] band
|