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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hook-llm.mjs +15 -4
- package/hook.mjs +22 -0
- package/lib/edge-attribution.mjs +158 -0
- package/lib/export-columns.mjs +1 -0
- package/lib/file-edge-match.mjs +44 -0
- package/lib/observation-write.mjs +11 -1
- package/mem-cli.mjs +5 -1
- package/package.json +3 -1
- package/schema.mjs +58 -10
- package/scripts/pre-tool-recall.js +83 -12
- package/source-files.mjs +7 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-mem-lite",
|
|
13
|
-
"version": "3.
|
|
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.
|
|
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
|
+
}
|
package/lib/export-columns.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
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
|
|
149
|
+
const stmt = db.prepare(
|
|
126
150
|
`SELECT 1 AS present FROM pragma_table_info(?) WHERE name = ?`
|
|
127
|
-
)
|
|
128
|
-
return Boolean(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
389
|
-
//
|
|
390
|
-
|
|
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
|
|
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, `%"${
|
|
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
|
-
?
|
|
416
|
-
:
|
|
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.
|