brain-core-mcp 0.2.2__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.
Files changed (77) hide show
  1. brain_core/__init__.py +13 -0
  2. brain_core/agent.py +423 -0
  3. brain_core/cli/__init__.py +1 -0
  4. brain_core/cli/doctor.py +66 -0
  5. brain_core/cli/export.py +120 -0
  6. brain_core/cli/init.py +80 -0
  7. brain_core/cli/main.py +297 -0
  8. brain_core/config.py +173 -0
  9. brain_core/cron.py +403 -0
  10. brain_core/decisions/__init__.py +37 -0
  11. brain_core/decisions/classifier.py +235 -0
  12. brain_core/distill.py +269 -0
  13. brain_core/installer.py +214 -0
  14. brain_core/mcp/__init__.py +8 -0
  15. brain_core/mcp/server.py +123 -0
  16. brain_core/mcp/tools.py +343 -0
  17. brain_core/memory/__init__.py +26 -0
  18. brain_core/memory/episodic.py +105 -0
  19. brain_core/memory/hsam.py +105 -0
  20. brain_core/memory/structured.py +207 -0
  21. brain_core/py.typed +0 -0
  22. brain_core/recall.py +169 -0
  23. brain_core/reflection/__init__.py +38 -0
  24. brain_core/reflection/session.py +233 -0
  25. brain_core/remember.py +129 -0
  26. brain_core/session.py +269 -0
  27. brain_core/status.py +126 -0
  28. brain_core/storage/__init__.py +11 -0
  29. brain_core/storage/db.py +97 -0
  30. brain_core/storage/migrations/001_initial.sql +105 -0
  31. brain_core/storage/migrations/002_relations.sql +15 -0
  32. brain_core/storage/migrations/__init__.py +5 -0
  33. brain_core/storage/migrator.py +103 -0
  34. brain_core/stores/__init__.py +26 -0
  35. brain_core/stores/base.py +56 -0
  36. brain_core/stores/fakes.py +39 -0
  37. brain_core/stores/graph.py +110 -0
  38. brain_core/stores/hindsight.py +167 -0
  39. brain_core/stores/sqlite.py +68 -0
  40. brain_core/stores/vault.py +428 -0
  41. brain_core/templates/__init__.py +0 -0
  42. brain_core/templates/agent/.claude/hooks/block-dangerous-bash.sh +77 -0
  43. brain_core/templates/agent/.claude/rules/01-identity.md +26 -0
  44. brain_core/templates/agent/.claude/rules/02-doctrine.md +35 -0
  45. brain_core/templates/agent/.claude/rules/03-authority.md +40 -0
  46. brain_core/templates/agent/.claude/rules/04-memory.md +45 -0
  47. brain_core/templates/agent/.claude/rules/05-system.md +24 -0
  48. brain_core/templates/agent/.claude/settings.json +10 -0
  49. brain_core/templates/agent/.claude/skills/brain-check/SKILL.md +21 -0
  50. brain_core/templates/agent/.claude/skills/distill/SKILL.md +55 -0
  51. brain_core/templates/agent/.claude/skills/session-end/SKILL.md +26 -0
  52. brain_core/templates/agent/.mcp.json +6 -0
  53. brain_core/templates/agent/CLAUDE.md +15 -0
  54. brain_core/templates/agent/_gitignore +10 -0
  55. brain_core/templates/agent/brain.yaml +23 -0
  56. brain_core/templates/agent/data/.gitkeep +0 -0
  57. brain_core/templates/agent/vault/corrections.md +10 -0
  58. brain_core/templates/agent/vault/daily_brief.md +3 -0
  59. brain_core/templates/agent/vault/decisions_log.md +14 -0
  60. brain_core/templates/agent/vault/executive_state.md +24 -0
  61. brain_core/templates/agent/vault/facts.md +11 -0
  62. brain_core/templates/agent/vault/goals/mission_queue.md +12 -0
  63. brain_core/templates/agent/vault/inbox_staged.md +10 -0
  64. brain_core/templates/agent/vault/knowledge/_index.md +2 -0
  65. brain_core/templates/agent/vault/knowledge/_log.md +3 -0
  66. brain_core/templates/agent/vault/last_standalone_session.md +11 -0
  67. brain_core/templates/agent/vault/patterns.md +12 -0
  68. brain_core/templates/agent/vault/procedures/README.md +3 -0
  69. brain_core/templates/agent/vault/raw/.gitkeep +0 -0
  70. brain_core/templates/agent/vault/session_critique.md +17 -0
  71. brain_core/templates/agent/vault/sessions/.gitkeep +0 -0
  72. brain_core/templates/agent/vault/user_profile.md +13 -0
  73. brain_core_mcp-0.2.2.dist-info/METADATA +239 -0
  74. brain_core_mcp-0.2.2.dist-info/RECORD +77 -0
  75. brain_core_mcp-0.2.2.dist-info/WHEEL +4 -0
  76. brain_core_mcp-0.2.2.dist-info/entry_points.txt +2 -0
  77. brain_core_mcp-0.2.2.dist-info/licenses/LICENSE +21 -0
