cctally 1.112.0 → 1.114.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 +27 -0
- package/README.md +4 -5
- package/bin/_cctally_cache.py +1683 -485
- package/bin/_cctally_core.py +223 -1
- package/bin/_cctally_dashboard.py +96 -18
- package/bin/_cctally_dashboard_perf.py +128 -9
- package/bin/_cctally_dashboard_sources.py +267 -204
- package/bin/_cctally_db.py +861 -18
- package/bin/_cctally_diagnosis_sources.py +22 -4
- package/bin/_cctally_doctor.py +136 -18
- package/bin/_cctally_five_hour.py +2 -2
- package/bin/_cctally_forecast.py +27 -6
- package/bin/_cctally_journal.py +67 -5
- package/bin/_cctally_milestone_history.py +82 -9
- package/bin/_cctally_percent_breakdown.py +3 -2
- package/bin/_cctally_quota.py +201 -33
- package/bin/_cctally_record.py +14 -0
- package/bin/_cctally_rederive.py +23 -9
- package/bin/_cctally_source_analytics.py +60 -15
- package/bin/_cctally_statusline.py +17 -0
- package/bin/_cctally_store.py +80 -5
- package/bin/_cctally_tui.py +4 -3
- package/bin/_cctally_weekrefs.py +128 -21
- package/bin/_lib_cache_report.py +35 -6
- package/bin/_lib_codex_conversation.py +32 -0
- package/bin/_lib_codex_conversation_query.py +164 -30
- package/bin/_lib_conversation_query.py +95 -56
- package/bin/_lib_conversation_retention.py +1605 -640
- package/bin/_lib_cost_provenance.py +53 -2
- package/bin/_lib_doctor.py +263 -69
- package/bin/_lib_fmt.py +11 -0
- package/bin/_lib_merge_gate.py +469 -51
- package/bin/_lib_perf.py +27 -1
- package/bin/_lib_pricing.py +209 -8
- package/bin/_lib_pricing_check.py +20 -4
- package/bin/_lib_reclaim_planner.py +792 -0
- package/bin/_lib_rederive.py +225 -77
- package/bin/_lib_snapshot_cache.py +21 -0
- package/bin/_lib_sqlite_close.py +81 -0
- package/bin/_lib_subscription_weeks.py +100 -17
- package/bin/_lib_tick_stats.py +217 -17
- package/bin/_lib_wal_checkpoint.py +1077 -0
- package/bin/_lib_write_budget.py +483 -0
- package/bin/_lib_write_io.py +703 -0
- package/bin/cctally +8 -0
- package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
- package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
- package/dashboard/static/assets/index-BKITk1CT.css +1 -0
- package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
- package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
- package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
|
@@ -353,13 +353,19 @@ def _load_conversation_index_rows(
|
|
|
353
353
|
"""
|
|
354
354
|
rows: list = []
|
|
355
355
|
detail_bytes: dict[tuple[str, int], int] = {}
|
|
356
|
+
# #901 Amendment 19 T1: index order, each timestamp tie run then put in
|
|
357
|
+
# physical order in Python (``kern.in_physical_order``), not a sorter.
|
|
356
358
|
for (source_path, line_offset, timestamp_utc, turn_id, call_id, kind,
|
|
357
359
|
event_type, record_family, model, content_digest, content_len,
|
|
358
|
-
detail_json, detail_len) in
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
360
|
+
detail_json, detail_len) in kern.in_physical_order(
|
|
361
|
+
conn.execute(
|
|
362
|
+
"SELECT " + _NARROW_ROW_COLS + " FROM codex_conversation_messages "
|
|
363
|
+
"WHERE conversation_key = ? "
|
|
364
|
+
"ORDER BY timestamp_utc, id",
|
|
365
|
+
(conversation_key,),
|
|
366
|
+
),
|
|
367
|
+
timestamp=lambda row: row[2],
|
|
368
|
+
position=lambda row: (row[0], row[1]),
|
|
363
369
|
):
|
|
364
370
|
rows.append(kern.CodexNormalizedRow(
|
|
365
371
|
conversation_key=conversation_key, source_root_key="",
|
|
@@ -3872,34 +3878,165 @@ def _project_find_row(row, *, payload: dict | None = None, block: dict | None =
|
|
|
3872
3878
|
return project_plain((RenderLeaf("t0", text),))
|
|
3873
3879
|
|
|
3874
3880
|
|
|
3881
|
+
#: Every ``codex_find_projection`` column in one fixed order: the builder's row
|
|
3882
|
+
#: tuples, the stored-row read and the write statements below all use it.
|
|
3883
|
+
#: ``(message_id, surface)`` (positions 0 and 5) is the table's primary key;
|
|
3884
|
+
#: everything else is an attribute the difference compares (#901 §5.3b).
|
|
3885
|
+
_FIND_PROJECTION_COLUMNS = (
|
|
3886
|
+
"message_id,conversation_key,item_key,block_key,container_block_key,"
|
|
3887
|
+
"surface,render_order,projected_text,leaves_json,disclosure_json,"
|
|
3888
|
+
"projection_version"
|
|
3889
|
+
)
|
|
3890
|
+
_FIND_PROJECTION_KEY = (0, 5)
|
|
3891
|
+
_FIND_PROJECTION_ORDER = 6
|
|
3892
|
+
|
|
3893
|
+
_FIND_PROJECTION_INSERT_SQL = (
|
|
3894
|
+
"INSERT INTO codex_find_projection "
|
|
3895
|
+
"(" + _FIND_PROJECTION_COLUMNS + ") "
|
|
3896
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?)"
|
|
3897
|
+
)
|
|
3898
|
+
#: An attribute change that keeps the row's place: the ordering index
|
|
3899
|
+
#: ``(conversation_key, render_order, message_id, surface)`` is untouched.
|
|
3900
|
+
_FIND_PROJECTION_UPDATE_IN_PLACE_SQL = (
|
|
3901
|
+
"UPDATE codex_find_projection SET item_key=?,block_key=?,"
|
|
3902
|
+
"container_block_key=?,projected_text=?,leaves_json=?,disclosure_json=?,"
|
|
3903
|
+
"projection_version=? WHERE message_id=? AND surface=?"
|
|
3904
|
+
)
|
|
3905
|
+
#: A moved row (its dense ``render_order`` shifted): the index entry moves too.
|
|
3906
|
+
_FIND_PROJECTION_UPDATE_MOVED_SQL = (
|
|
3907
|
+
"UPDATE codex_find_projection SET conversation_key=?,item_key=?,"
|
|
3908
|
+
"block_key=?,container_block_key=?,render_order=?,projected_text=?,"
|
|
3909
|
+
"leaves_json=?,disclosure_json=?,projection_version=? "
|
|
3910
|
+
"WHERE message_id=? AND surface=?"
|
|
3911
|
+
)
|
|
3912
|
+
_FIND_PROJECTION_DELETE_SQL = (
|
|
3913
|
+
"DELETE FROM codex_find_projection WHERE message_id=? AND surface=?"
|
|
3914
|
+
)
|
|
3915
|
+
_FIND_PROJECTION_GENERATION_SQL = (
|
|
3916
|
+
"INSERT INTO cache_meta(key,value) VALUES"
|
|
3917
|
+
"('codex_find_projection_generation','1') "
|
|
3918
|
+
"ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1"
|
|
3919
|
+
)
|
|
3920
|
+
|
|
3921
|
+
|
|
3922
|
+
def _write_codex_find_projection_difference(
|
|
3923
|
+
conn: sqlite3.Connection,
|
|
3924
|
+
conversation_key: str,
|
|
3925
|
+
built: list[tuple],
|
|
3926
|
+
) -> int:
|
|
3927
|
+
"""Make the stored projection of one conversation equal ``built``.
|
|
3928
|
+
|
|
3929
|
+
``built`` is the complete canonical projection in the builder's order. Both
|
|
3930
|
+
sides are keyed by the primary key ``(message_id, surface)`` and compared
|
|
3931
|
+
on every other column; only the difference is written. Returns the number
|
|
3932
|
+
of rows deleted, inserted or updated (0 for an unchanged conversation).
|
|
3933
|
+
"""
|
|
3934
|
+
built_by_key = {
|
|
3935
|
+
(row[_FIND_PROJECTION_KEY[0]], row[_FIND_PROJECTION_KEY[1]]): row
|
|
3936
|
+
for row in built
|
|
3937
|
+
}
|
|
3938
|
+
if len(built_by_key) != len(built):
|
|
3939
|
+
# The whole-rewrite writer raised on a duplicate key at INSERT time;
|
|
3940
|
+
# a difference must not silently keep only one of the two rows.
|
|
3941
|
+
raise sqlite3.IntegrityError(
|
|
3942
|
+
"UNIQUE constraint failed: codex_find_projection.message_id, "
|
|
3943
|
+
"codex_find_projection.surface"
|
|
3944
|
+
)
|
|
3945
|
+
stored = {
|
|
3946
|
+
(row[_FIND_PROJECTION_KEY[0]], row[_FIND_PROJECTION_KEY[1]]): row
|
|
3947
|
+
for row in conn.execute(
|
|
3948
|
+
"SELECT " + _FIND_PROJECTION_COLUMNS + " FROM codex_find_projection "
|
|
3949
|
+
"WHERE conversation_key=?",
|
|
3950
|
+
(conversation_key,),
|
|
3951
|
+
)
|
|
3952
|
+
}
|
|
3953
|
+
deletes = sorted(key for key in stored if key not in built_by_key)
|
|
3954
|
+
inserts: list[tuple] = []
|
|
3955
|
+
in_place: list[tuple] = []
|
|
3956
|
+
moved: list[tuple] = []
|
|
3957
|
+
for key, row in built_by_key.items():
|
|
3958
|
+
old = stored.get(key)
|
|
3959
|
+
if old is None:
|
|
3960
|
+
inserts.append(row)
|
|
3961
|
+
continue
|
|
3962
|
+
if tuple(old) == row:
|
|
3963
|
+
continue
|
|
3964
|
+
message_id, conversation, item, block, container, surface, order, *rest = row
|
|
3965
|
+
if old[1] == conversation and old[_FIND_PROJECTION_ORDER] == order:
|
|
3966
|
+
in_place.append((item, block, container, *rest, message_id, surface))
|
|
3967
|
+
else:
|
|
3968
|
+
moved.append(
|
|
3969
|
+
(conversation, item, block, container, order, *rest,
|
|
3970
|
+
message_id, surface)
|
|
3971
|
+
)
|
|
3972
|
+
if deletes:
|
|
3973
|
+
conn.executemany(_FIND_PROJECTION_DELETE_SQL, deletes)
|
|
3974
|
+
if in_place:
|
|
3975
|
+
conn.executemany(_FIND_PROJECTION_UPDATE_IN_PLACE_SQL, in_place)
|
|
3976
|
+
if moved:
|
|
3977
|
+
conn.executemany(_FIND_PROJECTION_UPDATE_MOVED_SQL, moved)
|
|
3978
|
+
if inserts:
|
|
3979
|
+
conn.executemany(_FIND_PROJECTION_INSERT_SQL, inserts)
|
|
3980
|
+
return len(deletes) + len(inserts) + len(in_place) + len(moved)
|
|
3981
|
+
|
|
3982
|
+
|
|
3983
|
+
def _load_conversation_rows(
|
|
3984
|
+
conn: sqlite3.Connection, conversation_key: str,
|
|
3985
|
+
) -> list:
|
|
3986
|
+
"""A conversation's WIDE normalized rows in physical order:
|
|
3987
|
+
``(timestamp_utc, source_path, line_offset)``.
|
|
3988
|
+
|
|
3989
|
+
#901 Amendment 19 T1: read in ``(timestamp_utc, id)`` index order and each
|
|
3990
|
+
timestamp tie run put in physical order by ``kern.in_physical_order``; the
|
|
3991
|
+
old ``ORDER BY`` left a partial sorter over every tie run, and one
|
|
3992
|
+
conversation is not a bound."""
|
|
3993
|
+
return kern.in_physical_order(
|
|
3994
|
+
(
|
|
3995
|
+
kern.CodexNormalizedRow(*row)
|
|
3996
|
+
for row in conn.execute(
|
|
3997
|
+
"SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
|
|
3998
|
+
"WHERE conversation_key=? "
|
|
3999
|
+
"ORDER BY timestamp_utc,id",
|
|
4000
|
+
(conversation_key,),
|
|
4001
|
+
)
|
|
4002
|
+
),
|
|
4003
|
+
timestamp=lambda row: row.timestamp_utc,
|
|
4004
|
+
position=lambda row: (row.source_path, row.line_offset),
|
|
4005
|
+
)
|
|
4006
|
+
|
|
4007
|
+
|
|
3875
4008
|
def materialize_codex_find_projection(
|
|
3876
4009
|
conn: sqlite3.Connection,
|
|
3877
4010
|
conversation_keys,
|
|
4011
|
+
*,
|
|
4012
|
+
cascaded: bool = False,
|
|
3878
4013
|
) -> None:
|
|
3879
|
-
"""
|
|
4014
|
+
"""Bring #482 projection rows of the affected conversations up to date.
|
|
3880
4015
|
|
|
3881
4016
|
The existing item/block builder is the only authority for native folds.
|
|
3882
4017
|
Every searchable physical row keeps its own block key; a folded output or
|
|
3883
4018
|
completion separately records the visual call block that owns it.
|
|
4019
|
+
|
|
4020
|
+
#901 §5.3b (W6): each conversation's complete canonical projection is still
|
|
4021
|
+
built in memory, exactly as before, but only its difference against the
|
|
4022
|
+
stored rows is written, so an unchanged conversation writes no row.
|
|
4023
|
+
``codex_find_projection_generation`` advances exactly once per call that
|
|
4024
|
+
changed a row, or when the caller says ``cascaded=True``: it deleted
|
|
4025
|
+
messages of these conversations in the same transaction, so the message
|
|
4026
|
+
trigger already cascaded their projection rows away and the builder may
|
|
4027
|
+
find nothing left to change. A true no-op leaves the generation alone.
|
|
4028
|
+
Callers make one call per transaction, so this is once per transaction.
|
|
3884
4029
|
"""
|
|
3885
4030
|
keys = sorted({key for key in conversation_keys if key})
|
|
3886
4031
|
if not keys:
|
|
3887
4032
|
return
|
|
4033
|
+
changed = 0
|
|
3888
4034
|
for conversation_key in keys:
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
(conversation_key,),
|
|
3892
|
-
)
|
|
3893
|
-
rows = [
|
|
3894
|
-
kern.CodexNormalizedRow(*row)
|
|
3895
|
-
for row in conn.execute(
|
|
3896
|
-
"SELECT " + _ROW_COLS + " FROM codex_conversation_messages "
|
|
3897
|
-
"WHERE conversation_key=? "
|
|
3898
|
-
"ORDER BY timestamp_utc,source_path,line_offset",
|
|
3899
|
-
(conversation_key,),
|
|
3900
|
-
)
|
|
3901
|
-
]
|
|
4035
|
+
built: list[tuple] = []
|
|
4036
|
+
rows = _load_conversation_rows(conn, conversation_key)
|
|
3902
4037
|
if not rows:
|
|
4038
|
+
changed += _write_codex_find_projection_difference(
|
|
4039
|
+
conn, conversation_key, built)
|
|
3903
4040
|
continue
|
|
3904
4041
|
kept, _suppressed = kern.pair_mirrors(rows)
|
|
3905
4042
|
items = kern.canonical_items(kept)
|
|
@@ -3941,12 +4078,9 @@ def materialize_codex_find_projection(
|
|
|
3941
4078
|
if row.kind in {"reasoning", "meta"} or surface != "body"
|
|
3942
4079
|
else []
|
|
3943
4080
|
)
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
"container_block_key,surface,render_order,projected_text,"
|
|
3948
|
-
"leaves_json,disclosure_json,projection_version) "
|
|
3949
|
-
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
4081
|
+
# The builder collects complete rows; the difference below writes
|
|
4082
|
+
# them (column order: `_FIND_PROJECTION_COLUMNS`).
|
|
4083
|
+
built.append(
|
|
3950
4084
|
(
|
|
3951
4085
|
message_id,
|
|
3952
4086
|
conversation_key,
|
|
@@ -4032,11 +4166,11 @@ def materialize_codex_find_projection(
|
|
|
4032
4166
|
container = completion_owner.get(physical_key, physical_key)
|
|
4033
4167
|
store(row, container_block_key=container)
|
|
4034
4168
|
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4169
|
+
changed += _write_codex_find_projection_difference(
|
|
4170
|
+
conn, conversation_key, built)
|
|
4171
|
+
|
|
4172
|
+
if changed or cascaded:
|
|
4173
|
+
conn.execute(_FIND_PROJECTION_GENERATION_SQL)
|
|
4040
4174
|
|
|
4041
4175
|
|
|
4042
4176
|
def _fts_query(query: str, column: str | None) -> str:
|
|
@@ -282,29 +282,32 @@ def _session_first_prompt_titles_map(conn, session_ids):
|
|
|
282
282
|
to this helper's result dict): once a sid resolves a first-prompt title, later
|
|
283
283
|
rows in its 12-row window do not overwrite it (Codex P1-3).
|
|
284
284
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
#901 Amendment 19 T1 (spec I1): one index-served ``LIMIT 12`` lookup per
|
|
286
|
+
session, walking ``idx_conv_session_ts`` in ``(timestamp_utc, id)`` order —
|
|
287
|
+
a true top-12 with no sorter. The ``ROW_NUMBER()`` window it replaces
|
|
288
|
+
ranked the session's WHOLE human partition through a temp b-tree before
|
|
289
|
+
keeping twelve, and one session's history is not a bound. Same rows in the
|
|
290
|
+
same order: sessions in ascending id order (the window's
|
|
291
|
+
``ORDER BY session_id``), each one's earliest twelve candidates by
|
|
292
|
+
``(timestamp_utc, id)`` with NULL timestamps first. Honors
|
|
293
|
+
``_reingest_pending(conn)``'s skip_skill_titles gate: while 005's reingest
|
|
294
|
+
is pending a stale ``human`` row may actually be an injected skill body, so
|
|
295
|
+
skip those as title candidates (gated on the flag so a genuine
|
|
296
|
+
post-reingest human prompt starting with the preamble stays a normal title
|
|
297
|
+
— Codex code-review P2)."""
|
|
292
298
|
if not session_ids:
|
|
293
299
|
return {}
|
|
294
300
|
titles = {}
|
|
295
301
|
skip_skill_titles = _reingest_pending(conn)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
") WHERE rn <= 12 ORDER BY session_id, rn",
|
|
306
|
-
tuple(session_ids),
|
|
307
|
-
).fetchall()
|
|
302
|
+
rows = []
|
|
303
|
+
for sid in sorted({s for s in session_ids if s is not None}, key=str):
|
|
304
|
+
rows.extend(conn.execute(
|
|
305
|
+
"SELECT session_id, text FROM conversation_messages "
|
|
306
|
+
"WHERE session_id = ? AND entry_type='human' "
|
|
307
|
+
" AND is_sidechain=0 AND COALESCE(text,'') <> '' "
|
|
308
|
+
"ORDER BY timestamp_utc, id LIMIT 12",
|
|
309
|
+
(sid,),
|
|
310
|
+
).fetchall())
|
|
308
311
|
for sid, text in rows:
|
|
309
312
|
if sid in titles:
|
|
310
313
|
continue # first-wins: already resolved a title
|
|
@@ -578,7 +581,7 @@ _CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lo
|
|
|
578
581
|
_CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
|
|
579
582
|
|
|
580
583
|
|
|
581
|
-
def _cache_failure_wasted_usd(model, lost, *, speed):
|
|
584
|
+
def _cache_failure_wasted_usd(model, lost, *, speed, prompt_tokens):
|
|
582
585
|
"""Marginal extra paid by re-creating `lost` previously-cached tokens at the
|
|
583
586
|
cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
|
|
584
587
|
pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
|
|
@@ -586,40 +589,54 @@ def _cache_failure_wasted_usd(model, lost, *, speed):
|
|
|
586
589
|
any cost-snapshot / budget / reconciled figure — a display-only estimate.
|
|
587
590
|
The lost-prefix subset has no authoritative mapping to the source row's
|
|
588
591
|
5m/1h write buckets, so this preserves the existing 5m estimate while
|
|
589
|
-
applying the retained effective speed tier.
|
|
592
|
+
applying the retained effective speed tier.
|
|
593
|
+
|
|
594
|
+
`prompt_tokens` (REQUIRED, #929) is the SOURCE request's real prompt total
|
|
595
|
+
(input + cache creation + cache read). Both synthetic calls pass it as
|
|
596
|
+
`prompt_tokens_for_tier`, so a prompt-length-priced card (Claude Haiku 5.5)
|
|
597
|
+
bills the subset at the card the real request selected, never at the card
|
|
598
|
+
the smaller subset would select on its own."""
|
|
590
599
|
write = _calculate_entry_cost(
|
|
591
600
|
model or "",
|
|
592
601
|
claude_usage_dict(
|
|
593
602
|
cache_1h_tokens=None, speed=speed, cache_creation_tokens=lost
|
|
594
603
|
),
|
|
604
|
+
prompt_tokens_for_tier=prompt_tokens,
|
|
595
605
|
)
|
|
596
606
|
read = _calculate_entry_cost(
|
|
597
607
|
model or "",
|
|
598
608
|
claude_usage_dict(
|
|
599
609
|
cache_1h_tokens=None, speed=speed, cache_read_tokens=lost
|
|
600
610
|
),
|
|
611
|
+
prompt_tokens_for_tier=prompt_tokens,
|
|
601
612
|
)
|
|
602
613
|
return write - read
|
|
603
614
|
|
|
604
615
|
|
|
605
|
-
def _cache_read_saved_usd(model, cache_read, *, speed):
|
|
616
|
+
def _cache_read_saved_usd(model, cache_read, *, speed, prompt_tokens):
|
|
606
617
|
"""Marginal USD the cache SAVED this turn: the `cache_read` prefix priced at
|
|
607
618
|
the full input rate minus its actual cache-READ rate. Display-only (same
|
|
608
619
|
caveat as `_cache_failure_wasted_usd`): NEVER summed into a cost-snapshot /
|
|
609
620
|
budget / reconciled figure. `input_tokens` and `cache_read_input_tokens` are
|
|
610
621
|
independent keys in the Claude `_calculate_entry_cost` (no subset
|
|
611
|
-
subtraction), so passing each alone yields the two rates cleanly.
|
|
622
|
+
subtraction), so passing each alone yields the two rates cleanly.
|
|
623
|
+
|
|
624
|
+
`prompt_tokens` (REQUIRED, #929) is the source request's real prompt total,
|
|
625
|
+
passed to both calls as `prompt_tokens_for_tier` exactly as in
|
|
626
|
+
`_cache_failure_wasted_usd`."""
|
|
612
627
|
full = _calculate_entry_cost(
|
|
613
628
|
model or "",
|
|
614
629
|
claude_usage_dict(
|
|
615
630
|
cache_1h_tokens=None, speed=speed, input_tokens=cache_read
|
|
616
631
|
),
|
|
632
|
+
prompt_tokens_for_tier=prompt_tokens,
|
|
617
633
|
)
|
|
618
634
|
read = _calculate_entry_cost(
|
|
619
635
|
model or "",
|
|
620
636
|
claude_usage_dict(
|
|
621
637
|
cache_1h_tokens=None, speed=speed, cache_read_tokens=cache_read
|
|
622
638
|
),
|
|
639
|
+
prompt_tokens_for_tier=prompt_tokens,
|
|
623
640
|
)
|
|
624
641
|
return full - read
|
|
625
642
|
|
|
@@ -749,11 +766,17 @@ def _stamp_cache_failures(items):
|
|
|
749
766
|
"""
|
|
750
767
|
events, sources = _cache_failure_events_from_items(items)
|
|
751
768
|
for idx, prev_cached, lost, model, speed in _iter_cache_failures(events):
|
|
769
|
+
# #929: the flagged request's REAL prompt selects the pricing card, not
|
|
770
|
+
# the lost-prefix subset being priced.
|
|
771
|
+
tok = sources[idx].get("tokens") or {}
|
|
772
|
+
prompt_tokens = ((tok.get("input", 0) or 0)
|
|
773
|
+
+ (tok.get("cache_creation", 0) or 0)
|
|
774
|
+
+ (tok.get("cache_read", 0) or 0))
|
|
752
775
|
sources[idx]["cache_failure"] = {
|
|
753
776
|
"tokens_recreated": lost,
|
|
754
777
|
"prev_cached": prev_cached,
|
|
755
778
|
"est_wasted_usd": _cache_failure_wasted_usd(
|
|
756
|
-
model, lost, speed=speed
|
|
779
|
+
model, lost, speed=speed, prompt_tokens=prompt_tokens
|
|
757
780
|
),
|
|
758
781
|
}
|
|
759
782
|
|
|
@@ -1037,18 +1060,24 @@ def _session_cost_map(conn, session_ids):
|
|
|
1037
1060
|
shared ``_turn_costs_for_keys`` helper (cost-once per deduped session_entries
|
|
1038
1061
|
row), summing into the owning session. (msg_id, req_id) is globally unique in
|
|
1039
1062
|
session_entries and maps to exactly one session_id, so per-session sums are
|
|
1040
|
-
clean and a turn replayed across files contributes once.
|
|
1063
|
+
clean and a turn replayed across files contributes once.
|
|
1064
|
+
|
|
1065
|
+
#901 Amendment 19 T1 (spec I1): the de-duplication runs in Python over the
|
|
1066
|
+
streamed rows, keeping each key's FIRST occurrence in scan order — exactly
|
|
1067
|
+
the rows, and the order, ``SELECT DISTINCT`` returned. The DISTINCT built a
|
|
1068
|
+
temp b-tree of every turn of every touched session, and one session's
|
|
1069
|
+
history is not a bound."""
|
|
1041
1070
|
costs = {sid: 0.0 for sid in session_ids}
|
|
1042
1071
|
if not session_ids:
|
|
1043
1072
|
return costs
|
|
1044
1073
|
placeholders = ",".join("?" for _ in session_ids)
|
|
1045
|
-
pairs = conn.execute(
|
|
1046
|
-
"SELECT
|
|
1074
|
+
pairs = list(dict.fromkeys(conn.execute(
|
|
1075
|
+
"SELECT session_id, msg_id, req_id "
|
|
1047
1076
|
"FROM conversation_messages "
|
|
1048
1077
|
"WHERE session_id IN (%s) AND msg_id IS NOT NULL AND req_id IS NOT NULL"
|
|
1049
1078
|
% placeholders,
|
|
1050
1079
|
list(session_ids),
|
|
1051
|
-
)
|
|
1080
|
+
)))
|
|
1052
1081
|
if not pairs:
|
|
1053
1082
|
return costs
|
|
1054
1083
|
key_cost = _turn_costs_for_keys(conn, [(m, r) for _, m, r in pairs])
|
|
@@ -1084,20 +1113,25 @@ def _models_main_first(rows):
|
|
|
1084
1113
|
|
|
1085
1114
|
def _session_models_map(conn, session_ids):
|
|
1086
1115
|
"""{session_id: models ordered MAIN-session-first (subagent/sidechain-only
|
|
1087
|
-
models last), alphabetical within each group — see _models_main_first}.
|
|
1116
|
+
models last), alphabetical within each group — see _models_main_first}.
|
|
1117
|
+
|
|
1118
|
+
#901 Amendment 19 T1 (spec I1): the de-duplication runs in Python over the
|
|
1119
|
+
streamed rows (one entry per distinct tuple), where a ``SELECT DISTINCT``
|
|
1120
|
+
built a temp b-tree over every touched session's whole history.
|
|
1121
|
+
``_models_main_first`` reads its input as sets, so the result is the same."""
|
|
1088
1122
|
out = {sid: [] for sid in session_ids}
|
|
1089
1123
|
if not session_ids:
|
|
1090
1124
|
return out
|
|
1091
1125
|
placeholders = ",".join("?" for _ in session_ids)
|
|
1092
1126
|
sql = (
|
|
1093
|
-
"SELECT
|
|
1127
|
+
"SELECT session_id, model, source_path, is_sidechain "
|
|
1094
1128
|
"FROM conversation_messages "
|
|
1095
1129
|
"WHERE session_id IN (%s) AND model IS NOT NULL AND model != ''"
|
|
1096
1130
|
% placeholders
|
|
1097
1131
|
)
|
|
1098
1132
|
per_session = {}
|
|
1099
|
-
for sid, model, source_path, is_sidechain in
|
|
1100
|
-
sql, list(session_ids)):
|
|
1133
|
+
for sid, model, source_path, is_sidechain in dict.fromkeys(
|
|
1134
|
+
conn.execute(sql, list(session_ids))):
|
|
1101
1135
|
per_session.setdefault(sid, []).append(
|
|
1102
1136
|
(model, source_path, is_sidechain))
|
|
1103
1137
|
for sid, rows in per_session.items():
|
|
@@ -1113,30 +1147,31 @@ def _session_latest_meta_map(conn, session_ids):
|
|
|
1113
1147
|
latest non-null git_branch may land on DIFFERENT rows (the newest row can
|
|
1114
1148
|
carry one but not the other), so each is resolved independently.
|
|
1115
1149
|
|
|
1116
|
-
#
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
id
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1150
|
+
#901 Q11 (spec §5.3a, 901-PA-001 b): two ``LIMIT 1`` lookups per session,
|
|
1151
|
+
one per column, each ``WHERE <col> IS NOT NULL ORDER BY timestamp_utc DESC,
|
|
1152
|
+
id DESC`` and served by its partial index on ``(session_id, timestamp_utc,
|
|
1153
|
+
id)`` (conversations migration 011): one seek, however long the session.
|
|
1154
|
+
The #217 S1 window query this replaces (two ``FIRST_VALUE`` windows over
|
|
1155
|
+
each touched session) sorted the session's whole history through temp
|
|
1156
|
+
b-trees on every conversation-sync pass, and one session is not a bound.
|
|
1157
|
+
The semantics are unchanged: an empty string is a value, NULL timestamps
|
|
1158
|
+
order below every real one, ties fall to the larger ``id``, an all-NULL
|
|
1159
|
+
column or a missing session gives ``None``. Guarded by
|
|
1160
|
+
``test_session_latest_meta_map_parity_*`` and
|
|
1161
|
+
``tests/test_901_session_latest_meta.py`` (a Python reference and the
|
|
1162
|
+
frozen window query, over sparse metadata in long histories)."""
|
|
1124
1163
|
meta = {sid: (None, None) for sid in session_ids}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
"FROM conversation_messages WHERE session_id IN (%s)" % placeholders
|
|
1137
|
-
)
|
|
1138
|
-
for sid, cwd, branch in conn.execute(sql, list(session_ids)):
|
|
1139
|
-
meta[sid] = (cwd, branch)
|
|
1164
|
+
for sid in meta:
|
|
1165
|
+
cwd = conn.execute(
|
|
1166
|
+
"SELECT cwd FROM conversation_messages "
|
|
1167
|
+
"WHERE session_id = ? AND cwd IS NOT NULL "
|
|
1168
|
+
"ORDER BY timestamp_utc DESC, id DESC LIMIT 1", (sid,)).fetchone()
|
|
1169
|
+
branch = conn.execute(
|
|
1170
|
+
"SELECT git_branch FROM conversation_messages "
|
|
1171
|
+
"WHERE session_id = ? AND git_branch IS NOT NULL "
|
|
1172
|
+
"ORDER BY timestamp_utc DESC, id DESC LIMIT 1", (sid,)).fetchone()
|
|
1173
|
+
meta[sid] = (cwd[0] if cwd is not None else None,
|
|
1174
|
+
branch[0] if branch is not None else None)
|
|
1140
1175
|
return meta
|
|
1141
1176
|
|
|
1142
1177
|
|
|
@@ -2982,8 +3017,12 @@ def get_conversation_outline(conn, session_id):
|
|
|
2982
3017
|
tokens[k] += tok.get(k, 0)
|
|
2983
3018
|
cr_tokens = tok.get("cache_read", 0) or 0
|
|
2984
3019
|
if cr_tokens > 0:
|
|
3020
|
+
# #929: the turn's real prompt selects the card.
|
|
2985
3021
|
cache_saved += _cache_read_saved_usd(
|
|
2986
|
-
it.get("model"), cr_tokens, speed=it.get("_speed")
|
|
3022
|
+
it.get("model"), cr_tokens, speed=it.get("_speed"),
|
|
3023
|
+
prompt_tokens=((tok.get("input", 0) or 0)
|
|
3024
|
+
+ (tok.get("cache_creation", 0) or 0)
|
|
3025
|
+
+ cr_tokens),
|
|
2987
3026
|
)
|
|
2988
3027
|
# Copy the cache-failure marker onto the OutlineTurn exactly where
|
|
2989
3028
|
# tokens is copied (assistant-only, rides the same source row) and
|