@seanyao/roll 2026.523.2 → 2026.524.1
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 +15 -0
- package/bin/roll +411 -75
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/i18n/agent.sh +0 -0
- package/lib/i18n/alert.sh +0 -0
- package/lib/i18n/backlog.sh +0 -0
- package/lib/i18n/brief.sh +0 -0
- package/lib/i18n/changelog.sh +0 -0
- package/lib/i18n/ci.sh +0 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +31 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +17 -0
- package/lib/i18n/lang.sh +0 -0
- package/lib/i18n/loop.sh +0 -0
- package/lib/i18n/migrate.sh +0 -0
- package/lib/i18n/offboard.sh +15 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +0 -0
- package/lib/i18n/propose.sh +0 -0
- package/lib/i18n/release.sh +0 -0
- package/lib/i18n/research.sh +0 -0
- package/lib/i18n/review_pr.sh +0 -0
- package/lib/i18n/sentinel.sh +0 -0
- package/lib/i18n/setup.sh +0 -0
- package/lib/i18n/shared.sh +83 -0
- package/lib/i18n/skills/roll-brief.sh +20 -0
- package/lib/i18n/skills/roll-build.sh +97 -0
- package/lib/i18n/skills/roll-design.sh +18 -0
- package/lib/i18n/skills/roll-fix.sh +14 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +16 -0
- package/lib/i18n/slides.sh +0 -0
- package/lib/i18n/status.sh +0 -0
- package/lib/i18n/update.sh +9 -0
- package/lib/i18n.sh +25 -0
- package/lib/loop-fmt.py +12 -8
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +93 -12
- package/lib/prices/snapshot-2026-05-22.json +2 -0
- package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
- package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
- package/lib/roll-home.py +17 -1
- package/lib/roll-loop-status.py +3 -0
- package/lib/roll_render.py +5 -2
- package/package.json +1 -1
- package/skills/roll-.dream/SKILL.md +4 -4
package/lib/loop-fmt.py
CHANGED
|
@@ -346,11 +346,11 @@ class LoopFmt:
|
|
|
346
346
|
|
|
347
347
|
@staticmethod
|
|
348
348
|
def _price_at_snapshot(model, totals):
|
|
349
|
-
"""Resolve (
|
|
349
|
+
"""Resolve (cost_list, currency, prices_version) from the active price snapshot.
|
|
350
350
|
|
|
351
|
-
Returns (None, None) when model_prices isn't loadable or the snapshot
|
|
351
|
+
Returns (None, None, None) when model_prices isn't loadable or the snapshot
|
|
352
352
|
has no usable prices — callers still emit the event so token data and
|
|
353
|
-
duration aren't lost. When tokens are all zero,
|
|
353
|
+
duration aren't lost. When tokens are all zero, cost_list is None.
|
|
354
354
|
"""
|
|
355
355
|
try:
|
|
356
356
|
import importlib.util
|
|
@@ -361,11 +361,11 @@ class LoopFmt:
|
|
|
361
361
|
mp = importlib.util.module_from_spec(spec)
|
|
362
362
|
spec.loader.exec_module(mp)
|
|
363
363
|
except Exception:
|
|
364
|
-
return None, None
|
|
364
|
+
return None, None, None
|
|
365
365
|
prices_version = getattr(mp, "VERSION", None)
|
|
366
366
|
has_tokens = any(int(totals.get(k) or 0) > 0 for k in totals)
|
|
367
367
|
if not has_tokens:
|
|
368
|
-
return None, prices_version
|
|
368
|
+
return None, None, prices_version
|
|
369
369
|
try:
|
|
370
370
|
cost = mp.compute_list_cost(
|
|
371
371
|
model,
|
|
@@ -374,9 +374,10 @@ class LoopFmt:
|
|
|
374
374
|
cache_creation_tokens=int(totals.get("cache_creation_tokens") or 0),
|
|
375
375
|
cache_read_tokens=int(totals.get("cache_read_tokens") or 0),
|
|
376
376
|
)
|
|
377
|
+
currency = mp.currency_for(model) if model else "USD"
|
|
377
378
|
except Exception:
|
|
378
|
-
return None, prices_version
|
|
379
|
-
return float(cost), prices_version
|
|
379
|
+
return None, None, prices_version
|
|
380
|
+
return float(cost), currency, prices_version
|
|
380
381
|
|
|
381
382
|
def _emit_usage_event(self, result_ev, dur_ms, cost_usd):
|
|
382
383
|
slug = os.environ.get("LOOP_PROJECT_SLUG")
|
|
@@ -405,7 +406,9 @@ class LoopFmt:
|
|
|
405
406
|
# later prices refresh (or roll upgrade) never rewrites history. The
|
|
406
407
|
# dashboard reads cost_list_usd first; only legacy events without it
|
|
407
408
|
# fall back to recomputing and get tagged [legacy].
|
|
408
|
-
|
|
409
|
+
# FIX-116: also capture cost_currency so the dashboard shows the
|
|
410
|
+
# correct currency symbol (e.g. $ for USD, ¥ for CNY).
|
|
411
|
+
cost_list_usd, cost_currency, prices_version = self._price_at_snapshot(
|
|
409
412
|
model if has_model else None,
|
|
410
413
|
self._usage_totals,
|
|
411
414
|
)
|
|
@@ -419,6 +422,7 @@ class LoopFmt:
|
|
|
419
422
|
"cost_reported_usd": float(cost_usd) if has_cost else None,
|
|
420
423
|
"duration_ms": int(dur_ms) if has_dur else None,
|
|
421
424
|
"cost_list_usd": cost_list_usd,
|
|
425
|
+
"cost_currency": cost_currency,
|
|
422
426
|
"prices_version": prices_version,
|
|
423
427
|
}
|
|
424
428
|
evfile = os.path.join(shared, "loop", f"events-{slug}.ndjson")
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""FIX-112: revert 🔨 In Progress stories whose latest cycle definitively
|
|
3
|
+
failed and has been quiet for a while. Default safe gate is conservative:
|
|
4
|
+
|
|
5
|
+
- Story row is currently 🔨 In Progress in backlog
|
|
6
|
+
- Most recent `pick_todo <story_id>` event in events-<slug>.ndjson lives in
|
|
7
|
+
a cycle whose `cycle_end` outcome is one of: failed | aborted | blocked
|
|
8
|
+
- That cycle_end timestamp is at least N hours ago (default 4)
|
|
9
|
+
|
|
10
|
+
Stories that match are flipped back to 📋 Todo and an ALERT note is appended
|
|
11
|
+
to the per-project ALERT file. Stories still actively running, or claimed
|
|
12
|
+
by a human / agent for legitimate work (no failed cycle_end), stay alone.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 lib/loop_unstick.py # apply (writes backlog + ALERT)
|
|
16
|
+
python3 lib/loop_unstick.py --dry-run # report what would change, write nothing
|
|
17
|
+
python3 lib/loop_unstick.py --ttl-hours 8
|
|
18
|
+
|
|
19
|
+
Returns 0 always (idempotent). Prints one line per reverted story.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
import argparse, json, os, re, sys, time
|
|
23
|
+
from datetime import datetime, timezone, timedelta
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
_LIB_DIR = os.path.dirname(os.path.realpath(__file__))
|
|
27
|
+
if _LIB_DIR not in sys.path:
|
|
28
|
+
sys.path.insert(0, _LIB_DIR)
|
|
29
|
+
|
|
30
|
+
# FIX-108-compatible: accept multi-segment story IDs (US-VIEW-011, US-I18N-001)
|
|
31
|
+
# and alphanumeric segments (K8S, D2, 2FA-ish layouts within rules).
|
|
32
|
+
ID_RE = re.compile(r"^\s*\[?([A-Z][A-Z0-9]*(?:-[A-Z][A-Z0-9]*)*-\d+)")
|
|
33
|
+
TICK = chr(96)
|
|
34
|
+
|
|
35
|
+
def _shared_root() -> Path:
|
|
36
|
+
# bin/roll uses _SHARED_ROOT, lib/roll-home.py uses ROLL_SHARED_ROOT.
|
|
37
|
+
# Honor both so tests that sandbox either name work transparently.
|
|
38
|
+
root = os.environ.get("ROLL_SHARED_ROOT") or os.environ.get("_SHARED_ROOT")
|
|
39
|
+
return Path(root or os.path.expanduser("~/.shared/roll"))
|
|
40
|
+
|
|
41
|
+
def _project_slug() -> str:
|
|
42
|
+
try:
|
|
43
|
+
import subprocess, hashlib
|
|
44
|
+
path = os.path.realpath(os.getcwd())
|
|
45
|
+
common = subprocess.check_output(
|
|
46
|
+
["git", "-C", path, "rev-parse", "--git-common-dir"],
|
|
47
|
+
stderr=subprocess.DEVNULL, text=True,
|
|
48
|
+
).strip()
|
|
49
|
+
if common.endswith("/.git"):
|
|
50
|
+
path = common[:-5]
|
|
51
|
+
except Exception:
|
|
52
|
+
path = os.path.realpath(os.getcwd())
|
|
53
|
+
import hashlib
|
|
54
|
+
base = re.sub(r"[^A-Za-z0-9]+", "-", os.path.basename(path)).strip("-")
|
|
55
|
+
h = hashlib.md5(path.encode()).hexdigest()[:6]
|
|
56
|
+
return f"{base}-{h}"
|
|
57
|
+
|
|
58
|
+
def _read_events(slug: str) -> list:
|
|
59
|
+
path = _shared_root() / "loop" / f"events-{slug}.ndjson"
|
|
60
|
+
out = []
|
|
61
|
+
if not path.exists():
|
|
62
|
+
return out
|
|
63
|
+
with path.open(errors="ignore") as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
ev = json.loads(line)
|
|
70
|
+
ts = ev.get("ts", "")
|
|
71
|
+
ev["_ts"] = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else None
|
|
72
|
+
out.append(ev)
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
def _scan_in_progress(backlog: Path) -> list:
|
|
78
|
+
"""Return list of (line_index, story_id, raw_line) for rows that are 🔨 In Progress."""
|
|
79
|
+
if not backlog.exists():
|
|
80
|
+
return []
|
|
81
|
+
rows = []
|
|
82
|
+
for i, line in enumerate(backlog.open(errors="ignore")):
|
|
83
|
+
if "| 🔨 In Progress |" not in line:
|
|
84
|
+
continue
|
|
85
|
+
if not line.startswith("|"):
|
|
86
|
+
continue
|
|
87
|
+
parts = [p.strip() for p in line.split("|")]
|
|
88
|
+
if len(parts) < 4:
|
|
89
|
+
continue
|
|
90
|
+
m = ID_RE.match(parts[1])
|
|
91
|
+
if not m:
|
|
92
|
+
continue
|
|
93
|
+
rows.append((i, m.group(1), line.rstrip("\n")))
|
|
94
|
+
return rows
|
|
95
|
+
|
|
96
|
+
def _cycle_end_for_pick(events: list, story_id: str):
|
|
97
|
+
"""Return (cycle_end_ts, outcome) of the latest cycle that picked
|
|
98
|
+
story_id, or None if no such cycle / cycle still running."""
|
|
99
|
+
# Walk events back to front looking for the latest pick_todo matching story_id
|
|
100
|
+
latest_pick = None
|
|
101
|
+
for ev in reversed(events):
|
|
102
|
+
if ev.get("stage") == "pick_todo" and ev.get("detail") == story_id:
|
|
103
|
+
latest_pick = ev
|
|
104
|
+
break
|
|
105
|
+
if not latest_pick:
|
|
106
|
+
return None
|
|
107
|
+
label = latest_pick.get("label", "")
|
|
108
|
+
# Look forward (from the pick) for cycle_end with the same label
|
|
109
|
+
pick_idx = events.index(latest_pick)
|
|
110
|
+
for ev in events[pick_idx + 1:]:
|
|
111
|
+
if ev.get("stage") == "cycle_end" and ev.get("label", "").endswith(label):
|
|
112
|
+
return ev.get("_ts"), ev.get("outcome", "")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def main():
|
|
116
|
+
ap = argparse.ArgumentParser()
|
|
117
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
118
|
+
ap.add_argument("--ttl-hours", type=float, default=4.0,
|
|
119
|
+
help="Minimum hours since failed cycle_end before reverting (default 4)")
|
|
120
|
+
ap.add_argument("--backlog", default=".roll/backlog.md")
|
|
121
|
+
args = ap.parse_args()
|
|
122
|
+
|
|
123
|
+
backlog = Path(args.backlog)
|
|
124
|
+
if not backlog.exists():
|
|
125
|
+
print(f"backlog not found: {backlog}", file=sys.stderr)
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
slug = _project_slug()
|
|
129
|
+
events = _read_events(slug)
|
|
130
|
+
in_progress = _scan_in_progress(backlog)
|
|
131
|
+
if not in_progress:
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
now = datetime.now(timezone.utc)
|
|
135
|
+
cutoff = now - timedelta(hours=args.ttl_hours)
|
|
136
|
+
candidates_to_revert = []
|
|
137
|
+
|
|
138
|
+
failed_outcomes = {"failed", "aborted", "blocked"}
|
|
139
|
+
for line_idx, sid, raw in in_progress:
|
|
140
|
+
result = _cycle_end_for_pick(events, sid)
|
|
141
|
+
if not result:
|
|
142
|
+
continue # still running OR no failed cycle yet — leave alone
|
|
143
|
+
end_ts, outcome = result
|
|
144
|
+
if outcome not in failed_outcomes:
|
|
145
|
+
continue
|
|
146
|
+
if not end_ts or end_ts > cutoff:
|
|
147
|
+
continue # too recent
|
|
148
|
+
age_hours = (now - end_ts).total_seconds() / 3600
|
|
149
|
+
candidates_to_revert.append((line_idx, sid, raw, outcome, age_hours))
|
|
150
|
+
|
|
151
|
+
if not candidates_to_revert:
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
if args.dry_run:
|
|
155
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
156
|
+
print(f"would-revert {sid} (cycle ended {outcome} {age:.1f}h ago)")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
# Apply: read backlog, flip status, write back.
|
|
160
|
+
lines = backlog.read_text(errors="ignore").splitlines(keepends=True)
|
|
161
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
162
|
+
lines[line_idx] = lines[line_idx].replace("| 🔨 In Progress |", "| 📋 Todo |")
|
|
163
|
+
|
|
164
|
+
backlog.write_text("".join(lines))
|
|
165
|
+
|
|
166
|
+
# Append ALERT
|
|
167
|
+
alert_file = _shared_root() / "loop" / f"ALERT-{slug}.md"
|
|
168
|
+
alert_file.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
with alert_file.open("a") as f:
|
|
170
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
171
|
+
ts = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
172
|
+
f.write(f"[{ts}] unstick: reverted {sid} (cycle ended {outcome} {age:.1f}h ago, > {args.ttl_hours}h TTL)\n")
|
|
173
|
+
|
|
174
|
+
for line_idx, sid, raw, outcome, age in candidates_to_revert:
|
|
175
|
+
print(f"reverted {sid} (cycle ended {outcome} {age:.1f}h ago)")
|
|
176
|
+
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
sys.exit(main())
|
package/lib/model_prices.py
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
|
-
model_prices — list-price table for
|
|
2
|
+
model_prices — list-price table for AI model API pricing.
|
|
3
3
|
|
|
4
|
-
Pricing is per million tokens (MTok),
|
|
5
|
-
discounts (Pro subscription, prepay
|
|
6
|
-
modeled — IDEA-025 is about
|
|
4
|
+
Pricing is per million tokens (MTok), in the vendor's native currency.
|
|
5
|
+
These are the public list rates; discounts (Pro subscription, prepay
|
|
6
|
+
credits, etc.) are intentionally not modeled — IDEA-025 is about
|
|
7
|
+
cross-account / cross-project comparable cost.
|
|
7
8
|
|
|
8
9
|
US-VIEW-013: prices are no longer hardcoded here. They live in versioned
|
|
9
10
|
snapshot files under ``lib/prices/snapshot-YYYY-MM-DD.json`` and are loaded
|
|
10
11
|
at module import time. ``roll prices refresh`` produces new snapshots; this
|
|
11
|
-
module never writes — it only loads
|
|
12
|
+
module never writes — it only loads all snapshots and merges them.
|
|
13
|
+
|
|
14
|
+
FIX-116: multi-vendor support — snapshots carry ``vendor`` and ``currency``
|
|
15
|
+
fields. All snapshots are loaded and merged into a single PRICES map, with
|
|
16
|
+
each model entry carrying its native ``currency``. Vendor-prefixed model
|
|
17
|
+
names (``deepseek/deepseek-chat``) are resolved by stripping the vendor
|
|
18
|
+
segment when no exact match exists.
|
|
12
19
|
|
|
13
20
|
Unknown models fall back to the snapshot's ``default_model`` with a stderr
|
|
14
21
|
warning so dashboards don't blank out.
|
|
@@ -45,6 +52,8 @@ def load_snapshot(path: str) -> Dict[str, Any]:
|
|
|
45
52
|
if not isinstance(data["prices"], dict) or not data["prices"]:
|
|
46
53
|
raise ValueError(f"snapshot {path!r} has empty or invalid prices map")
|
|
47
54
|
data.setdefault("default_model", next(iter(data["prices"])))
|
|
55
|
+
data.setdefault("vendor", "anthropic")
|
|
56
|
+
data.setdefault("currency", "USD")
|
|
48
57
|
return data
|
|
49
58
|
|
|
50
59
|
|
|
@@ -58,12 +67,34 @@ def load_latest_snapshot(snapshot_dir: str = SNAPSHOT_DIR) -> Dict[str, Any]:
|
|
|
58
67
|
return load_snapshot(snaps[-1])
|
|
59
68
|
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
def load_all_snapshots(snapshot_dir: str = SNAPSHOT_DIR) -> List[Dict[str, Any]]:
|
|
71
|
+
"""Load all snapshots, sorted oldest → newest. Raises FileNotFoundError if none."""
|
|
72
|
+
snaps = list_snapshots(snapshot_dir)
|
|
73
|
+
if not snaps:
|
|
74
|
+
raise FileNotFoundError(
|
|
75
|
+
f"no price snapshots found in {snapshot_dir}; run `roll prices refresh`"
|
|
76
|
+
)
|
|
77
|
+
return [load_snapshot(p) for p in snaps]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_SNAPSHOTS: List[Dict[str, Any]] = load_all_snapshots()
|
|
81
|
+
_DEFAULT_SNAP: Dict[str, Any] = _SNAPSHOTS[-1]
|
|
82
|
+
|
|
83
|
+
# Merge PRICES from all snapshots, injecting currency per model.
|
|
84
|
+
# Later snapshots override earlier ones for the same model name.
|
|
85
|
+
PRICES: Dict[str, Dict[str, float]] = {}
|
|
86
|
+
_CURRENCY: Dict[str, str] = {}
|
|
87
|
+
for _snap in _SNAPSHOTS:
|
|
88
|
+
_snap_currency = _snap.get("currency", "USD")
|
|
89
|
+
for _model, _rates in _snap["prices"].items():
|
|
90
|
+
PRICES[_model] = dict(_rates)
|
|
91
|
+
PRICES[_model]["currency"] = _snap_currency
|
|
92
|
+
_CURRENCY[_model] = _snap_currency
|
|
93
|
+
|
|
94
|
+
DEFAULT: str = _DEFAULT_SNAP["default_model"]
|
|
95
|
+
VERSION: str = _DEFAULT_SNAP["version"]
|
|
96
|
+
EFFECTIVE_AT: str = _DEFAULT_SNAP["effective_at"]
|
|
97
|
+
SOURCE_URL: str = _DEFAULT_SNAP["source_url"]
|
|
67
98
|
|
|
68
99
|
_warned: set = set()
|
|
69
100
|
|
|
@@ -80,9 +111,20 @@ def _resolve(model: Optional[str], prices: Optional[Dict[str, Dict[str, float]]]
|
|
|
80
111
|
if not model:
|
|
81
112
|
return table[fallback]
|
|
82
113
|
base = model.split("[")[0].rstrip("0123456789-")
|
|
114
|
+
|
|
115
|
+
# Direct match: model starts with a known key
|
|
83
116
|
candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
|
|
84
117
|
if candidates:
|
|
85
118
|
return table[max(candidates, key=len)]
|
|
119
|
+
|
|
120
|
+
# Vendor prefix: try stripping "vendor/" segment for proxy tools (pi, etc.)
|
|
121
|
+
if "/" in model:
|
|
122
|
+
inner = model.split("/", 1)[1]
|
|
123
|
+
inner_base = inner.split("[")[0].rstrip("0123456789-")
|
|
124
|
+
for k in table:
|
|
125
|
+
if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
|
|
126
|
+
return table[k]
|
|
127
|
+
|
|
86
128
|
if model not in _warned:
|
|
87
129
|
_warned.add(model)
|
|
88
130
|
print(f"[model_prices] warn: unknown model {model!r}, falling back to {fallback}",
|
|
@@ -90,6 +132,45 @@ def _resolve(model: Optional[str], prices: Optional[Dict[str, Dict[str, float]]]
|
|
|
90
132
|
return table[fallback]
|
|
91
133
|
|
|
92
134
|
|
|
135
|
+
def _resolve_name(model: Optional[str],
|
|
136
|
+
prices: Optional[Dict[str, Dict[str, float]]] = None,
|
|
137
|
+
default: Optional[str] = None) -> str:
|
|
138
|
+
"""Return the canonical model name (key in PRICES) for a given model string.
|
|
139
|
+
|
|
140
|
+
Same resolution logic as _resolve, but returns the matched key name
|
|
141
|
+
instead of the rate dict. Used by currency_for() to find the currency.
|
|
142
|
+
"""
|
|
143
|
+
table = prices if prices is not None else PRICES
|
|
144
|
+
fallback = default if default is not None else DEFAULT
|
|
145
|
+
if not model:
|
|
146
|
+
return fallback
|
|
147
|
+
base = model.split("[")[0].rstrip("0123456789-")
|
|
148
|
+
|
|
149
|
+
# Direct match: model starts with a known key
|
|
150
|
+
candidates = [k for k in table if model.startswith(k) or base.startswith(k)]
|
|
151
|
+
if candidates:
|
|
152
|
+
return max(candidates, key=len)
|
|
153
|
+
|
|
154
|
+
# Vendor prefix: try stripping "vendor/" segment
|
|
155
|
+
if "/" in model:
|
|
156
|
+
inner = model.split("/", 1)[1]
|
|
157
|
+
inner_base = inner.split("[")[0].rstrip("0123456789-")
|
|
158
|
+
for k in table:
|
|
159
|
+
if inner == k or inner_base == k or inner.startswith(k) or inner_base.startswith(k):
|
|
160
|
+
return k
|
|
161
|
+
|
|
162
|
+
return fallback
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def currency_for(model: Optional[str]) -> str:
|
|
166
|
+
"""Return the native currency code (USD/CNY) for a model.
|
|
167
|
+
|
|
168
|
+
Falls back to 'USD' when the model isn't in any snapshot.
|
|
169
|
+
"""
|
|
170
|
+
name = _resolve_name(model)
|
|
171
|
+
return _CURRENCY.get(name, "USD")
|
|
172
|
+
|
|
173
|
+
|
|
93
174
|
def compute_list_cost(model: Optional[str],
|
|
94
175
|
*,
|
|
95
176
|
input_tokens: int = 0,
|
|
@@ -98,7 +179,7 @@ def compute_list_cost(model: Optional[str],
|
|
|
98
179
|
cache_read_tokens: int = 0,
|
|
99
180
|
prices: Optional[Dict[str, Dict[str, float]]] = None,
|
|
100
181
|
default: Optional[str] = None) -> float:
|
|
101
|
-
"""Return
|
|
182
|
+
"""Return cost (in native currency) at list price for one cycle's token usage."""
|
|
102
183
|
p = _resolve(model, prices=prices, default=default)
|
|
103
184
|
total = (input_tokens * p["in"]
|
|
104
185
|
+ output_tokens * p["out"]
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
"version": "2026-05-22",
|
|
3
3
|
"effective_at": "2026-05-22",
|
|
4
4
|
"source_url": "https://platform.claude.com/docs/en/about-claude/pricing",
|
|
5
|
+
"vendor": "anthropic",
|
|
6
|
+
"currency": "USD",
|
|
5
7
|
"default_model": "claude-sonnet-4-6",
|
|
6
8
|
"notes": "Rates per million tokens (USD). cache_create = 5-minute cache write (1.25x input). 1-hour cache writes (2x input) are not modeled — Roll loop uses the default 5m caching only. 2026-05 repricing: Opus 4.5+ moved to $5/$25 base (3x cheaper than Opus 4/4.1).",
|
|
7
9
|
"prices": {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-05-23",
|
|
3
|
+
"effective_at": "2026-05-23",
|
|
4
|
+
"source_url": "https://api-docs.deepseek.com/quick_start/pricing",
|
|
5
|
+
"vendor": "deepseek",
|
|
6
|
+
"currency": "USD",
|
|
7
|
+
"default_model": "deepseek-chat",
|
|
8
|
+
"notes": "Rates per million tokens (USD). deepseek-chat and deepseek-reasoner are both deepseek-v4-flash with different thinking modes — same pricing. deepseek-v4-pro has 75% promotional discount until 2026-05-31. cache_create estimated at 1.25x input (same convention as Claude snapshot). cache_hit discount per official docs: 1/50 for flash ($0.14→$0.0028), 1/120 for pro ($0.435→$0.003625). Actual cache_create not published by DeepSeek — using 1.25x input convention.",
|
|
9
|
+
"prices": {
|
|
10
|
+
"deepseek-chat": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
11
|
+
"deepseek-reasoner": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
12
|
+
"deepseek-v4-flash": {"in": 0.14, "out": 0.28, "cache_create": 0.175, "cache_read": 0.0028},
|
|
13
|
+
"deepseek-v4-pro": {"in": 0.435, "out": 0.87, "cache_create": 0.54375, "cache_read": 0.003625}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-05-23",
|
|
3
|
+
"effective_at": "2026-05-23",
|
|
4
|
+
"source_url": "https://platform.kimi.com/docs/pricing/chat",
|
|
5
|
+
"vendor": "kimi",
|
|
6
|
+
"currency": "USD",
|
|
7
|
+
"default_model": "kimi-k2.5",
|
|
8
|
+
"notes": "Rates per million tokens (USD). cache_create estimated at 1.25x input. Prices from public Kimi API platform docs — verify with `roll prices refresh` if page layout changes. Model names: kimi-k2 (prior gen), kimi-k2.5 (current), kimi-k2.6 (latest).",
|
|
9
|
+
"prices": {
|
|
10
|
+
"kimi-k2": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
|
|
11
|
+
"kimi-k2.5": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25},
|
|
12
|
+
"kimi-k2.6": {"in": 1.00, "out": 4.00, "cache_create": 1.25, "cache_read": 0.25}
|
|
13
|
+
}
|
|
14
|
+
}
|
package/lib/roll-home.py
CHANGED
|
@@ -76,6 +76,22 @@ def _load_config() -> Dict[str, str]:
|
|
|
76
76
|
return d
|
|
77
77
|
return {}
|
|
78
78
|
|
|
79
|
+
|
|
80
|
+
def _resolve_project_agent(config: Dict[str, str]) -> str:
|
|
81
|
+
"""FIX-117: home banner agent label must honor project-level override
|
|
82
|
+
in `.roll/local.yaml` (set by `roll agent use`), not just the global
|
|
83
|
+
`~/.roll/config.yaml#primary_agent`. Mirror bin/roll _project_agent
|
|
84
|
+
precedence: local.yaml > .roll.yaml > config.primary_agent > 'claude'."""
|
|
85
|
+
for path in (
|
|
86
|
+
Path(".roll/local.yaml"),
|
|
87
|
+
Path(".roll.yaml"),
|
|
88
|
+
):
|
|
89
|
+
if path.exists():
|
|
90
|
+
local = _load_yaml_flat(path)
|
|
91
|
+
if local.get("agent"):
|
|
92
|
+
return local["agent"]
|
|
93
|
+
return config.get("primary_agent") or "claude"
|
|
94
|
+
|
|
79
95
|
def _git_info() -> Tuple[str, str]:
|
|
80
96
|
try:
|
|
81
97
|
branch = subprocess.check_output(
|
|
@@ -482,7 +498,7 @@ def main() -> None:
|
|
|
482
498
|
d = dict(
|
|
483
499
|
project_name = os.path.basename(os.getcwd()),
|
|
484
500
|
version = _roll_version(),
|
|
485
|
-
agent = config
|
|
501
|
+
agent = _resolve_project_agent(config),
|
|
486
502
|
git_branch = bra,
|
|
487
503
|
git_status = gs,
|
|
488
504
|
timestamp = datetime.now().strftime("%H:%M"),
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -373,6 +373,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
373
373
|
persisted = ue.get("cost_list_usd")
|
|
374
374
|
if persisted is not None:
|
|
375
375
|
cy["cost_list"] = float(persisted)
|
|
376
|
+
cy["cost_currency"] = ue.get("cost_currency") or "USD"
|
|
376
377
|
cy["cost_list_legacy"] = False
|
|
377
378
|
else:
|
|
378
379
|
cy["cost_list"] = mp.compute_list_cost(
|
|
@@ -382,6 +383,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
382
383
|
cache_creation_tokens=ue.get("cache_creation_tokens", 0),
|
|
383
384
|
cache_read_tokens=ue.get("cache_read_tokens", 0),
|
|
384
385
|
)
|
|
386
|
+
cy["cost_currency"] = mp.currency_for(ue.get("model")) or "USD"
|
|
385
387
|
cy["cost_list_legacy"] = True
|
|
386
388
|
if ue.get("duration_ms") and not cy.get("duration_s"):
|
|
387
389
|
cy["duration_s"] = int(ue["duration_ms"] / 1000)
|
|
@@ -404,6 +406,7 @@ def backfill_usage_from_claude_sessions(cycles: List[Dict[str, Any]], slug: str)
|
|
|
404
406
|
cache_creation_tokens=u["cache_creation_tokens"],
|
|
405
407
|
cache_read_tokens=u["cache_read_tokens"],
|
|
406
408
|
)
|
|
409
|
+
cy["cost_currency"] = mp.currency_for(u["model"]) or "USD"
|
|
407
410
|
# US-VIEW-014: session salvage never has a frozen cycle_end cost, so
|
|
408
411
|
# this path is always legacy.
|
|
409
412
|
cy["cost_list_legacy"] = True
|
package/lib/roll_render.py
CHANGED
|
@@ -313,10 +313,13 @@ def cycle_row(cy: Dict[str, Any], backlog: Dict[str, str]) -> None:
|
|
|
313
313
|
tok = f"{fmt_tokens(inp)}/{fmt_tokens(out_tok)}"
|
|
314
314
|
# cost prefers the backfilled list-price; falls back to cron.log when
|
|
315
315
|
# the claude session log isn't available (only the latest cycle).
|
|
316
|
+
# FIX-116: use the model's native currency symbol.
|
|
317
|
+
cur = cy.get("cost_currency", "USD")
|
|
318
|
+
symbol = "¥" if cur == "CNY" else "$"
|
|
316
319
|
if cy.get("cost_list") is not None:
|
|
317
|
-
cost = f"
|
|
320
|
+
cost = f"{symbol}{cy['cost_list']:.2f}"
|
|
318
321
|
elif cr:
|
|
319
|
-
cost = f"
|
|
322
|
+
cost = f"{symbol}{cr.get('cost', 0):.2f}"
|
|
320
323
|
else:
|
|
321
324
|
cost = "—"
|
|
322
325
|
sid = cy.get("story") or "—"
|
package/package.json
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
hidden: true
|
|
3
3
|
name: roll-.dream
|
|
4
4
|
license: MIT
|
|
5
|
-
allowed-tools: "Read, Glob, Grep, Bash(git:*), Write, Edit"
|
|
5
|
+
allowed-tools: "Read, Glob, Grep, Bash(git:*, curl:*, claude:*), Write, Edit"
|
|
6
6
|
description: |
|
|
7
7
|
Nightly code and architecture health scan. Passively triggered by scheduler
|
|
8
8
|
(cron or GitHub Actions), not invoked by users directly. Detects dead code,
|
|
9
9
|
architectural drift from domain model, pruning candidates, emerging patterns,
|
|
10
|
-
doc coverage gaps, and doc staleness (文档新鲜度).
|
|
11
|
-
to .roll/backlog.md and a daily log to .roll/dream/.
|
|
10
|
+
doc coverage gaps, and doc staleness (文档新鲜度).
|
|
11
|
+
Outputs REFACTOR entries to .roll/backlog.md and a daily log to .roll/dream/.
|
|
12
12
|
Distinct from roll-sentinel: sentinel monitors runtime behavior; dream reviews
|
|
13
13
|
code structure and architectural health.
|
|
14
14
|
---
|
|
@@ -34,7 +34,7 @@ in the morning brief.
|
|
|
34
34
|
|
|
35
35
|
## Scan Logic
|
|
36
36
|
|
|
37
|
-
Run all
|
|
37
|
+
Run all scans every night. Each scan is independent.
|
|
38
38
|
|
|
39
39
|
### Scan 1 — Dead Code
|
|
40
40
|
|