brain_core/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """brain-core — cognitive primitives for AI agents.
2
+
3
+ Public API:
4
+ Agent — composed entry point over memory/decisions/reflection
5
+ BrainConfig — brain.yaml schema (pydantic v2)
6
+ """
7
+
8
+ from brain_core.agent import Agent
9
+ from brain_core.config import BrainConfig
10
+
11
+ __version__ = "0.2.2"
12
+
13
+ __all__ = ["Agent", "BrainConfig", "__version__"]
brain_core/agent.py ADDED
@@ -0,0 +1,423 @@
1
+ """Agent class — high-level API composing all brain-core capabilities.
2
+
3
+ Consumers touch this one object. Each method is a thin, typed pass-through
4
+ to a capability module; capability checks are enforced at the Agent
5
+ boundary so misconfigured agents fail fast with actionable errors.
6
+
7
+ Lifecycle::
8
+
9
+ agent = Agent.from_config("./brain.yaml")
10
+ agent.start_session("sess-1")
11
+ agent.remember("Ada prefers dark mode", kind="preference")
12
+ agent.decide(action="deploy", reversible=True, risk="medium")
13
+ ctx = agent.gather_context() # uses current session
14
+ summary = agent.template_summary() # zero-LLM fallback
15
+ agent.save_reflection(what_went_well=summary)
16
+ agent.end_session()
17
+ agent.close()
18
+
19
+ Or as a context manager::
20
+
21
+ with Agent.from_config("./brain.yaml") as agent:
22
+ ...
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from datetime import date as Date
28
+ from pathlib import Path
29
+ from types import TracebackType
30
+ from typing import Any, Self, cast, get_args
31
+
32
+ from brain_core.config import BrainConfig
33
+ from brain_core.decisions import classifier
34
+ from brain_core.decisions.classifier import Confidence, Decision, Risk, Status
35
+ from brain_core.distill import brain_distill
36
+ from brain_core.memory import episodic, hsam, structured
37
+ from brain_core.memory.episodic import Event
38
+ from brain_core.memory.hsam import HSAMDay
39
+ from brain_core.memory.structured import Fact, FactKind
40
+ from brain_core.recall import brain_recall
41
+ from brain_core.reflection import session as reflection_session
42
+ from brain_core.reflection.session import SessionContext, SessionReflection
43
+ from brain_core.remember import brain_remember
44
+ from brain_core.session import session_end, session_start
45
+ from brain_core.status import brain_status
46
+ from brain_core.storage.db import Database
47
+ from brain_core.storage.migrator import migrate
48
+ from brain_core.stores import GraphStore, HindsightStore, SqliteStore, Store, VaultStore
49
+ from brain_core.stores.base import Kind
50
+
51
+
52
+ class Agent:
53
+ """Composed entry point over memory + decisions + reflection capabilities."""
54
+
55
+ def __init__(
56
+ self, config: BrainConfig, db: Database, data_dir: Path, config_path: Path | None = None
57
+ ) -> None:
58
+ self._config = config
59
+ self._db = db
60
+ self._data_dir = data_dir
61
+ self.config_path = config_path
62
+ self._current_session: str | None = None
63
+ self._stores: list[Store] | None = None
64
+
65
+ # ── construction ─────────────────────────────────────────────────────────
66
+
67
+ @classmethod
68
+ def from_config(cls, config_path: Path | str) -> Self:
69
+ """Load ``brain.yaml``, open SQLite, apply migrations, return Agent.
70
+
71
+ Workspace paths in the YAML are resolved relative to the config file
72
+ itself (not the caller's cwd). The ``data_dir`` is created on demand.
73
+ """
74
+ cfg_path = Path(config_path).resolve()
75
+ config = BrainConfig.from_yaml(cfg_path)
76
+
77
+ data_dir = _resolve_dir(cfg_path.parent, config.workspace.data_dir)
78
+ data_dir.mkdir(parents=True, exist_ok=True)
79
+
80
+ db = Database.connect(data_dir / "brain.db")
81
+ migrate(db)
82
+ return cls(config=config, db=db, data_dir=data_dir, config_path=cfg_path)
83
+
84
+ # ── introspection ────────────────────────────────────────────────────────
85
+
86
+ @property
87
+ def name(self) -> str:
88
+ return self._config.name
89
+
90
+ @property
91
+ def config(self) -> BrainConfig:
92
+ return self._config
93
+
94
+ @property
95
+ def db(self) -> Database:
96
+ """Escape hatch for raw SQL. Prefer the typed methods."""
97
+ return self._db
98
+
99
+ @property
100
+ def data_dir(self) -> Path:
101
+ return self._data_dir
102
+
103
+ @property
104
+ def current_session(self) -> str | None:
105
+ return self._current_session
106
+
107
+ def resolve(self, p: Path | str) -> Path:
108
+ """A path from brain.yaml: ``~`` expanded, relative ones anchored at the yaml's folder."""
109
+ base = self.config_path.parent if self.config_path else Path.cwd()
110
+ return _resolve_dir(base, Path(p).expanduser())
111
+
112
+ @property
113
+ def stores(self) -> list[Store]:
114
+ """Every configured store (v0.2). SQLite first, then vault, Hindsight, graph as set."""
115
+ if self._stores is None:
116
+ s = self._config.stores
117
+ self._stores = [SqliteStore(self._db)]
118
+ if s.vault:
119
+ self._stores.append(VaultStore(self.resolve(s.vault), owner=self.name.lower()))
120
+ if s.hindsight:
121
+ self._stores.append(HindsightStore(s.hindsight.url, s.hindsight.bank, self.name))
122
+ self._stores.append(GraphStore(self._db)) # relations table in brain.db, always on
123
+ return self._stores
124
+
125
+ def brain_recall(
126
+ self, query: str, scope: str = "all", fmt: str = "concise", limit: int = 10
127
+ ) -> dict[str, Any]:
128
+ """One query across every store, deduplicated and ranked. See :mod:`brain_core.recall`."""
129
+ self._require_capability("memory")
130
+ return brain_recall(self.stores, query, scope=scope, fmt=fmt, limit=limit)
131
+
132
+ # ── session lifecycle ────────────────────────────────────────────────────
133
+
134
+ def start_session(self, session_id: str) -> None:
135
+ """Set the active session id. Used as the default for log_event and
136
+ reflection calls until :meth:`end_session`."""
137
+ if not session_id.strip():
138
+ raise ValueError("session_id must not be empty")
139
+ self._current_session = session_id
140
+
141
+ def end_session(self) -> None:
142
+ self._current_session = None
143
+
144
+ # ── memory ───────────────────────────────────────────────────────────────
145
+
146
+ def remember(
147
+ self,
148
+ content: str,
149
+ kind: FactKind = "fact",
150
+ tags: list[str] | None = None,
151
+ metadata: dict[str, Any] | None = None,
152
+ ) -> Fact:
153
+ self._require_capability("memory")
154
+ return structured.remember(self._db, content, kind=kind, tags=tags, metadata=metadata)
155
+
156
+ def recall(
157
+ self,
158
+ query: str | None = None,
159
+ kind: FactKind | None = None,
160
+ limit: int = 10,
161
+ include_superseded: bool = False,
162
+ ) -> list[Fact]:
163
+ self._require_capability("memory")
164
+ return structured.recall(
165
+ self._db,
166
+ query=query,
167
+ kind=kind,
168
+ limit=limit,
169
+ include_superseded=include_superseded,
170
+ )
171
+
172
+ def supersede(
173
+ self,
174
+ old_fact_id: int,
175
+ new_content: str,
176
+ kind: FactKind = "fact",
177
+ tags: list[str] | None = None,
178
+ metadata: dict[str, Any] | None = None,
179
+ ) -> Fact:
180
+ self._require_capability("memory")
181
+ return structured.supersede(
182
+ self._db,
183
+ old_fact_id,
184
+ new_content,
185
+ kind=kind,
186
+ tags=tags,
187
+ metadata=metadata,
188
+ )
189
+
190
+ def log_event(
191
+ self,
192
+ event_type: str,
193
+ payload: dict[str, Any] | None = None,
194
+ session_id: str | None = None,
195
+ ) -> Event:
196
+ self._require_capability("memory")
197
+ sid = session_id if session_id is not None else self._current_session
198
+ return episodic.log_event(self._db, event_type, payload=payload, session_id=sid)
199
+
200
+ def recent_events(
201
+ self,
202
+ session_id: str | None = None,
203
+ event_type: str | None = None,
204
+ limit: int = 50,
205
+ ) -> list[Event]:
206
+ self._require_capability("memory")
207
+ return episodic.recent_events(
208
+ self._db,
209
+ session_id=session_id,
210
+ event_type=event_type,
211
+ limit=limit,
212
+ )
213
+
214
+ def append_today(self, text: str, today: Date | None = None) -> HSAMDay:
215
+ """Append text to today's HSAM archive.
216
+
217
+ Raises:
218
+ RuntimeError: if the memory capability is disabled, or HSAM is
219
+ disabled in ``brain.yaml``.
220
+ """
221
+ self._require_capability("memory")
222
+ if not self._config.memory.hsam.enabled:
223
+ raise RuntimeError("HSAM is disabled in brain.yaml (memory.hsam.enabled)")
224
+ return hsam.append_today(
225
+ self._db,
226
+ workspace=self._data_dir,
227
+ text=text,
228
+ subdir=self._config.memory.hsam.subdir,
229
+ today=today,
230
+ )
231
+
232
+ def brain_remember(
233
+ self,
234
+ text: str,
235
+ kind: str,
236
+ *,
237
+ domain: str = "",
238
+ source: str = "",
239
+ mission: str | None = None,
240
+ when: Date | None = None,
241
+ force: bool = False,
242
+ **extra: str,
243
+ ) -> dict[str, Any]:
244
+ """Write-through to every store with per-layer status. See :mod:`brain_core.remember`."""
245
+ self._require_capability("memory")
246
+ if kind not in get_args(Kind):
247
+ raise ValueError(f"kind must be one of {'|'.join(get_args(Kind))}")
248
+ out = brain_remember(
249
+ self.stores,
250
+ text,
251
+ cast(Kind, kind),
252
+ domain=domain,
253
+ source=source,
254
+ mission=mission,
255
+ when=when,
256
+ force=force,
257
+ **cast(dict[str, Any], extra),
258
+ )
259
+ # the session journal: brain_session('end') reports and summarises these
260
+ episodic.log_event(
261
+ self._db,
262
+ "brain_remember",
263
+ payload={
264
+ "kind": kind,
265
+ "id": out.get("id", ""),
266
+ "status": out["status"],
267
+ "text": out["text"][:200],
268
+ "contradiction": "contradiction" in out,
269
+ },
270
+ session_id=self._current_session,
271
+ )
272
+ return out
273
+
274
+ def brain_session(
275
+ self,
276
+ phase: str,
277
+ session_id: str | None = None,
278
+ *,
279
+ executive_state: str | None = None,
280
+ critique: str | None = None,
281
+ sync: str | None = None,
282
+ ) -> dict[str, Any]:
283
+ """START block or END writes. See :mod:`brain_core.session`."""
284
+ self._require_capability("memory")
285
+ if phase == "start":
286
+ return session_start(self, session_id)
287
+ if phase == "end":
288
+ return session_end(
289
+ self, session_id, executive_state=executive_state, critique=critique, sync=sync
290
+ )
291
+ raise ValueError("phase must be 'start' or 'end'")
292
+
293
+ def brain_distill(
294
+ self,
295
+ source: str,
296
+ *,
297
+ text: str | None = None,
298
+ title: str | None = None,
299
+ author: str | None = None,
300
+ published: str | None = None,
301
+ ) -> dict[str, Any]:
302
+ """Capture a source into ``raw/``. See :mod:`brain_core.distill`."""
303
+ self._require_capability("memory")
304
+ return brain_distill(
305
+ self, source, text=text, title=title, author=author, published=published
306
+ )
307
+
308
+ def brain_status(self) -> dict[str, Any]:
309
+ """Health in one call. See :mod:`brain_core.status`."""
310
+ return brain_status(self)
311
+
312
+ # ── decisions ────────────────────────────────────────────────────────────
313
+
314
+ def decide(
315
+ self,
316
+ action: str,
317
+ reversible: bool,
318
+ risk: Risk = "medium",
319
+ confidence: Confidence = "medium",
320
+ reasoning: str | None = None,
321
+ alternatives: list[str] | None = None,
322
+ ) -> Decision:
323
+ self._require_capability("decisions")
324
+ return classifier.decide(
325
+ self._db,
326
+ action=action,
327
+ reversible=reversible,
328
+ risk=risk,
329
+ confidence=confidence,
330
+ reasoning=reasoning,
331
+ alternatives=alternatives,
332
+ )
333
+
334
+ def reverse(self, decision_id: int, replaced_by: int | None = None) -> Decision:
335
+ self._require_capability("decisions")
336
+ return classifier.reverse(self._db, decision_id, replaced_by=replaced_by)
337
+
338
+ def complete(self, decision_id: int) -> Decision:
339
+ self._require_capability("decisions")
340
+ return classifier.complete(self._db, decision_id)
341
+
342
+ def list_decisions(self, status: Status | None = None, limit: int = 50) -> list[Decision]:
343
+ self._require_capability("decisions")
344
+ return classifier.list_decisions(self._db, status=status, limit=limit)
345
+
346
+ # ── reflection ───────────────────────────────────────────────────────────
347
+
348
+ def gather_context(
349
+ self,
350
+ session_id: str | None = None,
351
+ event_limit: int = 20,
352
+ decision_limit: int = 20,
353
+ ) -> SessionContext:
354
+ self._require_capability("reflection")
355
+ sid = self._require_session(session_id)
356
+ return reflection_session.gather_session_context(
357
+ self._db,
358
+ session_id=sid,
359
+ event_limit=event_limit,
360
+ decision_limit=decision_limit,
361
+ )
362
+
363
+ def save_reflection(
364
+ self,
365
+ session_id: str | None = None,
366
+ what_went_well: str | None = None,
367
+ what_to_improve: str | None = None,
368
+ apply_next_session: str | None = None,
369
+ auto_generated: bool = False,
370
+ ) -> SessionReflection:
371
+ self._require_capability("reflection")
372
+ sid = self._require_session(session_id)
373
+ return reflection_session.save_reflection(
374
+ self._db,
375
+ session_id=sid,
376
+ what_went_well=what_went_well,
377
+ what_to_improve=what_to_improve,
378
+ apply_next_session=apply_next_session,
379
+ auto_generated=auto_generated,
380
+ )
381
+
382
+ def template_summary(self, session_id: str | None = None) -> str:
383
+ self._require_capability("reflection")
384
+ ctx = self.gather_context(session_id=session_id)
385
+ return reflection_session.template_summary(ctx)
386
+
387
+ # ── lifecycle ────────────────────────────────────────────────────────────
388
+
389
+ def close(self) -> None:
390
+ self._db.close()
391
+
392
+ def __enter__(self) -> Self:
393
+ return self
394
+
395
+ def __exit__(
396
+ self,
397
+ exc_type: type[BaseException] | None,
398
+ exc_val: BaseException | None,
399
+ exc_tb: TracebackType | None,
400
+ ) -> None:
401
+ self.close()
402
+
403
+ # ── internal ─────────────────────────────────────────────────────────────
404
+
405
+ def _require_capability(self, name: str) -> None:
406
+ if name not in self._config.capabilities:
407
+ raise RuntimeError(
408
+ f"capability {name!r} is not enabled in brain.yaml "
409
+ f"(enabled: {list(self._config.capabilities)})"
410
+ )
411
+
412
+ def _require_session(self, explicit: str | None) -> str:
413
+ if explicit is not None:
414
+ if not explicit.strip():
415
+ raise ValueError("session_id must not be empty")
416
+ return explicit
417
+ if self._current_session is None:
418
+ raise RuntimeError("no active session — call start_session() or pass session_id")
419
+ return self._current_session
420
+
421
+
422
+ def _resolve_dir(base: Path, p: Path) -> Path:
423
+ return p if p.is_absolute() else (base / p).resolve()
@@ -0,0 +1 @@
1
+ """CLI entry points. See :mod:`brain_core.cli.main`."""
@@ -0,0 +1,66 @@
1
+ """``brain doctor`` — health check an agent's state.
2
+
3
+ Exposes :func:`doctor_agent` as the pure implementation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import typer
11
+
12
+ from brain_core.agent import Agent
13
+
14
+ _ROW_COUNT_TABLES = (
15
+ "facts",
16
+ "decisions",
17
+ "events",
18
+ "reflections",
19
+ "hsam_days",
20
+ "metrics",
21
+ )
22
+
23
+
24
+ def doctor_agent(path: Path) -> None:
25
+ """Run a health check on an agent at ``path``.
26
+
27
+ Exits non-zero on missing brain.yaml, invalid config, or DB access error.
28
+ """
29
+ config_path = path if path.is_file() else path / "brain.yaml"
30
+ if not config_path.exists():
31
+ typer.echo(f"ERROR: no brain.yaml at {config_path}", err=True)
32
+ raise typer.Exit(code=1)
33
+
34
+ try:
35
+ agent = Agent.from_config(config_path)
36
+ except Exception as e:
37
+ typer.echo(f"ERROR: failed to load agent: {e}", err=True)
38
+ raise typer.Exit(code=1) from e
39
+
40
+ try:
41
+ typer.echo(f"Agent: {agent.name}")
42
+ typer.echo(f" config: {config_path}")
43
+ typer.echo(f" data_dir: {agent.data_dir}")
44
+ typer.echo(f" capabilities: {', '.join(agent.config.capabilities)}")
45
+ typer.echo("")
46
+ typer.echo("Row counts:")
47
+ for table in _ROW_COUNT_TABLES:
48
+ row = agent.db.fetchone(f"SELECT COUNT(*) AS n FROM {table}")
49
+ count = int(row["n"]) if row is not None else 0
50
+ typer.echo(f" {table:<12} {count:>6}")
51
+
52
+ if agent.config.memory.hsam.enabled:
53
+ typer.echo("")
54
+ hsam_row = agent.db.fetchone(
55
+ "SELECT COUNT(*) AS days, COALESCE(SUM(word_count), 0) AS words FROM hsam_days"
56
+ )
57
+ if hsam_row is not None:
58
+ typer.echo(
59
+ f"HSAM: {int(hsam_row['days'])} days archived, "
60
+ f"{int(hsam_row['words'])} words total"
61
+ )
62
+
63
+ typer.echo("")
64
+ typer.echo("Health check: OK")
65
+ finally:
66
+ agent.close()
@@ -0,0 +1,120 @@
1
+ """``brain export`` — dump agent state as human-readable markdown.
2
+
3
+ Exposes :func:`export_agent` as the pure implementation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections import defaultdict
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from brain_core.agent import Agent
14
+ from brain_core.memory.structured import Fact
15
+
16
+
17
+ def export_agent(
18
+ path: Path,
19
+ fact_limit: int = 100,
20
+ decision_limit: int = 50,
21
+ event_limit: int = 50,
22
+ ) -> None:
23
+ """Dump agent state at ``path`` to stdout as markdown.
24
+
25
+ Exits non-zero if brain.yaml is missing or invalid.
26
+ """
27
+ config_path = path / "brain.yaml"
28
+ if not config_path.exists():
29
+ typer.echo(f"ERROR: no brain.yaml at {config_path}", err=True)
30
+ raise typer.Exit(code=1)
31
+
32
+ try:
33
+ agent = Agent.from_config(config_path)
34
+ except Exception as e:
35
+ typer.echo(f"ERROR: failed to load agent: {e}", err=True)
36
+ raise typer.Exit(code=1) from e
37
+
38
+ try:
39
+ caps = agent.config.capabilities
40
+ lines: list[str] = [
41
+ f"# {agent.name}",
42
+ "",
43
+ f"_Exported from {agent.data_dir}_",
44
+ "",
45
+ f"**Capabilities:** {', '.join(caps)}",
46
+ "",
47
+ ]
48
+
49
+ if "memory" in caps:
50
+ lines.extend(_facts_section(agent, fact_limit))
51
+
52
+ if "decisions" in caps:
53
+ lines.extend(_decisions_section(agent, decision_limit))
54
+
55
+ if "memory" in caps:
56
+ lines.extend(_events_section(agent, event_limit))
57
+
58
+ typer.echo("\n".join(lines))
59
+ finally:
60
+ agent.close()
61
+
62
+
63
+ def _facts_section(agent: Agent, limit: int) -> list[str]:
64
+ facts = agent.recall(limit=limit)
65
+ lines: list[str] = ["## Facts", ""]
66
+ if not facts:
67
+ lines.append("_No facts remembered yet._")
68
+ lines.append("")
69
+ return lines
70
+
71
+ by_kind: dict[str, list[Fact]] = defaultdict(list)
72
+ for f in facts:
73
+ by_kind[f.kind].append(f)
74
+
75
+ for kind in sorted(by_kind):
76
+ items = by_kind[kind]
77
+ lines.append(f"### {kind} ({len(items)})")
78
+ lines.append("")
79
+ for f in items:
80
+ tags_suffix = f" _tags: {', '.join(f.tags)}_" if f.tags else ""
81
+ lines.append(f"- {f.content}{tags_suffix}")
82
+ lines.append("")
83
+ return lines
84
+
85
+
86
+ def _decisions_section(agent: Agent, limit: int) -> list[str]:
87
+ decisions = agent.list_decisions(limit=limit)
88
+ lines: list[str] = ["## Decisions", ""]
89
+ if not decisions:
90
+ lines.append("_No decisions logged yet._")
91
+ lines.append("")
92
+ return lines
93
+
94
+ for d in decisions:
95
+ reversibility = "reversible" if d.reversible else "IRREVERSIBLE"
96
+ header = (
97
+ f"- **[{d.status}]** {d.action} _({reversibility}, risk={d.risk}, conf={d.confidence})_"
98
+ )
99
+ lines.append(header)
100
+ if d.reasoning:
101
+ lines.append(f" - reasoning: {d.reasoning}")
102
+ if d.reversed_by is not None:
103
+ lines.append(f" - replaced by decision #{d.reversed_by}")
104
+ lines.append("")
105
+ return lines
106
+
107
+
108
+ def _events_section(agent: Agent, limit: int) -> list[str]:
109
+ events = agent.recent_events(limit=limit)
110
+ lines: list[str] = ["## Recent events", ""]
111
+ if not events:
112
+ lines.append("_No events logged yet._")
113
+ lines.append("")
114
+ return lines
115
+
116
+ for e in events:
117
+ session = f" session={e.session_id}" if e.session_id else ""
118
+ lines.append(f"- `{e.occurred_at.isoformat()}` **{e.event_type}**{session}")
119
+ lines.append("")
120
+ return lines