glaip-sdk 0.1.3__py3-none-any.whl → 0.6.10__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 (141) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -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.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +101 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -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 +846 -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 +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +228 -119
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +287 -29
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +133 -88
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/mcps/__init__.py +21 -0
  65. glaip_sdk/mcps/base.py +345 -0
  66. glaip_sdk/models/__init__.py +90 -0
  67. glaip_sdk/models/agent.py +47 -0
  68. glaip_sdk/models/agent_runs.py +116 -0
  69. glaip_sdk/models/common.py +42 -0
  70. glaip_sdk/models/mcp.py +33 -0
  71. glaip_sdk/models/tool.py +33 -0
  72. glaip_sdk/payload_schemas/__init__.py +1 -13
  73. glaip_sdk/registry/__init__.py +55 -0
  74. glaip_sdk/registry/agent.py +164 -0
  75. glaip_sdk/registry/base.py +139 -0
  76. glaip_sdk/registry/mcp.py +253 -0
  77. glaip_sdk/registry/tool.py +232 -0
  78. glaip_sdk/rich_components.py +58 -2
  79. glaip_sdk/runner/__init__.py +59 -0
  80. glaip_sdk/runner/base.py +84 -0
  81. glaip_sdk/runner/deps.py +115 -0
  82. glaip_sdk/runner/langgraph.py +706 -0
  83. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  84. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  85. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  86. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  87. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  88. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  89. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  90. glaip_sdk/tools/__init__.py +22 -0
  91. glaip_sdk/tools/base.py +435 -0
  92. glaip_sdk/utils/__init__.py +58 -12
  93. glaip_sdk/utils/a2a/__init__.py +34 -0
  94. glaip_sdk/utils/a2a/event_processor.py +188 -0
  95. glaip_sdk/utils/bundler.py +267 -0
  96. glaip_sdk/utils/client.py +111 -0
  97. glaip_sdk/utils/client_utils.py +39 -7
  98. glaip_sdk/utils/datetime_helpers.py +58 -0
  99. glaip_sdk/utils/discovery.py +78 -0
  100. glaip_sdk/utils/display.py +23 -15
  101. glaip_sdk/utils/export.py +143 -0
  102. glaip_sdk/utils/general.py +0 -33
  103. glaip_sdk/utils/import_export.py +12 -7
  104. glaip_sdk/utils/import_resolver.py +492 -0
  105. glaip_sdk/utils/instructions.py +101 -0
  106. glaip_sdk/utils/rendering/__init__.py +115 -1
  107. glaip_sdk/utils/rendering/formatting.py +5 -30
  108. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  109. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  110. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  111. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  112. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  113. glaip_sdk/utils/rendering/models.py +1 -0
  114. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  115. glaip_sdk/utils/rendering/renderer/base.py +217 -1476
  116. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  117. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  118. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  119. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  120. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  121. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  122. glaip_sdk/utils/rendering/state.py +204 -0
  123. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  124. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  125. glaip_sdk/utils/rendering/steps/format.py +176 -0
  126. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  127. glaip_sdk/utils/rendering/timing.py +36 -0
  128. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  129. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  130. glaip_sdk/utils/resource_refs.py +25 -13
  131. glaip_sdk/utils/runtime_config.py +425 -0
  132. glaip_sdk/utils/serialization.py +18 -0
  133. glaip_sdk/utils/sync.py +142 -0
  134. glaip_sdk/utils/tool_detection.py +33 -0
  135. glaip_sdk/utils/validation.py +16 -24
  136. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  137. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  138. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  139. glaip_sdk/models.py +0 -240
  140. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,815 @@
