reactifact 0.6.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.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sqlite3
6
+ import time
7
+ import urllib.parse
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from typing import Any, cast
11
+
12
+
13
+ class CheckpointBackend(ABC):
14
+ """Interface for saving and loading the Workspace state (single blob)."""
15
+
16
+ @abstractmethod
17
+ async def save(self, data: dict[str, Any]) -> None:
18
+ """Saves the state dictionary."""
19
+ ...
20
+
21
+ @abstractmethod
22
+ async def load(self) -> dict[str, Any]:
23
+ """Loads the state dictionary."""
24
+ ...
25
+
26
+ async def aclose(self) -> None: # noqa: B027
27
+ """Releases held resources (a pooled connection, ...). No-op by default."""
28
+
29
+
30
+ class KVBackend(ABC):
31
+ """Key-value store. Used for sessions (session_id → Context)."""
32
+
33
+ @abstractmethod
34
+ async def set(self, key: str, data: dict[str, Any]) -> None: ...
35
+
36
+ @abstractmethod
37
+ async def get(self, key: str) -> dict[str, Any] | None: ...
38
+
39
+ @abstractmethod
40
+ async def delete(self, key: str) -> None: ...
41
+
42
+ @abstractmethod
43
+ async def keys(self) -> list[str]: ...
44
+
45
+ async def aclose(self) -> None: # noqa: B027
46
+ """Releases held resources (a pooled connection, ...). No-op by default."""
47
+
48
+
49
+ class FileKVBackend(KVBackend):
50
+ """File KV backend: one JSON file per key in a directory.
51
+
52
+ Every call offloads its blocking filesystem I/O to a worker thread
53
+ (`asyncio.to_thread`) so it never stalls the event loop reactifact's runtime
54
+ and chat/web layers run on.
55
+ """
56
+
57
+ def __init__(self, directory: str):
58
+ self.directory = Path(directory)
59
+
60
+ def _path(self, key: str) -> Path:
61
+ safe = urllib.parse.quote(key, safe="")
62
+ return self.directory / f"{safe}.json"
63
+
64
+ def _set_sync(self, key: str, data: dict[str, Any]) -> None:
65
+ self.directory.mkdir(parents=True, exist_ok=True)
66
+ path = self._path(key)
67
+ tmp = path.with_suffix(".tmp")
68
+ # json.dumps() + one write(), not json.dump(obj, f): CPython's
69
+ # json.dump() streams via JSONEncoder.iterencode(), which — unlike
70
+ # .encode() (what dumps() calls) — never takes the C-accelerated
71
+ # encoder path, so it's the pure-Python encoder walking the whole
72
+ # object graph node by node. For a large session (thousands of
73
+ # artifacts/commits, written on every save — see reactifact/session.py)
74
+ # that's a real, measured ~5-10x difference on identical content.
75
+ payload = json.dumps({"key": key, "data": data})
76
+ with open(tmp, "w", encoding="utf-8") as f:
77
+ f.write(payload)
78
+ tmp.replace(path)
79
+
80
+ async def set(self, key: str, data: dict[str, Any]) -> None:
81
+ await asyncio.to_thread(self._set_sync, key, data)
82
+
83
+ def _get_sync(self, key: str) -> dict[str, Any] | None:
84
+ path = self._path(key)
85
+ if not path.exists():
86
+ return None
87
+ with open(path, encoding="utf-8") as f:
88
+ return cast(dict[str, Any] | None, json.load(f).get("data"))
89
+
90
+ async def get(self, key: str) -> dict[str, Any] | None:
91
+ return await asyncio.to_thread(self._get_sync, key)
92
+
93
+ def _delete_sync(self, key: str) -> None:
94
+ path = self._path(key)
95
+ if path.exists():
96
+ path.unlink()
97
+
98
+ async def delete(self, key: str) -> None:
99
+ await asyncio.to_thread(self._delete_sync, key)
100
+
101
+ def _keys_sync(self) -> list[str]:
102
+ self.directory.mkdir(parents=True, exist_ok=True)
103
+ return [
104
+ urllib.parse.unquote(p.stem) for p in sorted(self.directory.glob("*.json"))
105
+ ]
106
+
107
+ async def keys(self) -> list[str]:
108
+ return await asyncio.to_thread(self._keys_sync)
109
+
110
+
111
+ class FileBackend(CheckpointBackend):
112
+ """File backend: state is stored in a single JSON file."""
113
+
114
+ def __init__(self, path: str):
115
+ self.path = path
116
+
117
+ def _save_sync(self, data: dict[str, Any]) -> None:
118
+ with open(self.path, "w", encoding="utf-8") as f:
119
+ json.dump(data, f, indent=2)
120
+
121
+ async def save(self, data: dict[str, Any]) -> None:
122
+ await asyncio.to_thread(self._save_sync, data)
123
+
124
+ def _load_sync(self) -> dict[str, Any]:
125
+ with open(self.path, encoding="utf-8") as f:
126
+ return cast(dict[str, Any], json.load(f))
127
+
128
+ async def load(self) -> dict[str, Any]:
129
+ return await asyncio.to_thread(self._load_sync)
130
+
131
+
132
+ class _AsyncSQLite:
133
+ """One persistent `sqlite3.Connection` per backend instance, guarded by an
134
+ `asyncio.Lock` and run off-thread via `asyncio.to_thread`.
135
+
136
+ Reconnecting on every call (the previous behavior) meant every checkpoint
137
+ write paid a fresh `connect()`, and concurrent writers had no `busy_timeout`
138
+ to wait out a lock — they just raised `sqlite3.OperationalError: database is
139
+ locked`. WAL mode lets readers and the single writer overlap; the lock here
140
+ only serializes access from *this process* (SQLite itself still serializes
141
+ writers across processes via the database file).
142
+ """
143
+
144
+ def __init__(self, db_path: str, init_sql: str):
145
+ self.db_path = db_path
146
+ self._init_sql = init_sql
147
+ self._conn: sqlite3.Connection | None = None
148
+ self._lock = asyncio.Lock()
149
+
150
+ def _connect(self) -> sqlite3.Connection:
151
+ # `busy_timeout` only takes effect once set on *this* connection, so
152
+ # the pragmas below race the very connections that would honor it —
153
+ # switching journal mode is an exclusive operation SQLite does not
154
+ # always retry through the normal busy handler. Several backends
155
+ # opening a brand-new file at once can still hit `database is
156
+ # locked` here; retry the one-time bootstrap instead of the hot path.
157
+ conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=5.0)
158
+ attempts = 0
159
+ while True:
160
+ try:
161
+ conn.execute("PRAGMA journal_mode=WAL")
162
+ conn.execute("PRAGMA busy_timeout=5000")
163
+ conn.execute(self._init_sql)
164
+ conn.commit()
165
+ return conn
166
+ except sqlite3.OperationalError:
167
+ attempts += 1
168
+ if attempts >= 10:
169
+ raise
170
+ time.sleep(0.05 * attempts)
171
+
172
+ def _exec(self, sql: str, params: tuple[Any, ...]) -> list[tuple[Any, ...]]:
173
+ assert self._conn is not None
174
+ cur = self._conn.execute(sql, params)
175
+ self._conn.commit()
176
+ return cur.fetchall()
177
+
178
+ async def execute(
179
+ self, sql: str, params: tuple[Any, ...] = ()
180
+ ) -> list[tuple[Any, ...]]:
181
+ async with self._lock:
182
+ if self._conn is None:
183
+ self._conn = await asyncio.to_thread(self._connect)
184
+ return await asyncio.to_thread(self._exec, sql, params)
185
+
186
+ async def aclose(self) -> None:
187
+ async with self._lock:
188
+ if self._conn is not None:
189
+ await asyncio.to_thread(self._conn.close)
190
+ self._conn = None
191
+
192
+
193
+ class SQLiteKVBackend(KVBackend):
194
+ """SQLite KV backend: table kv_entries(key PRIMARY KEY, data JSON)."""
195
+
196
+ def __init__(self, db_path: str):
197
+ self.db_path = db_path
198
+ self._sql = _AsyncSQLite(
199
+ db_path,
200
+ "CREATE TABLE IF NOT EXISTS kv_entries "
201
+ "(key TEXT PRIMARY KEY, data TEXT NOT NULL)",
202
+ )
203
+
204
+ async def set(self, key: str, data: dict[str, Any]) -> None:
205
+ await self._sql.execute(
206
+ "INSERT OR REPLACE INTO kv_entries (key, data) VALUES (?, ?)",
207
+ (key, json.dumps(data)),
208
+ )
209
+
210
+ async def get(self, key: str) -> dict[str, Any] | None:
211
+ rows = await self._sql.execute(
212
+ "SELECT data FROM kv_entries WHERE key = ?", (key,)
213
+ )
214
+ return cast(dict[str, Any], json.loads(rows[0][0])) if rows else None
215
+
216
+ async def delete(self, key: str) -> None:
217
+ await self._sql.execute("DELETE FROM kv_entries WHERE key = ?", (key,))
218
+
219
+ async def keys(self) -> list[str]:
220
+ rows = await self._sql.execute("SELECT key FROM kv_entries")
221
+ return [cast(str, r[0]) for r in rows]
222
+
223
+ async def aclose(self) -> None:
224
+ await self._sql.aclose()
225
+
226
+
227
+ class SQLiteBackend(CheckpointBackend):
228
+ """SQLite backend: state is stored in the checkpoint table (single row)."""
229
+
230
+ def __init__(self, db_path: str):
231
+ self.db_path = db_path
232
+ self._sql = _AsyncSQLite(
233
+ db_path,
234
+ "CREATE TABLE IF NOT EXISTS checkpoint "
235
+ "(id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)",
236
+ )
237
+
238
+ async def save(self, data: dict[str, Any]) -> None:
239
+ await self._sql.execute(
240
+ "INSERT OR REPLACE INTO checkpoint (id, data) VALUES (1, ?)",
241
+ (json.dumps(data),),
242
+ )
243
+
244
+ async def load(self) -> dict[str, Any]:
245
+ rows = await self._sql.execute("SELECT data FROM checkpoint WHERE id = 1")
246
+ if not rows:
247
+ raise ValueError(f"Checkpoint not found in SQLite database: {self.db_path}")
248
+ return cast(dict[str, Any], json.loads(rows[0][0]))
249
+
250
+ async def aclose(self) -> None:
251
+ await self._sql.aclose()
252
+
253
+
254
+ class PostgreSQLKVBackend(KVBackend):
255
+ """PostgreSQL KV backend (`kv_entries(key, data)`) — behind the `pg` extra.
256
+
257
+ Like the SQLite KV backend, but shared across processes: a natural store
258
+ when sessions are persisted in the same Postgres as the application. The
259
+ driver (`psycopg`) is imported lazily so the core stays dependency-free;
260
+ install it with `uv sync --extra pg` (or group `pg`).
261
+
262
+ Uses psycopg3's native async API (`AsyncConnection`) over one persistent,
263
+ lazily-(re)connected connection, serialized by an `asyncio.Lock` — the same
264
+ shape as `SQLiteKVBackend`. A dedicated connection per backend instance is
265
+ enough for session-checkpoint traffic (small, infrequent writes); reach for
266
+ `psycopg_pool.AsyncConnectionPool` yourself if you need many concurrent
267
+ writers sharing one DSN.
268
+ """
269
+
270
+ def __init__(self, dsn: str):
271
+ from ._extras import require_extra
272
+
273
+ self._psycopg = require_extra("PostgreSQLKVBackend", "psycopg", "pg")
274
+ self.dsn = dsn
275
+ self._conn: Any | None = None
276
+ self._lock = asyncio.Lock()
277
+
278
+ async def _connection(self) -> Any:
279
+ conn = self._conn
280
+ if conn is None or conn.closed:
281
+ conn = await self._psycopg.AsyncConnection.connect(self.dsn)
282
+ async with conn.cursor() as cur:
283
+ await cur.execute(
284
+ "CREATE TABLE IF NOT EXISTS kv_entries "
285
+ "(key TEXT PRIMARY KEY, data TEXT NOT NULL)"
286
+ )
287
+ await conn.commit()
288
+ self._conn = conn
289
+ return conn
290
+
291
+ async def set(self, key: str, data: dict[str, Any]) -> None:
292
+ async with self._lock:
293
+ conn = await self._connection()
294
+ async with conn.cursor() as cur:
295
+ await cur.execute(
296
+ "INSERT INTO kv_entries (key, data) VALUES (%s, %s) "
297
+ "ON CONFLICT (key) DO UPDATE SET data = EXCLUDED.data",
298
+ (key, json.dumps(data)),
299
+ )
300
+ await conn.commit()
301
+
302
+ async def get(self, key: str) -> dict[str, Any] | None:
303
+ async with self._lock:
304
+ conn = await self._connection()
305
+ async with conn.cursor() as cur:
306
+ await cur.execute("SELECT data FROM kv_entries WHERE key = %s", (key,))
307
+ row = await cur.fetchone()
308
+ return cast(dict[str, Any], json.loads(row[0])) if row is not None else None
309
+
310
+ async def delete(self, key: str) -> None:
311
+ async with self._lock:
312
+ conn = await self._connection()
313
+ async with conn.cursor() as cur:
314
+ await cur.execute("DELETE FROM kv_entries WHERE key = %s", (key,))
315
+ await conn.commit()
316
+
317
+ async def keys(self) -> list[str]:
318
+ async with self._lock:
319
+ conn = await self._connection()
320
+ async with conn.cursor() as cur:
321
+ await cur.execute("SELECT key FROM kv_entries")
322
+ rows = await cur.fetchall()
323
+ return [cast(str, r[0]) for r in rows]
324
+
325
+ async def aclose(self) -> None:
326
+ async with self._lock:
327
+ if self._conn is not None and not self._conn.closed:
328
+ await self._conn.close()
329
+ self._conn = None
@@ -0,0 +1,73 @@
1
+ """python -m reactifact — inspect & visualize the runtime as Mermaid.
2
+
3
+ Subcommands:
4
+
5
+ graph static agent blueprint from a module path ("pkg.mod:Attr")
6
+ context live provenance graph from a saved session (KV backend)
7
+ trace run diagram from a trace store (SQLite)
8
+ replay deterministic state replay of a saved session
9
+ branch persistent forks over a KV backend
10
+ scenario run reactifact.testing scenarios (separate from pytest)
11
+
12
+ Examples:
13
+
14
+ python -m reactifact graph examples.knowledge.agents
15
+ python -m reactifact context examples/knowledge/sessions/traces.sqlite3
16
+ python -m reactifact trace traces.db
17
+
18
+ Everything prints Mermaid source to stdout — paste it into GitHub, Notion or
19
+ mermaid.live.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import sys
26
+
27
+ from .. import __version__
28
+ from . import branch, context, graph, replay, scenario, trace
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(
33
+ prog="reactifact",
34
+ description="Inspect & visualize reactifact agents, contexts and traces as Mermaid.",
35
+ )
36
+ parser.add_argument(
37
+ "-V",
38
+ "--version",
39
+ action="version",
40
+ version=f"%(prog)s {__version__}",
41
+ )
42
+ sub = parser.add_subparsers(dest="command")
43
+
44
+ for module in (graph, context, trace, replay, branch, scenario):
45
+ module.add_parser(sub)
46
+
47
+ return parser
48
+
49
+
50
+ def main(argv: list[str] | None = None) -> int:
51
+ # Make local modules importable the same way `python -m reactifact` does —
52
+ # the installed console-script entry point doesn't prepend cwd on its own.
53
+ if "" not in sys.path:
54
+ sys.path.insert(0, "")
55
+ parser = build_parser()
56
+ args = parser.parse_args(argv)
57
+ if args.command is None:
58
+ print(
59
+ f"reactifact {__version__} — inspect & visualize agents, contexts and"
60
+ " traces as Mermaid.\n"
61
+ )
62
+ print("Render what your agent app already did:")
63
+ print(" reactifact graph <module:Agent> static blueprint")
64
+ print(" reactifact trace <trace.db> run diagram")
65
+ print(" reactifact context <sessions.sqlite3> live provenance graph")
66
+ print(" reactifact replay <sessions.sqlite3> deterministic replay")
67
+ print(" reactifact branch <path> <session> <action> persistent forks")
68
+ print(" reactifact scenario <module> run reactifact.testing scenarios\n")
69
+ print("Run 'reactifact <command> --help' for details, or try an example:")
70
+ print(" uv run python ./examples/llm_ladder/level1.py")
71
+ parser.print_help()
72
+ return 0
73
+ return int(args.func(args))
@@ -0,0 +1,77 @@
1
+ """`reactifact branch` — persistent forks over a KV backend (§39-§40)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+
8
+ from .common import open_store
9
+
10
+
11
+ async def _cmd_branch(args: argparse.Namespace) -> int:
12
+ from ..branching import BranchStore
13
+ from ..session import SessionStore
14
+
15
+ store = SessionStore(open_store(args.path, args.backend))
16
+ branches = BranchStore(store.backend)
17
+
18
+ if args.action == "list":
19
+ names = await branches.list_branches(args.session)
20
+ stat = f"branches of {args.session!r}: " + (
21
+ ", ".join(names) if names else "none"
22
+ )
23
+ print(stat)
24
+ return 0
25
+
26
+ if args.action == "save":
27
+ context = await store.load_session(args.session)
28
+ if context is None:
29
+ print(f"session {args.session!r} not found")
30
+ return 1
31
+ await branches.save_branch(context, session_id=args.session, name=args.name)
32
+ print(f"saved branch {args.session!r}:{args.name}")
33
+ return 0
34
+
35
+ # merge
36
+ into = await branches.load_branch(args.session, args.into)
37
+ source = await branches.load_branch(args.session, args.source)
38
+ if into is None or source is None:
39
+ print("missing branch (use 'branch list')")
40
+ return 1
41
+ try:
42
+ into.merge(source)
43
+ except Exception as exc: # MergeConflict and friends
44
+ print(str(exc))
45
+ return 1
46
+ if args.as_name:
47
+ await branches.save_branch(into, session_id=args.session, name=args.as_name)
48
+ print(f"merged into {args.session!r}:{args.as_name}")
49
+ else:
50
+ await branches.save_branch(into, session_id=args.session, name=args.into)
51
+ print(f"merged {args.session!r}:{args.source} into {args.into}")
52
+ return 0
53
+
54
+
55
+ def cmd_branch(args: argparse.Namespace) -> int:
56
+ return asyncio.run(_cmd_branch(args))
57
+
58
+
59
+ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
60
+ p_branch = sub.add_parser(
61
+ "branch", help="persistent forks over a KV backend (§39-§40)"
62
+ )
63
+ p_branch.add_argument(
64
+ "path", help="KV backend file (sessions.sqlite3) or directory"
65
+ )
66
+ p_branch.add_argument("session", help="session id")
67
+ p_branch.add_argument(
68
+ "action", choices=["list", "save", "merge"], help="what to do"
69
+ )
70
+ p_branch.add_argument("name", nargs="?", default=None, help="name (save)")
71
+ p_branch.add_argument("--into", default=None, help="merge target branch")
72
+ p_branch.add_argument("--source", default=None, help="merge source branch")
73
+ p_branch.add_argument(
74
+ "--as", dest="as_name", default=None, help="result branch name"
75
+ )
76
+ p_branch.add_argument("--backend", choices=["file", "sqlite"], default="auto")
77
+ p_branch.set_defaults(func=cmd_branch)
@@ -0,0 +1,67 @@
1
+ """Shared helpers for CLI subcommands: module-path agent loading, KV-backend
2
+ opening by path/extension. No argparse wiring here — see each subcommand
3
+ module for that."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..agents import Agent
11
+
12
+ if TYPE_CHECKING:
13
+ from ..checkpoints import FileKVBackend, SQLiteKVBackend
14
+
15
+
16
+ def load_agents(spec: str) -> list[Agent]:
17
+ module_name, has_attr, attr = spec.partition(":")
18
+ try:
19
+ module = importlib.import_module(module_name)
20
+ except ModuleNotFoundError as exc:
21
+ raise SystemExit(
22
+ f"could not import {module_name!r}: {exc}. If this is a local "
23
+ "module (not installed as a package), run via "
24
+ f"`uv run python -m reactifact graph {spec}` instead."
25
+ ) from exc
26
+ if has_attr:
27
+ obj = getattr(module, attr)
28
+ if isinstance(obj, Agent):
29
+ return [obj]
30
+ if isinstance(obj, (list, tuple)):
31
+ if not obj:
32
+ raise SystemExit(f"{spec!r} resolved to an empty list")
33
+ agents = [o for o in obj if isinstance(o, Agent)]
34
+ if len(agents) != len(obj):
35
+ raise SystemExit(
36
+ f"{spec!r} must resolve to an Agent or a list of Agents"
37
+ )
38
+ return agents
39
+ raise SystemExit(f"expected an Agent or list[Agent] at {spec!r}")
40
+
41
+ agents = [value for value in vars(module).values() if isinstance(value, Agent)]
42
+ if not agents:
43
+ # The demos instantiate their agents inline — synthesize the blueprint
44
+ # from every Agent subclass defined in the module (stateless containers).
45
+ classes = [
46
+ value
47
+ for _name, value in sorted(vars(module).items())
48
+ if isinstance(value, type)
49
+ and issubclass(value, Agent)
50
+ and value is not Agent
51
+ and value.__module__ == module_name
52
+ ]
53
+ agents = [cls() for cls in classes]
54
+ if not agents:
55
+ raise SystemExit(
56
+ f"no Agent instances or subclasses found in {module_name!r}; "
57
+ 'use the "module:Attr" form'
58
+ )
59
+ return agents
60
+
61
+
62
+ def open_store(path: str, backend: str) -> FileKVBackend | SQLiteKVBackend:
63
+ from ..checkpoints import FileKVBackend, SQLiteKVBackend
64
+
65
+ if backend == "sqlite" or path.endswith((".db", ".sqlite", ".sqlite3")):
66
+ return SQLiteKVBackend(path)
67
+ return FileKVBackend(path)
@@ -0,0 +1,53 @@
1
+ """`reactifact context` — live provenance graph from a saved session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+
8
+ from .common import open_store
9
+
10
+
11
+ async def _cmd_context(args: argparse.Namespace) -> int:
12
+ import sqlite3
13
+
14
+ from ..session import SessionStore
15
+ from ..viz import context_to_mermaid
16
+
17
+ try:
18
+ store = SessionStore(open_store(args.path, args.backend))
19
+ sessions = await store.list_sessions()
20
+ except sqlite3.OperationalError:
21
+ print(f"no session store found at {args.path!r}")
22
+ return 1
23
+ if not sessions:
24
+ print("no sessions found")
25
+ return 1
26
+ session_id = args.session or sessions[-1]
27
+ context = await store.load_session(session_id)
28
+ if context is None:
29
+ print(f"session {session_id!r} not found")
30
+ return 1
31
+ print(context_to_mermaid(context, limit=args.limit))
32
+ return 0
33
+
34
+
35
+ def cmd_context(args: argparse.Namespace) -> int:
36
+ return asyncio.run(_cmd_context(args))
37
+
38
+
39
+ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
40
+ p_context = sub.add_parser(
41
+ "context", help="live provenance graph of a saved session"
42
+ )
43
+ p_context.add_argument(
44
+ "path", help="KV backend file (sessions.sqlite3) or directory"
45
+ )
46
+ p_context.add_argument(
47
+ "--session", default=None, help="session id (default: latest)"
48
+ )
49
+ p_context.add_argument("--backend", choices=["file", "sqlite"], default="auto")
50
+ p_context.add_argument(
51
+ "--limit", type=int, default=None, help="max artifacts shown"
52
+ )
53
+ p_context.set_defaults(func=cmd_context)
@@ -0,0 +1,21 @@
1
+ """`reactifact graph` — static agent blueprint from a module path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ..viz import blueprint
8
+ from .common import load_agents
9
+
10
+
11
+ def cmd_graph(args: argparse.Namespace) -> int:
12
+ agents = load_agents(args.agents)
13
+ print(blueprint(agents, title=args.title))
14
+ return 0
15
+
16
+
17
+ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
18
+ p_graph = sub.add_parser("graph", help="static agent blueprint")
19
+ p_graph.add_argument("agents", help='module path, e.g. "examples.knowledge.agents"')
20
+ p_graph.add_argument("--title", default="reactifact blueprint")
21
+ p_graph.set_defaults(func=cmd_graph)
@@ -0,0 +1,69 @@
1
+ """`reactifact replay` — deterministic state replay of a saved session (§55)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+
8
+ from .common import open_store
9
+
10
+
11
+ async def _cmd_replay(args: argparse.Namespace) -> int:
12
+ import sqlite3
13
+
14
+ from ..replay import replay_context, replay_summary
15
+ from ..session import SessionStore
16
+
17
+ try:
18
+ store = SessionStore(open_store(args.path, args.backend))
19
+ sessions = await store.list_sessions()
20
+ except sqlite3.OperationalError:
21
+ print(f"no session store found at {args.path!r}")
22
+ return 1
23
+ if not sessions:
24
+ print("no sessions found")
25
+ return 1
26
+ session_id = args.session or sessions[-1]
27
+ try:
28
+ context = await replay_context(store, session_id, version=args.version)
29
+ except KeyError as exc:
30
+ print(str(exc))
31
+ return 1
32
+ summary = replay_summary(context)
33
+ print(f"session {session_id!r} — replay v{summary['version']}")
34
+ print(
35
+ f"artifacts: {summary['artifacts']} · relations: {summary['relations']} · "
36
+ f"pending questions: {summary['pending_questions']}"
37
+ )
38
+ for tname, count in sorted(summary["by_type"].items()):
39
+ print(f" {tname}: {count}")
40
+ if args.diagram:
41
+ from ..viz import context_to_mermaid
42
+
43
+ print()
44
+ print(context_to_mermaid(context))
45
+ return 0
46
+
47
+
48
+ def cmd_replay(args: argparse.Namespace) -> int:
49
+ return asyncio.run(_cmd_replay(args))
50
+
51
+
52
+ def add_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
53
+ p_replay = sub.add_parser(
54
+ "replay", help="deterministic state replay of a saved session (§55)"
55
+ )
56
+ p_replay.add_argument(
57
+ "path", help="KV backend file (sessions.sqlite3) or directory"
58
+ )
59
+ p_replay.add_argument(
60
+ "--session", default=None, help="session id (default: latest)"
61
+ )
62
+ p_replay.add_argument(
63
+ "--version", type=int, default=None, help="replay to this commit"
64
+ )
65
+ p_replay.add_argument("--backend", choices=["file", "sqlite"], default="auto")
66
+ p_replay.add_argument(
67
+ "--diagram", action="store_true", help="also print the provenance graph"
68
+ )
69
+ p_replay.set_defaults(func=cmd_replay)