remagent 1.0.0__py3-none-any.whl

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.
remagent/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """
2
+ RemAgent: Autonomous Zero-Vector Memory Framework for AI Agents.
3
+ Powered by Google Gemini and biological sleep/REM consolidation principles.
4
+ """
5
+
6
+ from remagent.schemas import (
7
+ Fact,
8
+ OperationalRule,
9
+ DreamConsolidationResult,
10
+ RawTurnLog,
11
+ MemoryProfile,
12
+ ContradictionResolution,
13
+ )
14
+ from remagent.storage.base import StorageAdapter
15
+ from remagent.storage.sqlite import SQLiteStorageAdapter
16
+ from remagent.storage.firestore import FirestoreStorageAdapter
17
+ from remagent.engine.synthesizer import DreamSynthesizer
18
+ from remagent.daemon import DreamDaemon
19
+ from remagent.governor import TokenBudgetGovernor
20
+ from remagent.decay import MemoryDecayEngine
21
+ from remagent.integrations.hermes import HermesMemoryConnector, RemAgentTool
22
+
23
+ __version__ = "1.0.0"
24
+ __all__ = [
25
+ "Fact",
26
+ "OperationalRule",
27
+ "DreamConsolidationResult",
28
+ "RawTurnLog",
29
+ "MemoryProfile",
30
+ "ContradictionResolution",
31
+ "StorageAdapter",
32
+ "SQLiteStorageAdapter",
33
+ "FirestoreStorageAdapter",
34
+ "DreamSynthesizer",
35
+ "DreamDaemon",
36
+ "TokenBudgetGovernor",
37
+ "MemoryDecayEngine",
38
+ "HermesMemoryConnector",
39
+ "RemAgentTool",
40
+ ]
remagent/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ Command-line interface for the RemAgent framework.
3
+ """
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import sys
10
+ from remagent.daemon import ConsolidationBusyError, DreamDaemon
11
+ from remagent.decay import MemoryDecayEngine
12
+ from remagent.doctor import run_doctor
13
+ from remagent.export import ExportError, default_out_dir, export_markdown
14
+ from remagent.schemas import current_utc_iso
15
+ from remagent.engine.synthesizer import DreamSynthesizer
16
+ from remagent.schemas import RawTurnLog
17
+ from remagent.storage.sqlite import SQLiteStorageAdapter
18
+ from remagent.governor import GovernorBudgetError, TokenBudgetGovernor
19
+ from remagent.integrations.claude_hooks import detect_native_auto_memory, generate_claude_configuration
20
+
21
+
22
+ def _default_db() -> str:
23
+ """--db default: REMAGENT_DB env var wins, else remagent_memory.db in CWD."""
24
+ return os.environ.get("REMAGENT_DB", "remagent_memory.db")
25
+
26
+
27
+ def _default_agent(fallback: str = "default_agent") -> str:
28
+ """--agent default: REMAGENT_AGENT env var wins, else the command's fallback."""
29
+ return os.environ.get("REMAGENT_AGENT", fallback)
30
+
31
+
32
+ async def run_cli():
33
+ parser = argparse.ArgumentParser(
34
+ prog="remagent",
35
+ description="RemAgent: Autonomous Zero-Vector Memory Framework for AI Agents",
36
+ )
37
+ subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
38
+
39
+ # Command: dream
40
+ dream_parser = subparsers.add_parser("dream", help="Trigger an immediate REM consolidation cycle")
41
+ dream_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
42
+ dream_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
43
+ dream_parser.add_argument(
44
+ "--export-md", nargs="?", const="", default=None, metavar="DIR",
45
+ help="After a successful dream, regenerate the markdown mirror (default DIR: <db>_md)",
46
+ )
47
+
48
+ # Command: status
49
+ status_parser = subparsers.add_parser("status", help="Inspect active memory profile and consolidated facts")
50
+ status_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
51
+ status_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
52
+
53
+ # Command: recall
54
+ recall_parser = subparsers.add_parser("recall", help="Recall consolidated memory and active directives")
55
+ recall_parser.add_argument("--format", choices=["injection", "json"], default="injection", help="Output format")
56
+ recall_parser.add_argument("--query", default=None, help="Query context for fact relevance filtering")
57
+ recall_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
58
+ recall_parser.add_argument("--max-tokens", type=int, default=6000, help="Maximum token budget for prompt injection")
59
+ recall_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
60
+
61
+ # Command: log
62
+ log_parser = subparsers.add_parser("log", help="Append a raw interaction turn into the memory buffer")
63
+ log_parser.add_argument("--role", choices=["user", "assistant", "system", "tool"], required=True, help="Turn role")
64
+ log_parser.add_argument("--content", required=True, help="Turn message content")
65
+ log_parser.add_argument("--session", default="default_session", help="Session ID")
66
+ log_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
67
+
68
+ # Command: export
69
+ export_parser = subparsers.add_parser("export", help="Export memory as a human-readable mirror")
70
+ export_parser.add_argument("--markdown", action="store_true", help="Markdown format (currently the only format)")
71
+ export_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
72
+ export_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
73
+ export_parser.add_argument("--out", default=None, help="Output directory (default: <db>_md next to the database)")
74
+
75
+ # Command: decay
76
+ decay_parser = subparsers.add_parser("decay", help="Apply Ebbinghaus temporal decay to stored facts")
77
+ decay_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
78
+ decay_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
79
+ decay_parser.add_argument("--half-life-days", type=float, default=30.0, help="Confidence half-life in days")
80
+ decay_parser.add_argument("--floor", type=float, default=0.20, help="Confidence floor below which facts are deactivated")
81
+
82
+ # Command: doctor
83
+ doctor_parser = subparsers.add_parser("doctor", help="Read-only self-audit of the memory pipeline")
84
+ doctor_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
85
+ doctor_parser.add_argument("--agent", default=_default_agent(), help="Agent identifier (env: REMAGENT_AGENT)")
86
+ doctor_parser.add_argument("--max-queue", type=int, default=100, help="Max acceptable unconsolidated turns")
87
+ doctor_parser.add_argument("--max-dream-age-hours", type=float, default=24.0, help="Max hours since last dream when turns are queued")
88
+ doctor_parser.add_argument("--json", action="store_true", help="Emit one JSON object instead of text")
89
+
90
+ # Command: soak
91
+ soak_parser = subparsers.add_parser("soak", help="Plain-English verdict on the 7-day soak")
92
+ soak_parser.add_argument("--config", default="~/.remagent/soak_config.json", help="Soak config path")
93
+ soak_parser.add_argument("--today", default=None, help=argparse.SUPPRESS) # test hook: YYYY-MM-DD
94
+
95
+ # Command: init-claude
96
+ init_claude_parser = subparsers.add_parser("init-claude", help="Scaffold Claude Code hooks and settings.json")
97
+ init_claude_parser.add_argument("--dir", default=".", help="Target workspace directory")
98
+ init_claude_parser.add_argument("--db", default=_default_db(), help="SQLite database path (env: REMAGENT_DB)")
99
+ init_claude_parser.add_argument("--agent", default=_default_agent("claude_code"), help="Agent identifier (env: REMAGENT_AGENT)")
100
+ init_claude_parser.add_argument("--force", action="store_true", help="Overwrite existing configuration and hooks")
101
+
102
+ args = parser.parse_args()
103
+
104
+ if not args.command:
105
+ parser.print_help()
106
+ return
107
+
108
+ if args.command == "init-claude":
109
+ # Detect BEFORE scaffolding so pre-existing settings are what's read.
110
+ native_state, native_detail = detect_native_auto_memory(args.dir)
111
+ results = generate_claude_configuration(
112
+ target_dir=args.dir,
113
+ db_path=args.db,
114
+ agent_id=args.agent,
115
+ force=args.force,
116
+ )
117
+ print("🧠 [RemAgent] Claude Code Integration Scaffolding:")
118
+ for path, status in results.items():
119
+ print(f" • {path}: {status}")
120
+ print("\n✨ Claude Code is now wired to RemAgent memory!")
121
+ print(" - SessionStart hook: Pre-loads active rules & knowledge into context.")
122
+ print(" - Stop hook: Runs background REM sleep consolidation when work completes.")
123
+ print(" - MCP server: Exposes remagent_recall, remagent_log, and remagent_dream tools.")
124
+ if native_state == "disabled":
125
+ print(f"\nℹ️ Native Claude Code auto-memory appears DISABLED ({native_detail}).")
126
+ print(" RemAgent will be the only memory layer for this repo.")
127
+ else:
128
+ qualifier = "is ACTIVE" if native_state == "active" else "may be active"
129
+ print(f"\nℹ️ Native Claude Code auto-memory (Auto Dream) {qualifier} ({native_detail}).")
130
+ print(" The two are complementary: native handles this repo's own markdown memory;")
131
+ print(" RemAgent adds a cross-agent shared brain on top (one database reachable from")
132
+ print(" Claude Code, Gemini, and any MCP host).")
133
+ return
134
+
135
+ if args.command == "soak":
136
+ from remagent.soak import run_soak_report
137
+ code, report = run_soak_report(config_path=args.config, today=args.today)
138
+ print(report)
139
+ if code != 0:
140
+ sys.exit(code)
141
+ return
142
+
143
+ if args.command == "export":
144
+ # Read-only like doctor: must never create the DB it mirrors.
145
+ if not args.markdown:
146
+ print("❌ FAILED: specify a format — currently only --markdown is supported.", file=sys.stderr)
147
+ sys.exit(2)
148
+ out_dir = args.out or default_out_dir(args.db)
149
+ try:
150
+ written = export_markdown(db_path=args.db, agent_id=args.agent, out_dir=out_dir)
151
+ except ExportError as exc:
152
+ print(f"❌ FAILED: markdown export did not complete: {exc}", file=sys.stderr)
153
+ print(" Nothing was written.", file=sys.stderr)
154
+ sys.exit(1)
155
+ print(f"📝 [RemAgent] Memory mirror written: {len(written)} file(s) in {out_dir}")
156
+ for path in written:
157
+ print(f" • {os.path.basename(path)}")
158
+ return
159
+
160
+ if args.command == "doctor":
161
+ # Runs before any storage initialization: doctor is strictly
162
+ # read-only and must never create the database it is auditing.
163
+ results = run_doctor(
164
+ db_path=args.db,
165
+ agent_id=args.agent,
166
+ max_queue=args.max_queue,
167
+ max_dream_age_hours=args.max_dream_age_hours,
168
+ )
169
+ ok = all(r.passed for r in results)
170
+ if args.json:
171
+ print(json.dumps({
172
+ "ok": ok,
173
+ "timestamp": current_utc_iso(),
174
+ "db": args.db,
175
+ "agent": args.agent,
176
+ "checks": [{"name": r.name, "passed": r.passed, "detail": r.detail} for r in results],
177
+ }))
178
+ else:
179
+ print(f"🩺 [RemAgent Doctor] db={args.db} agent={args.agent}")
180
+ for r in results:
181
+ print(f" {'✅' if r.passed else '❌'} {r.name}: {r.detail}")
182
+ print(" → ALL CHECKS PASSED" if ok else " → DOCTOR FAILED: pipeline needs attention")
183
+ if not ok:
184
+ sys.exit(1)
185
+ return
186
+
187
+ storage = SQLiteStorageAdapter(db_path=args.db)
188
+ await storage.initialize()
189
+
190
+ try:
191
+ if args.command == "dream":
192
+ print("🧠 [RemAgent] Initiating REM Sleep Consolidation Cycle...")
193
+ synthesizer = DreamSynthesizer()
194
+ daemon = DreamDaemon(storage=storage, synthesizer=synthesizer, agent_id=args.agent)
195
+ try:
196
+ result = await daemon.consolidate_now()
197
+ except ConsolidationBusyError as exc:
198
+ print(f"⏳ BUSY: {exc}", file=sys.stderr)
199
+ sys.exit(2)
200
+ except Exception as exc:
201
+ print(f"❌ FAILED: REM consolidation did not complete: {exc}", file=sys.stderr)
202
+ print(" No facts were written; unconsolidated turns remain queued for retry.", file=sys.stderr)
203
+ sys.exit(1)
204
+ if result:
205
+ print(f"✨ Consolidation Complete! Run ID: {result.run_id}")
206
+ print(f" - Added Facts: {len(result.added_facts)}")
207
+ print(f" - Updated Rules: {len(result.updated_rules)}")
208
+ print(f" - Pruned Noise Items: {result.pruned_noise_count}")
209
+ print(f" - Token Savings: ~{result.estimated_token_savings} tokens")
210
+ print(f" - Cognitive Reasoning: {result.reasoning_summary}")
211
+ if args.export_md is not None:
212
+ out_dir = args.export_md or default_out_dir(args.db)
213
+ try:
214
+ written = export_markdown(db_path=args.db, agent_id=args.agent, out_dir=out_dir)
215
+ print(f"📝 Memory mirror regenerated: {len(written)} file(s) in {out_dir}")
216
+ except ExportError as exc:
217
+ # The consolidation itself persisted; the mirror did not.
218
+ print(f"❌ FAILED: consolidation succeeded but markdown export failed: {exc}", file=sys.stderr)
219
+ sys.exit(1)
220
+ else:
221
+ print("💤 No unconsolidated turns found. Agent memory is already fully consolidated.")
222
+
223
+ elif args.command == "status":
224
+ profile = await storage.load_memory_profile(agent_id=args.agent)
225
+ print(f"📊 [RemAgent Profile: {profile.agent_id}]")
226
+ print(f" Last Dream: {profile.last_dream_at or 'Never'}")
227
+ print(f" Total Pruned Turns: {profile.total_pruned_turns}")
228
+ print(f"\n📌 Active Facts ({len([f for f in profile.facts if f.is_active])}):")
229
+ for f in profile.facts:
230
+ if f.is_active:
231
+ print(f" • {f.entity}.{f.attribute} = {f.value} (conf: {f.confidence})")
232
+ print(f"\n📜 Operational Directives & Rules ({len([r for r in profile.rules if r.is_active])}):")
233
+ for r in profile.rules:
234
+ if r.is_active:
235
+ print(f" • [{r.category.upper()}] P{r.priority}: {r.rule}")
236
+
237
+ elif args.command == "recall":
238
+ profile = await storage.load_memory_profile(agent_id=args.agent)
239
+ if args.format == "json":
240
+ active_facts = [f.model_dump() for f in profile.facts if f.is_active]
241
+ active_rules = [r.model_dump() for r in profile.rules if r.is_active]
242
+ print(json.dumps({
243
+ "agent_id": args.agent,
244
+ "facts": active_facts,
245
+ "rules": active_rules,
246
+ "last_dream_at": profile.last_dream_at,
247
+ }, indent=2))
248
+ else:
249
+ governor = TokenBudgetGovernor(default_max_tokens=args.max_tokens)
250
+ try:
251
+ injection = governor.build_budgeted_prompt_injection(
252
+ profile=profile,
253
+ query_context=args.query,
254
+ max_tokens=args.max_tokens,
255
+ )
256
+ except GovernorBudgetError as exc:
257
+ print(f"❌ FAILED: recall injection could not be built: {exc}", file=sys.stderr)
258
+ sys.exit(1)
259
+ if injection:
260
+ print(injection)
261
+ else:
262
+ print("[No active memory facts or rules found]")
263
+
264
+ elif args.command == "decay":
265
+ try:
266
+ profile = await storage.load_memory_profile(agent_id=args.agent)
267
+ if not profile.facts and not profile.rules and profile.last_dream_at is None:
268
+ print(
269
+ f"❌ FAILED: no memory profile found for agent '{args.agent}' in {args.db}.",
270
+ file=sys.stderr,
271
+ )
272
+ sys.exit(1)
273
+ engine = MemoryDecayEngine(
274
+ half_life_days=args.half_life_days,
275
+ min_confidence_floor=args.floor,
276
+ )
277
+ updated_profile, pruned = engine.apply_decay(profile)
278
+ # Persist only after a fully successful decay pass.
279
+ await storage.save_memory_profile(updated_profile)
280
+ except Exception as exc:
281
+ print(f"❌ FAILED: decay pass did not complete: {exc}", file=sys.stderr)
282
+ print(" No changes were persisted.", file=sys.stderr)
283
+ sys.exit(1)
284
+ active_count = len([f for f in updated_profile.facts if f.is_active])
285
+ print(f"🍂 [RemAgent] Decay pass complete for agent '{args.agent}'.")
286
+ print(f" - Half-life: {args.half_life_days} days | Confidence floor: {args.floor}")
287
+ print(f" - Facts deactivated this pass: {len(pruned)}")
288
+ for f in pruned:
289
+ print(f" • {f.entity}.{f.attribute} (confidence decayed below floor)")
290
+ print(f" - Active facts remaining: {active_count}")
291
+
292
+ elif args.command == "log":
293
+ turn = RawTurnLog(session_id=args.session, role=args.role, content=args.content)
294
+ await storage.save_turn(turn)
295
+ print(f"📥 Logged raw turn {turn.turn_id} to buffer.")
296
+ finally:
297
+ await storage.close()
298
+
299
+
300
+ def main():
301
+ asyncio.run(run_cli())
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()
remagent/daemon.py ADDED
@@ -0,0 +1,274 @@
1
+ """
2
+ DreamDaemon: Autonomous background worker for RemAgent.
3
+ Monitors agent activity, triggers biological sleep / REM consolidation cycles during idle windows,
4
+ and commits consolidated structured memory to storage.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import time
10
+ from typing import Callable, Coroutine, List, Optional
11
+ from datetime import datetime, timezone
12
+
13
+ from remagent.schemas import (
14
+ Fact,
15
+ MemoryProfile,
16
+ DreamConsolidationResult,
17
+ current_utc_iso,
18
+ )
19
+ from remagent.storage.base import StorageAdapter
20
+ from remagent.engine.synthesizer import DreamSynthesizer
21
+
22
+ logger = logging.getLogger("remagent.daemon")
23
+
24
+
25
+ class ConsolidationBusyError(RuntimeError):
26
+ """
27
+ Raised when a consolidation cycle is already in progress. This is NOT
28
+ "memory is up to date": unconsolidated turns remain queued and untouched.
29
+ Callers must report busy as busy and retry after the running cycle ends.
30
+ """
31
+
32
+
33
+ class DreamDaemon:
34
+ """
35
+ Background daemon that runs autonomous REM consolidation passes
36
+ when the agent is idle or when explicitly triggered.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ storage: StorageAdapter,
42
+ synthesizer: Optional[DreamSynthesizer] = None,
43
+ agent_id: str = "default_agent",
44
+ idle_threshold_seconds: float = 30.0,
45
+ check_interval_seconds: float = 5.0,
46
+ min_turns_to_dream: int = 1,
47
+ on_dream_completed: Optional[Callable[[DreamConsolidationResult], Coroutine]] = None,
48
+ ):
49
+ self.storage = storage
50
+ self.synthesizer = synthesizer or DreamSynthesizer()
51
+ self.agent_id = agent_id
52
+ self.idle_threshold_seconds = idle_threshold_seconds
53
+ self.check_interval_seconds = check_interval_seconds
54
+ self.min_turns_to_dream = min_turns_to_dream
55
+ self.on_dream_completed = on_dream_completed
56
+
57
+ self._last_activity_time: float = time.time()
58
+ self._is_running: bool = False
59
+ self._is_dreaming: bool = False
60
+ self._task: Optional[asyncio.Task] = None
61
+ self._lock = asyncio.Lock()
62
+
63
+ def record_activity(self) -> None:
64
+ """Call whenever user or agent interacts to reset idle countdown."""
65
+ self._last_activity_time = time.time()
66
+
67
+ @property
68
+ def is_idle(self) -> bool:
69
+ return (time.time() - self._last_activity_time) >= self.idle_threshold_seconds
70
+
71
+ @property
72
+ def idle_seconds(self) -> float:
73
+ return time.time() - self._last_activity_time
74
+
75
+ @property
76
+ def is_dreaming(self) -> bool:
77
+ return self._is_dreaming
78
+
79
+ async def start(self) -> None:
80
+ """Start the background daemon loop."""
81
+ if self._is_running:
82
+ return
83
+ self._is_running = True
84
+ await self.storage.initialize()
85
+ self._task = asyncio.create_task(self._daemon_loop())
86
+ logger.info(f"RemAgent DreamDaemon started for agent '{self.agent_id}' (idle threshold: {self.idle_threshold_seconds}s)")
87
+
88
+ async def stop(self) -> None:
89
+ """Stop background daemon gracefully."""
90
+ self._is_running = False
91
+ if self._task and not self._task.done():
92
+ self._task.cancel()
93
+ try:
94
+ await self._task
95
+ except asyncio.CancelledError:
96
+ pass
97
+ logger.info(f"RemAgent DreamDaemon stopped for agent '{self.agent_id}'")
98
+
99
+ async def _daemon_loop(self) -> None:
100
+ while self._is_running:
101
+ try:
102
+ await asyncio.sleep(self.check_interval_seconds)
103
+ if not self._is_running:
104
+ break
105
+
106
+ if self.is_idle and not self._is_dreaming:
107
+ # Check if there are unconsolidated turns
108
+ turns = await self.storage.get_unconsolidated_turns(limit=50)
109
+ if len(turns) >= self.min_turns_to_dream:
110
+ logger.info(f"Idle detected ({self.idle_seconds:.1f}s). Triggering REM consolidation for {len(turns)} turns.")
111
+ await self.consolidate_now()
112
+
113
+ except asyncio.CancelledError:
114
+ break
115
+ except ConsolidationBusyError:
116
+ logger.debug("Skipped scheduled consolidation: a cycle is already in progress.")
117
+ except Exception as e:
118
+ logger.error(f"Error in DreamDaemon loop: {e}", exc_info=True)
119
+
120
+ async def consolidate_now(self) -> Optional[DreamConsolidationResult]:
121
+ """
122
+ Forces an immediate REM consolidation pass regardless of idle timer.
123
+
124
+ Returns None ONLY when there are no unconsolidated turns (memory is
125
+ genuinely up to date). Raises ConsolidationBusyError when another
126
+ cycle is already running — turns remain queued in that case.
127
+ """
128
+ if self._is_dreaming or self._lock.locked():
129
+ raise ConsolidationBusyError(
130
+ "A REM consolidation cycle is already in progress; "
131
+ "unconsolidated turns remain queued. Retry shortly."
132
+ )
133
+
134
+ async with self._lock:
135
+ self._is_dreaming = True
136
+ try:
137
+ # 1. Fetch pending turns
138
+ unconsolidated = await self.storage.get_unconsolidated_turns(limit=100)
139
+ if not unconsolidated:
140
+ logger.debug("No turns to consolidate.")
141
+ return None
142
+
143
+ # 2. Load existing profile
144
+ profile = await self.storage.load_memory_profile(agent_id=self.agent_id)
145
+
146
+ # 3. Run Gemini Dream Synthesizer
147
+ result = await self.synthesizer.consolidate_window(
148
+ unconsolidated_turns=unconsolidated,
149
+ existing_profile=profile,
150
+ )
151
+
152
+ # A failed or fabricated synthesis must never be persisted:
153
+ # no facts, no profile update, and turns stay unconsolidated
154
+ # so they are reprocessed on the next dream cycle.
155
+ if result.is_fallback or result.error:
156
+ raise RuntimeError(
157
+ f"Dream synthesis returned a failure result "
158
+ f"(is_fallback={result.is_fallback}, error={result.error}); "
159
+ f"refusing to persist memory or mark turns consolidated."
160
+ )
161
+
162
+ # 4. Resolve contradictions. The replacing fact is determined
163
+ # (or materialized) BEFORE the existing graph is touched, so
164
+ # every deactivated fact records the ID of the fact that
165
+ # superseded it. The daemon must stay correct even when the
166
+ # model returns an unexpected shape: an update must never
167
+ # leave the entity/attribute without an active fact.
168
+ def _find_replacement(entity: str, attribute: str) -> Optional[Fact]:
169
+ for f in result.added_facts:
170
+ if (
171
+ f.is_active
172
+ and f.entity.lower() == entity.lower()
173
+ and f.attribute.lower() == attribute.lower()
174
+ ):
175
+ return f
176
+ return None
177
+
178
+ for resolution in result.contradiction_resolutions:
179
+ replacement = _find_replacement(resolution.entity, resolution.attribute)
180
+ if replacement is None:
181
+ logger.warning(
182
+ f"Synthesizer resolved contradiction on "
183
+ f"{resolution.entity}.{resolution.attribute} without emitting a "
184
+ f"replacement in added_facts; materializing active fact from "
185
+ f"new_value={resolution.new_value!r}."
186
+ )
187
+ replacement = Fact(
188
+ entity=resolution.entity,
189
+ attribute=resolution.attribute,
190
+ value=resolution.new_value,
191
+ confidence=1.0,
192
+ source_turn_ids=list(result.consolidated_turn_ids),
193
+ is_active=True,
194
+ )
195
+ result.added_facts.append(replacement)
196
+
197
+ # Deactivate the pre-existing facts, pointing each at the
198
+ # fact that replaced it (added_facts are not yet appended,
199
+ # so the replacement itself cannot be deactivated here).
200
+ for fact in profile.facts:
201
+ if (
202
+ fact.entity.lower() == resolution.entity.lower()
203
+ and fact.attribute.lower() == resolution.attribute.lower()
204
+ and fact.is_active
205
+ ):
206
+ fact.is_active = False
207
+ fact.superseded_by = replacement.id
208
+
209
+ # 5. Append new active facts
210
+ for new_fact in result.added_facts:
211
+ profile.facts.append(new_fact)
212
+
213
+ def _has_active_fact(entity: str, attribute: str) -> bool:
214
+ return any(
215
+ f.is_active
216
+ and f.entity.lower() == entity.lower()
217
+ and f.attribute.lower() == attribute.lower()
218
+ for f in profile.facts
219
+ )
220
+
221
+ # 5c. Invariant: after applying contradictions, every resolved
222
+ # entity/attribute must have an active fact. A deactivated fact
223
+ # with no active replacement means memory was erased, not
224
+ # updated — fail the run instead of committing that state.
225
+ for resolution in result.contradiction_resolutions:
226
+ if not _has_active_fact(resolution.entity, resolution.attribute):
227
+ raise RuntimeError(
228
+ f"Memory-erasure invariant violated: "
229
+ f"{resolution.entity}.{resolution.attribute} was superseded but has "
230
+ f"no active replacement fact. Refusing to persist; turns remain "
231
+ f"unconsolidated for retry."
232
+ )
233
+
234
+ # 6. Update or append operational rules
235
+ existing_rule_map = {r.rule.lower(): r for r in profile.rules}
236
+ for new_rule in result.updated_rules:
237
+ if new_rule.rule.lower() in existing_rule_map:
238
+ existing_rule_map[new_rule.rule.lower()].priority = new_rule.priority
239
+ existing_rule_map[new_rule.rule.lower()].rationale = new_rule.rationale
240
+ existing_rule_map[new_rule.rule.lower()].updated_at = current_utc_iso()
241
+ else:
242
+ profile.rules.append(new_rule)
243
+
244
+ # 7. Update profile metadata
245
+ profile.total_pruned_turns += result.pruned_noise_count
246
+ profile.last_dream_at = result.timestamp
247
+
248
+ # 8. Save updated profile & mark turns consolidated
249
+ await self.storage.save_memory_profile(profile)
250
+ await self.storage.mark_turns_consolidated(result.consolidated_turn_ids)
251
+ await self.storage.record_consolidation_audit(result, agent_id=self.agent_id)
252
+
253
+ logger.info(
254
+ f"REM consolidation finished. Added {len(result.added_facts)} facts, "
255
+ f"updated {len(result.updated_rules)} rules, pruned {result.pruned_noise_count} noise items. "
256
+ f"Estimated savings: {result.estimated_token_savings} tokens."
257
+ )
258
+
259
+ # Reset activity clock so it doesn't dream in a tight loop
260
+ self.record_activity()
261
+
262
+ if self.on_dream_completed:
263
+ try:
264
+ await self.on_dream_completed(result)
265
+ except Exception as cb_err:
266
+ logger.warning(f"Error in on_dream_completed callback: {cb_err}")
267
+
268
+ return result
269
+
270
+ except Exception as e:
271
+ logger.error(f"Failed to execute REM consolidation: {e}", exc_info=True)
272
+ raise
273
+ finally:
274
+ self._is_dreaming = False