cctally 1.43.2 → 1.43.3
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 +10 -0
- package/bin/_cctally_cache.py +107 -8
- package/bin/_cctally_db.py +24 -0
- package/bin/_lib_conversation.py +49 -0
- package/bin/_lib_conversation_query.py +34 -2
- package/bin/cctally +1 -0
- package/dashboard/static/assets/{index-qxISkiGC.js → index-40fOgWiM.js} +9 -9
- package/dashboard/static/assets/{index-mPwkGfE9.css → index-Drpkfv6k.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.43.3] - 2026-06-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation viewer: each conversation is now titled by Claude Code's AI-generated session title — in the sessions rail and as the reader header — instead of the first prompt, falling back in order to the first prompt, the project label, then the session id; a title that Claude rewrites mid-session updates an already-open reader live (#193).
|
|
12
|
+
- Conversation viewer: a subagent thread is now titled by the description from the `Task` that spawned it, in both the thread-card header and the matching outline landmark (falling back to the subagent's first prompt), and a Bash tool call shows its own description on the dimmed chip line (falling back to the command, which always stays in the expanded `$ …` body); older transcripts and tool calls without a stored description keep their prior rendering (#193).
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Conversation viewer: a short single-line "You" prompt no longer shows an empty gap below its text — the per-message copy/permalink actions (previously an always-reserved but hidden-until-hover row that padded the bottom of the bordered box) now float into the bubble's top-right corner, so the box hugs its prose; assistant turns are unchanged (#192).
|
|
16
|
+
- Conversation viewer: the outline no longer highlights two entries at once when you scroll onto a landmark inside a prompt's section — a subagent card, a section heading, or a plan/question (most visible when a subagent is the last outline entry) — it now marks only that exact landmark instead of also lighting the enclosing "You" prompt, while scrolling onto ordinary prose still highlights its section prompt as before (#192).
|
|
17
|
+
|
|
8
18
|
## [1.43.2] - 2026-06-13
|
|
9
19
|
|
|
10
20
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -189,6 +189,18 @@ _CONV_INSERT_SQL = (
|
|
|
189
189
|
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
190
190
|
)
|
|
191
191
|
|
|
192
|
+
# #193: last non-null write wins (ai-title carries no timestamp; see spec S1). NO
|
|
193
|
+
# byte_offset guard — it can't order a cross-file resumed session. Ordering is
|
|
194
|
+
# made deterministic by ingest order: backfill_ai_titles walks files
|
|
195
|
+
# mtime-ascending so the newest file's last title is written last; the
|
|
196
|
+
# incremental fused walk appends only new bytes in file order.
|
|
197
|
+
_AI_TITLE_UPSERT_SQL = (
|
|
198
|
+
"INSERT INTO conversation_ai_titles(session_id,ai_title,source_path,byte_offset) "
|
|
199
|
+
"VALUES(?,?,?,?) "
|
|
200
|
+
"ON CONFLICT(session_id) DO UPDATE SET "
|
|
201
|
+
"ai_title=excluded.ai_title, source_path=excluded.source_path, byte_offset=excluded.byte_offset"
|
|
202
|
+
)
|
|
203
|
+
|
|
192
204
|
|
|
193
205
|
def _conv_row_tuple(m, path_str):
|
|
194
206
|
"""Flatten a ``MessageRow`` into the ``_CONV_INSERT_SQL`` column order.
|
|
@@ -212,19 +224,23 @@ def _conv_row_tuple(m, path_str):
|
|
|
212
224
|
|
|
213
225
|
def _iter_sync_entries(fh, path_str):
|
|
214
226
|
"""Fused single-pass sync walker (#138). Yields
|
|
215
|
-
``(byte_offset, cost_or_None, msgrow_or_None)`` for each
|
|
216
|
-
``fh``'s current position that produces a cost entry
|
|
217
|
-
message row.
|
|
227
|
+
``(byte_offset, cost_or_None, msgrow_or_None, aititle_or_None)`` for each
|
|
228
|
+
JSONL line from ``fh``'s current position that produces a cost entry, a
|
|
229
|
+
conversation message row, and/or an ai-title record.
|
|
218
230
|
|
|
219
231
|
Each line is read once (readline()+tell()) and ``json.loads``-parsed ONCE,
|
|
220
|
-
then classified by
|
|
232
|
+
then classified by the pure per-line parsers (#138 one-parse-per-line stays
|
|
233
|
+
intact — ``parse_ai_title`` runs on the SAME already-parsed ``obj``):
|
|
221
234
|
|
|
222
235
|
* ``cost_or_None`` is ``(UsageEntry, msg_id, req_id)`` when the line is a
|
|
223
236
|
billable assistant entry (``_lib_jsonl.parse_cost_entry``), else None.
|
|
224
237
|
* ``msgrow_or_None`` is a ``MessageRow`` when the line is a user/assistant
|
|
225
238
|
turn carrying a uuid (``_lib_conversation.parse_message_row``), else None.
|
|
239
|
+
* ``aititle_or_None`` is an ``AiTitleRow`` when the line is an ai-title
|
|
240
|
+
carrying a non-empty sessionId+aiTitle (#193), else None.
|
|
226
241
|
|
|
227
|
-
The
|
|
242
|
+
The three are independent — a normal assistant line yields the first two;
|
|
243
|
+
an ai-title line (a non-user/assistant type) yields only the third. This replaces
|
|
228
244
|
the former cost walk + re-seek-and-walk over the identical byte span: with a
|
|
229
245
|
single walk the "identical span" invariant is structural (one stop point),
|
|
230
246
|
not a prose-enforced ``mrow.byte_offset >= final_offset`` runtime break. A
|
|
@@ -252,8 +268,9 @@ def _iter_sync_entries(fh, path_str):
|
|
|
252
268
|
continue
|
|
253
269
|
cost = _lib_jsonl.parse_cost_entry(obj, path_str)
|
|
254
270
|
mrow = _lib_conversation.parse_message_row(obj, offset)
|
|
255
|
-
|
|
256
|
-
|
|
271
|
+
ai = _lib_conversation.parse_ai_title(obj, offset)
|
|
272
|
+
if cost is not None or mrow is not None or ai is not None:
|
|
273
|
+
yield offset, cost, mrow, ai
|
|
257
274
|
|
|
258
275
|
|
|
259
276
|
def _iter_claude_jsonl_files():
|
|
@@ -616,6 +633,12 @@ def sync_cache(
|
|
|
616
633
|
# under the lock (#138) — NOT a bare DELETE that fires conv_fts_ad
|
|
617
634
|
# per row.
|
|
618
635
|
clear_conversation_messages(conn)
|
|
636
|
+
# #193: ai-titles share the message lifecycle on a rebuild — wipe the
|
|
637
|
+
# table (so a title for a since-deleted session can't linger) and the
|
|
638
|
+
# pending-backfill flag in lockstep. The per-file fused walk below
|
|
639
|
+
# repopulates from offset 0, satisfying any deferred backfill.
|
|
640
|
+
conn.execute("DELETE FROM conversation_ai_titles")
|
|
641
|
+
conn.execute("DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'")
|
|
619
642
|
# Clear the walk-complete sentinel atomically with the wipe
|
|
620
643
|
# (cctally-dev#93, D5/D2): a stale "complete" marker must never
|
|
621
644
|
# survive a destructive rebuild. The end-of-loop write below
|
|
@@ -726,6 +749,27 @@ def sync_cache(
|
|
|
726
749
|
)
|
|
727
750
|
conn.commit()
|
|
728
751
|
|
|
752
|
+
# #193: consume the deferred ai-title backfill. Cache migration 012 is
|
|
753
|
+
# flag-only (sets ``ai_titles_backfill_pending``); the offset-0 walk
|
|
754
|
+
# over all history via backfill_ai_titles (mtime-ascending,
|
|
755
|
+
# last-write-wins) runs HERE under the held flock — same #139
|
|
756
|
+
# contract as the message backfill above. Touches ONLY
|
|
757
|
+
# conversation_ai_titles; the flag is dropped LAST so a crash mid-walk
|
|
758
|
+
# re-runs cleanly. Never on the rebuild path (which already cleared
|
|
759
|
+
# the flag + repopulates via the normal walk).
|
|
760
|
+
try:
|
|
761
|
+
_ai_pending = conn.execute(
|
|
762
|
+
"SELECT 1 FROM cache_meta WHERE key='ai_titles_backfill_pending'"
|
|
763
|
+
).fetchone() is not None
|
|
764
|
+
except sqlite3.OperationalError:
|
|
765
|
+
_ai_pending = False
|
|
766
|
+
if _ai_pending:
|
|
767
|
+
backfill_ai_titles(conn)
|
|
768
|
+
conn.execute(
|
|
769
|
+
"DELETE FROM cache_meta WHERE key='ai_titles_backfill_pending'"
|
|
770
|
+
)
|
|
771
|
+
conn.commit()
|
|
772
|
+
|
|
729
773
|
# Issue #164: consume the deferred conversation_messages re-ingest.
|
|
730
774
|
# Cache migration 003 is flag-only — it sets
|
|
731
775
|
# ``conversation_reingest_pending`` rather than clearing inline
|
|
@@ -905,6 +949,11 @@ def sync_cache(
|
|
|
905
949
|
# triggers → truncate → 'delete-all' → recreate, so conv_fts_ad
|
|
906
950
|
# never fires O(rows) inside the held lock.
|
|
907
951
|
clear_conversation_messages(conn)
|
|
952
|
+
# #193: truncation escalates to a full offset-0 re-ingest, so wipe
|
|
953
|
+
# conversation_ai_titles too (parallel to the session_entries +
|
|
954
|
+
# conversation_messages full-reset). The per-file fused walk below
|
|
955
|
+
# repopulates it from offset 0.
|
|
956
|
+
conn.execute("DELETE FROM conversation_ai_titles")
|
|
908
957
|
# Clear the walk-complete sentinel atomically with the truncation
|
|
909
958
|
# full-reset (cctally-dev#93, D5/D2): the cache is being wiped, so
|
|
910
959
|
# any "complete" marker is now stale. The end-of-loop write below
|
|
@@ -976,6 +1025,7 @@ def sync_cache(
|
|
|
976
1025
|
# so a slow JSONL doesn't hold a SQLite lock.
|
|
977
1026
|
rows: list[tuple[Any, ...]] = []
|
|
978
1027
|
conv_rows: list[tuple[Any, ...]] = []
|
|
1028
|
+
ai_rows: list[tuple[Any, ...]] = [] # #193: ai-title upserts
|
|
979
1029
|
final_offset = start_offset
|
|
980
1030
|
try:
|
|
981
1031
|
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
@@ -987,7 +1037,7 @@ def sync_cache(
|
|
|
987
1037
|
# walk over the identical span — the "identical span"
|
|
988
1038
|
# invariant is now structural (a single stop point) rather
|
|
989
1039
|
# than a prose-enforced ``>= final_offset`` runtime break.
|
|
990
|
-
for offset, cost, mrow in _iter_sync_entries(fh, path_str):
|
|
1040
|
+
for offset, cost, mrow, ai in _iter_sync_entries(fh, path_str):
|
|
991
1041
|
if cost is not None:
|
|
992
1042
|
entry, msg_id, req_id = cost
|
|
993
1043
|
usage = entry.usage
|
|
@@ -1015,6 +1065,11 @@ def sync_cache(
|
|
|
1015
1065
|
))
|
|
1016
1066
|
if mrow is not None:
|
|
1017
1067
|
conv_rows.append(_conv_row_tuple(mrow, path_str))
|
|
1068
|
+
if ai is not None:
|
|
1069
|
+
# #193: accumulate ai-title upserts in file order; the
|
|
1070
|
+
# executemany below applies them after conv_rows.
|
|
1071
|
+
ai_rows.append((ai.session_id, ai.ai_title,
|
|
1072
|
+
path_str, ai.byte_offset))
|
|
1018
1073
|
# ``final_offset`` is the single walk's stop — captured AFTER
|
|
1019
1074
|
# the loop drains (or rewinds a partial mid-write tail line).
|
|
1020
1075
|
# It is what session_files.last_byte_offset is written from,
|
|
@@ -1115,6 +1170,10 @@ def sync_cache(
|
|
|
1115
1170
|
# (parallel to the cost path's lifecycle).
|
|
1116
1171
|
if conv_rows:
|
|
1117
1172
|
conn.executemany(_CONV_INSERT_SQL, conv_rows)
|
|
1173
|
+
# #193: ai-title upserts for this file, in file order (last wins).
|
|
1174
|
+
# Committed atomically with the session_files cursor below.
|
|
1175
|
+
if ai_rows:
|
|
1176
|
+
conn.executemany(_AI_TITLE_UPSERT_SQL, ai_rows)
|
|
1118
1177
|
# UPSERT preserves session_id / project_path columns populated
|
|
1119
1178
|
# by _ensure_session_files_row at the top of this loop. A plain
|
|
1120
1179
|
# INSERT OR REPLACE would wipe them on every changed-file sync.
|
|
@@ -1226,6 +1285,46 @@ def backfill_conversation_messages(conn: sqlite3.Connection) -> int:
|
|
|
1226
1285
|
return inserted
|
|
1227
1286
|
|
|
1228
1287
|
|
|
1288
|
+
def backfill_ai_titles(conn: sqlite3.Connection) -> int:
|
|
1289
|
+
"""One-time backfill of ``conversation_ai_titles`` for existing installs
|
|
1290
|
+
(#193). Walks EVERY Claude JSONL from offset 0 via
|
|
1291
|
+
``_lib_conversation.iter_ai_titles`` and upserts.
|
|
1292
|
+
|
|
1293
|
+
Files are walked MTIME-ASCENDING so that, for a session whose ai-title spans
|
|
1294
|
+
multiple files (a ``--resume``), the most-recently-modified file's last
|
|
1295
|
+
non-null title is written last (last-write-wins; see _AI_TITLE_UPSERT_SQL).
|
|
1296
|
+
Per-file commit; the caller (``sync_cache``, consuming the
|
|
1297
|
+
``ai_titles_backfill_pending`` flag) holds the ``cache.db.lock`` flock for the
|
|
1298
|
+
duration. Touches ONLY ``conversation_ai_titles`` — the cost/message cursors
|
|
1299
|
+
are untouched. Idempotent: a re-run rewrites the same current title (the
|
|
1300
|
+
last-write-wins ordering is stable under the deterministic mtime walk).
|
|
1301
|
+
Returns rows upserted."""
|
|
1302
|
+
n = 0
|
|
1303
|
+
|
|
1304
|
+
def _mtime(p):
|
|
1305
|
+
try:
|
|
1306
|
+
return p.stat().st_mtime
|
|
1307
|
+
except OSError:
|
|
1308
|
+
return 0.0 # vanished mid-walk; sorts first, the open() below skips it
|
|
1309
|
+
|
|
1310
|
+
files = sorted(_iter_claude_jsonl_files(), key=_mtime)
|
|
1311
|
+
for jp in files:
|
|
1312
|
+
path_str = str(jp)
|
|
1313
|
+
rows: list[tuple[Any, ...]] = []
|
|
1314
|
+
try:
|
|
1315
|
+
with open(jp, "r", encoding="utf-8", errors="replace") as fh:
|
|
1316
|
+
for r in _lib_conversation.iter_ai_titles(fh, path_str):
|
|
1317
|
+
rows.append((r.session_id, r.ai_title, path_str, r.byte_offset))
|
|
1318
|
+
except OSError as exc:
|
|
1319
|
+
eprint(f"[ai-title-backfill] could not read {jp}: {exc}")
|
|
1320
|
+
continue
|
|
1321
|
+
if rows:
|
|
1322
|
+
conn.executemany(_AI_TITLE_UPSERT_SQL, rows)
|
|
1323
|
+
n += len(rows)
|
|
1324
|
+
conn.commit()
|
|
1325
|
+
return n
|
|
1326
|
+
|
|
1327
|
+
|
|
1229
1328
|
_REINGEST_FLAG_KEYS = (
|
|
1230
1329
|
"conversation_reingest_pending",
|
|
1231
1330
|
"conversation_source_tool_use_reingest_pending",
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2349,6 +2349,17 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2349
2349
|
CREATE INDEX IF NOT EXISTS idx_conv_turnkey
|
|
2350
2350
|
ON conversation_messages(msg_id, req_id);
|
|
2351
2351
|
|
|
2352
|
+
-- #193: per-session AI-generated title, isolated from the six places
|
|
2353
|
+
-- that iterate conversation_messages. The explicit NOT NULL on the
|
|
2354
|
+
-- non-INTEGER PRIMARY KEY matters (SQLite's legacy NULL-in-PK bug);
|
|
2355
|
+
-- _session_titles_map can only key on a concrete session_id.
|
|
2356
|
+
CREATE TABLE IF NOT EXISTS conversation_ai_titles (
|
|
2357
|
+
session_id TEXT NOT NULL PRIMARY KEY,
|
|
2358
|
+
ai_title TEXT NOT NULL,
|
|
2359
|
+
source_path TEXT,
|
|
2360
|
+
byte_offset INTEGER NOT NULL
|
|
2361
|
+
);
|
|
2362
|
+
|
|
2352
2363
|
CREATE TABLE IF NOT EXISTS codex_session_files (
|
|
2353
2364
|
path TEXT PRIMARY KEY,
|
|
2354
2365
|
size_bytes INTEGER NOT NULL,
|
|
@@ -3383,6 +3394,19 @@ def _011_conversation_promote_command_args(conn: sqlite3.Connection) -> None:
|
|
|
3383
3394
|
conn.commit()
|
|
3384
3395
|
|
|
3385
3396
|
|
|
3397
|
+
@cache_migration("012_create_conversation_ai_titles")
|
|
3398
|
+
def _012_create_conversation_ai_titles(conn: sqlite3.Connection) -> None:
|
|
3399
|
+
"""Flag-only arm for #193. The conversation_ai_titles table itself is created
|
|
3400
|
+
by _apply_cache_schema (runs on every open, fresh + existing installs); this
|
|
3401
|
+
migration sets ``ai_titles_backfill_pending`` so sync_cache walks all history
|
|
3402
|
+
once via backfill_ai_titles under the cache.db.lock flock. No data work here
|
|
3403
|
+
-> the dispatcher's central stamp (#140) marks a complete handler; a fresh
|
|
3404
|
+
install stamps WITHOUT a populated history (its incremental walk fills the
|
|
3405
|
+
table as it ingests, and the consumed backfill no-ops). Mirrors 002/010/011."""
|
|
3406
|
+
_set_cache_meta(conn, "ai_titles_backfill_pending", "1")
|
|
3407
|
+
conn.commit()
|
|
3408
|
+
|
|
3409
|
+
|
|
3386
3410
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3387
3411
|
|
|
3388
3412
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -263,6 +263,55 @@ def parse_message_row(obj, offset):
|
|
|
263
263
|
return _normalize(obj, t, offset)
|
|
264
264
|
|
|
265
265
|
|
|
266
|
+
@dataclass
|
|
267
|
+
class AiTitleRow:
|
|
268
|
+
"""Pure per-line AI-title record (no I/O). Parallels MessageRow but for the
|
|
269
|
+
main-session ``{"type":"ai-title","aiTitle":...,"sessionId":...}`` lines that
|
|
270
|
+
parse_message_row drops (type not in user/assistant). #193."""
|
|
271
|
+
session_id: "str | None"
|
|
272
|
+
ai_title: str
|
|
273
|
+
byte_offset: int
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def parse_ai_title(obj, offset):
|
|
277
|
+
"""Return an AiTitleRow when ``obj`` is an ai-title line with BOTH a non-empty
|
|
278
|
+
string sessionId and a non-empty string aiTitle, else None. Skips the null /
|
|
279
|
+
blank rewrites CC emits as the title evolves, and any malformed line. #193."""
|
|
280
|
+
if obj.get("type") != "ai-title":
|
|
281
|
+
return None
|
|
282
|
+
sid = obj.get("sessionId")
|
|
283
|
+
title = obj.get("aiTitle")
|
|
284
|
+
if not (isinstance(sid, str) and sid.strip()):
|
|
285
|
+
return None
|
|
286
|
+
if not (isinstance(title, str) and title.strip()):
|
|
287
|
+
return None
|
|
288
|
+
return AiTitleRow(sid, title, offset)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def iter_ai_titles(fh, path_str):
|
|
292
|
+
"""Yield AiTitleRow for each ai-title line from ``fh``'s current position.
|
|
293
|
+
Mirrors iter_message_rows: caller owns the open file handle; pure parse.
|
|
294
|
+
``path_str`` is accepted for signature-parity (ai-title rows don't use it)."""
|
|
295
|
+
while True:
|
|
296
|
+
offset = fh.tell()
|
|
297
|
+
line = fh.readline()
|
|
298
|
+
if not line:
|
|
299
|
+
return
|
|
300
|
+
if not line.endswith("\n"):
|
|
301
|
+
fh.seek(offset)
|
|
302
|
+
return
|
|
303
|
+
s = line.strip()
|
|
304
|
+
if not s:
|
|
305
|
+
continue
|
|
306
|
+
try:
|
|
307
|
+
obj = json.loads(s)
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
continue
|
|
310
|
+
row = parse_ai_title(obj, offset)
|
|
311
|
+
if row is not None:
|
|
312
|
+
yield row
|
|
313
|
+
|
|
314
|
+
|
|
266
315
|
def _normalize(obj, t, offset):
|
|
267
316
|
msg = obj.get("message")
|
|
268
317
|
if not isinstance(msg, dict):
|
|
@@ -196,6 +196,19 @@ def _session_titles_map(conn, session_ids):
|
|
|
196
196
|
if not session_ids:
|
|
197
197
|
return {}
|
|
198
198
|
titles = {}
|
|
199
|
+
# #193: AI title wins when present. Query the dedicated table first; the
|
|
200
|
+
# existing first-prompt scan below fills only sessions WITHOUT one (its
|
|
201
|
+
# ``if sid in titles: continue`` guard skips ai-title sessions for free).
|
|
202
|
+
try:
|
|
203
|
+
ph0 = ",".join("?" for _ in session_ids)
|
|
204
|
+
for sid, at in conn.execute(
|
|
205
|
+
f"SELECT session_id, ai_title FROM conversation_ai_titles "
|
|
206
|
+
f"WHERE session_id IN ({ph0})", tuple(session_ids)
|
|
207
|
+
).fetchall():
|
|
208
|
+
if at:
|
|
209
|
+
titles[sid] = at
|
|
210
|
+
except sqlite3.OperationalError:
|
|
211
|
+
pass # table absent (pre-migration / :memory:) -> fall through to first-prompt
|
|
199
212
|
# While 005's reingest is pending, a stale `human` row may actually be an
|
|
200
213
|
# injected skill body (a SessionStart skill can even lead the transcript) —
|
|
201
214
|
# skip those as title candidates so the rail never shows "Base directory for
|
|
@@ -536,6 +549,7 @@ def _assemble_session(conn, session_id):
|
|
|
536
549
|
# the top-level subagent_meta map (no undocumented block keys leak). Join is
|
|
537
550
|
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
538
551
|
spawn_kind = {} # tool_use id -> subagent_type
|
|
552
|
+
spawn_desc = {} # tool_use id -> spawning Task description (#193)
|
|
539
553
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
540
554
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
541
555
|
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
@@ -549,6 +563,13 @@ def _assemble_session(conn, session_id):
|
|
|
549
563
|
st = b.pop("subagent_type", None)
|
|
550
564
|
if st and b.get("id") is not None:
|
|
551
565
|
spawn_kind[b["id"]] = st
|
|
566
|
+
# #193: harvest the spawning Task description from the
|
|
567
|
+
# already-stored bounded input. Guarded on subagent_type, so
|
|
568
|
+
# a Bash `description` (no subagent_type) is NEVER picked up.
|
|
569
|
+
_inp = b.get("input")
|
|
570
|
+
_d = _inp.get("description") if isinstance(_inp, dict) else None
|
|
571
|
+
if isinstance(_d, str) and _d.strip():
|
|
572
|
+
spawn_desc[b["id"]] = _d
|
|
552
573
|
elif k == "tool_result":
|
|
553
574
|
aid = b.pop("agent_id", None)
|
|
554
575
|
meta = b.pop("subagent_meta", None)
|
|
@@ -579,6 +600,8 @@ def _assemble_session(conn, session_id):
|
|
|
579
600
|
continue # spawn with no (yet) result -> title-only
|
|
580
601
|
_aid, _raw = _link
|
|
581
602
|
_entry = {"kind": _kind}
|
|
603
|
+
if spawn_desc.get(_tuid): # #193: spawning Task description
|
|
604
|
+
_entry["description"] = spawn_desc[_tuid]
|
|
582
605
|
for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
|
|
583
606
|
if _raw.get(_f) is not None:
|
|
584
607
|
_entry[_f] = _raw[_f]
|
|
@@ -783,6 +806,13 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
783
806
|
subagent_meta = asm["subagent_meta"]
|
|
784
807
|
header_cost = asm["header_cost"]
|
|
785
808
|
|
|
809
|
+
# #193: compute the reader header title ONCE so BOTH return sites (the early
|
|
810
|
+
# empty/stale-cursor return and the normal return) carry it. Same fallback
|
|
811
|
+
# chain the rail/search rows use: ai-title -> first human prompt -> project
|
|
812
|
+
# label -> session_id (matching the list's `titles.get(sid) or pl or sid`).
|
|
813
|
+
_pl = _project_label(_latest(logical, 10))
|
|
814
|
+
_title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
|
|
815
|
+
|
|
786
816
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
787
817
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
788
818
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -796,7 +826,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
796
826
|
if start is None:
|
|
797
827
|
return {
|
|
798
828
|
"session_id": session_id,
|
|
799
|
-
"
|
|
829
|
+
"title": _title,
|
|
830
|
+
"project_label": _pl,
|
|
800
831
|
"git_branch": _latest(logical, 11),
|
|
801
832
|
"started_utc": logical[0][2],
|
|
802
833
|
"last_activity_utc": logical[-1][2],
|
|
@@ -830,7 +861,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
830
861
|
models = sorted({r[6] for r in logical if r[6]})
|
|
831
862
|
return {
|
|
832
863
|
"session_id": session_id,
|
|
833
|
-
"
|
|
864
|
+
"title": _title,
|
|
865
|
+
"project_label": _pl,
|
|
834
866
|
"git_branch": _latest(logical, 11),
|
|
835
867
|
"started_utc": first[2],
|
|
836
868
|
"last_activity_utc": last[2],
|
package/bin/cctally
CHANGED
|
@@ -792,6 +792,7 @@ _progress_stderr = _cctally_cache._progress_stderr
|
|
|
792
792
|
_ensure_session_files_row = _cctally_cache._ensure_session_files_row
|
|
793
793
|
sync_cache = _cctally_cache.sync_cache
|
|
794
794
|
backfill_conversation_messages = _cctally_cache.backfill_conversation_messages
|
|
795
|
+
backfill_ai_titles = _cctally_cache.backfill_ai_titles
|
|
795
796
|
iter_entries = _cctally_cache.iter_entries
|
|
796
797
|
_collect_entries_direct = _cctally_cache._collect_entries_direct
|
|
797
798
|
_JoinedClaudeEntry = _cctally_cache._JoinedClaudeEntry
|