cctally 1.72.0 → 1.73.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 +11 -0
- package/bin/_cctally_cache.py +245 -50
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_doctor.py +59 -0
- package/bin/_cctally_parser.py +17 -4
- package/bin/_cctally_record.py +162 -33
- package/bin/_cctally_refresh.py +58 -36
- package/bin/_cctally_statusline.py +811 -82
- package/bin/_cctally_transcript.py +223 -7
- 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_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +4 -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)
|
|
@@ -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"
|