claude-mem-lite 3.46.0 → 3.48.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.
@@ -10,7 +10,7 @@
10
10
  "plugins": [
11
11
  {
12
12
  "name": "claude-mem-lite",
13
- "version": "3.46.0",
13
+ "version": "3.48.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.46.0",
3
+ "version": "3.48.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
@@ -12,7 +12,7 @@ import {
12
12
  import { acquireLLMSlot, releaseLLMSlot } from './hook-semaphore.mjs';
13
13
  import { scrubRecord } from './lib/scrub-record.mjs';
14
14
  import { getVocabulary, computeVector, vecTextForRow } from './tfidf.mjs';
15
- import { insertObservationRow, insertObservationFiles, insertObservationVector } from './lib/observation-write.mjs';
15
+ import { insertObservationRow, insertObservationFiles, insertObservationVector, normalizeScope } from './lib/observation-write.mjs';
16
16
  import { DEDUP_JACCARD_THRESHOLD, AUTO_MERGE_THRESHOLD } from './lib/dedup-constants.mjs';
17
17
  import {
18
18
  RUNTIME_DIR, DEDUP_WINDOW_MS, RELATED_OBS_WINDOW_MS,
@@ -266,6 +266,10 @@ export function saveObservation(obs, projectOverride, sessionIdOverride, externa
266
266
  importance: obs.importance ?? 1, minhash_sig: minhashSig,
267
267
  lesson_learned: safe.lesson_learned, search_aliases: safe.search_aliases,
268
268
  branch: getCurrentBranch(), created_at: now.toISOString(), created_at_epoch: now.getTime(),
269
+ // P3 (D#78): re-validate at the write boundary — saveObservation is also
270
+ // reached by immediate-save / manual callers whose scope never saw the
271
+ // handleLLMEpisode whitelist.
272
+ scope: normalizeScope(obs.scope),
269
273
  });
270
274
 
271
275
  insertObservationFiles(db, id, obs.files);
@@ -298,6 +302,7 @@ function obsToSummary(obs) {
298
302
  importance: obs.importance,
299
303
  lesson_learned: obs.lessonLearned,
300
304
  search_aliases: obs.searchAliases,
305
+ scope: obs.scope,
301
306
  };
302
307
  }
303
308
 
@@ -366,6 +371,7 @@ export function persistHaikuSummary(db, summary, ctx) {
366
371
  importance: summary.importance ?? 1,
367
372
  lessonLearned: summary.lesson_learned || null,
368
373
  searchAliases: summary.search_aliases || null,
374
+ scope: summary.scope ?? null,
369
375
  }, ctx.project, ctx.session_id, db);
370
376
  return { table: 'observations', id };
371
377
  }
@@ -731,6 +737,7 @@ type: pick by strongest signal. decision = explicit tradeoff / "chose X over Y b
731
737
  Facts: each MUST be (1) atomic—one claim, (2) self-contained—no pronouns, include file/function name, (3) specific—"refreshToken() in auth.ts:45 uses 1h TTL" not "handles tokens"
732
738
  importance: Be strict — default to 1. 0=pure browsing with zero learning value. 1=routine file edits, standard changes, normal workflow (MOST episodes). 2=notable ONLY if it reveals something non-obvious: error fix with discovered root cause, architectural decision with explicit tradeoff, config change with unexpected side effects. 3=critical: breaking change affecting users, security vulnerability fix, data migration. Ask yourself: "would a future session benefit from knowing this?" — if not, it's importance=1.
733
739
  lesson_learned: The non-obvious insight a future session would benefit from. Examples: "FTS5 porter stemmer doesn't tokenize CJK — need bigram workaround", "vitest --reporter=verbose hangs on large test suites, use default reporter". Look hard before giving up — most coding episodes contain at least one micro-lesson (an undocumented flag, a surprising default, a debugging shortcut, an unexpected interaction). If literally no insight worth teaching (e.g. version bump, whitespace fix, file rename), output JSON null. Do NOT invent a lesson, do NOT write the strings "none"/"n/a"/"todo"/"tbd"/"-" — those will be discarded as noise.
740
+ scope: where does the lesson APPLY (not where it was learned)? file = specific to the touched file(s)' own code. module = a directory/subsystem of this project. project = a project-wide convention, architecture, or workflow. environment = a tooling/OS/CI/network/registry/service quirk (proxy, npm, git, GitHub, shell, runner, editor) that would hold in ANY project — even though some project files were touched when it surfaced. When lesson_learned is null, still classify the episode's dominant subject.
734
741
  search_aliases: 2-6 alternative search terms someone might use to find this memory later (include CJK if project uses Chinese)`;
735
742
 
736
743
  let prompt;
@@ -738,7 +745,7 @@ search_aliases: 2-6 alternative search terms someone might use to find this memo
738
745
  const e = episode.entries[0];
739
746
  const system = `Extract a structured observation from this code change. Return ONLY valid JSON, no markdown fences.
740
747
 
741
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","search_aliases":["alt query 1","alt query 2"]}
748
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"concise ≤80 char description","narrative":"what changed, why, and outcome (2-3 sentences)","concepts":["kw1","kw2"],"facts":["fact1","fact2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
742
749
  ${SHARED_OBS_SCHEMA_TAIL}`;
743
750
  const user = `Tool: ${e.tool}
744
751
  File: ${episodeFiles.join(', ') || 'unknown'}
@@ -752,7 +759,7 @@ Error: ${e.isError ? 'yes' : 'no'}`;
752
759
 
753
760
  const system = `Summarize this coding episode as ONE coherent observation. Return ONLY valid JSON, no markdown fences.
754
761
 
755
- JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","search_aliases":["alt query 1","alt query 2"]}
762
+ JSON: {"type":"decision|bugfix|feature|refactor|discovery|change","title":"coherent ≤80 char summary","narrative":"what was done, why, and outcome (3-5 sentences)","concepts":["keyword1","keyword2"],"facts":["specific fact 1","specific fact 2"],"importance":1,"lesson_learned":"non-obvious insight a future session needs, or null","scope":"file|module|project|environment","search_aliases":["alt query 1","alt query 2"]}
756
763
  ${SHARED_OBS_SCHEMA_TAIL}`;
757
764
  const user = `Project: ${episode.project}
758
765
  Files: ${fileList}
@@ -919,6 +926,8 @@ ${actionList}`;
919
926
  : Math.max(Math.min(ruleImportance, 2), clampImportance(parsed.importance)),
920
927
  lessonLearned,
921
928
  searchAliases,
929
+ // P3 (D#78): lesson applicability scope — whitelist-validated, invalid → null.
930
+ scope: normalizeScope(parsed.scope),
922
931
  };
923
932
 
924
933
  // v2.56.0 #1: paired-gate DROP. Haiku-titled `change` obs with null lesson
@@ -1000,7 +1009,8 @@ ${actionList}`;
1000
1009
  db.prepare(`
1001
1010
  UPDATE observations SET type=?, title=?, subtitle=?,
1002
1011
  narrative=COALESCE(NULLIF(?, ''), narrative), concepts=?, facts=?,
1003
- text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?
1012
+ text=?, importance=?, files_read=?, minhash_sig=?, lesson_learned=?, search_aliases=?,
1013
+ scope=COALESCE(?, scope)
1004
1014
  WHERE id = ?
1005
1015
  `).run(
1006
1016
  obs.type, safe.title, safe.subtitle,
@@ -1011,6 +1021,7 @@ ${actionList}`;
1011
1021
  minhashSig,
1012
1022
  safe.lesson_learned,
1013
1023
  safe.search_aliases,
1024
+ normalizeScope(obs.scope),
1014
1025
  episode.savedId
1015
1026
  );
1016
1027
  savedId = episode.savedId;
package/hook.mjs CHANGED
@@ -58,8 +58,10 @@ import {
58
58
  recordCitationFunnel,
59
59
  hasMainThreadAssistantText,
60
60
  } from './lib/citation-tracker.mjs';
61
+ import { resolveEdgeAttribution, readPreRecallFileEdges } from './lib/edge-attribution.mjs';
61
62
  import { extractTailAssistantText, extractStructuredSummary } from './lib/summary-extractor.mjs';
62
63
  import { searchRelevantMemories, formatMemoryLine, selectImperativeLesson } from './hook-memory.mjs';
64
+ import { searchInjectableEvents, renderInjectableEvent } from './lib/events-injection.mjs';
63
65
  import { formatTaskImperative } from './lib/task-imperative.mjs';
64
66
  import { recordSkillAdoption, gcOldShadowShards } from './registry-recommend.mjs';
65
67
  import { gcOldMetricShards } from './lib/metrics.mjs';
@@ -86,7 +88,7 @@ import { getVocabulary } from './tfidf.mjs';
86
88
  // Prevent recursive hooks from background claude -p calls
87
89
  // Background workers (llm-episode, llm-summary) are exempt — they're ours
88
90
  const event = process.argv[2];
89
- 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']);
90
92
 
91
93
  // Respect Claude Code plugin disable state even when legacy settings.json hooks remain.
92
94
  // install.mjs writes direct hooks into ~/.claude/settings.json, so disabling the plugin
@@ -419,7 +421,18 @@ function triggerErrorRecall(db, toolInput, response) {
419
421
  `).all(ftsQuery, project, nowR);
420
422
 
421
423
  const out = formatErrorRecallHints(rows);
422
- if (out) process.stdout.write(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
+ }
423
436
  } catch (e) { debugCatch(e, 'triggerErrorRecall'); }
