cctally 1.71.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 +20 -0
- package/bin/_cctally_cache.py +501 -49
- package/bin/_cctally_core.py +20 -40
- package/bin/_cctally_dashboard_conversation.py +731 -94
- package/bin/_cctally_db.py +286 -1
- 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.py +509 -0
- package/bin/_lib_codex_conversation_export.py +124 -0
- package/bin/_lib_codex_conversation_query.py +1422 -0
- package/bin/_lib_codex_conversation_watch.py +323 -0
- package/bin/_lib_conversation_dispatch.py +809 -0
- package/bin/_lib_conversation_query.py +88 -0
- package/bin/_lib_conversation_retention.py +344 -0
- package/bin/_lib_doctor.py +62 -0
- package/bin/_lib_statusline_candidates.py +734 -0
- package/bin/cctally +31 -2
- package/package.json +8 -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)
|