clawmem 0.5.0 → 0.6.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.
@@ -1,17 +1,21 @@
1
1
  /**
2
2
  * ClawMem Consolidation Worker
3
3
  *
4
- * Two-phase background worker:
4
+ * Three-phase background worker:
5
5
  * 1. A-MEM backfill: enriches documents missing memory notes
6
6
  * 2. 3-tier consolidation: synthesizes clusters of related observations
7
7
  * into higher-order consolidated observations with proof counts and trends
8
+ * 3. Deductive synthesis: combines related recent observations into
9
+ * first-class deductive documents with source provenance
8
10
  *
9
11
  * Pattern H from ENHANCEMENT-PLAN.md (source: Hindsight consolidator.py)
12
+ * Deductive synthesis inspired by Honcho's Dreamer deduction specialist.
10
13
  */
11
14
 
12
15
  import type { Store } from "./store.ts";
13
16
  import type { LlamaCpp } from "./llm.ts";
14
17
  import { extractJsonFromLLM } from "./amem.ts";
18
+ import { hashContent } from "./indexer.ts";
15
19
 
16
20
  // =============================================================================
17
21
  // Types
@@ -115,6 +119,11 @@ async function tick(store: Store, llm: LlamaCpp): Promise<void> {
115
119
  if (tickCount % 6 === 0) {
116
120
  await consolidateObservations(store, llm);
117
121
  }
122
+
123
+ // Phase 3: Deductive synthesis (every 3rd tick, ~15 min at default interval)
124
+ if (tickCount % 3 === 0) {
125
+ await generateDeductiveObservations(store, llm);
126
+ }
118
127
  } catch (err) {
119
128
  console.error("[consolidation] Tick failed:", err);
120
129
  } finally {
@@ -375,6 +384,308 @@ function updateTrends(store: Store): void {
375
384
  }
376
385
  }
377
386
 
