cctally 1.72.0 → 1.74.0
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.
- package/CHANGELOG.md +22 -0
- package/bin/_cctally_cache.py +271 -55
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard.py +35 -1
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +812 -74
- package/bin/_cctally_db.py +104 -1
- package/bin/_cctally_doctor.py +86 -4
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- package/bin/_cctally_tui.py +17 -14
- package/bin/_lib_aggregators.py +48 -34
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +500 -28
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +269 -7
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +106 -0
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +31 -2
- package/dashboard/static/assets/index-DeQjEMO6.js +80 -0
- package/dashboard/static/assets/index-ZiPO8veo.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
- package/dashboard/static/assets/index-D2nwo_ln.css +0 -1
|
@@ -48,12 +48,43 @@ def cmd_transcript(args) -> int:
|
|
|
48
48
|
|
|
49
49
|
# ---- export ----------------------------------------------------------------
|
|
50
50
|
|
|
51
|
+
_SPEED_ONLY_CODEX_MSG = "transcript: --speed applies only to Codex conversations"
|
|
52
|
+
_PENDING_EXPORT_MSG = (
|
|
53
|
+
"transcript: Codex conversation is not yet normalized "
|
|
54
|
+
"(migration 025 runs on the next cache open) — retry shortly")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _emit_export(md: str, output) -> None:
|
|
58
|
+
"""Byte-exact emission shared by the bare and qualified export paths: the exact
|
|
59
|
+
UTF-8 bytes to ``--output`` or to stdout (no ``print``, no added trailing
|
|
60
|
+
newline — the render already ends in exactly one), so the CLI byte-matches
|
|
61
|
+
``GET /api/conversation/<id>/export``."""
|
|
62
|
+
data = md.encode("utf-8")
|
|
63
|
+
if output:
|
|
64
|
+
pathlib.Path(output).write_bytes(data)
|
|
65
|
+
else:
|
|
66
|
+
sys.stdout.buffer.write(data)
|
|
67
|
+
sys.stdout.buffer.flush()
|
|
68
|
+
|
|
69
|
+
|
|
51
70
|
def _cmd_transcript_export(args) -> int:
|
|
52
71
|
c = _cctally()
|
|
53
72
|
session_id = args.session_id
|
|
54
73
|
scope = getattr(args, "scope", "all")
|
|
55
74
|
raw = bool(getattr(args, "raw", False))
|
|
56
75
|
output = getattr(args, "output", None)
|
|
76
|
+
speed_arg = getattr(args, "speed", None) # None sentinel = flag omitted
|
|
77
|
+
|
|
78
|
+
if session_id.startswith("v1."):
|
|
79
|
+
return _cmd_transcript_export_qualified(
|
|
80
|
+
c, session_id, scope, raw, output, speed_arg)
|
|
81
|
+
|
|
82
|
+
# Legacy bare Claude path — byte-untouched. --speed is Codex pricing behavior,
|
|
83
|
+
# so an explicit value on any non-Codex ref is a usage error (resolved-source
|
|
84
|
+
# rule, §4.1) — never a silent no-op.
|
|
85
|
+
if speed_arg is not None:
|
|
86
|
+
eprint(_SPEED_ONLY_CODEX_MSG)
|
|
87
|
+
return 2
|
|
57
88
|
|
|
58
89
|
conn = c.open_cache_db()
|
|
59
90
|
try:
|
|
@@ -69,19 +100,68 @@ def _cmd_transcript_export(args) -> int:
|
|
|
69
100
|
finally:
|
|
70
101
|
conn.close()
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
_emit_export(md, output)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_transcript_export_qualified(
|
|
108
|
+
c, session_id, scope, raw, output, speed_arg) -> int:
|
|
109
|
+
"""Qualified (``v1.``) export via the neutral dispatch layer (§4.1). Anonymized
|
|
110
|
+
by default with the QUALIFIED provider-aware plan (§3.6); ``--raw`` escapes.
|
|
111
|
+
Byte-matches ``GET /api/conversation/<v1key>/export`` in both modes."""
|
|
112
|
+
disp = c._load_sibling("_lib_conversation_dispatch")
|
|
113
|
+
cref = disp.resolve_conversation_ref(session_id)
|
|
114
|
+
# Resolved-source --speed rejection (§4.1): explicit --speed (any value,
|
|
115
|
+
# including auto) is a usage error unless the ref resolves to source == codex.
|
|
116
|
+
if speed_arg is not None and (cref is None or cref.source != "codex"):
|
|
117
|
+
eprint(_SPEED_ONLY_CODEX_MSG)
|
|
118
|
+
return 2
|
|
119
|
+
speed = c._resolve_codex_speed(speed_arg or "auto")
|
|
120
|
+
|
|
121
|
+
conn = c.open_cache_db()
|
|
122
|
+
try:
|
|
123
|
+
env = disp.neutral_export(
|
|
124
|
+
conn, session_id, scope=scope, effective_speed=speed)
|
|
125
|
+
status = env.get("status")
|
|
126
|
+
if status == "normalization_pending":
|
|
127
|
+
eprint(_PENDING_EXPORT_MSG)
|
|
128
|
+
return 1
|
|
129
|
+
if status == "validation_error":
|
|
130
|
+
eprint(f"transcript: scope {scope!r} is not supported for a Codex "
|
|
131
|
+
f"conversation (only the whole-conversation export)")
|
|
132
|
+
return 2
|
|
133
|
+
if status != "ok":
|
|
134
|
+
eprint(f"transcript: no conversation found for key {session_id!r}")
|
|
135
|
+
return 1
|
|
136
|
+
md = env["markdown"]
|
|
137
|
+
if not raw:
|
|
138
|
+
cq = c._load_sibling("_lib_conversation_query")
|
|
139
|
+
anon = c._load_sibling("_lib_conversation_anon")
|
|
140
|
+
plan = cq.build_anon_plan_for_sources(
|
|
141
|
+
conn, home_dir=os.path.expanduser("~"),
|
|
142
|
+
sources={cref.source})
|
|
143
|
+
md = anon.scrub_text(md, plan)
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
_emit_export(md, output)
|
|
79
148
|
return 0
|
|
80
149
|
|
|
81
150
|
|
|
82
151
|
# ---- search ----------------------------------------------------------------
|
|
83
152
|
|
|
84
153
|
def _cmd_transcript_search(args) -> int:
|
|
154
|
+
if getattr(args, "source", "claude") == "codex":
|
|
155
|
+
return _cmd_transcript_search_codex(args)
|
|
156
|
+
# Legacy Claude path (byte-frozen). --cursor is Codex-only pagination — using
|
|
157
|
+
# it with --source claude is a usage error, never a silent no-op (§4.3).
|
|
158
|
+
if getattr(args, "cursor", None) is not None:
|
|
159
|
+
eprint("transcript: --cursor requires --source codex")
|
|
160
|
+
return 2
|
|
161
|
+
return _cmd_transcript_search_claude(args)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _cmd_transcript_search_claude(args) -> int:
|
|
85
165
|
c = _cctally()
|
|
86
166
|
query = args.query
|
|
87
167
|
kind = getattr(args, "kind", "all")
|
|
@@ -131,6 +211,142 @@ def _cmd_transcript_search(args) -> int:
|
|
|
131
211
|
return 0
|
|
132
212
|
|
|
133
213
|
|
|
214
|
+
# ---- search (Codex) --------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
_CODEX_SEARCH_PENDING_MSG = (
|
|
217
|
+
"transcript: Codex conversations are not yet normalized "
|
|
218
|
+
"(migration 025 runs on the next cache open); no results yet")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _cmd_transcript_search_codex(args) -> int:
|
|
222
|
+
"""``transcript search --source codex`` (§4.3). The Codex search kernel has no
|
|
223
|
+
filter axes and paginates by opaque cursor, so ``--offset`` and the Claude-only
|
|
224
|
+
filter flags are usage errors, and ``--cursor`` carries the external cursor."""
|
|
225
|
+
c = _cctally()
|
|
226
|
+
query = args.query
|
|
227
|
+
kind = getattr(args, "kind", "all")
|
|
228
|
+
limit = getattr(args, "limit", 50)
|
|
229
|
+
cursor = getattr(args, "cursor", None)
|
|
230
|
+
as_json = bool(getattr(args, "json", False))
|
|
231
|
+
|
|
232
|
+
# Pagination + filter axes the Codex kernel does not have → exit 2 (silently
|
|
233
|
+
# ignoring a filter would fabricate results).
|
|
234
|
+
if getattr(args, "offset", 0):
|
|
235
|
+
eprint("transcript: --offset is not supported with --source codex "
|
|
236
|
+
"(use --cursor)")
|
|
237
|
+
return 2
|
|
238
|
+
rejected = []
|
|
239
|
+
if getattr(args, "project", None):
|
|
240
|
+
rejected.append("--project")
|
|
241
|
+
if getattr(args, "model", None):
|
|
242
|
+
rejected.append("--model")
|
|
243
|
+
if getattr(args, "date_from", None):
|
|
244
|
+
rejected.append("--date-from")
|
|
245
|
+
if getattr(args, "date_to", None):
|
|
246
|
+
rejected.append("--date-to")
|
|
247
|
+
if getattr(args, "cost_min", None) is not None:
|
|
248
|
+
rejected.append("--cost-min")
|
|
249
|
+
if getattr(args, "cost_max", None) is not None:
|
|
250
|
+
rejected.append("--cost-max")
|
|
251
|
+
if getattr(args, "rebuild_min", None) is not None:
|
|
252
|
+
rejected.append("--rebuild-min")
|
|
253
|
+
if rejected:
|
|
254
|
+
eprint(f"transcript: {', '.join(rejected)} not supported with "
|
|
255
|
+
f"--source codex")
|
|
256
|
+
return 2
|
|
257
|
+
|
|
258
|
+
disp = c._load_sibling("_lib_conversation_dispatch")
|
|
259
|
+
# Validate the external cursor up front → exit 2 on a bad token.
|
|
260
|
+
if cursor is not None:
|
|
261
|
+
try:
|
|
262
|
+
disp.decode_search_cursor(cursor)
|
|
263
|
+
except disp.InvalidSearchCursor:
|
|
264
|
+
eprint("transcript: invalid --cursor")
|
|
265
|
+
return 2
|
|
266
|
+
|
|
267
|
+
conn = c.open_cache_db()
|
|
268
|
+
try:
|
|
269
|
+
result = disp.neutral_search(
|
|
270
|
+
conn, query, source="codex", kind=kind,
|
|
271
|
+
effective_speed=c._resolve_codex_speed("auto"),
|
|
272
|
+
limit=limit, cursor=cursor)
|
|
273
|
+
finally:
|
|
274
|
+
conn.close()
|
|
275
|
+
|
|
276
|
+
# normalization_pending: an empty, exit-0 answer with one stderr note — search
|
|
277
|
+
# is navigation, and "nothing yet" is truthful (§4.3).
|
|
278
|
+
if result.get("status") == "normalization_pending":
|
|
279
|
+
eprint(_CODEX_SEARCH_PENDING_MSG)
|
|
280
|
+
|
|
281
|
+
if as_json:
|
|
282
|
+
payload = c.stamp_schema_version(_codex_search_to_camel(result, query))
|
|
283
|
+
print(_json_dumps(payload))
|
|
284
|
+
return 0
|
|
285
|
+
_render_codex_search_table(args, result)
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _codex_search_to_camel(result: dict, query: str) -> dict:
|
|
290
|
+
"""The pinned, stamped-first camelCase Codex search envelope (§4.3):
|
|
291
|
+
``{schemaVersion, source, query, mode, total, hits[...], nextCursor}``. The
|
|
292
|
+
leading ``schemaVersion`` is inserted by ``stamp_schema_version``."""
|
|
293
|
+
hits = [
|
|
294
|
+
{
|
|
295
|
+
"conversationKey": h.get("conversation_key"),
|
|
296
|
+
"itemKey": h.get("item_key"),
|
|
297
|
+
"title": h.get("title"),
|
|
298
|
+
"snippet": h.get("snippet"),
|
|
299
|
+
"badges": h.get("badges", []),
|
|
300
|
+
"lastActivityUtc": h.get("last_activity_utc"),
|
|
301
|
+
"projectLabel": h.get("project_label"),
|
|
302
|
+
}
|
|
303
|
+
for h in result.get("hits", [])
|
|
304
|
+
]
|
|
305
|
+
return {
|
|
306
|
+
"source": "codex",
|
|
307
|
+
"query": result.get("query", query),
|
|
308
|
+
"mode": result.get("mode"),
|
|
309
|
+
"total": result.get("total", 0),
|
|
310
|
+
"hits": hits,
|
|
311
|
+
"nextCursor": (result.get("page") or {}).get("cursor"),
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _render_codex_search_table(args, result: dict) -> None:
|
|
316
|
+
"""Codex human table: Key / When / Project / Kinds / Snippet (§4.3). ``Key`` is
|
|
317
|
+
the full untruncated ``v1.`` conversation key (so search → export pipes);
|
|
318
|
+
``When`` renders ``last_activity_utc`` through the display-tz chokepoint;
|
|
319
|
+
``Project`` is ``—`` when null; ``Kinds`` are the hit badges."""
|
|
320
|
+
c = _cctally()
|
|
321
|
+
hits = result.get("hits", [])
|
|
322
|
+
if not hits:
|
|
323
|
+
print("No matching transcripts.")
|
|
324
|
+
return
|
|
325
|
+
tz = c.resolve_display_tz(args, c.load_config())
|
|
326
|
+
rows = []
|
|
327
|
+
for h in hits:
|
|
328
|
+
last = h.get("last_activity_utc")
|
|
329
|
+
when = c.format_display_dt(
|
|
330
|
+
last, tz, fmt="%Y-%m-%d %H:%M", suffix=True) if last else ""
|
|
331
|
+
kinds = ",".join(h.get("badges") or []) or "-"
|
|
332
|
+
rows.append([
|
|
333
|
+
h.get("conversation_key") or "", # full, untruncated key
|
|
334
|
+
when,
|
|
335
|
+
h.get("project_label") or "—",
|
|
336
|
+
kinds,
|
|
337
|
+
(h.get("snippet") or "").strip(),
|
|
338
|
+
])
|
|
339
|
+
table = _boxed_table(
|
|
340
|
+
["Key", "When", "Project", "Kinds", "Snippet"], rows,
|
|
341
|
+
["left", "left", "left", "left", "left"])
|
|
342
|
+
print(table)
|
|
343
|
+
total = result.get("total", len(hits))
|
|
344
|
+
print(f"\n{len(hits)} of {total} match(es)")
|
|
345
|
+
next_cursor = (result.get("page") or {}).get("cursor")
|
|
346
|
+
if next_cursor:
|
|
347
|
+
print(f"next: --cursor {next_cursor}")
|
|
348
|
+
|
|
349
|
+
|
|
134
350
|
def _json_dumps(payload) -> str:
|
|
135
351
|
import json
|
|
136
352
|
return json.dumps(payload, indent=2)
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -227,6 +227,7 @@ from _lib_fmt import stable_sum
|
|
|
227
227
|
# shared no-op singleton), so the _tui_build_snapshot seam wraps below cost
|
|
228
228
|
# nothing on the default path.
|
|
229
229
|
import _lib_perf as _perf
|
|
230
|
+
import _lib_log
|
|
230
231
|
|
|
231
232
|
import importlib.util as _ilu
|
|
232
233
|
|
|
@@ -261,7 +262,6 @@ from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, Bud
|
|
|
261
262
|
_ensure_sibling_loaded("_lib_dashboard_sources")
|
|
262
263
|
_ensure_sibling_loaded("_cctally_dashboard_sources")
|
|
263
264
|
from _cctally_dashboard_sources import (
|
|
264
|
-
CodexProjectionIncoherent,
|
|
265
265
|
DashboardReadContext,
|
|
266
266
|
build_codex_source_state,
|
|
267
267
|
refresh_codex_source_clock,
|
|
@@ -2520,8 +2520,18 @@ def _tui_build_source_bundle(
|
|
|
2520
2520
|
else unavailable_source_state("codex", warning)
|
|
2521
2521
|
)
|
|
2522
2522
|
else:
|
|
2523
|
-
|
|
2524
|
-
|
|
2523
|
+
# Projection coherence can recover when S2 writes its certificate
|
|
2524
|
+
# without changing physical accounting. A current, hero-scoped
|
|
2525
|
+
# incoherence generation is intentionally not retained through
|
|
2526
|
+
# that repair opportunity; all other fresh partial states remain
|
|
2527
|
+
# eligible for exact reuse.
|
|
2528
|
+
codex = (
|
|
2529
|
+
None if prior_codex is not None and any(
|
|
2530
|
+
warning.code == "codex_projection_incoherent"
|
|
2531
|
+
for warning in prior_codex.warnings
|
|
2532
|
+
) else reuse_coherent_source_state(
|
|
2533
|
+
prior_codex, data_version=codex_version,
|
|
2534
|
+
)
|
|
2525
2535
|
)
|
|
2526
2536
|
if codex is None:
|
|
2527
2537
|
try:
|
|
@@ -2539,18 +2549,11 @@ def _tui_build_source_bundle(
|
|
|
2539
2549
|
),
|
|
2540
2550
|
data_version=codex_version,
|
|
2541
2551
|
)
|
|
2542
|
-
except CodexProjectionIncoherent:
|
|
2543
|
-
warning = SourceDashboardWarning(
|
|
2544
|
-
"codex_projection_incoherent",
|
|
2545
|
-
"Codex quota projection is unavailable.",
|
|
2546
|
-
"quota",
|
|
2547
|
-
)
|
|
2548
|
-
codex = (
|
|
2549
|
-
degrade_source_state(prior_codex, warning)
|
|
2550
|
-
if prior_codex is not None
|
|
2551
|
-
else unavailable_source_state("codex", warning)
|
|
2552
|
-
)
|
|
2553
2552
|
except Exception:
|
|
2553
|
+
_lib_log.get_logger("dashboard").error(
|
|
2554
|
+
"codex_read_model source build failed",
|
|
2555
|
+
exc_info=True,
|
|
2556
|
+
)
|
|
2554
2557
|
warning = SourceDashboardWarning(
|
|
2555
2558
|
"source_build_failed", "Source data could not be built.", "read_model",
|
|
2556
2559
|
)
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -42,7 +42,7 @@ import os
|
|
|
42
42
|
import pathlib
|
|
43
43
|
import sys
|
|
44
44
|
from dataclasses import dataclass
|
|
45
|
-
from typing import Any, Callable
|
|
45
|
+
from typing import Any, Callable, Iterable
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def _cctally():
|
|
@@ -414,6 +414,17 @@ class CodexSessionUsage:
|
|
|
414
414
|
codex_root: str = ""
|
|
415
415
|
|
|
416
416
|
|
|
417
|
+
@dataclass(frozen=True)
|
|
418
|
+
class CodexSessionIdentity:
|
|
419
|
+
"""Caller-supplied identity for the pure Codex session accumulator."""
|
|
420
|
+
|
|
421
|
+
group_key: tuple[str, str]
|
|
422
|
+
session_id_path: str
|
|
423
|
+
session_file: str
|
|
424
|
+
directory: str
|
|
425
|
+
codex_root: str
|
|
426
|
+
|
|
427
|
+
|
|
417
428
|
@dataclass
|
|
418
429
|
class ClaudeSessionUsage:
|
|
419
430
|
"""Aggregated Claude usage for one sessionId (may span multiple JSONL files)."""
|
|
@@ -619,41 +630,19 @@ def _codex_home_root_from_prefix(root_prefix: str) -> str:
|
|
|
619
630
|
return s
|
|
620
631
|
|
|
621
632
|
|
|
622
|
-
def
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
recent first), matching upstream's default view.
|
|
628
|
-
|
|
629
|
-
Per-model breakdowns include `isFallback: bool` — true when the model is
|
|
630
|
-
absent from CODEX_MODEL_PRICING.
|
|
631
|
-
"""
|
|
633
|
+
def _aggregate_codex_sessions_keyed(
|
|
634
|
+
entries: Iterable[tuple[CodexEntry, CodexSessionIdentity]],
|
|
635
|
+
speed: str = "standard",
|
|
636
|
+
) -> list[CodexSessionUsage]:
|
|
637
|
+
"""Pure Codex session arithmetic over caller-owned file identities."""
|
|
632
638
|
by_session: dict[tuple[str, str], dict[str, Any]] = {}
|
|
633
|
-
for entry in entries:
|
|
634
|
-
|
|
635
|
-
# Disambiguate identical relative paths under DIFFERENT $CODEX_HOME
|
|
636
|
-
# roots (issue #108). _session_path_parts strips the matched root, so
|
|
637
|
-
# <rootA>/sessions/2026/04/17/rollout-x.jsonl and the same relative
|
|
638
|
-
# path under <rootB> both yield id_path "2026/04/17/rollout-x";
|
|
639
|
-
# grouping on id_path alone would silently merge two distinct sessions
|
|
640
|
-
# (summed tokens, one UUID). Key on (root_prefix, id_path), where
|
|
641
|
-
# root_prefix is source_path with the id_path tail removed. Single-root
|
|
642
|
-
# data — and the bare-relative fixture form — has a constant prefix, so
|
|
643
|
-
# the grouping, insertion order, and every golden stay byte-identical;
|
|
644
|
-
# only a genuine cross-root collision splits into separate rows.
|
|
645
|
-
suffix = id_path + ".jsonl"
|
|
646
|
-
sp = entry.source_path
|
|
647
|
-
root_prefix = sp[: -len(suffix)] if sp.endswith(suffix) else sp
|
|
648
|
-
sess = by_session.setdefault((root_prefix, id_path), {
|
|
639
|
+
for entry, identity in entries:
|
|
640
|
+
sess = by_session.setdefault(identity.group_key, {
|
|
649
641
|
"session_id_uuid": entry.session_id,
|
|
650
|
-
"session_id_path":
|
|
651
|
-
"session_file":
|
|
652
|
-
"directory": directory,
|
|
653
|
-
|
|
654
|
-
# disambiguator. Derived from the same root_prefix that keys the
|
|
655
|
-
# group, so it's constant per group.
|
|
656
|
-
"codex_root": _codex_home_root_from_prefix(root_prefix),
|
|
642
|
+
"session_id_path": identity.session_id_path,
|
|
643
|
+
"session_file": identity.session_file,
|
|
644
|
+
"directory": identity.directory,
|
|
645
|
+
"codex_root": identity.codex_root,
|
|
657
646
|
"input": 0, "cached_input": 0, "output": 0, "reasoning": 0,
|
|
658
647
|
"cost": 0.0, "models": {}, "models_order": [],
|
|
659
648
|
"last": entry.timestamp,
|
|
@@ -724,6 +713,31 @@ def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard"
|
|
|
724
713
|
return result
|
|
725
714
|
|
|
726
715
|
|
|
716
|
+
def _aggregate_codex_sessions(entries: list[CodexEntry], speed: str = "standard") -> list[CodexSessionUsage]:
|
|
717
|
+
"""Group CLI Codex entries by their existing filesystem-derived identity.
|
|
718
|
+
|
|
719
|
+
The wrapper preserves the historic CLI path parsing and byte-stable output;
|
|
720
|
+
only the accumulation itself lives in the keyed pure seam above.
|
|
721
|
+
"""
|
|
722
|
+
keyed: list[tuple[CodexEntry, CodexSessionIdentity]] = []
|
|
723
|
+
for entry in entries:
|
|
724
|
+
id_path, file_name, directory = _session_path_parts(entry.source_path)
|
|
725
|
+
# Disambiguate equal relative paths under separate configured roots.
|
|
726
|
+
suffix = id_path + ".jsonl"
|
|
727
|
+
source_path = entry.source_path
|
|
728
|
+
root_prefix = (
|
|
729
|
+
source_path[: -len(suffix)] if source_path.endswith(suffix) else source_path
|
|
730
|
+
)
|
|
731
|
+
keyed.append((entry, CodexSessionIdentity(
|
|
732
|
+
group_key=(root_prefix, id_path),
|
|
733
|
+
session_id_path=id_path,
|
|
734
|
+
session_file=file_name,
|
|
735
|
+
directory=directory,
|
|
736
|
+
codex_root=_codex_home_root_from_prefix(root_prefix),
|
|
737
|
+
)))
|
|
738
|
+
return _aggregate_codex_sessions_keyed(keyed, speed=speed)
|
|
739
|
+
|
|
740
|
+
|
|
727
741
|
def _aggregate_claude_sessions(
|
|
728
742
|
entries: list["_JoinedClaudeEntry"],
|
|
729
743
|
mode: str = "auto",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""#294 S7 — deterministic Codex conversation Markdown renderer (§3.3).
|
|
2
|
+
|
|
3
|
+
The Codex sibling of ``_lib_conversation_export`` (Claude). Pure and I/O-free: the
|
|
4
|
+
whole input is the neutral detail envelope ``get_codex_conversation`` produces
|
|
5
|
+
(items + blocks + per-turn cost + tokens + threading), so the render is a pure
|
|
6
|
+
function of a fixed DB + effective_speed — no ambient width, version, or clock
|
|
7
|
+
inputs, so a fixture golden only stales on a real data/pricing change.
|
|
8
|
+
|
|
9
|
+
Provider-truthful throughout: native Codex token labels
|
|
10
|
+
(``input``/``output``/``cached_input``/``reasoning_output``, never Claude cache
|
|
11
|
+
vocabulary), per-turn cost with the unattributed bucket rendered as an explicit
|
|
12
|
+
line, and children listed as ``v1.`` conversation-key references — NEVER inlined
|
|
13
|
+
(a Codex child is its own conversation; inlining would double-export and break
|
|
14
|
+
cost-once).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _money(value: float | None) -> str:
|
|
20
|
+
"""Deterministic USD formatting (no locale / ambient inputs)."""
|
|
21
|
+
return f"${(value or 0.0):.4f}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _token_line(tokens: dict | None) -> str:
|
|
25
|
+
"""Provider-native token summary — Codex fields only."""
|
|
26
|
+
t = tokens or {}
|
|
27
|
+
return (f"input {int(t.get('input', 0) or 0)} · "
|
|
28
|
+
f"output {int(t.get('output', 0) or 0)} · "
|
|
29
|
+
f"cached_input {int(t.get('cached_input', 0) or 0)} · "
|
|
30
|
+
f"reasoning_output {int(t.get('reasoning_output', 0) or 0)}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _blockquote(text: str) -> str:
|
|
34
|
+
"""Render ``text`` as a Markdown blockquote (one ``> `` per line)."""
|
|
35
|
+
lines = (text or "").splitlines() or [""]
|
|
36
|
+
return "\n".join(f"> {ln}" if ln else ">" for ln in lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _fence(text: str) -> str:
|
|
40
|
+
return f"```\n{text}\n```" if text else "_(empty)_"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ITEM_HEADER = {
|
|
44
|
+
"user": "## 👤 User",
|
|
45
|
+
"assistant": "## 🤖 Assistant",
|
|
46
|
+
"event": "## 🗓 Event",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _render_block(b: dict) -> str:
|
|
51
|
+
bk = b.get("kind")
|
|
52
|
+
text = (b.get("text") or "").strip()
|
|
53
|
+
if bk == "reasoning":
|
|
54
|
+
return "> 💭 Reasoning\n>\n" + _blockquote(text) if text else ""
|
|
55
|
+
if bk == "tool_call":
|
|
56
|
+
detail = b.get("detail") if isinstance(b.get("detail"), dict) else {}
|
|
57
|
+
name = detail.get("name")
|
|
58
|
+
head = f"**🔧 Tool call: {name}**" if name else "**🔧 Tool call**"
|
|
59
|
+
chunks = [head]
|
|
60
|
+
if text:
|
|
61
|
+
chunks.append(_fence(text))
|
|
62
|
+
output = b.get("output")
|
|
63
|
+
if isinstance(output, dict):
|
|
64
|
+
otext = (output.get("text") or "").strip()
|
|
65
|
+
chunks.append("**Output**")
|
|
66
|
+
chunks.append(_fence(otext))
|
|
67
|
+
return "\n\n".join(c for c in chunks if c)
|
|
68
|
+
if bk == "tool_output":
|
|
69
|
+
return "**🔧 Tool output**\n\n" + _fence(text)
|
|
70
|
+
if bk == "event":
|
|
71
|
+
return f"> 🗓 {text}" if text else ""
|
|
72
|
+
# user / assistant prose block
|
|
73
|
+
return text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render_item(it: dict) -> str:
|
|
77
|
+
kind = it.get("kind")
|
|
78
|
+
header = _ITEM_HEADER.get(kind, f"## {kind}")
|
|
79
|
+
ts = it.get("timestamp_utc") or ""
|
|
80
|
+
if ts:
|
|
81
|
+
header += f" · {ts}"
|
|
82
|
+
model = it.get("model")
|
|
83
|
+
if kind == "assistant" and model:
|
|
84
|
+
header += f" · {model}"
|
|
85
|
+
chunks = [header]
|
|
86
|
+
for b in it.get("blocks", []):
|
|
87
|
+
rendered = _render_block(b)
|
|
88
|
+
if rendered:
|
|
89
|
+
chunks.append(rendered)
|
|
90
|
+
cost = it.get("cost_usd")
|
|
91
|
+
if cost is not None:
|
|
92
|
+
chunks.append(f"_Cost: {_money(cost)} · {_token_line(it.get('tokens'))}_")
|
|
93
|
+
return "\n\n".join(c for c in chunks if c)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_codex_conversation_markdown(detail: dict) -> str:
|
|
97
|
+
"""Render a whole Codex conversation (an ``ok`` detail envelope) to Markdown.
|
|
98
|
+
|
|
99
|
+
Deterministic for a fixed ``detail``. Children appear only as ``v1.`` reference
|
|
100
|
+
lines — never inlined. The pending / not_found envelopes are handled upstream
|
|
101
|
+
(this renderer only ever sees ``ok``)."""
|
|
102
|
+
title = (detail.get("title") or "").strip()
|
|
103
|
+
parts = [f"# {title}" if title else "# Codex conversation"]
|
|
104
|
+
parent = detail.get("parent")
|
|
105
|
+
if isinstance(parent, dict) and parent.get("conversation_key"):
|
|
106
|
+
parts.append(
|
|
107
|
+
f"_Parent: {(parent.get('title') or '').strip()} "
|
|
108
|
+
f"(`{parent['conversation_key']}`)_")
|
|
109
|
+
for it in detail.get("items", []):
|
|
110
|
+
parts.append(_render_item(it))
|
|
111
|
+
unattr = detail.get("unattributed_cost_usd")
|
|
112
|
+
if unattr:
|
|
113
|
+
parts.append(f"_Unattributed cost: {_money(unattr)}_")
|
|
114
|
+
parts.append(
|
|
115
|
+
f"_Total cost: {_money(detail.get('total_cost_usd'))} · "
|
|
116
|
+
f"{_token_line(detail.get('tokens'))}_")
|
|
117
|
+
children = detail.get("children") or []
|
|
118
|
+
if children:
|
|
119
|
+
lines = ["## Child conversations"]
|
|
120
|
+
for c in children:
|
|
121
|
+
lines.append(
|
|
122
|
+
f"- {(c.get('title') or '').strip()} (`{c.get('conversation_key')}`)")
|
|
123
|
+
parts.append("\n".join(lines))
|
|
124
|
+
return "\n\n".join(p for p in parts if p).rstrip() + "\n"
|