clawmem 0.5.1 → 0.7.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 CHANGED
@@ -250,9 +250,9 @@ ClawMem hooks handle ~90% of retrieval automatically. Agent-initiated MCP calls
250
250
  | `postcompact-inject` | SessionStart (compact) | 1200 tokens | re-injects authoritative context after compaction: precompact state (600) + recent decisions (400) + antipatterns (150) + vault context (200) → `<vault-postcompact>` |
251
251
  | `curator-nudge` | SessionStart | 200 tokens | surfaces curator report actions, nudges when report is stale (>7 days) |
252
252
  | `precompact-extract` | PreCompact | — | extracts decisions, file paths, open questions → writes `precompact-state.md` to auto-memory. Query-aware decision ranking. Reindexes auto-memory collection. |
253
- | `decision-extractor` | Stop | — | LLM extracts observations → `_clawmem/agent/observations/`, infers causal links, detects contradictions with prior decisions |
253
+ | `decision-extractor` | Stop | — | LLM extracts observations → `_clawmem/agent/observations/`, infers causal links, detects contradictions, extracts SPO triples from decision/preference/milestone/problem facts. Background consolidation worker synthesizes deductive observations from related facts (Phase 3, every ~15 min). |
254
254
  | `handoff-generator` | Stop | — | LLM summarizes session → `_clawmem/agent/handoffs/` |
255
- | `feedback-loop` | Stop | — | tracks referenced notes → boosts confidence, records usage relations + co-activations between co-referenced docs, tracks utility signals (surfaced vs referenced ratio for lifecycle automation) |
255
+ | `feedback-loop` | Stop | — | tracks referenced notes → boosts confidence, records usage relations + co-activations between co-referenced docs, tracks utility signals (surfaced vs referenced ratio for lifecycle automation), per-turn recall attribution (marks which surfaced docs were cited in which turn) |
256
256
 
257
257
  **Default behavior:** Read injected `<vault-context>` first. If sufficient, answer immediately.
258
258
 
@@ -447,7 +447,7 @@ compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenc
447
447
 
448
448
  | Content Type | Half-Life | Effect |
449
449
  |--------------|-----------|--------|
450
- | decision, preference, hub | ∞ | Never decay |
450
+ | decision, deductive, preference, hub | ∞ | Never decay |
451
451
  | antipattern | ∞ | Never decay — accumulated negative patterns persist |
452
452
  | project | 120 days | Slow decay |
453
453
  | research | 90 days | Moderate decay |
@@ -456,7 +456,7 @@ compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenc
456
456
  | handoff | 30 days | Fast — recent matters most |
457
457
 
458
458
  Half-lives extend up to 3× for frequently-accessed memories (access reinforcement decays over 90 days).
459
- Attention decay: non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access. Decision/preference/hub/research/antipattern are exempt.
459
+ Attention decay: non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access. Decision/deductive/preference/hub/research/antipattern are exempt.
460
460
 
461
461
  ## Indexing & Graph Building
462
462
 
@@ -499,6 +499,7 @@ The `memory_relations` table is populated by multiple independent sources:
499
499
  | `buildSemanticGraph()` | semantic | `build_graphs` MCP tool (manual) | Pure cosine similarity. PK collision: `INSERT OR IGNORE` means A-MEM semantic edges take precedence if they exist first. |
500
500
  | Entity co-occurrence graph | entity | A-MEM enrichment (indexing) | LLM entity extraction → quality filters (title/length/blocklist/location validation) → type-agnostic canonical resolution within compatibility buckets (person, org, location, tech=project/service/tool/concept) → `entity_mentions` + `entity_cooccurrences` tables. Entity edges use IDF-based specificity scoring. Feeds ENTITY intent queries and MPFP `[entity, semantic]` patterns. |
501
501
  | `consolidated_observations` | supporting | Consolidation worker (background) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. |
502
+ | Deductive synthesis | supporting | Consolidation worker Phase 3 (background, every ~15 min) | Combines 2-3 related recent observations (decision/preference/milestone/problem, last 7 days) into `content_type='deductive'` documents with `source_doc_ids` provenance. First-class searchable docs with ∞ half-life. |
502
503
 
