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.
Files changed (50) hide show
  1. package/AGENTS.md +660 -0
  2. package/CLAUDE.md +660 -0
  3. package/LICENSE +21 -0
  4. package/README.md +993 -0
  5. package/SKILL.md +717 -0
  6. package/bin/clawmem +75 -0
  7. package/package.json +72 -0
  8. package/src/amem.ts +797 -0
  9. package/src/beads.ts +263 -0
  10. package/src/clawmem.ts +1849 -0
  11. package/src/collections.ts +405 -0
  12. package/src/config.ts +178 -0
  13. package/src/consolidation.ts +123 -0
  14. package/src/directory-context.ts +248 -0
  15. package/src/errors.ts +41 -0
  16. package/src/formatter.ts +427 -0
  17. package/src/graph-traversal.ts +247 -0
  18. package/src/hooks/context-surfacing.ts +317 -0
  19. package/src/hooks/curator-nudge.ts +89 -0
  20. package/src/hooks/decision-extractor.ts +639 -0
  21. package/src/hooks/feedback-loop.ts +214 -0
  22. package/src/hooks/handoff-generator.ts +345 -0
  23. package/src/hooks/postcompact-inject.ts +226 -0
  24. package/src/hooks/precompact-extract.ts +314 -0
  25. package/src/hooks/pretool-inject.ts +79 -0
  26. package/src/hooks/session-bootstrap.ts +324 -0
  27. package/src/hooks/staleness-check.ts +130 -0
  28. package/src/hooks.ts +367 -0
  29. package/src/indexer.ts +327 -0
  30. package/src/intent.ts +294 -0
  31. package/src/limits.ts +26 -0
  32. package/src/llm.ts +1175 -0
  33. package/src/mcp.ts +2138 -0
  34. package/src/memory.ts +336 -0
  35. package/src/mmr.ts +93 -0
  36. package/src/observer.ts +269 -0
  37. package/src/openclaw/engine.ts +283 -0
  38. package/src/openclaw/index.ts +221 -0
  39. package/src/openclaw/plugin.json +83 -0
  40. package/src/openclaw/shell.ts +207 -0
  41. package/src/openclaw/tools.ts +304 -0
  42. package/src/profile.ts +346 -0
  43. package/src/promptguard.ts +218 -0
  44. package/src/retrieval-gate.ts +106 -0
  45. package/src/search-utils.ts +127 -0
  46. package/src/server.ts +783 -0
  47. package/src/splitter.ts +325 -0
  48. package/src/store.ts +4062 -0
  49. package/src/validation.ts +67 -0
  50. 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
+ }