claude-mem-lite 3.47.0 → 3.49.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-context.mjs +20 -1
- package/hook-llm.mjs +2 -1
- package/hook-optimize.mjs +23 -13
- package/hook.mjs +54 -3
- package/lib/activity.mjs +2 -1
- package/lib/citation-tracker.mjs +7 -1
- package/lib/delete-core.mjs +72 -0
- package/lib/events-injection.mjs +100 -0
- package/lib/export-columns.mjs +4 -1
- package/lib/obs-types.mjs +12 -0
- package/lib/save-nudge.mjs +22 -0
- package/lib/search-core.mjs +47 -5
- package/lib/stats-core.mjs +113 -0
- package/mem-cli.mjs +22 -147
- package/package.json +6 -1
- package/registry.mjs +1 -1
- package/schema.mjs +1 -1
- package/scripts/pre-tool-recall.js +12 -4
- package/scripts/user-prompt-search.js +4 -1
- package/server.mjs +17 -136
- package/source-files.mjs +23 -0
- package/tool-schemas.mjs +2 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
13
|
+
"version": "3.49.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.49.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-context.mjs
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
debugLog, debugCatch, neutralizeContextDelimiters,
|
|
9
9
|
DECAY_HALF_LIFE_BY_TYPE, DEFAULT_DECAY_HALF_LIFE_MS, notLowSignalTitleClause,
|
|
10
10
|
} from './utils.mjs';
|
|
11
|
-
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet } from './hook-shared.mjs';
|
|
11
|
+
import { STALE_SESSION_MS, FALLBACK_OBS_WINDOW_MS, RUNTIME_DIR, effectiveQuiet, isQuietHooks } from './hook-shared.mjs';
|
|
12
12
|
import { extractUnfinishedSummary } from './hook-handoff.mjs';
|
|
13
|
+
import { recentInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Infer the project directory from environment variables or cwd.
|
|
@@ -390,6 +391,24 @@ export function buildSessionContextLines(db, project, now = new Date(), currentC
|
|
|
390
391
|
}
|
|
391
392
|
}
|
|
392
393
|
|
|
394
|
+
// HIGH-1 (full audit 2026-07-16): surface recent high-importance events — the
|
|
395
|
+
// canonical store for promoted bugfix/decision/lesson memories that
|
|
396
|
+
// persistHaikuSummary upgrade-deletes out of observations. Without this section
|
|
397
|
+
// SessionStart never shows them. E# prefix keeps citation extractors (bare-`#`
|
|
398
|
+
// anchored) from reading an event id as an observation id. Gated on isQuietHooks()
|
|
399
|
+
// ONLY (explicit low-noise opt-out), NOT effectiveQuiet: unlike Key Context, events
|
|
400
|
+
// never appear in the obs-only Recent table and are absent from the MEMORY.md
|
|
401
|
+
// sentinel, so an adopted project (the default) would otherwise have zero
|
|
402
|
+
// SessionStart surface for them. Never throws (recentInjectableEvents catches).
|
|
403
|
+
if (!isQuietHooks()) {
|
|
404
|
+
const keyEvents = recentInjectableEvents(db, { project, limit: 5 });
|
|
405
|
+
if (keyEvents.length > 0) {
|
|
406
|
+
summaryLines.push('### Key Events');
|
|
407
|
+
for (const e of keyEvents) summaryLines.push(`- ${renderInjectableEvent(e)}`);
|
|
408
|
+
summaryLines.push('');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
393
412
|
// 5. Working state from latest /clear handoff.
|
|
394
413
|
// Session scoping: when currentCcSessionId is provided, restrict to this session's
|
|
395
414
|
// own clear handoff so parallel sessions don't see each other's Working State block.
|
package/hook-llm.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { EVENT_TYPES, saveEvent } from './lib/activity.mjs';
|
|
22
22
|
import { isNoiseObservation, capNoiseImportance, isLowYieldChangeObs } from './lib/low-signal-patterns.mjs';
|
|
23
23
|
import { episodeHasSignificantContent } from './hook-episode.mjs';
|
|
24
|
+
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
24
25
|
|
|
25
26
|
// T9: memdir-incompatible types live in the `events` table, not `observations`.
|
|
26
27
|
// Set lookup is O(1) — authoritative source is lib/activity.mjs::EVENT_TYPES.
|
|
@@ -771,7 +772,7 @@ ${actionList}`;
|
|
|
771
772
|
const ruleImportance = computeRuleImportance(episode);
|
|
772
773
|
|
|
773
774
|
let obs;
|
|
774
|
-
const validTypes =
|
|
775
|
+
const validTypes = OBS_TYPE_SET;
|
|
775
776
|
|
|
776
777
|
const gotSlot = await acquireLLMSlot();
|
|
777
778
|
if (gotSlot) {
|
package/hook-optimize.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { scrubRecord } from './lib/scrub-record.mjs';
|
|
|
15
15
|
import { getVocabulary, computeVector, cosineSimilarity, vecTextForRow } from './tfidf.mjs';
|
|
16
16
|
import { MERGE_JACCARD_LOW, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
|
|
17
17
|
import { DB_DIR } from './schema.mjs';
|
|
18
|
+
import { OBS_TYPE_SET } from './lib/obs-types.mjs';
|
|
18
19
|
|
|
19
20
|
const RUNTIME_DIR = join(DB_DIR, 'runtime');
|
|
20
21
|
|
|
@@ -150,7 +151,7 @@ export async function executeReenrich(db, limit = 10, { scope = 'narrow', projec
|
|
|
150
151
|
if (candidates.length === 0) return { processed: 0, skipped: 0 };
|
|
151
152
|
|
|
152
153
|
let processed = 0, skipped = 0;
|
|
153
|
-
const validTypes =
|
|
154
|
+
const validTypes = OBS_TYPE_SET;
|
|
154
155
|
|
|
155
156
|
for (const cand of candidates) {
|
|
156
157
|
const gotSlot = await acquireLLMSlot();
|
|
@@ -929,19 +930,28 @@ export async function optimizeRun(db, { tasks, maxItems = 15, force = false, ree
|
|
|
929
930
|
try {
|
|
930
931
|
switch (task) {
|
|
931
932
|
case 're-enrich':
|
|
932
|
-
if (reenrichScope === 'narrow') {
|
|
933
|
-
// P1-2: the
|
|
934
|
-
// fully-degraded rows
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
933
|
+
if (reenrichScope === 'narrow' || reenrichScope === 'wide') {
|
|
934
|
+
// P1-2 (v3.43) + audit 2026-07-17 P4: the maintenance pass covers BOTH the main
|
|
935
|
+
// scope (narrow = fill lesson/concepts on fully-degraded rows; wide = lesson
|
|
936
|
+
// backfill on substantive event-typed rows) AND aliases (backfill search_aliases
|
|
937
|
+
// on lesson-bearing manual saves that narrow+wide both skip — mem_save writes no
|
|
938
|
+
// aliases, so without this they stay paraphrase-unfindable). v3.43 hung the split
|
|
939
|
+
// only on the DEFAULT 'narrow' branch, but the DAILY auto path (handleLLMOptimize
|
|
940
|
+
// via auto-maintain) passes 'wide' explicitly — so aliases never had a cadence and
|
|
941
|
+
// live coverage crawled at ~15%. The split is ADAPTIVE: aliases takes at most half
|
|
942
|
+
// the budget and only what its candidate pool actually holds, so a zero-candidate
|
|
943
|
+
// aliases pass costs nothing and the main scope keeps its full budget.
|
|
944
|
+
// An explicit --scope aliases still runs exactly that one scope (below).
|
|
945
|
+
const half = Math.max(1, Math.floor(budget.reenrich / 2));
|
|
946
|
+
const aliasBudget = Math.min(half, findReenrichCandidates(db, half, { scope: 'aliases', project }).length);
|
|
947
|
+
const mainRes = await executeReenrich(db, budget.reenrich - aliasBudget, { scope: reenrichScope, project });
|
|
948
|
+
const aliasRes = aliasBudget > 0
|
|
949
|
+
? await executeReenrich(db, aliasBudget, { scope: 'aliases', project })
|
|
950
|
+
: { processed: 0, skipped: 0 };
|
|
941
951
|
results.reenrich = {
|
|
942
|
-
processed: (
|
|
943
|
-
skipped: (
|
|
944
|
-
byScope: {
|
|
952
|
+
processed: (mainRes.processed || 0) + (aliasRes.processed || 0),
|
|
953
|
+
skipped: (mainRes.skipped || 0) + (aliasRes.skipped || 0),
|
|
954
|
+
byScope: { [reenrichScope]: mainRes, aliases: aliasRes },
|
|
945
955
|
};
|
|
946
956
|
} else {
|
|
947
957
|
results.reenrich = await executeReenrich(db, budget.reenrich, { scope: reenrichScope, project });
|
package/hook.mjs
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
|
|
62
62
|
import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
|
|
63
63
|
import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
|
|
64
|
+
import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
|
|
64
65
|
import { formatTaskImperative } from './lib/task-imperative.mjs';
|
|
65
66
|
import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
|
|
66
67
|
import { gcOldMetricShards } from './lib/metrics.mjs';
|
|
@@ -87,7 +88,7 @@ import { getVocabulary } from './tfidf.mjs';
|
|
|
87
88
|
// Prevent recursive hooks from background claude -p calls
|
|
88
89
|
// Background workers (llm-episode, llm-summary) are exempt — they're ours
|
|
89
90
|
const event = process.argv[2];
|
|
90
|
-
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize']);
|
|
91
|
+
const BG_EVENTS = new Set(['llm-episode', 'llm-summary', 'auto-compress', 'llm-optimize', 'auto-maintain']);
|
|
91
92
|
|
|
92
93
|
// Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
|
|
93
94
|
// install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
|
|
@@ -420,7 +421,18 @@ function triggerErrorRecall(db, toolInput, response) {
|
|
|
420
421
|
`).all(ftsQuery, project, nowR);
|
|
421
422
|
|
|
422
423
|
const out = formatErrorRecallHints(rows);
|
|
423
|
-
if (out)
|
|
424
|
+
if (out) {
|
|
425
|
+
// MED-3 (full audit 2026-07-16): emit via the JSON envelope (trailing '\n'),
|
|
426
|
+
// NOT raw stdout. A raw multi-line write corrupts a co-emitted episode-flush
|
|
427
|
+
// receipt (both write to PostToolUse stdout in the same call → `<text>{json}`)
|
|
428
|
+
// and is silently dropped on CC variants that ignore plain-text PostToolUse
|
|
429
|
+
// stdout. This is the same suppressOutput+additionalContext channel
|
|
430
|
+
// flushEpisode uses; two separate JSON lines each parse independently.
|
|
431
|
+
process.stdout.write(JSON.stringify({
|
|
432
|
+
suppressOutput: true,
|
|
433
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: out },
|
|
434
|
+
}) + '\n');
|
|
435
|
+
}
|
|
424
436
|
} catch (e) { debugCatch(e, 'triggerErrorRecall'); }
|
|
425
437
|
}
|
|
426
438
|
|
|
@@ -975,6 +987,29 @@ function runSessionStartAutoMaintain(db) {
|
|
|
975
987
|
}
|
|
976
988
|
}
|
|
977
989
|
|
|
990
|
+
// SessionStart boot path (MED-4): cheap 24h gate pre-check only. When maintenance is
|
|
991
|
+
// due, the heavy pass (VACUUM-INTO snapshot + purge/cleanup/decay/dedup) is handed to
|
|
992
|
+
// a detached `auto-maintain` worker via spawnBackground so it never blocks interactive
|
|
993
|
+
// session start. The worker re-checks the same gate (idempotent) before doing the work.
|
|
994
|
+
function scheduleSessionStartAutoMaintain() {
|
|
995
|
+
const maintainFile = join(RUNTIME_DIR, 'last-auto-maintain.json');
|
|
996
|
+
try {
|
|
997
|
+
const last = JSON.parse(readFileSync(maintainFile, 'utf8'));
|
|
998
|
+
if (Date.now() - last.epoch < 24 * 3600000) return; // not due — no spawn
|
|
999
|
+
} catch { /* no gate file → due */ }
|
|
1000
|
+
if (!process.env.CLAUDE_MEM_SKIP_MAINTAIN) spawnBackground('auto-maintain');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Detached `auto-maintain` worker entry: opens its own DB and runs the maintenance
|
|
1004
|
+
// pass off the interactive boot path. runSessionStartAutoMaintain still owns the 24h
|
|
1005
|
+
// gate + the compress/optimize spawns at its tail.
|
|
1006
|
+
function handleAutoMaintain() {
|
|
1007
|
+
const db = openDb();
|
|
1008
|
+
if (!db) return;
|
|
1009
|
+
try { runSessionStartAutoMaintain(db); }
|
|
1010
|
+
finally { try { db.close(); } catch { /* ignore */ } }
|
|
1011
|
+
}
|
|
1012
|
+
|
|
978
1013
|
function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
|
|
979
1014
|
// Shared clear handoff reference — queried once, used by fast summary + working state
|
|
980
1015
|
let prevClearHandoff = null;
|
|
@@ -1285,7 +1320,7 @@ async function handleSessionStart() {
|
|
|
1285
1320
|
|
|
1286
1321
|
runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
|
|
1287
1322
|
|
|
1288
|
-
|
|
1323
|
+
scheduleSessionStartAutoMaintain();
|
|
1289
1324
|
|
|
1290
1325
|
// ── Non-transactional operations (side effects, background work) ──
|
|
1291
1326
|
|
|
@@ -1520,6 +1555,21 @@ async function handleUserPrompt() {
|
|
|
1520
1555
|
lines.push('</memory-context>');
|
|
1521
1556
|
process.stdout.write(lines.join('\n') + '\n');
|
|
1522
1557
|
}
|
|
1558
|
+
// HIGH-1 (full audit 2026-07-16): surface FTS-matched events — the canonical
|
|
1559
|
+
// store for promoted bugfix/decision/lesson memories that persistHaikuSummary
|
|
1560
|
+
// upgrade-deletes out of observations. Without this leg they are unreachable at
|
|
1561
|
+
// prompt time. Separate E#-tagged block so it doesn't perturb observation
|
|
1562
|
+
// ranking and citation extractors (bare-`#` anchored) never read an event id as
|
|
1563
|
+
// an obs id. Nested try so an events failure can't suppress the imperative pick.
|
|
1564
|
+
try {
|
|
1565
|
+
const events = searchInjectableEvents(db, { prompt: promptText, project });
|
|
1566
|
+
if (events.length > 0) {
|
|
1567
|
+
const elines = ['<memory-context relevance="events">'];
|
|
1568
|
+
for (const e of events) elines.push(`- ${renderInjectableEvent(e)}`);
|
|
1569
|
+
elines.push('</memory-context>');
|
|
1570
|
+
process.stdout.write(elines.join('\n') + '\n');
|
|
1571
|
+
}
|
|
1572
|
+
} catch (e) { debugCatch(e, 'handleUserPrompt-events'); }
|
|
1523
1573
|
if (imperativePick) {
|
|
1524
1574
|
// Guard the write on a non-empty return — formatTaskImperative yields '' for a
|
|
1525
1575
|
// lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
|
|
@@ -1639,6 +1689,7 @@ try {
|
|
|
1639
1689
|
case 'llm-episode': await handleLLMEpisode(); break;
|
|
1640
1690
|
case 'llm-summary': await handleLLMSummary(); break;
|
|
1641
1691
|
case 'auto-compress': handleAutoCompress(); break;
|
|
1692
|
+
case 'auto-maintain': handleAutoMaintain(); break;
|
|
1642
1693
|
case 'llm-optimize': await handleLLMOptimize(); break;
|
|
1643
1694
|
// Detached update refresh spawned by handleSessionStart (audit P3d) — does the
|
|
1644
1695
|
// GitHub fetch + (non-plugin) install off the SessionStart critical path,
|
package/lib/activity.mjs
CHANGED
|
@@ -8,10 +8,11 @@ import { sanitizeFtsQuery } from '../utils.mjs';
|
|
|
8
8
|
import { scrubRecord } from './scrub-record.mjs';
|
|
9
9
|
import { saveObservation } from './save-observation.mjs';
|
|
10
10
|
import { notLowSignalTitleClause } from '../scoring-sql.mjs';
|
|
11
|
+
import { OBS_TYPE_SET } from './obs-types.mjs';
|
|
11
12
|
|
|
12
13
|
// Observation types (mirrors the observations.type enum) — events carry a wider
|
|
13
14
|
// set, so promotion maps the extras (lesson/bug/observation) onto valid obs types.
|
|
14
|
-
const OBS_TYPES =
|
|
15
|
+
const OBS_TYPES = OBS_TYPE_SET;
|
|
15
16
|
const EVENT_TO_OBS_TYPE = { bug: 'bugfix', lesson: 'discovery', observation: 'discovery' };
|
|
16
17
|
|
|
17
18
|
/**
|
package/lib/citation-tracker.mjs
CHANGED
|
@@ -707,7 +707,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
|
|
|
707
707
|
}
|
|
708
708
|
}
|
|
709
709
|
});
|
|
710
|
-
|
|
710
|
+
// IMMEDIATE (not the default DEFERRED): this txn reads each obs then writes it.
|
|
711
|
+
// Under concurrent same-project Stop hooks a DEFERRED txn pins a read snapshot
|
|
712
|
+
// first, and the second session's write then hits SQLITE_BUSY_SNAPSHOT — a
|
|
713
|
+
// snapshot-upgrade conflict busy_timeout cannot resolve — silently dropping the
|
|
714
|
+
// whole decay pass (promotes/demotes/streak + the paired funnel deltas). Taking
|
|
715
|
+
// the write lock up front lets busy_timeout actually serialize the two sessions.
|
|
716
|
+
try { txn.immediate(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
|
|
711
717
|
return { promoted, demoted, touched };
|
|
712
718
|
}
|
|
713
719
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// lib/delete-core.mjs — shared hard-delete orchestration for the CLI `delete`
|
|
2
|
+
// command (mem-cli.mjs cmdDelete) and the MCP `mem_delete` tool (server.mjs).
|
|
3
|
+
// Both surfaces previously inlined a byte-for-byte-equivalent copy of this logic,
|
|
4
|
+
// kept in sync by "aligned with MCP mem_delete" / "matching the CLI delete" parity
|
|
5
|
+
// comments — the project's documented #1 drift risk. Consolidated here so there is
|
|
6
|
+
// one implementation: pre-delete snapshot, stale related_ids cleanup,
|
|
7
|
+
// merged/compressed-child recovery, and the delete transaction.
|
|
8
|
+
import { snapshotDb } from './db-backup.mjs';
|
|
9
|
+
import { recoverChildrenOf } from './maintain-core.mjs';
|
|
10
|
+
import { debugCatch } from '../utils.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hard-delete the given observation ids with full orchestration. The CALLER owns
|
|
14
|
+
* the confirm/preview gating and fetches whatever rows it needs to render (existence
|
|
15
|
+
* check, preview list, missing-id note); this performs only the irreversible mutation
|
|
16
|
+
* once the caller has decided to proceed.
|
|
17
|
+
*
|
|
18
|
+
* Order (preserved from the pre-extraction inline paths):
|
|
19
|
+
* 1. snapshotDb OUTSIDE any transaction (VACUUM INTO cannot run inside one). Best-
|
|
20
|
+
* effort: no-op on :memory:, never throws.
|
|
21
|
+
* 2. In ONE transaction: strip the deleted ids out of other rows' related_ids
|
|
22
|
+
* (coarse LIKE pre-filter → JSON-parse precise filter), recover children
|
|
23
|
+
* merged/compressed INTO the doomed rows (compressed_into has no FK), then DELETE.
|
|
24
|
+
*
|
|
25
|
+
* @param {import('better-sqlite3').Database} db
|
|
26
|
+
* @param {number[]} ids observation ids to delete
|
|
27
|
+
* @param {{ snapshotTag?: string }} [opts]
|
|
28
|
+
* @returns {{ deleted: number, recoveredChildren: number, snapshotPath: string|null }}
|
|
29
|
+
*/
|
|
30
|
+
export function deleteObservations(db, ids, { snapshotTag = 'pre-delete' } = {}) {
|
|
31
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
32
|
+
|
|
33
|
+
// Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
|
|
34
|
+
// matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
|
|
35
|
+
// (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM INTO).
|
|
36
|
+
const snapshotPath = snapshotDb(db, { tag: snapshotTag });
|
|
37
|
+
|
|
38
|
+
const deletedIds = new Set(ids);
|
|
39
|
+
const deleteTx = db.transaction(() => {
|
|
40
|
+
// Clean up stale references in other observations' related_ids.
|
|
41
|
+
// LIKE %id% is a coarse pre-filter (false positives: %1% matches [10], [21]) — it only
|
|
42
|
+
// narrows which rows to fetch; the JSON parse + Set.has below is the precise filter.
|
|
43
|
+
// Acceptable because observation count per user is typically <10K.
|
|
44
|
+
const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
|
|
45
|
+
const likeParams = ids.map(id => `%${id}%`);
|
|
46
|
+
const referencing = db.prepare(`
|
|
47
|
+
SELECT id, related_ids FROM observations
|
|
48
|
+
WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
|
|
49
|
+
`).all(...likeParams);
|
|
50
|
+
for (const r of referencing) {
|
|
51
|
+
let refIds;
|
|
52
|
+
try { refIds = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
|
|
53
|
+
// Only rewrite a well-formed integer array; a malformed related_ids value is left
|
|
54
|
+
// untouched rather than reshaped by the filter (the stricter of the two originals).
|
|
55
|
+
if (!Array.isArray(refIds) || !refIds.every(id => Number.isInteger(id))) continue;
|
|
56
|
+
const filtered = refIds.filter(id => !deletedIds.has(id));
|
|
57
|
+
if (filtered.length !== refIds.length) {
|
|
58
|
+
db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Resurface rows merged/compressed INTO the doomed keepers before deleting, else they
|
|
62
|
+
// dangle behind a now-missing parent (compressed_into has no FK) — invisible to every
|
|
63
|
+
// COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the maintain
|
|
64
|
+
// hard-delete paths use (recoverChildrenOf).
|
|
65
|
+
const recovered = recoverChildrenOf(db, ids);
|
|
66
|
+
// Execute deletion (FTS5 cleanup handled by the observations_ad trigger).
|
|
67
|
+
const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
|
|
68
|
+
return { changes: deleted.changes, recovered };
|
|
69
|
+
});
|
|
70
|
+
const result = deleteTx();
|
|
71
|
+
return { deleted: result.changes, recoveredChildren: result.recovered, snapshotPath };
|
|
72
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/events-injection.mjs — surface `events` into the passive injection surfaces.
|
|
2
|
+
//
|
|
3
|
+
// HIGH-1 (full audit 2026-07-16): persistHaikuSummary upgrade-deletes every
|
|
4
|
+
// event-typed memory (bugfix/decision/lesson/discovery/…) out of `observations`
|
|
5
|
+
// and into the `events` table, so after Haiku enrichment the high-value history
|
|
6
|
+
// lives ONLY in events. v3.44 wired events into mem_search + PreToolUse recall,
|
|
7
|
+
// but the passive injection surfaces still read only observations — so the promoted
|
|
8
|
+
// memories were unreachable at prompt/session time.
|
|
9
|
+
//
|
|
10
|
+
// Wiring (v3.48.0): SessionStart context (hook-context.mjs, recency) + UserPromptSubmit
|
|
11
|
+
// path B (hook.mjs memory-context, FTS). Path A (user-prompt-search.js) is INTENTIONALLY
|
|
12
|
+
// not wired: both UserPromptSubmit hooks fire on every prompt and there is no cross-hook
|
|
13
|
+
// events dedup, so wiring path A too would double-inject the same event. Do NOT "complete"
|
|
14
|
+
// the wiring by adding events here to path A.
|
|
15
|
+
//
|
|
16
|
+
// id-space discipline: events share the numeric id space with observations but are a
|
|
17
|
+
// separate table, so injected events are rendered with an `E#` prefix. The
|
|
18
|
+
// citation-tracker injected-id extractors all anchor on a BARE `#`
|
|
19
|
+
// (FYI `/^#\d/`, memory-context `/\(#\d/`, error-recall row-anchored), so an `E#`
|
|
20
|
+
// row is never mis-attributed to an observation id (which would let citation decay
|
|
21
|
+
// mutate an unrelated observation sharing that id). Events carry no citation columns,
|
|
22
|
+
// so they inject as reference-only — reachability, not decay bookkeeping.
|
|
23
|
+
|
|
24
|
+
import { searchEventsFts } from './search-core.mjs';
|
|
25
|
+
import { neutralizeContextDelimiters } from '../format-utils.mjs';
|
|
26
|
+
import { sanitizeFtsQuery } from '../utils.mjs';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LIMIT = 3;
|
|
29
|
+
const DEFAULT_MIN_IMPORTANCE = 2;
|
|
30
|
+
const TITLE_MAX = 80;
|
|
31
|
+
const LESSON_MAX = 160;
|
|
32
|
+
|
|
33
|
+
function normalizeRow(r) {
|
|
34
|
+
return {
|
|
35
|
+
id: r.id,
|
|
36
|
+
type: r.event_type || r.type || 'event',
|
|
37
|
+
title: r.title || '',
|
|
38
|
+
lesson_learned: (r.body ?? r.lesson_learned) || null,
|
|
39
|
+
importance: r.importance,
|
|
40
|
+
created_at_epoch: r.created_at_epoch,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
|
|
46
|
+
* excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
|
|
47
|
+
* @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
|
|
48
|
+
*/
|
|
49
|
+
export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
|
|
50
|
+
if (!db || !project) return [];
|
|
51
|
+
const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
|
|
52
|
+
if (!q) return [];
|
|
53
|
+
try {
|
|
54
|
+
const rows = searchEventsFts(db, {
|
|
55
|
+
ftsQuery: q, project, projectBoost: project,
|
|
56
|
+
importance: minImportance, perSourceLimit: limit,
|
|
57
|
+
});
|
|
58
|
+
return rows.map(normalizeRow);
|
|
59
|
+
} catch { return []; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Recency + importance events (SessionStart context — no FTS query available).
|
|
64
|
+
* Excludes superseded events. Never throws.
|
|
65
|
+
* @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
|
|
66
|
+
*/
|
|
67
|
+
export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
|
|
68
|
+
if (!db || !project) return [];
|
|
69
|
+
try {
|
|
70
|
+
const rows = db.prepare(`
|
|
71
|
+
SELECT id, event_type, title, body, importance, created_at_epoch
|
|
72
|
+
FROM events
|
|
73
|
+
WHERE project = ?
|
|
74
|
+
AND superseded_at_epoch IS NULL
|
|
75
|
+
AND COALESCE(importance, 1) >= ?
|
|
76
|
+
ORDER BY created_at_epoch DESC
|
|
77
|
+
LIMIT ?
|
|
78
|
+
`).all(project, minImportance, limit);
|
|
79
|
+
return rows.map(normalizeRow);
|
|
80
|
+
} catch { return []; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render one injectable event as a defanged `E#<id> [<type>] <title> — <lesson>` line.
|
|
85
|
+
* The E# prefix keeps citation-tracker extractors (which anchor on a bare `#`) from
|
|
86
|
+
* mis-reading the event id as an observation id.
|
|
87
|
+
*/
|
|
88
|
+
export function renderInjectableEvent(row) {
|
|
89
|
+
const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
|
|
90
|
+
// row.type is a frozen event_type enum (saveEvent is reached only via
|
|
91
|
+
// hook-llm.mjs behind `if (EVENT_TYPE_SET.has(summary.type))`), so it carries no
|
|
92
|
+
// injection markers and is intentionally NOT defanged — mirrors the un-defanged
|
|
93
|
+
// `[type]` for observations. If an unvalidated event writer is ever added, defang it.
|
|
94
|
+
const head = `E#${row.id} [${row.type}] ${title}`;
|
|
95
|
+
if (row.lesson_learned) {
|
|
96
|
+
const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
|
|
97
|
+
if (lesson) return `${head} — ${lesson}`;
|
|
98
|
+
}
|
|
99
|
+
return head;
|
|
100
|
+
}
|
package/lib/export-columns.mjs
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// + branch + timing. `id` + `memory_session_id` are informational (restore remaps id and
|
|
15
15
|
// buckets under a synthetic restore session). Session-idempotency keys (last_decided/
|
|
16
16
|
// last_cited_session_id, demoted_at, optimized_at) are intentionally NOT exported — they are
|
|
17
|
-
// meaningless after a row is re-bucketed under a restore session.
|
|
17
|
+
// meaningless after a row is re-bucketed under a restore session. Also intentionally NOT
|
|
18
|
+
// exported: `related_ids` (holds observation ids, stale/dangling after restore remaps ids)
|
|
19
|
+
// and `discovery_tokens` (a derived retrieval metric, rebuilt by the live system; exporting
|
|
20
|
+
// it would freeze a stale value into backups).
|
|
18
21
|
export const EXPORT_COLUMNS = [
|
|
19
22
|
'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
|
|
20
23
|
'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Single source of truth for the observation `type` vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// Audit 2026-07-17 MED-3: this list was hardcoded verbatim in 10 JS sites + the SQL
|
|
4
|
+
// CHECK constraint (schema.mjs observations DDL) — the project's #1 bug class
|
|
5
|
+
// (fix-lands-on-one-surface) sitting latent on the core type vocabulary. Every
|
|
6
|
+
// validator now imports from here; the SQL CHECK stays a literal (SQLite DDL cannot
|
|
7
|
+
// interpolate at migration time) and is locked to this list by
|
|
8
|
+
// tests/obs-types-invariant.test.mjs, which also fails if a new hardcoded copy of
|
|
9
|
+
// the list appears anywhere else in the runtime source.
|
|
10
|
+
export const OBS_TYPES = Object.freeze(['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change']);
|
|
11
|
+
|
|
12
|
+
export const OBS_TYPE_SET = new Set(OBS_TYPES);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Save-time lesson nudge (audit 2026-07-17 P4): bugfix/decision are the types whose
|
|
2
|
+
// value lives in the lesson (root cause + fix / constraint + tradeoff), yet they are
|
|
3
|
+
// exactly where lessonless writes concentrate (14d live data: bugfix 18.8% / decision
|
|
4
|
+
// 28.7% lessonless, worsening). The nudge fires in the SAVE RESPONSE — the one moment
|
|
5
|
+
// the model still has the context to write the lesson — and names the exact follow-up
|
|
6
|
+
// call, so acting on it costs one tool call. Zero LLM cost; the async backfill arm
|
|
7
|
+
// (optimizeRun wide/aliases re-enrich) stays the safety net for saves that ignore it.
|
|
8
|
+
//
|
|
9
|
+
// Shared by server.mjs (mem_save) and mem-cli.mjs (cmdSave) — one gate, two phrasings,
|
|
10
|
+
// no twin drift.
|
|
11
|
+
const NUDGE_TYPES = new Set(['bugfix', 'decision']);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{ type: string, id: number, lessonCaptured: boolean, surface: 'mcp'|'cli' }} p
|
|
15
|
+
* @returns {string} nudge text to append ('' when no nudge applies)
|
|
16
|
+
*/
|
|
17
|
+
export function buildLessonNudge({ type, id, lessonCaptured, surface }) {
|
|
18
|
+
if (lessonCaptured || !NUDGE_TYPES.has(type)) return '';
|
|
19
|
+
return surface === 'cli'
|
|
20
|
+
? `\n[mem] ⚠ ${type} #${id} saved without a lesson — capture the root cause + fix while it's fresh: claude-mem-lite update ${id} --lesson "<root cause + fix>"`
|
|
21
|
+
: ` ⚠ Saved without lesson_learned — a ${type} is worth keeping only with its lesson. Capture it now: mem_update(id=${id}, lesson_learned="<root cause + fix>").`;
|
|
22
|
+
}
|
package/lib/search-core.mjs
CHANGED
|
@@ -245,17 +245,59 @@ export function searchEventsFts(db, { ftsQuery, project = null, projectBoost = n
|
|
|
245
245
|
`).all(...params);
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
// A lone match has no within-source spread to normalize against (MED-5, v3.48.0).
|
|
249
|
+
// A magnitude-blind constant (-0.5) fixed the dominating direction — an incidental
|
|
250
|
+
// lone hit no longer outranks a strongly-matched page — but buried the opposite,
|
|
251
|
+
// equally common shape (audit 2026-07-17 MED-1): events are the CANONICAL store for
|
|
252
|
+
// promoted bugfix/decision memories AND a low-cardinality source, so "the best
|
|
253
|
+
// answer is one strong event" is routine, and within-source max-normalization pins
|
|
254
|
+
// every multi-hit source's best to -1 regardless of absolute strength.
|
|
255
|
+
//
|
|
256
|
+
// Fix: band the lone hit by its raw magnitude RELATIVE to the global raw max across
|
|
257
|
+
// all scored rows — the cross-source signal the per-source normalization destroys.
|
|
258
|
+
// obs/event/session BM25 live on comparable scales (weighted bm25 × decay ×
|
|
259
|
+
// project-boost), so the ratio is meaningful there; small-scale sources (prompts,
|
|
260
|
+
// bm25 ≈ -1) can only be penalized by this comparison, never inflated — the safe
|
|
261
|
+
// direction. Bands (ratio = |lone| / globalMaxAbs, first match wins):
|
|
262
|
+
// ratio ≥ 1 → -1.05 the lone hit IS the strongest raw match anywhere: rank it
|
|
263
|
+
// strictly ahead of every normalized -1 (ties in raw
|
|
264
|
+
// strength resolve toward the lone hit — the [-1, 0] band
|
|
265
|
+
// convention is deliberately exceeded here)
|
|
266
|
+
// ratio ≥ 0.5 → -0.75 comparable to the best: above neutral, below the leaders
|
|
267
|
+
// ratio ≥ 0.1 → -0.5 the MED-5 neutral mid
|
|
268
|
+
// ratio < 0.1 → -0.25 grazing match: sink it
|
|
269
|
+
// score === 0 rows (the prompts CJK LIKE fallback — zero confidence) are excluded
|
|
270
|
+
// from normalization entirely and keep their 0, sorting last in the ascending
|
|
271
|
+
// merge (audit L3: the old clamp promoted a lone 0-score row to the neutral mid).
|
|
272
|
+
const SINGLE_MATCH_BANDS = [
|
|
273
|
+
[1.0, -1.05],
|
|
274
|
+
[0.5, -0.75],
|
|
275
|
+
[0.1, -0.5],
|
|
276
|
+
[0, -0.25],
|
|
277
|
+
];
|
|
278
|
+
|
|
248
279
|
/**
|
|
249
280
|
* Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
|
|
250
281
|
* Prevents observations (BM25 can reach -40) from systematically outranking
|
|
251
|
-
* sessions (-6) and prompts (-1) regardless of relevance.
|
|
252
|
-
*
|
|
253
|
-
*
|
|
282
|
+
* sessions (-6) and prompts (-1) regardless of relevance. A source with a single
|
|
283
|
+
* scored row is banded by its magnitude relative to the global raw max (see
|
|
284
|
+
* SINGLE_MATCH_BANDS) — it has no within-source spread to normalize against.
|
|
285
|
+
* Mutates `results` in place; callers re-sort afterwards.
|
|
254
286
|
*/
|
|
255
287
|
export function normalizeCrossSourceScores(results, sourceKey) {
|
|
288
|
+
const scored = results.filter((r) => r.score !== null && r.score !== undefined && r.score !== 0);
|
|
289
|
+
const globalMaxAbs = scored.length ? Math.max(...scored.map((r) => Math.abs(r.score))) : 0;
|
|
256
290
|
for (const src of ['obs', 'session', 'prompt', 'event']) {
|
|
257
|
-
|
|
258
|
-
|
|
291
|
+
// score === 0 stays out: for multi-row sources 0/maxAbs would be 0 anyway, and a
|
|
292
|
+
// lone 0-score row must not be banded upward (it only occurs as the prompts LIKE
|
|
293
|
+
// fallback, which never coexists with real prompt FTS hits).
|
|
294
|
+
const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined && r.score !== 0);
|
|
295
|
+
if (srcResults.length === 0) continue;
|
|
296
|
+
if (srcResults.length === 1) {
|
|
297
|
+
const ratio = globalMaxAbs > 0 ? Math.abs(srcResults[0].score) / globalMaxAbs : 0;
|
|
298
|
+
srcResults[0].score = SINGLE_MATCH_BANDS.find(([floor]) => ratio >= floor)[1];
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
259
301
|
const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
|
|
260
302
|
if (maxAbs > 0) {
|
|
261
303
|
for (const r of srcResults) r.score = r.score / maxAbs;
|