503
504
  **Edge collision:** Both `generateMemoryLinks()` and `buildSemanticGraph()` insert `relation_type='semantic'`. PK is `(source_id, target_id, relation_type)` — first writer wins.
504
505
 
package/CLAUDE.md CHANGED
@@ -250,9 +250,9 @@ ClawMem hooks handle ~90% of retrieval automatically. Agent-initiated MCP calls
250
250
  | `postcompact-inject` | SessionStart (compact) | 1200 tokens | re-injects authoritative context after compaction: precompact state (600) + recent decisions (400) + antipatterns (150) + vault context (200) → `<vault-postcompact>` |
251
251
  | `curator-nudge` | SessionStart | 200 tokens | surfaces curator report actions, nudges when report is stale (>7 days) |
252
252
  | `precompact-extract` | PreCompact | — | extracts decisions, file paths, open questions → writes `precompact-state.md` to auto-memory. Query-aware decision ranking. Reindexes auto-memory collection. |
253
- | `decision-extractor` | Stop | — | LLM extracts observations → `_clawmem/agent/observations/`, infers causal links, detects contradictions with prior decisions |
253
+ | `decision-extractor` | Stop | — | LLM extracts observations → `_clawmem/agent/observations/`, infers causal links, detects contradictions, extracts SPO triples from decision/preference/milestone/problem facts. Background consolidation worker synthesizes deductive observations from related facts (Phase 3, every ~15 min). |
254
254
  | `handoff-generator` | Stop | — | LLM summarizes session → `_clawmem/agent/handoffs/` |
255
- | `feedback-loop` | Stop | — | tracks referenced notes → boosts confidence, records usage relations + co-activations between co-referenced docs, tracks utility signals (surfaced vs referenced ratio for lifecycle automation) |
255
+ | `feedback-loop` | Stop | — | tracks referenced notes → boosts confidence, records usage relations + co-activations between co-referenced docs, tracks utility signals (surfaced vs referenced ratio for lifecycle automation), per-turn recall attribution (marks which surfaced docs were cited in which turn) |
256
256
 
257
257
  **Default behavior:** Read injected `<vault-context>` first. If sufficient, answer immediately.
258
258
 
@@ -447,7 +447,7 @@ compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenc
447
447
 
448
448
  | Content Type | Half-Life | Effect |
449
449
  |--------------|-----------|--------|
450
- | decision, preference, hub | ∞ | Never decay |
450
+ | decision, deductive, preference, hub | ∞ | Never decay |
451
451
  | antipattern | ∞ | Never decay — accumulated negative patterns persist |
452
452
  | project | 120 days | Slow decay |
453
453
  | research | 90 days | Moderate decay |
