clawmem 0.7.1 → 0.8.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
@@ -96,6 +96,14 @@ curl http://host:8090/v1/models
96
96
  | `CLAWMEM_ENABLE_AMEM` | enabled | A-MEM note construction + link generation during indexing. |
97
97
  | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs. Needs long-lived MCP process. |
98
98
  | `CLAWMEM_CONSOLIDATION_INTERVAL` | 300000 | Worker interval in ms (min 15000). |
99
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. |
100
+ | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
101
+ | `CLAWMEM_HEAVY_LANE_WINDOW_START` | (none) | **v0.8.0.** Start hour (0-23) of the quiet window. Unset → no window. |
102
+ | `CLAWMEM_HEAVY_LANE_WINDOW_END` | (none) | **v0.8.0.** End hour (0-23, exclusive) of the quiet window. Supports midnight wrap (22→6). |
103
+ | `CLAWMEM_HEAVY_LANE_MAX_USAGES` | 30 | **v0.8.0.** Max `context_usage` rows in the last 10 min before the heavy lane skips with `reason='query_rate_high'`. |
104
+ | `CLAWMEM_HEAVY_LANE_OBS_LIMIT` | 100 | **v0.8.0.** Phase 2 stale-first observation batch size. |
105
+ | `CLAWMEM_HEAVY_LANE_DED_LIMIT` | 40 | **v0.8.0.** Phase 3 stale-first deductive candidate batch size. |
106
+ | `CLAWMEM_HEAVY_LANE_SURPRISAL` | `false` | **v0.8.0.** When `true`, the heavy lane seeds Phase 2 with k-NN anomaly-ranked doc ids from `computeSurprisalScores` instead of stale-first ordering. Degrades to stale-first (`surprisal-fallback-stale` metric) on vaults without embeddings. |
99
107
  | `CLAWMEM_NUDGE_INTERVAL` | `15` | Prompts between lifecycle tool use before `<vault-nudge>` injection. 0 to disable. |
100
108
  | `CLAWMEM_MERGE_SCORE_NORMAL` | `0.93` | **v0.7.1.** Phase 2 merge-safety score threshold when candidate and existing anchors align. Merges above this normalized 3-gram cosine similarity are allowed. |
101
109
  | `CLAWMEM_MERGE_SCORE_STRICT` | `0.98` | **v0.7.1.** Strictest merge-safety score threshold (fallback when anchors are ambiguous). |
@@ -368,7 +376,7 @@ Pin, snooze, and forget are **manual MCP tools** — not automated. The agent sh
368
376
  - Do NOT pin everything — pin is for persistent high-priority items, not temporary boosting.
369
377
  - Do NOT forget memories to "clean up" — let confidence decay and contradiction detection handle it naturally.
370
378
  - Do NOT run `build_graphs` after every reindex — A-MEM creates per-doc links automatically. Only after bulk ingestion or when `intent_search` returns weak graph results.
371
- - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command (same category as `update`/`reindex`). Suggest it to the user when they mention old conversation exports, but let them run it. Bulk import has disk/embedding cost implications that need user consent.
379
+ - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command (same category as `update`/`reindex`). Suggest it to the user when they mention old conversation exports, but let them run it. Bulk import has disk/embedding cost implications that need user consent. **v0.7.2 adds `--synthesize`** — an opt-in post-import LLM fact extraction pass that turns raw conversation dumps into searchable structured decisions / preferences / milestones / problems with cross-fact relations. Off by default; also requires user consent because it drives additional LLM calls (one per conversation doc). Suggest both together when the user wants to get real value out of old chat exports, not just the raw dumps.
372
380
  - Do NOT use `diary_write` in Claude Code — hooks (`decision-extractor`, `handoff-generator`) capture this automatically. Diary is for non-hooked environments only (Hermes, Gemini, plain MCP clients).
373
381
  - Do NOT use `kg_query` for causal "why" questions — use `intent_search` or `memory_retrieve`. `kg_query` returns structured entity facts (SPO triples), not reasoning chains.
374
382
 
@@ -503,8 +511,10 @@ The `memory_relations` table is populated by multiple independent sources:
503
511
  | `buildTemporalBackbone()` | temporal | `build_graphs` MCP tool (manual) | Creation-order edges between all active docs. |
