cctally 1.44.3 → 1.45.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 CHANGED
@@ -5,6 +5,11 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.45.0] - 2026-06-15
9
+
10
+ ### Added
11
+ - Conversation viewer: the dashboard now marks any assistant turn that suffered a **prompt-cache failure** — where Claude re-created the bulk of its cached prompt prefix instead of reading it (most often on the first turn after you send a new message — usually well inside the cache TTL, not just on long idle gaps), re-billing those tokens at the higher cache-write rate. The failing turn gets an amber `⚡ CACHE REBUILT · <tokens> · +$<wasted>` chip in its header, and the conversation outline gains a matching amber landmark, a `⚡` quick-jump button (`c` / `C` keys), and a "Cache" count in the at-a-glance stats card. Detection is conservative (per model + thread: `cache_read` collapses below half its running high-water mark **and** ≥75% of the turn's context is freshly re-created, with the unavoidable first cache prime, model switches, and context compaction all excluded), so a typical session shows about one marker. The markers are on by default and can be turned off via the new `dashboard.cache_failure_markers` config key (or the dashboard's Settings → "Conversation viewer" toggle). The wasted-cost figure is a display-only estimate recomputed per read — no new database tables and no migration.
12
+
8
13
  ## [1.44.3] - 2026-06-15
9
14
 
10
15
  ### Fixed
@@ -302,6 +302,7 @@ ALLOWED_CONFIG_KEYS = (
302
302
  "alerts.command_template",
303
303
  "dashboard.bind",
304
304
  "dashboard.expose_transcripts",
305
+ "dashboard.cache_failure_markers",
305
306
  "update.check.enabled",
306
307
  "update.check.ttl_hours",
307
308
  "statusline.visual_burn_rate",
@@ -478,6 +479,28 @@ def _config_known_value(config: dict, key: str) -> "object":
478
479
  except ValueError:
479
480
  return False
480
481
  return False
482
+ if key == "dashboard.cache_failure_markers":
483
+ # Boolean opt-OUT (spec §5). Default TRUE — absence is treated as ON
484
+ # (unlike dashboard.expose_transcripts, an opt-IN default of False).
485
+ # A hand-edited junk value surfaces the True default rather than
486
+ # crashing (mirrors dashboard.bind / expose_transcripts).
487
+ block = config.get("dashboard") if isinstance(config, dict) else None
488
+ if not isinstance(block, dict):
489
+ block = {}
490
+ stored = block.get("cache_failure_markers")
491
+ if stored is None:
492
+ return True
493
+ if isinstance(stored, bool):
494
+ return stored
495
+ # Only str spellings are normalizable; any other JSON scalar surfaces
496
+ # the default (the shared normalizer's .strip() would AttributeError on
497
+ # a bare int — uncaught by `except ValueError`).
498
+ if isinstance(stored, str):
499
+ try:
500
+ return c._normalize_alerts_enabled_value(stored)
501
+ except ValueError:
502
+ return True
503
+ return True
481
504
  if key in ("update.check.enabled", "update.check.ttl_hours"):
482
505
  # Defaults mirror `_is_update_check_due` (True / 24 hours).
483
506
  # Hand-edited junk surfaces as the default — matches dashboard.bind.
@@ -864,6 +887,49 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
864
887
  f"{'true' if canonical else 'false'}"
865
888
  )
866
889
  return 0
