cctally 1.53.0 → 1.55.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.
@@ -287,6 +287,103 @@ def _subagent_key(source_path):
287
287
  from _lib_conversation import (
288
288
  _NESTED_AGENT_ID_RE, _NESTED_USAGE_RE, _nested_agent_stamp_from_text,
289
289
  )
290
+ # #217 S5 F2 — the true per-edit +N/-M stat, recomputed at outline-build time for
291
+ # non-truncated edits (the stamped `block["edit_stat"]` only exists on truncated
292
+ # inputs, where the bounded copy can't be recounted; Codex P1-7).
293
+ from _lib_conversation import _edit_stat_for
294
+
295
+ # #217 S5 F2 — the files-touched aggregation is DELIBERATELY scoped to the three
296
+ # named edit tools, NOT `_lib_conversation._FILE_TOUCH_TOOLS` (which also includes
297
+ # NotebookEdit — rare; out of scope, Codex P2-4). Lower-cased for case-insensitive
298
+ # block-name matching.
299
+ _EXPORT_EDIT_TOOLS = {"edit", "multiedit", "write"}
300
+
301
+
302
+ def _collect_files(items):
303
+ """Whole-session files-touched aggregation (#217 S5 F2, spec §2).
304
+
305
+ For every Edit / MultiEdit / Write ``tool_call`` block (NOT NotebookEdit —
306
+ the narrower set is deliberate, Codex P2-4) carrying a string ``file_path``,
307
+ emit one ``touch`` per call and a per-path running ``+N/-M`` sum, preserving
308
+ first-touch document order. The signed stat is the stamped ``edit_stat`` when
309
+ present (truncated inputs), else a recompute via ``_edit_stat_for``; when
310
+ NEITHER is available the touch is STILL listed with ``add``/``del`` = ``None``
311
+ and the file total sums only the known values (``None`` only when every touch
312
+ is unknown — Codex P1-7). Pure over the assembled ``items``.
313
+ """
314
+ order, agg = [], {}
315
+ for it in items:
316
+ anchor_uuid = it["anchor"]["uuid"]
317
+ for b in it.get("blocks", []):
318
+ if b.get("kind") not in ("tool_call", "tool_use"):
319
+ continue
320
+ nm = (b.get("name") or "").lower()
321
+ if nm not in _EXPORT_EDIT_TOOLS:
322
+ continue
323
+ inp = b.get("input")
324
+ path = inp.get("file_path") if isinstance(inp, dict) else None
325
+ if not isinstance(path, str) or not path:
326
+ continue
327
+ st = b.get("edit_stat")
328
+ if st is None:
329
+ st = _edit_stat_for(b.get("name"), inp)
330
+ add = st.get("add") if isinstance(st, dict) else None
331
+ dele = st.get("del") if isinstance(st, dict) else None
332
+ if path not in agg:
333
+ agg[path] = {"path": path, "add": None, "del": None, "touches": []}
334
+ order.append(path)
335
+ entry = agg[path]
336
+ entry["touches"].append({
337
+ "uuid": anchor_uuid,
338
+ "tool_use_id": b.get("tool_use_id") or b.get("id"),
339
+ "op": nm,
340
+ "add": add,
341
+ "del": dele,
342
+ })
343
+ if add is not None:
344
+ entry["add"] = (entry["add"] or 0) + add
345
+ if dele is not None:
346
+ entry["del"] = (entry["del"] or 0) + dele
347
+ return [agg[p] for p in order]
348
+
349
+
350
+ def _task_completion(items):
351
+ """Whole-session task-completion detection (#217 S5 F7, spec §3).
352
+
353
+ Scan the assembled ``items`` in document order, MAIN THREAD ONLY
354
+ (``subagent_key is None`` AND not ``is_sidechain`` — Codex P1-1, because
355
+ ``_fold_task_runs`` keeps task state PER subagent thread, so a global
356
+ last-snapshot could describe a subagent's checklist rather than the
357
+ session's). Take the LAST main-thread block carrying a folded
358
+ ``task_snapshot`` (the Task* family, stamped by ``_fold_task_runs``) or a
359
+ legacy ``TodoWrite`` (``name == "TodoWrite"``, ``input.todos``). Returns
360
+ ``{all_done, total, completed, anchor_uuid}`` or ``None`` when the
361
+ main thread has no tasks. Pure over the assembled ``items``.
362
+ """
363
+ final = None
364
+ final_uuid = None
365
+ for it in items:
366
+ if it.get("subagent_key") is not None or it.get("is_sidechain"):
367
+ continue
368
+ for b in it.get("blocks", []):
369
+ if b.get("kind") not in ("tool_call", "tool_use"):
370
+ continue
371
+ snap = b.get("task_snapshot")
372
+ if snap is None and b.get("name") == "TodoWrite":
373
+ inp = b.get("input")
374
+ snap = inp.get("todos") if isinstance(inp, dict) else None
375
+ if isinstance(snap, list):
376
+ final = snap
377
+ final_uuid = it["anchor"]["uuid"]
378
+ if not final:
379
+ return None
380
+ total = len(final)
381
+ completed = sum(1 for t in final
382
+ if isinstance(t, dict) and t.get("status") == "completed")
383
+ return {"all_done": total > 0 and completed == total,
384
+ "total": total,
385
+ "completed": completed,
386
+ "anchor_uuid": final_uuid}
290
387
 