424
437
  }
425
438
 
@@ -650,6 +663,27 @@ async function handleStop() {
650
663
  // obs resolved this run (denominator), promoted = obs cited this run
651
664
  // (numerator). Idempotent (touched is 0 on re-fire) + best-effort.
652
665
  recordCitationFunnel(db, project, sessionId, r.touched, r.promoted);
666
+ // P1 (D#78): per-edge attribution. The session cooldown file
667
+ // (keyed by CC session id) records which FILE each obs was
668
+ // injected for; resolve those (obs,file) edges as hit/miss with
669
+ // the same citedMain set. Lives inside the same text-floor gate
670
+ // so a tool-only Stop can't lock edges as missed. Best-effort.
671
+ // Keying on ccSessionId (NOT the rotating memory sessionId)
672
+ // matches the cooldown file's lifetime — a memory-session
673
+ // rotation mid-CC-session must not re-resolve old injections as
674
+ // fresh misses. mainInjectedIds mirrors the mainOnly discipline
675
+ // above: sidechain-only injections in the cooldown never accrue
676
+ // misses (review D#78).
677
+ try {
678
+ if (ccSessionId) {
679
+ const edges = readPreRecallFileEdges(RUNTIME_DIR, ccSessionId);
680
+ if (edges.length > 0) {
681
+ const er = resolveEdgeAttribution(db, project, edges, citedMain, ccSessionId,
682
+ { mainInjectedIds: injected });
683
+ debugLog('DEBUG', 'handleStop', `edge-attribution: edges=${er.touchedEdges} hits=${er.hits} misses=${er.misses}`);
684
+ }
685
+ }
686
+ } catch (e) { debugCatch(e, 'handleStop-edge-attribution'); }
653
687
  }
654
688
  }
655
689
  } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
@@ -953,6 +987,29 @@ function runSessionStartAutoMaintain(db) {
953
987
  }
954
988
  }
