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