890
+ if key == "dashboard.cache_failure_markers":
891
+ # Same read-modify-write posture as dashboard.expose_transcripts:
892
+ # validate first, then write under config_writer_lock with
893
+ # _load_config_unlocked (load_config inside the writer-lock
894
+ # self-deadlocks — fcntl.flock is per-fd). Preserves sibling
895
+ # dashboard.bind / dashboard.expose_transcripts. Reuse the shared
896
+ # bool-normalizer; catch + re-message with the actual key name (it
897
+ # hardcodes "alerts.enabled" in its ValueError text).
898
+ try:
899
+ canonical = c._normalize_alerts_enabled_value(raw)
900
+ except ValueError:
901
+ print(
902
+ f"cctally: invalid boolean value for "
903
+ f"dashboard.cache_failure_markers: "
904
+ f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
905
+ file=sys.stderr,
906
+ )
907
+ return 2
908
+ with config_writer_lock():
909
+ config = _load_config_unlocked()
910
+ existing = config.get("dashboard")
911
+ if existing is not None and not isinstance(existing, dict):
912
+ print(
913
+ "cctally: dashboard config error: dashboard must be an object",
914
+ file=sys.stderr,
915
+ )
916
+ return 2
917
+ block = dict(existing or {})
918
+ block["cache_failure_markers"] = canonical
919
+ config["dashboard"] = block
920
+ save_config(config)
921
+ if getattr(args, "emit_json", False):
922
+ print(
923
+ json.dumps(
924
+ {"dashboard": {"cache_failure_markers": canonical}}, indent=2
925
+ )
926
+ )
927
+ else:
928
+ print(
929
+ f"dashboard.cache_failure_markers="
930
+ f"{'true' if canonical else 'false'}"
931
+ )
932
+ return 0
867
933
  if key in (
868
934
  "statusline.visual_burn_rate",
869
935
  "statusline.cost_source",
@@ -1236,6 +1302,22 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
1236
1302
  save_config(config)
1237
1303
  # idempotent: silent on missing key
1238
1304
  return 0
1305
+ if key == "dashboard.cache_failure_markers":
1306
+ # Mirror the dashboard.expose_transcripts unset branch: drop only the
1307
+ # cache_failure_markers leaf; if the dashboard block ends up empty, drop
1308
+ # the parent too so config.json stays tidy. Sibling dashboard.bind /
1309
+ # expose_transcripts survive. Unsetting restores the True (opt-out)
1310
+ # default at read time.
1311
+ with config_writer_lock():
1312
+ config = _load_config_unlocked()
1313
+ block = config.get("dashboard")
1314
+ if isinstance(block, dict) and "cache_failure_markers" in block:
1315
+ del block["cache_failure_markers"]
1316
+ if not block:
1317
+ config.pop("dashboard", None)
1318
+ save_config(config)
1319
+ # idempotent: silent on missing key
1320
+ return 0
1239
1321
  if key in (
1240
1322
  "statusline.visual_burn_rate",
1241
1323
  "statusline.cost_source",
@@ -4912,6 +4912,18 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4912
4912
  "notifier": _alerts_cfg.get("notifier", "auto"),
4913
4913
  "command_configured": _alerts_cfg.get("command_template") is not None,
4914
4914
  }
4915
+ # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
4916
+ # Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
4917
+ # FS hit on the hot path). Default true — absence is treated as ON (opt-out,
4918
+ # not opt-in); a hand-edited non-bool surfaces the default. The on/off toggle
4919
+ # is honored entirely at client render time; the kernel + API always emit the
4920
+ # marker data.
4921
+ _dash_cfg = _cfg_for_alerts.get("dashboard") if isinstance(
4922
+ _cfg_for_alerts.get("dashboard"), dict) else {}
4923
+ _cfm = _dash_cfg.get("cache_failure_markers", True)
4924
+ dashboard_prefs = {
4925
+ "cache_failure_markers": _cfm if isinstance(_cfm, bool) else True,
4926
+ }
4915
4927
 
4916
4928
  # Mirror update-state.json + update-suppress.json into the envelope
4917
4929
  # so the dashboard's amber "Update available" badge repaints from
@@ -5149,6 +5161,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
5149
5161
  "alerts": alerts_array,
5150
5162
  "alerts_settings": alerts_settings,
5151
5163
 
5164
+ # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
5165
+ # §5). Additive optional, like alerts_settings — envelope_version
5166
+ # stays at 2. The client derives `markersEnabled` from this, defaulting
5167
+ # to true when the field is undefined (older server / first tick).
5168
+ "dashboard_prefs": dashboard_prefs,
5169
+
5152
5170
  # update-subcommand SSE mirror (see comment above the
5153
5171
  # `_load_update_state()` block). Shape matches GET
5154
5172
  # /api/update/status's payload (`{state, suppress}`) so the
@@ -5685,7 +5703,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5685
5703
 
5686
5704
  # Reject unknown top-level keys (forward-compat hygiene).
5687
5705
  allowed_top_keys = {
5688
- "display", "alerts", "update", "cache_report", "budget",
5706
+ "display", "alerts", "update", "cache_report", "budget", "dashboard",
5689
5707
  }
5690
5708
  for k in payload.keys():
5691
5709
  if k not in allowed_top_keys:
@@ -5701,12 +5719,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5701
5719
  and "update" not in payload
5702
5720
  and "cache_report" not in payload
5703
5721
  and "budget" not in payload
5722
+ and "dashboard" not in payload
5704
5723
  ):
