@seanyao/roll 2026.526.1 → 2026.528.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 +39 -12
- package/README.md +1 -0
- package/bin/roll +813 -68
- package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +104 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/i18n.sh +12 -20
- package/lib/loop-fmt.py +67 -9
- package/lib/prices/snapshot-2026-05-23-deepseek.json +7 -7
- package/lib/roll-loop-status.py +42 -11
- package/lib/roll_render.py +11 -7
- package/package.json +1 -1
- package/skills/roll-design/SKILL.md +1 -1
- package/template/.github/workflows/ci.yml +2 -2
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
backfill-pi-usage — one-time, idempotent recovery of pi/deepseek token+cost
|
|
4
|
+
into an existing loop events file.
|
|
5
|
+
|
|
6
|
+
Why this exists
|
|
7
|
+
---------------
|
|
8
|
+
Before US-LOOP-026 the loop ran pi via ``pi -p`` (text mode), which prints no
|
|
9
|
+
usage. loop-fmt's old passthrough still appended a ``stage=="usage"`` event on
|
|
10
|
+
every retry attempt, each with ``model=="pi"`` and null tokens — so a single
|
|
11
|
+
cycle accumulated up to ~180 empty usage events. The dashboard SUMS token
|
|
12
|
+
fields across same-label usage events; with all-null tokens the SUM was 0
|
|
13
|
+
(harmless), but it means every affected cycle shows ``—/—``.
|
|
14
|
+
|
|
15
|
+
pi persists every session to ``~/.pi/agent/sessions/<enc-cwd>/<ts>_<uuid>.jsonl``
|
|
16
|
+
with real per-message usage. This script recovers that, and rewrites the events
|
|
17
|
+
file so each affected cycle is left with **exactly one** authoritative usage
|
|
18
|
+
event (real tokens, cost frozen in native CNY) — collapsing the N null events to
|
|
19
|
+
avoid the dashboard ×N inflation.
|
|
20
|
+
|
|
21
|
+
Safety / idempotency
|
|
22
|
+
--------------------
|
|
23
|
+
- Backs up the events file to ``<file>.bak-<UTC>`` first; aborts if backup fails.
|
|
24
|
+
- Only touches labels whose usage events are all pi-vendor (``model`` in
|
|
25
|
+
{"pi", "deepseek-v4-pro"}) AND carry null tokens AND match a pi session.
|
|
26
|
+
claude cycles, already-real cycles, and unmatched-null cycles are passed
|
|
27
|
+
through untouched.
|
|
28
|
+
- Re-runnable: once a label has a real-token usage event it is no longer a
|
|
29
|
+
candidate, so a second run is a no-op.
|
|
30
|
+
- FIX-065 tripwire: refuses to rewrite a production ``~/.shared/roll`` events
|
|
31
|
+
file from a test context (BATS / temp cwd) unless HOME itself is sandboxed.
|
|
32
|
+
|
|
33
|
+
Usage
|
|
34
|
+
-----
|
|
35
|
+
python3 lib/backfill-pi-usage.py --slug roll-ecf079
|
|
36
|
+
python3 lib/backfill-pi-usage.py --events /path/to/events.ndjson --dry-run
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import importlib.util
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import shutil
|
|
44
|
+
import sys
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
|
|
47
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
48
|
+
|
|
49
|
+
PI_VENDOR_MODELS = ("pi", "deepseek-v4-pro")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_pi_emit():
|
|
53
|
+
spec = importlib.util.spec_from_file_location(
|
|
54
|
+
"pi_emit", os.path.join(_THIS_DIR, "agent_usage", "pi_emit.py")
|
|
55
|
+
)
|
|
56
|
+
m = importlib.util.module_from_spec(spec)
|
|
57
|
+
spec.loader.exec_module(m)
|
|
58
|
+
return m
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _default_events_path(slug, shared=None):
|
|
62
|
+
base = shared or os.environ.get("LOOP_SHARED_ROOT") \
|
|
63
|
+
or os.path.expanduser("~/.shared/roll")
|
|
64
|
+
return os.path.join(base, "loop", "events-%s.ndjson" % slug)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_test_context():
|
|
68
|
+
return bool(os.environ.get("BATS_TEST_FILENAME")) or _cwd_is_temp()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cwd_is_temp():
|
|
72
|
+
p = os.environ.get("PWD") or os.getcwd()
|
|
73
|
+
return any(seg in p for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _home_is_sandbox():
|
|
77
|
+
home = os.environ.get("HOME") or ""
|
|
78
|
+
return any(seg in home for seg in ("/tmp/", "/private/tmp/", "/var/folders/", "/tmp."))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _tripwire(evfile):
|
|
82
|
+
"""FIX-065: refuse a prod write from a test context."""
|
|
83
|
+
home = os.environ.get("HOME") or ""
|
|
84
|
+
if not home or _home_is_sandbox():
|
|
85
|
+
return
|
|
86
|
+
prod = os.path.join(home, ".shared", "roll") + os.sep
|
|
87
|
+
if os.path.abspath(evfile).startswith(os.path.abspath(prod)) and _is_test_context():
|
|
88
|
+
raise SystemExit(
|
|
89
|
+
"[FIX-065] refusing to rewrite prod events file from test context: %s" % evfile
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _scan(lines):
|
|
94
|
+
"""Parse lines → (events_or_None list, per-label usage summary).
|
|
95
|
+
|
|
96
|
+
Returns (parsed, labels) where parsed is a list of (raw_line, obj_or_None)
|
|
97
|
+
preserving order, and labels maps label → {"pi": bool, "real": bool}.
|
|
98
|
+
"""
|
|
99
|
+
parsed = []
|
|
100
|
+
labels = {}
|
|
101
|
+
for raw in lines:
|
|
102
|
+
obj = None
|
|
103
|
+
try:
|
|
104
|
+
obj = json.loads(raw)
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
obj = None
|
|
107
|
+
parsed.append((raw, obj))
|
|
108
|
+
if not obj or obj.get("stage") != "usage":
|
|
109
|
+
continue
|
|
110
|
+
lab = obj.get("label")
|
|
111
|
+
d = obj.get("detail") or {}
|
|
112
|
+
rec = labels.setdefault(lab, {"pi": False, "real": False})
|
|
113
|
+
if d.get("model") in PI_VENDOR_MODELS:
|
|
114
|
+
rec["pi"] = True
|
|
115
|
+
if d.get("input_tokens"):
|
|
116
|
+
rec["real"] = True
|
|
117
|
+
return parsed, labels
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def backfill(evfile, slug=None, shared=None, base_dir=None, dry_run=False):
|
|
121
|
+
"""Rewrite evfile so each recoverable pi cycle nets one real usage event.
|
|
122
|
+
|
|
123
|
+
Returns a stats dict.
|
|
124
|
+
"""
|
|
125
|
+
_tripwire(evfile)
|
|
126
|
+
pi_emit = _load_pi_emit()
|
|
127
|
+
|
|
128
|
+
with open(evfile) as f:
|
|
129
|
+
lines = f.readlines()
|
|
130
|
+
|
|
131
|
+
parsed, labels = _scan(lines)
|
|
132
|
+
|
|
133
|
+
# Candidate = pi-vendor, all-null, and a session match yields real usage.
|
|
134
|
+
candidates = [l for l, r in labels.items() if r["pi"] and not r["real"]]
|
|
135
|
+
replacement = {} # label -> detail payload
|
|
136
|
+
matched, unmatched = [], []
|
|
137
|
+
for lab in candidates:
|
|
138
|
+
cwd = os.path.join(
|
|
139
|
+
(shared or os.environ.get("LOOP_SHARED_ROOT")
|
|
140
|
+
or os.path.expanduser("~/.shared/roll")),
|
|
141
|
+
"worktrees", "%s-cycle-%s" % (slug, lab),
|
|
142
|
+
)
|
|
143
|
+
ev = pi_emit.build_event(cwd=cwd, cycle_id=lab, slug=slug, base_dir=base_dir)
|
|
144
|
+
if ev is None:
|
|
145
|
+
unmatched.append(lab)
|
|
146
|
+
continue
|
|
147
|
+
replacement[lab] = ev["detail"]
|
|
148
|
+
matched.append(lab)
|
|
149
|
+
|
|
150
|
+
if dry_run or not matched:
|
|
151
|
+
# Nothing recoverable to rewrite → no backup, no write (keeps re-runs
|
|
152
|
+
# a true no-op instead of spawning empty .bak files).
|
|
153
|
+
return {
|
|
154
|
+
"candidates": len(candidates),
|
|
155
|
+
"matched": len(matched),
|
|
156
|
+
"unmatched": len(unmatched),
|
|
157
|
+
"matched_labels": sorted(matched),
|
|
158
|
+
"unmatched_labels": sorted(unmatched),
|
|
159
|
+
"written": False,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Backup before any write; abort the whole run if backup fails.
|
|
163
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
164
|
+
bak = "%s.bak-%s" % (evfile, stamp)
|
|
165
|
+
shutil.copy2(evfile, bak)
|
|
166
|
+
|
|
167
|
+
# Stream rewrite: for an affected label, the FIRST usage line becomes the
|
|
168
|
+
# real event (original ts preserved so it stays in its day bucket), every
|
|
169
|
+
# subsequent same-label usage line is dropped → exactly one per label.
|
|
170
|
+
emitted = set()
|
|
171
|
+
out = []
|
|
172
|
+
for raw, obj in parsed:
|
|
173
|
+
if not obj or obj.get("stage") != "usage":
|
|
174
|
+
out.append(raw)
|
|
175
|
+
continue
|
|
176
|
+
lab = obj.get("label")
|
|
177
|
+
if lab not in replacement:
|
|
178
|
+
out.append(raw) # claude / already-real / unmatched-null: untouched
|
|
179
|
+
continue
|
|
180
|
+
if lab in emitted:
|
|
181
|
+
continue # collapse the remaining null duplicates away
|
|
182
|
+
new_ev = {
|
|
183
|
+
"ts": obj.get("ts"),
|
|
184
|
+
"stage": "usage",
|
|
185
|
+
"label": lab,
|
|
186
|
+
"detail": replacement[lab],
|
|
187
|
+
"outcome": "ok",
|
|
188
|
+
}
|
|
189
|
+
out.append(json.dumps(new_ev) + "\n")
|
|
190
|
+
emitted.add(lab)
|
|
191
|
+
|
|
192
|
+
tmp = evfile + ".tmp-%s" % stamp
|
|
193
|
+
with open(tmp, "w") as f:
|
|
194
|
+
f.writelines(out)
|
|
195
|
+
os.replace(tmp, evfile)
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"candidates": len(candidates),
|
|
199
|
+
"matched": len(matched),
|
|
200
|
+
"unmatched": len(unmatched),
|
|
201
|
+
"matched_labels": sorted(matched),
|
|
202
|
+
"unmatched_labels": sorted(unmatched),
|
|
203
|
+
"backup": bak,
|
|
204
|
+
"written": True,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv=None):
|
|
209
|
+
ap = argparse.ArgumentParser(description="backfill pi/deepseek usage into events file")
|
|
210
|
+
ap.add_argument("--slug", help="project slug (resolves default events path + session cwd)")
|
|
211
|
+
ap.add_argument("--events", help="explicit events file path (overrides --slug default)")
|
|
212
|
+
ap.add_argument("--shared", help="shared root (default ~/.shared/roll)")
|
|
213
|
+
ap.add_argument("--base-dir", help="pi sessions root override (tests)")
|
|
214
|
+
ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
|
|
215
|
+
args = ap.parse_args(argv)
|
|
216
|
+
|
|
217
|
+
evfile = args.events or _default_events_path(args.slug, args.shared)
|
|
218
|
+
if not os.path.isfile(evfile):
|
|
219
|
+
print("[backfill] no events file: %s" % evfile, file=sys.stderr)
|
|
220
|
+
return 1
|
|
221
|
+
if not args.slug:
|
|
222
|
+
# slug is needed to reconstruct session cwd; derive from filename.
|
|
223
|
+
base = os.path.basename(evfile)
|
|
224
|
+
if base.startswith("events-") and base.endswith(".ndjson"):
|
|
225
|
+
args.slug = base[len("events-"):-len(".ndjson")]
|
|
226
|
+
|
|
227
|
+
stats = backfill(
|
|
228
|
+
evfile, slug=args.slug, shared=args.shared,
|
|
229
|
+
base_dir=args.base_dir, dry_run=args.dry_run,
|
|
230
|
+
)
|
|
231
|
+
mode = "DRY-RUN" if args.dry_run else "WROTE"
|
|
232
|
+
print("[backfill] %s %s" % (mode, evfile))
|
|
233
|
+
print(" candidates=%d matched=%d unmatched=%d"
|
|
234
|
+
% (stats["candidates"], stats["matched"], stats["unmatched"]))
|
|
235
|
+
if stats.get("backup"):
|
|
236
|
+
print(" backup=%s" % stats["backup"])
|
|
237
|
+
if stats["unmatched_labels"]:
|
|
238
|
+
print(" unmatched (left as null): %s" % ", ".join(stats["unmatched_labels"]))
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
sys.exit(main())
|
package/lib/i18n.sh
CHANGED
|
@@ -15,31 +15,19 @@
|
|
|
15
15
|
# > (macOS) AppleLanguages > 'en'.
|
|
16
16
|
# Decision: value starting with `zh` → "zh", everything else → "en".
|
|
17
17
|
|
|
18
|
-
# Sanitize a free-form key into a variable-safe suffix. Anything that isn't a
|
|
19
|
-
# letter, digit, or underscore becomes an underscore so callers can use natural
|
|
20
|
-
# dotted keys like "loop.cycle_start" without exploding bash syntax.
|
|
21
|
-
_i18n_safe_key() {
|
|
22
|
-
echo "${1//[^A-Za-z0-9_]/_}"
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
# Uppercase a lang code without forking for the EN/ZH common case.
|
|
26
|
-
# macOS still ships bash 3.2 which doesn't have ${var^^}; this helper keeps
|
|
27
|
-
# the no-subshell goal for the languages we actually ship.
|
|
28
|
-
_i18n_upper() {
|
|
29
|
-
case "$1" in
|
|
30
|
-
en|EN) printf 'EN' ;;
|
|
31
|
-
zh|ZH) printf 'ZH' ;;
|
|
32
|
-
*) printf '%s' "$1" | tr '[:lower:]' '[:upper:]' ;;
|
|
33
|
-
esac
|
|
34
|
-
}
|
|
35
|
-
|
|
36
18
|
# Fill the catalog. Modules call this at source-time:
|
|
37
19
|
# _i18n_set en hello "Hello, %s!"
|
|
38
20
|
# _i18n_set zh hello "你好,%s!"
|
|
21
|
+
# No subshell forks: lang uppercasing uses a case statement (bash 3.2-safe;
|
|
22
|
+
# ${var^^} requires bash 4+), key sanitization uses inline param-expansion.
|
|
39
23
|
_i18n_set() {
|
|
40
24
|
local lang="$1" key="$2" val="$3"
|
|
41
25
|
local upper safe varname
|
|
42
|
-
|
|
26
|
+
case "$lang" in
|
|
27
|
+
en|EN) upper=EN ;;
|
|
28
|
+
zh|ZH) upper=ZH ;;
|
|
29
|
+
*) upper="$(printf '%s' "$lang" | tr '[:lower:]' '[:upper:]')" ;;
|
|
30
|
+
esac
|
|
43
31
|
safe="${key//[^A-Za-z0-9_]/_}" # inline param-expansion — no subshell fork
|
|
44
32
|
varname="MSG_${upper}_${safe}"
|
|
45
33
|
printf -v "$varname" '%s' "$val"
|
|
@@ -135,7 +123,11 @@ msg() {
|
|
|
135
123
|
msg_lang() {
|
|
136
124
|
local lang="$1" key="$2"; shift 2 || true
|
|
137
125
|
local upper safe varname tmpl
|
|
138
|
-
|
|
126
|
+
case "$lang" in
|
|
127
|
+
en|EN) upper=EN ;;
|
|
128
|
+
zh|ZH) upper=ZH ;;
|
|
129
|
+
*) upper="$(printf '%s' "$lang" | tr '[:lower:]' '[:upper:]')" ;;
|
|
130
|
+
esac
|
|
139
131
|
safe="${key//[^A-Za-z0-9_]/_}"
|
|
140
132
|
varname="MSG_${upper}_${safe}"
|
|
141
133
|
tmpl="${!varname:-}"
|
package/lib/loop-fmt.py
CHANGED
|
@@ -445,9 +445,10 @@ def _passthrough_main(agent):
|
|
|
445
445
|
"""Transparent forwarding for non-claude agents (pi, deepseek, kimi, …).
|
|
446
446
|
|
|
447
447
|
Writes every stdin line to stdout with a HH:MM:SS timestamp prefix so
|
|
448
|
-
tmux shows real-time progress.
|
|
449
|
-
|
|
450
|
-
|
|
448
|
+
tmux shows real-time progress. Accumulates all lines; at cycle end,
|
|
449
|
+
dispatches to the agent_usage plugin registry (US-LOOP-026). If a plugin
|
|
450
|
+
returns real token/cost data, emits a single usage event with it;
|
|
451
|
+
otherwise falls back to a single null-payload event (US-LOOP-010 compat).
|
|
451
452
|
"""
|
|
452
453
|
slug = os.environ.get("LOOP_PROJECT_SLUG")
|
|
453
454
|
cycle = os.environ.get("LOOP_CYCLE_ID")
|
|
@@ -460,24 +461,35 @@ def _passthrough_main(agent):
|
|
|
460
461
|
except Exception:
|
|
461
462
|
evfile = None
|
|
462
463
|
|
|
464
|
+
# Accumulate all lines for end-of-cycle usage extraction.
|
|
465
|
+
accumulated: list[str] = []
|
|
466
|
+
|
|
463
467
|
for line in sys.stdin:
|
|
464
468
|
if not line.rstrip():
|
|
465
469
|
continue
|
|
470
|
+
accumulated.append(line.rstrip())
|
|
466
471
|
# Timestamp prefix so tmux shows activity (even if agent output has
|
|
467
472
|
# no timestamps of its own).
|
|
468
473
|
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
|
469
474
|
out = f"{DARK_GRAY}{ts}{RESET} {line.rstrip()}"
|
|
470
475
|
sys.stdout.write(out + "\n")
|
|
471
476
|
sys.stdout.flush()
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
+
|
|
478
|
+
# Passthrough is display-only. Usage is NOT emitted from here:
|
|
479
|
+
# - pi -p text mode carries no usage in stdout (nothing to extract), and
|
|
480
|
+
# - this runs once per retry attempt, so emitting here wrote N usage
|
|
481
|
+
# events per cycle and the dashboard SUMS same-label usage → ×N.
|
|
482
|
+
# Instead bin/roll calls agent_usage/pi_emit.py exactly once after the
|
|
483
|
+
# agent phase, recovering real usage from pi's session files. The
|
|
484
|
+
# _emit_* helpers below are retained for US-LOOP-010 unit tests.
|
|
485
|
+
_ = (accumulated, evfile) # intentionally unused now
|
|
477
486
|
|
|
478
487
|
|
|
479
488
|
def _emit_passthrough_event(evfile, cycle, agent, text):
|
|
480
|
-
"""Best-effort append a usage-type event to evfile.
|
|
489
|
+
"""Best-effort append a usage-type event to evfile (null payload).
|
|
490
|
+
|
|
491
|
+
Kept for backward-compat with US-LOOP-010 tests.
|
|
492
|
+
"""
|
|
481
493
|
payload = {
|
|
482
494
|
"model": agent,
|
|
483
495
|
"input_tokens": None,
|
|
@@ -499,6 +511,52 @@ def _emit_passthrough_event(evfile, cycle, agent, text):
|
|
|
499
511
|
pass
|
|
500
512
|
|
|
501
513
|
|
|
514
|
+
def _emit_final_usage_event(evfile, cycle, agent, accumulated_lines):
|
|
515
|
+
"""Try plugin extraction; emit one usage event (real or null).
|
|
516
|
+
|
|
517
|
+
US-LOOP-026: at cycle end, dispatches accumulated stdout to the
|
|
518
|
+
agent_usage plugin registry. If a plugin returns real data, emits
|
|
519
|
+
a usage event with it. Otherwise emits a single null-payload event
|
|
520
|
+
(US-LOOP-010 backward-compat).
|
|
521
|
+
"""
|
|
522
|
+
payload = None
|
|
523
|
+
try:
|
|
524
|
+
from agent_usage import extract_usage
|
|
525
|
+
usage = extract_usage(agent, accumulated_lines)
|
|
526
|
+
if usage is not None:
|
|
527
|
+
payload = {
|
|
528
|
+
"model": usage.get("model", agent),
|
|
529
|
+
"input_tokens": usage.get("input_tokens"),
|
|
530
|
+
"output_tokens": usage.get("output_tokens"),
|
|
531
|
+
"cost_list_usd": usage.get("cost_list_usd"),
|
|
532
|
+
"duration_ms": usage.get("duration_ms"),
|
|
533
|
+
}
|
|
534
|
+
except Exception:
|
|
535
|
+
pass
|
|
536
|
+
|
|
537
|
+
if payload is None:
|
|
538
|
+
payload = {
|
|
539
|
+
"model": agent,
|
|
540
|
+
"input_tokens": None,
|
|
541
|
+
"output_tokens": None,
|
|
542
|
+
"cost_list_usd": None,
|
|
543
|
+
"duration_ms": None,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
record = json.dumps({
|
|
547
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
548
|
+
"stage": "usage",
|
|
549
|
+
"label": cycle,
|
|
550
|
+
"detail": payload,
|
|
551
|
+
"outcome": "ok",
|
|
552
|
+
}) + "\n"
|
|
553
|
+
try:
|
|
554
|
+
with open(evfile, "a") as f:
|
|
555
|
+
f.write(record)
|
|
556
|
+
except Exception:
|
|
557
|
+
pass
|
|
558
|
+
|
|
559
|
+
|
|
502
560
|
def main():
|
|
503
561
|
agent = os.environ.get("ROLL_LOOP_AGENT", "claude")
|
|
504
562
|
if agent == "claude":
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "2026-05-23",
|
|
3
3
|
"effective_at": "2026-05-23",
|
|
4
|
-
"source_url": "https://api-docs.deepseek.com/quick_start/pricing",
|
|
4
|
+
"source_url": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
5
5
|
"vendor": "deepseek",
|
|
6
|
-
"currency": "
|
|
6
|
+
"currency": "CNY",
|
|
7
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
|
|
8
|
+
"notes": "Rates per million tokens in CNY (¥) — DeepSeek's native billing currency; we never convert to USD (the dashboard already shows the currency symbol). deepseek-chat and deepseek-reasoner are both deepseek-v4-flash with different thinking modes — same pricing. deepseek-v4-pro is under a 2.5折 (75% off) promo until Beijing time 2026-05-31 23:59 (normal: in 12 / out 24); after expiry, refresh this snapshot. cache_read is the official cache-hit input price (reduced to 1/10 of launch price since 2026-04-26). cache_create = cache-miss input rate: DeepSeek levies no separate cache-write surcharge, and pi reports cacheWrite cost as 0, so this rate only ever applies to (near-zero) cacheWrite tokens. pi's own per-message cost.total is computed in USD and is kept as cost_reported_usd for audit, NOT used for the authoritative cost.",
|
|
9
9
|
"prices": {
|
|
10
|
-
"deepseek-chat": {"in":
|
|
11
|
-
"deepseek-reasoner": {"in":
|
|
12
|
-
"deepseek-v4-flash": {"in":
|
|
13
|
-
"deepseek-v4-pro": {"in":
|
|
10
|
+
"deepseek-chat": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
|
|
11
|
+
"deepseek-reasoner": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
|
|
12
|
+
"deepseek-v4-flash": {"in": 1, "out": 2, "cache_create": 1, "cache_read": 0.02},
|
|
13
|
+
"deepseek-v4-pro": {"in": 3, "out": 6, "cache_create": 3, "cache_read": 0.025}
|
|
14
14
|
}
|
|
15
15
|
}
|
package/lib/roll-loop-status.py
CHANGED
|
@@ -711,8 +711,13 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
711
711
|
# show two metric rows. cache_read tokens deliberately excluded — they're
|
|
712
712
|
# already captured in cy["cost_list"] via list-price math (compute_list_cost
|
|
713
713
|
# reads all 4 fields), but they don't represent the model's actual work.
|
|
714
|
+
# FIX-126: cost is tracked per-currency. deepseek bills in native CNY (¥),
|
|
715
|
+
# claude in USD ($) — summing them into one number (and stamping it "$")
|
|
716
|
+
# is meaningless. `cost` stays as a legacy scalar sum for back-compat with
|
|
717
|
+
# callers that don't care about currency; `cost_by_cur` is the currency-
|
|
718
|
+
# aware breakdown the dashboard ROLLUP renders (one row per currency).
|
|
714
719
|
r = {"cycles": len(day_cycles), "prs": 0, "failed": 0,
|
|
715
|
-
"duration_s": 0, "cost": 0.0,
|
|
720
|
+
"duration_s": 0, "cost": 0.0, "cost_by_cur": {},
|
|
716
721
|
"input_tokens": 0, "output_tokens": 0,
|
|
717
722
|
"cache_creation_tokens": 0, "cache_read_tokens": 0}
|
|
718
723
|
for cy in day_cycles:
|
|
@@ -736,10 +741,14 @@ def rollup_for_day(day_cycles: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
736
741
|
r["prs"] += 1
|
|
737
742
|
if cy.get("cost_list") is not None:
|
|
738
743
|
r["cost"] += cy["cost_list"]
|
|
744
|
+
cur = cy.get("cost_currency") or "USD"
|
|
745
|
+
r["cost_by_cur"][cur] = r["cost_by_cur"].get(cur, 0.0) + cy["cost_list"]
|
|
739
746
|
elif cy.get("cron"):
|
|
740
747
|
# No claude session backfill available — fall back to whatever
|
|
741
|
-
# cron.log carries (best-effort, only the latest cycle).
|
|
748
|
+
# cron.log carries (best-effort, only the latest cycle). cron.log
|
|
749
|
+
# cost is claude's USD figure.
|
|
742
750
|
r["cost"] += cy["cron"]["cost"]
|
|
751
|
+
r["cost_by_cur"]["USD"] = r["cost_by_cur"].get("USD", 0.0) + cy["cron"]["cost"]
|
|
743
752
|
return r
|
|
744
753
|
|
|
745
754
|
# ════════════════════════════════════════════════════════════════════════════
|
|
@@ -753,8 +762,12 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
753
762
|
merge_runs_into_cycles(cycles, runs)
|
|
754
763
|
if git_merges:
|
|
755
764
|
repair_orphan_cycles_from_git(cycles, git_merges)
|
|
756
|
-
|
|
757
|
-
|
|
765
|
+
# Path 1 (usage_event from the events stream) is authoritative and needs no
|
|
766
|
+
# slug; path 2 (claude session-log salvage) self-guards on the worktree dir
|
|
767
|
+
# existing, so it's a no-op when claude_slug is empty. Always run both — the
|
|
768
|
+
# old `if claude_slug:` gate dropped real per-currency cost for any caller
|
|
769
|
+
# that didn't pass a slug (FIX-126).
|
|
770
|
+
backfill_usage_from_claude_sessions(cycles, claude_slug or "")
|
|
758
771
|
by_day = bucket_by_day(cycles)
|
|
759
772
|
days_keys = sorted(by_day.keys(), reverse=True)[:days]
|
|
760
773
|
|
|
@@ -894,7 +907,28 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
894
907
|
metric_tokens("cache writes", today["cache_creation_tokens"], yest["cache_creation_tokens"], d2["cache_creation_tokens"], partial=is_partial)
|
|
895
908
|
metric_tokens("cache reads", today["cache_read_tokens"], yest["cache_read_tokens"], d2["cache_read_tokens"], partial=is_partial)
|
|
896
909
|
metric_tokens("output tokens", today["output_tokens"], yest["output_tokens"], d2["output_tokens"], partial=is_partial)
|
|
897
|
-
|
|
910
|
+
# FIX-126: one cost row per currency (deepseek ¥, claude $) — never summed
|
|
911
|
+
# across currencies. Show a currency only if it has spend in any of the 3
|
|
912
|
+
# days; default to a single USD row when there's no cost at all.
|
|
913
|
+
_cost_days = (today, yest, d2)
|
|
914
|
+
_currencies = []
|
|
915
|
+
for _cur in ["USD", "CNY"]:
|
|
916
|
+
if any(r["cost_by_cur"].get(_cur) for r in _cost_days):
|
|
917
|
+
_currencies.append(_cur)
|
|
918
|
+
for r in _cost_days:
|
|
919
|
+
for _cur in r["cost_by_cur"]:
|
|
920
|
+
if _cur not in _currencies and r["cost_by_cur"][_cur]:
|
|
921
|
+
_currencies.append(_cur)
|
|
922
|
+
if not _currencies:
|
|
923
|
+
_currencies = ["USD"]
|
|
924
|
+
for _cur in _currencies:
|
|
925
|
+
_sym = "¥" if _cur == "CNY" else "$"
|
|
926
|
+
_label = "cost" if len(_currencies) == 1 else "cost " + _sym
|
|
927
|
+
metric_dollar(_label,
|
|
928
|
+
today["cost_by_cur"].get(_cur, 0.0),
|
|
929
|
+
yest["cost_by_cur"].get(_cur, 0.0),
|
|
930
|
+
d2["cost_by_cur"].get(_cur, 0.0),
|
|
931
|
+
partial=is_partial, symbol=_sym)
|
|
898
932
|
|
|
899
933
|
print()
|
|
900
934
|
print(c("faint", "─" * COLS))
|
|
@@ -931,13 +965,10 @@ def render(events, cron, state, backlog, *, days=3, lang="both", now=None,
|
|
|
931
965
|
c("muted", " ") +
|
|
932
966
|
c("dim", "more ") + c("blue", "roll loop status --days 7"))
|
|
933
967
|
|
|
934
|
-
# US-LOOP-
|
|
935
|
-
_VALID_SCHEDULE_PERIODS = {60, 30, 20, 15, 12, 10, 6, 5}
|
|
936
|
-
|
|
937
|
-
|
|
968
|
+
# US-LOOP-032: period 1–1440; offset 0–59 (deprecated, kept for backward compat)
|
|
938
969
|
def _schedule_valid(period: int, offset: int) -> bool:
|
|
939
|
-
"""Validate schedule spec: period
|
|
940
|
-
return period
|
|
970
|
+
"""Validate schedule spec: period 1–1440, offset in [0, 60)."""
|
|
971
|
+
return 1 <= period <= 1440 and 0 <= offset < 60
|
|
941
972
|
|
|
942
973
|
|
|
943
974
|
def _read_schedule_spec(project_root: Optional[Path] = None) -> Tuple[int, int]:
|
package/lib/roll_render.py
CHANGED
|
@@ -146,8 +146,8 @@ def fmt_delta(today: float, yest: float, *, kind: str, unit: str = "") -> Tuple[
|
|
|
146
146
|
arrow = "▲" if diff > 0 else "▼"
|
|
147
147
|
sign = "+" if diff > 0 else "−"
|
|
148
148
|
mag = abs(diff)
|
|
149
|
-
if unit
|
|
150
|
-
body = f"{sign}
|
|
149
|
+
if unit in ("$", "¥"):
|
|
150
|
+
body = f"{sign}{unit}{mag:.2f}"
|
|
151
151
|
elif unit == "m":
|
|
152
152
|
body = f"{sign}{int(round(mag))}m"
|
|
153
153
|
else:
|
|
@@ -215,16 +215,20 @@ def metric_dur(name: str, t: int, y: int, d2: int, *, partial: bool = False) ->
|
|
|
215
215
|
c("dim", pad(fmt_dur(y), 10)) +
|
|
216
216
|
c("muted", pad(fmt_dur(d2), 8)))
|
|
217
217
|
|
|
218
|
-
def metric_dollar(name: str, t: float, y: float, d2: float, *,
|
|
219
|
-
|
|
218
|
+
def metric_dollar(name: str, t: float, y: float, d2: float, *,
|
|
219
|
+
partial: bool = False, symbol: str = "$") -> None:
|
|
220
|
+
# FIX-126: currency-aware — deepseek cost is native CNY (¥), claude USD ($).
|
|
221
|
+
# We never convert; the rollup shows one row per currency with its own
|
|
222
|
+
# symbol, so a ¥-row and a $-row are never summed into a meaningless total.
|
|
223
|
+
delta_text, delta_c = fmt_delta(t, y, kind="up_bad", unit=symbol)
|
|
220
224
|
if partial and delta_c not in ("muted",):
|
|
221
225
|
delta_c = "muted"
|
|
222
226
|
print(" " +
|
|
223
227
|
c("dim", pad(name, 14)) +
|
|
224
|
-
c("fg", pad(f"
|
|
228
|
+
c("fg", pad(f"{symbol}{t:.2f}", 8, "r"), bold=True) + " " +
|
|
225
229
|
c(delta_c, pad(delta_text, 12), bold=(delta_c != "muted")) +
|
|
226
|
-
c("dim", pad(f"
|
|
227
|
-
c("muted", pad(f"
|
|
230
|
+
c("dim", pad(f"{symbol}{y:.2f}", 10)) +
|
|
231
|
+
c("muted", pad(f"{symbol}{d2:.2f}", 8)))
|
|
228
232
|
|
|
229
233
|
def metric_tokens(name: str, t: int, y: int, d2: int, *, partial: bool = False) -> None:
|
|
230
234
|
# Compose the delta string with token-unit scaling so a 200M increase
|
package/package.json
CHANGED
|
@@ -121,7 +121,7 @@ Document structure (two-layer separation):
|
|
|
121
121
|
3. **FIX / IDEA detail files use ID-prefixed filenames**: `.roll/features/<epic>/FIX-097.md`, not `.roll/features/<epic>/some-descriptive-slug.md`. Reason: a single FIX is one card, not a long-lived feature; the ID is the most stable handle, descriptive slugs date quickly and break links. US can keep feature-slug naming (US lives inside a multi-Story feature file). Quick lookup: `ls .roll/features/<epic>/FIX-*.md` finds all bugs in that area without grepping content.
|
|
122
122
|
4. .roll/backlog.md only contains index rows (one row per US), **do not write** AC / Files / Notes
|
|
123
123
|
5. Domain model files go in `.roll/domain/` — create on first greenfield design, update incrementally
|
|
124
|
-
6. **Do not** write to `~/.kimi
|
|
124
|
+
6. **Do not** write to `~/.kimi/`, `~/.kimi-code/`, or any global config directory
|
|
125
125
|
|
|
126
126
|
**File path resolution order:**
|
|
127
127
|
1. Determine Feature ownership (based on the requirement domain: compiler / ingest / qa / ...)
|
|
@@ -11,12 +11,12 @@ jobs:
|
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
12
|
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
15
|
|
|
16
16
|
- name: Setup Node.js
|
|
17
17
|
uses: actions/setup-node@v4
|
|
18
18
|
with:
|
|
19
|
-
node-version: '
|
|
19
|
+
node-version: '24'
|
|
20
20
|
cache: 'npm'
|
|
21
21
|
|
|
22
22
|
- name: Install dependencies
|