clawmem 0.6.0 → 0.7.1
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/AGENTS.md +10 -5
- package/CLAUDE.md +10 -5
- package/README.md +34 -4
- package/SKILL.md +15 -1
- package/package.json +1 -1
- package/src/consolidation.ts +525 -40
- package/src/deductive-guardrails.ts +481 -0
- package/src/hooks/context-surfacing.ts +285 -16
- package/src/hooks/feedback-loop.ts +40 -0
- package/src/hooks.ts +8 -3
- package/src/mcp.ts +32 -1
- package/src/merge-guards.ts +266 -0
- package/src/recall-attribution.ts +182 -0
- package/src/recall-buffer.ts +85 -0
- package/src/store.ts +271 -12
- package/src/text-similarity.ts +364 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contradiction-aware merge gate (Ext 2).
|
|
3
|
+
*
|
|
4
|
+
* LLM-first contradiction check with heuristic fallback. Returns a
|
|
5
|
+
* structured `ContradictionResult` that downstream merge code uses to
|
|
6
|
+
* decide whether to merge, supersede, or link two observations.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. `llmContradictionCheck` — structured LLM classification; returns
|
|
10
|
+
* null on LLM cooldown, network failure, malformed JSON, or missing
|
|
11
|
+
* `contradictory` field.
|
|
12
|
+
* 2. `heuristicContradictionCheck` — deterministic signal on
|
|
13
|
+
* negation asymmetry or number/date mismatch. Used as fallback when
|
|
14
|
+
* the LLM path returns null.
|
|
15
|
+
* 3. `checkContradiction` — orchestrator. Runs LLM first, falls back
|
|
16
|
+
* to heuristic on null. Never throws. Always returns a usable
|
|
17
|
+
* `ContradictionResult`.
|
|
18
|
+
*
|
|
19
|
+
* Adapted from Thoth `tools/memory_tool.py:111-184` contradiction-check
|
|
20
|
+
* pattern (THOTH_EXTRACTION_PLAN.md Extraction 2).
|
|
21
|
+
*
|
|
22
|
+
* Reuses the A-MEM convention relation type `'contradicts'` (plural) —
|
|
23
|
+
* see P0 taxonomy guard at `tests/unit/contradict-taxonomy.test.ts`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { LLM } from "./llm.ts";
|
|
27
|
+
import { extractJsonFromLLM } from "./amem.ts";
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Types
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export type ContradictionSource = "llm" | "heuristic" | "unknown";
|
|
34
|
+
|
|
35
|
+
export interface ContradictionResult {
|
|
36
|
+
contradictory: boolean;
|
|
37
|
+
confidence: number; // 0.0 - 1.0
|
|
38
|
+
reason?: string;
|
|
39
|
+
source: ContradictionSource;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Phase-2 contradiction handling policy. `link` (default) preserves
|
|
44
|
+
* both rows as active and sets `invalidated_by` as a backlink for
|
|
45
|
+
* operator queries. `supersede` additionally sets `invalidated_at` on
|
|
46
|
+
* the old row so it stops surfacing in active recalls.
|
|
47
|
+
*/
|
|
48
|
+
export type ContradictionPolicy = "link" | "supersede";
|
|
49
|
+
|
|
50
|
+
export function resolveContradictionPolicy(): ContradictionPolicy {
|
|
51
|
+
const raw = process.env.CLAWMEM_CONTRADICTION_POLICY;
|
|
52
|
+
if (raw === "supersede") return "supersede";
|
|
53
|
+
return "link"; // default
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Minimum LLM contradiction confidence to act on. Lower scores are
|
|
58
|
+
* treated as inconclusive and the merge proceeds (conservative: only
|
|
59
|
+
* block merges on clear contradictions). Overridable via
|
|
60
|
+
* `CLAWMEM_CONTRADICTION_MIN_CONFIDENCE` env var (0.0 - 1.0).
|
|
61
|
+
*/
|
|
62
|
+
export const CONTRADICTION_MIN_CONFIDENCE = parseEnvFloat(
|
|
63
|
+
"CLAWMEM_CONTRADICTION_MIN_CONFIDENCE",
|
|
64
|
+
0.5
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
function parseEnvFloat(name: string, fallback: number): number {
|
|
68
|
+
const raw = process.env[name];
|
|
69
|
+
if (raw === undefined) return fallback;
|
|
70
|
+
const n = Number.parseFloat(raw);
|
|
71
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) return fallback;
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Heuristic contradiction detection (deterministic, no LLM)
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deterministic heuristic contradiction check.
|
|
81
|
+
*
|
|
82
|
+
* Signals:
|
|
83
|
+
* - **Negation asymmetry:** one side has an explicit negation token
|
|
84
|
+
* (`not`, `never`, `no`, `didn't`, etc.) and the other doesn't.
|
|
85
|
+
* - **Number/date mismatch:** both sides cite numbers or dates but the
|
|
86
|
+
* sets have no shared values.
|
|
87
|
+
*
|
|
88
|
+
* Intentionally conservative: returns `contradictory=false,
|
|
89
|
+
* confidence=0` when no signal is found, leaving the decision to the
|
|
90
|
+
* LLM or the caller's default.
|
|
91
|
+
*/
|
|
92
|
+
export function heuristicContradictionCheck(
|
|
93
|
+
a: string,
|
|
94
|
+
b: string
|
|
95
|
+
): ContradictionResult {
|
|
96
|
+
const negA = hasNegation(a);
|
|
97
|
+
const negB = hasNegation(b);
|
|
98
|
+
|
|
99
|
+
// Negation asymmetry: one side explicitly negates, the other doesn't
|
|
100
|
+
if (negA !== negB) {
|
|
101
|
+
return {
|
|
102
|
+
contradictory: true,
|
|
103
|
+
confidence: 0.6,
|
|
104
|
+
reason: "negation asymmetry — one statement has explicit negation",
|
|
105
|
+
source: "heuristic",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const numsA = extractNumbers(a);
|
|
110
|
+
const numsB = extractNumbers(b);
|
|
111
|
+
|
|
112
|
+
// Number/date mismatch: both cite numbers but no shared values
|
|
113
|
+
if (numsA.length > 0 && numsB.length > 0) {
|
|
114
|
+
const setA = new Set(numsA);
|
|
115
|
+
const setB = new Set(numsB);
|
|
116
|
+
const shared = [...setA].filter((n) => setB.has(n));
|
|
117
|
+
if (shared.length === 0) {
|
|
118
|
+
return {
|
|
119
|
+
contradictory: true,
|
|
120
|
+
confidence: 0.5,
|
|
121
|
+
reason: `number/date mismatch (A=${numsA.join(",")} B=${numsB.join(",")})`,
|
|
122
|
+
source: "heuristic",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// No heuristic signal
|
|
128
|
+
return {
|
|
129
|
+
contradictory: false,
|
|
130
|
+
confidence: 0.0,
|
|
131
|
+
reason: "no heuristic signal",
|
|
132
|
+
source: "heuristic",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Extract standalone integers, decimals, and ISO-ish dates from a
|
|
138
|
+
* string as a normalized set of numeric tokens.
|
|
139
|
+
*/
|
|
140
|
+
function extractNumbers(s: string): string[] {
|
|
141
|
+
// Matches: integers, decimals (1.5, 1,000), ISO dates (2026-04-10),
|
|
142
|
+
// US dates (04/10/2026), version strings (v0.7.1 → 0.7.1)
|
|
143
|
+
const matches = s.match(/\b\d{1,5}(?:[.,/-]\d{1,5}){0,2}\b/g) || [];
|
|
144
|
+
return matches.map((m) => m.replace(/,/g, ""));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Return true if the string contains an explicit negation token.
|
|
149
|
+
* Matches English contractions (didn't, won't, cannot, etc.) plus
|
|
150
|
+
* bare negations (not, never, no).
|
|
151
|
+
*/
|
|
152
|
+
function hasNegation(s: string): boolean {
|
|
153
|
+
return /\b(not|never|no|don['\u2019]t|didn['\u2019]t|won['\u2019]t|cannot|can['\u2019]t|wasn['\u2019]t|isn['\u2019]t|aren['\u2019]t|weren['\u2019]t|shouldn['\u2019]t|couldn['\u2019]t|wouldn['\u2019]t)\b/i.test(
|
|
154
|
+
s
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// =============================================================================
|
|
159
|
+
// LLM-based contradiction detection
|
|
160
|
+
// =============================================================================
|
|
161
|
+
|
|
162
|
+
const CONTRADICTION_PROMPT_TEMPLATE = `You are a logic checker. Determine whether two statements contradict each other.
|
|
163
|
+
|
|
164
|
+
Statement A: {A}
|
|
165
|
+
|
|
166
|
+
Statement B: {B}{CONTEXT}
|
|
167
|
+
|
|
168
|
+
A contradiction exists if one statement directly denies the other, or if both cannot be true at the same time. Subtle differences in specificity (e.g. "Bob" vs "Bob Smith") are NOT contradictions. Different dates, counts, outcomes, or decisions on the same subject ARE contradictions.
|
|
169
|
+
|
|
170
|
+
Respond with ONLY a JSON object:
|
|
171
|
+
{"contradictory": true|false, "confidence": 0.0-1.0, "reason": "brief explanation"}
|
|
172
|
+
|
|
173
|
+
Do not include any other text. /no_think`;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* LLM-based contradiction classifier.
|
|
177
|
+
*
|
|
178
|
+
* Returns `null` on any of:
|
|
179
|
+
* - LLM generate call throws
|
|
180
|
+
* - LLM returns null (cooldown, timeout, remote LLM down)
|
|
181
|
+
* - LLM returns text but JSON extraction fails
|
|
182
|
+
* - Parsed JSON is missing a boolean `contradictory` field
|
|
183
|
+
*
|
|
184
|
+
* Callers should fall back to the heuristic path on null.
|
|
185
|
+
*/
|
|
186
|
+
export async function llmContradictionCheck(
|
|
187
|
+
llm: LLM,
|
|
188
|
+
a: string,
|
|
189
|
+
b: string,
|
|
190
|
+
context?: string
|
|
191
|
+
): Promise<ContradictionResult | null> {
|
|
192
|
+
const prompt = CONTRADICTION_PROMPT_TEMPLATE.replace("{A}", a)
|
|
193
|
+
.replace("{B}", b)
|
|
194
|
+
.replace("{CONTEXT}", context ? `\n\nContext:\n${context}` : "");
|
|
195
|
+
|
|
196
|
+
let result;
|
|
197
|
+
try {
|
|
198
|
+
result = await llm.generate(prompt, { temperature: 0.2, maxTokens: 150 });
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!result?.text) return null;
|
|
204
|
+
|
|
205
|
+
const parsed = extractJsonFromLLM(result.text) as {
|
|
206
|
+
contradictory?: unknown;
|
|
207
|
+
confidence?: unknown;
|
|
208
|
+
reason?: unknown;
|
|
209
|
+
} | null;
|
|
210
|
+
|
|
211
|
+
if (!parsed || typeof parsed.contradictory !== "boolean") return null;
|
|
212
|
+
|
|
213
|
+
const confidence =
|
|
214
|
+
typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence)
|
|
215
|
+
? Math.max(0, Math.min(1, parsed.confidence))
|
|
216
|
+
: 0.5;
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
contradictory: parsed.contradictory,
|
|
220
|
+
confidence,
|
|
221
|
+
reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
|
|
222
|
+
source: "llm",
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// =============================================================================
|
|
227
|
+
// Orchestrator
|
|
228
|
+
// =============================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Orchestrated contradiction check.
|
|
232
|
+
*
|
|
233
|
+
* 1. Try LLM path; if it returns a usable result, use it.
|
|
234
|
+
* 2. Otherwise fall back to the deterministic heuristic.
|
|
235
|
+
*
|
|
236
|
+
* Never throws. Always returns a `ContradictionResult`. When the
|
|
237
|
+
* result's `source` is `heuristic` and `contradictory=false`, the
|
|
238
|
+
* caller knows the check is inconclusive and should proceed with the
|
|
239
|
+
* default merge path.
|
|
240
|
+
*/
|
|
241
|
+
export async function checkContradiction(
|
|
242
|
+
llm: LLM,
|
|
243
|
+
a: string,
|
|
244
|
+
b: string,
|
|
245
|
+
context?: string
|
|
246
|
+
): Promise<ContradictionResult> {
|
|
247
|
+
const llmResult = await llmContradictionCheck(llm, a, b, context);
|
|
248
|
+
if (llmResult) return llmResult;
|
|
249
|
+
return heuristicContradictionCheck(a, b);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Apply the `CONTRADICTION_MIN_CONFIDENCE` threshold to a
|
|
254
|
+
* `ContradictionResult` — returns true iff the result claims a
|
|
255
|
+
* contradiction AND meets the confidence floor.
|
|
256
|
+
*
|
|
257
|
+
* Callers use this to decide whether to block a merge. Keeping the
|
|
258
|
+
* threshold check centralized means operators can tune via env var
|
|
259
|
+
* without touching the merge code.
|
|
260
|
+
*/
|
|
261
|
+
export function isActionableContradiction(result: ContradictionResult): boolean {
|
|
262
|
+
return (
|
|
263
|
+
result.contradictory === true &&
|
|
264
|
+
result.confidence >= CONTRADICTION_MIN_CONFIDENCE
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall Attribution — per-turn reference detection for recall tracking.
|
|
3
|
+
*
|
|
4
|
+
* Extracted into a standalone module for testability (per GPT 5.4 High review turn 4).
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* 1. Segment the transcript into ordered turns (user → assistant pairs)
|
|
8
|
+
* 2. Zip context_usage rows (by turn_index) with transcript turns (by position)
|
|
9
|
+
* 3. For each pair, detect references in that turn's assistant text only
|
|
10
|
+
* 4. Mark recall_events linked to the usage rows whose turn actually cited the doc
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Store, UsageRow } from "./store.ts";
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
export type TranscriptTurn = {
|
|
20
|
+
userText: string;
|
|
21
|
+
assistantText: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Transcript Segmentation
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Segment a flat message array into ordered turns.
|
|
30
|
+
* A turn starts on each "user" message and includes all following "assistant"
|
|
31
|
+
* messages until the next "user" message.
|
|
32
|
+
*
|
|
33
|
+
* @param messages - Ordered array of {role, content} from transcript JSONL
|
|
34
|
+
* @returns Ordered array of turns
|
|
35
|
+
*/
|
|
36
|
+
export function segmentTranscriptIntoTurns(
|
|
37
|
+
messages: { role: string; content: string }[]
|
|
38
|
+
): TranscriptTurn[] {
|
|
39
|
+
const turns: TranscriptTurn[] = [];
|
|
40
|
+
let currentUser = "";
|
|
41
|
+
let currentAssistant = "";
|
|
42
|
+
|
|
43
|
+
for (const msg of messages) {
|
|
44
|
+
if (msg.role === "user") {
|
|
45
|
+
// New turn: flush previous if it has assistant content
|
|
46
|
+
if (currentUser || currentAssistant) {
|
|
47
|
+
turns.push({ userText: currentUser, assistantText: currentAssistant });
|
|
48
|
+
}
|
|
49
|
+
currentUser = msg.content;
|
|
50
|
+
currentAssistant = "";
|
|
51
|
+
} else if (msg.role === "assistant") {
|
|
52
|
+
currentAssistant += (currentAssistant ? "\n" : "") + msg.content;
|
|
53
|
+
}
|
|
54
|
+
// Ignore system/tool messages for attribution purposes
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Flush final turn
|
|
58
|
+
if (currentUser || currentAssistant) {
|
|
59
|
+
turns.push({ userText: currentUser, assistantText: currentAssistant });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return turns;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Per-Turn Reference Detection
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if a displayPath (collection/path) is referenced in text.
|
|
71
|
+
* Matches by: full path, filename (without extension), or doc title.
|
|
72
|
+
*/
|
|
73
|
+
function isPathReferenced(
|
|
74
|
+
store: Store,
|
|
75
|
+
displayPath: string,
|
|
76
|
+
text: string
|
|
77
|
+
): boolean {
|
|
78
|
+
if (!text || !displayPath) return false;
|
|
79
|
+
|
|
80
|
+
// Full path match
|
|
81
|
+
if (text.includes(displayPath)) return true;
|
|
82
|
+
|
|
83
|
+
// Filename match (without extension, min 4 chars)
|
|
84
|
+
const filename = displayPath.split("/").pop()?.replace(/\.(md|txt)$/i, "");
|
|
85
|
+
if (filename && filename.length > 3 && text.toLowerCase().includes(filename.toLowerCase())) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Title match from DB
|
|
90
|
+
const parts = displayPath.split("/");
|
|
91
|
+
if (parts.length >= 2) {
|
|
92
|
+
const collection = parts[0]!;
|
|
93
|
+
const docPath = parts.slice(1).join("/");
|
|
94
|
+
const doc = store.findActiveDocument(collection, docPath);
|
|
95
|
+
if (doc?.title && doc.title.length >= 5 && text.toLowerCase().includes(doc.title.toLowerCase())) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// Attribution Core
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Attribute recall events to specific turns using per-turn reference detection.
|
|
109
|
+
*
|
|
110
|
+
* For each context_usage row (ordered by turn_index), finds the corresponding
|
|
111
|
+
* transcript turn and checks which of that turn's injected docs were cited in
|
|
112
|
+
* that turn's assistant text. Only marks recall_events linked to turns where
|
|
113
|
+
* the doc was actually referenced.
|
|
114
|
+
*
|
|
115
|
+
* @param store - Store instance for doc resolution and event marking
|
|
116
|
+
* @param sessionId - Session identifier
|
|
117
|
+
* @param usages - context_usage rows for this session, ordered by turn_index
|
|
118
|
+
* @param turns - Transcript turns, ordered by position
|
|
119
|
+
*/
|
|
120
|
+
export function attributeRecallReferences(
|
|
121
|
+
store: Store,
|
|
122
|
+
sessionId: string,
|
|
123
|
+
usages: UsageRow[],
|
|
124
|
+
turns: TranscriptTurn[]
|
|
125
|
+
): void {
|
|
126
|
+
// Filter to context-surfacing usages only
|
|
127
|
+
const surfacingUsages = usages.filter(u => u.hookName === "context-surfacing");
|
|
128
|
+
|
|
129
|
+
for (const usage of surfacingUsages) {
|
|
130
|
+
// Match usage to transcript turn by turn_index
|
|
131
|
+
const turn = turns[usage.turnIndex];
|
|
132
|
+
if (!turn || !turn.assistantText) continue;
|
|
133
|
+
|
|
134
|
+
// Parse injected paths for this turn
|
|
135
|
+
let injectedPaths: string[];
|
|
136
|
+
try { injectedPaths = JSON.parse(usage.injectedPaths) as string[]; }
|
|
137
|
+
catch { continue; }
|
|
138
|
+
if (injectedPaths.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
// Check which docs from THIS turn were referenced in THIS turn's assistant text
|
|
141
|
+
const referencedDocIds: number[] = [];
|
|
142
|
+
for (const path of injectedPaths) {
|
|
143
|
+
if (!isPathReferenced(store, path, turn.assistantText)) continue;
|
|
144
|
+
|
|
145
|
+
const parts = path.split("/");
|
|
146
|
+
if (parts.length < 2) continue;
|
|
147
|
+
const collection = parts[0]!;
|
|
148
|
+
const docPath = parts.slice(1).join("/");
|
|
149
|
+
const doc = store.findActiveDocument(collection, docPath);
|
|
150
|
+
if (doc) referencedDocIds.push(doc.id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (referencedDocIds.length === 0) continue;
|
|
154
|
+
|
|
155
|
+
// Mark only recall events linked to THIS usage row
|
|
156
|
+
for (const docId of referencedDocIds) {
|
|
157
|
+
// Primary: usage_id-linked events (current schema)
|
|
158
|
+
const linked = store.db.prepare(`
|
|
159
|
+
SELECT id FROM recall_events
|
|
160
|
+
WHERE usage_id = ? AND doc_id = ? AND was_referenced = 0
|
|
161
|
+
`).all(usage.id, docId) as { id: number }[];
|
|
162
|
+
|
|
163
|
+
if (linked.length > 0) {
|
|
164
|
+
const ids = linked.map(r => r.id);
|
|
165
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
166
|
+
store.db.prepare(`
|
|
167
|
+
UPDATE recall_events SET was_referenced = 1
|
|
168
|
+
WHERE id IN (${placeholders})
|
|
169
|
+
`).run(...ids);
|
|
170
|
+
} else {
|
|
171
|
+
// Fallback: pre-migration events without usage_id — match by turn_index
|
|
172
|
+
store.db.prepare(`
|
|
173
|
+
UPDATE recall_events SET was_referenced = 1
|
|
174
|
+
WHERE id IN (
|
|
175
|
+
SELECT id FROM recall_events
|
|
176
|
+
WHERE session_id = ? AND doc_id = ? AND turn_index = ? AND was_referenced = 0
|
|
177
|
+
)
|
|
178
|
+
`).run(sessionId, docId, usage.turnIndex);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall Tracking — direct-write recall event recording.
|
|
3
|
+
*
|
|
4
|
+
* Context-surfacing writes recall events directly to SQLite (single transaction,
|
|
5
|
+
* <0.4ms for ~12 rows). This replaces the original in-memory buffer design which
|
|
6
|
+
* failed in Claude Code mode where each hook is a separate process invocation.
|
|
7
|
+
*
|
|
8
|
+
* Per GPT 5.4 High review (Codex turn 1):
|
|
9
|
+
* - Direct INSERT is preferred over buffer for cross-process correctness
|
|
10
|
+
* - WAL mode handles concurrent writes safely (busy_timeout=5000ms)
|
|
11
|
+
* - Negative signals (surfaced but not referenced) marked retroactively by feedback-loop
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
import type { Store } from "./store.ts";
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Query Hashing
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hash a query string for recall tracking.
|
|
23
|
+
* SHA1 truncated to 12 hex chars (same as OpenClaw's approach).
|
|
24
|
+
*/
|
|
25
|
+
export function hashQuery(query: string): string {
|
|
26
|
+
return createHash("sha1")
|
|
27
|
+
.update(query.toLowerCase().trim())
|
|
28
|
+
.digest("hex")
|
|
29
|
+
.slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Direct Write (replaces in-memory buffer)
|
|
34
|
+
// =============================================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Record surfaced documents as recall events directly to SQLite.
|
|
38
|
+
* Called from context-surfacing hook — single transaction, ~0.4ms.
|
|
39
|
+
*
|
|
40
|
+
* Resolves displayPath → doc_id inline. Docs that can't be resolved
|
|
41
|
+
* (deleted between search and write) are silently skipped.
|
|
42
|
+
*
|
|
43
|
+
* @param store - Store instance with DB access
|
|
44
|
+
* @param sessionId - Current session identifier
|
|
45
|
+
* @param queryHash - SHA1 hash of the search query
|
|
46
|
+
* @param docs - Array of {displayPath, searchScore} for each surfaced result
|
|
47
|
+
* @returns Number of events recorded
|
|
48
|
+
*/
|
|
49
|
+
export function writeRecallEvents(
|
|
50
|
+
store: Store,
|
|
51
|
+
sessionId: string,
|
|
52
|
+
queryHash: string,
|
|
53
|
+
docs: { displayPath: string; searchScore: number }[],
|
|
54
|
+
usageId?: number,
|
|
55
|
+
turnIndex?: number
|
|
56
|
+
): number {
|
|
57
|
+
if (!sessionId || docs.length === 0) return 0;
|
|
58
|
+
|
|
59
|
+
const resolved: { docId: number; queryHash: string; searchScore: number; sessionId: string }[] = [];
|
|
60
|
+
|
|
61
|
+
for (const doc of docs) {
|
|
62
|
+
const parts = doc.displayPath.split("/");
|
|
63
|
+
if (parts.length < 2) continue;
|
|
64
|
+
const collection = parts[0]!;
|
|
65
|
+
const docPath = parts.slice(1).join("/");
|
|
66
|
+
const found = store.findActiveDocument(collection, docPath);
|
|
67
|
+
if (!found) {
|
|
68
|
+
console.debug?.(`[recall] skipping unresolvable displayPath: ${doc.displayPath}`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resolved.push({
|
|
73
|
+
docId: found.id,
|
|
74
|
+
queryHash,
|
|
75
|
+
searchScore: doc.searchScore,
|
|
76
|
+
sessionId,
|
|
77
|
+
usageId,
|
|
78
|
+
turnIndex,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (resolved.length === 0) return 0;
|
|
83
|
+
return store.insertRecallEvents(resolved);
|
|
84
|
+
}
|
|
85
|
+
|