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
|
@@ -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
|
package/mem-cli.mjs
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import { ensureDbWithWalRecovery, DB_PATH, DB_DIR, REGISTRY_DB_PATH } from './schema.mjs';
|
|
7
|
-
import { truncate, typeIcon, inferProject, scrubSecrets } from './utils.mjs';
|
|
7
|
+
import { truncate, typeIcon, inferProject, scrubSecrets, COMPRESSED_PENDING_PURGE } from './utils.mjs';
|
|
8
8
|
import { resolveProject } from './project-utils.mjs';
|
|
9
|
-
import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
|
|
10
9
|
import { _resetVocabCache, vecTextForRow, vectorsEnabled } from './tfidf.mjs';
|
|
11
|
-
import {
|
|
10
|
+
import { reRankWithContext } from './search-scoring.mjs';
|
|
12
11
|
import { searchObservationsHybrid } from './search-engine.mjs';
|
|
12
|
+
import { fetchObsDetail, OBS_FIELDS, SESSION_DETAIL_FIELDS } from './lib/get-core.mjs';
|
|
13
|
+
import { collectBrowseTiers, getActiveMemorySessionId, BROWSE_TIERS, BROWSE_TIER_LABELS } from './lib/browse-core.mjs';
|
|
13
14
|
import { deepSearch, resolveDeepMode, shouldEscalateToDeep, autoDeepLlmReady } from './deep-search.mjs';
|
|
14
|
-
import { ensureRegistryDb, upsertResource } from './registry.mjs';
|
|
15
|
+
import { ensureRegistryDb, upsertResource, collectRegistryStats, listResourcesRanked, formatRegistryListLine } from './registry.mjs';
|
|
15
16
|
import { searchResources } from './registry-retriever.mjs';
|
|
16
17
|
import { computeFunnel, formatFunnel, computeSweep, formatSweep, DEFAULT_SWEEP_FLOORS, DEFAULT_SWEEP_MARGINS } from './registry-recommend.mjs';
|
|
17
18
|
import { selectCompressionCandidates, groupByProjectWeek, compressGroup } from './lib/compress-core.mjs';
|
|
@@ -22,8 +23,8 @@ import {
|
|
|
22
23
|
hardDeleteCandidateCount,
|
|
23
24
|
OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
|
|
24
25
|
} from './lib/maintain-core.mjs';
|
|
25
|
-
import { snapshotDb } from './lib/db-backup.mjs';
|
|
26
|
-
import { deleteObservations } from './lib/delete-core.mjs';
|
|
26
|
+
import { snapshotDb, listSnapshots, backupBudgetBytes } from './lib/db-backup.mjs';
|
|
27
|
+
import { deleteObservations, previewDeleteRows } from './lib/delete-core.mjs';
|
|
27
28
|
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
28
29
|
import { computeStatsFeed } from './lib/stats-core.mjs';
|
|
29
30
|
import { buildLessonNudge } from './lib/save-nudge.mjs';
|
|
@@ -36,7 +37,7 @@ import { aggregateProjectCiteRecall } from './lib/citation-tracker.mjs';
|
|
|
36
37
|
import { probeOtherSources as probeIdSources, bucketIdTokens, splitDeferredTokens } from './lib/id-routing.mjs';
|
|
37
38
|
import { join, sep, dirname } from 'path';
|
|
38
39
|
import { spawnSync } from 'child_process';
|
|
39
|
-
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
40
|
+
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
40
41
|
|
|
41
42
|
// v2.41: shared CLI helpers extracted to cli/common.mjs. Keep this file as the
|
|
42
43
|
// router + remaining-command bodies during the incremental split. Future work:
|
|
@@ -45,7 +46,7 @@ import { isNativeBindingError, healAndReexec } from './lib/binding-probe.mjs';
|
|
|
45
46
|
import { CLI_PATH, CLI_INVOKE } from './cli-path.mjs';
|
|
46
47
|
import { parseArgs, out, outVerbatim, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, resolvePositionalAlias, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue, obsFieldLabel, formatPendingPurgeLine } from './cli/common.mjs';
|
|
47
48
|
import { saveObservation } from './lib/save-observation.mjs';
|
|
48
|
-
import {
|
|
49
|
+
import { normalizeScope, insertObservationVector, applyObsUpdate } from './lib/observation-write.mjs';
|
|
49
50
|
import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
|
|
50
51
|
import { recallByFile } from './lib/recall-core.mjs';
|
|
51
52
|
import { fetchRecent, RECENT_MAX } from './lib/recent-core.mjs';
|
|
@@ -500,7 +501,6 @@ function cmdRecall(db, args) {
|
|
|
500
501
|
}
|
|
501
502
|
}
|
|
502
503
|
|
|
503
|
-
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'];
|
|
504
504
|
|
|
505
505
|
// Time-field formatting moved to cli/common.mjs so the CLI `get` and the MCP
|
|
506
506
|
// `mem_get` (server.mjs) share one source and can't drift (the drift bug:
|
|
@@ -513,13 +513,8 @@ export { OBS_TIME_FIELDS, formatObsFieldValue };
|
|
|
513
513
|
export async function cmdSearchForTest(db, args, opts) { return cmdSearch(db, args, opts); }
|
|
514
514
|
|
|
515
515
|
function renderObsRows(db, ids, requestedFields) {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
db.prepare(`UPDATE observations SET access_count = COALESCE(access_count, 0) + 1, last_accessed_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
|
|
519
|
-
autoBoostIfNeeded(db, ids);
|
|
520
|
-
} catch { /* non-critical: FTS5 trigger may fail on corrupted index */ }
|
|
521
|
-
|
|
522
|
-
const rows = db.prepare(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`).all(...ids);
|
|
516
|
+
// Access-bump + fetch via the shared get-core (P2-12) — single source with mem_get.
|
|
517
|
+
const rows = fetchObsDetail(db, ids);
|
|
523
518
|
if (rows.length === 0) return null;
|
|
524
519
|
const fields = requestedFields || OBS_FIELDS;
|
|
525
520
|
const parts = [];
|
|
@@ -547,12 +542,15 @@ function renderSessionRows(db, ids) {
|
|
|
547
542
|
const parts = [];
|
|
548
543
|
for (const r of rows) {
|
|
549
544
|
const lines = [`S#${r.id} ${fmtDateShort(r.created_at)}`];
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
545
|
+
// SESSION_DETAIL_FIELDS (get-core, P2-12): the FULL render set — the old
|
|
546
|
+
// 6-field subset made remaining_items/notes/files_* searchable-but-invisible.
|
|
547
|
+
for (const f of SESSION_DETAIL_FIELDS) {
|
|
548
|
+
if (f === 'id' || f === 'created_at') continue; // already in the header
|
|
549
|
+
const val = r[f];
|
|
550
|
+
if (val === null || val === undefined || val === '') continue;
|
|
551
|
+
const label = f[0].toUpperCase() + f.slice(1).replace(/_/g, ' ');
|
|
552
|
+
lines.push(`${label}: ${val}`);
|
|
553
|
+
}
|
|
556
554
|
parts.push(lines.join('\n'));
|
|
557
555
|
}
|
|
558
556
|
return { text: parts.join('\n\n'), count: rows.length };
|
|
@@ -1200,6 +1198,15 @@ async function cmdStats(db, args) {
|
|
|
1200
1198
|
// failure mode that left code-graph's matcher bug undetected for 10 sessions.
|
|
1201
1199
|
const hookErrors24h = countRecentHookErrors(join(DB_DIR, 'runtime'), now - 86400000);
|
|
1202
1200
|
|
|
1201
|
+
// M-9 (audit 2026-08-14): disk footprint — a "lite" store had accumulated 360MB of
|
|
1202
|
+
// pre-maintain snapshots against a 59MB DB with nothing reporting it. Cheap probes
|
|
1203
|
+
// only (DB file + .bak aggregate), no recursive tree walk.
|
|
1204
|
+
let dbBytes = 0;
|
|
1205
|
+
try { dbBytes = statSync(join(DB_DIR, 'claude-mem-lite.db')).size; } catch { /* fresh */ }
|
|
1206
|
+
const snaps = listSnapshots(join(DB_DIR, 'claude-mem-lite.db'));
|
|
1207
|
+
const backupBytes = snaps.reduce((s, x) => s + x.size, 0);
|
|
1208
|
+
const mb = (n) => (n / (1024 * 1024)).toFixed(1);
|
|
1209
|
+
|
|
1203
1210
|
if (jsonOutput) {
|
|
1204
1211
|
out(JSON.stringify({
|
|
1205
1212
|
project,
|
|
@@ -1226,6 +1233,9 @@ async function cmdStats(db, args) {
|
|
|
1226
1233
|
compressed: compressedCount.c,
|
|
1227
1234
|
superseded_only: supersededOnlyCount.c,
|
|
1228
1235
|
hook_errors_24h: hookErrors24h,
|
|
1236
|
+
db_bytes: dbBytes,
|
|
1237
|
+
backup_count: snaps.length,
|
|
1238
|
+
backup_bytes: backupBytes,
|
|
1229
1239
|
},
|
|
1230
1240
|
tier_distribution: {
|
|
1231
1241
|
working: tierMap.working ?? 0,
|
|
@@ -1265,6 +1275,9 @@ async function cmdStats(db, args) {
|
|
|
1265
1275
|
out(` Low-signal titles (Modified/Error/Worked on…): ${lowSignalTitle.c} (${(lowSignalRatio * 100).toFixed(1)}%)`);
|
|
1266
1276
|
out(` Compressed: ${compressedCount.c}`);
|
|
1267
1277
|
out(` Hook errors (last 24h): ${hookErrors24h}${hookErrors24h > 0 ? ` ← tail ${join(DB_DIR, 'runtime/hook-errors')}` : ''}`);
|
|
1278
|
+
// Hint threshold = the REAL eviction budget (pre-release review 2026-08-16: a
|
|
1279
|
+
// hardcoded 3×-DB heuristic promised an eviction that fires only past the budget).
|
|
1280
|
+
out(` Disk: DB ${mb(dbBytes)}MB | ${snaps.length} backup snapshot(s) ${mb(backupBytes)}MB${backupBytes > backupBudgetBytes() ? ` ← over the ${mb(backupBudgetBytes())}MB backup budget; next maintain/save snapshot evicts oldest (>7d old)` : ''}`);
|
|
1268
1281
|
// Tier-1 firing counters for ① file-intel + ② reread-guard (recorded by
|
|
1269
1282
|
// pre-tool-recall.js via lib/metrics.mjs; CLAUDE_MEM_METRICS=1 to enable).
|
|
1270
1283
|
const featAgg = aggregateMetrics(DB_DIR, 7);
|
|
@@ -1351,53 +1364,13 @@ function cmdBrowse(db, args) {
|
|
|
1351
1364
|
const jsonOutput = flags.json === true || flags.json === 'true';
|
|
1352
1365
|
const now = Date.now();
|
|
1353
1366
|
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
currentSessionId:
|
|
1358
|
-
};
|
|
1359
|
-
const
|
|
1360
|
-
|
|
1361
|
-
const tiers = ['working', 'active', 'archive'];
|
|
1362
|
-
const tierLabels = { working: '🔴 Working Memory', active: '🟡 Active Memory', archive: '🔵 Archive' };
|
|
1363
|
-
const showTiers = tierFilter ? [tierFilter] : tiers;
|
|
1364
|
-
|
|
1365
|
-
// Collect data first (for JSON), then format. The text path also prints
|
|
1366
|
-
// tier headers as it walks; refactored to two passes so the JSON shape can
|
|
1367
|
-
// include row arrays alongside totals.
|
|
1368
|
-
const tierData = {};
|
|
1369
|
-
const tierCounts = {};
|
|
1370
|
-
let grandTotal = 0;
|
|
1371
|
-
|
|
1372
|
-
for (const tier of showTiers) {
|
|
1373
|
-
const countRow = db.prepare(`
|
|
1374
|
-
SELECT COUNT(*) as c FROM (
|
|
1375
|
-
SELECT ${TIER_CASE_SQL} as tier FROM observations
|
|
1376
|
-
WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
1377
|
-
) WHERE tier = ?
|
|
1378
|
-
`).get(...params, project, tier);
|
|
1379
|
-
const count = countRow?.c ?? 0;
|
|
1380
|
-
tierCounts[tier] = count;
|
|
1381
|
-
grandTotal += count;
|
|
1382
|
-
|
|
1383
|
-
// Archive in unfiltered view: keep count but skip row fetch (matches text path).
|
|
1384
|
-
const skipRows = tier === 'archive' && !tierFilter;
|
|
1385
|
-
if (count === 0 || skipRows) {
|
|
1386
|
-
tierData[tier] = { count, rows: [] };
|
|
1387
|
-
continue;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
const rows = db.prepare(`
|
|
1391
|
-
SELECT * FROM (
|
|
1392
|
-
SELECT id, type, title, importance, created_at_epoch, created_at, ${TIER_CASE_SQL} as tier
|
|
1393
|
-
FROM observations
|
|
1394
|
-
WHERE project = ? AND COALESCE(compressed_into, 0) = 0 AND superseded_at IS NULL
|
|
1395
|
-
) WHERE tier = ?
|
|
1396
|
-
ORDER BY created_at_epoch DESC
|
|
1397
|
-
LIMIT ?
|
|
1398
|
-
`).all(...params, project, tier, limit);
|
|
1399
|
-
tierData[tier] = { count, rows };
|
|
1400
|
-
}
|
|
1367
|
+
// Shared collection (lib/browse-core, P2-12) — single source with mem_browse.
|
|
1368
|
+
const { showTiers, tierData, tierCounts, grandTotal } = collectBrowseTiers(db, {
|
|
1369
|
+
project, tierFilter, limit, now,
|
|
1370
|
+
currentSessionId: getActiveMemorySessionId(db, project),
|
|
1371
|
+
});
|
|
1372
|
+
const tiers = BROWSE_TIERS;
|
|
1373
|
+
const tierLabels = BROWSE_TIER_LABELS;
|
|
1401
1374
|
|
|
1402
1375
|
if (jsonOutput) {
|
|
1403
1376
|
const tiersOut = {};
|
|
@@ -1455,13 +1428,6 @@ function cmdBrowse(db, args) {
|
|
|
1455
1428
|
}
|
|
1456
1429
|
}
|
|
1457
1430
|
|
|
1458
|
-
function getActiveSessionId(db, project) {
|
|
1459
|
-
const row = db.prepare(
|
|
1460
|
-
"SELECT memory_session_id FROM sdk_sessions WHERE project = ? AND status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
|
|
1461
|
-
).get(project);
|
|
1462
|
-
return row?.memory_session_id ?? '';
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
1431
|
// ─── Delete ──────────────────────────────────────────────────────────────────
|
|
1466
1432
|
|
|
1467
1433
|
function cmdDelete(db, args) {
|
|
@@ -1493,8 +1459,8 @@ function cmdDelete(db, args) {
|
|
|
1493
1459
|
}
|
|
1494
1460
|
|
|
1495
1461
|
const confirm = flags.confirm === true || flags.confirm === 'true';
|
|
1496
|
-
|
|
1497
|
-
const rows
|
|
1462
|
+
// Shared preview body (lib/delete-core, P2-12) — single source with mem_delete.
|
|
1463
|
+
const { rows, lines: previewLines } = previewDeleteRows(db, ids);
|
|
1498
1464
|
|
|
1499
1465
|
if (rows.length === 0) {
|
|
1500
1466
|
fail('[mem] No observations found for given IDs');
|
|
@@ -1503,9 +1469,7 @@ function cmdDelete(db, args) {
|
|
|
1503
1469
|
|
|
1504
1470
|
if (!confirm) {
|
|
1505
1471
|
out(`[mem] Preview: ${rows.length} observation(s) will be deleted:`);
|
|
1506
|
-
for (const
|
|
1507
|
-
out(` #${r.id} [${r.type}] ${truncate(r.title || '(untitled)', 80)} | ${r.project}`);
|
|
1508
|
-
}
|
|
1472
|
+
for (const line of previewLines) out(line);
|
|
1509
1473
|
out('[mem] Run with --confirm to execute deletion.');
|
|
1510
1474
|
return;
|
|
1511
1475
|
}
|
|
@@ -1554,8 +1518,7 @@ function cmdUpdate(db, args) {
|
|
|
1554
1518
|
// (#8470). Reject cleanly via the shared guard — single source with the other commands.
|
|
1555
1519
|
if (rejectBareStringFlags(flags, ['title', 'narrative', 'lesson', 'lesson-learned', 'concepts'])) return;
|
|
1556
1520
|
|
|
1557
|
-
const
|
|
1558
|
-
const params = [];
|
|
1521
|
+
const fields = {};
|
|
1559
1522
|
if (flags.title !== undefined) {
|
|
1560
1523
|
// Reject empty title — clears the observation's identifier and would render it
|
|
1561
1524
|
// as `(untitled)` in every listing. Almost always an accidental shell-stripped arg.
|
|
@@ -1563,7 +1526,7 @@ function cmdUpdate(db, args) {
|
|
|
1563
1526
|
fail('[mem] --title cannot be empty. Pass a non-empty string or omit the flag to leave the title unchanged.');
|
|
1564
1527
|
return;
|
|
1565
1528
|
}
|
|
1566
|
-
|
|
1529
|
+
fields.title = flags.title;
|
|
1567
1530
|
}
|
|
1568
1531
|
if (flags.narrative !== undefined) {
|
|
1569
1532
|
// Reject empty (mirror --title): an explicit '' would blank the narrative
|
|
@@ -1572,7 +1535,7 @@ function cmdUpdate(db, args) {
|
|
|
1572
1535
|
fail('[mem] --narrative cannot be empty. Omit the flag to leave it unchanged.');
|
|
1573
1536
|
return;
|
|
1574
1537
|
}
|
|
1575
|
-
|
|
1538
|
+
fields.narrative = flags.narrative;
|
|
1576
1539
|
}
|
|
1577
1540
|
if (flags.type) {
|
|
1578
1541
|
const validTypes = OBS_TYPE_SET;
|
|
@@ -1580,7 +1543,7 @@ function cmdUpdate(db, args) {
|
|
|
1580
1543
|
fail(`[mem] Invalid type "${flags.type}". Valid: ${[...validTypes].join(', ')}`);
|
|
1581
1544
|
return;
|
|
1582
1545
|
}
|
|
1583
|
-
|
|
1546
|
+
fields.type = flags.type;
|
|
1584
1547
|
}
|
|
1585
1548
|
if (flags.importance) {
|
|
1586
1549
|
const imp = parseInt(flags.importance, 10);
|
|
@@ -1590,7 +1553,7 @@ function cmdUpdate(db, args) {
|
|
|
1590
1553
|
fail(`[mem] Invalid importance "${flags.importance}". Must be 1, 2, or 3.`);
|
|
1591
1554
|
return;
|
|
1592
1555
|
}
|
|
1593
|
-
|
|
1556
|
+
fields.importance = imp;
|
|
1594
1557
|
}
|
|
1595
1558
|
if (flags.lesson !== undefined || flags['lesson-learned'] !== undefined) {
|
|
1596
1559
|
const rawLesson = flags.lesson ?? flags['lesson-learned'] ?? '';
|
|
@@ -1605,8 +1568,7 @@ function cmdUpdate(db, args) {
|
|
|
1605
1568
|
fail('[mem] --lesson cannot be empty. Omit the flag to leave it unchanged.');
|
|
1606
1569
|
return;
|
|
1607
1570
|
}
|
|
1608
|
-
|
|
1609
|
-
params.push(scrubSecrets(rawLesson));
|
|
1571
|
+
fields.lesson_learned = rawLesson;
|
|
1610
1572
|
}
|
|
1611
1573
|
// Scrub like the sibling text fields above (title/narrative/lesson) and the MCP twin
|
|
1612
1574
|
// mem_update — concepts is a scrub-target + FTS-indexed column, so a raw secret here
|
|
@@ -1616,24 +1578,18 @@ function cmdUpdate(db, args) {
|
|
|
1616
1578
|
fail('[mem] --concepts cannot be empty. Omit the flag to leave it unchanged.');
|
|
1617
1579
|
return;
|
|
1618
1580
|
}
|
|
1619
|
-
|
|
1581
|
+
fields.concepts = flags.concepts;
|
|
1620
1582
|
}
|
|
1621
1583
|
|
|
1622
|
-
|
|
1584
|
+
// Shared mutation (lib/observation-write applyObsUpdate, P2-12): scrub + UPDATE +
|
|
1585
|
+
// derived-column rebuild in one transaction — single source with MCP mem_update.
|
|
1586
|
+
const updatedCols = applyObsUpdate(db, id, fields);
|
|
1587
|
+
if (updatedCols.length === 0) {
|
|
1623
1588
|
fail('[mem] No fields to update. Use --title, --type, --importance, --lesson/--lesson-learned, --narrative, --concepts');
|
|
1624
1589
|
return;
|
|
1625
1590
|
}
|
|
1626
1591
|
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
// Atomic: update fields + rebuild derived columns (FTS text + vector) via the
|
|
1630
|
-
// shared core — single source with MCP mem_update (lib/observation-write.mjs).
|
|
1631
|
-
db.transaction(() => {
|
|
1632
|
-
db.prepare(`UPDATE observations SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
|
1633
|
-
rebuildObservationDerived(db, id);
|
|
1634
|
-
})();
|
|
1635
|
-
|
|
1636
|
-
out(`[mem] Updated #${id}: ${updates.map(u => u.split(' =')[0]).join(', ')}`);
|
|
1592
|
+
out(`[mem] Updated #${id}: ${updatedCols.join(', ')}`);
|
|
1637
1593
|
}
|
|
1638
1594
|
|
|
1639
1595
|
// ─── Export ──────────────────────────────────────────────────────────────────
|
|
@@ -1799,9 +1755,22 @@ function cmdRestore(db, argv) {
|
|
|
1799
1755
|
decay_seen_count = ?, last_accessed_at = ?
|
|
1800
1756
|
WHERE id = ?`);
|
|
1801
1757
|
|
|
1802
|
-
let restored = 0, skipped = 0, malformed = 0;
|
|
1758
|
+
let restored = 0, skipped = 0, malformed = 0, tombstoned = 0;
|
|
1803
1759
|
for (const r of rows) {
|
|
1804
1760
|
if (!r || typeof r !== 'object' || !r.type || !r.title) { malformed++; continue; }
|
|
1761
|
+
// M-8 (audit 2026-08-14): a row exported with --include-compressed carries its
|
|
1762
|
+
// compressed_into tombstone. Restoring it as a live row resurrects a member its
|
|
1763
|
+
// weekly-summary keeper already absorbed (duplicate search hits, and the marker's
|
|
1764
|
+
// target id is meaningless in this store). Reject rather than remap; the content
|
|
1765
|
+
// lives on in the keeper.
|
|
1766
|
+
// D#122: COMPRESSED_PENDING_PURGE (-2) is NOT keeper-absorbed — those rows have
|
|
1767
|
+
// no keeper, so rejecting them silently loses their only copy. Restore them
|
|
1768
|
+
// live; the maintain decay pipeline will re-evaluate them like any other row.
|
|
1769
|
+
// COMPRESSED_AUTO (-1) stays REJECTED by design (review 2026-08-16): those rows
|
|
1770
|
+
// were deliberately retired by the quality pipeline (idle-cleanup/optimize mark
|
|
1771
|
+
// importance-1 aged rows) — restoring them live would resurrect adjudicated
|
|
1772
|
+
// noise, unlike -2 (a purge QUEUE the user may still be racing to undo).
|
|
1773
|
+
if (r.compressed_into && r.compressed_into !== COMPRESSED_PENDING_PURGE) { tombstoned++; continue; }
|
|
1805
1774
|
const project = projOverride || r.project || inferProject();
|
|
1806
1775
|
const createdEpoch = Number.isFinite(Number(r.created_at_epoch)) ? Number(r.created_at_epoch) : Date.now();
|
|
1807
1776
|
// Durable exact-dup guard — saveObservation's 5-min Jaccard window can't catch a
|
|
@@ -1868,7 +1837,8 @@ function cmdRestore(db, argv) {
|
|
|
1868
1837
|
// ones that parsed.
|
|
1869
1838
|
const totalMalformed = malformed + parseFailures;
|
|
1870
1839
|
const totalLines = rows.length + parseFailures;
|
|
1871
|
-
|
|
1840
|
+
const tombstoneNote = tombstoned > 0 ? `, ${tombstoned} compressed member(s) rejected (keeper-absorbed or auto-retired tombstones)` : '';
|
|
1841
|
+
out(`[mem] Restore${dryRun ? ' (dry-run)' : ''}: ${restored} restored, ${skipped} duplicate(s) skipped${tombstoneNote}, ${totalMalformed} malformed/failed from ${totalLines} row(s).`);
|
|
1872
1842
|
// Name the lossiness where the user meets it. Export omits related_ids and drops
|
|
1873
1843
|
// superseded rows, and restore re-inserts under fresh AUTOINCREMENT ids — so no
|
|
1874
1844
|
// cross-link can survive the round-trip. That is a deliberate format tradeoff (stored
|
|
@@ -2283,18 +2253,13 @@ function cmdRegistry(_memDb, args) {
|
|
|
2283
2253
|
if (action === 'list') {
|
|
2284
2254
|
const typeFilter = flags.type;
|
|
2285
2255
|
const listLimit = parseIntFlag(flags.limit, { name: '--limit', defaultValue: 20, max: 1000 });
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
const allResources = rdb
|
|
2289
|
-
SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
|
|
2290
|
-
FROM resources ${where} ORDER BY adopt_count DESC, recommend_count DESC, type, name
|
|
2291
|
-
`).all(...params);
|
|
2256
|
+
// Shared ranked query + row line (registry.mjs, P2-12): COALESCE ordering and
|
|
2257
|
+
// the 80-char/adopt:0 row shape had drifted between the faces.
|
|
2258
|
+
const allResources = listResourcesRanked(rdb, { type: typeFilter });
|
|
2292
2259
|
if (allResources.length === 0) { out('[mem] No resources found.'); return; }
|
|
2293
2260
|
const resources = allResources.slice(0, listLimit);
|
|
2294
2261
|
out(`[mem] Resources (showing ${resources.length} of ${allResources.length}):`);
|
|
2295
|
-
for (const r of resources) {
|
|
2296
|
-
out(` ${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count} adopt:${r.adopt_count} — ${truncate(r.capability_summary || '', 50)}`);
|
|
2297
|
-
}
|
|
2262
|
+
for (const r of resources) out(` ${formatRegistryListLine(r)}`);
|
|
2298
2263
|
if (allResources.length > listLimit) {
|
|
2299
2264
|
out(`[mem] Use --limit N to see more, or "registry search <query>" to find specific resources.`);
|
|
2300
2265
|
}
|
|
@@ -2302,25 +2267,17 @@ function cmdRegistry(_memDb, args) {
|
|
|
2302
2267
|
}
|
|
2303
2268
|
|
|
2304
2269
|
if (action === 'stats') {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
const
|
|
2308
|
-
'SELECT name, type, adopt_count, recommend_count FROM resources WHERE status = ? AND adopt_count > 0 ORDER BY adopt_count DESC LIMIT 10'
|
|
2309
|
-
).all('active');
|
|
2310
|
-
const zeroAdopt = rdb.prepare(
|
|
2311
|
-
'SELECT COUNT(*) as c FROM resources WHERE status = ? AND recommend_count > 0 AND adopt_count = 0'
|
|
2312
|
-
).get('active');
|
|
2313
|
-
const userAdded = rdb.prepare(
|
|
2314
|
-
"SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'"
|
|
2315
|
-
).get('active');
|
|
2270
|
+
// Shared collection (registry.mjs collectRegistryStats, P2-12) — single
|
|
2271
|
+
// source with the MCP mem_registry stats action.
|
|
2272
|
+
const s = collectRegistryStats(rdb);
|
|
2316
2273
|
out(`[mem] Registry Stats:`);
|
|
2317
|
-
out(` Total active: ${total
|
|
2318
|
-
for (const t of byType) out(` ${t.type}: ${t.c}`);
|
|
2319
|
-
out(` User-added: ${userAdded
|
|
2320
|
-
out(` Zero adoption (recommended but never adopted): ${zeroAdopt
|
|
2321
|
-
if (topAdopted.length > 0) {
|
|
2274
|
+
out(` Total active: ${s.total}`);
|
|
2275
|
+
for (const t of s.byType) out(` ${t.type}: ${t.c}`);
|
|
2276
|
+
out(` User-added: ${s.userAdded}`);
|
|
2277
|
+
out(` Zero adoption (recommended but never adopted): ${s.zeroAdopt}`);
|
|
2278
|
+
if (s.topAdopted.length > 0) {
|
|
2322
2279
|
out(' Top adopted:');
|
|
2323
|
-
for (const r of topAdopted) out(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
|
|
2280
|
+
for (const r of s.topAdopted) out(` ${r.name} (${r.type}): ${r.adopt_count}/${r.recommend_count}`);
|
|
2324
2281
|
}
|
|
2325
2282
|
return;
|
|
2326
2283
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.64.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "claude-mem-lite",
|
|
9
|
-
"version": "3.
|
|
9
|
+
"version": "3.64.0",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
12
12
|
"better-sqlite3": "^12.6.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-mem-lite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.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
|
"type": "module",
|
|
6
6
|
"packageManager": "npm@10.9.2",
|
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"lib/atomic-write.mjs",
|
|
83
83
|
"lib/release-digest.mjs",
|
|
84
84
|
"lib/mem-override.mjs",
|
|
85
|
+
"lib/injected-ids.mjs",
|
|
86
|
+
"lib/inject-search-core.mjs",
|
|
87
|
+
"lib/get-core.mjs",
|
|
88
|
+
"lib/browse-core.mjs",
|
|
85
89
|
"lib/save-observation.mjs",
|
|
86
90
|
"lib/observation-write.mjs",
|
|
87
91
|
"lib/recall-core.mjs",
|
package/registry.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import { existsSync, mkdirSync } from 'fs';
|
|
6
6
|
import { dirname } from 'path';
|
|
7
|
-
import { debugCatch } from './utils.mjs';
|
|
7
|
+
import { debugCatch, truncate } from './utils.mjs';
|
|
8
8
|
|
|
9
9
|
// ─── Schema ──────────────────────────────────────────────────────────────────
|
|
10
10
|
|
|
@@ -513,3 +513,55 @@ export function upsertResource(db, r) {
|
|
|
513
513
|
return row?.id || 0;
|
|
514
514
|
})();
|
|
515
515
|
}
|
|
516
|
+
|
|
517
|
+
// ─── P2-12: registry stats/list twin cores (audit 2026-08-14) ────────────────
|
|
518
|
+
// The five stats statements + the list row line were duplicated in mem-cli.mjs
|
|
519
|
+
// and server.mjs and had already drifted (truncate 50 vs 80; `adopt:null` on
|
|
520
|
+
// one face; the COALESCE ordering fix landed on the MCP face only). Data
|
|
521
|
+
// collection + row-line shape live here; faces keep their own headers/limits.
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Collect the five stat groups the `registry stats` twin renders.
|
|
525
|
+
* @param {import('better-sqlite3').Database} rdb registry DB handle
|
|
526
|
+
* @returns {{total:number, byType:Array<{type:string,c:number}>, topAdopted:object[], zeroAdopt:number, userAdded:number}}
|
|
527
|
+
*/
|
|
528
|
+
export function collectRegistryStats(rdb) {
|
|
529
|
+
const total = rdb.prepare('SELECT COUNT(*) as c FROM resources WHERE status = ?').get('active');
|
|
530
|
+
const byType = rdb.prepare('SELECT type, COUNT(*) as c FROM resources WHERE status = ? GROUP BY type').all('active');
|
|
531
|
+
const topAdopted = rdb.prepare(
|
|
532
|
+
'SELECT name, type, adopt_count, recommend_count FROM resources WHERE status = ? AND adopt_count > 0 ORDER BY adopt_count DESC LIMIT 10'
|
|
533
|
+
).all('active');
|
|
534
|
+
const zeroAdopt = rdb.prepare(
|
|
535
|
+
'SELECT COUNT(*) as c FROM resources WHERE status = ? AND recommend_count > 0 AND adopt_count = 0'
|
|
536
|
+
).get('active');
|
|
537
|
+
const userAdded = rdb.prepare(
|
|
538
|
+
"SELECT COUNT(*) as c FROM resources WHERE status = ? AND source = 'user'"
|
|
539
|
+
).get('active');
|
|
540
|
+
return { total: total.c, byType, topAdopted, zeroAdopt: zeroAdopt.c, userAdded: userAdded.c };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Ranked resource listing for the `registry list` twin: adoption first, then
|
|
545
|
+
* recommendation, NULL counts coalesced (the un-coalesced face sorted NULLs
|
|
546
|
+
* apart AND rendered "adopt:null").
|
|
547
|
+
* @param {import('better-sqlite3').Database} rdb
|
|
548
|
+
* @param {{type?: string}} [opts]
|
|
549
|
+
* @returns {object[]}
|
|
550
|
+
*/
|
|
551
|
+
export function listResourcesRanked(rdb, { type } = {}) {
|
|
552
|
+
const where = type ? 'WHERE type = ? AND status = ?' : 'WHERE status = ?';
|
|
553
|
+
const params = type ? [type, 'active'] : ['active'];
|
|
554
|
+
return rdb.prepare(`
|
|
555
|
+
SELECT name, type, invocation_name, recommend_count, adopt_count, capability_summary
|
|
556
|
+
FROM resources ${where}
|
|
557
|
+
ORDER BY COALESCE(adopt_count, 0) DESC, COALESCE(recommend_count, 0) DESC, type, name
|
|
558
|
+
`).all(...params);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** One list row, shared shape: `S name (invocation) — rec:N adopt:N — summary…` (80-char summary cap).
|
|
562
|
+
* truncate(), not a hand-rolled slice (adversarial review 2026-08-16): the shared
|
|
563
|
+
* helper flattens newlines (this is a one-row-per-resource listing) and never
|
|
564
|
+
* splits a UTF-16 surrogate pair — a bare slice emitted lone surrogates. */
|
|
565
|
+
export function formatRegistryListLine(r) {
|
|
566
|
+
return `${r.type === 'skill' ? 'S' : 'A'} ${r.name}${r.invocation_name ? ` (${r.invocation_name})` : ''} — rec:${r.recommend_count ?? 0} adopt:${r.adopt_count ?? 0} — ${truncate(r.capability_summary || '', 80)}`;
|
|
567
|
+
}
|
package/scoring-sql.mjs
CHANGED
|
@@ -23,10 +23,11 @@ import { buildNotLowSignalSql } from './lib/low-signal-patterns.mjs';
|
|
|
23
23
|
// Where a multiplier reads 0 it is a benchmark-MISMATCH artifact (the instrument
|
|
24
24
|
// can't vary that axis), NOT proven dead weight. Decision: KEEP them; do NOT
|
|
25
25
|
// delete on "0 lift". Guardrail: the ci-gate `hybrid_over_bm25 >= -0.05` floor
|
|
26
|
-
// (benchmark/ci-gate.mjs) covers the full modelled chain
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
26
|
+
// (benchmark/ci-gate.mjs) covers the full modelled chain — D#121: cite + noise
|
|
27
|
+
// joined the matrix MULT_EXPR after M-3 put them in FULL_SCORE (fixture carries
|
|
28
|
+
// zero cite/noise state, so both read 0 by construction, same caveat as lesson;
|
|
29
|
+
// their real-SQL direction pins live in benchmark/events-pipeline-probes.mjs).
|
|
30
|
+
// Genuine validation of the prior-encoding axes needs a labeled real-dev-memory eval.
|
|
30
31
|
|
|
31
32
|
// ─── Type-Differentiated Recency Decay ──────────────────────────────────────
|
|
32
33
|
|