cctally 1.31.0 → 1.32.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 +13 -0
- package/bin/_cctally_cache.py +12 -2
- package/bin/_cctally_db.py +35 -0
- package/bin/_lib_conversation.py +62 -4
- package/bin/_lib_conversation_query.py +139 -0
- package/bin/_lib_pricing.py +10 -2
- package/dashboard/static/assets/{index-CSCnAwDx.css → index-C6pXKeN4.css} +1 -1
- package/dashboard/static/assets/index-tToO8p8A.js +57 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-U6iDXqCy.js +0 -56
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.32.0] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation reader: subagent thread cards now show the subagent's kind (`SUBAGENT · <kind>`) and its result meta (tokens, duration, tool count, status) on modern transcripts; old transcripts keep the title-only card (#166).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **The dashboard conversation reader no longer attributes injected content to you.** When an assistant turn invoked a skill (via the `Skill` tool), the skill's full markdown body rendered as a large **"You"** prompt, as if you had typed the entire skill into the conversation — and the same applied to other harness-injected (`isMeta`) lines (git-context blocks, "Continue from where you left off.", pasted-image placeholders, slash-command plumbing). These now render as quiet, collapsed-by-default disclosures attributed correctly: a skill body becomes a `Skill content · <name>` pill (still rendered with full Markdown — headings, lists, syntax-highlighted code — when expanded), and other injected content becomes a neutral `Injected context` pill. Nothing injected is ever shown as a "You" turn, and these bodies no longer pollute conversation titles or full-text search. 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, so nothing is lost.
|
|
15
|
+
|
|
16
|
+
## [1.31.1] - 2026-06-09
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Embedded pricing for `claude-fable-5`.** Anthropic's Fable 5 model ships in the API but was absent from the embedded `CLAUDE_MODEL_PRICING` table, so any session run on it logged a one-shot "unrecognized model" warning and contributed **zero cost** to every cost computation (`report`, `daily`, `weekly`, `session`, `blocks`, `forecast`, the dashboard, etc.), silently undercounting spend for Fable 5 users. The model is now priced at $10 / $50 per million input / output tokens (cache-write and cache-read derived at the standard 1.25× / 0.1× multipliers; 1M context at standard pricing with no long-context premium), verified against the Anthropic pricing page. `cctally pricing-check` no longer reports `claude-fable-5` as an unpriced vendor model and `doctor pricing.coverage` stays clean; the pricing snapshot date was bumped to 2026-06-10 (#172).
|
|
20
|
+
|
|
8
21
|
## [1.31.0] - 2026-06-09
|
|
9
22
|
|
|
10
23
|
### Added
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -615,7 +615,10 @@ def sync_cache(
|
|
|
615
615
|
# Issue #164: a rebuild also clears + repopulates the message index
|
|
616
616
|
# id-aware via the normal offset-0 walk, so drop the 003 reingest
|
|
617
617
|
# flag too — the post-rebuild sync must not run a redundant
|
|
618
|
-
# (idempotent but wasteful) clear+backfill pass.
|
|
618
|
+
# (idempotent but wasteful) clear+backfill pass. #166 migration 004
|
|
619
|
+
# also sets this same flag (to land the subagent kind/meta fields);
|
|
620
|
+
# the rebuild re-derives those fields via the same offset-0 walk, so
|
|
621
|
+
# dropping the flag here covers the 004 reingest too.
|
|
619
622
|
conn.execute(
|
|
620
623
|
"DELETE FROM cache_meta WHERE key='conversation_reingest_pending'")
|
|
621
624
|
conn.commit()
|
|
@@ -666,7 +669,14 @@ def sync_cache(
|
|
|
666
669
|
# storm-free (#138); the offset-0 backfill walks every JSONL from 0;
|
|
667
670
|
# the flag is dropped LAST so a crash mid-walk re-runs cleanly on the
|
|
668
671
|
# next sync. Never on the rebuild path (which already wipes +
|
|
669
|
-
# repopulates the index id-aware via the normal walk).
|
|
672
|
+
# repopulates the index id-aware via the normal walk). #166 migration
|
|
673
|
+
# 004 reuses this SAME flag (to land the spawn subagent_type + the
|
|
674
|
+
# record-level toolUseResult agentId/meta on existing history): the
|
|
675
|
+
# offset-0 backfill re-parses every JSONL through the current parser,
|
|
676
|
+
# so those fields land here with zero new consumption code. Migration
|
|
677
|
+
# 005 reuses it again to reclassify injected isMeta rows from
|
|
678
|
+
# entry_type='human' to 'meta' (so the reader stops attributing skill
|
|
679
|
+
# bodies / git-context to the user).
|
|
670
680
|
try:
|
|
671
681
|
_reingest = conn.execute(
|
|
672
682
|
"SELECT 1 FROM cache_meta "
|
package/bin/_cctally_db.py
CHANGED
|
@@ -3009,6 +3009,41 @@ def _003_conversation_reingest_tool_ids(conn: sqlite3.Connection) -> None:
|
|
|
3009
3009
|
conn.commit()
|
|
3010
3010
|
|
|
3011
3011
|
|
|
3012
|
+
@cache_migration("004_conversation_reingest_subagent_kind")
|
|
3013
|
+
def _004_conversation_reingest_subagent_kind(conn: sqlite3.Connection) -> None:
|
|
3014
|
+
"""Flag-only re-ingest of conversation_messages so the spawn ``subagent_type``
|
|
3015
|
+
and the record-level ``toolUseResult`` agentId/meta land on existing history
|
|
3016
|
+
(#166). REUSES 003's ``conversation_reingest_pending`` flag — sync_cache
|
|
3017
|
+
already consumes it (clear + offset-0 backfill under the cache.db.lock flock),
|
|
3018
|
+
and the offset-0 walk re-parses every JSONL through the current parser, so the
|
|
3019
|
+
new fields land with zero new consumption code. A distinct ``schema_migrations``
|
|
3020
|
+
marker is what triggers this reingest on an existing install that already has
|
|
3021
|
+
003 applied; the flag is the generic 'conversation index needs a full
|
|
3022
|
+
clear+reingest' signal. Central stamp via the dispatcher (issue #140); a fresh
|
|
3023
|
+
install stamps it without running (empty table -> the flag, if ever set, is a
|
|
3024
|
+
harmless no-op)."""
|
|
3025
|
+
_set_cache_meta(conn, "conversation_reingest_pending", "1")
|
|
3026
|
+
conn.commit()
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
@cache_migration("005_conversation_reingest_meta")
|
|
3030
|
+
def _005_conversation_reingest_meta(conn: sqlite3.Connection) -> None:
|
|
3031
|
+
"""Flag-only re-ingest of conversation_messages so injected ``isMeta`` user
|
|
3032
|
+
lines (skill bodies, git-context, "Continue…", image placeholders,
|
|
3033
|
+
slash-command caveats) are reclassified from ``entry_type='human'`` to the
|
|
3034
|
+
new ``'meta'`` value and stop rendering as "YOU" prompts in the reader.
|
|
3035
|
+
REUSES 003's ``conversation_reingest_pending`` flag exactly like 004 — the
|
|
3036
|
+
offset-0 walk in sync_cache (clear + backfill under the cache.db.lock flock)
|
|
3037
|
+
re-parses every JSONL through the now-meta-aware parser, so the new
|
|
3038
|
+
classification lands with zero new consumption code. A distinct
|
|
3039
|
+
``schema_migrations`` marker is what triggers this reingest on installs that
|
|
3040
|
+
already have 003/004 applied. Central stamp via the dispatcher (issue #140);
|
|
3041
|
+
a fresh install stamps it without running (empty table -> the flag, if ever
|
|
3042
|
+
set, is a harmless no-op)."""
|
|
3043
|
+
_set_cache_meta(conn, "conversation_reingest_pending", "1")
|
|
3044
|
+
conn.commit()
|
|
3045
|
+
|
|
3046
|
+
|
|
3012
3047
|
# === Region 7d: Stats migration 008_recompute_weekly_cost_snapshots_dedup_fix ===
|
|
3013
3048
|
|
|
3014
3049
|
@stats_migration("008_recompute_weekly_cost_snapshots_dedup_fix")
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -13,6 +13,7 @@ from dataclasses import dataclass
|
|
|
13
13
|
HUMAN = "human"
|
|
14
14
|
ASSISTANT = "assistant"
|
|
15
15
|
TOOL_RESULT = "tool_result"
|
|
16
|
+
META = "meta"
|
|
16
17
|
|
|
17
18
|
_TOOL_RESULT_CAP = 4000 # chars; full text always re-derivable from JSONL
|
|
18
19
|
|
|
@@ -91,11 +92,26 @@ def _normalize(obj, t, offset):
|
|
|
91
92
|
entry_type = ASSISTANT
|
|
92
93
|
elif any(b["kind"] == "tool_result" for b in blocks):
|
|
93
94
|
entry_type = TOOL_RESULT
|
|
95
|
+
_attach_subagent_result(blocks, obj) # #166: record-level toolUseResult
|
|
94
96
|
# tool_result rows are stored but NOT indexed as prose (spec §2). A
|
|
95
97
|
# user line that mixes a text block with a tool_result block must not
|
|
96
98
|
# leak that text into the FTS index; the full content stays in
|
|
97
99
|
# blocks_json for rendering.
|
|
98
100
|
text = ""
|
|
101
|
+
elif obj.get("isMeta"):
|
|
102
|
+
# Injected, harness-authored content carried as a user line: skill
|
|
103
|
+
# bodies (Skill tool + SessionStart), git-context blocks, "Continue
|
|
104
|
+
# from where you left off.", pasted-image placeholders, slash-command
|
|
105
|
+
# caveats, check-review "## Task" blocks. The user did NOT type these,
|
|
106
|
+
# so the reader must not render them as a "YOU" prompt. We classify
|
|
107
|
+
# them META here; text="" keeps the body out of the FTS index and out
|
|
108
|
+
# of title derivation (which filters entry_type='human'), exactly like
|
|
109
|
+
# tool_result. The body survives in blocks_json; the skill-vs-context
|
|
110
|
+
# discrimination is a read-time concern (the query kernel, keyed on the
|
|
111
|
+
# body). Ordered AFTER tool_result so an isMeta line that also carries a
|
|
112
|
+
# tool_result block still folds as a result.
|
|
113
|
+
entry_type = META
|
|
114
|
+
text = ""
|
|
99
115
|
else:
|
|
100
116
|
entry_type = HUMAN
|
|
101
117
|
is_asst = t == "assistant"
|
|
@@ -135,10 +151,15 @@ def _blocks_and_text(content):
|
|
|
135
151
|
elif bt == "thinking":
|
|
136
152
|
blocks.append({"kind": "thinking", "text": b.get("thinking", "") or ""})
|
|
137
153
|
elif bt == "tool_use":
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
block = {"kind": "tool_use", "name": b.get("name"),
|
|
155
|
+
"input_summary": _summarize(b.get("input")),
|
|
156
|
+
"id": b.get("id"),
|
|
157
|
+
"preview": tool_preview(b.get("name"), b.get("input"))}
|
|
158
|
+
inp = b.get("input")
|
|
159
|
+
st = inp.get("subagent_type") if isinstance(inp, dict) else None
|
|
160
|
+
if isinstance(st, str) and st: # #166: spawn kind (Agent/Task)
|
|
161
|
+
block["subagent_type"] = st
|
|
162
|
+
blocks.append(block)
|
|
142
163
|
elif bt == "tool_result":
|
|
143
164
|
raw = _stringify(b.get("content"))
|
|
144
165
|
blocks.append({"kind": "tool_result", "text": raw[:_TOOL_RESULT_CAP],
|
|
@@ -152,6 +173,43 @@ def _blocks_and_text(content):
|
|
|
152
173
|
return blocks, "\n".join(t for t in texts if t)
|
|
153
174
|
|
|
154
175
|
|
|
176
|
+
_SUBAGENT_META_KEYS = (
|
|
177
|
+
("totalTokens", "total_tokens"),
|
|
178
|
+
("totalDurationMs", "total_duration_ms"),
|
|
179
|
+
("totalToolUseCount", "total_tool_use_count"),
|
|
180
|
+
("status", "status"),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _attach_subagent_result(blocks, obj):
|
|
185
|
+
"""Attach the record-level ``toolUseResult`` agentId + meta (#166) onto the
|
|
186
|
+
tool_result block, but ONLY when the record carries exactly one tool_result
|
|
187
|
+
block — the unambiguous subagent-spawn result shape. Zero or >1 tool_result
|
|
188
|
+
blocks: no-op (the kernel then degrades that subagent card to title-only).
|
|
189
|
+
The kind (subagent_type) is captured separately on the spawn tool_use block;
|
|
190
|
+
the kernel joins the two on tool_use_id. ``agentId`` == the subagent file's
|
|
191
|
+
``_subagent_key``. Meta keys are normalized to snake_case here so the kernel
|
|
192
|
+
stays a pure pass-through (same posture as is_error / tool_use_id)."""
|
|
193
|
+
tur = obj.get("toolUseResult")
|
|
194
|
+
if not isinstance(tur, dict):
|
|
195
|
+
return
|
|
196
|
+
agent_id = tur.get("agentId")
|
|
197
|
+
if not isinstance(agent_id, str) or not agent_id:
|
|
198
|
+
return
|
|
199
|
+
results = [b for b in blocks if b.get("kind") == "tool_result"]
|
|
200
|
+
if len(results) != 1:
|
|
201
|
+
return
|
|
202
|
+
block = results[0]
|
|
203
|
+
block["agent_id"] = agent_id
|
|
204
|
+
meta = {}
|
|
205
|
+
for src, dst in _SUBAGENT_META_KEYS:
|
|
206
|
+
v = tur.get(src)
|
|
207
|
+
if v is not None:
|
|
208
|
+
meta[dst] = v
|
|
209
|
+
if meta:
|
|
210
|
+
block["subagent_meta"] = meta
|
|
211
|
+
|
|
212
|
+
|
|
155
213
|
def _stringify(c):
|
|
156
214
|
if isinstance(c, str):
|
|
157
215
|
return c
|
|
@@ -55,6 +55,84 @@ def _title_from_text(text) -> str:
|
|
|
55
55
|
return ""
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# Every Claude Code skill body (Skill-tool-invoked AND SessionStart-injected)
|
|
59
|
+
# opens with this preamble line — the entry_type-independent skill discriminator.
|
|
60
|
+
_SKILL_PREAMBLE = "Base directory for this skill:"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _first_nonblank_line(text) -> str:
|
|
64
|
+
"""First non-blank, stripped line of `text` ('' if none). Skill detection
|
|
65
|
+
keys on this (NOT a strict body.startswith) so a leading blank text block
|
|
66
|
+
can't hide the preamble (Codex P2.2)."""
|
|
67
|
+
for line in (text or "").split("\n"):
|
|
68
|
+
s = line.strip()
|
|
69
|
+
if s:
|
|
70
|
+
return s
|
|
71
|
+
return ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _skill_name_from_preamble(first_line) -> "str | None":
|
|
75
|
+
"""`brainstorming` from `Base directory for this skill: …/skills/brainstorming`.
|
|
76
|
+
Basename of the path after the first ':'; None on an empty/degenerate path
|
|
77
|
+
(Codex P2.2) so the client renders a name-less 'Skill content' rather than a
|
|
78
|
+
dangling separator."""
|
|
79
|
+
_, _, rest = first_line.partition(":")
|
|
80
|
+
path = rest.strip().rstrip("/")
|
|
81
|
+
return os.path.basename(path) or None if path else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _join_text_blocks(blocks) -> str:
|
|
85
|
+
"""Rejoin a row's text-block bodies the way the parser's _blocks_and_text did
|
|
86
|
+
('\\n'-joined). A true meta row carries text='' (parser) with the body here in
|
|
87
|
+
blocks; a not-yet-reingested human row carries the body in its text column —
|
|
88
|
+
_meta_classify reads whichever is populated."""
|
|
89
|
+
if not blocks:
|
|
90
|
+
return ""
|
|
91
|
+
return "\n".join(b.get("text", "") or "" for b in blocks if b.get("kind") == "text")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _reingest_pending(conn) -> bool:
|
|
95
|
+
"""True iff migration 005's ``conversation_reingest_pending`` flag is still
|
|
96
|
+
set — i.e. existing history has NOT yet been re-ingested under the meta-aware
|
|
97
|
+
parser. While pending, a stale ``human`` row may actually be an injected
|
|
98
|
+
skill body, so the read-time skill fallback (rendering + title-skip) is
|
|
99
|
+
active. Once sync consumes the flag (skill bodies become true ``meta`` rows),
|
|
100
|
+
the fallback turns OFF — so a genuine human prompt that merely *starts with*
|
|
101
|
+
the skill preamble is never misclassified as a collapsed skill pill (Codex
|
|
102
|
+
code-review P1). Missing table / degraded DB -> treated as not pending."""
|
|
103
|
+
try:
|
|
104
|
+
return conn.execute(
|
|
105
|
+
"SELECT 1 FROM cache_meta WHERE key='conversation_reingest_pending'"
|
|
106
|
+
).fetchone() is not None
|
|
107
|
+
except sqlite3.OperationalError:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _meta_classify(item, allow_human_fallback):
|
|
112
|
+
"""Classify an injected item by its BODY, returning ``(meta_kind, skill_name,
|
|
113
|
+
body)`` or ``None`` to leave it a genuine human turn.
|
|
114
|
+
|
|
115
|
+
- skill: first non-blank line is the skill preamble. Fires for a true 'meta'
|
|
116
|
+
row ALWAYS; for a 'human' row ONLY when ``allow_human_fallback`` is set (the
|
|
117
|
+
pre-reingest window — see _reingest_pending). After the reingest a 'human'
|
|
118
|
+
row keeping the preamble is a real user prompt, so it stays a "You" turn
|
|
119
|
+
rather than being hidden in a collapsed skill pill (Codex code-review P1).
|
|
120
|
+
- command/context: ONLY for a true 'meta' row (slash-command plumbing vs the
|
|
121
|
+
rest). A 'human' row that is not a skill body stays human — generic injected
|
|
122
|
+
context can't be recovered read-time without isMeta; it lands on the next
|
|
123
|
+
sync-triggered reingest."""
|
|
124
|
+
is_meta = item["kind"] == "meta"
|
|
125
|
+
body = item.get("text") or _join_text_blocks(item.get("blocks"))
|
|
126
|
+
first = _first_nonblank_line(body)
|
|
127
|
+
if first.startswith(_SKILL_PREAMBLE) and (is_meta or allow_human_fallback):
|
|
128
|
+
return ("skill", _skill_name_from_preamble(first), body)
|
|
129
|
+
if not is_meta:
|
|
130
|
+
return None
|
|
131
|
+
if _is_system_marker(body):
|
|
132
|
+
return ("command", None, body)
|
|
133
|
+
return ("context", None, body)
|
|
134
|
+
|
|
135
|
+
|
|
58
136
|
def _session_titles_map(conn, session_ids):
|
|
59
137
|
"""{sid: title} for the first non-marker, non-blank MAIN-session human line
|
|
60
138
|
per session (read-time, no migration). Windowed to the earliest 12 human
|
|
@@ -68,6 +146,14 @@ def _session_titles_map(conn, session_ids):
|
|
|
68
146
|
if not session_ids:
|
|
69
147
|
return {}
|
|
70
148
|
titles = {}
|
|
149
|
+
# While 005's reingest is pending, a stale `human` row may actually be an
|
|
150
|
+
# injected skill body (a SessionStart skill can even lead the transcript) —
|
|
151
|
+
# skip those as title candidates so the rail never shows "Base directory for
|
|
152
|
+
# this skill: …" until the next sync reclassifies them to `meta` (which the
|
|
153
|
+
# entry_type='human' filter below then excludes). Gated on the flag for the
|
|
154
|
+
# same reason as the render fallback: a genuine post-reingest human prompt
|
|
155
|
+
# starting with the preamble stays a normal title (Codex code-review P2).
|
|
156
|
+
skip_skill_titles = _reingest_pending(conn)
|
|
71
157
|
ph = ",".join("?" for _ in session_ids)
|
|
72
158
|
rows = conn.execute(
|
|
73
159
|
"SELECT session_id, text FROM ("
|
|
@@ -85,6 +171,8 @@ def _session_titles_map(conn, session_ids):
|
|
|
85
171
|
continue # already resolved to the first non-marker
|
|
86
172
|
if _is_system_marker(text):
|
|
87
173
|
continue
|
|
174
|
+
if skip_skill_titles and _first_nonblank_line(text).startswith(_SKILL_PREAMBLE):
|
|
175
|
+
continue
|
|
88
176
|
t = _title_from_text(text)
|
|
89
177
|
if t:
|
|
90
178
|
titles[sid] = t
|
|
@@ -355,6 +443,37 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
355
443
|
if etype == "assistant": # null-msg_id assistant: index its uses too
|
|
356
444
|
_index_tool_uses(it)
|
|
357
445
|
|
|
446
|
+
# ---- Subagent-kind correlation (#166); MUST run before Phase 2 fold ----
|
|
447
|
+
# Reads AND strips (pop) the parser-only keys in one pass, so the returned
|
|
448
|
+
# tool_call/tool_result block shapes are unchanged — the only new output is
|
|
449
|
+
# the top-level subagent_meta map (no undocumented block keys leak). Join is
|
|
450
|
+
# spawn tool_use id <-> tool_result tool_use_id; agent_id == subagent_key.
|
|
451
|
+
spawn_kind = {} # tool_use id -> subagent_type
|
|
452
|
+
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
453
|
+
for it in items:
|
|
454
|
+
for b in it["blocks"]:
|
|
455
|
+
k = b.get("kind")
|
|
456
|
+
if k == "tool_use":
|
|
457
|
+
st = b.pop("subagent_type", None)
|
|
458
|
+
if st and b.get("id") is not None:
|
|
459
|
+
spawn_kind[b["id"]] = st
|
|
460
|
+
elif k == "tool_result":
|
|
461
|
+
aid = b.pop("agent_id", None)
|
|
462
|
+
meta = b.pop("subagent_meta", None)
|
|
463
|
+
if aid and b.get("tool_use_id") is not None:
|
|
464
|
+
agent_link[b["tool_use_id"]] = (aid, meta or {})
|
|
465
|
+
subagent_meta = {}
|
|
466
|
+
for _tuid, _kind in spawn_kind.items():
|
|
467
|
+
_link = agent_link.get(_tuid)
|
|
468
|
+
if _link is None:
|
|
469
|
+
continue # spawn with no (yet) result -> title-only
|
|
470
|
+
_aid, _raw = _link
|
|
471
|
+
_entry = {"kind": _kind}
|
|
472
|
+
for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count", "status"):
|
|
473
|
+
if _raw.get(_f) is not None:
|
|
474
|
+
_entry[_f] = _raw[_f]
|
|
475
|
+
subagent_meta[_aid] = _entry # agent_id == subagent_key
|
|
476
|
+
|
|
358
477
|
# ---- Phase 2: fold each tool_result item into its owning assistant item ----
|
|
359
478
|
drop = set() # id() of folded placeholder items
|
|
360
479
|
for tr in tool_result_items:
|
|
@@ -398,6 +517,24 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
398
517
|
b["tool_use_id"] = b.pop("id", None)
|
|
399
518
|
b.setdefault("result", None)
|
|
400
519
|
|
|
520
|
+
# ---- Phase 4: classify injected meta items (skill / command / context) ----
|
|
521
|
+
# `meta` rows (the parser's isMeta classification) AND — only while the 005
|
|
522
|
+
# reingest is still pending — not-yet-reingested `human` rows whose body is a
|
|
523
|
+
# skill preamble (the read-time fallback) become kind='meta' with a meta_kind
|
|
524
|
+
# + skill_name, so the client renders a collapsed skill/system-marker/context
|
|
525
|
+
# disclosure instead of a "YOU" prompt. `text` is set to the rendered body
|
|
526
|
+
# (the DB text column stays '' for FTS); genuine human turns are untouched.
|
|
527
|
+
allow_human_fallback = _reingest_pending(conn)
|
|
528
|
+
for it in items:
|
|
529
|
+
if it["kind"] in ("meta", "human"):
|
|
530
|
+
cls = _meta_classify(it, allow_human_fallback)
|
|
531
|
+
if cls is not None:
|
|
532
|
+
meta_kind, skill_name, body = cls
|
|
533
|
+
it["kind"] = "meta"
|
|
534
|
+
it["meta_kind"] = meta_kind
|
|
535
|
+
it["skill_name"] = skill_name
|
|
536
|
+
it["text"] = body
|
|
537
|
+
|
|
401
538
|
costs = _turn_cost_map(conn, list(turn_index))
|
|
402
539
|
# Stamp per-item cost first, then derive the header from the SUM of the
|
|
403
540
|
# ROUNDED per-item assistant costs (M2) — so the §6.5 invariant
|
|
@@ -436,6 +573,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
436
573
|
"cost_usd": header_cost,
|
|
437
574
|
"models": sorted({r[6] for r in logical if r[6]}),
|
|
438
575
|
"items": [],
|
|
576
|
+
"subagent_meta": subagent_meta,
|
|
439
577
|
"page": {"next_after": None, "has_more": False},
|
|
440
578
|
}
|
|
441
579
|
page = items[start:start + limit]
|
|
@@ -460,6 +598,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
460
598
|
"cost_usd": header_cost,
|
|
461
599
|
"models": models,
|
|
462
600
|
"items": page,
|
|
601
|
+
"subagent_meta": subagent_meta,
|
|
463
602
|
"page": {"next_after": next_after, "has_more": has_more},
|
|
464
603
|
}
|
|
465
604
|
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -49,7 +49,7 @@ def _chip_for_model(name: str) -> str:
|
|
|
49
49
|
# Date the embedded pricing snapshots below were last verified against
|
|
50
50
|
# vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
|
|
51
51
|
# is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
|
|
52
|
-
PRICING_SNAPSHOT_DATE = "2026-
|
|
52
|
+
PRICING_SNAPSHOT_DATE = "2026-06-10"
|
|
53
53
|
PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
|
|
54
54
|
|
|
55
55
|
# Canonical machine-readable pricing source (Claude values + Codex values).
|
|
@@ -67,9 +67,11 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = []
|
|
|
67
67
|
|
|
68
68
|
# Anthropic API pricing snapshot:
|
|
69
69
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
70
|
-
# - Captured: 2026-
|
|
70
|
+
# - Captured: 2026-06-10 (see PRICING_SNAPSHOT_DATE)
|
|
71
71
|
# - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
|
|
72
72
|
# update in PRs touching this table.
|
|
73
|
+
# 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
|
|
74
|
+
# long-context premium) — issue #172.
|
|
73
75
|
CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
74
76
|
"claude-3-5-haiku-20241022": {
|
|
75
77
|
"input_cost_per_token": 8e-07,
|
|
@@ -147,6 +149,12 @@ CLAUDE_MODEL_PRICING: dict[str, dict[str, Any]] = {
|
|
|
147
149
|
"cache_creation_input_token_cost_above_200k_tokens": 7.5e-06,
|
|
148
150
|
"cache_read_input_token_cost_above_200k_tokens": 6e-07,
|
|
149
151
|
},
|
|
152
|
+
"claude-fable-5": {
|
|
153
|
+
"input_cost_per_token": 1e-05,
|
|
154
|
+
"output_cost_per_token": 5e-05,
|
|
155
|
+
"cache_creation_input_token_cost": 1.25e-05,
|
|
156
|
+
"cache_read_input_token_cost": 1e-06,
|
|
157
|
+
},
|
|
150
158
|
"claude-haiku-4-5": {
|
|
151
159
|
"input_cost_per_token": 1e-06,
|
|
152
160
|
"output_cost_per_token": 5e-06,
|