clawmem 0.1.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/AGENTS.md +660 -0
- package/CLAUDE.md +660 -0
- package/LICENSE +21 -0
- package/README.md +993 -0
- package/SKILL.md +717 -0
- package/bin/clawmem +75 -0
- package/package.json +72 -0
- package/src/amem.ts +797 -0
- package/src/beads.ts +263 -0
- package/src/clawmem.ts +1849 -0
- package/src/collections.ts +405 -0
- package/src/config.ts +178 -0
- package/src/consolidation.ts +123 -0
- package/src/directory-context.ts +248 -0
- package/src/errors.ts +41 -0
- package/src/formatter.ts +427 -0
- package/src/graph-traversal.ts +247 -0
- package/src/hooks/context-surfacing.ts +317 -0
- package/src/hooks/curator-nudge.ts +89 -0
- package/src/hooks/decision-extractor.ts +639 -0
- package/src/hooks/feedback-loop.ts +214 -0
- package/src/hooks/handoff-generator.ts +345 -0
- package/src/hooks/postcompact-inject.ts +226 -0
- package/src/hooks/precompact-extract.ts +314 -0
- package/src/hooks/pretool-inject.ts +79 -0
- package/src/hooks/session-bootstrap.ts +324 -0
- package/src/hooks/staleness-check.ts +130 -0
- package/src/hooks.ts +367 -0
- package/src/indexer.ts +327 -0
- package/src/intent.ts +294 -0
- package/src/limits.ts +26 -0
- package/src/llm.ts +1175 -0
- package/src/mcp.ts +2138 -0
- package/src/memory.ts +336 -0
- package/src/mmr.ts +93 -0
- package/src/observer.ts +269 -0
- package/src/openclaw/engine.ts +283 -0
- package/src/openclaw/index.ts +221 -0
- package/src/openclaw/plugin.json +83 -0
- package/src/openclaw/shell.ts +207 -0
- package/src/openclaw/tools.ts +304 -0
- package/src/profile.ts +346 -0
- package/src/promptguard.ts +218 -0
- package/src/retrieval-gate.ts +106 -0
- package/src/search-utils.ts +127 -0
- package/src/server.ts +783 -0
- package/src/splitter.ts +325 -0
- package/src/store.ts +4062 -0
- package/src/validation.ts +67 -0
- package/src/watcher.ts +58 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Extractor Hook - Stop
|
|
3
|
+
*
|
|
4
|
+
* Fires when a Claude Code session ends. Scans the transcript for
|
|
5
|
+
* decisions made during the conversation and persists them as
|
|
6
|
+
* decision documents in the _clawmem collection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
import type { Store } from "../store.ts";
|
|
12
|
+
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
13
|
+
import {
|
|
14
|
+
makeContextOutput,
|
|
15
|
+
makeEmptyOutput,
|
|
16
|
+
readTranscript,
|
|
17
|
+
validateTranscriptPath,
|
|
18
|
+
} from "../hooks.ts";
|
|
19
|
+
import { hashContent } from "../indexer.ts";
|
|
20
|
+
import { extractObservations, type Observation } from "../observer.ts";
|
|
21
|
+
import { updateDirectoryContext } from "../directory-context.ts";
|
|
22
|
+
import { loadConfig } from "../collections.ts";
|
|
23
|
+
import { getDefaultLlamaCpp } from "../llm.ts";
|
|
24
|
+
import type { ObservationWithDoc } from "../amem.ts";
|
|
25
|
+
import { extractJsonFromLLM } from "../amem.ts";
|
|
26
|
+
import { DEFAULT_EMBED_MODEL, extractSnippet, type SearchResult } from "../store.ts";
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// Facet-Based Merge Policy
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
export type MergePolicy = 'always_new' | 'merge_recent' | 'update_existing' | 'dedup_check';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Content-type-specific merge policy. Controls how new extracted content
|
|
36
|
+
* interacts with existing entries to prevent memory bloat.
|
|
37
|
+
*
|
|
38
|
+
* - always_new: Every entry is unique (handoffs, observations)
|
|
39
|
+
* - merge_recent: Merge with recent same-topic entry if within 7 days
|
|
40
|
+
* - update_existing: Overwrite older entry on same topic
|
|
41
|
+
* - dedup_check: Check embedding similarity before inserting
|
|
42
|
+
*/
|
|
43
|
+
export function getMergePolicy(contentType: string): MergePolicy {
|
|
44
|
+
switch (contentType) {
|
|
45
|
+
case 'decision': return 'dedup_check';
|
|
46
|
+
case 'antipattern': return 'merge_recent';
|
|
47
|
+
case 'preference': return 'update_existing';
|
|
48
|
+
case 'handoff': return 'always_new';
|
|
49
|
+
default: return 'always_new';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEDUP_SIMILARITY_THRESHOLD = 0.92;
|
|
54
|
+
const MERGE_RECENT_DAYS = 7;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if a new document should be merged/skipped based on merge policy.
|
|
58
|
+
* Returns the existing doc ID to merge with, or null to insert new.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkMergePolicy(
|
|
61
|
+
store: Store,
|
|
62
|
+
contentType: string,
|
|
63
|
+
body: string,
|
|
64
|
+
collection: string,
|
|
65
|
+
): Promise<{ action: 'insert' | 'skip' | 'merge'; existingId?: number }> {
|
|
66
|
+
const policy = getMergePolicy(contentType);
|
|
67
|
+
|
|
68
|
+
if (policy === 'always_new') return { action: 'insert' };
|
|
69
|
+
|
|
70
|
+
// Get recent entries of same content type
|
|
71
|
+
const recentDocs = store.getDocumentsByType(contentType, 5);
|
|
72
|
+
if (recentDocs.length === 0) return { action: 'insert' };
|
|
73
|
+
|
|
74
|
+
if (policy === 'dedup_check') {
|
|
75
|
+
// Vector similarity check against recent entries
|
|
76
|
+
try {
|
|
77
|
+
const results = await store.searchVec(body.slice(0, 500), DEFAULT_EMBED_MODEL, 3);
|
|
78
|
+
const sameType = results.filter(r =>
|
|
79
|
+
r.collectionName === collection &&
|
|
80
|
+
r.score >= DEDUP_SIMILARITY_THRESHOLD
|
|
81
|
+
);
|
|
82
|
+
if (sameType.length > 0) {
|
|
83
|
+
return { action: 'skip' };
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Vector search unavailable — fall through to insert
|
|
87
|
+
}
|
|
88
|
+
return { action: 'insert' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (policy === 'merge_recent') {
|
|
92
|
+
const cutoff = new Date();
|
|
93
|
+
cutoff.setDate(cutoff.getDate() - MERGE_RECENT_DAYS);
|
|
94
|
+
const recent = recentDocs.find(d =>
|
|
95
|
+
d.modifiedAt && new Date(d.modifiedAt) >= cutoff
|
|
96
|
+
);
|
|
97
|
+
if (recent) {
|
|
98
|
+
return { action: 'merge', existingId: recent.id };
|
|
99
|
+
}
|
|
100
|
+
return { action: 'insert' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (policy === 'update_existing') {
|
|
104
|
+
// Find most recent entry of same type
|
|
105
|
+
if (recentDocs.length > 0 && recentDocs[0]) {
|
|
106
|
+
return { action: 'merge', existingId: recentDocs[0].id };
|
|
107
|
+
}
|
|
108
|
+
return { action: 'insert' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { action: 'insert' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// =============================================================================
|
|
115
|
+
// Decision Patterns
|
|
116
|
+
// =============================================================================
|
|
117
|
+
|
|
118
|
+
export const DECISION_PATTERNS = [
|
|
119
|
+
/\b(?:we(?:'ll|'ve)?\s+)?decided?\s+(?:to|that|on)\b/i,
|
|
120
|
+
/\b(?:the\s+)?decision\s+(?:is|was)\s+to\b/i,
|
|
121
|
+
/\b(?:we(?:'re)?|i(?:'m)?)\s+going\s+(?:to|with)\b/i,
|
|
122
|
+
/\blet(?:'s)?\s+(?:go\s+with|use|stick\s+with)\b/i,
|
|
123
|
+
/\bchose\s+(?:to)?\b/i,
|
|
124
|
+
/\bwe\s+should\s+(?:use|go\s+with|implement)\b/i,
|
|
125
|
+
/\bthe\s+approach\s+(?:is|will\s+be)\b/i,
|
|
126
|
+
/\b(?:selected|picking|choosing)\s/i,
|
|
127
|
+
/\binstead\s+of\b.*\bwe(?:'ll)?\s/i,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// Antipattern / Failure Patterns
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
export const FAILURE_PATTERNS = [
|
|
135
|
+
/\b(?:this\s+)?(?:doesn't|didn't|won't)\s+work\b/i,
|
|
136
|
+
/\b(?:bug|error|issue|problem|failure)\s+(?:is|was|caused\s+by)\b/i,
|
|
137
|
+
/\b(?:reverted?|rolled?\s+back|undid|undo)\b/i,
|
|
138
|
+
/\b(?:wrong\s+approach|bad\s+idea|mistake)\b/i,
|
|
139
|
+
/\bdon't\s+(?:use|do|try)\b/i,
|
|
140
|
+
/\b(?:avoid|never|stop)\s+(?:using|doing)\b/i,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract antipatterns (failures, mistakes, things to avoid) from transcript messages.
|
|
145
|
+
* Same extraction structure as extractDecisions but with failure-oriented patterns.
|
|
146
|
+
*/
|
|
147
|
+
export function extractAntipatterns(
|
|
148
|
+
messages: { role: string; content: string }[]
|
|
149
|
+
): { text: string; context: string }[] {
|
|
150
|
+
const antipatterns: { text: string; context: string }[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (const msg of messages) {
|
|
154
|
+
if (msg.role !== "assistant") continue;
|
|
155
|
+
const sentences = msg.content.split(/[.!]\s+/);
|
|
156
|
+
|
|
157
|
+
for (const sentence of sentences) {
|
|
158
|
+
if (sentence.length < 15 || sentence.length > 500) continue;
|
|
159
|
+
|
|
160
|
+
for (const pattern of FAILURE_PATTERNS) {
|
|
161
|
+
if (pattern.test(sentence)) {
|
|
162
|
+
const key = sentence.slice(0, 80).toLowerCase();
|
|
163
|
+
if (!seen.has(key)) {
|
|
164
|
+
seen.add(key);
|
|
165
|
+
// Get surrounding context (previous sentence)
|
|
166
|
+
const idx = sentences.indexOf(sentence);
|
|
167
|
+
const context = idx > 0 ? sentences[idx - 1]!.trim() : "";
|
|
168
|
+
antipatterns.push({ text: sentence.trim(), context });
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return antipatterns.slice(0, 10);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// =============================================================================
|
|
180
|
+
// Contradiction Detection
|
|
181
|
+
// =============================================================================
|
|
182
|
+
|
|
183
|
+
async function detectContradictions(
|
|
184
|
+
store: Store,
|
|
185
|
+
newObservations: Observation[],
|
|
186
|
+
sessionId: string
|
|
187
|
+
): Promise<number> {
|
|
188
|
+
const decisions = newObservations.filter(o => o.type === "decision");
|
|
189
|
+
if (decisions.length === 0) return 0;
|
|
190
|
+
|
|
191
|
+
let contradictionCount = 0;
|
|
192
|
+
const llm = await getDefaultLlamaCpp();
|
|
193
|
+
if (!llm) return 0;
|
|
194
|
+
|
|
195
|
+
// Batch all new decision facts
|
|
196
|
+
const newFacts = decisions.flatMap(d => d.facts);
|
|
197
|
+
if (newFacts.length === 0) return 0;
|
|
198
|
+
|
|
199
|
+
// Vector search for existing decisions on overlapping topics
|
|
200
|
+
const queryText = newFacts.join(". ");
|
|
201
|
+
let existingDocs: SearchResult[];
|
|
202
|
+
try {
|
|
203
|
+
existingDocs = await store.searchVec(queryText, DEFAULT_EMBED_MODEL, 5);
|
|
204
|
+
} catch {
|
|
205
|
+
existingDocs = store.searchFTS(queryText, 5);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Filter to decision/observation docs, exclude same session
|
|
209
|
+
const sessionPrefix = sessionId.slice(0, 8);
|
|
210
|
+
const candidates = existingDocs.filter(d =>
|
|
211
|
+
(d.displayPath.includes("decisions/") || d.displayPath.includes("observations/")) &&
|
|
212
|
+
!d.displayPath.includes(sessionPrefix)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (candidates.length === 0) return 0;
|
|
216
|
+
|
|
217
|
+
// Build classification prompt
|
|
218
|
+
const existingFacts = candidates
|
|
219
|
+
.map((c, i) => `[OLD-${i}] ${c.displayPath}\n${extractSnippet(c.body || "", queryText, 300).snippet}`)
|
|
220
|
+
.join("\n\n");
|
|
221
|
+
|
|
222
|
+
const prompt = `You are analyzing decisions for contradictions.
|
|
223
|
+
|
|
224
|
+
NEW DECISIONS (this session):
|
|
225
|
+
${newFacts.map((f, i) => `[NEW-${i}] ${f}`).join("\n")}
|
|
226
|
+
|
|
227
|
+
EXISTING DECISIONS (prior sessions):
|
|
228
|
+
${existingFacts}
|
|
229
|
+
|
|
230
|
+
For each NEW decision, check against EXISTING decisions. Classify each relationship:
|
|
231
|
+
- "same": Identical decision, no action needed
|
|
232
|
+
- "update": New decision supersedes/refines old one
|
|
233
|
+
- "contradiction": New decision directly conflicts with old one
|
|
234
|
+
|
|
235
|
+
Return JSON array:
|
|
236
|
+
[{"new_idx": 0, "old_idx": 0, "relation": "update|contradiction|same", "confidence": 0.0-1.0, "reasoning": "..."}]
|
|
237
|
+
|
|
238
|
+
Only include pairs with confidence >= 0.7. Return [] if no relationships found. /no_think`;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const result = await llm.generate(prompt, { temperature: 0.3, maxTokens: 400 });
|
|
242
|
+
if (!result) return 0;
|
|
243
|
+
const parsed = extractJsonFromLLM(result.text);
|
|
244
|
+
if (!Array.isArray(parsed)) return 0;
|
|
245
|
+
|
|
246
|
+
for (const rel of parsed) {
|
|
247
|
+
if (rel.confidence < 0.7) continue;
|
|
248
|
+
const oldDoc = candidates[rel.old_idx];
|
|
249
|
+
if (!oldDoc) continue;
|
|
250
|
+
|
|
251
|
+
const existingDoc = store.findActiveDocument(oldDoc.collectionName, oldDoc.filepath);
|
|
252
|
+
if (!existingDoc) continue;
|
|
253
|
+
|
|
254
|
+
if (rel.relation === "contradiction") {
|
|
255
|
+
// Lower old doc confidence by 0.25 (floor 0.2)
|
|
256
|
+
const currentConfidence = existingDoc.confidence ?? 0.5;
|
|
257
|
+
store.updateDocumentMeta(existingDoc.id, {
|
|
258
|
+
confidence: Math.max(0.2, currentConfidence - 0.25),
|
|
259
|
+
});
|
|
260
|
+
contradictionCount++;
|
|
261
|
+
console.error(
|
|
262
|
+
`[decision-extractor] CONTRADICTION: "${newFacts[rel.new_idx]}" vs "${oldDoc.displayPath}" (conf: ${rel.confidence})`
|
|
263
|
+
);
|
|
264
|
+
} else if (rel.relation === "update") {
|
|
265
|
+
// Lower old doc confidence by 0.15 (floor 0.3)
|
|
266
|
+
const currentConfidence = existingDoc.confidence ?? 0.5;
|
|
267
|
+
store.updateDocumentMeta(existingDoc.id, {
|
|
268
|
+
confidence: Math.max(0.3, currentConfidence - 0.15),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error(`[decision-extractor] Contradiction classification failed:`, err);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return contradictionCount;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// =============================================================================
|
|
280
|
+
// Handler
|
|
281
|
+
// =============================================================================
|
|
282
|
+
|
|
283
|
+
export async function decisionExtractor(
|
|
284
|
+
store: Store,
|
|
285
|
+
input: HookInput
|
|
286
|
+
): Promise<HookOutput> {
|
|
287
|
+
const transcriptPath = validateTranscriptPath(input.transcriptPath);
|
|
288
|
+
if (!transcriptPath) return makeEmptyOutput("decision-extractor");
|
|
289
|
+
|
|
290
|
+
const messages = readTranscript(transcriptPath, 200);
|
|
291
|
+
if (messages.length === 0) return makeEmptyOutput("decision-extractor");
|
|
292
|
+
|
|
293
|
+
const sessionId = input.sessionId || `session-${Date.now()}`;
|
|
294
|
+
const now = new Date();
|
|
295
|
+
const dateStr = now.toISOString().slice(0, 10);
|
|
296
|
+
const timestamp = now.toISOString();
|
|
297
|
+
|
|
298
|
+
// Try observer first for structured observations
|
|
299
|
+
const observations = await extractObservations(messages);
|
|
300
|
+
const observedDecisions = observations.filter(o => o.type === "decision");
|
|
301
|
+
|
|
302
|
+
// Persist ALL observations unconditionally (C2 fix: not gated on decisions existing)
|
|
303
|
+
const observationsWithDocs: ObservationWithDoc[] = [];
|
|
304
|
+
if (observations.length > 0) {
|
|
305
|
+
for (const obs of observations) {
|
|
306
|
+
const obsPath = `observations/${dateStr}-${sessionId.slice(0, 8)}-${obs.type}.md`;
|
|
307
|
+
const obsBody = formatObservation(obs, dateStr, sessionId);
|
|
308
|
+
const obsHash = hashContent(obsBody);
|
|
309
|
+
|
|
310
|
+
store.insertContent(obsHash, obsBody, timestamp);
|
|
311
|
+
try {
|
|
312
|
+
store.insertDocument("_clawmem", obsPath, obs.title, obsHash, timestamp, timestamp);
|
|
313
|
+
const doc = store.findActiveDocument("_clawmem", obsPath);
|
|
314
|
+
if (doc) {
|
|
315
|
+
store.updateDocumentMeta(doc.id, {
|
|
316
|
+
content_type: obs.type === "decision" ? "decision" : "note",
|
|
317
|
+
confidence: 0.80,
|
|
318
|
+
});
|
|
319
|
+
store.updateObservationFields(obsPath, "_clawmem", {
|
|
320
|
+
observation_type: obs.type,
|
|
321
|
+
facts: JSON.stringify(obs.facts),
|
|
322
|
+
narrative: obs.narrative,
|
|
323
|
+
concepts: JSON.stringify(obs.concepts),
|
|
324
|
+
files_read: JSON.stringify(obs.filesRead),
|
|
325
|
+
files_modified: JSON.stringify(obs.filesModified),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (obs.facts.length > 0) {
|
|
329
|
+
observationsWithDocs.push({
|
|
330
|
+
docId: doc.id,
|
|
331
|
+
facts: obs.facts,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
// May already exist
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Infer causal links from observations with facts
|
|
341
|
+
if (observationsWithDocs.length > 0) {
|
|
342
|
+
try {
|
|
343
|
+
const llm = await getDefaultLlamaCpp();
|
|
344
|
+
if (llm) {
|
|
345
|
+
await store.inferCausalLinks(llm, observationsWithDocs);
|
|
346
|
+
}
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.log(`[decision-extractor] Error in causal inference:`, err);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Extract decisions (observer-first, regex fallback)
|
|
354
|
+
let decisionBody: string;
|
|
355
|
+
let decisionCount: number;
|
|
356
|
+
let decisionFacts: string = ""; // Stable semantic payload for dedup hashing
|
|
357
|
+
|
|
358
|
+
if (observedDecisions.length > 0) {
|
|
359
|
+
decisionBody = formatObservedDecisions(observedDecisions, dateStr, sessionId);
|
|
360
|
+
decisionCount = observedDecisions.length;
|
|
361
|
+
decisionFacts = observedDecisions.map(d => [d.title, ...d.facts].join(". ")).join("\n");
|
|
362
|
+
|
|
363
|
+
// Detect contradictions with existing decisions
|
|
364
|
+
try {
|
|
365
|
+
const contradictions = await detectContradictions(store, observedDecisions, sessionId);
|
|
366
|
+
if (contradictions > 0) {
|
|
367
|
+
console.error(`[decision-extractor] Found ${contradictions} contradiction(s) with prior decisions`);
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
console.error(`[decision-extractor] Error in contradiction detection:`, err);
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
// Fallback to regex extraction
|
|
374
|
+
const decisions = extractDecisions(messages);
|
|
375
|
+
if (decisions.length === 0 && observations.length === 0) return makeEmptyOutput("decision-extractor");
|
|
376
|
+
|
|
377
|
+
if (decisions.length === 0) {
|
|
378
|
+
decisionBody = `# Session Observations ${dateStr}\n\nNo decisions extracted. ${observations.length} observation(s) persisted separately.\n`;
|
|
379
|
+
decisionCount = 0;
|
|
380
|
+
} else {
|
|
381
|
+
decisionBody = formatDecisionLog(decisions, dateStr, sessionId);
|
|
382
|
+
decisionCount = decisions.length;
|
|
383
|
+
decisionFacts = decisions.map(d => d.text).join("\n");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Save decision via unified saveMemory API (handles dedup + upsert)
|
|
388
|
+
const semanticPayload = decisionFacts || decisionBody;
|
|
389
|
+
|
|
390
|
+
const decisionPath = `decisions/${dateStr}-${sessionId.slice(0, 8)}.md`;
|
|
391
|
+
|
|
392
|
+
// Check existing merge policy first (vector-based dedup for decisions)
|
|
393
|
+
const mergeResult = await checkMergePolicy(store, "decision", decisionBody, "_clawmem");
|
|
394
|
+
|
|
395
|
+
if (mergeResult.action === 'skip') {
|
|
396
|
+
process.stderr.write(`[decision-extractor] Skipped near-duplicate decision (vector dedup)\n`);
|
|
397
|
+
} else if (mergeResult.action === 'merge' && mergeResult.existingId) {
|
|
398
|
+
// Merge with existing entry (update content)
|
|
399
|
+
const mergeHash = hashContent(decisionBody);
|
|
400
|
+
store.insertContent(mergeHash, decisionBody, timestamp);
|
|
401
|
+
store.db.prepare(
|
|
402
|
+
"UPDATE documents SET hash = ?, modified_at = ?, revision_count = revision_count + 1, last_seen_at = ? WHERE id = ?"
|
|
403
|
+
).run(mergeHash, timestamp, timestamp, mergeResult.existingId);
|
|
404
|
+
} else {
|
|
405
|
+
// Use saveMemory for dedup-protected insert
|
|
406
|
+
const result = store.saveMemory({
|
|
407
|
+
collection: "_clawmem",
|
|
408
|
+
path: decisionPath,
|
|
409
|
+
title: `Decisions ${dateStr}`,
|
|
410
|
+
body: decisionBody,
|
|
411
|
+
contentType: "decision",
|
|
412
|
+
confidence: observedDecisions.length > 0 ? 0.90 : 0.85,
|
|
413
|
+
semanticPayload,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
if (result.action === 'deduplicated') {
|
|
417
|
+
process.stderr.write(`[decision-extractor] Dedup: existing decision within window (doc ${result.docId}, count=${result.duplicateCount})\n`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Extract and store antipatterns (E8) via saveMemory
|
|
422
|
+
try {
|
|
423
|
+
const antipatterns = extractAntipatterns(messages);
|
|
424
|
+
if (antipatterns.length > 0) {
|
|
425
|
+
const antiBody = [
|
|
426
|
+
`# Antipatterns ${dateStr}`,
|
|
427
|
+
``,
|
|
428
|
+
`_Session: ${sessionId.slice(0, 8)}_`,
|
|
429
|
+
``,
|
|
430
|
+
...antipatterns.map(a => {
|
|
431
|
+
const ctx = a.context ? `\n > Context: ${a.context.slice(0, 150)}` : "";
|
|
432
|
+
return `- **Avoid:** ${a.text}${ctx}`;
|
|
433
|
+
}),
|
|
434
|
+
].join("\n");
|
|
435
|
+
|
|
436
|
+
// Semantic payload: the antipattern texts only (stable across date wrappers)
|
|
437
|
+
const antiSemanticPayload = antipatterns.map(a => a.text).join("\n");
|
|
438
|
+
const antiPath = `antipatterns/${dateStr}-${sessionId.slice(0, 8)}.md`;
|
|
439
|
+
|
|
440
|
+
// Check existing merge policy first (merge_recent for antipatterns)
|
|
441
|
+
const antiMerge = await checkMergePolicy(store, "antipattern", antiBody, "_clawmem");
|
|
442
|
+
|
|
443
|
+
if (antiMerge.action === 'skip') {
|
|
444
|
+
// Near-duplicate — skip
|
|
445
|
+
} else if (antiMerge.action === 'merge' && antiMerge.existingId) {
|
|
446
|
+
const antiHash = hashContent(antiBody);
|
|
447
|
+
store.insertContent(antiHash, antiBody, timestamp);
|
|
448
|
+
store.db.prepare(
|
|
449
|
+
"UPDATE documents SET hash = ?, modified_at = ?, revision_count = revision_count + 1, last_seen_at = ? WHERE id = ?"
|
|
450
|
+
).run(antiHash, timestamp, timestamp, antiMerge.existingId);
|
|
451
|
+
} else {
|
|
452
|
+
const result = store.saveMemory({
|
|
453
|
+
collection: "_clawmem",
|
|
454
|
+
path: antiPath,
|
|
455
|
+
title: `Antipatterns ${dateStr}`,
|
|
456
|
+
body: antiBody,
|
|
457
|
+
contentType: "antipattern",
|
|
458
|
+
confidence: 0.75,
|
|
459
|
+
semanticPayload: antiSemanticPayload,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (result.action === 'deduplicated') {
|
|
463
|
+
process.stderr.write(`[decision-extractor] Dedup: antipattern within window (doc ${result.docId})\n`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
// Non-fatal
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Trigger directory context update if enabled and observer found files
|
|
472
|
+
const config = loadConfig();
|
|
473
|
+
if (config.directoryContext) {
|
|
474
|
+
const allModifiedFiles = observations.flatMap(o => o.filesModified);
|
|
475
|
+
if (allModifiedFiles.length > 0) {
|
|
476
|
+
try {
|
|
477
|
+
updateDirectoryContext(store, allModifiedFiles);
|
|
478
|
+
} catch { /* non-fatal */ }
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return makeEmptyOutput("decision-extractor");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// =============================================================================
|
|
486
|
+
// Extraction
|
|
487
|
+
// =============================================================================
|
|
488
|
+
|
|
489
|
+
export type Decision = {
|
|
490
|
+
text: string;
|
|
491
|
+
context: string;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
export function extractDecisions(messages: { role: string; content: string }[]): Decision[] {
|
|
495
|
+
const decisions: Decision[] = [];
|
|
496
|
+
const seen = new Set<string>();
|
|
497
|
+
|
|
498
|
+
for (let i = 0; i < messages.length; i++) {
|
|
499
|
+
const msg = messages[i]!;
|
|
500
|
+
if (msg.role !== "assistant") continue;
|
|
501
|
+
|
|
502
|
+
const sentences = msg.content.split(/(?<=[.!?])\s+/);
|
|
503
|
+
|
|
504
|
+
for (const sentence of sentences) {
|
|
505
|
+
if (sentence.length < 20 || sentence.length > 500) continue;
|
|
506
|
+
|
|
507
|
+
const isDecision = DECISION_PATTERNS.some(p => p.test(sentence));
|
|
508
|
+
if (!isDecision) continue;
|
|
509
|
+
|
|
510
|
+
// Deduplicate by first 80 chars
|
|
511
|
+
const key = sentence.slice(0, 80).toLowerCase();
|
|
512
|
+
if (seen.has(key)) continue;
|
|
513
|
+
seen.add(key);
|
|
514
|
+
|
|
515
|
+
// Get preceding user message as context
|
|
516
|
+
let context = "";
|
|
517
|
+
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
|
|
518
|
+
if (messages[j]!.role === "user") {
|
|
519
|
+
context = messages[j]!.content.slice(0, 200);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
decisions.push({ text: sentence.trim(), context });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return decisions;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// =============================================================================
|
|
532
|
+
// Formatting
|
|
533
|
+
// =============================================================================
|
|
534
|
+
|
|
535
|
+
function formatDecisionLog(decisions: Decision[], dateStr: string, sessionId: string): string {
|
|
536
|
+
const lines = [
|
|
537
|
+
`---`,
|
|
538
|
+
`content_type: decision`,
|
|
539
|
+
`tags: [auto-extracted]`,
|
|
540
|
+
`---`,
|
|
541
|
+
``,
|
|
542
|
+
`# Decisions — ${dateStr}`,
|
|
543
|
+
``,
|
|
544
|
+
`Session: \`${sessionId.slice(0, 8)}\``,
|
|
545
|
+
``,
|
|
546
|
+
];
|
|
547
|
+
|
|
548
|
+
for (const d of decisions) {
|
|
549
|
+
lines.push(`- ${d.text}`);
|
|
550
|
+
if (d.context) {
|
|
551
|
+
lines.push(` > Context: ${d.context.split("\n")[0]}`);
|
|
552
|
+
}
|
|
553
|
+
lines.push("");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return lines.join("\n");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function formatObservedDecisions(observations: Observation[], dateStr: string, sessionId: string): string {
|
|
560
|
+
const lines = [
|
|
561
|
+
`---`,
|
|
562
|
+
`content_type: decision`,
|
|
563
|
+
`tags: [auto-extracted, observer]`,
|
|
564
|
+
`---`,
|
|
565
|
+
``,
|
|
566
|
+
`# Decisions — ${dateStr}`,
|
|
567
|
+
``,
|
|
568
|
+
`Session: \`${sessionId.slice(0, 8)}\``,
|
|
569
|
+
``,
|
|
570
|
+
];
|
|
571
|
+
|
|
572
|
+
for (const obs of observations) {
|
|
573
|
+
lines.push(`## ${obs.title}`, ``);
|
|
574
|
+
if (obs.narrative) {
|
|
575
|
+
lines.push(obs.narrative, ``);
|
|
576
|
+
}
|
|
577
|
+
if (obs.facts.length > 0) {
|
|
578
|
+
lines.push(`**Facts:**`);
|
|
579
|
+
for (const fact of obs.facts) {
|
|
580
|
+
lines.push(`- ${fact}`);
|
|
581
|
+
}
|
|
582
|
+
lines.push(``);
|
|
583
|
+
}
|
|
584
|
+
if (obs.filesModified.length > 0) {
|
|
585
|
+
lines.push(`**Files:** ${obs.filesModified.map(f => `\`${f}\``).join(", ")}`, ``);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return lines.join("\n");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function formatObservation(obs: Observation, dateStr: string, sessionId: string): string {
|
|
593
|
+
const lines = [
|
|
594
|
+
`---`,
|
|
595
|
+
`content_type: ${obs.type === "decision" ? "decision" : "note"}`,
|
|
596
|
+
`tags: [auto-extracted, observer, ${obs.type}]`,
|
|
597
|
+
`---`,
|
|
598
|
+
``,
|
|
599
|
+
`# ${obs.title}`,
|
|
600
|
+
``,
|
|
601
|
+
`Session: \`${sessionId.slice(0, 8)}\` | Date: ${dateStr} | Type: ${obs.type}`,
|
|
602
|
+
``,
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
if (obs.narrative) {
|
|
606
|
+
lines.push(obs.narrative, ``);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (obs.facts.length > 0) {
|
|
610
|
+
lines.push(`## Facts`, ``);
|
|
611
|
+
for (const fact of obs.facts) {
|
|
612
|
+
lines.push(`- ${fact}`);
|
|
613
|
+
}
|
|
614
|
+
lines.push(``);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (obs.concepts.length > 0) {
|
|
618
|
+
lines.push(`## Concepts`, ``);
|
|
619
|
+
lines.push(obs.concepts.join(", "), ``);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (obs.filesRead.length > 0) {
|
|
623
|
+
lines.push(`## Files Read`, ``);
|
|
624
|
+
for (const f of obs.filesRead) {
|
|
625
|
+
lines.push(`- \`${f}\``);
|
|
626
|
+
}
|
|
627
|
+
lines.push(``);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (obs.filesModified.length > 0) {
|
|
631
|
+
lines.push(`## Files Modified`, ``);
|
|
632
|
+
for (const f of obs.filesModified) {
|
|
633
|
+
lines.push(`- \`${f}\``);
|
|
634
|
+
}
|
|
635
|
+
lines.push(``);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return lines.join("\n");
|
|
639
|
+
}
|