capsule-trace 0.1.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.
- capsule_trace/__init__.py +22 -0
- capsule_trace/cli/__init__.py +3 -0
- capsule_trace/cli/__main__.py +5 -0
- capsule_trace/cli/main.py +429 -0
- capsule_trace/cloud/__init__.py +1 -0
- capsule_trace/cloud/uploader.py +108 -0
- capsule_trace/core/__init__.py +4 -0
- capsule_trace/core/context.py +21 -0
- capsule_trace/core/decorator.py +88 -0
- capsule_trace/core/exporter.py +115 -0
- capsule_trace/core/importer.py +81 -0
- capsule_trace/core/models.py +238 -0
- capsule_trace/core/session.py +168 -0
- capsule_trace/integrations/__init__.py +1 -0
- capsule_trace/integrations/anthropic.py +163 -0
- capsule_trace/integrations/autopatch.py +33 -0
- capsule_trace/integrations/google.py +95 -0
- capsule_trace/integrations/langchain.py +203 -0
- capsule_trace/integrations/langgraph.py +121 -0
- capsule_trace/integrations/openai.py +204 -0
- capsule_trace/integrations/tools.py +161 -0
- capsule_trace/replay/__init__.py +3 -0
- capsule_trace/replay/cassette.py +44 -0
- capsule_trace/replay/engine.py +357 -0
- capsule_trace/replay/mode.py +29 -0
- capsule_trace/storage/__init__.py +4 -0
- capsule_trace/storage/base.py +30 -0
- capsule_trace/storage/sqlite.py +279 -0
- capsule_trace-0.1.0.dist-info/METADATA +124 -0
- capsule_trace-0.1.0.dist-info/RECORD +33 -0
- capsule_trace-0.1.0.dist-info/WHEEL +5 -0
- capsule_trace-0.1.0.dist-info/entry_points.txt +2 -0
- capsule_trace-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Capsule — Deterministic replay & time-travel debugger for AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from capsule_trace.core.decorator import trace
|
|
8
|
+
from capsule_trace.core.session import Session, get_current_session
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["trace", "Session", "get_current_session", "last_session_path"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def last_session_path() -> Path | None:
|
|
15
|
+
"""Return the path of the most recently saved .capsule file, or None."""
|
|
16
|
+
candidates: list[Path] = []
|
|
17
|
+
for search_dir in (Path.home() / ".capsule", Path.cwd()):
|
|
18
|
+
if search_dir.is_dir():
|
|
19
|
+
candidates.extend(search_dir.glob("*.capsule"))
|
|
20
|
+
if not candidates:
|
|
21
|
+
return None
|
|
22
|
+
return max(candidates, key=lambda p: p.stat().st_mtime)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Capsule CLI — entry point for all `capsule` commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(package_name="capsule-trace")
|
|
18
|
+
def main() -> None:
|
|
19
|
+
"""Capsule — deterministic replay & time-travel debugger for AI agents."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── capsule list ──────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@main.command("list")
|
|
26
|
+
@click.option("--agent", default=None, help="Filter by agent name")
|
|
27
|
+
@click.option("--status", default=None, help="Filter by status (success|failed)")
|
|
28
|
+
@click.option("--limit", default=20, show_default=True, help="Max results")
|
|
29
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
30
|
+
def list_sessions(agent: str | None, status: str | None, limit: int, as_json: bool) -> None:
|
|
31
|
+
"""List captured sessions."""
|
|
32
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
33
|
+
|
|
34
|
+
backend = SQLiteBackend.default()
|
|
35
|
+
sessions = backend.list_sessions(limit=limit)
|
|
36
|
+
|
|
37
|
+
if agent:
|
|
38
|
+
sessions = [s for s in sessions if agent.lower() in s.agent_name.lower()]
|
|
39
|
+
if status:
|
|
40
|
+
sessions = [s for s in sessions if s.status.value == status]
|
|
41
|
+
|
|
42
|
+
if as_json:
|
|
43
|
+
click.echo(json.dumps([s.model_dump(mode="json") for s in sessions], indent=2, default=str))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if not sessions:
|
|
47
|
+
console.print("[dim]No sessions found.[/dim]")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
table = Table(show_header=True, header_style="bold")
|
|
51
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
52
|
+
table.add_column("Agent")
|
|
53
|
+
table.add_column("Status")
|
|
54
|
+
table.add_column("Steps", justify="right")
|
|
55
|
+
table.add_column("Duration")
|
|
56
|
+
table.add_column("Started At")
|
|
57
|
+
|
|
58
|
+
for s in sessions:
|
|
59
|
+
status_style = "green" if s.status.value == "success" else "red"
|
|
60
|
+
duration = f"{s.duration_ms:.0f}ms" if s.duration_ms else "-"
|
|
61
|
+
table.add_row(
|
|
62
|
+
s.session_id[:26],
|
|
63
|
+
s.agent_name,
|
|
64
|
+
f"[{status_style}]{s.status.value}[/{status_style}]",
|
|
65
|
+
str(s.step_count),
|
|
66
|
+
duration,
|
|
67
|
+
str(s.started_at)[:19],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
console.print(table)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── capsule show ──────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@main.command("show")
|
|
77
|
+
@click.argument("session_id")
|
|
78
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
79
|
+
def show_session(session_id: str, as_json: bool) -> None:
|
|
80
|
+
"""Show details of a session."""
|
|
81
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
82
|
+
|
|
83
|
+
backend = SQLiteBackend.default()
|
|
84
|
+
try:
|
|
85
|
+
meta = backend.read_session_metadata(session_id)
|
|
86
|
+
events = backend.read_events(session_id)
|
|
87
|
+
except KeyError:
|
|
88
|
+
console.print(f"[red]Session not found:[/red] {session_id}")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
if as_json:
|
|
92
|
+
out = {
|
|
93
|
+
"session": meta.model_dump(mode="json"),
|
|
94
|
+
"events": [e.model_dump_json_safe() for e in events],
|
|
95
|
+
}
|
|
96
|
+
click.echo(json.dumps(out, indent=2, default=str))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
console.print(f"\n[bold]Session[/bold] [cyan]{meta.session_id}[/cyan]")
|
|
100
|
+
console.print(f" Agent: {meta.agent_name}")
|
|
101
|
+
console.print(f" Status: {meta.status.value}")
|
|
102
|
+
console.print(f" Steps: {meta.step_count}")
|
|
103
|
+
console.print(f" Duration: {meta.duration_ms:.0f}ms" if meta.duration_ms else " Duration: -")
|
|
104
|
+
console.print(f" Started: {meta.started_at}")
|
|
105
|
+
|
|
106
|
+
if meta.error:
|
|
107
|
+
console.print(f"\n[red]Error:[/red] {meta.error.type}: {meta.error.message}")
|
|
108
|
+
|
|
109
|
+
console.print(f"\n[bold]Events[/bold] ({len(events)})")
|
|
110
|
+
for event in events:
|
|
111
|
+
console.print(f" [{event.step_index:03d}] {event.event_type.value} — {event.duration_ms:.1f}ms")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── capsule export ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@main.command("export")
|
|
118
|
+
@click.argument("session_id")
|
|
119
|
+
@click.option(
|
|
120
|
+
"--output",
|
|
121
|
+
"-o",
|
|
122
|
+
default=None,
|
|
123
|
+
type=click.Path(),
|
|
124
|
+
help="Output .capsule file path (default: <session_id>.capsule)",
|
|
125
|
+
)
|
|
126
|
+
def export_session(session_id: str, output: str | None) -> None:
|
|
127
|
+
"""Export a session to a .capsule file."""
|
|
128
|
+
from capsule_trace.core.exporter import export_capsule
|
|
129
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
130
|
+
|
|
131
|
+
backend = SQLiteBackend.default()
|
|
132
|
+
out_path = Path(output) if output else Path(f"{session_id}.capsule")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
result = export_capsule(session_id, backend, out_path)
|
|
136
|
+
size_kb = result.stat().st_size / 1024
|
|
137
|
+
console.print(f"[green]Exported[/green] → {result} ({size_kb:.1f} KB)")
|
|
138
|
+
except KeyError:
|
|
139
|
+
console.print(f"[red]Session not found:[/red] {session_id}")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ── capsule import ────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@main.command("import")
|
|
147
|
+
@click.argument("capsule_file", type=click.Path(exists=True))
|
|
148
|
+
def import_capsule(capsule_file: str) -> None:
|
|
149
|
+
"""Import a .capsule file into the local store."""
|
|
150
|
+
from capsule_trace.core.importer import import_capsule_file
|
|
151
|
+
|
|
152
|
+
path = Path(capsule_file)
|
|
153
|
+
try:
|
|
154
|
+
session_id = import_capsule_file(path)
|
|
155
|
+
console.print(f"[green]Imported[/green] session {session_id}")
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
console.print(f"[red]Import failed:[/red] {exc}")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── capsule replay ────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@main.command("replay")
|
|
165
|
+
@click.argument("session_id_or_file")
|
|
166
|
+
@click.option("--mode", default="cassette", type=click.Choice(["cassette", "live"]))
|
|
167
|
+
@click.option("--json", "as_json", is_flag=True, help="Output result as JSON")
|
|
168
|
+
def replay_session(session_id_or_file: str, mode: str, as_json: bool) -> None:
|
|
169
|
+
"""Replay a captured session deterministically from cassettes."""
|
|
170
|
+
from capsule_trace.replay.engine import Replayer
|
|
171
|
+
from pathlib import Path
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
p = Path(session_id_or_file)
|
|
175
|
+
if p.exists() and p.suffix == ".capsule":
|
|
176
|
+
replayer = Replayer.from_file(p)
|
|
177
|
+
else:
|
|
178
|
+
replayer = Replayer.from_session_id(session_id_or_file)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
console.print(f"[red]Failed to load session:[/red] {exc}")
|
|
181
|
+
sys.exit(1)
|
|
182
|
+
|
|
183
|
+
console.print(
|
|
184
|
+
f"Replaying [cyan]{replayer.session_id}[/cyan] "
|
|
185
|
+
f"({replayer.step_count} steps) in [bold]{mode}[/bold] mode..."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
result = replayer.replay()
|
|
189
|
+
|
|
190
|
+
if as_json:
|
|
191
|
+
click.echo(json.dumps({
|
|
192
|
+
"session_id": result.session_id,
|
|
193
|
+
"replayed_steps": result.replayed_step_count,
|
|
194
|
+
"original_steps": result.original_step_count,
|
|
195
|
+
"is_deterministic": result.is_deterministic,
|
|
196
|
+
"integrity_ok": result.integrity_ok,
|
|
197
|
+
}, indent=2))
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
status = "[green]deterministic[/green]" if result.is_deterministic else "[yellow]mismatch[/yellow]"
|
|
201
|
+
console.print(f"\nResult: {status}")
|
|
202
|
+
console.print(f" Steps replayed: {result.replayed_step_count}/{result.original_step_count}")
|
|
203
|
+
console.print(f" Integrity check: {'✓' if result.integrity_ok else '✗'}")
|
|
204
|
+
|
|
205
|
+
for e in result.events:
|
|
206
|
+
has_cassette = "[dim](cassette)[/dim]" if e.event_type.value in ("llm_call", "tool_call") else ""
|
|
207
|
+
console.print(f" [{e.step_index:03d}] {e.event_type.value} {has_cassette}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── capsule branch ────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@main.command("branch")
|
|
214
|
+
@click.argument("session_id")
|
|
215
|
+
@click.option("--from-step", "-s", required=True, type=int, help="Step index to branch from")
|
|
216
|
+
@click.option("--modify", "-m", multiple=True, help="key=value modifications (e.g. temperature=0.0)")
|
|
217
|
+
def branch_session(session_id: str, from_step: int, modify: tuple[str, ...]) -> None:
|
|
218
|
+
"""Branch a session from a specific step with optional modifications."""
|
|
219
|
+
modifications: dict[str, str] = {}
|
|
220
|
+
for item in modify:
|
|
221
|
+
if "=" not in item:
|
|
222
|
+
console.print(f"[red]Invalid modification (expected key=value):[/red] {item}")
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
k, v = item.split("=", 1)
|
|
225
|
+
modifications[k.strip()] = v.strip()
|
|
226
|
+
|
|
227
|
+
from capsule_trace.replay.engine import Replayer
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
replayer = Replayer.from_session_id(session_id)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
console.print(f"[red]Failed to load session:[/red] {exc}")
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
branch = replayer.branch_from_step(from_step, modifications)
|
|
237
|
+
except IndexError as exc:
|
|
238
|
+
console.print(f"[red]{exc}[/red]")
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
|
|
241
|
+
console.print(
|
|
242
|
+
f"Branch context ready: [cyan]{session_id}[/cyan] @ step {from_step}"
|
|
243
|
+
)
|
|
244
|
+
console.print(f" Pre-branch events: {len(branch.pre_branch_events)}")
|
|
245
|
+
console.print(f" Modifications: {branch.modifications or '(none)'}")
|
|
246
|
+
console.print(
|
|
247
|
+
"\n[dim]Re-run your agent code under this branch context to continue "
|
|
248
|
+
"from step {from_step} with live LLM calls.[/dim]"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── capsule diff ──────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@main.command("diff")
|
|
256
|
+
@click.argument("session_id_1")
|
|
257
|
+
@click.argument("session_id_2")
|
|
258
|
+
def diff_sessions(session_id_1: str, session_id_2: str) -> None:
|
|
259
|
+
"""Show differences between two sessions."""
|
|
260
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
261
|
+
|
|
262
|
+
backend = SQLiteBackend.default()
|
|
263
|
+
try:
|
|
264
|
+
m1 = backend.read_session_metadata(session_id_1)
|
|
265
|
+
m2 = backend.read_session_metadata(session_id_2)
|
|
266
|
+
e1 = backend.read_events(session_id_1)
|
|
267
|
+
e2 = backend.read_events(session_id_2)
|
|
268
|
+
except KeyError as exc:
|
|
269
|
+
console.print(f"[red]Session not found:[/red] {exc}")
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
console.print(f"\n[bold]Session A[/bold] [cyan]{session_id_1}[/cyan]: {m1.status.value}, {len(e1)} steps")
|
|
273
|
+
console.print(f"[bold]Session B[/bold] [cyan]{session_id_2}[/cyan]: {m2.status.value}, {len(e2)} steps")
|
|
274
|
+
console.print(f"\nStep count diff: {len(e1)} vs {len(e2)} ({len(e2) - len(e1):+d})")
|
|
275
|
+
|
|
276
|
+
min_len = min(len(e1), len(e2))
|
|
277
|
+
diffs = 0
|
|
278
|
+
for i in range(min_len):
|
|
279
|
+
if e1[i].event_type != e2[i].event_type:
|
|
280
|
+
console.print(
|
|
281
|
+
f" Step {i:03d}: [yellow]event type changed[/yellow] "
|
|
282
|
+
f"{e1[i].event_type.value} → {e2[i].event_type.value}"
|
|
283
|
+
)
|
|
284
|
+
diffs += 1
|
|
285
|
+
|
|
286
|
+
if diffs == 0 and len(e1) == len(e2):
|
|
287
|
+
console.print("\n[green]Sessions have identical event structure.[/green]")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ── capsule delete ────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@main.command("delete")
|
|
294
|
+
@click.argument("session_id")
|
|
295
|
+
@click.option("--yes", is_flag=True, help="Skip confirmation prompt")
|
|
296
|
+
def delete_session(session_id: str, yes: bool) -> None:
|
|
297
|
+
"""Delete a session from the local store."""
|
|
298
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
299
|
+
|
|
300
|
+
if not yes:
|
|
301
|
+
click.confirm(f"Delete session {session_id}?", abort=True)
|
|
302
|
+
|
|
303
|
+
backend = SQLiteBackend.default()
|
|
304
|
+
try:
|
|
305
|
+
backend.delete_session(session_id)
|
|
306
|
+
console.print(f"[green]Deleted[/green] session {session_id}")
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
console.print(f"[red]Delete failed:[/red] {exc}")
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ── capsule serve ─────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@main.command("serve")
|
|
316
|
+
@click.option("--port", default=7842, show_default=True)
|
|
317
|
+
def serve(port: int) -> None:
|
|
318
|
+
"""Start the local web UI."""
|
|
319
|
+
console.print(f"[yellow]Local web UI coming in Sprint 6 (cloud platform).[/yellow]")
|
|
320
|
+
console.print(f"Would start on http://localhost:{port}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── capsule upload ────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@main.command("upload")
|
|
327
|
+
@click.argument("session_id")
|
|
328
|
+
@click.option("--workspace", "-w", default=None, envvar="CAPSULE_WORKSPACE_ID",
|
|
329
|
+
help="Workspace ID (or set CAPSULE_WORKSPACE_ID)")
|
|
330
|
+
@click.option("--api-key", "-k", default=None, envvar="CAPSULE_API_KEY",
|
|
331
|
+
help="API key (or set CAPSULE_API_KEY)")
|
|
332
|
+
@click.option("--cloud-url", default=None, envvar="CAPSULE_CLOUD_URL",
|
|
333
|
+
help="Cloud API base URL (default: https://api.capsule.dev)")
|
|
334
|
+
@click.option("--tag", "tags", multiple=True, help="Extra tags to attach")
|
|
335
|
+
@click.option("--redact", is_flag=True, help="Auto-redact PII before upload")
|
|
336
|
+
@click.option("--json", "as_json", is_flag=True, help="Output response as JSON")
|
|
337
|
+
def upload_session_cmd(
|
|
338
|
+
session_id: str,
|
|
339
|
+
workspace: str | None,
|
|
340
|
+
api_key: str | None,
|
|
341
|
+
cloud_url: str | None,
|
|
342
|
+
tags: tuple[str, ...],
|
|
343
|
+
redact: bool,
|
|
344
|
+
as_json: bool,
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Upload a local session to Capsule Cloud."""
|
|
347
|
+
import os as _os
|
|
348
|
+
|
|
349
|
+
if workspace:
|
|
350
|
+
_os.environ["CAPSULE_WORKSPACE_ID"] = workspace
|
|
351
|
+
if api_key:
|
|
352
|
+
_os.environ["CAPSULE_API_KEY"] = api_key
|
|
353
|
+
if cloud_url:
|
|
354
|
+
_os.environ["CAPSULE_CLOUD_URL"] = cloud_url
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
from capsule_trace.cloud.uploader import upload_session
|
|
358
|
+
|
|
359
|
+
console.print(f"Uploading session [cyan]{session_id}[/cyan]…")
|
|
360
|
+
result = upload_session(
|
|
361
|
+
session_id,
|
|
362
|
+
tags=list(tags) if tags else None,
|
|
363
|
+
auto_redact=redact,
|
|
364
|
+
)
|
|
365
|
+
except RuntimeError as exc:
|
|
366
|
+
console.print(f"[red]Configuration error:[/red] {exc}")
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
console.print(f"[red]Upload failed:[/red] {exc}")
|
|
370
|
+
sys.exit(1)
|
|
371
|
+
|
|
372
|
+
if as_json:
|
|
373
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
console.print(f"[green]Uploaded![/green] Cloud ID: [cyan]{result.get('id', '?')}[/cyan]")
|
|
377
|
+
if result.get("view_url"):
|
|
378
|
+
console.print(f" View: {result['view_url']}")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ── capsule cloud ─────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@main.group("cloud")
|
|
385
|
+
def cloud_group() -> None:
|
|
386
|
+
"""Manage Capsule Cloud connection."""
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@cloud_group.command("login")
|
|
390
|
+
@click.option("--url", default="https://api.capsule.dev", show_default=True,
|
|
391
|
+
help="Cloud API base URL")
|
|
392
|
+
@click.option("--api-key", prompt="API key", hide_input=True,
|
|
393
|
+
help="Your Capsule Cloud API key (csk_…)")
|
|
394
|
+
@click.option("--workspace", prompt="Workspace ID",
|
|
395
|
+
help="Your workspace ID")
|
|
396
|
+
def cloud_login(url: str, api_key: str, workspace: str) -> None:
|
|
397
|
+
"""Save Capsule Cloud credentials to ~/.capsule/cloud.json."""
|
|
398
|
+
import json as _json
|
|
399
|
+
|
|
400
|
+
config_dir = Path.home() / ".capsule"
|
|
401
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
config_file = config_dir / "cloud.json"
|
|
403
|
+
|
|
404
|
+
config_data = {
|
|
405
|
+
"base_url": url,
|
|
406
|
+
"api_key": api_key,
|
|
407
|
+
"workspace_id": workspace,
|
|
408
|
+
}
|
|
409
|
+
config_file.write_text(_json.dumps(config_data, indent=2))
|
|
410
|
+
config_file.chmod(0o600) # restrict to owner
|
|
411
|
+
|
|
412
|
+
console.print(f"[green]Saved cloud config[/green] → {config_file}")
|
|
413
|
+
console.print(f" Workspace: [cyan]{workspace}[/cyan]")
|
|
414
|
+
console.print(f" URL: {url}")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@cloud_group.command("status")
|
|
418
|
+
def cloud_status() -> None:
|
|
419
|
+
"""Show current Capsule Cloud connection status."""
|
|
420
|
+
from capsule_trace.cloud.uploader import _get_cloud_config
|
|
421
|
+
|
|
422
|
+
config = _get_cloud_config()
|
|
423
|
+
if config["api_key"]:
|
|
424
|
+
masked = config["api_key"][:8] + "…" if len(config["api_key"]) > 8 else "***"
|
|
425
|
+
console.print(f"[green]Connected[/green] to {config['base_url']}")
|
|
426
|
+
console.print(f" API key: {masked}")
|
|
427
|
+
console.print(f" Workspace: {config['workspace_id'] or '[dim](not set)[/dim]'}")
|
|
428
|
+
else:
|
|
429
|
+
console.print("[yellow]Not connected.[/yellow] Run `capsule cloud login` to configure.")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Capsule Cloud integration — upload sessions to Capsule Cloud."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Upload a local session to the Capsule Cloud API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("capsule.cloud")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_cloud_config() -> dict[str, str]:
|
|
15
|
+
"""Read cloud config from env vars or ~/.capsule/cloud.json."""
|
|
16
|
+
base_url = os.environ.get("CAPSULE_CLOUD_URL", "https://api.capsule.dev")
|
|
17
|
+
api_key = os.environ.get("CAPSULE_API_KEY", "")
|
|
18
|
+
workspace_id = os.environ.get("CAPSULE_WORKSPACE_ID", "")
|
|
19
|
+
|
|
20
|
+
# Fall back to config file
|
|
21
|
+
config_file = Path.home() / ".capsule" / "cloud.json"
|
|
22
|
+
if config_file.exists() and (not api_key or not workspace_id):
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(config_file.read_text())
|
|
25
|
+
base_url = base_url or data.get("base_url", base_url)
|
|
26
|
+
api_key = api_key or data.get("api_key", "")
|
|
27
|
+
workspace_id = workspace_id or data.get("workspace_id", "")
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
return {"base_url": base_url, "api_key": api_key, "workspace_id": workspace_id}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def upload_session(
|
|
35
|
+
session_id: str,
|
|
36
|
+
*,
|
|
37
|
+
agent_name: str | None = None,
|
|
38
|
+
agent_version: str | None = None,
|
|
39
|
+
tags: list[str] | None = None,
|
|
40
|
+
user_metadata: dict[str, Any] | None = None,
|
|
41
|
+
auto_redact: bool = False,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Export the session to a .capsule file and upload it to Capsule Cloud.
|
|
44
|
+
|
|
45
|
+
Returns the API response dict.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
RuntimeError: if CAPSULE_API_KEY or CAPSULE_WORKSPACE_ID are not configured.
|
|
49
|
+
httpx.HTTPStatusError: if the upload fails.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
import httpx
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
raise ImportError("httpx is required for cloud uploads: pip install httpx") from exc
|
|
55
|
+
|
|
56
|
+
from capsule_trace.core.exporter import export_capsule
|
|
57
|
+
from capsule_trace.storage.sqlite import SQLiteBackend
|
|
58
|
+
|
|
59
|
+
config = _get_cloud_config()
|
|
60
|
+
if not config["api_key"]:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
"CAPSULE_API_KEY is not set. "
|
|
63
|
+
"Run `capsule cloud login` or set the env var."
|
|
64
|
+
)
|
|
65
|
+
if not config["workspace_id"]:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"CAPSULE_WORKSPACE_ID is not set. "
|
|
68
|
+
"Set it via env var or `capsule cloud login`."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Export to a temp .capsule file
|
|
72
|
+
import tempfile
|
|
73
|
+
|
|
74
|
+
backend = SQLiteBackend.default()
|
|
75
|
+
meta = backend.read_session_metadata(session_id)
|
|
76
|
+
|
|
77
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
78
|
+
capsule_path = export_capsule(session_id, backend, Path(tmpdir) / f"{session_id}.capsule")
|
|
79
|
+
|
|
80
|
+
upload_metadata = {
|
|
81
|
+
"session_id": session_id,
|
|
82
|
+
"agent_name": agent_name or meta.agent_name,
|
|
83
|
+
"agent_version": agent_version or meta.agent_version,
|
|
84
|
+
"tags": tags if tags is not None else meta.tags,
|
|
85
|
+
"user_metadata": user_metadata if user_metadata is not None else meta.user_metadata,
|
|
86
|
+
"auto_redact": auto_redact,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
url = f"{config['base_url']}/api/v1/workspaces/{config['workspace_id']}/sessions"
|
|
90
|
+
headers = {"Authorization": f"Bearer {config['api_key']}"}
|
|
91
|
+
|
|
92
|
+
with open(capsule_path, "rb") as f:
|
|
93
|
+
resp = httpx.post(
|
|
94
|
+
url,
|
|
95
|
+
headers=headers,
|
|
96
|
+
files={"file": (capsule_path.name, f, "application/octet-stream")},
|
|
97
|
+
data={"metadata": json.dumps(upload_metadata)},
|
|
98
|
+
timeout=120.0,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
resp.raise_for_status()
|
|
102
|
+
result: dict[str, Any] = resp.json()
|
|
103
|
+
logger.info(
|
|
104
|
+
"capsule.cloud.uploaded",
|
|
105
|
+
session_id=session_id,
|
|
106
|
+
response_id=result.get("id"),
|
|
107
|
+
)
|
|
108
|
+
return result
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ContextVar-based session tracking — async-safe across concurrent agent calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from capsule_trace.core.session import Session
|
|
10
|
+
|
|
11
|
+
_current_session: ContextVar["Session | None"] = ContextVar(
|
|
12
|
+
"capsule_session", default=None
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_current_session() -> "Session | None":
|
|
17
|
+
return _current_session.get()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_current_session(session: "Session | None") -> None:
|
|
21
|
+
_current_session.set(session)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""@capsule.trace decorator — wraps a function or coroutine in a Session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Callable, TypeVar
|
|
9
|
+
|
|
10
|
+
from capsule_trace.core.session import Session
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
_DISABLED_ENV = "CAPSULE_DISABLE"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_disabled() -> bool:
|
|
18
|
+
return os.environ.get(_DISABLED_ENV, "").strip().lower() in ("1", "true", "yes")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def trace(
|
|
22
|
+
agent_name: str | None = None,
|
|
23
|
+
agent_version: str | None = None,
|
|
24
|
+
tags: list[str] | None = None,
|
|
25
|
+
user_metadata: dict[str, Any] | None = None,
|
|
26
|
+
redact: list[str] | None = None,
|
|
27
|
+
auto_upload: bool = False,
|
|
28
|
+
storage_backend: Any | None = None,
|
|
29
|
+
) -> Callable[[F], F]:
|
|
30
|
+
"""Decorator that wraps a function in a Capsule session.
|
|
31
|
+
|
|
32
|
+
Usage::
|
|
33
|
+
|
|
34
|
+
@capsule.trace(agent_name="billing-agent")
|
|
35
|
+
def run_agent(customer_id: str) -> str:
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@capsule.trace(agent_name="async-agent")
|
|
39
|
+
async def run_async_agent(query: str) -> str:
|
|
40
|
+
...
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def decorator(fn: F) -> F:
|
|
44
|
+
name = agent_name or fn.__name__
|
|
45
|
+
|
|
46
|
+
if asyncio.iscoroutinefunction(fn):
|
|
47
|
+
|
|
48
|
+
@functools.wraps(fn)
|
|
49
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
50
|
+
if _is_disabled():
|
|
51
|
+
return await fn(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
session = Session(
|
|
54
|
+
agent_name=name,
|
|
55
|
+
agent_version=agent_version,
|
|
56
|
+
tags=tags,
|
|
57
|
+
user_metadata=user_metadata,
|
|
58
|
+
redact=redact,
|
|
59
|
+
auto_upload=auto_upload,
|
|
60
|
+
storage_backend=storage_backend,
|
|
61
|
+
)
|
|
62
|
+
async with session:
|
|
63
|
+
return await fn(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
return async_wrapper # type: ignore[return-value]
|
|
66
|
+
|
|
67
|
+
else:
|
|
68
|
+
|
|
69
|
+
@functools.wraps(fn)
|
|
70
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
71
|
+
if _is_disabled():
|
|
72
|
+
return fn(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
session = Session(
|
|
75
|
+
agent_name=name,
|
|
76
|
+
agent_version=agent_version,
|
|
77
|
+
tags=tags,
|
|
78
|
+
user_metadata=user_metadata,
|
|
79
|
+
redact=redact,
|
|
80
|
+
auto_upload=auto_upload,
|
|
81
|
+
storage_backend=storage_backend,
|
|
82
|
+
)
|
|
83
|
+
with session:
|
|
84
|
+
return fn(*args, **kwargs)
|
|
85
|
+
|
|
86
|
+
return sync_wrapper # type: ignore[return-value]
|
|
87
|
+
|
|
88
|
+
return decorator
|