claude-mem-lite 3.46.0 → 3.47.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.47.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.47.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-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,6 +58,7 @@ 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';
63
64
  import { formatTaskImperative } from './lib/task-imperative.mjs';
@@ -650,6 +651,27 @@ async function handleStop() {
650
651
  // obs resolved this run (denominator), promoted = obs cited this run
651
652
  // (numerator). Idempotent (touched is 0 on re-fire) + best-effort.
652
653
  recordCitationFunnel(db, project, sessionId, r.touched, r.promoted);
654
+ // P1 (D#78): per-edge attribution. The session cooldown file
655
+ // (keyed by CC session id) records which FILE each obs was
656
+ // injected for; resolve those (obs,file) edges as hit/miss with
657
+ // the same citedMain set. Lives inside the same text-floor gate
658
+ // so a tool-only Stop can't lock edges as missed. Best-effort.
659
+ // Keying on ccSessionId (NOT the rotating memory sessionId)
660
+ // matches the cooldown file's lifetime — a memory-session
661
+ // rotation mid-CC-session must not re-resolve old injections as
662
+ // fresh misses. mainInjectedIds mirrors the mainOnly discipline
663
+ // above: sidechain-only injections in the cooldown never accrue
664
+ // misses (review D#78).
665
+ try {
666
+ if (ccSessionId) {
667
+ const edges = readPreRecallFileEdges(RUNTIME_DIR, ccSessionId);
668
+ if (edges.length > 0) {
669
+ const er = resolveEdgeAttribution(db, project, edges, citedMain, ccSessionId,
670
+ { mainInjectedIds: injected });
671
+ debugLog('DEBUG', 'handleStop', `edge-attribution: edges=${er.touchedEdges} hits=${er.hits} misses=${er.misses}`);
672
+ }
673
+ }
674
+ } catch (e) { debugCatch(e, 'handleStop-edge-attribution'); }
653
675
  }
654
676
  }
655
677
  } catch (e) { debugCatch(e, 'handleStop-citation-decay'); }
@@ -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
+ }
@@ -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.
package/mem-cli.mjs CHANGED
@@ -39,7 +39,7 @@ import { readFileSync, existsSync, readdirSync } from 'fs';
39
39
  // move each cmdXxx into its own cli/<cmd>.mjs; mem-cli.mjs becomes pure dispatch.
40
40
  import { parseArgs, out, fail, relativeTime, fmtDateShort, parseIdToken, formatProbeHints, rejectBareStringFlags, suggestUnknownFlags, OBS_TIME_FIELDS, formatObsFieldValue } from './cli/common.mjs';
41
41
  import { saveObservation } from './lib/save-observation.mjs';
42
- import { rebuildObservationDerived } from './lib/observation-write.mjs';
42
+ import { rebuildObservationDerived, normalizeScope } from './lib/observation-write.mjs';
43
43
  import { EXPORT_COLUMNS_SQL } from './lib/export-columns.mjs';
44
44
  import { computeNoiseGauge } from './lib/stats-quality.mjs';
45
45
  import { recallByFile } from './lib/recall-core.mjs';