5705
5724
  self._respond_json(
5706
5725
  400,
5707
5726
  {"error": (
5708
5727
  "body must contain at least one of: "
5709
- "display, alerts, update, cache_report, budget"
5728
+ "display, alerts, update, cache_report, budget, dashboard"
5710
5729
  )},
5711
5730
  )
5712
5731
  return
@@ -5835,6 +5854,52 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5835
5854
  )
5836
5855
  return
5837
5856
 
5857
+ # Pre-validate the dashboard block (spec §5). Only
5858
+ # ``cache_failure_markers`` is dashboard-writable — a JSON boolean
5859
+ # (string/int rejected, mirroring the strict bool checks for
5860
+ # ``alerts.enabled``). ``dashboard.bind`` / ``dashboard.expose_transcripts``
5861
+ # are bind-time / privacy-gate settings, NOT live-mutable, so they are
5862
+ # rejected explicitly here (rather than silently dropped). Outside the
5863
+ # config_writer_lock so a 400 short-circuit doesn't take the lock.
5864
+ dashboard_validated: "dict | None" = None
5865
+ if "dashboard" in payload:
5866
+ dashboard_block = payload["dashboard"]
5867
+ if not isinstance(dashboard_block, dict):
5868
+ self._respond_json(
5869
+ 400,
5870
+ {"error": "dashboard must be an object", "field": "dashboard"},
5871
+ )
5872
+ return
5873
+ for leaf in dashboard_block.keys():
5874
+ if leaf in ("bind", "expose_transcripts"):
5875
+ self._respond_json(
5876
+ 400,
5877
+ {"error": (f"dashboard.{leaf} is not settable via the "
5878
+ "dashboard (bind-time / privacy-gate setting)"),
5879
+ "field": f"dashboard.{leaf}"},
5880
+ )
5881
+ return
5882
+ if leaf != "cache_failure_markers":
5883
+ self._respond_json(
5884
+ 400,
5885
+ {"error": f"unknown dashboard settings key: {leaf}",
5886
+ "field": f"dashboard.{leaf}"},
5887
+ )
5888
+ return
5889
+ dashboard_validated = {}
5890
+ if "cache_failure_markers" in dashboard_block:
5891
+ if not isinstance(dashboard_block["cache_failure_markers"], bool):
5892
+ self._respond_json(
5893
+ 400,
5894
+ {"error": ("dashboard.cache_failure_markers must be a "
5895
+ "JSON boolean"),
5896
+ "field": "dashboard.cache_failure_markers"},
5897
+ )
5898
+ return
5899
+ dashboard_validated["cache_failure_markers"] = (
5900
+ dashboard_block["cache_failure_markers"]
5901
+ )
5902
+
5838
5903
  # Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
5839
5904
  # is settable today; any other key under `update` or `update.check`
5840
5905
  # is rejected so adding e.g. `update.banner.*` later is forward
@@ -6061,6 +6126,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6061
6126
  merged_cr.update(cache_report_validated)
