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,536 @@
1
+ """Helpers for storing and exporting agent run transcripts.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import secrets
12
+ from collections.abc import Iterable, Iterator
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from glaip_sdk.utils.datetime_helpers import (
19
+ coerce_datetime as _coerce_datetime,
20
+ )
21
+
22
+ DEFAULT_CACHE_ROOT = Path(
23
+ os.getenv(
24
+ "AIP_TRANSCRIPT_CACHE_DIR",
25
+ Path.home() / ".config" / "glaip-sdk" / "transcripts",
26
+ )
27
+ )
28
+ MANIFEST_FILENAME = "manifest.jsonl"
29
+ JSONL_SUFFIX = ".jsonl"
30
+ UTC_OFFSET_SUFFIX = "+00:00"
31
+
32
+ _RUN_ID_PREFIX = "run_"
33
+ _RUN_ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class TranscriptPayload:
38
+ """Data bundle representing a captured agent run."""
39
+
40
+ events: list[dict[str, Any]]
41
+ default_output: str
42
+ final_output: str
43
+ agent_id: str | None
44
+ agent_name: str | None
45
+ model: str | None
46
+ server_run_id: str | None
47
+ started_at: float | None
48
+ finished_at: float | None
49
+ created_at: datetime
50
+ source: str
51
+ meta: dict[str, Any]
52
+ run_id: str
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class TranscriptStoreResult:
57
+ """Result of writing a transcript to the local cache."""
58
+
59
+ path: Path
60
+ manifest_entry: dict[str, Any]
61
+ pruned_entries: list[dict[str, Any]]
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class TranscriptCacheStats:
66
+ """Lightweight usage snapshot for the transcript cache."""
67
+
68
+ cache_dir: Path
69
+ entry_count: int
70
+ total_bytes: int
71
+
72
+
73
+ def generate_run_id(length: int = 6) -> str:
74
+ """Return a short, human-friendly run identifier."""
75
+ length = max(4, min(int(length or 0), 16)) or 6
76
+ return _RUN_ID_PREFIX + "".join(secrets.choice(_RUN_ID_ALPHABET) for _ in range(length))
77
+
78
+
79
+ def _timestamp_to_iso(value: Any) -> str | None:
80
+ """Convert supported timestamp-like values to an ISO8601 string with UTC designator."""
81
+ dt = _coerce_datetime(value)
82
+ if dt is None:
83
+ return None
84
+ if dt.year < 2000:
85
+ return None
86
+ return dt.isoformat().replace(UTC_OFFSET_SUFFIX, "Z")
87
+
88
+
89
+ def _compute_duration_seconds(start: Any, end: Any) -> int | None:
90
+ """Compute whole-second duration between two timestamp-like values."""
91
+ start_dt = _coerce_datetime(start)
92
+ end_dt = _coerce_datetime(end)
93
+ if start_dt is None or end_dt is None:
94
+ return None
95
+ delta = (end_dt - start_dt).total_seconds()
96
+ if delta < 0:
97
+ return None
98
+ return int(round(delta))
99
+
100
+
101
+ def _iter_candidate_paths(entry: dict[str, Any], directory: Path) -> Iterator[Path]:
102
+ """Yield plausible transcript paths for a manifest entry, deduplicated."""
103
+ seen: set[str] = set()
104
+
105
+ def _offer(path: Path) -> Iterator[Path]:
106
+ key = str(path)
107
+ if key not in seen:
108
+ seen.add(key)
109
+ yield path
110
+
111
+ for candidate in _filename_candidate_paths(entry, directory):
112
+ yield from _offer(candidate)
113
+ for candidate in _cache_path_candidate_paths(entry):
114
+ yield from _offer(candidate)
115
+ for candidate in _run_id_candidate_paths(entry, directory):
116
+ yield from _offer(candidate)
117
+
118
+
119
+ def _filename_candidate_paths(entry: dict[str, Any], directory: Path) -> tuple[Path, ...]:
120
+ """Return possible transcript paths derived from the manifest filename."""
121
+ filename = entry.get("filename")
122
+ if not filename:
123
+ return ()
124
+ candidate = Path(str(filename))
125
+ if not candidate.is_absolute():
126
+ candidate = directory / candidate
127
+ return (candidate,)
128
+
129
+
130
+ def _cache_path_candidate_paths(entry: dict[str, Any]) -> tuple[Path, ...]:
131
+ """Return legacy cache_path-derived transcript candidates."""
132
+ cache_path = entry.get("cache_path")
133
+ if not cache_path:
134
+ return ()
135
+ return (Path(str(cache_path)).expanduser(),)
136
+
137
+
138
+ def _run_id_candidate_paths(entry: dict[str, Any], directory: Path) -> tuple[Path, ...]:
139
+ """Return candidate transcript paths derived from the run id."""
140
+ run_id = entry.get("run_id")
141
+ if not run_id:
142
+ return ()
143
+ paths: list[Path] = []
144
+ for variant in _run_id_variants(str(run_id)):
145
+ name = variant if variant.endswith(JSONL_SUFFIX) else f"{variant}{JSONL_SUFFIX}"
146
+ paths.append(directory / name)
147
+ return tuple(paths)
148
+
149
+
150
+ def _run_id_variants(run_id: str) -> set[str]:
151
+ """Return plausible filename stems derived from a run id."""
152
+ variants = {run_id}
153
+ if run_id.startswith(_RUN_ID_PREFIX):
154
+ suffix = run_id[len(_RUN_ID_PREFIX) :]
155
+ if suffix:
156
+ variants.update({suffix, f"run-{suffix}"})
157
+ variants.add(f"run-{run_id}")
158
+ else:
159
+ variants.update({f"run-{run_id}", _RUN_ID_PREFIX + run_id})
160
+ return variants
161
+
162
+
163
+ def transcript_path_candidates(entry: dict[str, Any], cache_dir: Path | None = None) -> list[Path]:
164
+ """Return possible transcript file locations for a manifest entry."""
165
+ directory = ensure_cache_dir(cache_dir)
166
+ return list(_iter_candidate_paths(entry, directory))
167
+
168
+
169
+ def resolve_transcript_path(entry: dict[str, Any], cache_dir: Path | None = None) -> Path:
170
+ """Resolve the cached transcript path for a manifest entry or raise informative errors."""
171
+ candidates = transcript_path_candidates(entry, cache_dir)
172
+ if not candidates:
173
+ raise FileNotFoundError("Cached transcript path missing from manifest.")
174
+
175
+ for candidate in candidates:
176
+ if candidate.exists():
177
+ return candidate
178
+
179
+ raise FileNotFoundError(f"Cached transcript file not found: {candidates[0]}")
180
+
181
+
182
+ def _manifest_sort_key(entry: dict[str, Any]) -> datetime:
183
+ """Return a datetime for ordering manifest rows, defaulting to the distant past."""
184
+ for key in ("started_at", "created_at"):
185
+ dt = _coerce_datetime(entry.get(key))
186
+ if dt is not None:
187
+ return dt
188
+ return datetime.min.replace(tzinfo=timezone.utc)
189
+
190
+
191
+ def ensure_cache_dir(cache_dir: Path | None = None) -> Path:
192
+ """Ensure the cache directory exists and return it."""
193
+ directory = cache_dir or DEFAULT_CACHE_ROOT
194
+ try:
195
+ directory.mkdir(parents=True, exist_ok=True)
196
+ except PermissionError:
197
+ return _fallback_cache_dir()
198
+
199
+ if not os.access(directory, os.W_OK):
200
+ return _fallback_cache_dir()
201
+
202
+ return directory
203
+
204
+
205
+ def _fallback_cache_dir() -> Path:
206
+ """Return a writable fallback cache directory under the current working tree."""
207
+ fallback = Path.cwd() / ".glaip-transcripts"
208
+ fallback.mkdir(parents=True, exist_ok=True)
209
+ return fallback
210
+
211
+
212
+ def manifest_path(cache_dir: Path | None = None) -> Path:
213
+ """Return the manifest file path."""
214
+ return ensure_cache_dir(cache_dir) / MANIFEST_FILENAME
215
+
216
+
217
+ def _parse_iso(ts: str | None) -> datetime | None:
218
+ """Parse metadata timestamps that may use the legacy Z suffix."""
219
+ if not ts:
220
+ return None
221
+ try:
222
+ return datetime.fromisoformat(ts.replace("Z", UTC_OFFSET_SUFFIX))
223
+ except Exception:
224
+ return None
225
+
226
+
227
+ def _load_manifest_entries(cache_dir: Path | None = None) -> list[dict[str, Any]]:
228
+ """Read manifest entries from disk, returning an empty list when missing."""
229
+ path = manifest_path(cache_dir)
230
+ entries: list[dict[str, Any]] = []
231
+ if not path.exists():
232
+ return entries
233
+
234
+ with path.open("r", encoding="utf-8") as fh:
235
+ for line in fh:
236
+ line = line.strip()
237
+ if not line:
238
+ continue
239
+ try:
240
+ entry = json.loads(line)
241
+ entries.append(entry)
242
+ except json.JSONDecodeError:
243
+ continue
244
+ return entries
245
+
246
+
247
+ def _json_default(value: Any) -> Any:
248
+ """Ensure non-serialisable values degrade to readable strings."""
249
+ if isinstance(value, (str, int, float, bool)) or value is None:
250
+ return value
251
+ if isinstance(value, Path):
252
+ return str(value)
253
+ return repr(value)
254
+
255
+
256
+ def _write_manifest(entries: Iterable[dict[str, Any]], cache_dir: Path | None = None) -> None:
257
+ """Atomically write manifest entries back to disk."""
258
+ path = manifest_path(cache_dir)
259
+ tmp_path = path.with_name(f"{path.name}.tmp")
260
+ with tmp_path.open("w", encoding="utf-8") as fh:
261
+ for entry in entries:
262
+ fh.write(json.dumps(entry, ensure_ascii=False, default=_json_default))
263
+ fh.write("\n")
264
+ tmp_path.replace(path)
265
+
266
+
267
+ def load_manifest_entries(cache_dir: Path | None = None) -> list[dict[str, Any]]:
268
+ """Public wrapper around manifest loading for downstream tooling."""
269
+ return _load_manifest_entries(cache_dir)
270
+
271
+
272
+ def write_manifest(entries: Iterable[dict[str, Any]], cache_dir: Path | None = None) -> None:
273
+ """Persist manifest entries atomically."""
274
+ _write_manifest(entries, cache_dir)
275
+
276
+
277
+ def store_transcript(
278
+ payload: TranscriptPayload,
279
+ *,
280
+ cache_dir: Path | None = None,
281
+ ) -> TranscriptStoreResult:
282
+ """Persist a transcript to disk and update the manifest."""
283
+ directory = ensure_cache_dir(cache_dir)
284
+ filename = _normalise_run_filename(payload.run_id)
285
+ transcript_path = directory / filename
286
+
287
+ meta_line = _build_meta_line(payload)
288
+ transcript_path = _write_transcript_file(transcript_path, filename, meta_line, payload.events)
289
+ size_bytes = _safe_file_size(transcript_path)
290
+ manifest_entry = _build_manifest_entry(payload, transcript_path.name, size_bytes)
291
+ if transcript_path.parent != directory:
292
+ manifest_entry["cache_path"] = str(transcript_path)
293
+
294
+ existing_entries = _load_manifest_entries(directory)
295
+ existing_entries.append(manifest_entry)
296
+ _write_manifest(existing_entries, directory)
297
+
298
+ return TranscriptStoreResult(
299
+ path=transcript_path,
300
+ manifest_entry=manifest_entry,
301
+ pruned_entries=[],
302
+ )
303
+
304
+
305
+ def _normalise_run_filename(run_id: str) -> str:
306
+ """Ensure cached run filenames always end with .jsonl."""
307
+ run_basename = run_id.rstrip()
308
+ if run_basename.endswith(JSONL_SUFFIX):
309
+ run_basename = run_basename[: -len(JSONL_SUFFIX)]
310
+ return f"{run_basename}{JSONL_SUFFIX}"
311
+
312
+
313
+ def _build_meta_line(payload: TranscriptPayload) -> dict[str, Any]:
314
+ """Return the metadata header stored at the top of transcript files."""
315
+ return {
316
+ "type": "meta",
317
+ "run_id": payload.run_id,
318
+ "agent_id": payload.agent_id,
319
+ "agent_name": payload.agent_name,
320
+ "model": payload.model,
321
+ "created_at": payload.created_at.isoformat(),
322
+ "default_output": payload.default_output,
323
+ "final_output": payload.final_output,
324
+ "server_run_id": payload.server_run_id,
325
+ "started_at": payload.started_at,
326
+ "finished_at": payload.finished_at,
327
+ "meta": payload.meta,
328
+ "source": payload.source,
329
+ }
330
+
331
+
332
+ def _write_transcript_file(
333
+ path: Path,
334
+ filename: str,
335
+ meta_line: dict[str, Any],
336
+ events: list[dict[str, Any]],
337
+ ) -> Path:
338
+ """Persist the transcript JSONL file, falling back to cwd when necessary."""
339
+
340
+ def _write(target: Path) -> None:
341
+ with target.open("w", encoding="utf-8") as fh:
342
+ fh.write(json.dumps(meta_line, ensure_ascii=False, default=_json_default))
343
+ fh.write("\n")
344
+ for event in events:
345
+ fh.write(
346
+ json.dumps(
347
+ {"type": "event", "event": event},
348
+ ensure_ascii=False,
349
+ default=_json_default,
350
+ )
351
+ )
352
+ fh.write("\n")
353
+
354
+ try:
355
+ _write(path)
356
+ return path
357
+ except PermissionError:
358
+ fallback_dir = _fallback_cache_dir()
359
+ fallback_path = fallback_dir / filename
360
+ _write(fallback_path)
361
+ return fallback_path
362
+
363
+
364
+ def _safe_file_size(path: Path) -> int:
365
+ """Return the file size, tolerating missing paths."""
366
+ try:
367
+ return path.stat().st_size
368
+ except FileNotFoundError:
369
+ return 0
370
+
371
+
372
+ def _build_manifest_entry(payload: TranscriptPayload, filename: str, size_bytes: int) -> dict[str, Any]:
373
+ """Generate the manifest row corresponding to a stored transcript."""
374
+ entry: dict[str, Any] = {
375
+ "run_id": payload.run_id,
376
+ "agent_id": payload.agent_id,
377
+ "agent_name": payload.agent_name,
378
+ "started_at": _timestamp_to_iso(payload.started_at) or payload.created_at.isoformat(),
379
+ "finished_at": _timestamp_to_iso(payload.finished_at),
380
+ "duration_seconds": _compute_duration_seconds(payload.started_at, payload.finished_at),
381
+ "size_bytes": size_bytes,
382
+ "filename": filename,
383
+ "retained": True,
384
+ "model": payload.model,
385
+ }
386
+
387
+ api_url = payload.meta.get("api_url")
388
+ if api_url:
389
+ entry["api_url"] = api_url
390
+
391
+ if entry["duration_seconds"] is None:
392
+ entry["duration_seconds"] = _coerce_duration_hint(payload.meta.get("final_duration_seconds"))
393
+
394
+ if entry.get("finished_at") is None and entry.get("started_at") and entry.get("duration_seconds") is not None:
395
+ start_dt = _coerce_datetime(entry["started_at"])
396
+ if start_dt is not None:
397
+ finished_dt = start_dt + timedelta(seconds=int(entry["duration_seconds"]))
398
+ entry["finished_at"] = finished_dt.isoformat().replace(UTC_OFFSET_SUFFIX, "Z")
399
+
400
+ return entry
401
+
402
+
403
+ def _coerce_duration_hint(value: Any) -> int | None:
404
+ """Convert loose duration hints to whole seconds."""
405
+ try:
406
+ if value is None:
407
+ return None
408
+ return int(round(float(value)))
409
+ except Exception:
410
+ return None
411
+
412
+
413
+ def latest_manifest_entry(cache_dir: Path | None = None) -> dict[str, Any] | None:
414
+ """Return the most recent manifest entry, if any."""
415
+ entries = _load_manifest_entries(cache_dir)
416
+ if not entries:
417
+ return None
418
+ return max(entries, key=_manifest_sort_key)
419
+
420
+
421
+ def resolve_manifest_entry(
422
+ run_id: str,
423
+ cache_dir: Path | None = None,
424
+ ) -> dict[str, Any] | None:
425
+ """Find a manifest entry by run id."""
426
+ entries = _load_manifest_entries(cache_dir)
427
+ for entry in entries:
428
+ if entry.get("run_id") == run_id:
429
+ return entry
430
+ return None
431
+
432
+
433
+ def export_transcript(
434
+ *,
435
+ destination: Path,
436
+ run_id: str | None = None,
437
+ cache_dir: Path | None = None,
438
+ ) -> Path:
439
+ """Copy a cached transcript to the requested destination path."""
440
+ directory = ensure_cache_dir(cache_dir)
441
+ entry = resolve_manifest_entry(run_id, directory) if run_id else latest_manifest_entry(directory)
442
+ if entry is None:
443
+ raise FileNotFoundError("No cached transcripts available for export.")
444
+
445
+ try:
446
+ cache_file = resolve_transcript_path(entry, directory)
447
+ except FileNotFoundError as exc:
448
+ raise FileNotFoundError(str(exc)) from exc
449
+
450
+ destination.parent.mkdir(parents=True, exist_ok=True)
451
+
452
+ try:
453
+ lines = cache_file.read_text(encoding="utf-8").splitlines()
454
+ records = [json.loads(line) for line in lines if line.strip()]
455
+ except json.JSONDecodeError as exc:
456
+ raise FileNotFoundError(f"Cached transcript file is corrupted: {cache_file}") from exc
457
+
458
+ with destination.open("w", encoding="utf-8") as fh:
459
+ for idx, record in enumerate(records):
460
+ json.dump(record, fh, ensure_ascii=False, indent=2)
461
+ fh.write("\n")
462
+ if idx != len(records) - 1:
463
+ fh.write("\n")
464
+
465
+ return destination
466
+
467
+
468
+ def suggest_filename(entry: dict[str, Any] | None = None) -> str:
469
+ """Return a friendly filename suggestion for exporting a transcript."""
470
+ run_id = entry.get("run_id") if entry else None
471
+ if not run_id:
472
+ run_id = generate_run_id()
473
+
474
+ timestamp_source = None
475
+ if entry:
476
+ timestamp_source = entry.get("started_at") or entry.get("created_at")
477
+
478
+ if not timestamp_source:
479
+ timestamp_source = datetime.now(timezone.utc).isoformat()
480
+
481
+ timestamp = str(timestamp_source).replace(":", "").replace("-", "").replace("T", "_").split("+")[0]
482
+ safe_run_id = str(run_id).replace("/", "-").replace(" ", "-")
483
+ return f"aip-run-{timestamp}-{safe_run_id}{JSONL_SUFFIX}"
484
+
485
+
486
+ def build_payload(
487
+ *,
488
+ events: list[dict[str, Any]],
489
+ renderer_output: str,
490
+ final_output: str,
491
+ agent_id: str | None,
492
+ agent_name: str | None,
493
+ model: str | None,
494
+ server_run_id: str | None,
495
+ started_at: float | None,
496
+ finished_at: float | None,
497
+ meta: dict[str, Any],
498
+ source: str,
499
+ ) -> TranscriptPayload:
500
+ """Factory helper to prepare payload objects consistently."""
501
+ return TranscriptPayload(
502
+ events=events,
503
+ default_output=renderer_output,
504
+ final_output=final_output,
505
+ agent_id=agent_id,
506
+ agent_name=agent_name,
507
+ model=model,
508
+ server_run_id=server_run_id,
509
+ started_at=started_at,
510
+ finished_at=finished_at,
511
+ created_at=datetime.now(timezone.utc),
512
+ source=source,
513
+ meta=meta,
514
+ run_id=generate_run_id(),
515
+ )
516
+
517
+
518
+ def get_transcript_cache_stats(
519
+ cache_dir: Path | None = None,
520
+ ) -> TranscriptCacheStats:
521
+ """Return basic usage information about the transcript cache."""
522
+ directory = ensure_cache_dir(cache_dir)
523
+ entries = _load_manifest_entries(directory)
524
+
525
+ total_bytes = 0
526
+ for entry in entries:
527
+ try:
528
+ total_bytes += int(entry.get("size_bytes") or 0)
529
+ except Exception:
530
+ continue
531
+
532
+ return TranscriptCacheStats(
533
+ cache_dir=directory,
534
+ entry_count=len(entries),
535
+ total_bytes=total_bytes,
536
+ )