@@ -1808,6 +1808,7 @@ function cmdRestore(db, argv) {
1808
1808
  const signalUpdate = db.prepare(`UPDATE observations SET
1809
1809
  text = COALESCE(?, text),
1810
1810
  subtitle = ?, concepts = ?, facts = ?, search_aliases = ?, files_read = ?, branch = COALESCE(?, branch),
1811
+ scope = ?,
1811
1812
  access_count = ?, cited_count = ?, uncited_streak = ?, injection_count = ?,
1812
1813
  decay_seen_count = ?, last_accessed_at = ?
1813
1814
  WHERE id = ?`);
@@ -1854,6 +1855,9 @@ function cmdRestore(db, argv) {
1854
1855
  scrubSecrets(r.subtitle || ''), scrubSecrets(r.concepts || ''), scrubSecrets(r.facts || ''),
1855
1856
  r.search_aliases === null || r.search_aliases === undefined ? null : scrubSecrets(r.search_aliases),
1856
1857
  r.files_read || '[]', r.branch ?? null,
1858
+ // v44 scope round-trip (review D#78): whitelist-validated; old backups
1859
+ // without the column restore as NULL — same as pre-v44 behavior.
1860
+ normalizeScope(r.scope),
1857
1861
  num(r.access_count), num(r.cited_count), num(r.uncited_streak), num(r.injection_count),
1858
1862
  num(r.decay_seen_count), r.last_accessed_at ?? null,
1859
1863
  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.47.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",
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,6 +10,7 @@ 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';
@@ -58,6 +59,23 @@ const STALE_MS = 10 * 60 * 1000; // 10 minutes cleanup threshold for legacy fi
58
59
  // small reads carry no noise. Env names mirror schema.mjs CLAUDE_MEM_* convention (#8447).
59
60
  const FILE_INTEL_OFF = ['0', 'off', 'false', 'no'].includes(
60
61
  String(process.env.CLAUDE_MEM_FILE_INTEL || '').toLowerCase());
62
+ // P2 (D#78): edge-level decay ENFORCEMENT — opt-in (default OFF, shadow-first).
63
+ // When on, a (obs,file) edge whose miss_streak reached K consecutive uncited
64
+ // injections stops firing on this surface; the lesson stays reachable via
65
+ // search / UPS / error-recall. P1 counting (Stop-side attribution) is always
66
+ // on regardless of this flag. Flip only after real-DB cite-rate evidence.
67
+ const EDGE_DECAY_ON = ['1', 'on', 'true', 'yes'].includes(
68
+ String(process.env.CLAUDE_MEM_EDGE_DECAY || '').toLowerCase());
69
+ // NaN-checked, not `|| 3`: an explicit K=0 is falsy and would silently become
70
+ // the default instead of clamping to the declared minimum of 1 (review D#78).
71
+ const EDGE_DECAY_K_RAW = parseInt(process.env.CLAUDE_MEM_EDGE_DECAY_K, 10);
72
+ const EDGE_DECAY_K = Math.max(1, Number.isNaN(EDGE_DECAY_K_RAW) ? 3 : EDGE_DECAY_K_RAW);
73
+ // P3 (D#78): scope filter — opt-in (default OFF, shadow-first). When on,
74
+ // environment-scoped observations (tooling/CI/network gotchas that apply in
75
+ // ANY project) stop firing on FILE-triggered recall; they stay reachable via
76
+ // search / UPS / error-recall. NULL scope (legacy / manual rows) always passes.
77
+ const SCOPE_FILTER_ON = ['1', 'on', 'true', 'yes'].includes(
78
+ String(process.env.CLAUDE_MEM_SCOPE_FILTER || '').toLowerCase());
61
79
  const FILE_INTEL_MIN_TOKENS = Math.max(1,
62
80
  parseInt(process.env.CLAUDE_MEM_FILE_INTEL_MIN_TOKENS, 10) || 800);
63
81
  // Feature ② (repeated-read guard): when the agent does a FULL re-read of a file
@@ -323,9 +341,14 @@ try {
323
341
  try {
324
342
  const project = inferProject();
325
343
  const fname = basename(filePath);
326
- // Escape LIKE wildcards
344
+ // Escape LIKE wildcards (still needed below for the events file_paths arms)
327
345
  const escaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
328
- const likePattern = `%${escaped}`;
346
+ // P0 (D#78): path-boundary match — editing utils.mjs must NOT pull lessons
347
+ // stored under bash-utils.mjs (the old '%<basename>' suffix LIKE did).
348
+ // Clause + params come from lib/file-edge-match.mjs, byte-shared with the
349
+ // Stop-side edge attribution so trigger and resolver can never drift.
350
+ const fileMatch = fileMatchClause('of2');
351
+ const fileParams = fileMatchParams(filePath);
329
352
  // 60-day lookback to avoid surfacing ancient observations
330
353
  const cutoff = Date.now() - 60 * 86400000;
331
354
 
@@ -358,6 +381,30 @@ try {
358
381
  // merely-most-recent one. Single-match files unchanged (obsLimit=1 Read /
359
382
  // 2 Edit). Composes with v2.83.0 A1 to extend the citation-decay feedback
360
383
  // loop to the 85%-recall PreToolUse:Read/Edit path.
384
+ // P2 (D#78): decayed-edge filter. This readonly fast-path never migrates,
385
+ // so a pre-v43 DB has no miss_streak column and the filter would throw at
386
+ // prepare time — probe pragma_table_info and fall back to unfiltered
387
+ // (pre-v43 edges carry no counts anyway, so nothing would be filtered).
388
+ let edgeDecayFilter = '';
389
+ if (EDGE_DECAY_ON) {
390
+ try {
391
+ const hasCol = db.prepare(
392
+ `SELECT 1 FROM pragma_table_info('observation_files') WHERE name = 'miss_streak'`
393
+ ).get();
394
+ if (hasCol) edgeDecayFilter = `AND of2.miss_streak < ${EDGE_DECAY_K}`;
395
+ } catch { /* probe failure → unfiltered */ }
396
+ }
397
+ // P3 (D#78): environment-scope filter — same probe discipline (readonly
398
+ // fast-path may hit a pre-v43 DB where observations.scope doesn't exist).
399
+ let scopeFilter = '';
400
+ if (SCOPE_FILTER_ON) {
401
+ try {
402
+ const hasScope = db.prepare(
403
+ `SELECT 1 FROM pragma_table_info('observations') WHERE name = 'scope'`
404
+ ).get();
405
+ if (hasScope) scopeFilter = `AND (o.scope IS NULL OR o.scope != 'environment')`;
406
+ } catch { /* probe failure → unfiltered */ }
407
+ }
361
408
  const rows = db.prepare(`
362
409
  SELECT DISTINCT o.id, o.type, o.title, o.lesson_learned
363
410
  FROM observations o
@@ -367,14 +414,16 @@ try {
367
414
  AND COALESCE(o.compressed_into, 0) = 0
368
415
  AND o.superseded_at IS NULL
369
416
  AND o.created_at_epoch > ?
370
- AND (of2.filename = ? OR of2.filename LIKE ? ESCAPE '\\')
417
+ AND ${fileMatch}
418
+ ${edgeDecayFilter}
419
+ ${scopeFilter}
371
420
  ${typeFallback}
372
421
  ORDER BY
373
422
  CASE WHEN o.lesson_learned IS NOT NULL AND o.lesson_learned != '' THEN 0 ELSE 1 END,
374
423
  ${citeFactorClause('o')} DESC,
375
424
  o.created_at_epoch DESC
376
425
  LIMIT ${obsLimit}
377
- `).all(project, cutoff, filePath, likePattern);
426
+ `).all(project, cutoff, ...fileParams);
378
427
 
379
428
  // T9: also query the `events` table — after T9, bugfix/lesson/decision/etc.
380
429
  // route here instead of observations, so we must read both sources to keep
@@ -382,12 +431,21 @@ try {
382
431
  // patterns match both basename and full-path entries. JSON quoting
383
432
  // (`"<name>"`) prevents partial-match false positives like "foo.mjs"
384
433
  // matching "myfoo.mjs".
385
- const fnameEscaped = fname.replace(/%/g, '\\%').replace(/_/g, '\\_');
386
434
  const filePathEscaped = filePath.replace(/%/g, '\\%').replace(/_/g, '\\_');
387
435
  // 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 != ''" : '';
436
+ // body (= lesson equivalent). Edit path keeps a wider net, but P0 (D#78)
437
+ // closes the parallel-path drift vs the observations query: a bodyless row
438
+ // is admitted only when it is a lesson-bearing type (bugfix/decision the
439
+ // obs fallback's set — plus events-only 'lesson') AND its title isn't a
440
+ // LOW_SIGNAL auto-fallback; body-bearing rows outrank bodyless ones
441
+ // (mirrors the obs lesson-first sort). Known tradeoff: a deliberately
442
+ // bodyless manual event whose title starts with a LOW_SIGNAL prefix
443
+ // ('npm …', 'Error: …') no longer fires here — it stays reachable via
444
+ // search / UPS; /bug and /lesson write bodies, so this is a rare shape.
445
+ const eventsBodyFilter = isRead
446
+ ? "AND body IS NOT NULL AND body != ''"
447
+ : `AND ((body IS NOT NULL AND body != '')
448
+ OR (event_type IN ('bugfix', 'decision', 'lesson') AND ${buildNotLowSignalSql('')}))`;
391
449
  const eventsLimit = isRead ? 1 : 2;
392
450
  let eventRows = [];
393
451
  try {
@@ -400,9 +458,11 @@ try {
400
458
  AND created_at_epoch > ?
401
459
  AND (file_paths LIKE ? ESCAPE '\\' OR file_paths LIKE ? ESCAPE '\\')
402
460
  ${eventsBodyFilter}
403
- ORDER BY created_at_epoch DESC
461
+ ORDER BY
462
+ CASE WHEN body IS NOT NULL AND body != '' THEN 0 ELSE 1 END,
463
+ created_at_epoch DESC
404
464
  LIMIT ${eventsLimit}
405
- `).all(project, cutoff, `%"${fnameEscaped}"%`, `%"${filePathEscaped}"%`);
465
+ `).all(project, cutoff, `%"${escaped}"%`, `%"${filePathEscaped}"%`);
406
466
  } catch { /* events table may not exist on pre-v2.31 DBs — silent */ }
407
467
 
408
468
  // A3 (v2.83): cross-hook dedup. UPS may have already injected some of
@@ -410,10 +470,17 @@ try {
410
470
  // (and inflates context). Drop ids found in the cross-hook injected file
411
471
  // inside the staleness window; keep file-cooldown unchanged (the same
412
472
  // file might also re-warrant a different lesson next session).
473
+ // P1 (D#78): tag each row's source table — events share the numeric id
474
+ // space with observations, and the Stop-side edge attribution must never
475
+ // feed an event id into observation_files updates.
413
476
  const crossHookSeen = readCrossHookInjected(project);
477
+ const sourcedRows = [
478
+ ...rows.map(r => ({ ...r, src: 'obs' })),
479
+ ...eventRows.map(r => ({ ...r, src: 'evt' })),
480
+ ];
414
481
  const dedupedRows = crossHookSeen.size > 0
415
- ? [...rows, ...eventRows].filter(r => !crossHookSeen.has(String(r.id)))
416
- : [...rows, ...eventRows];
482
+ ? sourcedRows.filter(r => !crossHookSeen.has(String(r.id)))
483
+ : sourcedRows;
417
484
 
418
485
  // Merge: observations first (they carry richer lesson_learned), then events.
419
486
  // Edit/Write caps at 3 total; Read caps at 1 (single most-actionable hit).
@@ -537,6 +604,10 @@ try {
537
604
  cooldown[filePath] = {
538
605
  ts: now,
539
606
  lessonIds: allRows.map(r => r.id),
607
+ // P1 (D#78): observation-sourced ids only — consumed by the Stop-side
608
+ // edge attribution (lib/edge-attribution.mjs). lessonIds stays mixed for
609
+ // the cite-back hint contract.
610
+ obsIds: allRows.filter(r => r.src === 'obs').map(r => r.id),
540
611
  mode: isRead ? 'read' : 'edit',
541
612
  ...(lessonIdents ? { lessonIdents } : {}),
542
613
  ...(rereadMeta ? { reread: { mtimeMs: rereadMeta.mtimeMs, tokens: rereadMeta.tokens, full: isFullRead } } : {}),
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.