claude-mem-lite 3.62.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/README.md +26 -0
- package/format-utils.mjs +21 -0
- package/hook-memory.mjs +6 -9
- package/hook.mjs +44 -17
- package/install.mjs +21 -0
- package/lib/browse-core.mjs +67 -0
- package/lib/db-backup.mjs +84 -1
- package/lib/delete-core.mjs +18 -1
- package/lib/export-columns.mjs +7 -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 +86 -129
- package/npm-shrinkwrap.json +2 -2
- package/package.json +5 -1
- package/registry.mjs +53 -1
- package/scoring-sql.mjs +5 -4
- package/scripts/post-tool-recall.js +12 -2
- package/scripts/pre-agent-inject.js +22 -4
- package/scripts/pre-skill-bridge.js +18 -3
- package/scripts/pre-tool-recall.js +39 -19
- package/scripts/prompt-search-utils.mjs +9 -2
- package/scripts/user-prompt-search.js +59 -31
- package/search-engine.mjs +56 -20
- package/search-scoring.mjs +7 -2
- package/server.mjs +75 -137
- package/source-files.mjs +44 -1
|
@@ -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/README.md
CHANGED
|
@@ -529,6 +529,32 @@ Notes:
|
|
|
529
529
|
- Direct install / npx mode keeps auto-update enabled and uses staged replacement with rollback on install failure.
|
|
530
530
|
- If you disabled the plugin but still have old mem hooks in `~/.claude/settings.json`, run `node install.mjs cleanup-hooks`.
|
|
531
531
|
|
|
532
|
+
#### Trust model per install path
|
|
533
|
+
|
|
534
|
+
The three install paths do **not** carry the same supply-chain guarantees — pick the one that matches your threat model:
|
|
535
|
+
|
|
536
|
+
| Path | Update mechanism | Ed25519 release-signature verification |
|
|
537
|
+
|------|------------------|----------------------------------------|
|
|
538
|
+
| npm / npx / git-clone direct install | auto-update from GitHub Releases | **Yes** — every runtime file (140 entries incl. hook scripts, MCP launcher, plugin declaration files) is hash-pinned in a signed manifest; verification is fail-closed |
|
|
539
|
+
| `/plugin install` (marketplace) | manual `/plugin marketplace update` + reinstall | **No** — Claude Code installs from a git clone of the marketplace repo; the plugin's own signature chain is not consulted on this path. You are trusting GitHub + the repo's branch protection, not the release signing key |
|
|
540
|
+
|
|
541
|
+
**Rollback recipe (plugin path).** If an update misbehaves, pin the marketplace clone to the previous release tag and reinstall from it:
|
|
542
|
+
|
|
543
|
+
```bash
|
|
544
|
+
# 1. Find the local marketplace clone
|
|
545
|
+
ls ~/.claude/plugins/marketplaces/ # e.g. sdsrss
|
|
546
|
+
|
|
547
|
+
# 2. Pin it to the previous good tag (tags mirror npm versions, e.g. v3.62.0)
|
|
548
|
+
cd ~/.claude/plugins/marketplaces/sdsrss
|
|
549
|
+
git fetch --tags && git checkout v3.62.0
|
|
550
|
+
|
|
551
|
+
# 3. Reinstall from the pinned clone — inside Claude Code:
|
|
552
|
+
# /plugin install claude-mem-lite@sdsrss
|
|
553
|
+
# 4. To leave the pin later: git checkout main, then the normal update flow.
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Your data directory (`~/.claude-mem-lite/`) is untouched by install/rollback; schema migrations are forward-only, so after rolling back more than one minor version check `node install.mjs doctor` before trusting search results.
|
|
557
|
+
|
|
532
558
|
### doctor
|
|
533
559
|
|
|
534
560
|
Checks Node.js version, dependencies, server/hook files, database integrity, FTS5 indexes, and stale processes.
|
package/format-utils.mjs
CHANGED
|
@@ -123,6 +123,27 @@ export function neutralizeSkillDelimiters(s) {
|
|
|
123
123
|
return defangToFixpoint(s, SKILL_BLOCK_RE);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// <skill-bridge> is the wrapper scripts/pre-skill-bridge.js puts around a managed
|
|
127
|
+
// skill body it injects as PreToolUse additionalContext. The body comes from a
|
|
128
|
+
// third-party repo (tools/adopt import) — an untrusted boundary — so a literal
|
|
129
|
+
// `</skill-bridge>` inside it would close the wrapper early and spill the rest of
|
|
130
|
+
// the payload (e.g. a forged <system-reminder>) as undelimited context (audit
|
|
131
|
+
// 2026-08-14 M-4). Not in CONTEXT_DELIMITER_RE for the same reason <skill-loaded>
|
|
132
|
+
// isn't: the bridge's OWN wrapper must stay live, so the defang is applied per
|
|
133
|
+
// call site to the untrusted body only.
|
|
134
|
+
const SKILL_BRIDGE_RE = /<\/?skill-bridge(?:\s[^>]*)?>/gi;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Defang a literal `<skill-bridge>` opener/closer in untrusted text that is about
|
|
138
|
+
* to be wrapped in a real skill-bridge block. Same fixpoint treatment as the
|
|
139
|
+
* classes above. Never apply to the wrapper itself.
|
|
140
|
+
* @param {string} s Input string (any type; coerced)
|
|
141
|
+
* @returns {string} Text with skill-bridge delimiters defanged
|
|
142
|
+
*/
|
|
143
|
+
export function neutralizeSkillBridgeDelimiters(s) {
|
|
144
|
+
return defangToFixpoint(s, SKILL_BRIDGE_RE);
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
/**
|
|
127
148
|
* Render the PostToolUse error-recall hint block (hook.mjs::triggerErrorRecall).
|
|
128
149
|
* The single most-relevant hit (rows[0]) that carries a lesson_learned gets its
|
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,11 +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')}
|
|
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).
|
|
497
500
|
ORDER BY ${OBS_BM25}
|
|
498
|
-
* (
|
|
501
|
+
* ${recencyDecaySql({ tsExpr: 'o.created_at_epoch', halfLifeSql: '1209600000.0' })}
|
|
499
502
|
LIMIT 3
|
|
500
503
|
`).all(ftsQuery, project, nowR);
|
|
501
504
|
|
|
@@ -853,16 +856,21 @@ function buildCiteRecallNudge(project) {
|
|
|
853
856
|
return libBuildCiteRecallNudge(project, RUNTIME_DIR);
|
|
854
857
|
}
|
|
855
858
|
|
|
856
|
-
// GC
|
|
857
|
-
// (
|
|
858
|
-
//
|
|
859
|
-
//
|
|
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.
|
|
860
864
|
const PRE_RECALL_COOLDOWN_STALE_MS = 24 * 60 * 60 * 1000;
|
|
861
865
|
function gcStalePreRecallCooldowns() {
|
|
862
866
|
try {
|
|
863
867
|
const now = Date.now();
|
|
864
868
|
for (const name of readdirSync(RUNTIME_DIR)) {
|
|
865
|
-
|
|
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;
|
|
866
874
|
try {
|
|
867
875
|
const p = join(RUNTIME_DIR, name);
|
|
868
876
|
const st = statSync(p);
|
|
@@ -1017,20 +1025,29 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1017
1025
|
|
|
1018
1026
|
// Auto-dedup (exact): merge identical-title observations within 1h.
|
|
1019
1027
|
// Catches rapid duplicate writes (same hook firing twice, race conditions).
|
|
1028
|
+
// BOTH join sides must be live (audit 2026-08-14 H-1): without the
|
|
1029
|
+
// superseded_at filters (which the fuzzy channel below always had), a row the
|
|
1030
|
+
// fuzzy pass had tombstoned could come back as `a` (a.id < b.id) and tombstone
|
|
1031
|
+
// the LIVE keeper `b` — both copies gone from every read path. Worse, a user
|
|
1032
|
+
// correction saved with supersedes=[#A] (A.superseded_by = B's NUMERIC id) has
|
|
1033
|
+
// the same title as A, so the pair (A, B) tombstoned the correction B itself
|
|
1034
|
+
// and the string 'auto-dedup' write clobbered numeric supersession chains that
|
|
1035
|
+
// citation-tracker decay hand-off and timeline re-anchoring both follow. The
|
|
1036
|
+
// UPDATE repeats the guard so a concurrent writer can't re-stamp a chain.
|
|
1020
1037
|
const dupPairs = db.prepare(`
|
|
1021
1038
|
SELECT a.id as keep_id, b.id as remove_id
|
|
1022
1039
|
FROM observations a
|
|
1023
1040
|
JOIN observations b ON a.title = b.title AND a.project = b.project
|
|
1024
1041
|
AND a.id < b.id
|
|
1025
1042
|
AND ABS(a.created_at_epoch - b.created_at_epoch) < 3600000
|
|
1026
|
-
AND
|
|
1027
|
-
AND
|
|
1043
|
+
AND ${liveObsFilterSql('a')}
|
|
1044
|
+
AND ${liveObsFilterSql('b')}
|
|
1028
1045
|
LIMIT 20
|
|
1029
1046
|
`).all();
|
|
1030
1047
|
if (dupPairs.length > 0) {
|
|
1031
1048
|
const removeIds = dupPairs.map(p => p.remove_id);
|
|
1032
1049
|
const ph = removeIds.map(() => '?').join(',');
|
|
1033
|
-
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph})`).run(Date.now(), ...removeIds);
|
|
1050
|
+
db.prepare(`UPDATE observations SET superseded_at = ?, superseded_by = 'auto-dedup' WHERE id IN (${ph}) AND superseded_at IS NULL`).run(Date.now(), ...removeIds);
|
|
1034
1051
|
debugLog('DEBUG', 'auto-maintain', `auto-deduped ${dupPairs.length} near-identical observations`);
|
|
1035
1052
|
}
|
|
1036
1053
|
|
|
@@ -1047,8 +1064,7 @@ function runSessionStartAutoMaintain(db) {
|
|
|
1047
1064
|
const recent = db.prepare(`
|
|
1048
1065
|
SELECT id, title, importance, created_at_epoch, narrative, text
|
|
1049
1066
|
FROM observations
|
|
1050
|
-
WHERE
|
|
1051
|
-
AND superseded_at IS NULL
|
|
1067
|
+
WHERE ${liveObsFilterSql('')}
|
|
1052
1068
|
AND created_at_epoch > ?
|
|
1053
1069
|
AND title IS NOT NULL AND title != ''
|
|
1054
1070
|
ORDER BY created_at_epoch DESC LIMIT ${SCAN_LIMIT}
|
|
@@ -1646,11 +1662,17 @@ async function handleUserPrompt() {
|
|
|
1646
1662
|
|
|
1647
1663
|
// Read IDs already injected by user-prompt-search.js to avoid duplicate injection
|
|
1648
1664
|
try {
|
|
1649
|
-
|
|
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));
|
|
1650
1669
|
const raw = readFileSync(injectedFile, 'utf8');
|
|
1651
|
-
const { ids, ts } = JSON.parse(raw);
|
|
1652
|
-
// Only use if written within last 10 seconds (same prompt cycle)
|
|
1653
|
-
|
|
1670
|
+
const { ids, ts, session } = JSON.parse(raw);
|
|
1671
|
+
// Only use if written within last 10 seconds (same prompt cycle) AND by this
|
|
1672
|
+
// CC session (M-6 payload gate, still load-bearing for legacy files).
|
|
1673
|
+
// Legacy payloads without `session` keep the old time-window-only behavior.
|
|
1674
|
+
if (ts && Date.now() - ts < 10000 && Array.isArray(ids)
|
|
1675
|
+
&& !(session && ccSessionId && session !== ccSessionId)) {
|
|
1654
1676
|
for (const id of ids) { keyContextIds.push(id); pathAInjectedIds.push(id); }
|
|
1655
1677
|
}
|
|
1656
1678
|
} catch { /* file may not exist — that's fine */ }
|
|
@@ -1871,6 +1893,11 @@ try {
|
|
|
1871
1893
|
// lib/native-binding-hint.mjs.
|
|
1872
1894
|
const line = formatHookError(err, event, { runtimeDir: RUNTIME_DIR });
|
|
1873
1895
|
if (line) console.error(line);
|
|
1896
|
+
// stderr alone is invisible to `stats` self-observation — only the native-binding
|
|
1897
|
+
// family was persisted, so a non-binding fatal (schema drift, bad stdin shape)
|
|
1898
|
+
// could kill every dispatch-routed surface while the hook-errors log read zero
|
|
1899
|
+
// (audit 2026-08-14 M-5; same blindness one layer up from the v3.60 outage).
|
|
1900
|
+
recordHookError(`hook:${event}`, err, RUNTIME_DIR);
|
|
1874
1901
|
}
|
|
1875
1902
|
|
|
1876
1903
|
process.exit(0);
|
package/install.mjs
CHANGED
|
@@ -1561,6 +1561,27 @@ async function doctor() {
|
|
|
1561
1561
|
ok(`Native DB binding: loadable on Node ${process.version}`);
|
|
1562
1562
|
}
|
|
1563
1563
|
|
|
1564
|
+
// Disk footprint (audit 2026-08-14 M-9): a "lite" data dir had grown to 653MB
|
|
1565
|
+
// against a 59MB DB — 360MB of it orphaned per-tag .bak snapshots — with no
|
|
1566
|
+
// check anywhere. Cheap probes only (DB file + .bak aggregate, no tree walk).
|
|
1567
|
+
// The budget itself is enforced by lib/db-backup on every new snapshot; this
|
|
1568
|
+
// check surfaces stores that predate the budget or exceed it between snapshots.
|
|
1569
|
+
try {
|
|
1570
|
+
const { listSnapshots, backupBudgetBytes } = await import('./lib/db-backup.mjs');
|
|
1571
|
+
const dbFile = join(MEM_DATA_DIR, 'claude-mem-lite.db');
|
|
1572
|
+
const dbBytes = existsSync(dbFile) ? statSync(dbFile).size : 0;
|
|
1573
|
+
const snaps = listSnapshots(dbFile);
|
|
1574
|
+
const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
|
|
1575
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
1576
|
+
// Warn threshold = the REAL eviction budget (pre-release review 2026-08-16) —
|
|
1577
|
+
// warning below it promised an eviction enforceBackupBudget would never do.
|
|
1578
|
+
if (backupBytes > backupBudgetBytes()) {
|
|
1579
|
+
dwarn(`Disk footprint: ${snaps.length} backup snapshot(s) hold ${mb(backupBytes)}MB, over the ${mb(backupBudgetBytes())}MB budget (CLAUDE_MEM_BACKUP_BUDGET_MB) — the next maintain/save snapshot evicts oldest snapshots past the 7d undo grace`);
|
|
1580
|
+
} else {
|
|
1581
|
+
ok(`Disk footprint: DB ${mb(dbBytes)}MB, ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB (budget ${mb(backupBudgetBytes())}MB)`);
|
|
1582
|
+
}
|
|
1583
|
+
} catch { /* footprint check is informational — never block doctor */ }
|
|
1584
|
+
|
|
1564
1585
|
// Plugin/hook lifecycle state
|
|
1565
1586
|
const settings = readSettings();
|
|
1566
1587
|
const hasHooks = hasMemHooksConfigured(settings);
|
|
@@ -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/db-backup.mjs
CHANGED
|
@@ -7,10 +7,92 @@
|
|
|
7
7
|
//
|
|
8
8
|
// MUST be called OUTSIDE any transaction — VACUUM cannot run inside one, which is
|
|
9
9
|
// why the maintenance entry points snapshot before opening their db.transaction().
|
|
10
|
-
import { readdirSync, unlinkSync } from 'fs';
|
|
10
|
+
import { readdirSync, unlinkSync, statSync } from 'fs';
|
|
11
11
|
import { dirname, basename, join } from 'path';
|
|
12
12
|
import { debugLog } from '../utils.mjs';
|
|
13
13
|
|
|
14
|
+
// M-9 (audit 2026-08-14): per-tag retention alone let one-shot tags live forever —
|
|
15
|
+
// each tag kept its newest 3, but a tag written once (pre-backfill-v3390 and four
|
|
16
|
+
// siblings) never got a second snapshot to age it out, so 9 orphaned .bak files held
|
|
17
|
+
// 360MB against a 59MB live DB. A TOTAL byte budget across ALL tags bounds the
|
|
18
|
+
// footprint of a system whose name promises "lite". Oldest-first eviction, newest
|
|
19
|
+
// snapshot always survives (the most recent safety net must outlive any budget).
|
|
20
|
+
const DEFAULT_BACKUP_BUDGET_BYTES = 256 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/** Effective budget (env-tunable). Exported so stats/doctor derive their
|
|
23
|
+
* footprint-warning thresholds from the SAME number eviction acts on —
|
|
24
|
+
* pre-release review 2026-08-16: a hardcoded `3× DB` hint fired ~2.7× below
|
|
25
|
+
* the real budget and promised an eviction that would never happen. */
|
|
26
|
+
export function backupBudgetBytes() {
|
|
27
|
+
const mb = Number(process.env.CLAUDE_MEM_BACKUP_BUDGET_MB);
|
|
28
|
+
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : DEFAULT_BACKUP_BUDGET_BYTES;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Eviction grace: snapshots younger than this are never budget-evicted. This is
|
|
32
|
+
// what protects a fresh `pre-delete` undo pre-image (deleteObservations reports
|
|
33
|
+
// its path as the recovery route) from being unlinked by same-day maintain churn
|
|
34
|
+
// (pre-release review 2026-08-16). Deliberately age-based, NOT newest-per-tag:
|
|
35
|
+
// a per-tag exemption would make every one-shot tag's only snapshot immortal —
|
|
36
|
+
// exactly the 360MB orphan shape M-9 exists to evict. Mirrors the purge grace
|
|
37
|
+
// convention (7d) used by stale-observation retention.
|
|
38
|
+
export const BACKUP_EVICTION_GRACE_MS = 7 * 86400000;
|
|
39
|
+
|
|
40
|
+
// Canonical snapshot shape `<base>.<tag>-<ISO stamp>-<pid>-<seq>.bak` (what
|
|
41
|
+
// snapshotDb writes). Budget eviction deletes ONLY names matching this — a
|
|
42
|
+
// user's hand-made `cp db db.before-upgrade.bak` must never be auto-unlinked.
|
|
43
|
+
const SNAPSHOT_STAMP_RE = /-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-\d+-\d+\.bak$/;
|
|
44
|
+
|
|
45
|
+
/** All `<db>.<tag>-<ts>.bak` snapshots for `dbPath`, any tag, with size + mtime. */
|
|
46
|
+
export function listSnapshots(dbPath) {
|
|
47
|
+
try {
|
|
48
|
+
const dir = dirname(dbPath);
|
|
49
|
+
const prefix = `${basename(dbPath)}.`;
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const n of readdirSync(dir)) {
|
|
52
|
+
if (!n.startsWith(prefix) || !n.endsWith('.bak')) continue;
|
|
53
|
+
const full = join(dir, n);
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(full);
|
|
56
|
+
out.push({ path: full, size: st.size, mtimeMs: st.mtimeMs });
|
|
57
|
+
} catch { /* raced away */ }
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
} catch { return []; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Evict oldest snapshots (across ALL tags) until the total is within the byte
|
|
65
|
+
* budget. Never evicted: the single newest snapshot (even if it alone exceeds
|
|
66
|
+
* the budget), anything younger than BACKUP_EVICTION_GRACE_MS, and any file not
|
|
67
|
+
* matching the canonical snapshot name shape. The total counts every .bak
|
|
68
|
+
* (exempt files included), so a burst of young snapshots can hold the dir above
|
|
69
|
+
* budget for up to the grace window — bounded by write rate, and the price of
|
|
70
|
+
* never deleting an undo pre-image someone may still want.
|
|
71
|
+
* Returns the number of files removed. Best-effort, never throws.
|
|
72
|
+
*/
|
|
73
|
+
export function enforceBackupBudget(dbPath, { budgetBytes = backupBudgetBytes(), nowMs = Date.now() } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const snaps = listSnapshots(dbPath).sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
|
|
76
|
+
let total = snaps.reduce((s, x) => s + x.size, 0);
|
|
77
|
+
let removed = 0;
|
|
78
|
+
const graceCutoff = nowMs - BACKUP_EVICTION_GRACE_MS;
|
|
79
|
+
// Walk oldest-first; stop before touching the newest (index 0).
|
|
80
|
+
for (let i = snaps.length - 1; i >= 1 && total > budgetBytes; i--) {
|
|
81
|
+
if (!SNAPSHOT_STAMP_RE.test(snaps[i].path)) continue; // not ours — never delete
|
|
82
|
+
if (snaps[i].mtimeMs > graceCutoff) continue; // inside undo grace
|
|
83
|
+
try {
|
|
84
|
+
unlinkSync(snaps[i].path);
|
|
85
|
+
total -= snaps[i].size;
|
|
86
|
+
removed++;
|
|
87
|
+
} catch { /* per-entry best-effort */ }
|
|
88
|
+
}
|
|
89
|
+
if (removed > 0) {
|
|
90
|
+
try { debugLog('DEBUG', 'db-backup', `budget eviction removed ${removed} snapshot(s)`); } catch { /* ignore */ }
|
|
91
|
+
}
|
|
92
|
+
return removed;
|
|
93
|
+
} catch { return 0; }
|
|
94
|
+
}
|
|
95
|
+
|
|
14
96
|
// Monotonic per-process suffix so two snapshots in the same millisecond (same pid)
|
|
15
97
|
// still get unique filenames (VACUUM INTO fails if the target already exists).
|
|
16
98
|
let _seq = 0;
|
|
@@ -31,6 +113,7 @@ export function snapshotDb(db, { tag = 'pre-maintain', retain = 3 } = {}) {
|
|
|
31
113
|
// defensively since VACUUM INTO takes a string literal, not a bound param.
|
|
32
114
|
db.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`);
|
|
33
115
|
pruneSnapshots(dbPath, tag, retain);
|
|
116
|
+
enforceBackupBudget(dbPath);
|
|
34
117
|
return out;
|
|
35
118
|
} catch (e) {
|
|
36
119
|
try { debugLog('WARN', 'db-backup', `snapshot skipped (proceeding without): ${e.message}`); } catch { /* ignore */ }
|
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/export-columns.mjs
CHANGED
|
@@ -18,10 +18,16 @@
|
|
|
18
18
|
// exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
|
|
19
19
|
// and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
|
|
20
20
|
// it would freeze a stale value into backups).
|
|
21
|
+
// `compressed_into` (audit 2026-08-14 M-8): only ever non-null in an export taken
|
|
22
|
+
// with --include-compressed / include_compressed — the default WHERE excludes those
|
|
23
|
+
// rows. Without the column, a compressed member round-tripped through restore as a
|
|
24
|
+
// LIVE row and turned up in search results next to the weekly-summary keeper that
|
|
25
|
+
// already absorbed it. Restore does not remap it (the keeper's id is meaningless in
|
|
26
|
+
// the target store); it REJECTS marked rows instead — see cmdRestore.
|
|
21
27
|
export const EXPORT_COLUMNS = [
|
|
22
28
|
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
23
29
|
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
24
|
-
'scope',
|
|
30
|
+
'scope', 'compressed_into',
|
|
25
31
|
'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
|
|
26
32
|
'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
|
|
27
33
|
];
|
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
|
+
}
|