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 +40 -0
- remagent/cli.py +305 -0
- remagent/daemon.py +274 -0
- remagent/decay.py +86 -0
- remagent/doctor.py +180 -0
- remagent/engine/__init__.py +7 -0
- remagent/engine/synthesizer.py +328 -0
- remagent/export.py +151 -0
- remagent/governor.py +135 -0
- remagent/integrations/__init__.py +14 -0
- remagent/integrations/claude_code.py +189 -0
- remagent/integrations/claude_hooks.py +248 -0
- remagent/integrations/hermes.py +207 -0
- remagent/schemas.py +110 -0
- remagent/soak.py +181 -0
- remagent/storage/__init__.py +13 -0
- remagent/storage/base.py +64 -0
- remagent/storage/firestore.py +149 -0
- remagent/storage/sqlite.py +289 -0
- remagent-1.0.0.dist-info/METADATA +369 -0
- remagent-1.0.0.dist-info/RECORD +24 -0
- remagent-1.0.0.dist-info/WHEEL +4 -0
- remagent-1.0.0.dist-info/entry_points.txt +3 -0
- remagent-1.0.0.dist-info/licenses/LICENSE +202 -0
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
|