387
+ // =============================================================================
388
+ // Phase 3: Deductive Observation Synthesis
389
+ // =============================================================================
390
+
391
+ /**
392
+ * Find pairs/groups of recent high-confidence observations that can be combined
393
+ * into higher-level deductive conclusions. Creates first-class documents with
394
+ * content_type='deductive' and source_doc_ids provenance.
395
+ *
396
+ * Only considers decision/preference/milestone/problem observations from the
397
+ * last 7 days that haven't already been used as sources for deductions.
398
+ */
399
+ async function generateDeductiveObservations(store: Store, llm: LlamaCpp): Promise<number> {
400
+ // Find recent high-value observations not yet used in deductions
401
+ const DEDUCTIVE_TYPES = ['decision', 'preference', 'milestone', 'problem'];
402
+ const recentObs = store.db.prepare(`
403
+ SELECT d.id, d.title, d.facts, d.narrative, d.observation_type, d.content_type,
404
+ d.collection, d.path, d.modified_at
405
+ FROM documents d
406
+ WHERE d.active = 1
407
+ AND d.content_type IN (${DEDUCTIVE_TYPES.map(() => '?').join(',')})
408
+ AND d.observation_type IS NOT NULL
409
+ AND d.facts IS NOT NULL
410
+ AND d.modified_at >= datetime('now', '-7 days')
411
+ AND d.id NOT IN (
412
+ SELECT value FROM (
413
+ SELECT json_each.value as value
414
+ FROM documents dd, json_each(dd.source_doc_ids)
415
+ WHERE dd.content_type = 'deductive' AND dd.active = 1
416
+ )
417
+ )
418
+ ORDER BY d.modified_at DESC
419
+ LIMIT 20
420
+ `).all(...DEDUCTIVE_TYPES) as {
421
+ id: number; title: string; facts: string; narrative: string;
422
+ observation_type: string; content_type: string; collection: string;
423
+ path: string; modified_at: string;
424
+ }[];
425
+
426
+ if (recentObs.length < 2) return 0;
427
+
428
+ // Build context for LLM
429
+ const obsText = recentObs.map((o, i) =>
430
+ `[${i + 1}] (${o.content_type}/${o.observation_type}) "${o.title}"\n Facts: ${(o.facts || '').slice(0, 300)}\n Narrative: ${(o.narrative || '').slice(0, 200)}`
431
+ ).join('\n\n');
432
+
433
+ const prompt = `You are analyzing recent observations from a developer's work sessions. Find logical deductions that can be drawn by combining 2-3 observations.
434
+
435
+ A deduction combines facts from different observations into a NEW conclusion that isn't stated in any single observation alone.
436
+
437
+ Observations:
438
+ ${obsText}
439
+
440
+ For each valid deduction:
441
+ 1. State the conclusion clearly (1-2 sentences)
442
+ 2. List the premises (which observations support it)
443
+ 3. List the source indices (1-indexed)
444
+
445
+ Return ONLY valid JSON array:
446
+ [
447
+ {
448
+ "conclusion": "Clear deductive statement",
449
+ "premises": ["Premise from obs 1", "Premise from obs 3"],
450
+ "source_indices": [1, 3]
451
+ }
452
+ ]
453
+
454
+ Rules:
455
+ - Each deduction MUST combine 2+ different observations (not restate a single one)
456
+ - Only include conclusions with genuine logical basis
457
+ - Maximum 3 deductions
458
+ - If no valid deductions exist, return []
459
+ Return ONLY the JSON array. /no_think`;
460
+
461
+ const result = await llm.generate(prompt, { temperature: 0.3, maxTokens: 500 });
462
+ if (!result?.text) return 0;
463
+
464
+ const parsed = extractJsonFromLLM(result.text) as Array<{
465
+ conclusion: string;
466
+ premises: string[];
467
+ source_indices: number[];
468
+ }> | null;
469
+
470
+ if (!Array.isArray(parsed)) return 0;
471
+
472
+ let created = 0;
473
+ const timestamp = new Date().toISOString();
474
+ const dateStr = timestamp.slice(0, 10);
475
+
476
+ for (const deduction of parsed) {
477
+ if (!deduction.conclusion || !Array.isArray(deduction.source_indices) || deduction.source_indices.length < 2) continue;
478
+
479
+ const sourceDocIds = deduction.source_indices
480
+ .filter(i => i >= 1 && i <= recentObs.length)
481
+ .map(i => recentObs[i - 1]!.id);
482
+
483
+ if (sourceDocIds.length < 2) continue;
484
+
485
+ // Check for duplicate deduction (Jaccard on conclusion text)
486
+ const existingDedups = store.db.prepare(`
487
+ SELECT id, title FROM documents
488
+ WHERE content_type = 'deductive' AND active = 1
489
+ ORDER BY created_at DESC LIMIT 20
490
+ `).all() as { id: number; title: string }[];
491
+
492
+ const conclusionWords = new Set(deduction.conclusion.toLowerCase().split(/\s+/).filter(w => w.length > 3));
493
+ const isDuplicate = existingDedups.some(d => {
494
+ const titleWords = new Set(d.title.toLowerCase().split(/\s+/).filter(w => w.length > 3));
495
+ const intersection = [...conclusionWords].filter(w => titleWords.has(w)).length;
496
+ const union = new Set([...conclusionWords, ...titleWords]).size;
497
+ return union > 0 && intersection / union > 0.5;
498
+ });
499
+
500
+ if (isDuplicate) continue;
501
+
502
+ // Build the deductive document
503
+ const premisesText = (deduction.premises || []).map(p => `- ${p}`).join('\n');
504
+ const sourceRefs = sourceDocIds.map(id => {
505
+ const obs = recentObs.find(o => o.id === id);
506
+ return obs ? `- "${obs.title}" (${obs.content_type})` : `- doc#${id}`;
507
+ }).join('\n');
508
+
509
+ const body = [
510
+ `---`,
511
+ `content_type: deductive`,
512
+ `tags: [auto-deduced, consolidation]`,
513
+ `---`,
514
+ ``,
515
+ `# ${deduction.conclusion.slice(0, 80)}`,
516
+ ``,
517
+ deduction.conclusion,
518
+ ``,
519
+ `## Premises`,
520
+ ``,
521
+ premisesText,
522
+ ``,
523
+ `## Sources`,
524
+ ``,
525
+ sourceRefs,
526
+ ``,
527
+ ].join('\n');
528
+
529
+ const dedPath = `deductions/${dateStr}-${sourceDocIds.join('-')}.md`;
530
+ const hash = hashContent(body);
531
+
532
+ try {
533
+ store.insertContent(hash, body, timestamp);
534
+ store.insertDocument("_clawmem", dedPath, deduction.conclusion.slice(0, 80), hash, timestamp, timestamp);
535
+
536
+ const doc = store.findActiveDocument("_clawmem", dedPath);
537
+ if (doc) {
538
+ store.updateDocumentMeta(doc.id, {
539
+ content_type: "deductive",
540
+ confidence: 0.85,
541
+ });
542
+ store.updateObservationFields(dedPath, "_clawmem", {
543
+ observation_type: "deductive",
544
+ facts: JSON.stringify(deduction.premises || []),
545
+ narrative: deduction.conclusion,
546
+ });
547
+ // Store source provenance
548
+ store.db.prepare(`UPDATE documents SET source_doc_ids = ? WHERE id = ?`)
549
+ .run(JSON.stringify(sourceDocIds), doc.id);
550
+
551
+ // Create supporting edges in memory_relations
552
+ for (const sourceId of sourceDocIds) {
553
+ try {
554
+ store.db.prepare(`
555
+ INSERT OR IGNORE INTO memory_relations (source_id, target_id, relation_type, weight, created_at)
556
+ VALUES (?, ?, 'supporting', 0.85, datetime('now'))
557
+ `).run(sourceId, doc.id);
558
+ } catch { /* non-fatal */ }
559
+ }
560
+
561
+ created++;
562
+ console.log(`[deductive] Created: "${deduction.conclusion.slice(0, 60)}..." from ${sourceDocIds.length} sources`);
563
+ }
564
+ } catch (err) {
565
+ console.error(`[deductive] Failed to create deduction:`, err);
566
+ }
567
+ }
568
+
569
+ return created;
570
+ }
571
+
572
+ /**
573
+ * Manually trigger deductive synthesis (for CLI or MCP tool).
574
+ */
575
+ export async function runDeductiveSynthesis(
576
+ store: Store,
577
+ llm: LlamaCpp,
578
+ ): Promise<{ created: number }> {
579
+ const created = await generateDeductiveObservations(store, llm);
580
+ return { created };
581
+ }
582
+
583
+ // =============================================================================
584
+ // Surprisal Scoring (k-NN density anomaly detection)
585
+ // =============================================================================
586
+
587
+ export interface SurprisalResult {
588
+ docId: number;
589
+ title: string;
590
+ path: string;
591
+ collection: string;
592
+ contentType: string;
593
+ avgNeighborDistance: number; // higher = more anomalous
594
+ neighborCount: number;
595
+ }
596
+
597
+ /**
598
+ * Compute surprisal scores for observation documents using k-NN average
599
+ * neighbor distance in embedding space. High-surprisal observations are
600
+ * anomalous — they don't fit existing patterns and deserve curator attention.
601
+ *
602
+ * Uses sqlite-vec's built-in KNN query (vec0 virtual table) for efficiency.
603
+ * Only scores documents that have embeddings (content_vectors + vectors_vec).
604
+ */
605
+ export function computeSurprisalScores(
606
+ store: Store,
607
+ options?: { collection?: string; limit?: number; k?: number; minScore?: number }
608
+ ): SurprisalResult[] {
609
+ const k = options?.k ?? 5;
610
+ const limit = options?.limit ?? 20;
611
+ const minScore = options?.minScore ?? 0;
612
+
613
+ // Get observation documents with embeddings (seq=0 = primary fragment)
614
+ let sql = `
615
+ SELECT d.id, d.title, d.path, d.collection, d.content_type,
616
+ cv.hash || '_0' as hash_seq
617
+ FROM documents d
618
+ JOIN content_vectors cv ON d.hash = cv.hash AND cv.seq = 0
619
+ WHERE d.active = 1
620
+ AND d.observation_type IS NOT NULL
621
+ `;
622
+ const params: any[] = [];
623
+ if (options?.collection) {
624
+ sql += ` AND d.collection = ?`;
625
+ params.push(options.collection);
626
+ }
627
+ sql += ` ORDER BY d.modified_at DESC LIMIT 100`;
628
+
629
+ const docs = store.db.prepare(sql).all(...params) as {
630
+ id: number; title: string; path: string; collection: string;
631
+ content_type: string; hash_seq: string;
632
+ }[];
633
+
634
+ if (docs.length < k + 1) return []; // Not enough docs for meaningful k-NN
635
+
636
+ // For each doc, query its k nearest neighbors and compute average distance
637
+ const results: SurprisalResult[] = [];
638
+
639
+ // Check if vectors_vec exists
640
+ const vecTable = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
641
+ if (!vecTable) return [];
642
+
643
+ for (const doc of docs) {
644
+ try {
645
+ // Get this doc's embedding from vectors_vec
646
+ const vecRow = store.db.prepare(
647
+ `SELECT embedding FROM vectors_vec WHERE hash_seq = ?`
648
+ ).get(doc.hash_seq) as { embedding: Float32Array | number[] } | null;
649
+
650
+ if (!vecRow?.embedding) continue;
651
+
652
+ // Query k+1 nearest neighbors (first result is the doc itself)
653
+ const neighbors = store.db.prepare(`
654
+ SELECT distance
655
+ FROM vectors_vec
656
+ WHERE embedding MATCH ?
657
+ ORDER BY distance
658
+ LIMIT ?
659
+ `).all(vecRow.embedding, k + 1) as { distance: number }[];
660
+
661
+ // Skip the first result (self, distance ≈ 0) and compute average
662
+ const nonSelf = neighbors.filter(n => n.distance > 0.001);
663
+ if (nonSelf.length === 0) continue;
664
+
665
+ const avgDist = nonSelf.reduce((sum, n) => sum + n.distance, 0) / nonSelf.length;
666
+
667
+ if (avgDist >= minScore) {
668
+ results.push({
669
+ docId: doc.id,
670
+ title: doc.title,
671
+ path: doc.path,
672
+ collection: doc.collection,
673
+ contentType: doc.content_type,
674
+ avgNeighborDistance: avgDist,
675
+ neighborCount: nonSelf.length,
676
+ });
677
+ }
678
+ } catch {
679
+ // Skip docs that fail vector lookup (missing embedding, dimension mismatch)
680
+ continue;
681
+ }
682
+ }
683
+
684
+ // Sort by surprisal (highest first) and limit
685
+ results.sort((a, b) => b.avgNeighborDistance - a.avgNeighborDistance);
686
+ return results.slice(0, limit);
687
+ }
688
+
378
689
  // =============================================================================
