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.
@@ -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
+