glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,755 @@
1
+ """Transcript commands for inspecting cached agent transcripts.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from collections.abc import Iterable, Sequence
12
+ from datetime import datetime, timedelta, timezone
13
+ from io import StringIO
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import click
18
+ from rich.console import Console
19
+
20
+ from glaip_sdk.branding import (
21
+ INFO_STYLE,
22
+ SUCCESS_STYLE,
23
+ WARNING_STYLE,
24
+ )
25
+ from glaip_sdk.cli.transcript.cache import (
26
+ export_transcript as export_cached_transcript,
27
+ )
28
+ from glaip_sdk.cli.transcript.history import (
29
+ ClearResult,
30
+ HistoryEntry,
31
+ HistorySnapshot,
32
+ clear_cached_runs,
33
+ coerce_sortable_datetime,
34
+ load_history_snapshot,
35
+ )
36
+ from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
37
+ from glaip_sdk.cli.utils import format_size, get_ctx_value, parse_json_line
38
+ from glaip_sdk.rich_components import AIPTable
39
+ from glaip_sdk.utils.rendering.layout.panels import create_final_panel
40
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
41
+
42
+ console = Console()
43
+
44
+
45
+ def _format_duration(seconds: int | None) -> str:
46
+ """Format elapsed seconds as HH:MM:SS."""
47
+ if seconds is None:
48
+ return "—"
49
+ seconds = int(max(0, seconds))
50
+ delta = timedelta(seconds=seconds)
51
+ total = int(delta.total_seconds())
52
+ hours, remainder = divmod(total, 3600)
53
+ minutes, secs = divmod(remainder, 60)
54
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
55
+
56
+
57
+ def _format_timestamp(value: datetime | None) -> str:
58
+ """Render datetimes in UTC display format."""
59
+ if value is None:
60
+ return "—"
61
+ try:
62
+ dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
63
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
64
+ except Exception:
65
+ return str(value)
66
+
67
+
68
+ def _row_label(entry: HistoryEntry) -> str:
69
+ """Build a run id label with warning markers."""
70
+ suffix = ""
71
+ if entry.warning:
72
+ suffix += " !"
73
+ return f"{entry.run_id}{suffix}"
74
+
75
+
76
+ def _should_use_transcript_viewer(ctx: click.Context | None, target_console: Console, *, force: bool = False) -> bool:
77
+ """Return True if the interactive transcript viewer should be launched."""
78
+ if not target_console.is_terminal:
79
+ return False
80
+ if force:
81
+ return True
82
+
83
+ selected_view = get_ctx_value(ctx, "view", "rich") if ctx else "rich"
84
+ if selected_view != "rich":
85
+ return False
86
+ if ctx is not None and not bool(get_ctx_value(ctx, "tty", True)):
87
+ return False
88
+ try:
89
+ return bool(sys.stdin.isatty() and sys.stdout.isatty())
90
+ except Exception:
91
+ return False
92
+
93
+
94
+ def _coerce_timestamp_to_float(value: Any) -> float | None:
95
+ """Convert assorted timestamp formats to epoch seconds."""
96
+ if value is None:
97
+ return None
98
+ if isinstance(value, (int, float)):
99
+ try:
100
+ return float(value)
101
+ except Exception:
102
+ return None
103
+ if isinstance(value, datetime):
104
+ try:
105
+ return value.timestamp()
106
+ except Exception:
107
+ return None
108
+ if isinstance(value, str):
109
+ try:
110
+ text = value.replace("Z", "+00:00")
111
+ parsed = datetime.fromisoformat(text)
112
+ return parsed.timestamp()
113
+ except ValueError:
114
+ return None
115
+ return None
116
+
117
+
118
+ def _build_viewer_context(
119
+ entry: HistoryEntry, meta: dict[str, Any] | None, events: list[dict[str, Any]]
120
+ ) -> ViewerContext:
121
+ """Create a ViewerContext payload for the cached transcript."""
122
+ manifest_entry = dict(entry.manifest or {})
123
+ if entry.run_id and not manifest_entry.get("run_id"):
124
+ manifest_entry["run_id"] = entry.run_id
125
+
126
+ meta_payload = dict(meta or {})
127
+ default_output = str(meta_payload.get("default_output") or meta_payload.get("renderer_output") or "")
128
+ final_output = str(meta_payload.get("final_output") or "")
129
+
130
+ started_hint = meta_payload.get("started_at") or entry.started_at or entry.started_at_iso
131
+ stream_started_at = _coerce_timestamp_to_float(started_hint)
132
+
133
+ return ViewerContext(
134
+ manifest_entry=manifest_entry,
135
+ events=list(events),
136
+ default_output=default_output,
137
+ final_output=final_output,
138
+ stream_started_at=stream_started_at,
139
+ meta=meta_payload,
140
+ )
141
+
142
+
143
+ def _launch_transcript_viewer(
144
+ entry: HistoryEntry,
145
+ meta: dict[str, Any] | None,
146
+ events: list[dict[str, Any]],
147
+ *,
148
+ console_override: Console | None = None,
149
+ initial_view: str = "default",
150
+ ) -> bool:
151
+ """Launch the transcript viewer for a cached run."""
152
+ if not entry.run_id:
153
+ return False
154
+
155
+ target_console = console_override or console
156
+ viewer_ctx = _build_viewer_context(entry, meta, events)
157
+
158
+ def _export(destination: Path) -> Path:
159
+ """Export cached transcript to destination.
160
+
161
+ Args:
162
+ destination: Path to export transcript to.
163
+
164
+ Returns:
165
+ Path to exported transcript file.
166
+ """
167
+ return export_cached_transcript(destination=destination, run_id=entry.run_id)
168
+
169
+ run_viewer_session(target_console, viewer_ctx, _export, initial_view=initial_view)
170
+ return True
171
+
172
+
173
+ def _maybe_launch_transcript_viewer(
174
+ ctx: click.Context | None,
175
+ entry: HistoryEntry,
176
+ meta: dict[str, Any] | None,
177
+ events: list[dict[str, Any]],
178
+ *,
179
+ console_override: Console | None = None,
180
+ force: bool = False,
181
+ initial_view: str = "default",
182
+ ) -> bool:
183
+ """Launch the transcript viewer when the environment supports it."""
184
+ target_console = console_override or console
185
+ if not _should_use_transcript_viewer(ctx, target_console, force=force):
186
+ return False
187
+ try:
188
+ _launch_transcript_viewer(
189
+ entry,
190
+ meta,
191
+ events,
192
+ console_override=target_console,
193
+ initial_view=initial_view,
194
+ )
195
+ return True
196
+ except Exception:
197
+ return False
198
+
199
+
200
+ def _build_table(entries: Iterable[HistoryEntry]) -> AIPTable:
201
+ """Create the Rich table used by both CLI and slash history commands."""
202
+ table = AIPTable(title="Agent run cache", expand=True)
203
+ table.add_column("Run ID", style="bold")
204
+ table.add_column("Agent")
205
+ table.add_column("Agent ID")
206
+ table.add_column("API URL")
207
+ table.add_column("Started (UTC)")
208
+ table.add_column("Duration")
209
+ table.add_column("Size")
210
+
211
+ for entry in entries:
212
+ row_style = WARNING_STYLE if entry.warning else None
213
+ if entry.status == "cached":
214
+ size_value = entry.size_bytes or 0
215
+ size_text = format_size(size_value)
216
+ else:
217
+ size_text = "—"
218
+ table.add_row(
219
+ _row_label(entry),
220
+ entry.agent_name or "—",
221
+ entry.agent_id or "—",
222
+ entry.api_url or "—",
223
+ _format_timestamp(entry.started_at),
224
+ _format_duration(entry.duration_seconds),
225
+ size_text,
226
+ style=row_style,
227
+ )
228
+ return table
229
+
230
+
231
+ def _emit_warnings(snapshot: HistorySnapshot) -> None:
232
+ """Print warning strings associated with a snapshot."""
233
+ for warning in snapshot.warnings:
234
+ console.print(f"[{WARNING_STYLE}]{warning}[/]")
235
+
236
+
237
+ def _abbreviate_path(path: Path | None) -> str:
238
+ """Return a cache path with the home directory abbreviated to `~`."""
239
+ if path is None:
240
+ return "—"
241
+
242
+ raw = str(path)
243
+ try:
244
+ home = Path.home()
245
+ home_str = str(home)
246
+ except Exception:
247
+ return raw
248
+
249
+ if home_str and raw.startswith(home_str):
250
+ suffix = raw[len(home_str) :]
251
+ if suffix.startswith("/"):
252
+ suffix = suffix[1:]
253
+ return f"~/{suffix}" if suffix else "~"
254
+ return raw
255
+
256
+
257
+ def _parse_transcript_line(raw_line: str) -> dict[str, Any] | None:
258
+ """Parse a JSONL transcript line into a dictionary payload."""
259
+ return parse_json_line(raw_line)
260
+
261
+
262
+ def _decode_transcript(contents: str) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
263
+ """Decode transcript JSONL contents into meta and event payloads."""
264
+ meta: dict[str, Any] | None = None
265
+ events: list[dict[str, Any]] = []
266
+
267
+ for payload in filter(None, (_parse_transcript_line(line) for line in contents.splitlines())):
268
+ kind = payload.get("type")
269
+ if kind == "meta" and meta is None:
270
+ meta = payload
271
+ continue
272
+ if kind == "event":
273
+ event = payload.get("event")
274
+ if isinstance(event, dict):
275
+ events.append(event)
276
+
277
+ return meta, events
278
+
279
+
280
+ def _render_transcript_display(
281
+ entry: HistoryEntry,
282
+ manifest_path: Path,
283
+ transcript_path: Path,
284
+ meta: dict[str, Any] | None,
285
+ events: list[dict[str, Any]],
286
+ ) -> str:
287
+ """Return a Rich-formatted transcript stream similar to transcript mode."""
288
+ buffer = StringIO()
289
+ width = console.width or 120
290
+ view_console = Console(
291
+ file=buffer,
292
+ force_terminal=True,
293
+ color_system=console.color_system,
294
+ width=width,
295
+ soft_wrap=True,
296
+ )
297
+
298
+ header = (
299
+ f"[dim]Manifest: {manifest_path} · {entry.run_id or '—'} · "
300
+ f"{_abbreviate_path(transcript_path)} · {len(events)} events[/]"
301
+ )
302
+ view_console.print(header)
303
+ view_console.print()
304
+
305
+ final_text = None
306
+ if meta:
307
+ final_text = meta.get("final_output") or meta.get("default_output")
308
+ if final_text:
309
+ view_console.print(create_final_panel(final_text, title="Final Result", theme="dark"))
310
+ view_console.print()
311
+
312
+ view_console.print("[bold]Transcript Events[/bold]")
313
+ if not events:
314
+ view_console.print("[dim]No SSE events were captured for this run.[/dim]")
315
+ else:
316
+ view_console.print("[dim]────────────────────────────────────────────────────────[/dim]")
317
+ baseline: datetime | None = None
318
+ for event in events:
319
+ received = _parse_event_received_timestamp(event)
320
+ if baseline is None and received is not None:
321
+ baseline = received
322
+ render_debug_event(event, view_console, received_ts=received, baseline_ts=baseline)
323
+ view_console.print()
324
+
325
+ return buffer.getvalue()
326
+
327
+
328
+ def _render_transcript_jsonl(
329
+ entry: HistoryEntry,
330
+ manifest_path: Path,
331
+ transcript_path: Path,
332
+ contents: str,
333
+ ) -> str:
334
+ """Return a plain-text transcript stream that mirrors the cached JSONL payload."""
335
+ header = f"Manifest: {manifest_path} · {entry.run_id or '—'} · {_abbreviate_path(transcript_path)}"
336
+ normalized = contents if contents.endswith("\n") else contents + "\n"
337
+ return f"{header}\n{normalized}"
338
+
339
+
340
+ def _parse_event_received_timestamp(event: dict[str, Any]) -> datetime | None:
341
+ """Extract received timestamp metadata from an SSE event."""
342
+ metadata = event.get("metadata") or {}
343
+ ts_value = metadata.get("received_at") or event.get("received_at")
344
+ if not ts_value:
345
+ return None
346
+ if isinstance(ts_value, datetime):
347
+ return ts_value if ts_value.tzinfo else ts_value.replace(tzinfo=timezone.utc)
348
+ if isinstance(ts_value, str):
349
+ try:
350
+ text = ts_value.replace("Z", "+00:00")
351
+ parsed = datetime.fromisoformat(text)
352
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
353
+ except ValueError:
354
+ return None
355
+ return None
356
+
357
+
358
+ def _resolve_transcript_path(entry: HistoryEntry) -> Path:
359
+ """Locate the cached transcript for a manifest entry or raise a helpful error."""
360
+ target = entry.resolved_path or entry.expected_path
361
+ if target is None:
362
+ raise click.ClickException(
363
+ f"Manifest entry for run {entry.run_id or '?'} does not include a transcript filename."
364
+ )
365
+ if not target.exists():
366
+ run_label = entry.run_id or "?"
367
+ hint = entry.run_id or "<RUN_ID>"
368
+ location = _abbreviate_path(target)
369
+ raise click.ClickException(
370
+ f"Transcript file missing for run {run_label} (expected {location}). "
371
+ f"Run `aip transcripts clear --id {hint}` to reconcile the manifest."
372
+ )
373
+ return target
374
+
375
+
376
+ def _load_transcript_text(entry: HistoryEntry) -> tuple[Path, str]:
377
+ """Read the cached transcript file into memory."""
378
+ path = _resolve_transcript_path(entry)
379
+ try:
380
+ contents = path.read_text(encoding="utf-8")
381
+ except FileNotFoundError:
382
+ raise click.ClickException(
383
+ f"Transcript file missing for run {entry.run_id or '?'} (expected {path})."
384
+ ) from None
385
+ except OSError as exc: # Permission problems, etc.
386
+ raise click.ClickException(f"Failed to read cached transcript {path}: {exc}") from exc
387
+ return path, contents
388
+
389
+
390
+ def _transcripts_payload(snapshot: HistorySnapshot) -> dict:
391
+ """Convert a snapshot into the JSON payload returned by `aip transcripts --json`."""
392
+ rows = []
393
+ for entry in snapshot.entries:
394
+ row = {
395
+ "run_id": entry.run_id,
396
+ "started_at": entry.started_at_iso,
397
+ "finished_at": entry.finished_at_iso,
398
+ "agent_name": entry.agent_name,
399
+ "agent_id": entry.agent_id,
400
+ "api_url": entry.api_url,
401
+ "duration_seconds": entry.duration_seconds,
402
+ "size_bytes": entry.size_bytes,
403
+ "status": entry.status,
404
+ "warning": entry.warning,
405
+ }
406
+ rows.append(row)
407
+
408
+ return {
409
+ "manifest_path": str(snapshot.manifest_path),
410
+ "limit_requested": snapshot.limit_requested,
411
+ "limit_applied": snapshot.limit_applied,
412
+ "limit_clamped": snapshot.limit_clamped,
413
+ "total_entries": snapshot.total_entries,
414
+ "cached_entries": snapshot.cached_entries,
415
+ "total_size_bytes": snapshot.total_size_bytes,
416
+ "warnings": list(snapshot.warnings),
417
+ "migration_summary": snapshot.migration_summary,
418
+ "rows": rows,
419
+ }
420
+
421
+
422
+ def _print_snapshot(snapshot: HistorySnapshot) -> None:
423
+ """Render the textual history view for the standard CLI."""
424
+ if snapshot.cached_entries == 0:
425
+ console.print(f"[{WARNING_STYLE}]No cached transcripts found. Try running an agent first.[/]")
426
+ console.print(f"[dim]Manifest: {snapshot.manifest_path}[/]")
427
+ if snapshot.total_entries and snapshot.warnings:
428
+ _emit_warnings(snapshot)
429
+ return
430
+
431
+ header = (
432
+ f"[dim]Manifest: {snapshot.manifest_path} · {snapshot.total_entries} runs · "
433
+ f"{format_size(snapshot.total_size_bytes)} used"
434
+ )
435
+ if snapshot.limit_applied and snapshot.total_entries > snapshot.limit_applied:
436
+ header += (
437
+ f" · showing {len(snapshot.entries)} of {snapshot.total_entries} runs (limit={snapshot.limit_applied})"
438
+ )
439
+ console.print(header)
440
+
441
+ if snapshot.limit_clamped:
442
+ console.print(
443
+ f"[{WARNING_STYLE}]Requested limit exceeded maximum. Showing first {snapshot.limit_applied} runs.[/]"
444
+ )
445
+
446
+ if snapshot.migration_summary:
447
+ console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
448
+
449
+ _emit_warnings(snapshot)
450
+
451
+ table = _build_table(snapshot.entries)
452
+ console.print(table)
453
+ console.print("[dim]! Missing transcript[/]")
454
+
455
+
456
+ def _render_detail_view(ctx: click.Context | None, snapshot: HistorySnapshot, run_id: str) -> None:
457
+ """Render the cached transcript for a specific run."""
458
+ entry = snapshot.index.get(run_id)
459
+ if entry is None:
460
+ raise click.ClickException(f"Run id {run_id} was not found in {snapshot.manifest_path}.")
461
+
462
+ path, contents = _load_transcript_text(entry)
463
+
464
+ meta, events = _decode_transcript(contents)
465
+ if _maybe_launch_transcript_viewer(ctx, entry, meta, events, force=True, initial_view="transcript"):
466
+ if snapshot.migration_summary:
467
+ console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
468
+ _emit_warnings(snapshot)
469
+ return
470
+
471
+ if snapshot.migration_summary:
472
+ console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
473
+ _emit_warnings(snapshot)
474
+ transcript_view = _render_transcript_jsonl(entry, snapshot.manifest_path, path, contents)
475
+ click.echo_via_pager(transcript_view)
476
+
477
+
478
+ def _render_history_overview(snapshot: HistorySnapshot, emit_json: bool) -> None:
479
+ """Render the standard history table or its JSON payload."""
480
+ if emit_json:
481
+ payload = _transcripts_payload(snapshot)
482
+ click.echo(json.dumps(payload, indent=2, default=str))
483
+ for warning in snapshot.warnings:
484
+ click.echo(warning, err=True)
485
+ return
486
+
487
+ _print_snapshot(snapshot)
488
+
489
+
490
+ @click.group("transcripts", invoke_without_command=True)
491
+ @click.option("--limit", type=int, help="Maximum runs to display (default 10).")
492
+ @click.option("--json", "as_json", is_flag=True, help="Return machine-friendly JSON output.")
493
+ @click.option("--detail", "detail_run_id", metavar="RUN_ID", help="Show cached transcript details for a run id.")
494
+ @click.pass_context
495
+ def transcripts_group(ctx: click.Context, limit: int | None, as_json: bool, detail_run_id: str | None) -> None:
496
+ """Inspect and manage cached agent transcripts."""
497
+ if ctx.invoked_subcommand or ctx.resilient_parsing:
498
+ return
499
+
500
+ snapshot = load_history_snapshot(limit=limit, ctx=ctx)
501
+
502
+ view = None
503
+ ctx_obj = ctx.obj if isinstance(ctx.obj, dict) else {}
504
+ if ctx_obj:
505
+ view = ctx_obj.get("view")
506
+
507
+ emit_json = as_json or view == "json"
508
+
509
+ if detail_run_id:
510
+ if emit_json:
511
+ raise click.UsageError("--json output is only available for the history table view.")
512
+ _render_detail_view(ctx, snapshot, detail_run_id)
513
+ return
514
+
515
+ _render_history_overview(snapshot, emit_json)
516
+
517
+
518
+ @transcripts_group.command("detail")
519
+ @click.argument("run_id")
520
+ @click.pass_context
521
+ def transcripts_detail(ctx: click.Context, run_id: str) -> None:
522
+ """Show cached transcript details for a specific run id."""
523
+ snapshot = load_history_snapshot(ctx=ctx)
524
+ view = ctx.obj.get("view") if isinstance(ctx.obj, dict) else None
525
+ if view == "json":
526
+ raise click.UsageError("`aip transcripts detail` only supports the default view.")
527
+ _render_detail_view(ctx, snapshot, run_id)
528
+
529
+
530
+ def _collect_targets(
531
+ snapshot: HistorySnapshot,
532
+ run_ids: Sequence[str] | None,
533
+ delete_all: bool,
534
+ ) -> tuple[list[HistoryEntry], list[str]]:
535
+ """Return the HistoryEntry objects that should be deleted plus any missing ids."""
536
+ if delete_all:
537
+ runs = sorted(
538
+ snapshot.index.values(),
539
+ key=lambda entry: coerce_sortable_datetime(entry.started_at),
540
+ reverse=False,
541
+ )
542
+ return runs, []
543
+
544
+ ordered: list[str] = []
545
+ seen: set[str] = set()
546
+ for run_id in run_ids or ():
547
+ if run_id in seen:
548
+ continue
549
+ seen.add(run_id)
550
+ ordered.append(run_id)
551
+
552
+ found: list[HistoryEntry] = []
553
+ missing: list[str] = []
554
+ for run_id in ordered:
555
+ entry = snapshot.index.get(run_id)
556
+ if entry is None:
557
+ missing.append(run_id)
558
+ else:
559
+ found.append(entry)
560
+ return found, missing
561
+
562
+
563
+ def _build_deletion_preview_payload(entries: Iterable[HistoryEntry]) -> list[dict[str, Any]]:
564
+ """Build the payload list for deletion preview."""
565
+ payload = []
566
+ for entry in entries:
567
+ size_text = format_size(entry.size_bytes or 0) if entry.status == "cached" else "—"
568
+ status_text = "Missing file" if entry.warning else "Cached"
569
+ payload.append(
570
+ {
571
+ "run_id": entry.run_id,
572
+ "agent_name": entry.agent_name or "—",
573
+ "agent_id": entry.agent_id or "—",
574
+ "started_at": _format_timestamp(entry.started_at),
575
+ "size": size_text,
576
+ "status": status_text,
577
+ }
578
+ )
579
+ return payload
580
+
581
+
582
+ def _format_timestamp_display(timestamp_raw: Any) -> str:
583
+ """Format timestamp for display in deletion preview."""
584
+ if timestamp_raw in (None, "—"):
585
+ return "—"
586
+ try:
587
+ timestamp_value = str(timestamp_raw).strip()
588
+ except Exception:
589
+ timestamp_value = str(timestamp_raw)
590
+ return f"{timestamp_value} UTC" if timestamp_value else "—"
591
+
592
+
593
+ def _render_deletion_preview_rich(payload: list[dict[str, Any]], manifest_path: Path) -> None:
594
+ """Render deletion preview in rich format."""
595
+ console.print("Transcripts slated for deletion:")
596
+ console.print(f"[dim]Manifest: {_abbreviate_path(manifest_path)}[/]")
597
+ for row in payload:
598
+ timestamp_display = _format_timestamp_display(row["started_at"])
599
+ status_suffix = " (file missing)" if row["status"] == "Missing file" else ""
600
+ console.print(
601
+ f" • {row['run_id'] or '—'} {row['agent_name'] or '—'} {timestamp_display} {row['size']}{status_suffix}"
602
+ )
603
+
604
+
605
+ def _render_deletion_preview(
606
+ ctx: click.Context,
607
+ entries: Iterable[HistoryEntry],
608
+ manifest_path: Path,
609
+ *,
610
+ delete_all: bool,
611
+ reclaimed_hint: int,
612
+ ) -> None:
613
+ """Display a preview of the transcripts that are about to be purged."""
614
+ entry_list = list(entries)
615
+ view = get_ctx_value(ctx, "view", "rich")
616
+
617
+ if delete_all:
618
+ summary = {
619
+ "manifest_path": str(manifest_path),
620
+ "delete_all": True,
621
+ "entry_count": len(entry_list),
622
+ "estimated_reclaimed_bytes": reclaimed_hint,
623
+ }
624
+ if view == "json":
625
+ click.echo(json.dumps(summary, indent=2, default=str))
626
+ return
627
+
628
+ console.print("Transcripts slated for deletion:")
629
+ console.print(f"[dim]Manifest: {_abbreviate_path(manifest_path)}[/]")
630
+ console.print(
631
+ f"[{WARNING_STYLE}]This will remove ALL cached transcripts ({len(entry_list)} entries, "
632
+ f"{format_size(reclaimed_hint)} reclaimed).[/]"
633
+ )
634
+ console.print("[dim]Use `aip transcripts clear --id <run_id>` to delete specific runs.[/]")
635
+ return
636
+
637
+ payload = _build_deletion_preview_payload(entry_list)
638
+ preview = {"manifest_path": str(manifest_path), "transcripts": payload}
639
+ if view == "json":
640
+ click.echo(json.dumps(preview, indent=2, default=str))
641
+ else:
642
+ _render_deletion_preview_rich(payload, manifest_path)
643
+
644
+
645
+ def _confirm_deletion(
646
+ ctx: click.Context,
647
+ entries: list[HistoryEntry],
648
+ reclaimed_hint: int,
649
+ delete_all: bool,
650
+ skip_prompt: bool,
651
+ manifest_path: Path,
652
+ ) -> bool:
653
+ """Prompt the user for confirmation before deleting transcripts."""
654
+ if skip_prompt:
655
+ return True
656
+
657
+ size_text = format_size(reclaimed_hint)
658
+ if delete_all:
659
+ console.print(
660
+ f"[{WARNING_STYLE}]Deleting ALL cached transcripts ({len(entries)} entries, {size_text} reclaimed).[/]"
661
+ )
662
+ else:
663
+ console.print(
664
+ f"[{WARNING_STYLE}]You are about to delete {len(entries)} cached transcript(s) ({size_text} reclaimed).[/]"
665
+ )
666
+ _render_deletion_preview(
667
+ ctx,
668
+ entries,
669
+ manifest_path,
670
+ delete_all=delete_all,
671
+ reclaimed_hint=reclaimed_hint,
672
+ )
673
+ return click.confirm("Proceed?", default=False)
674
+
675
+
676
+ def _handle_clear_result(result: ClearResult) -> None:
677
+ """Summarise the result of a cache sweep."""
678
+ removed_count = len(result.removed_entries)
679
+ reclaimed_text = format_size(result.reclaimed_bytes)
680
+ console.print(f"[{SUCCESS_STYLE}]Deleted {removed_count} transcript(s), reclaimed {reclaimed_text}.[/]")
681
+ if result.not_found:
682
+ console.print(f"[{WARNING_STYLE}]The following run id(s) were not found: {', '.join(result.not_found)}[/]")
683
+ for warning in result.warnings:
684
+ console.print(f"[{WARNING_STYLE}]{warning}[/]")
685
+ if result.cache_empty:
686
+ console.print(f"[{SUCCESS_STYLE}]Cache folder now clean. Future runs will repopulate history.[/]")
687
+
688
+
689
+ def _validate_clear_options(run_ids: tuple[str, ...], delete_all: bool) -> None:
690
+ """Ensure --all/--id input combinations are valid."""
691
+ if delete_all and run_ids:
692
+ raise click.UsageError("Use either --all or --id, not both.")
693
+ if not delete_all and not run_ids:
694
+ raise click.UsageError("Specify --all to delete everything or provide at least one --id.")
695
+
696
+
697
+ def _should_exit_for_targets(
698
+ *,
699
+ delete_all: bool,
700
+ targets: list[HistoryEntry],
701
+ missing: list[str],
702
+ ) -> bool:
703
+ """Return True when deletion should stop due to empty or invalid selections."""
704
+ if delete_all and not targets:
705
+ console.print(f"[{WARNING_STYLE}]Cache is already empty.[/]")
706
+ return True
707
+ if not delete_all and not targets:
708
+ console.print(f"[{WARNING_STYLE}]No matching transcript ids were found.[/]")
709
+ if missing:
710
+ console.print(f"[{WARNING_STYLE}]Unknown run ids: {', '.join(missing)}[/]")
711
+ return True
712
+ return False
713
+
714
+
715
+ @transcripts_group.command("clear")
716
+ @click.argument("run_ids_args", nargs=-1)
717
+ @click.option("--id", "run_ids", multiple=True, help="Run ID to delete (repeatable).")
718
+ @click.option("--all", "delete_all", is_flag=True, help="Delete all cached transcripts.")
719
+ @click.option("--yes", "assume_yes", is_flag=True, help="Skip confirmation prompt.")
720
+ @click.pass_context
721
+ def transcripts_clear(
722
+ ctx: click.Context,
723
+ run_ids_args: tuple[str, ...],
724
+ run_ids: tuple[str, ...],
725
+ delete_all: bool,
726
+ assume_yes: bool,
727
+ ) -> None:
728
+ """Delete cached transcript files by run id or sweep the entire cache."""
729
+ identifiers = tuple(list(run_ids) + list(run_ids_args))
730
+
731
+ _validate_clear_options(identifiers, delete_all)
732
+ snapshot = load_history_snapshot(ctx=ctx)
733
+
734
+ targets, missing = _collect_targets(snapshot, identifiers, delete_all)
735
+ if _should_exit_for_targets(delete_all=delete_all, targets=targets, missing=missing):
736
+ return
737
+
738
+ total_estimated_bytes = sum(entry.size_bytes or 0 for entry in targets)
739
+
740
+ if missing:
741
+ console.print(f"[{WARNING_STYLE}]Unknown run ids: {', '.join(missing)}[/]")
742
+
743
+ if not _confirm_deletion(
744
+ ctx,
745
+ targets,
746
+ total_estimated_bytes,
747
+ delete_all,
748
+ assume_yes,
749
+ snapshot.manifest_path,
750
+ ):
751
+ console.print("[dim]Aborted. Cache unchanged.[/]")
752
+ return
753
+
754
+ result = clear_cached_runs(None if delete_all else list(identifiers))
755
+ _handle_clear_result(result)