379
690
  // Public API for MCP / CLI
380
691
  // =============================================================================
@@ -374,6 +374,32 @@ export async function decisionExtractor(
374
374
  console.log(`[decision-extractor] Error in causal inference:`, err);
375
375
  }
376
376
  }
377
+
378
+ // Extract SPO triples from observation facts (preference/decision types get priority)
379
+ for (const obs of observations) {
380
+ if (!obs.facts || obs.facts.length === 0) continue;
381
+ for (const fact of obs.facts) {
382
+ const triple = extractTripleFromFact(fact, obs.type);
383
+ if (triple) {
384
+ try {
385
+ store.db.prepare(
386
+ "INSERT OR IGNORE INTO entity_nodes (entity_id, name, entity_type, created_at) VALUES (?, ?, ?, ?)"
387
+ ).run(triple.subjectId, triple.subject, "auto", new Date().toISOString());
388
+ if (triple.objectId) {
389
+ store.db.prepare(
390
+ "INSERT OR IGNORE INTO entity_nodes (entity_id, name, entity_type, created_at) VALUES (?, ?, ?, ?)"
391
+ ).run(triple.objectId, triple.object, "auto", new Date().toISOString());
392
+ }
393
+ store.addTriple(triple.subjectId, triple.predicate, triple.objectId, triple.objectId ? null : triple.object, {
394
+ confidence: obs.type === "decision" || obs.type === "preference" ? 0.9 : 0.7,
395
+ sourceFact: fact,
396
+ });
397
+ } catch {
398
+ // Triple insertion errors are non-fatal
399
+ }
400
+ }
401
+ }
402
+ }
377
403
  }