6062
6127
  merged["cache_report"] = merged_cr
6063
6128
 
6129
+ if dashboard_validated is not None:
6130
+ # Same hand-edited-junk guard as the other blocks: a non-dict
6131
+ # stored ``dashboard`` block should surface as a recoverable
6132
+ # 400, not a 500. Partial-merge so the (bind-time, CLI-only)
6133
+ # ``bind`` / ``expose_transcripts`` siblings are PRESERVED —
6134
+ # cache_failure_markers writes must never clobber them.
6135
+ existing_dash = merged.get("dashboard")
6136
+ if existing_dash is not None and not isinstance(existing_dash, dict):
6137
+ self._respond_json(
6138
+ 400, {"error": "dashboard must be an object",
6139
+ "field": "dashboard"}
6140
+ )
6141
+ return
6142
+ merged_dash = dict(existing_dash or {})
6143
+ merged_dash.update(dashboard_validated)
6144
+ merged["dashboard"] = merged_dash
6145
+
6064
6146
  save_config(merged)
6065
6147
 
6066
6148
  # Build the response: subset of touched blocks.
@@ -6152,6 +6234,16 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6152
6234
  out["cache_report"] = {
6153
6235
  "anomaly_threshold_pp": stored_threshold,
6154
6236
  }
6237
+ if dashboard_validated is not None:
6238
+ # Echo the persisted cache_failure_markers (the only dashboard-
6239
+ # writable leaf) so the SettingsOverlay can repaint without a
6240
+ # follow-up GET. Default true (opt-out) when nothing is persisted.
6241
+ persisted_dash = merged.get("dashboard") or {}
6242
+ out["dashboard"] = {
6243
+ "cache_failure_markers": bool(
6244
+ persisted_dash.get("cache_failure_markers", True)
6245
+ ),
6246
+ }
6155
6247
  out["saved_at"] = (
6156
6248
  dt.datetime.now(dt.timezone.utc)
6157
6249
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -285,6 +285,105 @@ def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
285
285
  return _calculate_entry_cost(model or "", usage, cost_usd=cost_usd_raw)
286
286
 
287
287
 
288
+ # --- Cache-failure detection (spec §1) -------------------------------------
289
+ # A prompt-cache "failure" is an assistant turn that re-creates the bulk of its
290
+ # cached prefix instead of reading it: cache_read collapses while cache_creation
291
+ # balloons, re-billing those tokens at the higher cache-WRITE rate. We flag only
292
+ # clear mid-session prefix losses (the Conservative profile, ~1 per session).
293
+ #
294
+ # Thresholds (maintainer-tunable module constants, NOT user config). Rationale
295
+ # from a scan of 58 recent sessions (spec Evidence): a 0.5 running-max-collapse
296
+ # threshold sits in the wide empty gap between the worst healthy turn and the
297
+ # mildest failure, and the recreated-fraction guard (0.75) sits in the matching
298
+ # fraction gap (healthy max 0.723 < 0.75 <= failure min 0.759), both with margin.
299
+ _CACHE_FAILURE_COLLAPSE_FRACTION = 0.5 # cache_read must fall to <= half the prior running-max
300
+ _CACHE_FAILURE_RECREATE_FRACTION = 0.75 # >= 75% of THIS turn's context freshly created
301
+ _CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lose"
302
+ _CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
303
+
304
+
305
+ def _cache_failure_wasted_usd(model, lost):
306
+ """Marginal extra paid by re-creating `lost` previously-cached tokens at the
307
+ cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
308
+ pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
309
+ helper emits its own one-shot stderr warning, never raises). NEVER summed into
310
+ any cost-snapshot / budget / reconciled figure — a display-only estimate."""
311
+ write = _calculate_entry_cost(model or "", {"cache_creation_input_tokens": lost})
312
+ read = _calculate_entry_cost(model or "", {"cache_read_input_tokens": lost})
313
+ return write - read
314
+
315
+
316
+ def _stamp_cache_failures(items):
317
+ """Stamp ``item["cache_failure"]`` on each assistant turn that re-creates the
318
+ bulk of its cached prefix instead of reading it (spec §1). Mutates `items` in
319
+ place; healthy turns are left WITHOUT the key (absent, not zero — matching the
320
+ ``tokens?`` "absent, not zero" convention).
321
+
322
+ Walks items in DOCUMENT ORDER (the order `_assemble_session` produces),
323
+ maintaining a running-max of ``cache_read`` keyed by ``(subagent_key, model)``
324
+ — ``None`` subagent_key = main session. The key is per-thread AND per-model
325
+ because Anthropic prompt caches are model-specific: a model switch within a
326
+ thread legitimately starts a fresh cache (cr ~ 0) and must not read as a loss,
327
+ and the compound key makes that the first turn under its own key (no prior rm).
328
+ The per-key running-max is RESET when a context-compaction boundary is crossed
329
+ (a ``meta`` item whose ``meta_kind == "compaction"``), since compaction
330
+ legitimately invalidates the prefix and the post-compaction re-prime is not a
331
+ failure.
332
+
333
+ For each assistant item with a ``tokens`` dict — cc = tokens["cache_creation"],
334
+ cr = tokens["cache_read"], rm = the running-max for this key BEFORE this turn —
335
+ flag iff rm >= CACHE_FLOOR and cc >= CREATE_FLOOR and cr <= COLLAPSE_FRACTION*rm
336
+ and cc/(cc+cr) >= RECREATE_FRACTION. The fourth term keeps the rule aligned
337
+ with the Evidence: it requires that most of THIS turn's context was freshly
338
+ created, not merely that cache_read dipped. After the check, update
339
+ ``running_max[key] = max(rm, cr)`` (a failure's small cr never lowers the
340
+ high-water mark; the next healthy turn re-establishes it).
341
+
342
+ Payload (lost-prefix basis — NOT raw cc, which would over-count when the turn
343
+ also writes genuinely-new cacheable content that was never cached):
344
+ lost = min(cc, max(0, rm - cr))
345
+ tokens_recreated = lost
346
+ prev_cached = rm
347
+ est_wasted_usd = write(lost) - read(lost)
348
+
349
+ Items lacking a ``tokens`` dict (no session_entries row, or non-assistant
350
+ items other than a compaction meta) are skipped and do NOT move the
351
+ running-max. Order-dependent by construction; run over document-ordered items.
352
+ """
353
+ running_max = {} # (subagent_key, model) -> high-water cache_read
354
+ for it in items:
355
+ # Context-compaction boundary resets the per-key prefix high-water mark:
356
+ # the post-compaction re-prime is a legitimate fresh build, not a loss.
357
+ # Reset the whole map (compaction invalidates every thread's prefix in
358
+ # this session view; a per-key reset would need a thread tag the meta row
359
+ # does not reliably carry).
360
+ if it.get("kind") == "meta" and it.get("meta_kind") == "compaction":
361
+ running_max.clear()
362
+ continue
363
+ if it.get("kind") != "assistant":
364
+ continue
365
+ tok = it.get("tokens")
366
+ if not isinstance(tok, dict):
367
+ continue # no session_entries row -> skip, don't move max
368
+ cc = tok.get("cache_creation", 0) or 0
369
+ cr = tok.get("cache_read", 0) or 0
370
+ key = (it.get("subagent_key"), it.get("model"))
371
+ rm = running_max.get(key, 0)
372
+ total = cc + cr
373
+ if (rm >= _CACHE_FAILURE_CACHE_FLOOR
374
+ and cc >= _CACHE_FAILURE_CREATE_FLOOR
375
+ and cr <= _CACHE_FAILURE_COLLAPSE_FRACTION * rm
376
+ and total > 0
377
+ and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
378
+ lost = min(cc, max(0, rm - cr))
379
+ it["cache_failure"] = {
380
+ "tokens_recreated": lost,
381
+ "prev_cached": rm,
382
+ "est_wasted_usd": _cache_failure_wasted_usd(it.get("model"), lost),
383
+ }
384
+ running_max[key] = max(rm, cr)
385
+
386
+
288
387
  def _session_cost_map(conn, session_ids):
289
388
  """{session_id: total_cost_usd} for the given sessions. Joins
290
389
  conversation_messages turn keys to the single deduped session_entries row
@@ -862,6 +961,12 @@ def _assemble_session(conn, session_id):
862
961
  it.pop("_has_prose", None)
863
962
  header_cost = round(header_cost, 6)
864
963
 
964
+ # Stamp cache-failure markers (spec §1) AFTER tokens are on each item and
965
+ # while `items` is still document-ordered (the running-max walk is
966
+ # order-dependent). Healthy turns get no key (absent, not zero). Shared
967
+ # assembly -> the flag reaches BOTH the reader detail and the outline.
968
+ _stamp_cache_failures(items)
969
+
865
970
  # Strip the internal Phase-4b threading key from EVERY item (meta/human items
866
971
  # carry it too, not just assistant turns) so it never surfaces in the public
867
972
  # item JSON.
@@ -995,6 +1100,12 @@ def get_conversation_outline(conn, session_id):
995
1100
  tool_counts, models = {}, {}
996
1101
  error_count = 0
997
1102
  tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
1103
+ # Cache-failure aggregate (spec §2): count + summed lost-prefix tokens +
1104
+ # summed wasted-cost over the flagged turns. Emitted only when count > 0
1105
+ # (the per-turn "absent, not zero" convention; ~65% of sessions have zero).
1106
+ cf_count = 0
1107
+ cf_tokens = 0
1108
+ cf_wasted = 0.0
998
1109
  for it in items:
999
1110
  kind = it["kind"]
1000
1111
  turn_counts["total"] += 1
@@ -1036,6 +1147,15 @@ def get_conversation_outline(conn, session_id):
1036
1147
  t["tokens"] = tok
1037
1148
  for k in tokens:
1038
1149
  tokens[k] += tok.get(k, 0)
1150
+ # Copy the cache-failure marker onto the OutlineTurn exactly where
1151
+ # tokens is copied (assistant-only, rides the same source row) and
1152
+ # accumulate the session-level aggregate (spec §2).
1153
+ cf = it.get("cache_failure")
1154
+ if cf is not None:
1155
+ t["cache_failure"] = cf
1156
+ cf_count += 1
1157
+ cf_tokens += cf.get("tokens_recreated", 0)
1158
+ cf_wasted += cf.get("est_wasted_usd", 0.0)
1039
1159
  if kind == "meta":
1040
1160
  t["meta_kind"] = it.get("meta_kind")
1041
1161
  t["skill_name"] = it.get("skill_name")
@@ -1046,12 +1166,17 @@ def get_conversation_outline(conn, session_id):
1046
1166
  d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
1047
1167
  d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
1048
1168
  duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
1169
+ stats = {"turns": turn_counts, "tool_counts": tool_counts,
1170
+ "error_count": error_count, "models": models,
1171
+ "duration_seconds": duration, "tokens": tokens,
1172
+ "cost_usd": asm["header_cost"]}
1173
+ if cf_count:
1174
+ stats["cache_failures"] = {"count": cf_count,
1175
+ "tokens_recreated": cf_tokens,
1176
+ "est_wasted_usd": cf_wasted}
1049
1177
  return {"session_id": session_id,
1050
1178
  "subagent_meta": asm["subagent_meta"],
1051
- "stats": {"turns": turn_counts, "tool_counts": tool_counts,
1052
- "error_count": error_count, "models": models,
1053
- "duration_seconds": duration, "tokens": tokens,
1054
- "cost_usd": asm["header_cost"]},
1179
+ "stats": stats,
1055
1180
  "turns": turns}
1056
1181
 
1057
1182