504
512
  | `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. |
505
513
  | 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. |
506
- | `consolidated_observations` | supporting, contradicts | Consolidation worker (background) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. **v0.7.1 safety gates:** name-aware merge gate uses entity-anchor comparison + 3-gram cosine similarity (dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) to prevent cross-entity merges ("Alice decided X" merging into "Bob decided X"). Merge-time contradiction gate runs deterministic heuristic + LLM check; blocked merges route to `CLAWMEM_CONTRADICTION_POLICY`=`link` (new row + `contradicts` edge, default) or `supersede` (old row `status='inactive'`, new row replaces). |
507
- | Deductive synthesis | supporting, contradicts | 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. **v0.7.1 anti-contamination wrapper:** every draft passes through deterministic pre-checks (empty conclusion, invalid source_indices, pool-only entity contamination via `entity_mentions` or lexical fallback) + LLM validator (fail-open with `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats exposed via `DeductiveSynthesisStats` (contaminationRejects, invalidIndexRejects, unsupportedRejects, emptyRejects, dedupSkipped, validatorFallbackAccepts). Contradictory dedupe matches are linked via `contradicts` edges. |
514
+ | `consolidated_observations` | supporting, contradicts | Consolidation worker (background, light + heavy lanes) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. **v0.7.1 safety gates:** name-aware merge gate uses entity-anchor comparison + 3-gram cosine similarity (dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) to prevent cross-entity merges ("Alice decided X" merging into "Bob decided X"). Merge-time contradiction gate runs deterministic heuristic + LLM check; blocked merges route to `CLAWMEM_CONTRADICTION_POLICY`=`link` (new row + `contradicts` edge, default) or `supersede` (old row `status='inactive'`, new row replaces). **v0.8.0:** the heavy lane calls `consolidateObservations(store, llm, { maxDocs, guarded: true, staleOnly: true })` — `guarded: true` forces merge-safety enforcement regardless of `CLAWMEM_MERGE_GUARD_DRY_RUN`, and `staleOnly: true` reorders candidates by `recall_stats.last_recalled_at ASC` so long-unseen docs bubble up first. Optional `candidateIds` filter plumbs k-NN anomaly ids from `computeSurprisalScores`. |
515
+ | Deductive synthesis | supporting, contradicts | Consolidation worker Phase 3 (background, every ~15 min in the light lane; batched in the heavy lane) | 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. **v0.7.1 anti-contamination wrapper:** every draft passes through deterministic pre-checks (empty conclusion, invalid source_indices, pool-only entity contamination via `entity_mentions` or lexical fallback) + LLM validator (fail-open with `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats exposed via `DeductiveSynthesisStats` (contaminationRejects, invalidIndexRejects, unsupportedRejects, emptyRejects, dedupSkipped, validatorFallbackAccepts). Contradictory dedupe matches are linked via `contradicts` edges. **v0.8.0:** heavy lane calls `generateDeductiveObservations(store, llm, { maxRecent, guarded: true, staleOnly: true })` for stale-first batching on large vaults. |
516
+ | Heavy maintenance lane journal | — | `clawmem` process with `CLAWMEM_HEAVY_LANE=true` (v0.8.0) | Writes a row to the `maintenance_runs` table for every scheduled heavy-lane attempt (including skips). Columns: `lane` (`heavy`), `phase` (`gate`/`consolidate`/`deductive`), `status` (`started`/`completed`/`failed`/`skipped`), `reason` (`outside_window`/`query_rate_high`/`lease_unavailable`), per-phase `selected`/`processed`/`created`/`null_call` counts, and `metrics_json` with selector type (`stale-first`/`surprisal`/`surprisal-fallback-stale`) + full `DeductiveSynthesisStats` on completed Phase 3 runs. Exclusivity is enforced via the new `worker_leases` table with atomic `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` acquisition, random 16-byte fencing tokens, and TTL reclaim. Gate uses the existing `context_usage` table (no `query_activity` table) and `recall_stats.last_recalled_at` for stale-first ordering. Off by default — requires explicit opt-in. |
517
+ | Conversation synthesis (`runConversationSynthesis`) | semantic, supporting, contradicts, causal, temporal, entity | `clawmem mine <dir> --synthesize` (opt-in, post-index) | **v0.7.2.** Two-pass LLM pipeline over freshly imported `content_type='conversation'` docs. Pass 1 extracts structured facts (decision/preference/milestone/problem) via `extractFactsFromConversation`, saves each via dedup-aware `saveMemory`, populates a local Set-based alias map. Pass 2 resolves cross-fact links against the local map first (fails closed on ambiguity — multi-candidate titles return unresolved), falls back to collection-scoped SQL lookup with LIMIT 2 ambiguity detection. Relations upsert via `ON CONFLICT DO UPDATE SET weight = MAX(weight, excluded.weight)` — idempotent on equal-weight reruns but monotonically accepts stronger later evidence. Synthesized fact paths are a pure function of `(sourceDocId, slug(title), short sha256(normalizedTitle))`, so reruns update in place instead of creating parallel rows. Counters split into `llmFailures` (null/thrown/invalid-JSON) vs `docsWithNoFacts` (valid-empty extraction). All failures non-fatal — never rolls back the mine import. |
508
518
 
509
519
  **Edge collision:** Both `generateMemoryLinks()` and `buildSemanticGraph()` insert `relation_type='semantic'`. PK is `(source_id, target_id, relation_type)` — first writer wins.
510
520
 
package/CLAUDE.md CHANGED
@@ -96,6 +96,14 @@ curl http://host:8090/v1/models
96
96
  | `CLAWMEM_ENABLE_AMEM` | enabled | A-MEM note construction + link generation during indexing. |
97
97
  | `CLAWMEM_ENABLE_CONSOLIDATION` | disabled | Background worker backfills unenriched docs. Needs long-lived MCP process. |
98
98
  | `CLAWMEM_CONSOLIDATION_INTERVAL` | 300000 | Worker interval in ms (min 15000). |
99
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane; off by default. |
100
+ | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
101
+ | `CLAWMEM_HEAVY_LANE_WINDOW_START` | (none) | **v0.8.0.** Start hour (0-23) of the quiet window. Unset → no window. |
102
+ | `CLAWMEM_HEAVY_LANE_WINDOW_END` | (none) | **v0.8.0.** End hour (0-23, exclusive) of the quiet window. Supports midnight wrap (22→6). |
103
+ | `CLAWMEM_HEAVY_LANE_MAX_USAGES` | 30 | **v0.8.0.** Max `context_usage` rows in the last 10 min before the heavy lane skips with `reason='query_rate_high'`. |
104
+ | `CLAWMEM_HEAVY_LANE_OBS_LIMIT` | 100 | **v0.8.0.** Phase 2 stale-first observation batch size. |
105
+ | `CLAWMEM_HEAVY_LANE_DED_LIMIT` | 40 | **v0.8.0.** Phase 3 stale-first deductive candidate batch size. |
106
+ | `CLAWMEM_HEAVY_LANE_SURPRISAL` | `false` | **v0.8.0.** When `true`, the heavy lane seeds Phase 2 with k-NN anomaly-ranked doc ids from `computeSurprisalScores` instead of stale-first ordering. Degrades to stale-first (`surprisal-fallback-stale` metric) on vaults without embeddings. |
99
107
  | `CLAWMEM_NUDGE_INTERVAL` | `15` | Prompts between lifecycle tool use before `<vault-nudge>` injection. 0 to disable. |
100
108
  | `CLAWMEM_MERGE_SCORE_NORMAL` | `0.93` | **v0.7.1.** Phase 2 merge-safety score threshold when candidate and existing anchors align. Merges above this normalized 3-gram cosine similarity are allowed. |
101
109
  | `CLAWMEM_MERGE_SCORE_STRICT` | `0.98` | **v0.7.1.** Strictest merge-safety score threshold (fallback when anchors are ambiguous). |
@@ -368,7 +376,7 @@ Pin, snooze, and forget are **manual MCP tools** — not automated. The agent sh
368
376
  - Do NOT pin everything — pin is for persistent high-priority items, not temporary boosting.
369
377
  - Do NOT forget memories to "clean up" — let confidence decay and contradiction detection handle it naturally.
370
378
  - Do NOT run `build_graphs` after every reindex — A-MEM creates per-doc links automatically. Only after bulk ingestion or when `intent_search` returns weak graph results.
371
- - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command (same category as `update`/`reindex`). Suggest it to the user when they mention old conversation exports, but let them run it. Bulk import has disk/embedding cost implications that need user consent.
379
+ - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command (same category as `update`/`reindex`). Suggest it to the user when they mention old conversation exports, but let them run it. Bulk import has disk/embedding cost implications that need user consent. **v0.7.2 adds `--synthesize`** — an opt-in post-import LLM fact extraction pass that turns raw conversation dumps into searchable structured decisions / preferences / milestones / problems with cross-fact relations. Off by default; also requires user consent because it drives additional LLM calls (one per conversation doc). Suggest both together when the user wants to get real value out of old chat exports, not just the raw dumps.
372
380
  - Do NOT use `diary_write` in Claude Code — hooks (`decision-extractor`, `handoff-generator`) capture this automatically. Diary is for non-hooked environments only (Hermes, Gemini, plain MCP clients).
373
381
  - Do NOT use `kg_query` for causal "why" questions — use `intent_search` or `memory_retrieve`. `kg_query` returns structured entity facts (SPO triples), not reasoning chains.
374
382
 
@@ -503,8 +511,10 @@ The `memory_relations` table is populated by multiple independent sources:
503
511
  | `buildTemporalBackbone()` | temporal | `build_graphs` MCP tool (manual) | Creation-order edges between all active docs. |
504
512
  | `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. |
505
513
  | 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. |
506
- | `consolidated_observations` | supporting, contradicts | Consolidation worker (background) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. **v0.7.1 safety gates:** name-aware merge gate uses entity-anchor comparison + 3-gram cosine similarity (dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) to prevent cross-entity merges ("Alice decided X" merging into "Bob decided X"). Merge-time contradiction gate runs deterministic heuristic + LLM check; blocked merges route to `CLAWMEM_CONTRADICTION_POLICY`=`link` (new row + `contradicts` edge, default) or `supersede` (old row `status='inactive'`, new row replaces). |
507
- | Deductive synthesis | supporting, contradicts | 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. **v0.7.1 anti-contamination wrapper:** every draft passes through deterministic pre-checks (empty conclusion, invalid source_indices, pool-only entity contamination via `entity_mentions` or lexical fallback) + LLM validator (fail-open with `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats exposed via `DeductiveSynthesisStats` (contaminationRejects, invalidIndexRejects, unsupportedRejects, emptyRejects, dedupSkipped, validatorFallbackAccepts). Contradictory dedupe matches are linked via `contradicts` edges. |
514
+ | `consolidated_observations` | supporting, contradicts | Consolidation worker (background, light + heavy lanes) | 3-tier consolidation: facts → observations → mental models. Observations track `proof_count`, `trend` (STABLE/STRENGTHENING/WEAKENING/STALE), and source links. **v0.7.1 safety gates:** name-aware merge gate uses entity-anchor comparison + 3-gram cosine similarity (dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) to prevent cross-entity merges ("Alice decided X" merging into "Bob decided X"). Merge-time contradiction gate runs deterministic heuristic + LLM check; blocked merges route to `CLAWMEM_CONTRADICTION_POLICY`=`link` (new row + `contradicts` edge, default) or `supersede` (old row `status='inactive'`, new row replaces). **v0.8.0:** the heavy lane calls `consolidateObservations(store, llm, { maxDocs, guarded: true, staleOnly: true })` — `guarded: true` forces merge-safety enforcement regardless of `CLAWMEM_MERGE_GUARD_DRY_RUN`, and `staleOnly: true` reorders candidates by `recall_stats.last_recalled_at ASC` so long-unseen docs bubble up first. Optional `candidateIds` filter plumbs k-NN anomaly ids from `computeSurprisalScores`. |
515
+ | Deductive synthesis | supporting, contradicts | Consolidation worker Phase 3 (background, every ~15 min in the light lane; batched in the heavy lane) | 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. **v0.7.1 anti-contamination wrapper:** every draft passes through deterministic pre-checks (empty conclusion, invalid source_indices, pool-only entity contamination via `entity_mentions` or lexical fallback) + LLM validator (fail-open with `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats exposed via `DeductiveSynthesisStats` (contaminationRejects, invalidIndexRejects, unsupportedRejects, emptyRejects, dedupSkipped, validatorFallbackAccepts). Contradictory dedupe matches are linked via `contradicts` edges. **v0.8.0:** heavy lane calls `generateDeductiveObservations(store, llm, { maxRecent, guarded: true, staleOnly: true })` for stale-first batching on large vaults. |
516
+ | Heavy maintenance lane journal | — | `clawmem` process with `CLAWMEM_HEAVY_LANE=true` (v0.8.0) | Writes a row to the `maintenance_runs` table for every scheduled heavy-lane attempt (including skips). Columns: `lane` (`heavy`), `phase` (`gate`/`consolidate`/`deductive`), `status` (`started`/`completed`/`failed`/`skipped`), `reason` (`outside_window`/`query_rate_high`/`lease_unavailable`), per-phase `selected`/`processed`/`created`/`null_call` counts, and `metrics_json` with selector type (`stale-first`/`surprisal`/`surprisal-fallback-stale`) + full `DeductiveSynthesisStats` on completed Phase 3 runs. Exclusivity is enforced via the new `worker_leases` table with atomic `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` acquisition, random 16-byte fencing tokens, and TTL reclaim. Gate uses the existing `context_usage` table (no `query_activity` table) and `recall_stats.last_recalled_at` for stale-first ordering. Off by default — requires explicit opt-in. |
517
+ | Conversation synthesis (`runConversationSynthesis`) | semantic, supporting, contradicts, causal, temporal, entity | `clawmem mine <dir> --synthesize` (opt-in, post-index) | **v0.7.2.** Two-pass LLM pipeline over freshly imported `content_type='conversation'` docs. Pass 1 extracts structured facts (decision/preference/milestone/problem) via `extractFactsFromConversation`, saves each via dedup-aware `saveMemory`, populates a local Set-based alias map. Pass 2 resolves cross-fact links against the local map first (fails closed on ambiguity — multi-candidate titles return unresolved), falls back to collection-scoped SQL lookup with LIMIT 2 ambiguity detection. Relations upsert via `ON CONFLICT DO UPDATE SET weight = MAX(weight, excluded.weight)` — idempotent on equal-weight reruns but monotonically accepts stronger later evidence. Synthesized fact paths are a pure function of `(sourceDocId, slug(title), short sha256(normalizedTitle))`, so reruns update in place instead of creating parallel rows. Counters split into `llmFailures` (null/thrown/invalid-JSON) vs `docsWithNoFacts` (valid-empty extraction). All failures non-fatal — never rolls back the mine import. |
508
518
 
509
519
  **Edge collision:** Both `generateMemoryLinks()` and `buildSemanticGraph()` insert `relation_type='semantic'`. PK is `(source_id, target_id, relation_type)` — first writer wins.
510
520
 
package/README.md CHANGED
@@ -19,7 +19,7 @@ ClawMem turns your markdown notes, project docs, and research dumps into persist
19
19
  - **Surfaces relevant context** on every prompt (context-surfacing hook)
20
20
  - **Bootstraps sessions** with your profile, latest handoff, recent decisions, and stale notes
21
21
  - **Captures decisions, preferences, milestones, and problems** from session transcripts using a local GGUF observer model
22
- - **Imports conversation exports** from Claude Code, ChatGPT, Claude.ai, Slack, and plain text via `clawmem mine`
22
+ - **Imports conversation exports** from Claude Code, ChatGPT, Claude.ai, Slack, and plain text via `clawmem mine`, with optional post-import LLM fact extraction (`--synthesize`) that pulls structured decisions / preferences / milestones / problems and cross-fact links out of otherwise full-text conversation dumps (v0.7.2)
23
23
  - **Generates handoffs** at session end so the next session can pick up where you left off
24
24
  - **Learns what matters** via a feedback loop that boosts referenced notes and decays unused ones
25
25
  - **Guards against prompt injection** in surfaced content
@@ -43,6 +43,7 @@ ClawMem turns your markdown notes, project docs, and research dumps into persist
43
43
  - **Manages document lifecycle** — policy-driven archival sweeps with restore capability
44
44
  - **Auto-routes queries** via `memory_retrieve` — classifies intent and dispatches to the optimal search backend
45
45
  - **Syncs project issues** from Beads issue trackers into searchable memory
46
+ - **Runs a quiet-window heavy maintenance lane** — a second consolidation worker, off by default behind `CLAWMEM_HEAVY_LANE=true`, that runs on a longer interval only inside a configurable hour window. Gated by `context_usage` query-rate so it never competes for CPU/GPU with interactive sessions, scoped exclusively via DB-backed `worker_leases`, stale-first by default with an optional surprisal selector, and journals every attempt in `maintenance_runs` for operator visibility (v0.8.0)
46
47
 
47
48
  Runs fully local with no API keys and no cloud services. Integrates via Claude Code hooks and MCP tools, as an OpenClaw ContextEngine plugin, or as a Hermes Agent MemoryProvider plugin. All modes share the same vault for cross-runtime memory. Works with any MCP-compatible client.
48
49
 
@@ -66,6 +67,34 @@ Five independent safety gates around the consolidation pipeline and context surf
66
67
  - **Anti-contamination deductive synthesis** — every Phase 3 draft runs through a three-layer validator: deterministic pre-checks (empty conclusion, invalid source_indices, pool-only entity contamination via `entity_mentions`) + LLM validator (fail-open with `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats exposed via `DeductiveSynthesisStats` so Phase 3 yield can be diagnosed without enabling extra logging.
67
68
  - **Context instruction + relationship snippets** — `context-surfacing` now always prepends an `<instruction>` block framing the surfaced facts as background knowledge the model already holds, and appends an optional `<relationships>` block listing memory-graph edges where BOTH endpoints are in the surfaced doc set. The relationships block is the first thing dropped when the payload would overflow `CLAWMEM_PROFILE`'s token budget, preserving facts-first behaviour while giving the model graph-level reasoning hooks directly in-prompt.
68
69
 
70
+ ### v0.7.2 Post-Import Conversation Synthesis
71
+
72
+ Opt-in LLM pass that runs **after** `clawmem mine` finishes indexing an imported collection. Operates on the freshly imported `content_type='conversation'` documents and extracts structured knowledge facts (decisions / preferences / milestones / problems) plus cross-fact relations, writing each fact as a first-class searchable document alongside the raw conversation exchanges. See [post-import synthesis](docs/concepts/architecture.md#post-import-conversation-synthesis-v072) for the architectural walkthrough.
73
+
74
+ - **New CLI flag** — `clawmem mine <dir> --synthesize [--synthesis-max-docs N]`. Off by default. When omitted, existing mine behaviour is byte-identical to v0.7.1.
75
+ - **Two-pass pipeline** — Pass 1 extracts facts per conversation via the existing LLM, saves each via dedup-aware `saveMemory`, and populates a local alias map. Pass 2 resolves cross-fact links against the local map first, falling back to collection-scoped SQL lookup. Forward references (link to a fact extracted later in the same run) are resolved correctly.
76
+ - **Idempotent reruns** — synthesized fact paths are a pure function of `(sourceDocId, slug(title), short sha256(normalizedTitle))`, so reruns over the same conversation batch hit the `saveMemory` update branch instead of creating parallel rows. Same-slug collisions are disambiguated by the stable hash suffix, not encounter order.
77
+ - **Fail-closed link resolution** — when two different facts claim the same normalized title or alias, the resolver treats the link as ambiguous and counts it unresolved. Pre-existing docs with duplicate titles in the collection do not silently bind either.
78
+ - **Weight-monotonic relation upsert** — `memory_relations` insert uses `ON CONFLICT DO UPDATE SET weight = MAX(weight, excluded.weight)`, which is idempotent on equal-weight reruns but still accepts stronger later evidence without double-counting.
79
+ - **Non-fatal failure model** — any LLM failure, JSON parse error, saveMemory collision, or relation insert error is counted and logged, never re-thrown. Synthesis failure after `indexCollection` commits does not roll back the mine import.
80
+ - **Split operator counters** — `llmFailures` counts actual LLM path failures (null, thrown, non-array JSON), while `docsWithNoFacts` counts docs where the LLM responded validly but returned zero structured facts. Previously these were conflated as `nullCalls`.
81
+
82
+ Adds +63 tests (46 unit + 5 integration + 12 regression) on top of the v0.7.1 baseline.
83
+
84
+ ### v0.8.0 Quiet-Window Heavy Maintenance Lane
85
+
86
+ A second, longer-interval consolidation worker that keeps Phase 2 + Phase 3 running on large vaults without starving interactive sessions. Off by default — set `CLAWMEM_HEAVY_LANE=true` to enable. The existing 5-minute light-lane worker is unchanged. See [heavy maintenance lane](docs/concepts/architecture.md#heavy-maintenance-lane-v080) for the architectural walkthrough.
87
+
88
+ - **Quiet-window gating** — the heavy lane only runs inside the hours set by `CLAWMEM_HEAVY_LANE_WINDOW_START` / `CLAWMEM_HEAVY_LANE_WINDOW_END` (0-23). Supports midnight wraparound (e.g., 22→6). Null on either bound means "always in window".
89
+ - **Query-rate gating via `context_usage`** — counts hook injections in the last 10 minutes and skips the tick when the rate exceeds `CLAWMEM_HEAVY_LANE_MAX_USAGES` (default 30). No new `query_activity` table; reuses v0.7.0 telemetry.
90
+ - **DB-backed worker leases** — exclusivity enforced via a new `worker_leases` table with atomic `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?` acquisition, random 16-byte fencing tokens, and TTL reclaim. Safe under multi-process contention; any SQLite error translates to a `lease_unavailable` skip rather than a thrown exception.
91
+ - **Stale-first selection** — Phase 2 and Phase 3 reorder their candidate sets by `COALESCE(recall_stats.last_recalled_at, documents.last_accessed_at, documents.modified_at) ASC` so long-unseen docs bubble up first. Empty `recall_stats` falls through to access-time without erroring.
92
+ - **Optional surprisal selector** — `CLAWMEM_HEAVY_LANE_SURPRISAL=true` plumbs k-NN anomaly-ranked doc ids (via the existing `computeSurprisalScores`) into Phase 2 as an explicit `candidateIds` filter. Degrades to stale-first on vaults without embeddings and logs `selector: 'surprisal-fallback-stale'` in the journal.
93
+ - **`maintenance_runs` journal** — every scheduled attempt writes a row: `status` (`started`/`completed`/`failed`/`skipped`), `reason` for skips, selected/processed/created/null_call counts, and a `metrics_json` payload with selector type and full `DeductiveSynthesisStats` breakdown. Operators can reconstruct any lane decision without reading worker logs.
94
+ - **Force-enforce merge gate** — the heavy lane passes `guarded: true` to `consolidateObservations`, which overrides `CLAWMEM_MERGE_GUARD_DRY_RUN` inside `findSimilarConsolidation` so experimenting operators cannot weaken heavy-lane enforcement via env flag.
95
+
96
+ Adds +56 tests (13 worker-lease + 35 maintenance unit + 8 maintenance integration) on top of the v0.7.2 baseline.
97
+
69
98
  ## Architecture
70
99
 
71
100
  <p align="center">
@@ -657,7 +686,7 @@ clawmem collection list List collections
657
686
  clawmem collection remove <name> Remove a collection
658
687
 
659
688
  clawmem update [--pull] [--embed] Incremental re-scan
660
- clawmem mine <dir> [-c name] [--embed] Import conversation exports (Claude, ChatGPT, Slack)
689
+ clawmem mine <dir> [-c name] [--embed] [--synthesize] Import conversation exports (--synthesize runs post-import LLM fact extraction, v0.7.2)
661
690
  clawmem embed [-f] Generate fragment embeddings
662
691
  clawmem reindex [--force] Full re-index
663
692
  clawmem watch File watcher daemon
package/SKILL.md CHANGED
@@ -92,6 +92,12 @@ curl http://host:8090/v1/models
92
92
  | `CLAWMEM_MERGE_GUARD_DRY_RUN` | `false` | **v0.7.1.** When `true`, Phase 2 merge-safety rejections are logged but not enforced — use for calibration. |
93
93
  | `CLAWMEM_CONTRADICTION_POLICY` | `link` | **v0.7.1.** How the merge-time contradiction gate handles a blocked merge. `link` (default) keeps both rows + inserts `contradicts` edge. `supersede` marks the old row `status='inactive'`. |
94
94
  | `CLAWMEM_CONTRADICTION_MIN_CONFIDENCE` | `0.5` | **v0.7.1.** Minimum combined heuristic+LLM confidence required before the contradiction gate blocks a merge. |
95
+ | `CLAWMEM_HEAVY_LANE` | disabled | **v0.8.0.** Enable the quiet-window heavy maintenance worker — a second, longer-interval consolidation lane with DB-backed `worker_leases` exclusivity, stale-first batching, and `maintenance_runs` journaling. Runs alongside the light lane. |
96
+ | `CLAWMEM_HEAVY_LANE_INTERVAL` | 1800000 | **v0.8.0.** Heavy-lane tick interval in ms (min 30000, default 30 min). |
97
+ | `CLAWMEM_HEAVY_LANE_WINDOW_START` / `_END` | (none) | **v0.8.0.** Start/end hours (0-23) of the quiet window. Supports midnight wrap (22→6). Null on either bound = always in window. |
98
+ | `CLAWMEM_HEAVY_LANE_MAX_USAGES` | 30 | **v0.8.0.** Max `context_usage` rows in the last 10 min before the heavy lane skips with `reason='query_rate_high'`. |
99
+ | `CLAWMEM_HEAVY_LANE_OBS_LIMIT` / `_DED_LIMIT` | 100 / 40 | **v0.8.0.** Phase 2 / Phase 3 stale-first batch sizes. |
100
+ | `CLAWMEM_HEAVY_LANE_SURPRISAL` | `false` | **v0.8.0.** When `true`, the heavy lane seeds Phase 2 with k-NN anomaly-ranked doc ids from `computeSurprisalScores` instead of stale-first ordering. Degrades to stale-first on vaults without embeddings. |
95
101
 
96
102
  **Note:** The `bin/clawmem` wrapper sets all endpoint defaults. Always use the wrapper — never `bun run src/clawmem.ts` directly.
97
103
 
@@ -526,8 +532,10 @@ mcp__clawmem__vsearch(query, collection="name", compact=true) # vector
526
532
  | Beads `syncBeadsIssues()` | causal, supporting, semantic | `beads_sync` MCP or watcher | Queries `bd` CLI (Dolt backend). |
527
533
  | `buildTemporalBackbone()` | temporal | `build_graphs` MCP (manual) | Creation-order edges. |
528
534
  | `buildSemanticGraph()` | semantic | `build_graphs` MCP (manual) | Pure cosine similarity. A-MEM edges take precedence (first-writer wins). |
529
- | `consolidated_observations` | supporting, contradicts | Consolidation worker (background) | **v0.7.1 safety gates:** Phase 2 name-aware merge gate (entity anchors + 3-gram cosine, dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) blocks cross-entity merges. Merge-time contradiction gate (heuristic + LLM) routes blocked merges to `link` (default, inserts `contradicts` edge) or `supersede` (old row `status='inactive'`) via `CLAWMEM_CONTRADICTION_POLICY`. |
530
- | Deductive synthesis | supporting, contradicts | Consolidation worker Phase 3 (every ~15 min) | Combines 2-3 related observations (decision/preference/milestone/problem, last 7 days) into `content_type='deductive'` docs. **v0.7.1 anti-contamination:** deterministic pre-checks (empty/invalid_indices/pool-only entity contamination) + LLM validator (fail-open, `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats via `DeductiveSynthesisStats`. Contradictory dedupe matches linked via `contradicts` edges. |
535
+ | `consolidated_observations` | supporting, contradicts | Consolidation worker (background, light + heavy lanes) | **v0.7.1 safety gates:** Phase 2 name-aware merge gate (entity anchors + 3-gram cosine, dual-threshold `CLAWMEM_MERGE_SCORE_NORMAL`=0.93 / `_STRICT`=0.98) blocks cross-entity merges. Merge-time contradiction gate (heuristic + LLM) routes blocked merges to `link` (default, inserts `contradicts` edge) or `supersede` (old row `status='inactive'`) via `CLAWMEM_CONTRADICTION_POLICY`. **v0.8.0:** heavy lane calls `consolidateObservations(store, llm, { maxDocs, guarded: true, staleOnly: true, candidateIds? })` — `guarded: true` forces enforcement regardless of `CLAWMEM_MERGE_GUARD_DRY_RUN`, `staleOnly: true` reorders by `recall_stats.last_recalled_at ASC`, and optional `candidateIds` plumbs surprisal-selector output. |
536
+ | Deductive synthesis | supporting, contradicts | Consolidation worker Phase 3 (every ~15 min in light lane; batched in heavy lane) | Combines 2-3 related observations (decision/preference/milestone/problem, last 7 days) into `content_type='deductive'` docs. **v0.7.1 anti-contamination:** deterministic pre-checks (empty/invalid_indices/pool-only entity contamination) + LLM validator (fail-open, `validatorFallbackAccepts` counter) + dedupe. Per-reason rejection stats via `DeductiveSynthesisStats`. Contradictory dedupe matches linked via `contradicts` edges. **v0.8.0:** heavy lane calls `generateDeductiveObservations(store, llm, { maxRecent, guarded: true, staleOnly: true })` for stale-first batching. |
537
+ | Conversation synthesis | semantic, supporting, contradicts, causal, temporal, entity | `clawmem mine <dir> --synthesize` (opt-in, post-index) | **v0.7.2.** Two-pass LLM pipeline over freshly imported `content_type='conversation'` docs. Pass 1 extracts structured decision/preference/milestone/problem facts + aliases + cross-fact links, saves via dedup-aware `saveMemory`, populates ambiguity-aware local Set map. Pass 2 resolves links (local first, SQL fallback with `LIMIT 2` ambiguity detection), upserts relations via `ON CONFLICT DO UPDATE SET weight = MAX(weight, excluded.weight)`. Synthesized paths are a pure function of `(sourceDocId, slug(title), short sha256(normalizedTitle))` so reruns update in place. All failures non-fatal. Counters split: `llmFailures` (LLM/parse error) vs `docsWithNoFacts` (valid empty extraction). |
538
+ | Heavy maintenance lane journal | — | `CLAWMEM_HEAVY_LANE=true` (v0.8.0) | Writes a row to `maintenance_runs` for every scheduled heavy-lane attempt (including skips). Columns: `lane` (`heavy`), `phase` (`gate`/`consolidate`/`deductive`), `status` (`started`/`completed`/`failed`/`skipped`), `reason` (`outside_window`/`query_rate_high`/`lease_unavailable`), per-phase counts, and `metrics_json` with selector type (`stale-first`/`surprisal`/`surprisal-fallback-stale`) + full `DeductiveSynthesisStats`. Exclusivity via new `worker_leases` table (atomic `INSERT ... ON CONFLICT DO UPDATE ... WHERE expires_at <= ?`, 16-byte fencing tokens, TTL reclaim). Gate reuses `context_usage` (no `query_activity` table). |
531
539
 
532
540
  **Graph traversal asymmetry:** `adaptiveTraversal()` traverses all edge types outbound (source->target) but only `semantic` and `entity` inbound.
533
541
 
@@ -589,7 +597,7 @@ Phase 3 deductive synthesis applies the same `contradicts` link for any draft th
589
597
  - Do NOT pin everything — pin is for persistent high-priority items.
590
598
  - Do NOT forget memories to "clean up" — let confidence decay and contradiction detection handle it.
591
599
  - Do NOT run `build_graphs` after every reindex — A-MEM creates per-doc links automatically.
592
- - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command. Suggest it to the user when they mention old conversation exports, but let them run it.
600
+ - Do NOT run `clawmem mine` autonomously — it is a bulk ingestion command. Suggest it to the user when they mention old conversation exports, but let them run it. **v0.7.2 adds `--synthesize`** — an opt-in post-import LLM fact extraction pass. Also requires user consent because it drives one extra LLM call per conversation doc. Suggest both together when the user wants searchable structured memory from raw chat exports.
593
601
  - Do NOT use `diary_write` in Claude Code — hooks capture this automatically. Diary is for non-hooked environments only (Hermes, Gemini, plain MCP).
594
602
  - Do NOT use `kg_query` for causal "why" questions — use `intent_search` or `memory_retrieve`. `kg_query` returns structured entity facts (SPO triples), not reasoning chains.
595
603
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.7.1",
3
+ "version": "0.8.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
@@ -242,12 +242,14 @@ async function cmdMine(args: string[]) {
242
242
  collection: { type: "string", short: "c" },
243
243
  embed: { type: "boolean", default: false },
244
244
  "dry-run": { type: "boolean", default: false },
245
+ synthesize: { type: "boolean", default: false },
246
+ "synthesis-max-docs": { type: "string" },
245
247
  },
246
248
  allowPositionals: true,
247
249
  });
248
250
 
249
251
  const dir = positionals[0];
250
- if (!dir) die("Usage: clawmem mine <directory> [-c collection-name] [--embed] [--dry-run]");
252
+ if (!dir) die("Usage: clawmem mine <directory> [-c collection-name] [--embed] [--dry-run] [--synthesize] [--synthesis-max-docs N]");
251
253
  const absDir = pathResolve(dir);
252
254
  if (!existsSync(absDir)) die(`Directory not found: ${absDir}`);
253
255
 
@@ -319,6 +321,32 @@ async function cmdMine(args: string[]) {
319
321
  const stats = await indexCollection(s, collectionName, stagingDir, "**/*.md");
320
322
  console.log(` ${c.green}+${stats.added}${c.reset} added, ${c.yellow}~${stats.updated}${c.reset} updated, ${c.dim}=${stats.unchanged}${c.reset} unchanged`);
321
323
 
324
+ // Ext 4 — post-import conversation synthesis (opt-in via --synthesize)
325
+ // Runs AFTER indexCollection has committed. Failure is non-fatal and never
326
+ // rolls back the mine import.
327
+ if (values.synthesize) {
328
+ const maxDocs = values["synthesis-max-docs"]
329
+ ? parseInt(values["synthesis-max-docs"] as string, 10)
330
+ : undefined;
331
+ console.log(`\n${c.cyan}Running post-import conversation synthesis${c.reset}`);
332
+ try {
333
+ const { runConversationSynthesis } = await import("./conversation-synthesis.ts");
334
+ const llm = getDefaultLlamaCpp();
335
+ const synthResult = await runConversationSynthesis(s, llm, {
336
+ collection: collectionName,
337
+ maxDocs: Number.isFinite(maxDocs) && (maxDocs as number) > 0 ? maxDocs : undefined,
338
+ });
339
+ console.log(
340
+ ` ${c.green}${synthResult.factsSaved}${c.reset} facts saved, ` +
341
+ `${c.green}${synthResult.linksResolved}${c.reset} links resolved, ` +
342
+ `${c.yellow}${synthResult.linksUnresolved}${c.reset} unresolved, ` +
343
+ `${c.dim}${synthResult.llmFailures} LLM failure(s), ${synthResult.docsWithNoFacts} docs with no facts${c.reset}`,
344
+ );
345
+ } catch (err) {
346
+ console.log(` ${c.yellow}Synthesis failed (mine import preserved):${c.reset} ${err}`);
347
+ }
348
+ }
349
+
322
350
  if (values.embed) {
323
351
  console.log();
324
352
  await cmdEmbed([]);
@@ -2500,7 +2528,7 @@ ${c.bold}Setup:${c.reset}
2500
2528
 
2501
2529
  ${c.bold}Indexing:${c.reset}
2502
2530
  clawmem update [--pull] [--embed] Re-scan collections (--embed auto-embeds)
2503
- clawmem mine <dir> [-c name] [--embed] Import conversation exports (Claude, ChatGPT, Slack)
2531
+ clawmem mine <dir> [-c name] [--embed] [--synthesize] Import conversation exports (Claude, ChatGPT, Slack); --synthesize runs post-import LLM fact extraction
2504
2532
  clawmem embed [-f] Generate fragment embeddings
2505
2533
  clawmem reindex [--force] [--enrich] Full re-index (--enrich: run entity extraction + links on all docs)
2506
2534
  clawmem watch File watcher daemon
@@ -114,6 +114,50 @@ interface ObservationCluster {
114
114
  collection: string;
115
115
  }
116
116
 
117
+ /**
118
+ * Phase 2 consolidation option bag (v0.8.0 Ext 5). All fields optional;
119
+ * omitting the bag reproduces pre-Ext-5 behavior exactly.
120
+ *
121
+ * - `maxDocs` override for the observation batch size (default 50)
122
+ * - `guarded` when true, force merge-safety enforcement regardless of
123
+ * the `CLAWMEM_MERGE_GUARD_DRY_RUN` env var. Heavy-lane
124
+ * callers pass this so experimenting operators cannot
125
+ * weaken the heavy-lane gate by toggling an env flag.
126
+ * - `staleOnly` when true, order observations by
127
+ * `recall_stats.last_recalled_at ASC` (with
128
+ * `documents.last_accessed_at` fallback) so least-recalled
129
+ * documents are processed first instead of most-recent.
130
+ * - `candidateIds` when non-empty, restricts the Phase 2 candidate set to
131
+ * exactly these document ids. Heavy lane uses this to
132
+ * plumb surprisal-selector output into consolidation so
133
+ * `useSurprisalSelector: true` actually feeds anomaly-first
134
+ * candidates instead of just relabeling the default batch.
135
+ */
136
+ export interface ConsolidateOptions {
137
+ maxDocs?: number;
138
+ guarded?: boolean;
139
+ staleOnly?: boolean;
140
+ candidateIds?: number[];
141
+ }
142
+
143
+ /**
144
+ * Phase 3 deductive synthesis option bag (v0.8.0 Ext 5). All fields optional;
145
+ * omitting the bag reproduces pre-Ext-5 behavior exactly.
146
+ *
147
+ * - `maxRecent` override for the recent-observation window size (default 20)
148
+ * - `guarded` forward-compat marker; currently a no-op because the Phase 3
149
+ * deductive guardrails always enforce their deterministic
150
+ * pre-checks + LLM validator regardless of this flag
151
+ * - `staleOnly` when true, order candidate observations by
152
+ * `recall_stats.last_recalled_at ASC` with
153
+ * `documents.last_accessed_at` fallback
154
+ */
155
+ export interface DeductiveOptions {
156
+ maxRecent?: number;
157
+ guarded?: boolean;
158
+ staleOnly?: boolean;
159
+ }
160
+
117
161
  // =============================================================================
118
162
  // Worker State
119
163
  // =============================================================================
@@ -247,17 +291,71 @@ async function backfillAmem(store: Store, llm: LlamaCpp): Promise<void> {
247
291
  /**
248
292
  * Find clusters of related observations and synthesize into consolidated observations.
249
293
  * Runs per-collection to prevent cross-vault false merges.
294
+ *
295
+ * `opts` (v0.8.0 Ext 5) lets the heavy-maintenance lane override batch size,
296
+ * force merge-safety enforcement, and switch to stale-first ordering. The
297
+ * zero-arg call path (internal light-lane tick) preserves pre-Ext-5 behavior.
250
298
  */
251
- async function consolidateObservations(store: Store, llm: LlamaCpp): Promise<void> {
252
- console.log("[consolidation] Starting observation consolidation");
299
+ export async function consolidateObservations(
300
+ store: Store,
301
+ llm: LlamaCpp,
302
+ opts: ConsolidateOptions = {},
303
+ ): Promise<void> {
304
+ const maxDocs = opts.maxDocs && opts.maxDocs > 0 ? opts.maxDocs : 50;
305
+ const staleOnly = opts.staleOnly === true;
306
+ const guarded = opts.guarded === true;
307
+ const candidateIds = opts.candidateIds && opts.candidateIds.length > 0
308
+ ? opts.candidateIds
309
+ : null;
310
+
311
+ // Early exit when caller passed candidateIds: [] explicitly. An empty
312
+ // array means "the selector found nothing" — not "select everything".
313
+ if (opts.candidateIds && opts.candidateIds.length === 0) {
314
+ console.log("[consolidation] Empty candidateIds array — nothing to consolidate");
315
+ return;
316
+ }
317
+
318
+ console.log(
319
+ `[consolidation] Starting observation consolidation (maxDocs=${maxDocs}, ` +
320
+ `staleOnly=${staleOnly}, guarded=${guarded}, ` +
321
+ `candidateIds=${candidateIds ? candidateIds.length : "null"})`,
322
+ );
323
+
324
+ // Base SELECT — observation-type docs not yet consolidated.
325
+ // Stale-first ordering joins recall_stats.last_recalled_at ASC with a
326
+ // documents.last_accessed_at fallback so long-unseen docs bubble up first.
327
+ // Default ordering (modified_at DESC) preserves pre-Ext-5 light-lane semantics.
328
+ const orderBy = staleOnly
329
+ ? `ORDER BY
330
+ d.collection,
331
+ COALESCE(rs.last_recalled_at, d.last_accessed_at, d.modified_at) ASC,
332
+ d.modified_at ASC`
333
+ : `ORDER BY d.collection, d.modified_at DESC`;
334
+
335
+ const joinClause = staleOnly
336
+ ? `LEFT JOIN recall_stats rs ON rs.doc_id = d.id`
337
+ : ``;
338
+
339
+ // When candidateIds is provided, restrict the SELECT to those docs. This
340
+ // is how the heavy lane plumbs surprisal-selector output into Phase 2:
341
+ // select anomaly-first IDs via computeSurprisalScores, then ask
342
+ // consolidateObservations to limit its pattern detection to that subset.
343
+ const candidateFilter = candidateIds
344
+ ? `AND d.id IN (${candidateIds.map(() => "?").join(",")})`
345
+ : ``;
346
+
347
+ const sqlParams: (number | string)[] = candidateIds
348
+ ? [...candidateIds, maxDocs]
349
+ : [maxDocs];
253
350
 
254
- // Find observation-type documents not yet consolidated
255
351
  const observations = store.db.prepare(`
256
352
  SELECT d.id, d.title, d.facts, d.amem_context as context, d.modified_at, d.collection
257
353
  FROM documents d
354
+ ${joinClause}
258
355
  WHERE d.active = 1
259
356
  AND d.content_type = 'observation'
260
357
  AND d.facts IS NOT NULL
358
+ ${candidateFilter}
261
359
  AND d.id NOT IN (
262
360
  SELECT value FROM (
263
361
  SELECT json_each.value as value
@@ -265,9 +363,9 @@ async function consolidateObservations(store: Store, llm: LlamaCpp): Promise<voi
265
363
  WHERE co.status = 'active'
266
364
  )
267
365
  )
268
- ORDER BY d.collection, d.modified_at DESC
269
- LIMIT 50
270
- `).all() as { id: number; title: string; facts: string; context: string; modified_at: string; collection: string }[];
366
+ ${orderBy}
367
+ LIMIT ?
368
+ `).all(...sqlParams) as { id: number; title: string; facts: string; context: string; modified_at: string; collection: string }[];
271
369
 
272
370
  if (observations.length === 0) {
273
371
  console.log("[consolidation] No unconsolidated observations found");
@@ -288,7 +386,7 @@ async function consolidateObservations(store: Store, llm: LlamaCpp): Promise<voi
288
386
  if (cluster.docs.length < 2) continue; // Need at least 2 observations to consolidate
289
387
 
290
388
  try {
291
- await synthesizeCluster(store, llm, cluster);
389
+ await synthesizeCluster(store, llm, cluster, { guarded });
292
390
  } catch (err) {
293
391
  console.error(`[consolidation] Failed to consolidate cluster for ${collection}:`, err);
294
392
  }
@@ -302,11 +400,15 @@ async function consolidateObservations(store: Store, llm: LlamaCpp): Promise<voi
302
400
 
303
401
  /**
304
402
  * Synthesize a cluster of observations into consolidated observations using LLM.
403
+ *
404
+ * `opts.guarded` (v0.8.0 Ext 5) forces merge-safety enforcement inside
405
+ * `findSimilarConsolidation` regardless of `CLAWMEM_MERGE_GUARD_DRY_RUN`.
305
406
  */
306
407
  async function synthesizeCluster(
307
408
  store: Store,
308
409
  llm: LlamaCpp,
309
- cluster: ObservationCluster
410
+ cluster: ObservationCluster,
411
+ opts: { guarded?: boolean } = {},
310
412
  ): Promise<void> {
311
413
  const docsText = cluster.docs.map((d, i) =>
312
414
  `${i + 1}. [${d.modified_at}] "${d.title}"\n Facts: ${d.facts?.slice(0, 300) || 'none'}\n Context: ${d.context?.slice(0, 200) || 'none'}`
@@ -364,11 +466,13 @@ Return ONLY the JSON array. /no_think`;
364
466
 
365
467
  // Check for existing similar consolidated observation (avoid duplicates).
366
468
  // Two-stage gate: Jaccard shortlist + name-aware merge safety (Ext 3).
469
+ // `guarded` forces gate enforcement when the heavy lane calls us.
367
470
  const existing = findSimilarConsolidation(
368
471
  store,
369
472
  pattern.observation,
370
473
  cluster.collection,
371
- sourceDocIds
474
+ sourceDocIds,
475
+ opts.guarded === true,
372
476
  );
373
477
  if (existing) {
374
478
  // Ext 2: contradiction gate. Before merging into an existing
@@ -524,7 +628,8 @@ export function findSimilarConsolidation(
524
628
  store: Store,
525
629
  observation: string,
526
630
  collection: string,
527
- candidateSourceDocIds: number[]
631
+ candidateSourceDocIds: number[],
632
+ forceEnforce: boolean = false,
528
633
  ): { id: number; observation: string; source_doc_ids: string } | null {
529
634
  // ORDER BY id ASC makes "first shortlist hit" deterministic across
530
635
  // SQLite plan changes — the dry-run legacy parity case relies on
@@ -553,7 +658,12 @@ export function findSimilarConsolidation(
553
658
 
554
659
  if (shortlist.length === 0) return null;
555
660
 
556
- const dryRun = process.env.CLAWMEM_MERGE_GUARD_DRY_RUN === "true";
661
+ // `forceEnforce` (v0.8.0 Ext 5) lets the heavy lane override the env
662
+ // `CLAWMEM_MERGE_GUARD_DRY_RUN` so operators can experiment with dry-run
663
+ // in the light lane without weakening heavy-lane guarantees.
664
+ const dryRun = forceEnforce
665
+ ? false
666
+ : process.env.CLAWMEM_MERGE_GUARD_DRY_RUN === "true";
557
667
 
558
668
  // Dry-run: preserve EXACT legacy behavior — return the first shortlist
559
669
  // hit (the pre-Ext-3 code iterated the SELECT rows in order and returned
@@ -723,18 +833,38 @@ function updateTrends(store: Store): void {
723
833
  *
724
834
  * Only considers decision/preference/milestone/problem observations from the
725
835
  * last 7 days that haven't already been used as sources for deductions.
836
+ *
837
+ * `opts` (v0.8.0 Ext 5):
838
+ * - `maxRecent` override batch size (default 20)
839
+ * - `staleOnly` order by recall_stats.last_recalled_at ASC with
840
+ * documents.last_accessed_at fallback instead of
841
+ * modified_at DESC
842
+ * - `guarded` forward-compat marker — no-op in v0.8.0 because the Phase 3
843
+ * guardrails always enforce their gates regardless
726
844
  */
727
- async function generateDeductiveObservations(
845
+ export async function generateDeductiveObservations(
728
846
  store: Store,
729
- llm: LlamaCpp
847
+ llm: LlamaCpp,
848
+ opts: DeductiveOptions = {},
730
849
  ): Promise<DeductiveSynthesisStats> {
850
+ const maxRecent = opts.maxRecent && opts.maxRecent > 0 ? opts.maxRecent : 20;
851
+ const staleOnly = opts.staleOnly === true;
731
852
  const stats = emptyDeductiveStats();
732
853
  // Find recent high-value observations not yet used in deductions
733
854
  const DEDUCTIVE_TYPES = ['decision', 'preference', 'milestone', 'problem'];
855
+
856
+ const orderBy = staleOnly
857
+ ? `ORDER BY COALESCE(rs.last_recalled_at, d.last_accessed_at, d.modified_at) ASC, d.modified_at ASC`
858
+ : `ORDER BY d.modified_at DESC`;
859
+ const joinClause = staleOnly
860
+ ? `LEFT JOIN recall_stats rs ON rs.doc_id = d.id`
861
+ : ``;
862
+
734
863
  const recentObs = store.db.prepare(`
735
864
  SELECT d.id, d.title, d.facts, d.narrative, d.observation_type, d.content_type,
736
865
  d.collection, d.path, d.modified_at
737
866
  FROM documents d
867
+ ${joinClause}
738
868
  WHERE d.active = 1
739
869
  AND d.content_type IN (${DEDUCTIVE_TYPES.map(() => '?').join(',')})
740
870
  AND d.observation_type IS NOT NULL
@@ -747,9 +877,9 @@ async function generateDeductiveObservations(
747
877
  WHERE dd.content_type = 'deductive' AND dd.active = 1
748
878
  )
749
879
  )
750
- ORDER BY d.modified_at DESC
751
- LIMIT 20
752
- `).all(...DEDUCTIVE_TYPES) as {
880
+ ${orderBy}
881
+ LIMIT ?
882
+ `).all(...DEDUCTIVE_TYPES, maxRecent) as {
753
883
  id: number; title: string; facts: string; narrative: string;
754
884
  observation_type: string; content_type: string; collection: string;
755
885
  path: string; modified_at: string;