cctally 1.100.0 → 1.102.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -43,7 +43,7 @@ from _lib_conversation import _MARKER_TAGS, _MARKER_RE, _is_system_marker
43
43
  # present a legacy/ingested command-marker row carrying a real <command-args>
44
44
  # prompt as a "You" turn (text=args, command_name badge). Re-exported for the
45
45
  # back-compat import surface + the consumer in _cctally_cache.py.
46
- from _lib_conversation import _extract_command_invocation
46
+ from _lib_conversation import _extract_command_invocation, _join_text_blocks
47
47
  # #186: read-time ANSI strip for rows already indexed with raw SGR (no forced
48
48
  # re-ingest). Scoped to prose/thinking/title/label — NEVER tool_result (Bash
49
49
  # AnsiText boundary). Shares the parser's regex so ingest and read-time agree.
@@ -160,16 +160,6 @@ def _skill_name_from_preamble(first_line) -> "str | None":
160
160
  return os.path.basename(path) or None if path else None
161
161
 
162
162
 
163
- def _join_text_blocks(blocks) -> str:
164
- """Rejoin a row's text-block bodies the way the parser's _blocks_and_text did
165
- ('\\n'-joined). A true meta row carries text='' (parser) with the body here in
166
- blocks; a not-yet-reingested human row carries the body in its text column —
167
- _meta_classify reads whichever is populated."""
168
- if not blocks:
169
- return ""
170
- return "\n".join(b.get("text", "") or "" for b in blocks if b.get("kind") == "text")
171
-
172
-
173
163
  def _reingest_pending(conn) -> bool:
174
164
  """True iff migration 005's ``conversation_reingest_pending`` flag is still
175
165
  set — i.e. existing history has NOT yet been re-ingested under the meta-aware
@@ -767,8 +757,108 @@ def _stamp_cache_failures(items):
767
757
  }
768
758
 
769
759
 
760
+ def parse_blocks_json(blocks_json):
761
+ """A normalized blocks list, or `[]` for anything that is not one.
762
+
763
+ Ingest writes a JSON array of block objects, but nothing in the schema
764
+ enforces the element shape. The shared text join skips malformed elements;
765
+ stricter semantic consumers validate separately. A bare string can still
766
+ make command-invocation normalization raise `AttributeError`, which the
767
+ diagnosis contains as `calculation_failed` (#620 S3 R1).
768
+ """
769
+ try:
770
+ parsed = _json.loads(blocks_json or "[]")
771
+ except (ValueError, TypeError):
772
+ return []
773
+ return parsed if isinstance(parsed, list) else []
774
+
775
+
776
+ def is_compaction_blocks(text, parsed_blocks):
777
+ """The ONE read-time compaction predicate, over parsed blocks.
778
+
779
+ Compaction leaves exactly one retained signal and it is not the flag:
780
+ `isCompactSummary` is consumed at ingest, sets `entry_type = META` and
781
+ blanks `text`, and is never persisted. What survives is the summary body
782
+ inside `blocks_json`, so detection is sentinel inference over the body
783
+ reconstructed from it — mirroring the assembly meta-classify, which
784
+ requires an all-text body that reads as a compaction summary and is not a
785
+ slash-command invocation.
786
+
787
+ The all-text guard is evaluated FIRST and rejects a malformed element,
788
+ which is what keeps a bare string in the array from raising through
789
+ `_join_text_blocks`.
790
+ """
791
+ blocks = parsed_blocks or []
792
+ if not all(isinstance(b, dict) and b.get("kind") == "text" for b in blocks):
793
+ return False
794
+ body = text or _join_text_blocks(blocks)
795
+ return bool(_is_compaction_body(body)
796
+ and _extract_command_invocation(blocks, body) is None)
797
+
798
+
799
+ def is_compaction_row(text, blocks_json):
800
+ """`is_compaction_blocks` over a raw `blocks_json` column value."""
801
+ return is_compaction_blocks(text, parse_blocks_json(blocks_json))
802
+
803
+
804
+ def _cache_failure_rows(conn, session_id):
805
+ """The narrow physical read the cache-churn stream is built from.
806
+
807
+ Enough to dedup, group turns, derive the subagent_key, detect compaction
808
+ and join the token row. No ``blocks_json`` body parse beyond the all-text
809
+ compaction guard. Returns None for an unknown session.
810
+ """
811
+ exists = conn.execute(
812
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
813
+ (session_id,)).fetchone()
814
+ if exists is None:
815
+ return None
816
+ return conn.execute(
817
+ "SELECT id, uuid, entry_type, text, blocks_json, model, "
818
+ " msg_id, req_id, source_path "
819
+ "FROM conversation_messages WHERE session_id=? "
820
+ "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
821
+
822
+
823
+ def _cache_failure_turn_keys(rows):
824
+ """The deduped assistant turn keys this stream will need tokens for.
825
+
826
+ The same list ``fold_claude_cache_failure_events`` builds internally, in
827
+ the same first-occurrence order, derived here so the wrapper can prefetch
828
+ the usage map.
829
+ """
830
+ seen_uuid = set()
831
+ seen_keys = set()
832
+ keys = []
833
+ for row in rows:
834
+ u = row[1]
835
+ if u in seen_uuid:
836
+ continue
837
+ seen_uuid.add(u)
838
+ if row[2] == "assistant" and row[6] is not None:
839
+ key = (row[6], row[7])
840
+ if key not in seen_keys:
841
+ seen_keys.add(key)
842
+ keys.append(key)
843
+ return keys
844
+
845
+
770
846
  def _lightweight_rebuild_events(conn, session_id):
771
- """Build the document-ordered ``_CFEvent`` stream for a session WITHOUT a
847
+ """The document-ordered ``_CFEvent`` stream for one session (U1, #217 S1).
848
+
849
+ Since #620 S3 this is a thin wrapper over the pure
850
+ ``fold_claude_cache_failure_events``; it performs the two reads and
851
+ delegates. Its signature, return shape and every caller are unchanged.
852
+ """
853
+ rows = _cache_failure_rows(conn, session_id)
854
+ if rows is None:
855
+ return []
856
+ return fold_claude_cache_failure_events(
857
+ rows, _turn_usage_map(conn, _cache_failure_turn_keys(rows)))
858
+
859
+
860
+ def fold_claude_cache_failure_events(rows, usage_map, *, with_sources=False):
861
+ """Build the document-ordered ``_CFEvent`` stream WITHOUT a
772
862
  full ``_assemble_session`` (U1, #217 S1). Skips the expensive assembly work
773
863
  (no ``blocks_json`` body parse, no tool-result folding, no meta-classify /
774
864
  ANSI strip, no subagent correlation) but faithfully reproduces the canonical
@@ -794,19 +884,6 @@ def _lightweight_rebuild_events(conn, session_id):
794
884
  fragment, and each meta/human row at its own position). That preserves the
795
885
  running-max walk the predicate requires.
796
886
  """
797
- exists = conn.execute(
798
- "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
799
- (session_id,)).fetchone()
800
- if exists is None:
801
- return []
802
- # Narrow column set: enough to dedup, group turns, derive the subagent_key,
803
- # detect compaction, and join the token row. No blocks_json body parse beyond
804
- # the all-text compaction guard (which needs text OR the text-block join).
805
- rows = conn.execute(
806
- "SELECT id, uuid, entry_type, text, blocks_json, model, "
807
- " msg_id, req_id, source_path "
808
- "FROM conversation_messages WHERE session_id=? "
809
- "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
810
887
  seen_uuid = set()
811
888
  logical = []
812
889
  for row in rows:
@@ -824,7 +901,6 @@ def _lightweight_rebuild_events(conn, session_id):
824
901
  sources = [] # parallel: the turn key per assistant event
825
902
  turn_index = {} # (msg_id, req_id) -> index into events
826
903
  turn_has_prose = {} # (msg_id, req_id) -> bool (model promoted yet?)
827
- turn_keys = [] # ordered list of grouped turn keys (for the token map)
828
904
  for (rid, u, etype, text, blocks, model, msg_id, req_id,
829
905
  source_path) in logical:
830
906
  if etype == "assistant" and msg_id is not None:
@@ -834,7 +910,6 @@ def _lightweight_rebuild_events(conn, session_id):
834
910
  if ev_idx is None:
835
911
  ev_idx = len(events)
836
912
  turn_index[key] = ev_idx
837
- turn_keys.append(key)
838
913
  ev = _CFEvent(key=(_subagent_key(source_path), model),
839
914
  model=model)
840
915
  events.append(ev)
@@ -861,15 +936,7 @@ def _lightweight_rebuild_events(conn, session_id):
861
936
  # BEFORE meta-classify, so a body carrying <command-args> never reads
862
937
  # as compaction; compaction bodies have no <command-args>, so in
863
938
  # practice the invocation check is a faithful belt-and-suspenders.
864
- try:
865
- parsed_blocks = _json.loads(blocks or "[]")
866
- except (ValueError, TypeError):
867
- parsed_blocks = []
868
- body = text or _join_text_blocks(parsed_blocks)
869
- all_text = all(b.get("kind") == "text"
870
- for b in (parsed_blocks or []))
871
- if (all_text and _is_compaction_body(body)
872
- and _extract_command_invocation(parsed_blocks, body) is None):
939
+ if is_compaction_row(text, blocks):
873
940
  events.append(_CFEvent(compaction=True))
874
941
  sources.append(None)
875
942
  # tool_result rows and null-msg_id assistant rows carry no token event.
@@ -879,12 +946,14 @@ def _lightweight_rebuild_events(conn, session_id):
879
946
  # turn key absent from session_entries carries NO tokens, so its event is
880
947
  # dropped (neither flags nor moves the running-max) — parity with
881
948
  # _cache_failure_events_from_items's `tokens` skip.
882
- usage = _turn_usage_map(conn, turn_keys)
949
+ usage = usage_map
883
950
  filtered = []
951
+ filtered_sources = [] # parallel: the turn key per emitted event
884
952
  si = 0
885
953
  for ev in events:
886
954
  if ev.compaction:
887
955
  filtered.append(ev)
956
+ filtered_sources.append(None)
888
957
  si += 1
889
958
  continue
890
959
  key = sources[si]
@@ -895,6 +964,14 @@ def _lightweight_rebuild_events(conn, session_id):
895
964
  ev.cc = tok.get("cache_creation", 0) or 0
896
965
  ev.cr = tok.get("cache_read", 0) or 0
897
966
  filtered.append(ev)
967
+ filtered_sources.append(key)
968
+ # `with_sources` mirrors `_cache_failure_events_from_items`, which has always
969
+ # returned `(events, sources)`. `_iter_cache_failures` yields the flagged
970
+ # event's INDEX, so without the parallel list a caller cannot say which turn
971
+ # was flagged — which is exactly what the diagnosis has to publish. Default
972
+ # off, so every existing caller keeps the bare list it always received.
973
+ if with_sources:
974
+ return filtered, filtered_sources
898
975
  return filtered
899
976
 
900
977
 
@@ -1678,14 +1755,68 @@ def _turn_usage_map(conn, turn_keys):
1678
1755
  _SPAWN_TOOL_NAMES = ("Agent", "Task")
1679
1756
 
1680
1757
 
1758
+ def _claude_canonical_rows(conn, session_id):
1759
+ """The 18-column physical read a canonical assembly runs over.
1760
+
1761
+ Returns None for an unknown session. Split out of ``_assemble_session``
1762
+ (#620 S3 X1) so the assembly fold can be pure over prefetched rows while
1763
+ the wrapper keeps performing exactly the reads it always did.
1764
+
1765
+ #177: stop_reason / attribution_* are TAIL-APPENDED (indices 15/16/17)
1766
+ AFTER source_tool_use_id so the existing positional reads (incl.
1767
+ ``_latest(logical, 10/11)`` for cwd/git_branch, ``r[6]`` for model, ``[2]``
1768
+ for ts) are all unchanged. Every unpacker downstream extends its tail in
1769
+ lockstep.
1770
+ """
1771
+ exists = conn.execute(
1772
+ "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
1773
+ (session_id,)).fetchone()
1774
+ if exists is None:
1775
+ return None
1776
+ return conn.execute(
1777
+ "SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
1778
+ " msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid, "
1779
+ " source_tool_use_id, stop_reason, attribution_skill, attribution_plugin "
1780
+ "FROM conversation_messages WHERE session_id=? "
1781
+ "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
1782
+
1783
+
1784
+ def _claude_turn_keys(rows):
1785
+ """The deduped assistant turn keys, in first-occurrence document order.
1786
+
1787
+ Exactly the list ``fold_claude_canonical`` builds internally as
1788
+ ``turn_index``, derived here so the wrapper can prefetch the cost and usage
1789
+ maps the fold consumes. Dedup by uuid comes first, because a replay carries
1790
+ the original uuid and its later occurrence is not canonical.
1791
+ """
1792
+ seen_uuid = set()
1793
+ seen_keys = set()
1794
+ keys = []
1795
+ for row in rows:
1796
+ u = row[1]
1797
+ if u in seen_uuid:
1798
+ continue
1799
+ seen_uuid.add(u)
1800
+ if row[3] == "assistant" and row[7] is not None:
1801
+ key = (row[7], row[8])
1802
+ if key not in seen_keys:
1803
+ seen_keys.add(key)
1804
+ keys.append(key)
1805
+ return keys
1806
+
1807
+
1681
1808
  def _assemble_session(conn, session_id):
1682
1809
  """Shared assembly for get_conversation / get_conversation_outline (#177 S5).
1683
1810
 
1684
- Runs the full dedup → turn-grouping → fold → sweep → meta-classify →
1811
+ Runs the full dedup -> turn-grouping -> fold -> sweep -> meta-classify ->
1685
1812
  cost/usage-stamp pipeline over the WHOLE session and returns the
1686
1813
  pre-pagination state, so the outline's turns match the reader's items 1:1
1687
- BY CONSTRUCTION (Codex F8 — one grouping pass, never two implementations).
1814
+ BY CONSTRUCTION (Codex F8 - one grouping pass, never two implementations).
1688
1815
  Returns None for an unknown session.
1816
+
1817
+ Since #620 S3 this is a thin wrapper: it performs the three reads and
1818
+ delegates to the pure ``fold_claude_canonical``. Its signature, return
1819
+ shape and every caller are unchanged.
1689
1820
  """
1690
1821
  # Opt-in phase instrumentation (issue #276 M5): an outer `assemble` root
1691
1822
  # wrapping eight structural child seams. Explicit __enter__/__exit__ (not a
@@ -1696,38 +1827,94 @@ def _assemble_session(conn, session_id):
1696
1827
  sp = _perf()
1697
1828
  _root = sp.phase("assemble"); _root.__enter__()
1698
1829
  _ph = sp.phase("assemble.read"); _ph.__enter__()
1699
- exists = conn.execute(
1700
- "SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
1701
- (session_id,)).fetchone()
1702
- if exists is None:
1830
+ rows = _claude_canonical_rows(conn, session_id)
1831
+ if rows is None:
1703
1832
  _ph.__exit__(None, None, None)
1704
1833
  _root.__exit__(None, None, None)
1705
1834
  return None
1835
+ _ph.set_count(len(rows)); _ph.__exit__(None, None, None)
1836
+
1837
+ # The three reads the fold would otherwise perform, hoisted here.
1838
+ # `_turn_cost_map` and `_turn_usage_map` are SEPARATE maps over the same
1839
+ # deduped session_entries row: cost may be a vendor-provided override, so
1840
+ # the two are never arithmetically equal and neither can be derived from
1841
+ # the other.
1842
+ turn_keys = _claude_turn_keys(rows)
1843
+ # #620 S3 R5: the two chunked `session_entries` reads are the database work
1844
+ # `assemble.cost` was created to measure, and hoisting them out of the fold
1845
+ # left that time attributed to the untraced remainder of the root. They get
1846
+ # their own phase here, carrying the same chunking meta, so
1847
+ # `dashboard-perf --trace on` still sees them. A DISTINCT name, because the
1848
+ # fold keeps its own `assemble.cost` phase over the stamping it still does
1849
+ # and two siblings sharing one name would collide in every by-name view.
1850
+ _ph = sp.phase("assemble.cost_read"); _ph.__enter__()
1851
+ _chunks = (len(turn_keys) + 399) // 400 if turn_keys else 0
1852
+ _ph.set_meta(turn_keys=len(turn_keys), cost_chunks=_chunks,
1853
+ usage_chunks=_chunks)
1854
+ _usage = _turn_usage_map(conn, turn_keys)
1855
+ _costs = _turn_cost_map(conn, turn_keys)
1856
+ _ph.__exit__(None, None, None)
1857
+ result = fold_claude_canonical(
1858
+ rows, _usage, _costs,
1859
+ allow_human_fallback=_reingest_pending(conn),
1860
+ )
1861
+ _root.__exit__(None, None, None)
1862
+ return result
1706
1863
 
1707
- # Pull the session ordered; dedup logical messages by (session_id, uuid),
1708
- # canonical row = earliest (timestamp_utc, id). Replays carry the original
1709
- # uuid, so the first occurrence in ascending order is canonical.
1710
- # #177: stop_reason / attribution_* are TAIL-APPENDED (indices 15/16/17)
1711
- # AFTER source_tool_use_id so the existing positional reads (incl.
1712
- # _latest(logical, 10/11) for cwd/git_branch, r[6] for model, [2] for ts)
1713
- # are all unchanged. Every unpacker below extends its tail in lockstep.
1714
- raw = conn.execute(
1715
- "SELECT id, uuid, timestamp_utc, entry_type, text, blocks_json, model, "
1716
- " msg_id, req_id, is_sidechain, cwd, git_branch, source_path, parent_uuid, "
1717
- " source_tool_use_id, stop_reason, attribution_skill, attribution_plugin "
1718
- "FROM conversation_messages WHERE session_id=? "
1719
- "ORDER BY timestamp_utc, id", (session_id,)).fetchall()
1720
- _ph.set_count(len(raw)); _ph.__exit__(None, None, None)
1721
1864
 
1722
- _ph = sp.phase("assemble.dedup"); _ph.__enter__()
1723
- seen_uuid = set()
1724
- logical = [] # canonical physical rows, in order
1725
- for row in raw:
1726
- u = row[1]
1727
- if u in seen_uuid:
1865
+ def dedupe_claude_uuid_rows(rows, uuid_of=None):
1866
+ """Keep the FIRST occurrence of each uuid, in the order given.
1867
+
1868
+ One statement of the rule, shared by ``fold_claude_canonical`` and by the
1869
+ #620 S3 fan-out join. ``conversation_messages`` is unique on
1870
+ ``(source_path, byte_offset)`` only, the dedicated ``idx_conv_session_uuid``
1871
+ index exists precisely because the canonical reader dedupes on the logical
1872
+ identity, and a ``--resume``d session replays its rows into a second file
1873
+ carrying the ORIGINAL uuid. Callers pass ONE session's rows in
1874
+ ``(timestamp_utc, id)`` order, which makes this the ``(session_id, uuid)``
1875
+ deduplication the S3 spec names.
1876
+
1877
+ ``uuid_of`` reads the uuid out of a caller's row shape; it defaults to the
1878
+ 18-column physical tuple's index 1.
1879
+ """
1880
+ read = uuid_of if uuid_of is not None else (lambda row: row[1])
1881
+ seen = set()
1882
+ kept = []
1883
+ for row in rows:
1884
+ key = read(row)
1885
+ if key in seen:
1728
1886
  continue
1729
- seen_uuid.add(u)
1730
- logical.append(row)
1887
+ seen.add(key)
1888
+ kept.append(row)
1889
+ return kept
1890
+
1891
+
1892
+ def fold_claude_canonical(rows, token_map, cost_map, *,
1893
+ allow_human_fallback=False):
1894
+ """The pure canonical assembly, over prefetched facts (#620 S3 X1).
1895
+
1896
+ THREE positional arguments, not two. ``_assemble_session`` consumes a
1897
+ separately priced cost map alongside the token map, so a fold taking only
1898
+ rows and tokens cannot reproduce it: cost may be the vendor-provided
1899
+ ``cost_usd_raw`` override with the token math bypassed, and the contract
1900
+ between the two maps is "same source row", never ``cost == f(tokens)``.
1901
+
1902
+ ``rows`` is the 18-column physical stream in (timestamp_utc, id) order;
1903
+ ``token_map`` and ``cost_map`` are keyed by ``(msg_id, req_id)``;
1904
+ ``allow_human_fallback`` is the read-time 005-reingest gate. This function
1905
+ opens no database and returns the same dict ``_assemble_session`` has
1906
+ always returned.
1907
+ """
1908
+ sp = _perf()
1909
+ # #620 S3 R8: this fold opens CHILD phases, and `_assemble_session` is no
1910
+ # longer its only caller — the diagnosis calls it directly. With no root
1911
+ # open those children would be emitted into whatever phase happened to be
1912
+ # on the stack, so open the `assemble` root here when nothing else has.
1913
+ _own_root = None
1914
+ if not sp.in_phase():
1915
+ _own_root = sp.phase("assemble"); _own_root.__enter__()
1916
+ _ph = sp.phase("assemble.dedup"); _ph.__enter__()
1917
+ logical = dedupe_claude_uuid_rows(rows) # canonical physical rows, in order
1731
1918
  _ph.set_count(len(logical)); _ph.__exit__(None, None, None)
1732
1919
 
1733
1920
  _ph = sp.phase("assemble.build"); _ph.__enter__()
@@ -1993,7 +2180,6 @@ def _assemble_session(conn, session_id):
1993
2180
  # + skill_name, so the client renders a collapsed skill/system-marker/context
1994
2181
  # disclosure instead of a "YOU" prompt. `text` is set to the rendered body
1995
2182
  # (the DB text column stays '' for FTS); genuine human turns are untouched.
1996
- allow_human_fallback = _reingest_pending(conn)
1997
2183
  for it in items:
1998
2184
  if it["kind"] in ("meta", "human"):
1999
2185
  # #188: a slash-command invocation carrying a real prompt in
@@ -2070,10 +2256,10 @@ def _assemble_session(conn, session_id):
2070
2256
  _cost_chunks = (_turn_keys + 399) // 400 if _turn_keys else 0
2071
2257
  _ph.set_meta(turn_keys=_turn_keys, cost_chunks=_cost_chunks,
2072
2258
  usage_chunks=_cost_chunks)
2073
- costs = _turn_cost_map(conn, list(turn_index))
2259
+ costs = cost_map
2074
2260
  # #177: per-turn token usage from the SAME deduped session_entries row cost
2075
2261
  # uses (a separate map; _turn_cost_map is unchanged for the search path).
2076
- usage = _turn_usage_map(conn, list(turn_index))
2262
+ usage = token_map
2077
2263
  # Stamp per-item cost first, then derive the header from the SUM of the
2078
2264
  # ROUNDED per-item assistant costs (M2) — so the §6.5 invariant
2079
2265
  # sum(items.cost_usd) == header cost_usd holds EXACTLY to 1e-9 by
@@ -2256,7 +2442,8 @@ def _assemble_session(conn, session_id):
2256
2442
  for it in items:
2257
2443
  it.pop("_source_tool_use_id", None)
2258
2444
 
2259
- _root.__exit__(None, None, None)
2445
+ if _own_root is not None:
2446
+ _own_root.__exit__(None, None, None)
2260
2447
  return {"items": items, "logical": logical,
2261
2448
  "subagent_meta": subagent_meta, "header_cost": header_cost}
2262
2449