378
404
 
379
405
  // Extract decisions (observer-first, regex fallback)
@@ -663,3 +689,69 @@ function formatObservation(obs: Observation, dateStr: string, sessionId: string)
663
689
 
664
690
  return lines.join("\n");
665
691
  }
692
+
693
+ // =============================================================================
694
+ // SPO Triple Extraction from Facts
695
+ // =============================================================================
696
+
697
+ type ExtractedTriple = {
698
+ subject: string;
699
+ subjectId: string;
700
+ predicate: string;
701
+ object: string;
702
+ objectId: string | null;
703
+ };
704
+
705
+ function toEntityId(name: string): string {
706
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
707
+ }
708
+
709
+ function extractTripleFromFact(fact: string, obsType: string): ExtractedTriple | null {
710
+ // Only extract from decision/preference/milestone/problem types — skip noisy bugfix/feature/change facts
711
+ if (!["decision", "preference", "milestone", "problem"].includes(obsType)) return null;
712
+
713
+ // Conservative verb patterns — only clear relational predicates
714
+ const verbPatterns = [
715
+ /^(.+?)\s+(chose|selected|switched to|migrated to|adopted)\s+(.+?)\.?$/i,
716
+ /^(.+?)\s+(deployed to|runs on|hosted on|installed on)\s+(.+?)\.?$/i,
717
+ /^(.+?)\s+(replaced|superseded|deprecated)\s+(.+?)\.?$/i,
718
+ /^(.+?)\s+(depends on|integrates with|connects to)\s+(.+?)\.?$/i,
719
+ ];
720
+
721
+ for (const pattern of verbPatterns) {
722
+ const match = fact.match(pattern);
723
+ if (match) {
724
+ const subject = match[1]!.trim();
725
+ const predicate = match[2]!.trim();
726
+ const object = match[3]!.trim();
727
+
728
+ // Reject subjects/objects that look like sentences rather than entity names
729
+ if (subject.length < 3 || object.length < 3 || subject.length > 60 || object.length > 60) continue;
730
+ if (subject.includes(",") || object.includes(",")) continue; // likely a clause, not an entity
731
+
732
+ return {
733
+ subject,
734
+ subjectId: toEntityId(subject),
735
+ predicate: predicate.toLowerCase().replace(/\s+/g, "_"),
736
+ object,
737
+ objectId: toEntityId(object),
738
+ };
739
+ }
740
+ }
741
+
742
+ // Preference facts only: "User prefers X" / "Prefers X"
743
+ if (obsType === "preference") {
744
+ const prefMatch = fact.match(/^(?:user\s+)?(?:prefers?|avoids?)\s+(.+?)\.?$/i);
745
+ if (prefMatch && prefMatch[1]!.trim().length > 2) {
746
+ return {
747
+ subject: "user",
748
+ subjectId: "user",
749
+ predicate: "prefers",
750
+ object: prefMatch[1]!.trim(),
751
+ objectId: null, // literal, not entity
752
+ };
753
+ }
754
+ }
755
+
756
+ return null;
757
+ }
@@ -78,13 +78,13 @@ export async function sessionBootstrap(
78
78
  }