955
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
+
956
1013
  function saveHandoffAndFastSummary(db, { prevSessionId, prevProject, project, ccSessionId, episodeSnapshot, now }) {
957
1014
  // Shared clear handoff reference — queried once, used by fast summary + working state
958
1015
  let prevClearHandoff = null;
@@ -1263,7 +1320,7 @@ async function handleSessionStart() {
1263
1320
 
1264
1321
  runSessionStartDbMutations(db, { sessionId, project, prevSessionId, now });
1265
1322
 
1266
- runSessionStartAutoMaintain(db);
1323
+ scheduleSessionStartAutoMaintain();
1267
1324
 
1268
1325
  // ── Non-transactional operations (side effects, background work) ──
1269
1326
 
@@ -1498,6 +1555,21 @@ async function handleUserPrompt() {
1498
1555
  lines.push('</memory-context>');
1499
1556
  process.stdout.write(lines.join('\n') + '\n');
1500
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'); }
1501
1573
  if (imperativePick) {
1502
1574
  // Guard the write on a non-empty return — formatTaskImperative yields '' for a
1503
1575
  // lesson that strips to empty (e.g. "."), which would otherwise emit a bare line.
@@ -1617,6 +1689,7 @@ try {
1617
1689
  case 'llm-episode': await handleLLMEpisode(); break;
1618
1690
  case 'llm-summary': await handleLLMSummary(); break;
1619
1691
  case 'auto-compress': handleAutoCompress(); break;
1692
+ case 'auto-maintain': handleAutoMaintain(); break;
1620
1693
  case 'llm-optimize': await handleLLMOptimize(); break;
1621
1694
  // Detached update refresh spawned by handleSessionStart (audit P3d) — does the
1622
1695
  // GitHub fetch + (non-plugin) install off the SessionStart critical path,
@@ -707,7 +707,13 @@ export function applyCitationDecay(db, project, injectedIds, citedIds, sessionId
707
707
  }
708
708
  }
709
709
  });
710
- try { txn(); } catch (e) { debugCatch(e, 'applyCitationDecay-txn'); return empty; }
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,158 @@
1
+ // P1 (D#78): per-(obs, file) edge attribution for pre-tool-recall injections.
2
+ //
3
+ // pre-tool-recall triggers purely on file-edge matches (observation_files),
4
+ // but 89% of lessons never mention their attached file — the edges record
5
+ // "touched during the episode", not "about this file". A mis-bound edge (the
6
+ // environment-gotcha-on-unrelated-file class from D#65) therefore re-fires on
7
+ // every future session. This module gives each edge its own hit/miss record so
8
+ // P2 can stop firing edges with a consecutive-miss streak, WITHOUT touching
9
+ // the per-obs decay counters on observations (#8641: separate policies — an
10
+ // edge going quiet must not bury the lesson on other injection surfaces).
11
+ //
12
+ // Wiring: pre-tool-recall.js records { filePath → obsIds } in the session
13
+ // cooldown file; hook.mjs handleStop reads it back (readPreRecallFileEdges),
14
+ // unions the same citedMain set the per-obs decay uses, and calls
15
+ // resolveEdgeAttribution inside the same text-floor-gated block.
16
+
17
+ import { readFileSync, existsSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { debugCatch } from '../utils.mjs';
20
+ import { fileMatchClause, fileMatchParams } from './file-edge-match.mjs';
21
+
22
+ // Mirrors pre-tool-recall.js cooldownPathFor sanitization — the two MUST agree
23
+ // or Stop-side resolution reads a different file than the hook wrote.
24
+ function cooldownFileFor(runtimeDir, ccSessionId) {
25
+ const safe = String(ccSessionId).replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 64);
26
+ return join(runtimeDir, `pre-recall-cooldown-${safe}.json`);
27
+ }
28
+
29
+ /**
30
+ * Read the session cooldown file and return the file→obsIds edge list for
31
+ * attribution. Entries without a non-empty obsIds array (legacy bare-number
32
+ * entries, no-lesson files, event-only injections) are skipped.
33
+ *
34
+ * @param {string} runtimeDir
35
+ * @param {string|null|undefined} ccSessionId Claude Code session id (cooldown file key)
36
+ * @returns {Array<{filePath: string, obsIds: number[]}>}
37
+ */
38
+ export function readPreRecallFileEdges(runtimeDir, ccSessionId) {
39
+ if (!runtimeDir || !ccSessionId) return [];
40
+ const file = cooldownFileFor(runtimeDir, ccSessionId);
41
+ if (!existsSync(file)) return [];
42
+ let data;
43
+ try { data = JSON.parse(readFileSync(file, 'utf8')); } catch { return []; }
44
+ if (!data || typeof data !== 'object') return [];
45
+ const edges = [];
46
+ for (const [filePath, entry] of Object.entries(data)) {
47
+ if (!entry || typeof entry !== 'object' || !Array.isArray(entry.obsIds)) continue;
48
+ const obsIds = entry.obsIds
49
+ .map(Number)
50
+ .filter((n) => Number.isInteger(n) && n > 0 && n < 1e7);
51
+ if (obsIds.length === 0) continue;
52
+ edges.push({ filePath, obsIds });
53
+ }
54
+ return edges;
55
+ }
56
+
57
+ /**
58
+ * Resolve one session's pre-tool file-edge injections as hit (cited) or miss
59
+ * (uncited), updating the per-edge counters on observation_files.
60
+ *
61
+ * Edge matching uses the SAME predicate as pre-tool-recall's trigger query —
62
+ * lib/file-edge-match.mjs is the shared source — so attribution lands exactly
63
+ * on the edges that caused (or would cause) the injection.
64
+ *
65
+ * Semantics mirror applyCitationDecay's split idempotency keys:
66
+ * - miss: guarded on last_resolved_session_id — never double-streaks a session.
67
+ * - hit: guarded on last_cited_session_id — a citation landing in a LATER
68
+ * turn of the same session still resets the streak (undoes that session's
69
+ * miss) without re-counting inject_count.
70
+ * - MEM_DISABLE_CITATION_DECAY=1 disables all writes (same kill switch as the
71
+ * per-obs loop — edge attribution is part of the decay feedback family).
72
+ *
73
+ * @param {import('better-sqlite3').Database} db
74
+ * @param {string} project
75
+ * @param {Array<{filePath: string, obsIds: number[]}>} fileEdges
76
+ * @param {Set<number>|Iterable<number>} citedIds
77
+ * @param {string} sessionId — the CLAUDE CODE session id (the cooldown file's
78
+ * own key), NOT the memory session id: memory sessions rotate on /clear /
79
+ * resume / 12h expiry while the cooldown file lives for the whole CC
80
+ * session, so a rotating key would re-resolve the same injection as a fresh
81
+ * miss after every rotation (review D#78 — 2 rotations ≈ a full K=3 streak
82
+ * from ONE real injection).
83
+ * @param {{mainInjectedIds?: Set<number>}} [opts] — when mainInjectedIds is
84
+ * given, only obsIds present in it (or in citedIds) are resolved. The
85
+ * cooldown file has no sidechain marker, so subagent-triggered injections
86
+ * land in it too; gating on the main-thread injected set mirrors the
87
+ * per-obs loop's mainOnly discipline (hook.mjs:625) — an obs injected only
88
+ * inside a subagent must not accrue misses it can never repay.
89
+ * @returns {{hits: number, misses: number, touchedEdges: number}}
90
+ */
91
+ export function resolveEdgeAttribution(db, project, fileEdges, citedIds, sessionId, opts = {}) {
92
+ const empty = { hits: 0, misses: 0, touchedEdges: 0 };
93
+ if (process.env.MEM_DISABLE_CITATION_DECAY === '1') return empty;
94
+ if (!db || !project || !sessionId) return empty;
95
+ if (!Array.isArray(fileEdges) || fileEdges.length === 0) return empty;
96
+ const cited = citedIds instanceof Set ? citedIds : new Set(citedIds || []);
97
+ const mainInjected = opts.mainInjectedIds instanceof Set ? opts.mainInjectedIds : null;
98
+
99
+ const selectEdges = db.prepare(`
100
+ SELECT of2.rowid AS edge_rowid,
101
+ of2.miss_streak,
102
+ of2.last_resolved_session_id,
103
+ of2.last_cited_session_id
104
+ FROM observation_files of2
105
+ JOIN observations o ON o.id = of2.obs_id
106
+ WHERE of2.obs_id = ?
107
+ AND o.project = ?
108
+ AND ${fileMatchClause('of2')}
109
+ `);
110
+ const updateHit = db.prepare(`
111
+ UPDATE observation_files
112
+ SET miss_streak = 0,
113
+ inject_count = inject_count + ?,
114
+ last_cited_session_id = ?,
115
+ last_resolved_session_id = ?
116
+ WHERE rowid = ?
117
+ `);
118
+ const updateMiss = db.prepare(`
119
+ UPDATE observation_files
120
+ SET miss_streak = miss_streak + 1,
121
+ inject_count = inject_count + 1,
122
+ last_resolved_session_id = ?
123
+ WHERE rowid = ?
124
+ `);
125
+
126
+ let hits = 0, misses = 0, touchedEdges = 0;
127
+ const txn = db.transaction(() => {
128
+ for (const { filePath, obsIds } of fileEdges) {
129
+ if (!filePath || !Array.isArray(obsIds)) continue;
130
+ const fileParams = fileMatchParams(filePath);
131
+ for (const obsId of obsIds) {
132
+ // Sidechain gate: skip obs the main thread never saw injected or cited
133
+ // (see opts.mainInjectedIds in the JSDoc above).
134
+ if (mainInjected && !mainInjected.has(obsId) && !cited.has(obsId)) continue;
135
+ let rows;
136
+ try {
137
+ rows = selectEdges.all(obsId, project, ...fileParams);
138
+ } catch (e) { debugCatch(e, `resolveEdgeAttribution-select-${obsId}`); continue; }
139
+ for (const row of rows) {
140
+ if (cited.has(obsId)) {
141
+ if (row.last_cited_session_id === sessionId) continue; // already credited
142
+ // First resolution this session counts the injection; a cross-turn
143
+ // late cite (already resolved as miss) only flips the verdict.
144
+ const first = row.last_resolved_session_id !== sessionId;
145
+ updateHit.run(first ? 1 : 0, sessionId, sessionId, row.edge_rowid);
146
+ hits++; touchedEdges++;
147
+ } else {
148
+ if (row.last_resolved_session_id === sessionId) continue; // idempotent
149
+ updateMiss.run(sessionId, row.edge_rowid);
150
+ misses++; touchedEdges++;
151
+ }
152
+ }
153
+ }
154
+ }
155
+ });
156
+ try { txn(); } catch (e) { debugCatch(e, 'resolveEdgeAttribution-txn'); return empty; }
157
+ return { hits, misses, touchedEdges };
158
+ }
@@ -0,0 +1,91 @@
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 three PASSIVE injection surfaces (SessionStart context, UserPromptSubmit
8
+ // path A = user-prompt-search.js, path B = hook.mjs memory-context) still read only
9
+ // observations — so the promoted memories were unreachable at prompt/session time.
10
+ //
11
+ // id-space discipline: events share the numeric id space with observations but are a
12
+ // separate table, so injected events are rendered with an `E#` prefix. The
13
+ // citation-tracker injected-id extractors all anchor on a BARE `#`
14
+ // (FYI `/^#\d/`, memory-context `/\(#\d/`, error-recall row-anchored), so an `E#`
15
+ // row is never mis-attributed to an observation id (which would let citation decay
16
+ // mutate an unrelated observation sharing that id). Events carry no citation columns,
17
+ // so they inject as reference-only — reachability, not decay bookkeeping.
18
+
19
+ import { searchEventsFts } from './search-core.mjs';
20
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
21
+ import { sanitizeFtsQuery } from '../utils.mjs';
22
+
23
+ const DEFAULT_LIMIT = 3;
24
+ const DEFAULT_MIN_IMPORTANCE = 2;
25
+ const TITLE_MAX = 80;
26
+ const LESSON_MAX = 160;
27
+
28
+ function normalizeRow(r) {
29
+ return {
30
+ id: r.id,
31
+ type: r.event_type || r.type || 'event',
32
+ title: r.title || '',
33
+ lesson_learned: (r.body ?? r.lesson_learned) || null,
34
+ importance: r.importance,
35
+ created_at_epoch: r.created_at_epoch,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * FTS-matched events for a prompt (UserPromptSubmit surfaces). Superseded events are
41
+ * excluded by searchEventsFts; importance floor drops low-value rows. Never throws.
42
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
43
+ */
44
+ export function searchInjectableEvents(db, { prompt, ftsQuery, project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
45
+ if (!db || !project) return [];
46
+ const q = ftsQuery || (prompt ? sanitizeFtsQuery(prompt) : null);
47
+ if (!q) return [];
48
+ try {
49
+ const rows = searchEventsFts(db, {
50
+ ftsQuery: q, project, projectBoost: project,
51
+ importance: minImportance, perSourceLimit: limit,
52
+ });
53
+ return rows.map(normalizeRow);
54
+ } catch { return []; }
55
+ }
56
+
57
+ /**
58
+ * Recency + importance events (SessionStart context — no FTS query available).
59
+ * Excludes superseded events. Never throws.
60
+ * @returns {Array<{id,type,title,lesson_learned,importance,created_at_epoch}>}
61
+ */
62
+ export function recentInjectableEvents(db, { project, limit = DEFAULT_LIMIT, minImportance = DEFAULT_MIN_IMPORTANCE } = {}) {
63
+ if (!db || !project) return [];
64
+ try {
65
+ const rows = db.prepare(`
66
+ SELECT id, event_type, title, body, importance, created_at_epoch
67
+ FROM events
68
+ WHERE project = ?
69
+ AND superseded_at_epoch IS NULL
70
+ AND COALESCE(importance, 1) >= ?
71
+ ORDER BY created_at_epoch DESC
72
+ LIMIT ?
73
+ `).all(project, minImportance, limit);
74
+ return rows.map(normalizeRow);
75
+ } catch { return []; }
76
+ }
77
+
78
+ /**
79
+ * Render one injectable event as a defanged `E#<id> [<type>] <title> — <lesson>` line.
80
+ * The E# prefix keeps citation-tracker extractors (which anchor on a bare `#`) from
81
+ * mis-reading the event id as an observation id.
82
+ */
83
+ export function renderInjectableEvent(row) {
84
+ const title = neutralizeContextDelimiters((row.title || '').slice(0, TITLE_MAX));
85
+ const head = `E#${row.id} [${row.type}] ${title}`;
86
+ if (row.lesson_learned) {
87
+ const lesson = neutralizeContextDelimiters(row.lesson_learned.trim().slice(0, LESSON_MAX));
88
+ if (lesson) return `${head} — ${lesson}`;
89
+ }
90
+ return head;
91
+ }
@@ -18,6 +18,7 @@
18
18
  export const EXPORT_COLUMNS = [
19
19
  'id', 'memory_session_id', 'project', 'type', 'title', 'subtitle', 'narrative', 'text',
20
20
  'concepts', 'facts', 'files_read', 'files_modified', 'lesson_learned', 'search_aliases',
21
+ 'scope',
21
22
  'importance', 'branch', 'access_count', 'cited_count', 'uncited_streak', 'injection_count',
22
23
  'decay_seen_count', 'last_accessed_at', 'created_at', 'created_at_epoch',
23
24
  ];
@@ -0,0 +1,44 @@
1
+ // Single source of truth for the (obs,file) trigger-edge match predicate.
2
+ //
3
+ // Two consumers MUST stay in byte-identical agreement or injection and
4
+ // attribution diverge (a lesson injected via an edge the resolver can't find
5
+ // never resolves, and vice versa): scripts/pre-tool-recall.js (injection
6
+ // trigger) and lib/edge-attribution.mjs (Stop-side hit/miss resolution).
7
+ // Review 2026-07-14 found the pair enforced only by comments — this module
8
+ // makes the parity mechanical.
9
+ //
10
+ // Semantics (P0 D#78, plus the review's case/backslash recall fix):
11
+ // observation_files.filename is heterogeneous (bare basename, relative path,
12
+ // absolute path, either separator, historical case variants). An edited file
13
+ // matches an edge when the stored value is:
14
+ // 1. the exact full path (= COLLATE NOCASE — old LIKE was
15
+ // 2. the exact bare basename ASCII-case-insensitive; '=' alone is
16
+ // BINARY and silently dropped 'Utils.mjs')
17
+ // 3. a path ending in '/<basename>' (LIKE, path boundary — blocks the
18
+ // 4. a path ending in '\<basename>' bash-utils.mjs-vs-utils.mjs suffix
19
+ // collision while keeping both separators)
20
+ // LIKE wildcards in the basename are escaped (sqlite gotcha #9); LIKE itself
21
+ // is ASCII-case-insensitive, matching arm 1/2's NOCASE.
22
+ //
23
+ // Dependency-free on purpose: pre-tool-recall.js is a ~30ms cold-start script.
24
+
25
+ import { basename } from 'path';
26
+
27
+ /**
28
+ * SQL boolean expression for the four-arm match. Placeholder order matches
29
+ * fileMatchParams. @param {string} [alias=''] table alias (e.g. 'of2').
30
+ */
31
+ export function fileMatchClause(alias = '') {
32
+ const p = alias ? `${alias}.` : '';
33
+ return `(${p}filename = ? COLLATE NOCASE OR ${p}filename = ? COLLATE NOCASE ` +
34
+ `OR ${p}filename LIKE ? ESCAPE '\\' OR ${p}filename LIKE ? ESCAPE '\\')`;
35
+ }
36
+
37
+ /** Bind values for fileMatchClause, in placeholder order. */
38
+ export function fileMatchParams(filePath) {
39
+ const fname = basename(filePath);
40
+ const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
41
+ // `%\\` before the basename: under ESCAPE '\', a literal backslash is
42
+ // written '\\' — so the JS string carries two backslash characters.
43
+ return [filePath, fname, `%/${escaped}`, `%\\\\${escaped}`];
44
+ }
@@ -16,8 +16,18 @@ const OBS_COLUMNS = [
16
16
  'memory_session_id', 'project', 'text', 'type', 'title', 'subtitle',
17
17
  'narrative', 'concepts', 'facts', 'files_read', 'files_modified',
18
18
  'importance', 'minhash_sig', 'lesson_learned', 'search_aliases', 'branch',
19
- 'created_at', 'created_at_epoch',
19
+ 'created_at', 'created_at_epoch', 'scope',
20
20
  ];
21
+
22
+ // P3 (D#78): lesson applicability scope. Hard whitelist — Haiku output is
23
+ // untrusted; anything outside the enum (including case variants) becomes NULL,
24
+ // which every scope-aware read path treats as "unclassified, do not filter".
25
+ const VALID_SCOPES = new Set(['file', 'module', 'project', 'environment']);
26
+
27
+ /** Validate an LLM-emitted scope value against the enum; invalid → null. */
28
+ export function normalizeScope(value) {
29
+ return typeof value === 'string' && VALID_SCOPES.has(value) ? value : null;
30
+ }
21
31
  // Defaults for columns a caller omits. NULL-default columns (subtitle,
22
32
  // search_aliases) match the schema DEFAULT, so omitting == the old short INSERT.
23
33
  // concepts/facts/files_read default to the empty literals the manual path used.
@@ -245,17 +245,33 @@ 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. Clamp it to
249
+ // the neutral middle of the [-1, 0] band (MED-5): leaving its raw BM25 magnitude
250
+ // (a title hit can reach -8..-40) let one incidental single-source match — and
251
+ // `event` is now a common low-cardinality source — outrank an entire page of
252
+ // strongly-matched, normalized rows from another source. -0.5 is magnitude-
253
+ // independent, so it fixes both the dominating strong lone match and does not
254
+ // inflate a weak one to -1.0 (the reason single-row sources were previously
255
+ // skipped). Global-max normalization would NOT fix a lone match that IS the
256
+ // global max, so a fixed neutral value is used instead.
257
+ const NEUTRAL_SINGLE_MATCH_SCORE = -0.5;
258
+
248
259
  /**
249
260
  * Normalize each source's BM25 scores to [-1, 0] before cross-source merge.
250
261
  * Prevents observations (BM25 can reach -40) from systematically outranking
251
- * sessions (-6) and prompts (-1) regardless of relevance. Sources with a
252
- * single scored row are skipped normalizing would inflate a weak match to
253
- * -1.0. Mutates `results` in place; callers re-sort afterwards.
262
+ * sessions (-6) and prompts (-1) regardless of relevance. A source with a single
263
+ * scored row is clamped to the neutral mid (see NEUTRAL_SINGLE_MATCH_SCORE)
264
+ * it has no within-source spread to normalize against. Mutates `results` in
265
+ * place; callers re-sort afterwards.
254
266
  */
255
267
  export function normalizeCrossSourceScores(results, sourceKey) {
256
268
  for (const src of ['obs', 'session', 'prompt', 'event']) {
257
269
  const srcResults = results.filter((r) => r[sourceKey] === src && r.score !== null && r.score !== undefined);
258
- if (srcResults.length < 2) continue;
270
+ if (srcResults.length === 0) continue;
271
+ if (srcResults.length === 1) {
272
+ srcResults[0].score = NEUTRAL_SINGLE_MATCH_SCORE;
273
+ continue;
274
+ }
259
275
  const maxAbs = Math.max(...srcResults.map((r) => Math.abs(r.score)));
260
276
  if (maxAbs > 0) {
261
277
  for (const r of srcResults) r.score = r.score / maxAbs;
package/mem-cli.mjs CHANGED
@@ -20,10 +20,11 @@ import {
20
20
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
21
21
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
22
22
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
23
- recoverChildrenOf, hardDeleteCandidateCount,
23
+ hardDeleteCandidateCount,
24
24
  OP_CAP, STALE_AGE_MS, PINNED_INJ_THRESHOLD,
25
25
  } from './lib/maintain-core.mjs';
26
26
  import { snapshotDb } from './lib/db-backup.mjs';
27
+ import { deleteObservations } from './lib/delete-core.mjs';
27
28
  import { optimizePreview, optimizeRun } from './hook-optimize.mjs';
28
29
  import { buildSessionContextLines } from './hook-context.mjs';
29
30
  import { cmdAdopt, cmdUnadopt } from './adopt-cli.mjs';
@@ -39,7 +40,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
39
40
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
40
41
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
41
42
  import { saveObservation } from './lib/save-observation.mjs';
42
- import { rebuildObservationDerived } from './lib/observation-write.mjs';
43
+ import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
43
44
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
44
45
  import { computeNoiseGauge } from './lib/stats-quality.mjs';
45
46
  import { recallByFile } from './lib/recall-core.mjs';
@@ -1508,43 +1509,13 @@ function cmdDelete(db, args) {
1508
1509
  return;
1509
1510
  }
1510
1511
 
1511
- // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
1512
- // matching the maintain purge/cleanup hard-delete paths (audit MED-2). Best-effort
1513
- // (never throws, skips :memory:); rows here are confirmed + non-empty, so the delete
1514
- // always removes something worth backing up. Must run OUTSIDE the transaction (VACUUM).
1515
- snapshotDb(db, { tag: 'pre-delete' });
1516
-
1517
- // Transaction: clean up related_ids references + delete (aligned with MCP mem_delete)
1518
- const deletedIds = new Set(ids);
1519
- const deleteTx = db.transaction(() => {
1520
- const likeConditions = ids.map(() => `related_ids LIKE ?`).join(' OR ');
1521
- const likeParams = ids.map(id => `%${id}%`);
1522
- const referencing = db.prepare(`
1523
- SELECT id, related_ids FROM observations
1524
- WHERE related_ids IS NOT NULL AND related_ids != '[]' AND (${likeConditions})
1525
- `).all(...likeParams);
1526
- for (const r of referencing) {
1527
- let refIds;
1528
- try { refIds = JSON.parse(r.related_ids); } catch { continue; }
1529
- if (!Array.isArray(refIds)) continue;
1530
- const filtered = refIds.filter(id => !deletedIds.has(id));
1531
- if (filtered.length !== refIds.length) {
1532
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
1533
- }
1534
- }
1535
- // Resurface any rows merged/compressed INTO the doomed keepers before deleting,
1536
- // else they dangle behind a missing parent (compressed_into has no FK) — invisible
1537
- // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Same guard the
1538
- // maintain hard-delete paths use (recoverChildrenOf); the interactive delete path
1539
- // was missing it. Returned in the result so the user sees the recovery count.
1540
- const recovered = recoverChildrenOf(db, ids);
1541
- const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...ids);
1542
- return { changes: deleted.changes, recovered };
1543
- });
1544
- const result = deleteTx();
1512
+ // Full delete orchestration (snapshot + related_ids cleanup + child recovery + delete
1513
+ // transaction) lives in lib/delete-core.mjs single source of truth shared with the MCP
1514
+ // mem_delete path (was inlined here + kept in sync by parity comments, the #1 drift risk).
1515
+ const result = deleteObservations(db, ids);
1545
1516
  const missing = ids.filter(id => !rows.some(r => r.id === id));
1546
- const recoveredNote = result.recovered > 0 ? ` Recovered ${result.recovered} merged/compressed child observation(s) to live.` : '';
1547
- out(`[mem] Deleted ${result.changes} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
1517
+ const recoveredNote = result.recoveredChildren > 0 ? ` Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.` : '';
1518
+ out(`[mem] Deleted ${result.deleted} observation(s).${recoveredNote}${missing.length > 0 ? ` Note: ID(s) ${missing.join(', ')} not found.` : ''}`);
1548
1519
  }
1549
1520
 
1550
1521
  // ─── Update ──────────────────────────────────────────────────────────────────
@@ -1808,6 +1779,7 @@ function cmdRestore(db, argv) {
1808
1779
  const signalUpdate = db.prepare(`UPDATE observations SET
1809
1780
  text = COALESCE(?, text),
1810
1781
  subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1782
+ scope = ?,
1811
1783
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1812
1784
  decay_seen_count = ?, last_accessed_at = ?
1813
1785
  WHERE id = ?`);
@@ -1854,6 +1826,9 @@ function cmdRestore(db, argv) {
1854
1826
  scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1855
1827
  r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1856
1828
  r.files_read || '[]', r.branch ?? null,
1829
+ // v44 scope round-trip (review D#78): whitelist-validated; old backups
1830
+ // without the column restore as NULL — same as pre-v44 behavior.
1831
+ normalizeScope(r.scope),
1857
1832
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1858
1833
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1859
1834
  res.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mem-lite",
3
- "version": "3.46.0",
3
+ "version": "3.48.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",
@@ -61,6 +61,8 @@
61
61
  "lib/low-signal-patterns.mjs",
62
62
  "lib/private-strip.mjs",
63
63
  "lib/citation-tracker.mjs",
64
+ "lib/edge-attribution.mjs",
65
+ "lib/file-edge-match.mjs",
64
66
  "lib/cite-back-hint.mjs",
65
67
  "lib/tmp-fixture-sweep.mjs",
66
68
  "lib/summary-extractor.mjs",
@@ -88,6 +90,8 @@
88
90
  "lib/rrf.mjs",
89
91
  "lib/compress-core.mjs",
90
92
  "lib/db-backup.mjs",
93
+ "lib/delete-core.mjs",
94
+ "lib/events-injection.mjs",
91
95
  "lib/maintain-core.mjs",
92
96
  "lib/dedup-constants.mjs",
93
97
  "lib/deferred-work.mjs",
package/schema.mjs CHANGED
@@ -112,20 +112,44 @@ export const CODE_DIR = join(homedir(), '.claude-mem-lite');
112
112
  // silently drop event writes. NO new column, so LATEST_MIGRATION_COLUMN is unchanged — the
113
113
  // forced pass alone carries it (same pattern as v35/v36/v38/v39/v40); existing DBs run it
114
114
  // because version 41 != 42 falls through the fast-path.
115
- export const CURRENT_SCHEMA_VERSION = 42;
116
-
117
- // Sentinel column for the LATEST migration set. The fast-path uses this to
118
- // self-heal half-migrated DBs schema_version bumped but column ALTERs rolled
119
- // back (observed once in dev during v2.74.0). Update both the column AND
120
- // (if needed) the table when adding a new migration batch.
121
- const LATEST_MIGRATION_COLUMN = { table: 'observations', column: 'last_cited_session_id' };
115
+ // v43 (D#78 edge attribution): adds 4 columns to observation_files so each
116
+ // (obs, file) trigger edge carries its own injection/citation record —
117
+ // inject_count, miss_streak, last_resolved_session_id, last_cited_session_id.
118
+ // Per-EDGE policy, deliberately separate from the per-obs decay counters on
119
+ // observations (#8641: the two encode different policies an edge that stops
120
+ // firing must not bury the lesson on other surfaces). The ALTERs live NEXT TO
121
+ // the observation_files CREATE (initSchema body), NOT in MIGRATIONS[] — that
122
+ // array runs before the table exists on fresh DBs.
123
+ // v44 (D#78 P3 scope label): observations.scope (file|module|project|
124
+ // environment, NULL for legacy/manual rows) — where a lesson APPLIES,
125
+ // decoupled from which files the episode touched. SEPARATE version from v43
126
+ // on purpose: dev-mode hooks migrate the live DB between working-tree edits,
127
+ // and a two-table batch under ONE version left a real DB at "43 with edge
128
+ // columns, without scope" that the single sentinel could not detect (observed
129
+ // 2026-07-14 on this machine's own DB). One version per migration batch keeps
130
+ // the version number itself the detector. LATEST_MIGRATION_COLUMN advances to
131
+ // observations.scope.
132
+ export const CURRENT_SCHEMA_VERSION = 44;
133
+
134
+ // Sentinel columns for the LATEST migration set(s). The fast-path uses these
135
+ // to self-heal half-migrated DBs — schema_version bumped but column ALTERs
136
+ // rolled back (observed once in dev during v2.74.0). Update the list when
137
+ // adding a new migration batch. Plural since v44 (review D#78): v43 and v44
138
+ // touch DIFFERENT tables, and a restore-from-old-backup can resurrect one
139
+ // table's pre-migration shape while the version row and the other table stay
140
+ // current — a single sentinel can't see that hole, so every recent batch
141
+ // keeps a representative column here until it is ancient enough to retire.
142
+ const LATEST_MIGRATION_COLUMNS = [
143
+ { table: 'observations', column: 'scope' }, // v44
144
+ { table: 'observation_files', column: 'last_cited_session_id' }, // v43
145
+ ];
122
146
 
123
147
  function hasLatestMigrationColumn(db) {
124
148
  try {
125
- const row = db.prepare(
149
+ const stmt = db.prepare(
126
150
  `SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
127
- ).get(LATEST_MIGRATION_COLUMN.table, LATEST_MIGRATION_COLUMN.column);
128
- return Boolean(row);
151
+ );
152
+ return LATEST_MIGRATION_COLUMNS.every(({ table, column }) => Boolean(stmt.get(table, column)));
129
153
  } catch {
130
154
  return false; // table itself missing → caller falls through to CORE_SCHEMA
131
155
  }
@@ -286,6 +310,12 @@ const MIGRATIONS = [
286
310
  // legacy rows read NULL and behave exactly as before until their first same-session
287
311
  // late cite.
288
312
  'ALTER TABLE observations ADD COLUMN last_cited_session_id TEXT DEFAULT NULL',
313
+ // v44 (D#78 P3): lesson applicability scope — file | module | project |
314
+ // environment, validated by lib/observation-write normalizeScope; NULL for
315
+ // legacy rows / manual saves / events. CLAUDE_MEM_SCOPE_FILTER=1 (opt-in)
316
+ // makes pre-tool-recall skip environment-scoped rows on file-triggered
317
+ // injection; NULL always passes the filter.
318
+ 'ALTER TABLE observations ADD COLUMN scope TEXT DEFAULT NULL',
289
319
  ];
290
320
 
291
321
  /**
@@ -574,10 +604,28 @@ export function initSchema(db) {
574
604
  CREATE TABLE IF NOT EXISTS observation_files (
575
605
  obs_id INTEGER NOT NULL REFERENCES observations(id) ON DELETE CASCADE,
576
606
  filename TEXT NOT NULL,
607
+ inject_count INTEGER NOT NULL DEFAULT 0,
608
+ miss_streak INTEGER NOT NULL DEFAULT 0,
609
+ last_resolved_session_id TEXT DEFAULT NULL,
610
+ last_cited_session_id TEXT DEFAULT NULL,
577
611
  UNIQUE(obs_id, filename)
578
612
  )
579
613
  `);
580
614
  db.exec(`CREATE INDEX IF NOT EXISTS idx_obsfiles_filename ON observation_files(filename)`);
615
+ // v43 (D#78): per-edge attribution columns for DBs whose observation_files
616
+ // predates them — CREATE IF NOT EXISTS above is a no-op on those (gotcha #1),
617
+ // so each column gets its own idempotent ALTER (swallow duplicate-column only,
618
+ // same discipline as MIGRATIONS[]). Fresh DBs hit the duplicate branch.
619
+ for (const sql of [
620
+ 'ALTER TABLE observation_files ADD COLUMN inject_count INTEGER NOT NULL DEFAULT 0',
621
+ 'ALTER TABLE observation_files ADD COLUMN miss_streak INTEGER NOT NULL DEFAULT 0',
622
+ 'ALTER TABLE observation_files ADD COLUMN last_resolved_session_id TEXT DEFAULT NULL',
623
+ 'ALTER TABLE observation_files ADD COLUMN last_cited_session_id TEXT DEFAULT NULL',
624
+ ]) {
625
+ try { db.exec(sql); } catch (e) {
626
+ if (!e.message?.includes('duplicate column name')) throw e;
627
+ }
628
+ }
581
629
 
582
630
  // Data migration: populate observation_files from existing observations.files_modified JSON
583
631
  // Only runs once: when observation_files is empty but observations has rows with files_modified
@@ -10,10 +10,12 @@ import { resolveDataDir } from '../lib/resolve-data-dir.mjs';
10
10
  import { buildNotLowSignalSql } from '../lib/low-signal-patterns.mjs';
11
11
  import { recordHookError } from '../lib/hook-telemetry.mjs';
12
12
  import { citeFactorClause } from '../scoring-sql.mjs';
13
+ import { fileMatchClause, fileMatchParams } from '../lib/file-edge-match.mjs';
13
14
  import { fileIntelFor } from '../lib/file-intel.mjs';
14
15
  import { shouldWarnReread, buildRereadWarning, readFileMeta } from '../lib/reread-guard.mjs';
15
16
  import { recordMetric } from '../lib/metrics.mjs';
16
17
  import { presentIdents } from '../lib/lesson-idents.mjs';
18
+ import { neutralizeContextDelimiters } from '../format-utils.mjs';
17
19
 
18
20
  // CLAUDE_MEM_DIR matches schema.mjs / main CLI — one env var sandboxes the
19
21
  // whole system. CLAUDE_MEM_DB_PATH / CLAUDE_MEM_RUNTIME_DIR remain as
@@ -58,6 +60,23 @@ const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy fi
58
60
  // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
59
61
  const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
60
62
  String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
63
+ // P2 (D#78): edge-level decay ENFORCEMENT — opt-in (default OFF, shadow-first).
64
+ // When on, a (obs,file) edge whose miss_streak reached K consecutive uncited
65
+ // injections stops firing on this surface; the lesson stays reachable via
66
+ // search / UPS / error-recall. P1 counting (Stop-side attribution) is always
67
+ // on regardless of this flag. Flip only after real-DB cite-rate evidence.
68
+ const EDGE_DECAY_ON = ['1', 'on', 'true', 'yes'].includes(
69
+ String(process.env.CLAUDE_MEM_EDGE_DECAY || '').toLowerCase());
70
+ // NaN-checked, not `|| 3`: an explicit K=0 is falsy and would silently become
71
+ // the default instead of clamping to the declared minimum of 1 (review D#78).
72
+ const EDGE_DECAY_K_RAW = parseInt(process.env.CLAUDE_MEM_EDGE_DECAY_K, 10);
73
+ const EDGE_DECAY_K = Math.max(1, Number.isNaN(EDGE_DECAY_K_RAW) ? 3 : EDGE_DECAY_K_RAW);
74
+ // P3 (D#78): scope filter — opt-in (default OFF, shadow-first). When on,
75
+ // environment-scoped observations (tooling/CI/network gotchas that apply in
76
+ // ANY project) stop firing on FILE-triggered recall; they stay reachable via
77
+ // search / UPS / error-recall. NULL scope (legacy / manual rows) always passes.
78
+ const SCOPE_FILTER_ON = ['1', 'on', 'true', 'yes'].includes(
79
+ String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase());
61
80
  const FILE_INTEL_MIN_TOKENS = Math.max(1,
62
81
  parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
63
82
  // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
@@ -323,9 +342,14 @@ try {
323
342
  try {
324
343
  const project = inferProject();
325
344
  const fname = basename(filePath);
326
- // Escape LIKE wildcards
345
+ // Escape LIKE wildcards (still needed below for the events file_paths arms)
327
346
  const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
328
- const likePattern = `%${escaped}`;
347
+ // P0 (D#78): path-boundary match — editing utils.mjs must NOT pull lessons
348
+ // stored under bash-utils.mjs (the old '%<basename>' suffix LIKE did).
349
+ // Clause + params come from lib/file-edge-match.mjs, byte-shared with the
350
+ // Stop-side edge attribution so trigger and resolver can never drift.
351
+ const fileMatch = fileMatchClause('of2');
352
+ const fileParams = fileMatchParams(filePath);
329
353
  // 60-day lookback to avoid surfacing ancient observations
330
354
  const cutoff = Date.now() - 60 * 86400000;
331
355
 
@@ -358,6 +382,30 @@ try {
358
382
  // merely-most-recent one. Single-match files unchanged (obsLimit=1 Read /
359
383
  // 2 Edit). Composes with v2.83.0 A1 to extend the citation-decay feedback
360
384
  // loop to the 85%-recall PreToolUse:Read/Edit path.
385
+ // P2 (D#78): decayed-edge filter. This readonly fast-path never migrates,
386
+ // so a pre-v43 DB has no miss_streak column and the filter would throw at
387
+ // prepare time — probe pragma_table_info and fall back to unfiltered
388
+ // (pre-v43 edges carry no counts anyway, so nothing would be filtered).
389
+ let edgeDecayFilter = '';
390
+ if (EDGE_DECAY_ON) {
391
+ try {
392
+ const hasCol = db.prepare(
393
+ `SELECT 1 FROM pragma_table_info('observation_files') WHERE name = 'miss_streak'`
394
+ ).get();
395
+ if (hasCol) edgeDecayFilter = `AND of2.miss_streak < ${EDGE_DECAY_K}`;
396
+ } catch { /* probe failure → unfiltered */ }
397
+ }
398
+ // P3 (D#78): environment-scope filter — same probe discipline (readonly
399
+ // fast-path may hit a pre-v43 DB where observations.scope doesn't exist).
400
+ let scopeFilter = '';
401
+ if (SCOPE_FILTER_ON) {
402
+ try {
403
+ const hasScope = db.prepare(
404
+ `SELECT 1 FROM pragma_table_info('observations') WHERE name = 'scope'`
405
+ ).get();
406
+ if (hasScope) scopeFilter = `AND (o.scope IS NULL OR o.scope != 'environment')`;
407
+ } catch { /* probe failure → unfiltered */ }
408
+ }
361
409
  const rows = db.prepare(`
362
410
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
363
411
  FROM observations o
@@ -367,14 +415,16 @@ try {
367
415
  AND COALESCE(o.compressed_into, 0) = 0
368
416
  AND o.superseded_at IS NULL
369
417
  AND o.created_at_epoch > ?
370
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
418
+ AND ${fileMatch}
419
+ ${edgeDecayFilter}
420
+ ${scopeFilter}
371
421
  ${typeFallback}
372
422
  ORDER BY
373
423
  CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
374
424
  ${citeFactorClause('o')} DESC,
375
425
  o.created_at_epoch DESC
376
426
  LIMIT ${obsLimit}
377
- `).all(project, cutoff, filePath, likePattern);
427
+ `).all(project, cutoff, ...fileParams);
378
428
 
379
429
  // T9: also query the `events` table — after T9, bugfix/lesson/decision/etc.
380
430
  // route here instead of observations, so we must read both sources to keep
@@ -382,12 +432,21 @@ try {
382
432
  // patterns match both basename and full-path entries. JSON quoting
383
433
  // (`"<name>"`) prevents partial-match false positives like "foo.mjs"
384
434
  // matching "myfoo.mjs".
385
- const fnameEscaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
386
435
  const filePathEscaped = filePath.replace(/%/g, '\\%').replace(/_/g, '\\_');
387
436
  // v2.34.6: Read also tightens the events query — only rows with a non-empty
388
- // body (= lesson equivalent). Edit path keeps the wider net since the agent
389
- // is about to change the file and benefits from any contextual signal.
390
- const eventsBodyFilter = isRead ? "AND body IS NOT NULL AND body != ''" : '';
437
+ // body (= lesson equivalent). Edit path keeps a wider net, but P0 (D#78)
438
+ // closes the parallel-path drift vs the observations query: a bodyless row
439
+ // is admitted only when it is a lesson-bearing type (bugfix/decision the
440
+ // obs fallback's set — plus events-only 'lesson') AND its title isn't a
441
+ // LOW_SIGNAL auto-fallback; body-bearing rows outrank bodyless ones
442
+ // (mirrors the obs lesson-first sort). Known tradeoff: a deliberately
443
+ // bodyless manual event whose title starts with a LOW_SIGNAL prefix
444
+ // ('npm …', 'Error: …') no longer fires here — it stays reachable via
445
+ // search / UPS; /bug and /lesson write bodies, so this is a rare shape.
446
+ const eventsBodyFilter = isRead
447
+ ? "AND body IS NOT NULL AND body != ''"
448
+ : `AND ((body IS NOT NULL AND body != '')
449
+ OR (event_type IN ('bugfix', 'decision', 'lesson') AND ${buildNotLowSignalSql('')}))`;
391
450
  const eventsLimit = isRead ? 1 : 2;
392
451
  let eventRows = [];
393
452
  try {
@@ -400,9 +459,11 @@ try {
400
459
  AND created_at_epoch > ?
401
460
  AND (file_paths LIKE ? ESCAPE '\\' OR file_paths LIKE ? ESCAPE '\\')
402
461
  ${eventsBodyFilter}
403
- ORDER BY created_at_epoch DESC
462
+ ORDER BY
463
+ CASE WHEN body IS NOT NULL AND body != '' THEN 0 ELSE 1 END,
464
+ created_at_epoch DESC
404
465
  LIMIT ${eventsLimit}
405
- `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
466
+ `).all(project, cutoff, `%"${escaped}"%`, `%"${filePathEscaped}"%`);
406
467
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
407
468
 
408
469
  // A3 (v2.83): cross-hook dedup. UPS may have already injected some of
@@ -410,10 +471,17 @@ try {
410
471
  // (and inflates context). Drop ids found in the cross-hook injected file
411
472
  // inside the staleness window; keep file-cooldown unchanged (the same
412
473
  // file might also re-warrant a different lesson next session).
474
+ // P1 (D#78): tag each row's source table — events share the numeric id
475
+ // space with observations, and the Stop-side edge attribution must never
476
+ // feed an event id into observation_files updates.
413
477
  const crossHookSeen = readCrossHookInjected(project);
478
+ const sourcedRows = [
479
+ ...rows.map(r => ({ ...r, src: 'obs' })),
480
+ ...eventRows.map(r => ({ ...r, src: 'evt' })),
481
+ ];
414
482
  const dedupedRows = crossHookSeen.size > 0
415
- ? [...rows, ...eventRows].filter(r => !crossHookSeen.has(String(r.id)))
416
- : [...rows, ...eventRows];
483
+ ? sourcedRows.filter(r => !crossHookSeen.has(String(r.id)))
484
+ : sourcedRows;
417
485
 
418
486
  // Merge: observations first (they carry richer lesson_learned), then events.
419
487
  // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
@@ -452,7 +520,14 @@ try {
452
520
  // when the model misreads passive lesson context as a closing note.
453
521
  lines.push(`[mem] PreToolUse recall — system-injected context, continue your planned action:`);
454
522
  }
455
- if (fileIntelLine) lines.push(fileIntelLine);
523
+ // MED-1 (full audit 2026-07-16): defang the injection-block delimiters in
524
+ // all DB/file-derived text before it enters additionalContext (which CC wraps
525
+ // in <system-reminder>). Stored text is raw — a lesson/title/summary carrying
526
+ // a literal </system-reminder> or forged <invoke ...> would break the wrapper
527
+ // and inject a privileged instruction. Mirrors formatErrorRecallHints, which
528
+ // already applies the same guard on the parallel error-recall surface. The
529
+ // `#id [type]` prefix is agent-generated (numeric id + type enum) — no defang.
530
+ if (fileIntelLine) lines.push(neutralizeContextDelimiters(fileIntelLine));
456
531
  if (hasLessons) {
457
532
  lines.push(`[mem] Lessons for ${fname}:`);
458
533
  for (const r of allRows) {
@@ -460,12 +535,12 @@ try {
460
535
  const lesson = r.lesson_learned.length > LESSON_MAX
461
536
  ? r.lesson_learned.slice(0, LESSON_MAX - 3) + '...'
462
537
  : r.lesson_learned;
463
- lines.push(` #${r.id} [${r.type}] ${lesson}`);
538
+ lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(lesson)}`);
464
539
  } else {
465
540
  const title = (r.title || '').length > LESSON_MAX
466
541
  ? r.title.slice(0, LESSON_MAX - 3) + '...'
467
542
  : (r.title || '');
468
- lines.push(` #${r.id} [${r.type}] ${title}`);
543
+ lines.push(` #${r.id} [${r.type}] ${neutralizeContextDelimiters(title)}`);
469
544
  }
470
545
  }
471
546
  // v2.98 salience: Edit/Write is the action point — close the block with an
@@ -477,7 +552,7 @@ try {
477
552
  const changeText = [toolInput?.old_string, toolInput?.new_string, toolInput?.content]
478
553
  .filter(Boolean).join('\n');
479
554
  const bridged = await bridgeTopLesson(allRows, changeText);
480
- if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${bridged.check}. Confirm your new code satisfies it.`);
555
+ if (bridged) lines.push(`[mem] ⚠ #${bridged.id} → this edit must: ${neutralizeContextDelimiters(bridged.check)}. Confirm your new code satisfies it.`);
481
556
  else lines.push(`[mem] ⚠ Before this edit: ${ACTIVE_DIRECTIVE}`);
482
557
  }
483
558
  } else if (!isRead && process.env.CLAUDE_MEM_PRETOOL_NUDGE === '1') {
@@ -537,6 +612,10 @@ try {
537
612
  cooldown[filePath] = {
538
613
  ts: now,
539
614
  lessonIds: allRows.map(r => r.id),
615
+ // P1 (D#78): observation-sourced ids only — consumed by the Stop-side
616
+ // edge attribution (lib/edge-attribution.mjs). lessonIds stays mixed for
617
+ // the cite-back hint contract.
618
+ obsIds: allRows.filter(r => r.src === 'obs').map(r => r.id),
540
619
  mode: isRead ? 'read' : 'edit',
541
620
  ...(lessonIdents ? { lessonIdents } : {}),
542
621
  ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
package/server.mjs CHANGED
@@ -19,10 +19,11 @@ import {
19
19
  cleanupBroken, decayAndMarkIdle, boostAccessed, demotePinned, mergeDuplicates,
20
20
  recoverOrphanedChildren, recoverBuriedLessons, sweepDeferredWorkOrphans,
21
21
  purgeStale, purgeStalePreview, findDuplicates, maintenanceStats, rebuildVectors, vacuum,
22
- recoverChildrenOf, hardDeleteCandidateCount,
22
+ hardDeleteCandidateCount,
23
23
  OP_CAP, STALE_AGE_MS,
24
24
  } from './lib/maintain-core.mjs';
25
25
  import { snapshotDb } from './lib/db-backup.mjs';
26
+ import { deleteObservations } from './lib/delete-core.mjs';
26
27
  import { effectiveQuiet, RUNTIME_DIR } from './hook-shared.mjs';
27
28
  import { TIER_CASE_SQL, tierSqlParams } from './tier.mjs';
28
29
  import { formatObsFieldValue } from './cli/common.mjs';
@@ -680,49 +681,14 @@ server.registerTool(
680
681
  return { content: [{ type: 'text', text: lines.join('\n') }] };
681
682
  }
682
683
 
683
- // Snapshot before the irreversible hard-delete so a wrong-id delete has a pre-image,
684
- // matching the CLI delete + maintain purge/cleanup paths (audit MED-2). Best-effort
685
- // (never throws, skips :memory:). Must run OUTSIDE the transaction below (VACUUM).
686
- snapshotDb(db, { tag: 'pre-delete' });
687
-
688
- // Wrap cleanup + deletion in a transaction for consistency
689
- const deletedIds = new Set(args.ids);
690
- const deleteTx = db.transaction(() => {
691
- // Clean up stale references in other observations' related_ids
692
- // Use LIKE filter to avoid O(N) full-table scan — only fetch rows that may reference deleted IDs.
693
- // NOTE: LIKE %id% has false positives (e.g. %1% matches [10], [21]). This is intentional —
694
- // the LIKE is a coarse pre-filter; the JSON parse + Set.has below is the precise filter.
695
- // Acceptable because observation count per user is typically <10K.
696
- const likeConditions = args.ids.map(() => `related_ids LIKE ?`).join(' OR ');
697
- const likeParams = args.ids.map(id => `%${id}%`);
698
- const referencing = db.prepare(`
699
- SELECT id, related_ids FROM observations
700
- WHERE related_ids IS NOT NULL AND related_ids != '[]'
701
- AND (${likeConditions})
702
- `).all(...likeParams);
703
- for (const r of referencing) {
704
- let ids;
705
- try { ids = JSON.parse(r.related_ids); } catch (e) { debugCatch(e, 'deleteRelatedIds'); continue; }
706
- if (!Array.isArray(ids) || !ids.every(id => Number.isInteger(id))) continue;
707
- const filtered = ids.filter(id => !deletedIds.has(id));
708
- if (filtered.length !== ids.length) {
709
- db.prepare('UPDATE observations SET related_ids = ? WHERE id = ?').run(JSON.stringify(filtered), r.id);
710
- }
711
- }
712
- // Resurface rows merged/compressed INTO the doomed keepers before deleting, else
713
- // they dangle behind a now-missing parent (compressed_into has no FK) — invisible
714
- // to every COALESCE(compressed_into,0)=0 view and unrecoverable. Mirrors the CLI
715
- // delete path + the maintain hard-delete guard (recoverChildrenOf).
716
- const recovered = recoverChildrenOf(db, args.ids);
717
- // Execute deletion (FTS5 cleanup handled by observations_ad trigger)
718
- const deleted = db.prepare(`DELETE FROM observations WHERE id IN (${placeholders})`).run(...args.ids);
719
- return { changes: deleted.changes, recovered };
720
- });
721
- const result = deleteTx();
684
+ // Full delete orchestration (snapshot + related_ids cleanup + child recovery +
685
+ // delete transaction) lives in lib/delete-core.mjs single source of truth shared
686
+ // with the CLI `delete` path (was inlined + kept in sync by parity comments).
687
+ const result = deleteObservations(db, args.ids);
722
688
 
723
689
  const missing = args.ids.filter(id => !rows.some(r => r.id === id));
724
- const msg = [`Deleted ${result.changes} observation(s).`];
725
- if (result.recovered > 0) msg.push(`Recovered ${result.recovered} merged/compressed child observation(s) to live.`);
690
+ const msg = [`Deleted ${result.deleted} observation(s).`];
691
+ if (result.recoveredChildren > 0) msg.push(`Recovered ${result.recoveredChildren} merged/compressed child observation(s) to live.`);
726
692
  if (missing.length > 0) msg.push(`Note: ID(s) ${missing.join(', ')} not found.`);
727
693
  return { content: [{ type: 'text', text: msg.join(' ') }] };
728
694
  })
package/source-files.mjs CHANGED
@@ -49,6 +49,13 @@ export const SOURCE_FILES = [
49
49
  'lib/low-signal-patterns.mjs',
50
50
  'lib/private-strip.mjs',
51
51
  'lib/citation-tracker.mjs',
52
+ // v3.47 (D#78 P1): per-(obs,file) edge attribution. Imported by hook.mjs
53
+ // (handleStop edge resolution). Missing from manifest → tarball hook.mjs
54
+ // ERR_MODULE_NOT_FOUND on every Stop.
55
+ 'lib/edge-attribution.mjs',
56
+ // v3.47 (D#78 P0): shared trigger-edge match predicate. Imported by BOTH
57
+ // scripts/pre-tool-recall.js (hook fast-path) and lib/edge-attribution.mjs.
58
+ 'lib/file-edge-match.mjs',
52
59
  'lib/cite-back-hint.mjs',
53
60
  // v2.85: stale test-fixture sweeper. Imported by install.mjs (cleanup) + cli.mjs.
54
61
  // Missing from manifest → tarball ships install.mjs that ERR_MODULE_NOT_FOUND on cleanup.
@@ -145,6 +152,16 @@ export const SOURCE_FILES = [
145
152
  // server.mjs, and hook.mjs before their destructive purge/cleanup — missing it
146
153
  // would crash maintain on auto-update with an unresolved import.
147
154
  'lib/db-backup.mjs',
155
+ // HIGH-1 events-injection: surfaces the `events` canonical store into the passive
156
+ // injection surfaces. Statically imported by hook.mjs (UserPromptSubmit) and
157
+ // hook-context.mjs (SessionStart) — missing it from the manifest would break both
158
+ // hooks on auto-update.
159
+ 'lib/events-injection.mjs',
160
+ // Shared delete orchestration (snapshot + related_ids cleanup + child recovery
161
+ // + delete txn). Statically imported by server.mjs (mem_delete) and mem-cli.mjs
162
+ // (cmdDelete) — extracted to kill the byte-duplicated twin. Missing it from the
163
+ // manifest would leave both delete surfaces unsigned/broken on auto-update.
164
+ 'lib/delete-core.mjs',
148
165
  // P10 dedup/merge threshold constants — single source of truth for the Jaccard
149
166
  // dedup/merge cutoffs. Statically imported by hook.mjs, hook-llm.mjs,
150
167
  // hook-optimize.mjs, mem-cli.mjs, server.mjs, and the save/maintain cores;