@@ -456,7 +456,7 @@ compositeScore = (0.10 × searchScore + 0.70 × recencyScore + 0.20 × confidenc
456
456
  | handoff | 30 days | Fast — recent matters most |
457
457
 
458
458
  Half-lives extend up to 3× for frequently-accessed memories (access reinforcement decays over 90 days).
459
- Attention decay: non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access. Decision/preference/hub/research/antipattern are exempt.
459
+ Attention decay: non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access. Decision/deductive/preference/hub/research/antipattern are exempt.
460
460
 
461
461
  ## Indexing & Graph Building
462
462
 
@@ -499,6 +499,7 @@ The `memory_relations` table is populated by multiple independent sources:
499
499
  | `buildSemanticGraph()` | semantic | `build_graphs` MCP tool (manual) | Pure cosine similarity. PK collision: `INSERT OR IGNORE` means A-MEM semantic edges take precedence if they exist first. |
500
500
  | Entity co-occurrence graph | entity | A-MEM enrichment (indexing) | LLM entity extraction → quality filters (title/length/blocklist/location validation) → type-agnostic canonical resolution within compatibility buckets (person, org, location, tech=project/service/tool/concept) → `entity_mentions` + `entity_cooccurrences` tables. Entity edges use IDF-based specificity scoring. Feeds ENTITY intent queries and MPFP `[entity, semantic]` patterns. |
501
501
  | `consolidated_observations` | supporting | Consolidation worker (background) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. |
502
+ | Deductive synthesis | supporting | Consolidation worker Phase 3 (background, every ~15 min) | Combines 2-3 related recent observations (decision/preference/milestone/problem, last 7 days) into `content_type='deductive'` documents with `source_doc_ids` provenance. First-class searchable docs with ∞ half-life. |
502
503
 
503
504
  **Edge collision:** Both `generateMemoryLinks()` and `buildSemanticGraph()` insert `relation_type='semantic'`. PK is `(source_id, target_id, relation_type)` — first writer wins.
504
505
 
package/README.md CHANGED
@@ -85,7 +85,7 @@ Runs fully local with no API keys and no cloud services. Integrates via Claude C
85
85
  **Optional integrations:**
86
86
 
87
87
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — for hooks + MCP integration
88
- - [OpenClaw](https://github.com/openclawai/openclaw) — for ContextEngine plugin integration
88
+ - [OpenClaw](https://github.com/openclaw/openclaw) — for ContextEngine plugin integration
89
89
  - [Hermes Agent](https://github.com/NousResearch/hermes-agent) — for MemoryProvider plugin integration
90
90
  - [bd CLI](https://github.com/dolthub/dolt) v0.58.0+ — for Beads issue tracker sync (only if using Beads)
91
91
 
@@ -823,6 +823,7 @@ For WHY and ENTITY queries, the search pipeline expands results through the memo
823
823
  | Type | Half-life | Baseline | Notes |
824
824
  |---|---|---|---|
825
825
  | `decision` | ∞ | 0.85 | Never decays |
826
+ | `deductive` | ∞ | 0.85 | Never decays — cross-session derived insights with source provenance |
826
827
  | `preference` | ∞ | 0.80 | Never decays — user preferences are durable facts |
827
828
  | `hub` | ∞ | 0.80 | Never decays |
828
829
  | `antipattern` | ∞ | 0.75 | Never decays — accumulated negative patterns persist |
@@ -835,7 +836,7 @@ For WHY and ENTITY queries, the search pipeline expands results through the memo
835
836
  | `progress` | 45 days | 0.50 | |
836
837
  | `note` | 60 days | 0.50 | Default |
837
838
 
838
- Content types are inferred from frontmatter or file path patterns. Half-lives extend up to 3× for frequently-accessed memories (access reinforcement, decays over 90 days). Non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access (attention decay). Decision/preference/hub/research/antipattern are exempt.
839
+ Content types are inferred from frontmatter or file path patterns. Half-lives extend up to 3× for frequently-accessed memories (access reinforcement, decays over 90 days). Non-durable types (handoff, progress, conversation, note, project) lose 5% confidence per week without access (attention decay). Decision/deductive/preference/hub/research/antipattern are exempt.
839
840
 
840
841
  **Quality scoring:** Each document gets a `quality_score` (0.0–1.0) computed during indexing based on length, structure (headings, lists), decision/correction keywords, and frontmatter richness. Applied as `qualityMultiplier = 0.7 + 0.6 × qualityScore` (range: 0.7× penalty to 1.3× boost).
841
842
 
@@ -884,6 +885,17 @@ Documents are split into semantic fragments (sections, lists, code blocks, front
884
885
 
885
886
  Uses the LLM server (shared with query expansion and intent classification) to extract structured observations from session transcripts. Observation types: `decision`, `bugfix`, `feature`, `refactor`, `discovery`, `change`, `preference`, `milestone`, `problem`. Each observation includes title, facts, narrative, concepts, and files read/modified. Preferences, milestones, and problems get first-class content_type treatment with dedicated confidence baselines and half-lives instead of being flattened to generic "observation". Falls back to regex patterns if the model is unavailable.
886
887
 
888
+ ### Recall Tracking
889
+
890
+ Empirical tracking of which documents are surfaced by retrieval, which queries surfaced them, and whether the assistant actually cited them. Provides signals beyond raw search relevance for lifecycle decisions:
891
+
892
+ - **Per-query diversity**: docs surfaced by multiple distinct queries have proven cross-domain relevance
893
+ - **Multi-day spacing**: docs surfaced across separate calendar days (spaced frequency) are more valuable than binge recalls in one session
894
+ - **Negative signals**: docs surfaced frequently but rarely referenced are noise candidates for snooze
895
+ - **Per-turn attribution**: feedback-loop segments the transcript into turns and attributes references to specific context-surfacing invocations, not the session globally
896
+
897
+ Data feeds `lifecycle_status` (pin/snooze candidate reports) and `lifecycle_sweep` (recall-based recommendations). Adapted from [OpenClaw](https://github.com/openclaw/openclaw) dreaming promotion patterns.
898
+
887
899
  ### User Profile
888
900
 
889
901
  Two-tier auto-curated profile extracted from your decisions and hub documents:
@@ -1119,9 +1131,11 @@ Built on the shoulders of:
1119
1131
  - [Engram](https://github.com/Gentleman-Programming/engram) — observation dedup window, topic-key upsert pattern, temporal timeline navigation, duplicate metadata scoring signals
1120
1132
  - [Hermes Agent](https://github.com/NousResearch/hermes-agent) — MemoryProvider plugin integration, memory nudge system (periodic lifecycle tool prompting)
1121
1133
  - [Hindsight](https://github.com/vectorize-io/hindsight) — entity resolution, MPFP graph traversal, temporal extraction, 3-tier consolidation, observation invalidation, 4-way parallel retrieval
1134
+ - [Honcho](https://github.com/plastic-labs/honcho) — deductive observation synthesis patterns, surprisal-based anomaly scoring concept, embed-state self-healing, retrieval separation (raw vs derived)
1122
1135
  - [MAGMA](https://arxiv.org/abs/2501.13956) — multi-graph memory agent
1123
1136
  - [MemPalace](https://github.com/milla-jovovich/mempalace) — conversation import patterns, broadened observation taxonomy (preference/milestone/problem), session-bootstrap synthesis
1124
1137
  - [memory-lancedb-pro](https://github.com/CortexReach/memory-lancedb-pro) — retrieval gate, length normalization, MMR diversity, access reinforcement algorithms
1138
+ - [OpenClaw](https://github.com/openclaw/openclaw) — recall tracking patterns (per-query diversity, multi-day spacing, negative signal tracking, promotion scoring) extracted from the dreaming memory consolidation system
1125
1139
  - [OpenViking](https://github.com/volcengine/OpenViking) — query decomposition patterns, collection-scoped retrieval, transaction-safe indexing
1126
1140
  - [QMD](https://github.com/tobi/qmd) — search backend (BM25 + vectors + RRF + reranking)
1127
1141
  - [SAME](https://github.com/sgx-labs/statelessagent) — agent memory concepts (recency decay, confidence scoring, session tracking)
package/SKILL.md CHANGED
@@ -451,7 +451,7 @@ compositeScore = (0.10 x searchScore + 0.70 x recencyScore + 0.20 x confidenceSc
451
451
 
452
452
  | Content Type | Half-Life | Effect |
453
453
  |--------------|-----------|--------|
454
- | decision, preference, hub | infinity | Never decay |
454
+ | decision, deductive, preference, hub | infinity | Never decay |
455
455
  | antipattern | infinity | Never decay — accumulated negative patterns persist |
456
456
  | project | 120 days | Slow decay |
457
457
  | research | 90 days | Moderate decay |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "On-device context engine and memory for AI agents. Claude Code and OpenClaw. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/clawmem.ts CHANGED
@@ -410,6 +410,9 @@ async function cmdEmbed(args: string[]) {
410
410
 
411
411
  const fragments = splitDocument(body, frontmatter);
412
412
  const docStart = Date.now();
413
+ const prevTotalFragments = totalFragments;
414
+ const prevFailedFragments = failedFragments;
415
+ let seq0Succeeded = false;
413
416
  console.error(` [${docIdx + 1}/${hashes.length}] ${basename(path)} (${fragments.length} frags, ${body.length} chars)`);
414
417
 
415
418
  if (isCloudEmbed) {
@@ -463,6 +466,7 @@ async function cmdEmbed(args: string[]) {
463
466
  result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
464
467
  );
465
468
  totalFragments++;
469
+ if (seq === 0) seq0Succeeded = true;
466
470
  } else {
467
471
  failedFragments++;
468
472
  }
@@ -491,6 +495,7 @@ async function cmdEmbed(args: string[]) {
491
495
  result.model, new Date().toISOString(), frag.type, frag.label ?? undefined, canId
492
496
  );
493
497
  totalFragments++;
498
+ if (seq === 0) seq0Succeeded = true;
494
499
  if (seq === 0 || (seq + 1) % 5 === 0 || seq === fragments.length - 1) {
495
500
  console.error(` frag ${seq + 1}/${fragments.length} (${frag.type}) ${fragMs}ms [${text.length} chars]`);
496
501
  }
@@ -505,6 +510,18 @@ async function cmdEmbed(args: string[]) {
505
510
  }
506
511
  }
507
512
 
513
+ // Track embed state per document — seq=0 (primary) must succeed for synced status
514
+ const docFragsOk = totalFragments - prevTotalFragments;
515
+ const docFragsFail = failedFragments - prevFailedFragments;
516
+ if (seq0Succeeded) {
517
+ s.markEmbedSynced(hash);
518
+ } else if (docFragsOk === 0 && docFragsFail > 0) {
519
+ s.markEmbedFailed(hash, "all fragments failed");
520
+ } else {
521
+ // seq=0 failed but some later fragments succeeded — mark failed so seq=0 gets retried
522
+ s.markEmbedFailed(hash, "primary fragment (seq=0) failed");
523
+ }
524
+
508
525
  embedded++;
509
526
  const docMs = Date.now() - docStart;
510
527
  const elapsed = ((Date.now() - batchStart) / 1000).toFixed(0);
@@ -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,22 @@ 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
+ }
127
+
128
+ // Phase 4: Recall stats recomputation (every tick — lightweight SQL aggregation)
129
+ try {
130
+ const updated = store.recomputeRecallStats();
131
+ if (updated > 0) {
132
+ console.log(`[consolidation] Phase 4: recomputed recall_stats for ${updated} docs`);
133
+ }
134
+ } catch (err) {
135
+ // Non-critical — recall stats are informational, not retrieval-blocking
136
+ console.error("[consolidation] Phase 4 recall stats failed:", err);
137
+ }
118
138
  } catch (err) {
119
139
  console.error("[consolidation] Tick failed:", err);
120
140
  } finally {
@@ -375,6 +395,308 @@ function updateTrends(store: Store): void {
375
395
  }
376
396
  }
377
397
 
398
+ // =============================================================================
399
+ // Phase 3: Deductive Observation Synthesis
400
+ // =============================================================================
401
+
402
+ /**
403
+ * Find pairs/groups of recent high-confidence observations that can be combined
404
+ * into higher-level deductive conclusions. Creates first-class documents with
405
+ * content_type='deductive' and source_doc_ids provenance.
406
+ *
407
+ * Only considers decision/preference/milestone/problem observations from the
408
+ * last 7 days that haven't already been used as sources for deductions.
409
+ */
410
+ async function generateDeductiveObservations(store: Store, llm: LlamaCpp): Promise<number> {
411
+ // Find recent high-value observations not yet used in deductions
412
+ const DEDUCTIVE_TYPES = ['decision', 'preference', 'milestone', 'problem'];
413
+ const recentObs = store.db.prepare(`
414
+ SELECT d.id, d.title, d.facts, d.narrative, d.observation_type, d.content_type,
415
+ d.collection, d.path, d.modified_at
416
+ FROM documents d
417
+ WHERE d.active = 1
418
+ AND d.content_type IN (${DEDUCTIVE_TYPES.map(() => '?').join(',')})
419
+ AND d.observation_type IS NOT NULL
420
+ AND d.facts IS NOT NULL
421
+ AND d.modified_at >= datetime('now', '-7 days')
422
+ AND d.id NOT IN (
423
+ SELECT value FROM (
424
+ SELECT json_each.value as value
425
+ FROM documents dd, json_each(dd.source_doc_ids)
426
+ WHERE dd.content_type = 'deductive' AND dd.active = 1
427
+ )
428
+ )
429
+ ORDER BY d.modified_at DESC
430
+ LIMIT 20
431
+ `).all(...DEDUCTIVE_TYPES) as {
432
+ id: number; title: string; facts: string; narrative: string;
433
+ observation_type: string; content_type: string; collection: string;
434
+ path: string; modified_at: string;
435
+ }[];
436
+
437
+ if (recentObs.length < 2) return 0;
438
+
439
+ // Build context for LLM
440
+ const obsText = recentObs.map((o, i) =>
441
+ `[${i + 1}] (${o.content_type}/${o.observation_type}) "${o.title}"\n Facts: ${(o.facts || '').slice(0, 300)}\n Narrative: ${(o.narrative || '').slice(0, 200)}`
442
+ ).join('\n\n');
443
+
444
+ 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.
445
+
446
+ A deduction combines facts from different observations into a NEW conclusion that isn't stated in any single observation alone.
447
+
448
+ Observations:
449
+ ${obsText}
450
+
451
+ For each valid deduction:
452
+ 1. State the conclusion clearly (1-2 sentences)
453
+ 2. List the premises (which observations support it)
454
+ 3. List the source indices (1-indexed)
455
+
456
+ Return ONLY valid JSON array:
457
+ [
458
+ {
459
+ "conclusion": "Clear deductive statement",
460
+ "premises": ["Premise from obs 1", "Premise from obs 3"],
461
+ "source_indices": [1, 3]
462
+ }
463
+ ]
464
+
465
+ Rules:
466
+ - Each deduction MUST combine 2+ different observations (not restate a single one)
467
+ - Only include conclusions with genuine logical basis
468
+ - Maximum 3 deductions
469
+ - If no valid deductions exist, return []
470
+ Return ONLY the JSON array. /no_think`;
471
+
472
+ const result = await llm.generate(prompt, { temperature: 0.3, maxTokens: 500 });
473
+ if (!result?.text) return 0;
474
+
475
+ const parsed = extractJsonFromLLM(result.text) as Array<{
476
+ conclusion: string;
477
+ premises: string[];
478
+ source_indices: number[];
479
+ }> | null;
480
+
481
+ if (!Array.isArray(parsed)) return 0;
482
+
483
+ let created = 0;
484
+ const timestamp = new Date().toISOString();
485
+ const dateStr = timestamp.slice(0, 10);
486
+
487
+ for (const deduction of parsed) {
488
+ if (!deduction.conclusion || !Array.isArray(deduction.source_indices) || deduction.source_indices.length < 2) continue;
489
+
490
+ const sourceDocIds = deduction.source_indices
491
+ .filter(i => i >= 1 && i <= recentObs.length)
492
+ .map(i => recentObs[i - 1]!.id);
493
+
494
+ if (sourceDocIds.length < 2) continue;
495
+
496
+ // Check for duplicate deduction (Jaccard on conclusion text)
497
+ const existingDedups = store.db.prepare(`
498
+ SELECT id, title FROM documents
499
+ WHERE content_type = 'deductive' AND active = 1
500
+ ORDER BY created_at DESC LIMIT 20
501
+ `).all() as { id: number; title: string }[];
502
+
503
+ const conclusionWords = new Set(deduction.conclusion.toLowerCase().split(/\s+/).filter(w => w.length > 3));
504
+ const isDuplicate = existingDedups.some(d => {
505
+ const titleWords = new Set(d.title.toLowerCase().split(/\s+/).filter(w => w.length > 3));
506
+ const intersection = [...conclusionWords].filter(w => titleWords.has(w)).length;
507
+ const union = new Set([...conclusionWords, ...titleWords]).size;
508
+ return union > 0 && intersection / union > 0.5;
509
+ });
510
+
511
+ if (isDuplicate) continue;
512
+
513
+ // Build the deductive document
514
+ const premisesText = (deduction.premises || []).map(p => `- ${p}`).join('\n');
515
+ const sourceRefs = sourceDocIds.map(id => {
516
+ const obs = recentObs.find(o => o.id === id);
517
+ return obs ? `- "${obs.title}" (${obs.content_type})` : `- doc#${id}`;
518
+ }).join('\n');
519
+
520
+ const body = [
521
+ `---`,
522
+ `content_type: deductive`,
523
+ `tags: [auto-deduced, consolidation]`,
524
+ `---`,
525
+ ``,
526
+ `# ${deduction.conclusion.slice(0, 80)}`,
527
+ ``,
528
+ deduction.conclusion,
529
+ ``,
530
+ `## Premises`,
531
+ ``,
532
+ premisesText,
533
+ ``,
534
+ `## Sources`,
535
+ ``,
536
+ sourceRefs,
537
+ ``,
538
+ ].join('\n');
539
+
540
+ const dedPath = `deductions/${dateStr}-${sourceDocIds.join('-')}.md`;
541
+ const hash = hashContent(body);
542
+
543
+ try {
544
+ store.insertContent(hash, body, timestamp);
545
+ store.insertDocument("_clawmem", dedPath, deduction.conclusion.slice(0, 80), hash, timestamp, timestamp);
546
+
547
+ const doc = store.findActiveDocument("_clawmem", dedPath);
548
+ if (doc) {
549
+ store.updateDocumentMeta(doc.id, {
550
+ content_type: "deductive",
551
+ confidence: 0.85,
552
+ });
553
+ store.updateObservationFields(dedPath, "_clawmem", {
554
+ observation_type: "deductive",
555
+ facts: JSON.stringify(deduction.premises || []),
556
+ narrative: deduction.conclusion,
557
+ });
558
+ // Store source provenance
559
+ store.db.prepare(`UPDATE documents SET source_doc_ids = ? WHERE id = ?`)
560
+ .run(JSON.stringify(sourceDocIds), doc.id);
561
+
562
+ // Create supporting edges in memory_relations
563
+ for (const sourceId of sourceDocIds) {
564
+ try {
565
+ store.db.prepare(`
566
+ INSERT OR IGNORE INTO memory_relations (source_id, target_id, relation_type, weight, created_at)
567
+ VALUES (?, ?, 'supporting', 0.85, datetime('now'))
568
+ `).run(sourceId, doc.id);
569
+ } catch { /* non-fatal */ }
570
+ }
571
+
572
+ created++;
573
+ console.log(`[deductive] Created: "${deduction.conclusion.slice(0, 60)}..." from ${sourceDocIds.length} sources`);
574
+ }
575
+ } catch (err) {
576
+ console.error(`[deductive] Failed to create deduction:`, err);
577
+ }
578
+ }
579
+
580
+ return created;
581
+ }
582
+
583
+ /**
584
+ * Manually trigger deductive synthesis (for CLI or MCP tool).
585
+ */
586
+ export async function runDeductiveSynthesis(
587
+ store: Store,
588
+ llm: LlamaCpp,
589
+ ): Promise<{ created: number }> {
590
+ const created = await generateDeductiveObservations(store, llm);
591
+ return { created };
592
+ }
593
+
594
+ // =============================================================================
595
+ // Surprisal Scoring (k-NN density anomaly detection)
596
+ // =============================================================================
597
+
598
+ export interface SurprisalResult {
599
+ docId: number;
600
+ title: string;
601
+ path: string;
602
+ collection: string;
603
+ contentType: string;
604
+ avgNeighborDistance: number; // higher = more anomalous
605
+ neighborCount: number;
606
+ }
607
+
608
+ /**
609
+ * Compute surprisal scores for observation documents using k-NN average
610
+ * neighbor distance in embedding space. High-surprisal observations are
611
+ * anomalous — they don't fit existing patterns and deserve curator attention.
612
+ *
613
+ * Uses sqlite-vec's built-in KNN query (vec0 virtual table) for efficiency.
614
+ * Only scores documents that have embeddings (content_vectors + vectors_vec).
615
+ */
616
+ export function computeSurprisalScores(
617
+ store: Store,
618
+ options?: { collection?: string; limit?: number; k?: number; minScore?: number }
619
+ ): SurprisalResult[] {
620
+ const k = options?.k ?? 5;
621
+ const limit = options?.limit ?? 20;
622
+ const minScore = options?.minScore ?? 0;
623
+
624
+ // Get observation documents with embeddings (seq=0 = primary fragment)
625
+ let sql = `
626
+ SELECT d.id, d.title, d.path, d.collection, d.content_type,
627
+ cv.hash || '_0' as hash_seq
628
+ FROM documents d
629
+ JOIN content_vectors cv ON d.hash = cv.hash AND cv.seq = 0
630
+ WHERE d.active = 1
631
+ AND d.observation_type IS NOT NULL
632
+ `;
633
+ const params: any[] = [];
634
+ if (options?.collection) {
635
+ sql += ` AND d.collection = ?`;
636
+ params.push(options.collection);
637
+ }
638
+ sql += ` ORDER BY d.modified_at DESC LIMIT 100`;
639
+
640
+ const docs = store.db.prepare(sql).all(...params) as {
641
+ id: number; title: string; path: string; collection: string;
642
+ content_type: string; hash_seq: string;
643
+ }[];
644
+
645
+ if (docs.length < k + 1) return []; // Not enough docs for meaningful k-NN
646
+
647
+ // For each doc, query its k nearest neighbors and compute average distance
648
+ const results: SurprisalResult[] = [];
649
+
650
+ // Check if vectors_vec exists
651
+ const vecTable = store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
652
+ if (!vecTable) return [];
653
+
654
+ for (const doc of docs) {
655
+ try {
656
+ // Get this doc's embedding from vectors_vec
657
+ const vecRow = store.db.prepare(
658
+ `SELECT embedding FROM vectors_vec WHERE hash_seq = ?`
659
+ ).get(doc.hash_seq) as { embedding: Float32Array | number[] } | null;
660
+
661
+ if (!vecRow?.embedding) continue;
662
+
663
+ // Query k+1 nearest neighbors (first result is the doc itself)
664
+ const neighbors = store.db.prepare(`
665
+ SELECT distance
666
+ FROM vectors_vec
667
+ WHERE embedding MATCH ?
668
+ ORDER BY distance
669
+ LIMIT ?
670
+ `).all(vecRow.embedding, k + 1) as { distance: number }[];
671
+
672
+ // Skip the first result (self, distance ≈ 0) and compute average
673
+ const nonSelf = neighbors.filter(n => n.distance > 0.001);
674
+ if (nonSelf.length === 0) continue;
675
+
676
+ const avgDist = nonSelf.reduce((sum, n) => sum + n.distance, 0) / nonSelf.length;
677
+
678
+ if (avgDist >= minScore) {
679
+ results.push({
680
+ docId: doc.id,
681
+ title: doc.title,
682
+ path: doc.path,
683
+ collection: doc.collection,
684
+ contentType: doc.content_type,
685
+ avgNeighborDistance: avgDist,
686
+ neighborCount: nonSelf.length,
687
+ });
688
+ }
689
+ } catch {
690
+ // Skip docs that fail vector lookup (missing embedding, dimension mismatch)
691
+ continue;
692
+ }
693
+ }
694
+
695
+ // Sort by surprisal (highest first) and limit
696
+ results.sort((a, b) => b.avgNeighborDistance - a.avgNeighborDistance);
697
+ return results.slice(0, limit);
698
+ }
699
+
378
700
  // =============================================================================
379
701
  // Public API for MCP / CLI
380
702
  // =============================================================================