79
79
  }
80
80
 
81
- // 2. Recent decisions
82
- const decisionSection = getRecentDecisions(store, DECISION_TOKEN_BUDGET);
83
- if (decisionSection) {
84
- const tokens = estimateTokens(decisionSection.text);
81
+ // 2. Current focus (recent preferences + active problems)
82
+ const focusSection = getCurrentFocus(store, DECISION_TOKEN_BUDGET);
83
+ if (focusSection) {
84
+ const tokens = estimateTokens(focusSection.text);
85
85
  if (totalTokens + tokens <= TOTAL_TOKEN_BUDGET) {
86
- sections.push(decisionSection.text);
87
- paths.push(...decisionSection.paths);
86
+ sections.push(focusSection.text);
87
+ paths.push(...focusSection.paths);
88
88
  totalTokens += tokens;
89
89
  }
90
90
  }
@@ -252,38 +252,108 @@ function extractSection(body: string, sectionName: string): string | null {
252
252
  return text.length > 10 ? `**${sectionName}:**\n${text}` : null;
253
253
  }
254
254
 
255
- function getRecentDecisions(
255
+ function getCurrentFocus(
256
256
  store: Store,
257
257
  maxTokens: number
258
258
  ): { text: string; paths: string[] } | null {
259
- const decisions = store.getDocumentsByType("decision", 5);
260
- if (decisions.length === 0) return null;
261
-
262
259
  const cutoff = new Date();
263
260
  cutoff.setDate(cutoff.getDate() - DECISION_LOOKBACK_DAYS);
264
261
  const cutoffStr = cutoff.toISOString();
265
262
 
266
- // Filter to recent decisions
267
- const recent = decisions.filter(d => d.modifiedAt >= cutoffStr);
268
- if (recent.length === 0) return null;
263
+ // Gather recent decisions, preferences, active problems, and deductive insights
264
+ const decisions = store.getDocumentsByType("decision", 10);
265
+ const preferences = store.getDocumentsByType("preference", 5);
266
+ const problems = store.getDocumentsByType("problem", 5);
267
+ const deductions = store.getDocumentsByType("deductive", 5);
268
+
269
+ // Rank by: pinned first, then recency, then access_count
270
+ const now = Date.now();
271
+ const rankDoc = (d: any) => {
272
+ const pinBoost = d.pinned ? 1000 : 0;
273
+ const daysSince = (now - new Date(d.modifiedAt).getTime()) / 86400000;
274
+ const recencyScore = Math.max(0, 100 - daysSince * 5); // 0-100, loses 5 per day
275
+ const accessScore = (d.accessCount ?? 0) * 2;
276
+ return pinBoost + recencyScore + accessScore;
277
+ };
278
+
279
+ const recentDecisions = decisions
280
+ .filter(d => d.modifiedAt >= cutoffStr)
281
+ .sort((a, b) => rankDoc(b) - rankDoc(a));
282
+
283
+ const activeProblems = problems
284
+ .filter(d => d.modifiedAt >= cutoffStr && (d.confidence ?? 0.5) > 0.2);
285
+
286
+ // Preferences are durable — no date filter, just rank
287
+ const rankedPrefs = [...preferences].sort((a, b) => rankDoc(b) - rankDoc(a));
288
+
289
+ const recentDeductions = deductions
290
+ .filter(d => d.modifiedAt >= cutoffStr)
291
+ .sort((a, b) => rankDoc(b) - rankDoc(a));
292
+
293
+ if (recentDecisions.length === 0 && rankedPrefs.length === 0 && activeProblems.length === 0 && recentDeductions.length === 0) {
294
+ return null;
295
+ }
269
296
 
270
297
  const maxChars = maxTokens * 4;
271
- const lines: string[] = ["### Recent Decisions"];
298
+ const lines: string[] = ["### Current Focus"];
272
299
  const paths: string[] = [];
273
- let charCount = 25; // header
274
-
275
- for (const d of recent) {
276
- if (charCount >= maxChars) break;
277
- let body = store.getDocumentBody({ filepath: `${d.collection}/${d.path}`, displayPath: `${d.collection}/${d.path}` } as any);
278
- if (body) body = sanitizeSnippet(body);
279
- if (body === "[content filtered for security]") continue;
280
- const snippet = body ? smartTruncate(body, 200) : d.title;
281
- const entry = `- **${d.title}** (${d.modifiedAt.slice(0, 10)})\n ${snippet}`;
282
- const entryLen = entry.length;
283
- if (charCount + entryLen > maxChars && lines.length > 1) break;
284
- lines.push(entry);
285
- paths.push(`${d.collection}/${d.path}`);
286
- charCount += entryLen;
300
+ let charCount = 20;
301
+
302
+ // Active problems first (high priority)
303
+ if (activeProblems.length > 0) {
304
+ lines.push("**Active Problems:**");
305
+ charCount += 22;
306
+ for (const d of activeProblems) {
307
+ if (charCount >= maxChars) break;
308
+ const entry = `- ${d.title} (${d.modifiedAt.slice(0, 10)})`;
309
+ lines.push(entry);
310
+ paths.push(`${d.collection}/${d.path}`);
311
+ charCount += entry.length + 2;
312
+ }
313
+ }
314
+
315
+ // Recent decisions
316
+ if (recentDecisions.length > 0) {
317
+ lines.push("**Recent Decisions:**");
318
+ charCount += 24;
319
+ for (const d of recentDecisions) {
320
+ if (charCount >= maxChars) break;
321
+ let body = store.getDocumentBody({ filepath: `${d.collection}/${d.path}`, displayPath: `${d.collection}/${d.path}` } as any);
322
+ if (body) body = sanitizeSnippet(body);
323
+ if (body === "[content filtered for security]") continue;
324
+ const snippet = body ? smartTruncate(body, 200) : d.title;
325
+ const entry = `- **${d.title}** (${d.modifiedAt.slice(0, 10)})\n ${snippet}`;
326
+ if (charCount + entry.length > maxChars && lines.length > 2) break;
327
+ lines.push(entry);
328
+ paths.push(`${d.collection}/${d.path}`);
329
+ charCount += entry.length;
330
+ }
331
+ }
332
+
333
+ // User preferences (compact — title only, they're durable context)
334
+ if (rankedPrefs.length > 0) {
335
+ lines.push("**Preferences:**");
336
+ charCount += 18;
337
+ for (const d of rankedPrefs) {
338
+ if (charCount >= maxChars) break;
339
+ const entry = `- ${d.title}`;
340
+ lines.push(entry);
341
+ paths.push(`${d.collection}/${d.path}`);
342
+ charCount += entry.length + 2;
343
+ }
344
+ }
345
+
346
+ // Cross-session deductions (derived insights with source provenance)
347
+ if (recentDeductions.length > 0) {
348
+ lines.push("**Derived Insights:**");
349
+ charCount += 24;
350
+ for (const d of recentDeductions) {
351
+ if (charCount >= maxChars) break;
352
+ const entry = `- ${d.title} (${d.modifiedAt.slice(0, 10)})`;
353
+ lines.push(entry);
354
+ paths.push(`${d.collection}/${d.path}`);
355
+ charCount += entry.length + 2;
356
+ }
287
357
  }
288
358
 
289
359
  return lines.length > 1 ? { text: lines.join("\n"), paths } : null;
@@ -299,12 +369,15 @@ function getStaleNotes(
299
369
 
300
370
  if (stale.length === 0) return null;
301
371
 
372
+ // Rank by confidence descending — higher confidence notes are more important to review
373
+ const ranked = [...stale].sort((a, b) => (b.confidence ?? 0.5) - (a.confidence ?? 0.5));
374
+
302
375
  const maxChars = maxTokens * 4;
303
376
  const lines: string[] = ["### Notes to Review"];
304
377
  const paths: string[] = [];
305
378
  let charCount = 25;
306
379
 
307
- for (const d of stale.slice(0, 5)) {
380
+ for (const d of ranked.slice(0, 5)) {
308
381
  const entry = `- ${d.title} (${d.collection}/${d.path}) — last modified ${d.modifiedAt.slice(0, 10)}`;
309
382
  if (charCount + entry.length > maxChars && lines.length > 1) break;
310
383
  lines.push(entry);