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.
@@ -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
- data = md.encode("utf-8")
73
- if output:
74
- pathlib.Path(output).write_bytes(data)
75
- else:
76
- # Byte-exact: raw bytes to stdout, no `print`, no added trailing newline.
77
- sys.stdout.buffer.write(data)
78
- sys.stdout.buffer.flush()
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)