1
+ """Utilities for inspecting and cleaning cached agent run transcripts.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from glaip_sdk.cli.config import load_config
16
+ from glaip_sdk.cli.transcript.cache import ( # Reuse helpers even if marked private
17
+ MANIFEST_FILENAME,
18
+ _compute_duration_seconds, # type: ignore[attr-defined]
19
+ _timestamp_to_iso, # type: ignore[attr-defined]
20
+ ensure_cache_dir,
21
+ load_manifest_entries,
22
+ manifest_path,
23
+ transcript_path_candidates,
24
+ write_manifest,
25
+ )
26
+ from glaip_sdk.cli.utils import parse_json_line
27
+ from glaip_sdk.utils.datetime_helpers import coerce_datetime
28
+
29
+ DEFAULT_HISTORY_LIMIT = 10
30
+ MAX_HISTORY_LIMIT = 200
31
+ LEGACY_MANIFEST_KEYS: tuple[str, ...] = ("created_at", "source", "server_run_id")
32
+ UTC_MIN = datetime.min.replace(tzinfo=timezone.utc)
33
+
34
+
35
+ def coerce_sortable_datetime(value: datetime | None) -> datetime:
36
+ """Return a timezone-aware datetime for sorting, using UTC minimum as fallback."""
37
+ if value is None:
38
+ return UTC_MIN
39
+ if value.tzinfo is None:
40
+ return value.replace(tzinfo=timezone.utc)
41
+ return value
42
+
43
+
44
+ def _safe_resolve(path: Path) -> Path:
45
+ """Resolve a path while tolerating filesystem errors."""
46
+ try:
47
+ return path.resolve()
48
+ except OSError:
49
+ return path
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class HistoryEntry:
54
+ """Normalised entry describing a cached transcript run."""
55
+
56
+ run_id: str
57
+ agent_name: str | None
58
+ agent_id: str | None
59
+ api_url: str | None
60
+ started_at: datetime | None
61
+ started_at_iso: str | None
62
+ finished_at: datetime | None
63
+ finished_at_iso: str | None
64
+ duration_seconds: int | None
65
+ size_bytes: int | None
66
+ filename: str | None
67
+ status: str
68
+ warning: str | None
69
+ migration_notice: str | None
70
+ is_current_session: bool
71
+ expected_path: Path | None
72
+ resolved_path: Path | None
73
+ manifest: dict[str, Any]
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class NormalizedEntry:
78
+ """Internal representation of a manifest row post normalisation."""
79
+
80
+ persisted: dict[str, Any]
81
+ history: HistoryEntry
82
+ changed: bool
83
+ warnings: list[str]
84
+ resolved_path: Path | None
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class TimelineInfo:
89
+ """Computed timestamp snapshot for a manifest entry."""
90
+
91
+ started_iso: str | None
92
+ finished_iso: str | None
93
+ started_source: Any
94
+ finished_source: Any
95
+ changed: bool
96
+
97
+
98
+ @dataclass(slots=True)
99
+ class HistorySnapshot:
100
+ """Collection of history entries and aggregate stats for presentation."""
101
+
102
+ manifest_path: Path
103
+ entries: list[HistoryEntry]
104
+ total_entries: int
105
+ cached_entries: int
106
+ total_size_bytes: int
107
+ index: dict[str, HistoryEntry]
108
+ warnings: list[str]
109
+ migration_summary: str | None
110
+ limit_requested: int
111
+ limit_applied: int
112
+ limit_clamped: bool
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class ClearResult:
117
+ """Result of clearing cached transcripts from disk and manifest."""
118
+
119
+ manifest_path: Path
120
+ removed_entries: list[HistoryEntry]
121
+ not_found: list[str]
122
+ warnings: list[str]
123
+ reclaimed_bytes: int
124
+ cache_empty: bool
125
+
126
+
127
+ def _dedupe_run_id(run_id: str, existing: set[str]) -> str:
128
+ """Ensure run identifiers remain unique when synthesising orphan entries."""
129
+ candidate = run_id or "run"
130
+ if candidate not in existing:
131
+ existing.add(candidate)
132
+ return candidate
133
+
134
+ base = candidate
135
+ counter = 2
136
+ while True:
137
+ candidate = f"{base}-{counter}"
138
+ if candidate not in existing:
139
+ existing.add(candidate)
140
+ return candidate
141
+ counter += 1
142
+
143
+
144
+ def _load_transcript_meta(path: Path) -> dict[str, Any] | None:
145
+ """Read the metadata header from a cached transcript file."""
146
+ try:
147
+ with path.open("r", encoding="utf-8") as fh:
148
+ line = fh.readline()
149
+ except FileNotFoundError:
150
+ return None
151
+ except OSError:
152
+ return None
153
+
154
+ payload = parse_json_line(line)
155
+ if payload and payload.get("type") == "meta":
156
+ return payload
157
+ return None
158
+
159
+
160
+ def _to_int(value: Any) -> int | None:
161
+ """Safely coerce numeric-like values to integers."""
162
+ try:
163
+ if value is None:
164
+ return None
165
+ return int(value)
166
+ except Exception:
167
+ return None
168
+
169
+
170
+ def _resolve_cached_paths(entry: dict[str, Any], directory: Path) -> tuple[Path | None, Path | None]:
171
+ """Return (resolved, expected) transcript paths for a manifest entry."""
172
+ candidates = transcript_path_candidates(entry, directory)
173
+ resolved = next((path for path in candidates if path.exists()), None)
174
+ expected = candidates[0] if candidates else None
175
+ return resolved, expected
176
+
177
+
178
+ def _ensure_filename_field(entry: dict[str, Any], resolved: Path | None, expected: Path | None) -> bool:
179
+ """Ensure manifest rows include a filename pointing at the cached transcript."""
180
+ filename = entry.get("filename")
181
+ target = filename
182
+ if resolved is not None:
183
+ target = resolved.name
184
+ elif expected is not None:
185
+ target = expected.name
186
+ if target and target != filename:
187
+ entry["filename"] = target
188
+ return True
189
+ return False
190
+
191
+
192
+ def _normalise_cache_path(entry: dict[str, Any], *, directory: Path, resolved: Path | None) -> bool:
193
+ """Ensure cache_path points to fallback locations outside the active cache directory."""
194
+ cache_path = entry.get("cache_path")
195
+ changed = False
196
+
197
+ target_path: Path | None = resolved
198
+ if target_path is None and cache_path:
199
+ target_path = Path(str(cache_path)).expanduser()
200
+
201
+ if target_path is None:
202
+ if cache_path is not None:
203
+ entry.pop("cache_path", None)
204
+ return True
205
+ return False
206
+
207
+ directory_root = _safe_resolve(directory)
208
+ target_root = _safe_resolve(target_path.parent)
209
+
210
+ should_keep_cache_path = True
211
+ try:
212
+ should_keep_cache_path = not directory_root.samefile(target_root)
213
+ except (OSError, AttributeError):
214
+ should_keep_cache_path = directory_root != target_root
215
+
216
+ if should_keep_cache_path:
217
+ cache_value = str(target_path)
218
+ if cache_value != cache_path:
219
+ entry["cache_path"] = cache_value
220
+ changed = True
221
+ elif cache_path is not None:
222
+ entry.pop("cache_path", None)
223
+ changed = True
224
+
225
+ if not entry.get("filename"):
226
+ entry["filename"] = target_path.name
227
+ changed = True
228
+
229
+ return changed
230
+
231
+
232
+ def _prune_legacy_fields(entry: dict[str, Any]) -> bool:
233
+ """Remove legacy manifest keys after migrations complete."""
234
+ removed = False
235
+ for key in LEGACY_MANIFEST_KEYS:
236
+ if entry.pop(key, None) is not None:
237
+ removed = True
238
+ return removed
239
+
240
+
241
+ def _select_meta_value(entry: dict[str, Any], meta: dict[str, Any] | None, key: str) -> Any:
242
+ """Return the preferred value for a manifest key from meta or entry data."""
243
+ if meta and meta.get(key) not in (None, ""):
244
+ return meta[key]
245
+ return entry.get(key)
246
+
247
+
248
+ def _normalise_timestamps(entry: dict[str, Any], meta: dict[str, Any] | None) -> TimelineInfo:
249
+ """Normalise started/finished timestamps and return a timeline snapshot."""
250
+ changed = False
251
+
252
+ started_source = _select_meta_value(entry, meta, "started_at") or entry.get("created_at")
253
+ started_iso = _timestamp_to_iso(started_source) if started_source is not None else None
254
+ if started_iso is None:
255
+ created_fallback = entry.get("created_at")
256
+ started_iso = _timestamp_to_iso(created_fallback)
257
+ if started_iso is None and isinstance(created_fallback, str):
258
+ started_iso = created_fallback
259
+ if started_iso and started_iso != entry.get("started_at"):
260
+ entry["started_at"] = started_iso
261
+ changed = True
262
+
263
+ finished_source = _select_meta_value(entry, meta, "finished_at")
264
+ finished_iso = _timestamp_to_iso(finished_source) if finished_source is not None else None
265
+ if finished_iso and finished_iso != entry.get("finished_at"):
266
+ entry["finished_at"] = finished_iso
267
+ changed = True
268
+
269
+ return TimelineInfo(
270
+ started_iso=entry.get("started_at"),
271
+ finished_iso=entry.get("finished_at"),
272
+ started_source=started_source if started_source is not None else entry.get("started_at"),
273
+ finished_source=finished_source if finished_source is not None else entry.get("finished_at"),
274
+ changed=changed,
275
+ )
276
+
277
+
278
+ def _ensure_duration(entry: dict[str, Any], timeline: TimelineInfo) -> bool:
279
+ """Populate duration_seconds when both timestamps are available."""
280
+ duration = entry.get("duration_seconds")
281
+ if duration not in (None, "", 0):
282
+ return False
283
+ computed = _compute_duration_seconds(timeline.started_source, timeline.finished_source)
284
+ if computed is None:
285
+ return False
286
+ entry["duration_seconds"] = computed
287
+ return True
288
+
289
+
290
+ def _merge_meta_fields(entry: dict[str, Any], meta: dict[str, Any] | None, keys: tuple[str, ...]) -> bool:
291
+ """Overlay metadata attributes from the transcript header."""
292
+ if not meta:
293
+ return False
294
+ changed = False
295
+ for key in keys:
296
+ value = meta.get(key)
297
+ if value is None:
298
+ continue
299
+ if entry.get(key) != value:
300
+ entry[key] = value
301
+ changed = True
302
+ return changed
303
+
304
+
305
+ def _resolve_size(entry: dict[str, Any], resolved: Path | None) -> tuple[int, bool]:
306
+ """Return (size_bytes, changed) for a manifest entry."""
307
+ try:
308
+ size_bytes = resolved.stat().st_size if resolved else _to_int(entry.get("size_bytes")) or 0
309
+ except OSError:
310
+ size_bytes = _to_int(entry.get("size_bytes")) or 0
311
+
312
+ current = _to_int(entry.get("size_bytes"))
313
+ return size_bytes, current != size_bytes
314
+
315
+
316
+ def _resolve_entry_warning(
317
+ entry: dict[str, Any], resolved: Path | None, expected: Path | None
318
+ ) -> tuple[str | None, list[str]]:
319
+ """Determine warning code and text for manifest anomalies."""
320
+ if resolved is not None:
321
+ return None, []
322
+ warnings = []
323
+ if expected is not None:
324
+ run_label = entry.get("run_id") or "?"
325
+ hint = entry.get("run_id") or "<RUN_ID>"
326
+ warnings.append(
327
+ f"Transcript file missing for run {run_label} (expected {expected}). "
328
+ f"Run `aip transcripts clear --id {hint}` or `aip transcripts clear --all` to remove stale entries."
329
+ )
330
+ return "transcript_missing", warnings
331
+
332
+
333
+ def _build_history_entry(
334
+ entry: dict[str, Any],
335
+ *,
336
+ resolved: Path | None,
337
+ expected: Path | None,
338
+ warning: str | None,
339
+ current_run_id: str | None,
340
+ timeline: TimelineInfo,
341
+ ) -> HistoryEntry:
342
+ """Create a HistoryEntry from normalised manifest data."""
343
+ started_iso = timeline.started_iso
344
+ finished_iso = timeline.finished_iso
345
+ return HistoryEntry(
346
+ run_id=str(entry.get("run_id") or ""),
347
+ agent_name=entry.get("agent_name"),
348
+ agent_id=entry.get("agent_id"),
349
+ api_url=entry.get("api_url"),
350
+ started_at=coerce_datetime(started_iso),
351
+ started_at_iso=str(started_iso) if started_iso is not None else None,
352
+ finished_at=coerce_datetime(finished_iso),
353
+ finished_at_iso=str(finished_iso) if finished_iso is not None else None,
354
+ duration_seconds=_to_int(entry.get("duration_seconds")),
355
+ size_bytes=_to_int(entry.get("size_bytes")),
356
+ filename=entry.get("filename"),
357
+ status="cached" if resolved is not None else "missing",
358
+ warning=warning,
359
+ migration_notice=entry.get("migration_notice"),
360
+ is_current_session=bool(current_run_id and history_run_id_eq(entry, current_run_id)),
361
+ expected_path=expected,
362
+ resolved_path=resolved,
363
+ manifest=entry,
364
+ )
365
+
366
+
367
+ def _normalise_entry(
368
+ entry: dict[str, Any],
369
+ *,
370
+ directory: Path,
371
+ current_run_id: str | None,
372
+ ) -> NormalizedEntry:
373
+ """Normalise an existing manifest entry against on-disk metadata."""
374
+ persisted = dict(entry)
375
+ warnings: list[str] = []
376
+ changed = False
377
+
378
+ resolved_path, expected_path = _resolve_cached_paths(persisted, directory)
379
+ if _ensure_filename_field(persisted, resolved_path, expected_path):
380
+ changed = True
381
+ if _normalise_cache_path(persisted, directory=directory, resolved=resolved_path):
382
+ changed = True
383
+
384
+ meta = _load_transcript_meta(resolved_path) if resolved_path else None
385
+
386
+ timeline = _normalise_timestamps(persisted, meta)
387
+ if timeline.changed:
388
+ changed = True
389
+ if _ensure_duration(persisted, timeline):
390
+ changed = True
391
+ if _merge_meta_fields(persisted, meta, ("agent_id", "agent_name", "model")):
392
+ changed = True
393
+
394
+ size_bytes, size_changed = _resolve_size(persisted, resolved_path)
395
+ if size_changed:
396
+ persisted["size_bytes"] = size_bytes
397
+ changed = True
398
+
399
+ if "retained" in persisted:
400
+ retained = bool(persisted.get("retained", True))
401
+ if retained != persisted.get("retained"):
402
+ persisted["retained"] = retained
403
+ changed = True
404
+ else:
405
+ persisted["retained"] = True
406
+
407
+ warning, warning_messages = _resolve_entry_warning(persisted, resolved_path, expected_path)
408
+ warnings.extend(warning_messages)
409
+
410
+ if _prune_legacy_fields(persisted):
411
+ changed = True
412
+
413
+ history = _build_history_entry(
414
+ persisted,
415
+ resolved=resolved_path,
416
+ expected=expected_path,
417
+ warning=warning,
418
+ current_run_id=current_run_id,
419
+ timeline=timeline,
420
+ )
421
+
422
+ return NormalizedEntry(
423
+ persisted=persisted,
424
+ history=history,
425
+ changed=changed,
426
+ warnings=warnings,
427
+ resolved_path=resolved_path,
428
+ )
429
+
430
+
431
+ def history_run_id_eq(entry: dict[str, Any], target_run_id: str) -> bool:
432
+ """Return True when the manifest entry represents the target run id."""
433
+ run_id = entry.get("run_id")
434
+ return bool(run_id) and str(run_id) == str(target_run_id)
435
+
436
+
437
+ def _build_orphan_entry(
438
+ path: Path,
439
+ *,
440
+ existing_ids: set[str],
441
+ current_run_id: str | None,
442
+ ) -> NormalizedEntry:
443
+ """Create a synthetic manifest entry for an orphaned transcript file."""
444
+ meta = _load_transcript_meta(path)
445
+ run_id = None
446
+ if meta and meta.get("run_id"):
447
+ run_id = str(meta.get("run_id"))
448
+ if not run_id:
449
+ stem = path.stem
450
+ run_id = stem.replace("run-", "", 1) if stem.startswith("run-") else stem
451
+ run_id = _dedupe_run_id(run_id, existing_ids)
452
+
453
+ try:
454
+ size_bytes = path.stat().st_size
455
+ except OSError:
456
+ size_bytes = 0
457
+
458
+ persisted = {
459
+ "run_id": run_id,
460
+ "agent_id": meta.get("agent_id") if meta else None,
461
+ "agent_name": meta.get("agent_name") if meta else None,
462
+ "model": meta.get("model") if meta else None,
463
+ "started_at": meta.get("started_at"),
464
+ "finished_at": meta.get("finished_at"),
465
+ "duration_seconds": meta.get("duration_seconds"),
466
+ "size_bytes": size_bytes,
467
+ "filename": path.name,
468
+ "retained": True,
469
+ "migration_notice": "orphaned_transcript",
470
+ }
471
+
472
+ timeline = _normalise_timestamps(persisted, meta)
473
+ _ensure_duration(persisted, timeline)
474
+ _merge_meta_fields(persisted, meta, ("agent_id", "agent_name", "model"))
475
+
476
+ history = _build_history_entry(
477
+ persisted,
478
+ resolved=path,
479
+ expected=path,
480
+ warning=None,
481
+ current_run_id=current_run_id,
482
+ timeline=timeline,
483
+ )
484
+
485
+ return NormalizedEntry(
486
+ persisted=persisted,
487
+ history=history,
488
+ changed=True,
489
+ warnings=[],
490
+ resolved_path=path,
491
+ )
492
+
493
+
494
+ def _format_migration_summary(counters: dict[str, int]) -> str | None:
495
+ """Summarise any cache migrations performed during snapshot normalisation."""
496
+ parts: list[str] = []
497
+ legacy = counters.get("legacy", 0)
498
+ if legacy:
499
+ parts.append(f"Migrated {legacy} legacy entries")
500
+ orphans = counters.get("orphans", 0)
501
+ if orphans:
502
+ parts.append(f"{orphans} orphan files added from disk")
503
+ missing = counters.get("missing", 0)
504
+ if missing:
505
+ parts.append(f"{missing} stale rows flagged as missing")
506
+ if not parts:
507
+ return None
508
+ return "; ".join(parts) + "."
509
+
510
+
511
+ def _process_manifest_entries(
512
+ raw_entries: list[dict[str, Any]],
513
+ *,
514
+ directory: Path,
515
+ current_run_id: str | None,
516
+ ) -> tuple[
517
+ list[NormalizedEntry],
518
+ list[dict[str, Any]],
519
+ list[str],
520
+ dict[str, int],
521
+ bool,
522
+ int,
523
+ set[str],
524
+ ]:
525
+ """Normalise persisted manifest entries and collect aggregate stats."""
526
+ normalized_entries: list[NormalizedEntry] = []
527
+ persisted_entries: list[dict[str, Any]] = []
528
+ warnings: list[str] = []
529
+ counters = {"legacy": 0, "missing": 0, "cached": 0}
530
+ changed = False
531
+ total_bytes = 0
532
+ existing_ids: set[str] = set()
533
+
534
+ for entry in raw_entries:
535
+ normalized = _normalise_entry(entry, directory=directory, current_run_id=current_run_id)
536
+ normalized_entries.append(normalized)
537
+ persisted_entries.append(normalized.persisted)
538
+ warnings.extend(normalized.warnings)
539
+ if normalized.changed:
540
+ counters["legacy"] += 1
541
+ changed = True
542
+ if normalized.history.warning == "transcript_missing":
543
+ counters["missing"] += 1
544
+ if normalized.history.status == "cached":
545
+ counters["cached"] += 1
546
+ if normalized.history.size_bytes:
547
+ total_bytes += int(normalized.history.size_bytes or 0)
548
+ run_id = normalized.persisted.get("run_id")
549
+ if run_id:
550
+ existing_ids.add(str(run_id))
551
+
552
+ return normalized_entries, persisted_entries, warnings, counters, changed, total_bytes, existing_ids
553
+
554
+
555
+ def _append_orphan_entries(
556
+ directory: Path,
557
+ *,
558
+ current_run_id: str | None,
559
+ existing_ids: set[str],
560
+ normalized_entries: list[NormalizedEntry],
561
+ persisted_entries: list[dict[str, Any]],
562
+ counters: dict[str, int],
563
+ total_bytes: int,
564
+ ) -> tuple[bool, int]:
565
+ """Add on-disk transcripts that are missing from the manifest."""
566
+ resolved_paths = {entry.resolved_path.resolve() for entry in normalized_entries if entry.resolved_path}
567
+ try:
568
+ files_on_disk = {
569
+ path.resolve(): path
570
+ for path in directory.glob("*.jsonl")
571
+ if path.is_file() and path.name != MANIFEST_FILENAME
572
+ }
573
+ except OSError:
574
+ return False, total_bytes
575
+
576
+ changed = False
577
+ for resolved, actual in files_on_disk.items():
578
+ if resolved in resolved_paths:
579
+ continue
580
+ orphan_entry = _build_orphan_entry(
581
+ actual,
582
+ existing_ids=existing_ids,
583
+ current_run_id=current_run_id,
584
+ )
585
+ normalized_entries.append(orphan_entry)
586
+ persisted_entries.append(orphan_entry.persisted)
587
+ counters["orphans"] = counters.get("orphans", 0) + 1
588
+ if orphan_entry.history.status == "cached":
589
+ counters["cached"] = counters.get("cached", 0) + 1
590
+ if orphan_entry.history.size_bytes:
591
+ total_bytes += int(orphan_entry.history.size_bytes or 0)
592
+ changed = True
593
+
594
+ return changed, total_bytes
595
+
596
+
597
+ def _normalise_manifest(
598
+ *,
599
+ cache_dir: Path | None,
600
+ current_run_id: str | None,
601
+ include_orphans: bool,
602
+ ) -> tuple[list[NormalizedEntry], list[dict[str, Any]], list[str], dict[str, int], Path, Path, bool, int]:
603
+ """Return normalised entries plus bookkeeping metadata for a given cache directory."""
604
+ directory = ensure_cache_dir(cache_dir)
605
+ manifest = manifest_path(directory)
606
+ raw_entries = load_manifest_entries(directory)
607
+ (
608
+ normalized_entries,
609
+ persisted_entries,
610
+ warnings,
611
+ counters,
612
+ changed,
613
+ total_bytes,
614
+ existing_ids,
615
+ ) = _process_manifest_entries(
616
+ raw_entries,
617
+ directory=directory,
618
+ current_run_id=current_run_id,
619
+ )
620
+
621
+ counters.setdefault("orphans", 0)
622
+
623
+ if include_orphans:
624
+ orphan_changed, total_bytes = _append_orphan_entries(
625
+ directory,
626
+ current_run_id=current_run_id,
627
+ existing_ids=existing_ids,
628
+ normalized_entries=normalized_entries,
629
+ persisted_entries=persisted_entries,
630
+ counters=counters,
631
+ total_bytes=total_bytes,
632
+ )
633
+ changed = changed or orphan_changed
634
+
635
+ return (
636
+ normalized_entries,
637
+ persisted_entries,
638
+ warnings,
639
+ counters,
640
+ directory,
641
+ manifest,
642
+ changed,
643
+ total_bytes,
644
+ )
645
+
646
+
647
+ def _resolve_history_default_limit() -> int:
648
+ """Return the configured default limit from `aip config`, falling back to the built-in default."""
649
+ try:
650
+ config = load_config()
651
+ except Exception:
652
+ config = {}
653
+ config_limit = _to_int(config.get("history_default_limit"))
654
+ if config_limit is not None:
655
+ return max(0, config_limit)
656
+
657
+ return DEFAULT_HISTORY_LIMIT
658
+
659
+
660
+ def load_history_snapshot(
661
+ *,
662
+ limit: int | None = None,
663
+ ctx: Any | None = None,
664
+ cache_dir: Path | None = None,
665
+ ) -> HistorySnapshot:
666
+ """Load cached transcript history applying migrations as needed."""
667
+ ctx_obj = getattr(ctx, "obj", None)
668
+ current_run_id = None
669
+ if isinstance(ctx_obj, dict):
670
+ manifest = ctx_obj.get("_last_transcript_manifest")
671
+ if isinstance(manifest, dict):
672
+ run_id = manifest.get("run_id")
673
+ if run_id:
674
+ current_run_id = str(run_id)
675
+
676
+ (
677
+ normalized_entries,
678
+ persisted_entries,
679
+ warnings,
680
+ counters,
681
+ directory,
682
+ manifest_file,
683
+ changed,
684
+ total_bytes,
685
+ ) = _normalise_manifest(cache_dir=cache_dir, current_run_id=current_run_id, include_orphans=True)
686
+
687
+ if changed:
688
+ write_manifest(persisted_entries, directory)
689
+
690
+ if limit is None or limit == 0:
691
+ requested_limit = _resolve_history_default_limit()
692
+ else:
693
+ requested_limit = max(0, int(limit))
694
+
695
+ limit_applied = requested_limit
696
+ limit_clamped = False
697
+ if limit_applied > MAX_HISTORY_LIMIT:
698
+ limit_applied = MAX_HISTORY_LIMIT
699
+ limit_clamped = True
700
+
701
+ entries_sorted = sorted(
702
+ (entry.history for entry in normalized_entries),
703
+ key=lambda h: coerce_sortable_datetime(h.started_at),
704
+ reverse=True,
705
+ )
706
+
707
+ display_entries = entries_sorted[:limit_applied] if limit_applied else []
708
+ entries_index = {entry.history.run_id: entry.history for entry in normalized_entries if entry.history.run_id}
709
+
710
+ migration_summary = _format_migration_summary(counters)
711
+
712
+ return HistorySnapshot(
713
+ manifest_path=manifest_file,
714
+ entries=display_entries,
715
+ total_entries=len(entries_sorted),
716
+ cached_entries=counters.get("cached", 0),
717
+ total_size_bytes=total_bytes,
718
+ index=entries_index,
719
+ warnings=warnings,
720
+ migration_summary=migration_summary,
721
+ limit_requested=requested_limit,
722
+ limit_applied=limit_applied,
723
+ limit_clamped=limit_clamped,
724
+ )
725
+
726
+
727
+ def _normalise_run_ids(run_ids: Sequence[str] | None, entries_by_run: dict[str, NormalizedEntry]) -> list[str]:
728
+ """Return a deduplicated list of run ids requested for deletion."""
729
+ if run_ids is None:
730
+ return list(entries_by_run.keys())
731
+ seen: set[str] = set()
732
+ deduped: list[str] = []
733
+ for run_id in run_ids:
734
+ if run_id in seen:
735
+ continue
736
+ seen.add(run_id)
737
+ deduped.append(run_id)
738
+ return deduped
739
+
740
+
741
+ def _purge_entry(entry: NormalizedEntry) -> tuple[HistoryEntry, int, list[str]]:
742
+ """Remove a cached transcript from disk and return bookkeeping information."""
743
+ reclaimed_bytes = 0
744
+ warnings: list[str] = []
745
+ resolved = entry.resolved_path
746
+ if resolved and resolved.exists():
747
+ try:
748
+ reclaimed_bytes = resolved.stat().st_size
749
+ resolved.unlink()
750
+ except FileNotFoundError:
751
+ pass
752
+ except OSError as exc:
753
+ warnings.append(f"Failed to remove {resolved}: {exc}")
754
+ else:
755
+ warnings.append(
756
+ f"Transcript file already missing for run {entry.history.run_id}. Manifest entry will be removed."
757
+ )
758
+ return entry.history, reclaimed_bytes, warnings
759
+
760
+
761
+ def clear_cached_runs(
762
+ run_ids: Sequence[str] | None,
763
+ *,
764
+ cache_dir: Path | None = None,
765
+ ) -> ClearResult:
766
+ """Remove cached transcripts and update the manifest."""
767
+ (
768
+ normalized_entries,
769
+ persisted_entries,
770
+ warnings,
771
+ _counters,
772
+ directory,
773
+ manifest_file,
774
+ changed,
775
+ _total_bytes,
776
+ ) = _normalise_manifest(cache_dir=cache_dir, current_run_id=None, include_orphans=True)
777
+
778
+ # If normalisation changed entries, update manifest before processing deletions
779
+ if changed:
780
+ write_manifest(persisted_entries, directory)
781
+
782
+ entries_by_run = {entry.history.run_id: entry for entry in normalized_entries if entry.history.run_id}
783
+
784
+ target_run_ids = _normalise_run_ids(run_ids, entries_by_run)
785
+ missing = [run_id for run_id in target_run_ids if run_id not in entries_by_run]
786
+ removable = {run_id for run_id in target_run_ids if run_id in entries_by_run}
787
+
788
+ removed_entries: list[HistoryEntry] = []
789
+ reclaimed_bytes = 0
790
+ additional_warnings: list[str] = []
791
+ remaining_entries: list[dict[str, Any]] = []
792
+
793
+ for entry in normalized_entries:
794
+ run_id = entry.history.run_id
795
+ if run_id in removable:
796
+ history_entry, reclaimed, warnings_extra = _purge_entry(entry)
797
+ removed_entries.append(history_entry)
798
+ reclaimed_bytes += reclaimed
799
+ additional_warnings.extend(warnings_extra)
800
+ else:
801
+ remaining_entries.append(entry.persisted)
802
+
803
+ write_manifest(remaining_entries, directory)
804
+
805
+ combined_warnings = warnings + additional_warnings
806
+ cache_empty = len(remaining_entries) == 0
807
+
808
+ return ClearResult(
809
+ manifest_path=manifest_file,
810
+ removed_entries=removed_entries,
811
+ not_found=missing,
812
+ warnings=combined_warnings,
813
+ reclaimed_bytes=reclaimed_bytes,
814
+ cache_empty=cache_empty,
815
+ )