cctally 1.32.0 → 1.34.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 +18 -0
- package/bin/_cctally_cache.py +23 -9
- package/bin/_cctally_db.py +30 -0
- package/bin/_lib_conversation.py +2 -0
- package/bin/_lib_conversation_query.py +51 -3
- package/dashboard/static/assets/index-BQaiNUVl.js +57 -0
- package/dashboard/static/assets/{index-C6pXKeN4.css → index-PGNdJcG8.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-tToO8p8A.js +0 -57
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.34.0] - 2026-06-11
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Dashboard conversation reader: the open conversation now updates live — once you've paged to the end, new turns from an active session appear on each refresh tick with no manual reload, sticking to the newest turn if you're already at the bottom or surfacing a floating "↓ N new" pill (click to jump to the latest) if you've scrolled up. The cost and model totals update along with the new turns (#175).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Dashboard conversation reader: the loading state now shows an animated spinner instead of a static glyph (reduced-motion aware) (#175).
|
|
15
|
+
- Dashboard conversation reader: long-turn headers — assistant/human turns, tool-result runs, and subagent groups — now pin to the top while you scroll inside them, and clicking an assistant/human turn header scrolls back to that turn's start (#175).
|
|
16
|
+
- Dashboard conversation reader: the assistant model is now shown as a colored chip (matching the rest of the dashboard) instead of plain text; turns with no known model render no chip (#175).
|
|
17
|
+
|
|
18
|
+
## [1.33.0] - 2026-06-11
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Dashboard conversation reader: URL deep-linking — selecting a conversation or landing a turn jump now reflects into the address-bar hash (`#/conversations/<id>/<turn>`), reloads and Back/Forward restore that state, and every conversation turn gains a permalink button that copies a link straight to that turn (local-first; the link only resolves for someone who can already reach your dashboard). Closes #169, #174.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **The dashboard conversation reader now nests a skill's content inside its Skill tool call.** Previously a Skill invocation's loaded markdown body rendered as a separate collapsed "Skill content" pill below the assistant turn (visually detached from the Skill tool chip that produced it). The body now folds into the Skill tool chip itself as its expandable, rich-Markdown content (the redundant "Launching skill: <name>" line is dropped); SessionStart-injected skills, which have no Skill tool call, still render the standalone pill. The fix lands on existing history automatically the next time the dashboard syncs the conversation cache (a one-time re-ingest; the cache is re-derivable).
|
|
25
|
+
|
|
8
26
|
## [1.32.0] - 2026-06-10
|
|
9
27
|
|
|
10
28
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -183,8 +183,8 @@ _CONV_INSERT_SQL = (
|
|
|
183
183
|
"INSERT OR IGNORE INTO conversation_messages"
|
|
184
184
|
"(session_id,uuid,parent_uuid,source_path,byte_offset,"
|
|
185
185
|
" timestamp_utc,entry_type,text,blocks_json,model,msg_id,"
|
|
186
|
-
" req_id,cwd,git_branch,is_sidechain)"
|
|
187
|
-
" VALUES(
|
|
186
|
+
" req_id,cwd,git_branch,is_sidechain,source_tool_use_id)"
|
|
187
|
+
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
|
188
188
|
)
|
|
189
189
|
|
|
190
190
|
|
|
@@ -194,6 +194,7 @@ def _conv_row_tuple(m, path_str):
|
|
|
194
194
|
m.session_id, m.uuid, m.parent_uuid, path_str, m.byte_offset,
|
|
195
195
|
m.timestamp_utc, m.entry_type, m.text, m.blocks_json, m.model,
|
|
196
196
|
m.msg_id, m.req_id, m.cwd, m.git_branch, m.is_sidechain,
|
|
197
|
+
m.source_tool_use_id,
|
|
197
198
|
)
|
|
198
199
|
|
|
199
200
|
|
|
@@ -618,9 +619,14 @@ def sync_cache(
|
|
|
618
619
|
# (idempotent but wasteful) clear+backfill pass. #166 migration 004
|
|
619
620
|
# also sets this same flag (to land the subagent kind/meta fields);
|
|
620
621
|
# the rebuild re-derives those fields via the same offset-0 walk, so
|
|
621
|
-
# dropping the flag here covers the 004 reingest too.
|
|
622
|
+
# dropping the flag here covers the 004 reingest too. Migration 006
|
|
623
|
+
# sets the DISTINCT conversation_source_tool_use_reingest_pending
|
|
624
|
+
# flag (to land source_tool_use_id); the same offset-0 walk re-derives
|
|
625
|
+
# it, so drop that flag here as well to avoid a redundant pass.
|
|
622
626
|
conn.execute(
|
|
623
|
-
"DELETE FROM cache_meta WHERE key
|
|
627
|
+
"DELETE FROM cache_meta WHERE key IN "
|
|
628
|
+
"('conversation_reingest_pending',"
|
|
629
|
+
" 'conversation_source_tool_use_reingest_pending')")
|
|
624
630
|
conn.commit()
|
|
625
631
|
eprint("[cache-sync] rebuild: cleared Claude cached entries")
|
|
626
632
|
|
|
@@ -676,11 +682,18 @@ def sync_cache(
|
|
|
676
682
|
# so those fields land here with zero new consumption code. Migration
|
|
677
683
|
# 005 reuses it again to reclassify injected isMeta rows from
|
|
678
684
|
# entry_type='human' to 'meta' (so the reader stops attributing skill
|
|
679
|
-
# bodies / git-context to the user).
|
|
685
|
+
# bodies / git-context to the user). Migration 006 uses a DISTINCT
|
|
686
|
+
# flag ``conversation_source_tool_use_reingest_pending`` (NOT the
|
|
687
|
+
# shared one) to land the message-level ``source_tool_use_id`` — the
|
|
688
|
+
# shared flag also gates the kernel's 005 human-fallback, so re-arming
|
|
689
|
+
# it for 006 could misclassify a genuine human prompt during the
|
|
690
|
+
# pre-reingest window. We trigger the SAME clear + offset-0 backfill on
|
|
691
|
+
# EITHER flag and clear BOTH atomically here under the held flock.
|
|
680
692
|
try:
|
|
681
693
|
_reingest = conn.execute(
|
|
682
|
-
"SELECT 1 FROM cache_meta "
|
|
683
|
-
"
|
|
694
|
+
"SELECT 1 FROM cache_meta WHERE key IN "
|
|
695
|
+
"('conversation_reingest_pending',"
|
|
696
|
+
" 'conversation_source_tool_use_reingest_pending')"
|
|
684
697
|
).fetchone() is not None
|
|
685
698
|
except sqlite3.OperationalError:
|
|
686
699
|
_reingest = False
|
|
@@ -688,8 +701,9 @@ def sync_cache(
|
|
|
688
701
|
clear_conversation_messages(conn)
|
|
689
702
|
backfill_conversation_messages(conn)
|
|
690
703
|
conn.execute(
|
|
691
|
-
"DELETE FROM cache_meta "
|
|
692
|
-
"
|
|
704
|
+
"DELETE FROM cache_meta WHERE key IN "
|
|
705
|
+
"('conversation_reingest_pending',"
|
|
706
|
+
" 'conversation_source_tool_use_reingest_pending')"
|
|
693
707
|
)
|
|
694
708
|
conn.commit()
|
|
695
709
|
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2331,6 +2331,7 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2331
2331
|
cwd TEXT,
|
|
2332
2332
|
git_branch TEXT,
|
|
2333
2333
|
is_sidechain INTEGER NOT NULL DEFAULT 0,
|
|
2334
|
+
source_tool_use_id TEXT,
|
|
2334
2335
|
UNIQUE(source_path, byte_offset)
|
|
2335
2336
|
);
|
|
2336
2337
|
CREATE INDEX IF NOT EXISTS idx_conv_session_ts
|
|
@@ -2383,6 +2384,11 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2383
2384
|
# populated lazily in sync_cache() / _ensure_session_files_row().
|
|
2384
2385
|
add_column_if_missing(conn, "session_files", "session_id", "TEXT")
|
|
2385
2386
|
add_column_if_missing(conn, "session_files", "project_path", "TEXT")
|
|
2387
|
+
# Existing-DB guard for the skill-content fold link (cctally-dev
|
|
2388
|
+
# skill-content-nesting): the message-level sourceToolUseID. Idempotent
|
|
2389
|
+
# column-add (no marker, no version); cache migration 006 then re-ingests
|
|
2390
|
+
# so the value actually lands on historical rows.
|
|
2391
|
+
add_column_if_missing(conn, "conversation_messages", "source_tool_use_id", "TEXT")
|
|
2386
2392
|
conn.execute(
|
|
2387
2393
|
"CREATE INDEX IF NOT EXISTS idx_session_files_session_id "
|
|
2388
2394
|
"ON session_files(session_id)"
|
|
@@ -3044,6 +3050,30 @@ def _005_conversation_reingest_meta(conn: sqlite3.Connection) -> None:
|
|
|
3044
3050
|
conn.commit()
|
|
3045
3051
|
|
|
3046
3052
|
|
|
3053
|
+
@cache_migration("006_conversation_reingest_source_tool_use_id")
|
|
3054
|
+
def _006_conversation_reingest_source_tool_use_id(conn: sqlite3.Connection) -> None:
|
|
3055
|
+
"""Flag-only re-ingest of conversation_messages so the message-level
|
|
3056
|
+
``sourceToolUseID`` lands on existing history as the new
|
|
3057
|
+
``source_tool_use_id`` column — the link the reader uses to fold a
|
|
3058
|
+
Skill-invoked skill body into its Skill tool chip.
|
|
3059
|
+
|
|
3060
|
+
Sets a DISTINCT flag, ``conversation_source_tool_use_reingest_pending``,
|
|
3061
|
+
NOT the shared ``conversation_reingest_pending``. The shared flag also gates
|
|
3062
|
+
migration 005's read-time *human*-fallback (``_reingest_pending`` in the
|
|
3063
|
+
query kernel); re-arming it here would re-enable that fallback after it was
|
|
3064
|
+
consumed and could misclassify a genuine human prompt that happens to start
|
|
3065
|
+
with the skill preamble as a collapsed skill pill during 006's pre-reingest
|
|
3066
|
+
window. sync_cache consumes EITHER flag (clear + offset-0 backfill under the
|
|
3067
|
+
cache.db.lock flock) and the offset-0 walk re-parses every JSONL through the
|
|
3068
|
+
sourceToolUseID-aware parser, so the column lands with zero new consumption
|
|
3069
|
+
code. A distinct ``schema_migrations`` marker is what triggers this reingest
|
|
3070
|
+
on installs already at 005. Central stamp via the dispatcher (issue #140); a
|
|
3071
|
+
fresh install stamps it without running (empty table -> the flag, if ever
|
|
3072
|
+
set, is a harmless no-op)."""
|
|
3073
|
+
_set_cache_meta(conn, "conversation_source_tool_use_reingest_pending", "1")
|
|
3074
|
+
conn.commit()
|
|
3075
|
+
|
|
3076
|
+
|
|
3047
3077
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3048
3078
|
|
|
3049
3079
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -34,6 +34,7 @@ class MessageRow:
|
|
|
34
34
|
cwd: "str | None"
|
|
35
35
|
git_branch: "str | None"
|
|
36
36
|
is_sidechain: int
|
|
37
|
+
source_tool_use_id: "str | None" = None
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def iter_message_rows(fh, path_str):
|
|
@@ -130,6 +131,7 @@ def _normalize(obj, t, offset):
|
|
|
130
131
|
cwd=obj.get("cwd"),
|
|
131
132
|
git_branch=obj.get("gitBranch"),
|
|
132
133
|
is_sidechain=1 if obj.get("isSidechain") else 0,
|
|
134
|
+
source_tool_use_id=obj.get("sourceToolUseID"),
|
|
133
135
|
)
|
|
134
136
|
|
|
135
137
|
|
|
@@ -377,7 +377,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
377
377
|
# uuid, so the first occurrence in ascending order is canonical.
|
|
378
378
|
raw = conn.execute(
|
|
379
379
|
"SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
|
|
380
|
-
" msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid "
|
|
380
|
+
" msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid, "
|
|
381
|
+
" source_tool_use_id "
|
|
381
382
|
"FROM conversation_messages WHERE session_id=? "
|
|
382
383
|
"ORDER BY timestamp_utc, id", (session_id,)).fetchall()
|
|
383
384
|
|
|
@@ -421,7 +422,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
421
422
|
|
|
422
423
|
for row in logical:
|
|
423
424
|
(rid, u, ts, etype, text, blocks, model, msg_id, req_id,
|
|
424
|
-
is_sc, cwd, branch, source_path, parent_uuid) = row
|
|
425
|
+
is_sc, cwd, branch, source_path, parent_uuid, source_tool_use_id) = row
|
|
425
426
|
if etype == "assistant" and msg_id is not None:
|
|
426
427
|
key = (msg_id, req_id)
|
|
427
428
|
idx = turn_index.get(key)
|
|
@@ -535,6 +536,39 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
535
536
|
it["skill_name"] = skill_name
|
|
536
537
|
it["text"] = body
|
|
537
538
|
|
|
539
|
+
# ---- Phase 4b: fold a Skill-invoked skill body into its Skill tool chip ----
|
|
540
|
+
# A Skill invocation's injected body (now meta_kind='skill') links to its
|
|
541
|
+
# Skill tool_use via source_tool_use_id (threaded as the internal
|
|
542
|
+
# _source_tool_use_id). Resolve it against the SAME tooluse_index the Phase 2
|
|
543
|
+
# tool_result fold uses (ids unique per session; last-writer-wins). The index
|
|
544
|
+
# value is (item, block) holding the LIVE block dict — Phase 3 mutated that
|
|
545
|
+
# same dict in place to a `tool_call`, so block["skill_body"]=… mutates the
|
|
546
|
+
# live chip. On a hit: the body becomes the chip's expandable content
|
|
547
|
+
# (skill_body/skill_name), the trivial "Launching skill" result is dropped
|
|
548
|
+
# (result=None), the body uuid joins the owner's member_uuids (#160 jump
|
|
549
|
+
# anchor), and the standalone item is removed. NO hit (SessionStart skills;
|
|
550
|
+
# pre-006 NULL column; orphan id) -> the standalone pill stays. NULL-driven
|
|
551
|
+
# and flag-INDEPENDENT (it does NOT key on _reingest_pending). Runs before
|
|
552
|
+
# pagination so a match never depends on page boundaries.
|
|
553
|
+
_skill_drop = set()
|
|
554
|
+
for it in items:
|
|
555
|
+
if it.get("meta_kind") != "skill":
|
|
556
|
+
continue
|
|
557
|
+
stid = it.get("_source_tool_use_id")
|
|
558
|
+
if not stid:
|
|
559
|
+
continue
|
|
560
|
+
hit = tooluse_index.get(stid)
|
|
561
|
+
if hit is None:
|
|
562
|
+
continue
|
|
563
|
+
owner, block = hit
|
|
564
|
+
block["skill_body"] = it["text"]
|
|
565
|
+
block["skill_name"] = it.get("skill_name")
|
|
566
|
+
block["result"] = None
|
|
567
|
+
owner["member_uuids"].append(it["anchor"]["uuid"])
|
|
568
|
+
_skill_drop.add(id(it))
|
|
569
|
+
if _skill_drop:
|
|
570
|
+
items = [it for it in items if id(it) not in _skill_drop]
|
|
571
|
+
|
|
538
572
|
costs = _turn_cost_map(conn, list(turn_index))
|
|
539
573
|
# Stamp per-item cost first, then derive the header from the SUM of the
|
|
540
574
|
# ROUNDED per-item assistant costs (M2) — so the §6.5 invariant
|
|
@@ -553,6 +587,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
553
587
|
it.pop("_has_prose", None)
|
|
554
588
|
header_cost = round(header_cost, 6)
|
|
555
589
|
|
|
590
|
+
# Strip the internal Phase-4b threading key from EVERY item (meta/human items
|
|
591
|
+
# carry it too, not just assistant turns) so it never surfaces in the public
|
|
592
|
+
# item JSON.
|
|
593
|
+
for it in items:
|
|
594
|
+
it.pop("_source_tool_use_id", None)
|
|
595
|
+
|
|
556
596
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
557
597
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
558
598
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -636,6 +676,10 @@ def _build_turn(members):
|
|
|
636
676
|
"parent_uuid": first[13],
|
|
637
677
|
"_msg_id": first[7],
|
|
638
678
|
"_req_id": first[8],
|
|
679
|
+
# Internal threading for the Phase 4b skill-body fold (analogous to
|
|
680
|
+
# _msg_id/_req_id); consumed + del'd before items are returned, never in
|
|
681
|
+
# the public JSON. Meaningful only on meta skill items, harmless here.
|
|
682
|
+
"_source_tool_use_id": first[14],
|
|
639
683
|
"_has_prose": False,
|
|
640
684
|
}
|
|
641
685
|
_fold_fragment(item, first)
|
|
@@ -682,7 +726,7 @@ def _build_simple(row):
|
|
|
682
726
|
internal _msg_id/_req_id keys, so the cost loop's KeyError path can never fire
|
|
683
727
|
(I2). The model is preserved for assistant rows."""
|
|
684
728
|
(rid, u, ts, etype, text, blocks, model, msg_id, req_id, is_sc, cwd, branch,
|
|
685
|
-
source_path, parent_uuid) = row
|
|
729
|
+
source_path, parent_uuid, source_tool_use_id) = row
|
|
686
730
|
try:
|
|
687
731
|
parsed = _json.loads(blocks or "[]")
|
|
688
732
|
except (ValueError, TypeError):
|
|
@@ -697,6 +741,10 @@ def _build_simple(row):
|
|
|
697
741
|
"is_sidechain": bool(is_sc),
|
|
698
742
|
"subagent_key": _subagent_key(source_path),
|
|
699
743
|
"parent_uuid": parent_uuid,
|
|
744
|
+
# Internal threading for the Phase 4b skill-body fold (consumed + del'd
|
|
745
|
+
# before return). Carried on every simple item; meaningful only on the
|
|
746
|
+
# meta skill body row.
|
|
747
|
+
"_source_tool_use_id": source_tool_use_id,
|
|
700
748
|
}
|
|
701
749
|
if etype == "assistant":
|
|
702
750
|
item["model"] = model
|