291
388
 
292
389
  def _parse_nested_agent_result(text):
@@ -1126,6 +1223,13 @@ def _turn_usage_map(conn, turn_keys):
1126
1223
  return usage
1127
1224
 
1128
1225
 
1226
+ # #225: the two tool names that spawn a subagent. `Agent` = modern, `Task` =
1227
+ # older. Exact-match (does NOT match the TaskCreate/TaskUpdate/TaskList checklist
1228
+ # family). A spawn is identified by this name even when it carried no
1229
+ # subagent_type (the default general-purpose dispatch).
1230
+ _SPAWN_TOOL_NAMES = ("Agent", "Task")
1231
+
1232
+
1129
1233
  def _assemble_session(conn, session_id):
1130
1234
  """Shared assembly for get_conversation / get_conversation_outline (#177 S5).
1131
1235
 
@@ -1223,12 +1327,15 @@ def _assemble_session(conn, session_id):
1223
1327
  # tool_call/tool_result block shapes are unchanged — the only new output is
1224
1328
  # the top-level subagent_meta map (no undocumented block keys leak). Join is
1225
1329
  # spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
1226
- spawn_kind = {} # tool_use id -> subagent_type
1227
- spawn_desc = {} # tool_use id -> spawning Task description (#193)
1330
+ spawn_kind = {} # tool_use id -> subagent_type (kind), only when declared
1331
+ spawn_ids = {} # tool_use id -> None: ordered set of Agent/Task spawn ids
1332
+ # (#225, typed OR untyped). spawn_kind ⊆ spawn_ids. Dict
1333
+ # (NOT a set) so iteration stays document-ordered.
1334
+ spawn_desc = {} # tool_use id -> spawning Agent/Task description (#193/#225)
1228
1335
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
1229
1336
  nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
1230
1337
  # results with no structured agent_id, resolved after
1231
- # spawn_kind is complete (text still present pre-fold)
1338
+ # the spawn maps are complete (text still present pre-fold)
1232
1339
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
1233
1340
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
1234
1341
  web_search_link = {} # tool_use id -> web_search payload (#177 S4)
@@ -1239,15 +1346,23 @@ def _assemble_session(conn, session_id):
1239
1346
  k = b.get("kind")
1240
1347
  if k == "tool_use":
1241
1348
  st = b.pop("subagent_type", None)
1242
- if st and b.get("id") is not None:
1243
- spawn_kind[b["id"]] = st
1244
- # #193: harvest the spawning Task description from the
1245
- # already-stored bounded input. Guarded on subagent_type, so
1246
- # a Bash `description` (no subagent_type) is NEVER picked up.
1349
+ _id = b.get("id")
1350
+ # #225: a spawn is an Agent/Task tool_use. Identify it by tool
1351
+ # NAME (so an untyped general-purpose spawn no subagent_type —
1352
+ # still counts) OR by a recorded subagent_type. The name gate is
1353
+ # what keeps a Bash/Read `description` (and a coincidental
1354
+ # structured agent_id, #218 I-B.5) from ever being read as a
1355
+ # spawn.
1356
+ if _id is not None and (st or b.get("name") in _SPAWN_TOOL_NAMES):
1357
+ spawn_ids[_id] = None
1358
+ if st:
1359
+ spawn_kind[_id] = st # #166: declared kind
1360
+ # #193/#225: harvest the spawn description from the bounded
1361
+ # input for ANY spawn (typed or untyped); name-gated above.
1247
1362
  _inp = b.get("input")
1248
1363
  _d = _inp.get("description") if isinstance(_inp, dict) else None
1249
1364
  if isinstance(_d, str) and _d.strip():
1250
- spawn_desc[b["id"]] = _d
1365
+ spawn_desc[_id] = _d
1251
1366
  elif k == "tool_result":
1252
1367
  aid = b.pop("agent_id", None)
1253
1368
  meta = b.pop("subagent_meta", None)
@@ -1255,7 +1370,7 @@ def _assemble_session(conn, session_id):
1255
1370
  agent_link[b["tool_use_id"]] = (aid, meta or {})
1256
1371
  elif b.get("tool_use_id") is not None:
1257
1372
  # §4 1a candidate — a tool_result with no structured agentId.
1258
- # Resolved AFTER spawn_kind is complete (text still present,
1373
+ # Resolved AFTER the spawn maps are complete (text still present,
1259
1374
  # pre-Phase-2-fold). The block's `text` holds the result body.
1260
1375
  nested_candidates.append((b["tool_use_id"], b))
1261
1376
  ans = b.pop("ask_answers", None) # #177 S2
@@ -1276,30 +1391,33 @@ def _assemble_session(conn, session_id):
1276
1391
  tlist_ = b.pop("task_list", None)
1277
1392
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
1278
1393
  task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
1279
- # §4 symmetry (#218 I-B.5): the structured-agent_id populate above runs
1280
- # mid-loop, before spawn_kind is complete, so it cannot self-gate on spawn
1281
- # ownership. Re-validate here now that spawn_kind is final drop any
1282
- # agent_link entry whose owning tool_use is NOT a spawn, mirroring the nested
1283
- # fallback's `_tuid in spawn_kind` gate so BOTH link paths only ever carry
1284
- # spawn-owned results. Inert today (every agent_link consumer is itself
1285
- # spawn-gated); this protects a future, un-gated agent_link consumer.
1286
- agent_link = {t: v for t, v in agent_link.items() if t in spawn_kind}
1394
+ # §4 symmetry (#218 I-B.5 / #225): drop any agent_link entry whose owning
1395
+ # tool_use is NOT a spawn an ordinary tool result that coincidentally
1396
+ # carries a structured agent_id (e.g. a Read). Gated on spawn_ids (every
1397
+ # Agent/Task spawn, typed OR untyped) rather than the old spawn_kind, which
1398
+ # wrongly excluded untyped general-purpose spawns. A structured agent_id on a
1399
+ # real spawn is authoritative; the heuristic nested text-parse below stays
1400
+ # spawn-gated all the same.
1401
+ agent_link = {t: v for t, v in agent_link.items() if t in spawn_ids}
1287
1402
  # §4 1a — nested grandchild results: gate the text-parse on the owning
1288
- # tool_use being a spawn (in spawn_kind), so an ordinary tool result that
1403
+ # tool_use being a spawn (in spawn_ids), so an ordinary tool result that
1289
1404
  # merely mentions "agentId" never matches.
1290
1405
  for _tuid, _block in nested_candidates:
1291
- if _tuid in spawn_kind and _tuid not in agent_link:
1406
+ if _tuid in spawn_ids and _tuid not in agent_link:
1292
1407
  parsed = _parse_nested_agent_result(_block.get("text"))
1293
1408
  if parsed is not None:
1294
1409
  agent_link[_tuid] = parsed
1295
1410
  subagent_meta = {}
1296
- for _tuid, _kind in spawn_kind.items():
1411
+ for _tuid in spawn_ids: # document order (ordered dict)
1297
1412
  _link = agent_link.get(_tuid)
1298
1413
  if _link is None:
1299
1414
  continue # spawn with no (yet) result -> title-only
1300
1415
  _aid, _raw = _link
1301
- _entry = {"kind": _kind}
1302
- if spawn_desc.get(_tuid): # #193: spawning Task description
1416
+ # #225: kind defaults to "general-purpose" for an untyped Agent/Task
1417
+ # spawn (subagent_type omitted at dispatch ⟹ general-purpose; 0
1418
+ # counterexamples across 3085 real sidecars).
1419
+ _entry = {"kind": spawn_kind.get(_tuid, "general-purpose")}
1420
+ if spawn_desc.get(_tuid): # #193/#225: spawn description
1303
1421
  _entry["description"] = spawn_desc[_tuid]
1304
1422
  for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
1305
1423
  if _raw.get(_f) is not None:
@@ -1869,9 +1987,60 @@ def get_conversation_outline(conn, session_id):
1869
1987
  # matching the per-item / header cost display precision.
1870
1988
  "subagent_costs": {k: round(v, 6) for k, v in subagent_costs.items()},
1871
1989
  "stats": stats,
1990
+ # #217 S5 F2 — whole-session files-touched aggregation (always
1991
+ # present, possibly []; additive — existing consumers are byte-stable).
1992
+ "files": _collect_files(items),
1993
+ # #217 S5 F7 — main-thread task-completion (always present; None when
1994
+ # the main thread has no tasks; additive — byte-stable for consumers).
1995
+ "task_completion": _task_completion(items),
1872
1996
  "turns": turns}
1873
1997
 
1874
1998
 
1999
+ def get_conversation_prompts(conn, session_id):
2000
+ """``{session_id, prompts:[{uuid,text}]}`` — ordered main-thread human
2001
+ prompts with full text (#217 S7, F10 — the session-comparison spine).
2002
+
2003
+ Reuses the SAME ``_assemble_session`` pass as ``get_conversation_outline``
2004
+ (one grouping/dedup pipeline, never a parallel one) and the SAME main-thread
2005
+ predicate as the recipe/prompts export (``extract_prompt_entries`` →
2006
+ ``_human_prompts``), so the spine aligns 1:1 with the outline's human turns
2007
+ and can never drift from the export. Returns ``None`` for an unknown session
2008
+ (the handler's 404 sentinel) — the same contract ``get_conversation_outline``
2009
+ uses (``asm is None``).
2010
+ """
2011
+ asm = _assemble_session(conn, session_id)
2012
+ if asm is None:
2013
+ return None
2014
+ from _lib_conversation_export import extract_prompt_entries
2015
+ return {"session_id": session_id,
2016
+ "prompts": extract_prompt_entries(asm["items"])}
2017
+
2018
+
2019
+ def get_conversation_export(conn, session_id, scope):
2020
+ """Whole-session Markdown export for one scope (#217 S5, F1/F5).
2021
+
2022
+ Runs the SAME full ``_assemble_session`` the reader/outline use, then hands
2023
+ the assembled ``items`` (+ ``subagent_meta``) to the pure
2024
+ ``export_session_markdown`` serializer (`_lib_conversation_export.py`), so
2025
+ the renderer stays I/O-free and golden-testable. Returns the Markdown string,
2026
+ or ``None`` for an unknown session (the handler's 404 sentinel). ``scope`` is
2027
+ pre-validated by the handler (an unknown scope never reaches here)."""
2028
+ from _lib_conversation_export import export_session_markdown
2029
+ asm = _assemble_session(conn, session_id)
2030
+ if asm is None:
2031
+ return None
2032
+ items = asm["items"]
2033
+ subagent_meta = asm["subagent_meta"]
2034
+ logical = asm["logical"]
2035
+ # Title via the SAME fallback chain the reader header uses (#193):
2036
+ # ai-title → first human prompt → project label → session_id.
2037
+ _pl = _project_label(_latest(logical, 10))
2038
+ title = (_session_titles_map(conn, [session_id]).get(session_id)
2039
+ or _pl or session_id)
2040
+ return export_session_markdown(items, scope, subagent_meta=subagent_meta,
2041
+ title=title, session_id=session_id)
2042
+
2043
+
1875
2044
  _TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
1876
2045
 
1877
2046
 
@@ -2272,8 +2441,8 @@ def _like_escape(q):
2272
2441
  one) mis-escapes any appended ``%`` and the LIKE silently matches nothing.
2273
2442
 
2274
2443
  Single source of the escape chain (#217 S2 / I-3 review Minor #3):
2275
- ``_like_pattern`` (substring) and ``_search_files``'s prefix pattern both build
2276
- on this so the escaping is defined exactly once."""
2444
+ ``_like_pattern`` (substring) and ``_search_files``'s substring pattern both
2445
+ build on this so the escaping is defined exactly once."""
2277
2446
  return q.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
2278
2447
 
2279
2448
 
@@ -2664,13 +2833,6 @@ def _search_title(conn, q, limit, offset, fts_available, filt_sql="", filt_param
2664
2833
  "total": total}
2665
2834
 
2666
2835
 
2667
- # #217 S2 / I-3: a path query "looks like a prefix" when it carries no leading
2668
- # path separator — ``bin/cctally`` is a prefix probe, ``/dashboard`` (or a query
2669
- # that intentionally starts mid-path) forces the substring scan. The set is the
2670
- # common path separators (POSIX + Windows).
2671
- _PATH_SEPARATORS = ("/", "\\")
2672
-
2673
-
2674
2836
  def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
2675
2837
  """#217 S2 / I-3: ``kind=files`` cross-session search over the write-class
2676
2838
  file-touch axis (``conversation_file_touches``). Emits one hit per DISTINCT
@@ -2679,28 +2841,22 @@ def _search_files(conn, q, limit, offset, filt_sql="", filt_params=()):
2679
2841
  ``match_kinds:["file"]``, snippet = the file path. ``total`` = the distinct
2680
2842
  ``(session_id, file_path)`` count.
2681
2843
 
2682
- P3-10 (match shape): a PREFIX-looking query (no leading path separator) binds a
2683
- literal-prefix pattern (escaped(q) + ``%``) into ``file_path LIKE ? ESCAPE '\\'``
2684
- and is GENUINELY index-assisted it rides ``idx_file_touches_path`` as a SEARCH
2685
- over a key range (verified by EXPLAIN QUERY PLAN in
2686
- ``test_kind_files_prefix_branch_uses_index_substring_scans``). That assistance
2687
- depends on ``idx_file_touches_path`` being ``COLLATE NOCASE``: the default
2688
- ``LIKE`` is case-insensitive, and SQLite only optimizes ``LIKE 'prefix%'`` onto
2689
- a btree when the index folds the same way (a BINARY index leaves it a full
2690
- SCAN). The ``ESCAPE '\\'`` clause does NOT defeat the optimization the literal
2691
- prefix is still constant. A query with a leading separator binds ``%`` +
2692
- escaped(q) + ``%`` and is a full SCAN (a leading wildcard can never use the
2693
- index — the intentional, documented substring fallback over the modest table).
2694
- LIKE is case-insensitive for ASCII (matching the NOCASE collation exactly).
2844
+ Match shape (#223): the query binds a SUBSTRING pattern (``%`` + escaped(q)
2845
+ + ``%``) into ``file_path LIKE ? ESCAPE '\\'`` for EVERY query — a bare
2846
+ basename, a mid-path fragment, or a leading-separator path all match. This
2847
+ is a deliberate full SCAN over the modest ``conversation_file_touches``
2848
+ table (a leading wildcard can never use ``idx_file_touches_path``),
2849
+ consistent with how the content facets match; it replaces the former
2850
+ prefix/separator special-case so a basename like ``package.json`` is
2851
+ findable. ``LIKE`` stays case-insensitive (ASCII), matching the NOCASE path
2852
+ index collation even though the index no longer drives a prefix probe.
2695
2853
 
2696
2854
  Filtered-search: the ``filt_sql`` session-scope restriction is a post-match
2697
2855
  ``ft.session_id IN (<subquery>)`` (the same as the message kinds) — there is NO
2698
2856
  dedicated session index (the filter is a small IN over the already-narrowed
2699
2857
  match set, so a ``session_id`` btree earned nothing; dropped per review Minor #2).
2700
2858
  """
2701
- esc = _like_escape(q)
2702
- prefix = not q.startswith(_PATH_SEPARATORS)
2703
- pat = (esc + "%") if prefix else ("%" + esc + "%")
2859
+ pat = _like_pattern(q)
2704
2860
  fpred = _session_filter_pred("ft.session_id", filt_sql)
2705
2861
  fargs = tuple(filt_params)
2706
2862