@seanyao/roll 0.5.0 → 2.602.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 +717 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +14897 -815
- package/conventions/config.yaml +17 -1
- package/conventions/global/AGENTS.md +146 -100
- package/conventions/global/CLAUDE.md +1 -21
- package/conventions/global/GEMINI.md +8 -22
- package/conventions/global/project_rules.md +9 -0
- package/conventions/templates/backend-service/AGENTS.md +30 -81
- package/conventions/templates/backend-service/GEMINI.md +3 -3
- package/conventions/templates/backend-service/project_rules.md +16 -0
- package/conventions/templates/cli/AGENTS.md +31 -58
- package/conventions/templates/cli/CLAUDE.md +3 -5
- package/conventions/templates/cli/GEMINI.md +3 -3
- package/conventions/templates/cli/project_rules.md +16 -0
- package/conventions/templates/frontend-only/AGENTS.md +29 -64
- package/conventions/templates/frontend-only/GEMINI.md +3 -3
- package/conventions/templates/frontend-only/project_rules.md +14 -0
- package/conventions/templates/fullstack/AGENTS.md +31 -79
- package/conventions/templates/fullstack/CLAUDE.md +1 -1
- package/conventions/templates/fullstack/GEMINI.md +3 -3
- package/conventions/templates/fullstack/project_rules.md +15 -0
- package/lib/README.md +42 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.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_git.cpython-314.pyc +0 -0
- package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
- package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/README.md +49 -0
- package/lib/agent_usage/__init__.py +108 -0
- package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/openai.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/__pycache__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +278 -0
- package/lib/agent_usage/kimi_emit.py +123 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/pi.py +200 -0
- package/lib/agent_usage/pi_emit.py +135 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/backfill-pi-usage.py +243 -0
- package/lib/changelog_audit.py +155 -0
- package/lib/changelog_generate.py +263 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/README.md +54 -0
- package/lib/i18n/agent.sh +75 -0
- package/lib/i18n/alert.sh +20 -0
- package/lib/i18n/backlog.sh +96 -0
- package/lib/i18n/brief.sh +5 -0
- package/lib/i18n/changelog.sh +5 -0
- package/lib/i18n/ci.sh +15 -0
- package/lib/i18n/debug.sh +0 -0
- package/lib/i18n/doctor.sh +44 -0
- package/lib/i18n/dream.sh +0 -0
- package/lib/i18n/init.sh +91 -0
- package/lib/i18n/lang.sh +10 -0
- package/lib/i18n/loop.sh +140 -0
- package/lib/i18n/migrate.sh +74 -0
- package/lib/i18n/offboard.sh +31 -0
- package/lib/i18n/onboard.sh +0 -0
- package/lib/i18n/peer.sh +41 -0
- package/lib/i18n/peer_help.sh +25 -0
- package/lib/i18n/peer_reset.sh +7 -0
- package/lib/i18n/peer_status.sh +5 -0
- package/lib/i18n/prices.sh +3 -0
- package/lib/i18n/prices_refresh.sh +17 -0
- package/lib/i18n/prices_show.sh +7 -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 +3 -0
- package/lib/i18n/shared.sh +157 -0
- package/lib/i18n/skills/roll-brief.sh +47 -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 +53 -0
- package/lib/i18n/skills/roll-loop.sh +28 -0
- package/lib/i18n/skills/roll-onboard.sh +33 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/i18n/slides.sh +3 -0
- package/lib/i18n/slides_build.sh +38 -0
- package/lib/i18n/slides_delete.sh +19 -0
- package/lib/i18n/slides_list.sh +14 -0
- package/lib/i18n/slides_logs.sh +12 -0
- package/lib/i18n/slides_new.sh +15 -0
- package/lib/i18n/slides_preview.sh +14 -0
- package/lib/i18n/slides_templates.sh +7 -0
- package/lib/i18n/status.sh +21 -0
- package/lib/i18n/update.sh +24 -0
- package/lib/i18n.sh +211 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +589 -0
- package/lib/loop_pick_agent.py +316 -0
- package/lib/loop_result_eval.py +469 -0
- package/lib/loop_unstick.py +180 -0
- package/lib/model_prices.py +186 -0
- package/lib/prices/README.md +35 -0
- package/lib/prices/snapshot-2026-05-22.json +22 -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/prices_fetcher.py +285 -0
- package/lib/roll-backlog.py +225 -0
- package/lib/roll-brief.py +286 -0
- package/lib/roll-help.py +158 -0
- package/lib/roll-home.py +556 -0
- package/lib/roll-init.py +156 -0
- package/lib/roll-loop-status.py +1683 -0
- package/lib/roll-loop-story.py +191 -0
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +252 -0
- package/lib/roll-plan-validate.py +386 -0
- package/lib/roll-setup.py +102 -0
- package/lib/roll-status.py +367 -0
- package/lib/roll_git.py +41 -0
- package/lib/roll_render.py +414 -0
- package/lib/slides/components/README.md +123 -0
- package/lib/slides/components/cards-2.html +9 -0
- package/lib/slides/components/cards-3.html +9 -0
- package/lib/slides/components/cards-4.html +9 -0
- package/lib/slides/components/compare.html +22 -0
- package/lib/slides/components/highlight.html +9 -0
- package/lib/slides/components/pipeline.html +12 -0
- package/lib/slides/components/plain.html +7 -0
- package/lib/slides/components/quote.html +4 -0
- package/lib/slides/components/timeline.html +9 -0
- package/lib/slides/templates/introduction-v3.html +571 -0
- package/lib/slides/templates/pitch.html +0 -0
- package/lib/slides-render.py +778 -0
- package/lib/slides-validate.py +357 -0
- package/lib/test_quality_gate.py +143 -0
- package/package.json +8 -7
- package/skills/roll-.changelog/SKILL.md +406 -33
- package/skills/roll-.clarify/SKILL.md +5 -2
- package/skills/roll-.dream/SKILL.md +374 -0
- package/skills/roll-.echo/SKILL.md +5 -2
- package/skills/roll-.qa/SKILL.md +57 -3
- package/skills/roll-.review/SKILL.md +42 -3
- package/skills/roll-brief/SKILL.md +209 -0
- package/skills/roll-build/SKILL.md +308 -63
- package/skills/roll-debug/SKILL.md +341 -162
- package/skills/roll-debug/injectable-bb.js +263 -0
- package/skills/roll-deck/SKILL.md +296 -0
- package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
- package/skills/roll-design/SKILL.md +727 -94
- package/skills/roll-doc/SKILL.md +595 -0
- package/skills/roll-doctor/SKILL.md +192 -0
- package/skills/roll-fix/SKILL.md +149 -32
- package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
- package/skills/roll-loop/SKILL.md +578 -0
- package/skills/roll-notes/SKILL.md +103 -0
- package/skills/roll-onboard/SKILL.md +234 -0
- package/skills/roll-peer/SKILL.md +336 -0
- package/skills/roll-propose/SKILL.md +157 -0
- package/skills/roll-review-pr/SKILL.md +58 -0
- package/skills/roll-sentinel/SKILL.md +11 -2
- package/skills/roll-spar/SKILL.md +8 -6
- package/template/.github/workflows/ci.yml +5 -2
- package/template/AGENTS.md +20 -74
- package/skills/roll-research/SKILL.md +0 -307
- package/skills/roll-research/references/schema.json +0 -162
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
- package/tools/roll-fetch/SKILL.md +0 -182
- package/tools/roll-fetch/package.json +0 -15
- package/tools/roll-fetch/smart-web-fetch.js +0 -558
- package/tools/roll-probe/SKILL.md +0 -84
- /package/template/{BACKLOG.md → .roll/backlog.md} +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gemini (Google Gemini CLI) agent usage extractor.
|
|
3
|
+
|
|
4
|
+
Like openai (and unlike pi, which persists usage to session files), the
|
|
5
|
+
Gemini CLI prints a token-usage summary to stdout at the end of a session.
|
|
6
|
+
So this plugin implements the standard ``extract()`` registry contract:
|
|
7
|
+
scrape the passthrough stdout lines for the usage / model lines.
|
|
8
|
+
|
|
9
|
+
Recognised lines (case-insensitive, robust to thousands separators)::
|
|
10
|
+
|
|
11
|
+
Model: gemini-2.5-pro
|
|
12
|
+
Tokens: input=15300 output=3120
|
|
13
|
+
|
|
14
|
+
The Gemini CLI's "stats" / session-summary block is also accepted::
|
|
15
|
+
|
|
16
|
+
Input tokens: 15,300
|
|
17
|
+
Output tokens: 3,120
|
|
18
|
+
Total tokens: 18,420
|
|
19
|
+
model: gemini-2.5-flash
|
|
20
|
+
|
|
21
|
+
When an explicit USD cost line isn't present, cost is computed from
|
|
22
|
+
``lib/model_prices.py`` (list price) so the dashboard never shows ``—``
|
|
23
|
+
for a recognised gemini cycle. Returns None if no usage line is found,
|
|
24
|
+
so the caller falls back to the null payload (US-LOOP-010 compatible).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import sys
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
# model_prices lives one level up (lib/), alongside this package.
|
|
33
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
34
|
+
try:
|
|
35
|
+
import model_prices
|
|
36
|
+
except Exception: # pragma: no cover - import guard
|
|
37
|
+
model_prices = None
|
|
38
|
+
|
|
39
|
+
# Default model when the output omits an explicit model line.
|
|
40
|
+
_DEFAULT_MODEL = "gemini-2.5-pro"
|
|
41
|
+
|
|
42
|
+
_MODEL_RE = re.compile(r"^\s*model\s*[:=]\s*([A-Za-z0-9][\w.\-]*)", re.IGNORECASE)
|
|
43
|
+
_INPUT_RE = re.compile(r"input(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
44
|
+
_OUTPUT_RE = re.compile(r"output(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
45
|
+
_TOTAL_RE = re.compile(r"total(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
46
|
+
_COST_RE = re.compile(r"cost\s*[:=]?\s*\$?\s*([\d.]+)\s*(?:usd)?", re.IGNORECASE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_int(s: str) -> int:
|
|
50
|
+
"""Parse a token count string, tolerating thousands separators."""
|
|
51
|
+
return int(s.replace(",", ""))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extract(stdin_lines: list[str]) -> Optional[dict]:
|
|
55
|
+
"""Parse Gemini CLI stdout and return a usage dict, or None.
|
|
56
|
+
|
|
57
|
+
Scans every line (the usage summary is at the tail but may be wrapped
|
|
58
|
+
by surrounding text) and accumulates the last seen model / token / cost
|
|
59
|
+
values. Requires at least one of input/output/total tokens to be found;
|
|
60
|
+
otherwise returns None (caller falls back to null payload).
|
|
61
|
+
"""
|
|
62
|
+
if not stdin_lines:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
model = None
|
|
66
|
+
tin = tout = ttotal = None
|
|
67
|
+
cost = None
|
|
68
|
+
|
|
69
|
+
for raw in stdin_lines:
|
|
70
|
+
line = raw.rstrip("\n")
|
|
71
|
+
|
|
72
|
+
m = _MODEL_RE.match(line)
|
|
73
|
+
if m:
|
|
74
|
+
model = m.group(1)
|
|
75
|
+
|
|
76
|
+
m = _INPUT_RE.search(line)
|
|
77
|
+
if m:
|
|
78
|
+
tin = _to_int(m.group(1))
|
|
79
|
+
|
|
80
|
+
m = _OUTPUT_RE.search(line)
|
|
81
|
+
if m:
|
|
82
|
+
tout = _to_int(m.group(1))
|
|
83
|
+
|
|
84
|
+
m = _TOTAL_RE.search(line)
|
|
85
|
+
if m:
|
|
86
|
+
ttotal = _to_int(m.group(1))
|
|
87
|
+
|
|
88
|
+
m = _COST_RE.search(line)
|
|
89
|
+
if m:
|
|
90
|
+
try:
|
|
91
|
+
cost = float(m.group(1))
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# Require at least one token figure; otherwise this isn't a gemini cycle.
|
|
96
|
+
if tin is None and tout is None and ttotal is None:
|
|
97
|
+
return None
|
|
98
|
+
if tin is None and tout is None and ttotal is not None:
|
|
99
|
+
# No split available — attribute the whole total to input so the
|
|
100
|
+
# cycle is non-zero; output stays 0.
|
|
101
|
+
tin = ttotal
|
|
102
|
+
tout = 0
|
|
103
|
+
else:
|
|
104
|
+
tin = tin or 0
|
|
105
|
+
tout = tout or 0
|
|
106
|
+
if ttotal is not None and tin == 0 and tout == 0:
|
|
107
|
+
tin = ttotal
|
|
108
|
+
|
|
109
|
+
model = model or _DEFAULT_MODEL
|
|
110
|
+
|
|
111
|
+
if cost is None:
|
|
112
|
+
if model_prices is not None:
|
|
113
|
+
cost = model_prices.compute_list_cost(
|
|
114
|
+
model,
|
|
115
|
+
input_tokens=tin,
|
|
116
|
+
output_tokens=tout,
|
|
117
|
+
)
|
|
118
|
+
else: # pragma: no cover - only when model_prices unimportable
|
|
119
|
+
cost = 0.0
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
"model": model,
|
|
123
|
+
"input_tokens": tin,
|
|
124
|
+
"output_tokens": tout,
|
|
125
|
+
"cost_list_usd": cost,
|
|
126
|
+
"duration_ms": None,
|
|
127
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kimi (Moonshot Kimi CLI) agent usage extractor.
|
|
3
|
+
|
|
4
|
+
Two paths are supported, mirroring pi.py:
|
|
5
|
+
|
|
6
|
+
1. ``extract()`` — the registry stdout-scrape contract, kept for legacy
|
|
7
|
+
callers (and as a fallback when session files are absent).
|
|
8
|
+
2. ``usage_from_session()`` — authoritative recovery from kimi-code's
|
|
9
|
+
persisted session files at ``~/.kimi-code/sessions/wd_*/session_*/agents/main/wire.jsonl``.
|
|
10
|
+
Each wire file is NDJSON with one or more ``{"type":"usage.record","model":...,"usage":{...}}``
|
|
11
|
+
lines whose token fields are summed per cycle.
|
|
12
|
+
|
|
13
|
+
FIX-154 added the session path so loop cycles run by kimi-code (the
|
|
14
|
+
default agent today) no longer show ``—/—`` for tokens and cost in the
|
|
15
|
+
RECENT dashboard.
|
|
16
|
+
|
|
17
|
+
The stdout-scrape contract still recognises (case-insensitive)::
|
|
18
|
+
|
|
19
|
+
Model: kimi-k2
|
|
20
|
+
Tokens: input=15300 output=3120
|
|
21
|
+
Input tokens: 15,300
|
|
22
|
+
Output tokens: 3,120
|
|
23
|
+
Total tokens: 18,420
|
|
24
|
+
|
|
25
|
+
When an explicit USD cost line isn't present, cost is computed from
|
|
26
|
+
``lib/model_prices.py`` (list price).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import glob
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
# model_prices lives one level up (lib/), alongside this package.
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
38
|
+
try:
|
|
39
|
+
import model_prices
|
|
40
|
+
except Exception: # pragma: no cover - import guard
|
|
41
|
+
model_prices = None
|
|
42
|
+
|
|
43
|
+
# Default model when the output omits an explicit model line.
|
|
44
|
+
_DEFAULT_MODEL = "kimi-k2"
|
|
45
|
+
|
|
46
|
+
_MODEL_RE = re.compile(r"^\s*model\s*[:=]\s*([A-Za-z0-9][\w.\-]*)", re.IGNORECASE)
|
|
47
|
+
_INPUT_RE = re.compile(r"input(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
48
|
+
_OUTPUT_RE = re.compile(r"output(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
49
|
+
_TOTAL_RE = re.compile(r"total(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
50
|
+
_COST_RE = re.compile(r"cost\s*[:=]?\s*\$?\s*([\d.]+)\s*(?:usd)?", re.IGNORECASE)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _to_int(s: str) -> int:
|
|
54
|
+
"""Parse a token count string, tolerating thousands separators."""
|
|
55
|
+
return int(s.replace(",", ""))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract(stdin_lines: list[str]) -> Optional[dict]:
|
|
59
|
+
"""Parse Kimi CLI stdout and return a usage dict, or None.
|
|
60
|
+
|
|
61
|
+
Scans every line (the usage summary is at the tail but may be wrapped
|
|
62
|
+
by surrounding text) and accumulates the last seen model / token / cost
|
|
63
|
+
values. Requires at least one of input/output/total tokens to be found;
|
|
64
|
+
otherwise returns None (caller falls back to null payload).
|
|
65
|
+
"""
|
|
66
|
+
if not stdin_lines:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
model = None
|
|
70
|
+
tin = tout = ttotal = None
|
|
71
|
+
cost = None
|
|
72
|
+
|
|
73
|
+
for raw in stdin_lines:
|
|
74
|
+
line = raw.rstrip("\n")
|
|
75
|
+
|
|
76
|
+
m = _MODEL_RE.match(line)
|
|
77
|
+
if m:
|
|
78
|
+
model = m.group(1)
|
|
79
|
+
|
|
80
|
+
m = _INPUT_RE.search(line)
|
|
81
|
+
if m:
|
|
82
|
+
tin = _to_int(m.group(1))
|
|
83
|
+
|
|
84
|
+
m = _OUTPUT_RE.search(line)
|
|
85
|
+
if m:
|
|
86
|
+
tout = _to_int(m.group(1))
|
|
87
|
+
|
|
88
|
+
m = _TOTAL_RE.search(line)
|
|
89
|
+
if m:
|
|
90
|
+
ttotal = _to_int(m.group(1))
|
|
91
|
+
|
|
92
|
+
m = _COST_RE.search(line)
|
|
93
|
+
if m:
|
|
94
|
+
try:
|
|
95
|
+
cost = float(m.group(1))
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
# Require at least one token figure; otherwise this isn't a kimi cycle.
|
|
100
|
+
if tin is None and tout is None and ttotal is None:
|
|
101
|
+
return None
|
|
102
|
+
if tin is None and tout is None and ttotal is not None:
|
|
103
|
+
# No split available — attribute the whole total to input so the
|
|
104
|
+
# cycle is non-zero; output stays 0.
|
|
105
|
+
tin = ttotal
|
|
106
|
+
tout = 0
|
|
107
|
+
else:
|
|
108
|
+
tin = tin or 0
|
|
109
|
+
tout = tout or 0
|
|
110
|
+
if ttotal is not None and tin == 0 and tout == 0:
|
|
111
|
+
tin = ttotal
|
|
112
|
+
|
|
113
|
+
model = model or _DEFAULT_MODEL
|
|
114
|
+
|
|
115
|
+
if cost is None:
|
|
116
|
+
if model_prices is not None:
|
|
117
|
+
cost = model_prices.compute_list_cost(
|
|
118
|
+
model,
|
|
119
|
+
input_tokens=tin,
|
|
120
|
+
output_tokens=tout,
|
|
121
|
+
)
|
|
122
|
+
else: # pragma: no cover - only when model_prices unimportable
|
|
123
|
+
cost = 0.0
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"model": model,
|
|
127
|
+
"input_tokens": tin,
|
|
128
|
+
"output_tokens": tout,
|
|
129
|
+
"cost_list_usd": cost,
|
|
130
|
+
"duration_ms": None,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Session-file extraction (authoritative, FIX-154) ───────────────────────
|
|
135
|
+
|
|
136
|
+
# kimi-code persists every CLI session under
|
|
137
|
+
# ``~/.kimi-code/sessions/wd_<cwd-basename>_<8-hex>/session_<uuid>/agents/main/wire.jsonl``
|
|
138
|
+
# where ``<cwd-basename>`` is the basename of the cycle's worktree
|
|
139
|
+
# (e.g. ``roll-ecf079-cycle-20260601-170905-54957``).
|
|
140
|
+
# Each wire file is NDJSON; one or more lines have::
|
|
141
|
+
#
|
|
142
|
+
# {"type": "usage.record", "model": "kimi-code/kimi-for-coding",
|
|
143
|
+
# "usage": {"inputOther": <int>, "output": <int>,
|
|
144
|
+
# "inputCacheRead": <int>, "inputCacheCreation": <int>},
|
|
145
|
+
# "usageScope": "turn", "time": <ms>}
|
|
146
|
+
#
|
|
147
|
+
# We sum across all matching wire files (retries reuse the same worktree).
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _kimi_sessions_base_dir(base_dir: Optional[str]) -> str:
|
|
151
|
+
"""Resolve kimi-code's sessions root: arg → env → default."""
|
|
152
|
+
return (
|
|
153
|
+
base_dir
|
|
154
|
+
or os.environ.get("ROLL_KIMI_SESSIONS_DIR")
|
|
155
|
+
or os.path.expanduser("~/.kimi-code/sessions")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sum_wire_file(path: str) -> Optional[dict]:
|
|
160
|
+
"""Sum ``usage.record`` lines in a single kimi wire.jsonl.
|
|
161
|
+
|
|
162
|
+
Returns a usage dict or None when no usage records are found.
|
|
163
|
+
Field mapping kimi → roll::
|
|
164
|
+
|
|
165
|
+
inputOther → input_tokens
|
|
166
|
+
output → output_tokens
|
|
167
|
+
inputCacheRead → cache_read_tokens
|
|
168
|
+
inputCacheCreation → cache_creation_tokens
|
|
169
|
+
"""
|
|
170
|
+
tin = tout = tcr = tcw = 0
|
|
171
|
+
model = None
|
|
172
|
+
seen = False
|
|
173
|
+
try:
|
|
174
|
+
with open(path) as f:
|
|
175
|
+
for line in f:
|
|
176
|
+
line = line.strip()
|
|
177
|
+
if not line:
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
o = json.loads(line)
|
|
181
|
+
except json.JSONDecodeError:
|
|
182
|
+
continue
|
|
183
|
+
if o.get("type") != "usage.record":
|
|
184
|
+
continue
|
|
185
|
+
u = o.get("usage") or {}
|
|
186
|
+
seen = True
|
|
187
|
+
if o.get("model"):
|
|
188
|
+
model = o["model"]
|
|
189
|
+
tin += int(u.get("inputOther") or 0)
|
|
190
|
+
tout += int(u.get("output") or 0)
|
|
191
|
+
tcr += int(u.get("inputCacheRead") or 0)
|
|
192
|
+
tcw += int(u.get("inputCacheCreation") or 0)
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
if not seen:
|
|
196
|
+
return None
|
|
197
|
+
return {
|
|
198
|
+
"model": model or _DEFAULT_MODEL,
|
|
199
|
+
"input_tokens": tin,
|
|
200
|
+
"output_tokens": tout,
|
|
201
|
+
"cache_creation_tokens": tcw,
|
|
202
|
+
"cache_read_tokens": tcr,
|
|
203
|
+
"duration_ms": None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def usage_from_session(
|
|
208
|
+
cwd: Optional[str] = None,
|
|
209
|
+
cycle_id: Optional[str] = None,
|
|
210
|
+
slug: Optional[str] = None,
|
|
211
|
+
base_dir: Optional[str] = None,
|
|
212
|
+
) -> Optional[dict]:
|
|
213
|
+
"""Recover a kimi cycle's usage by reading its persisted wire file(s).
|
|
214
|
+
|
|
215
|
+
Matching: scan ``<base>/wd_*/session_*/agents/main/wire.jsonl`` and
|
|
216
|
+
select files whose ``wd_*`` directory name contains the worktree
|
|
217
|
+
basename (authoritative when ``cwd`` is given) or the ``cycle_id``
|
|
218
|
+
substring (fallback).
|
|
219
|
+
|
|
220
|
+
Retries can produce multiple wire files for the same cycle; their
|
|
221
|
+
usage is SUMMED so token totals reflect retry work too.
|
|
222
|
+
|
|
223
|
+
Returns the merged usage dict (tokens + model), or None when nothing
|
|
224
|
+
matches / zero tokens — caller writes nothing in that case, preserving
|
|
225
|
+
"n/a, not fake zero".
|
|
226
|
+
"""
|
|
227
|
+
base = _kimi_sessions_base_dir(base_dir)
|
|
228
|
+
files = sorted(glob.glob(
|
|
229
|
+
os.path.join(base, "wd_*", "session_*", "agents", "main", "wire.jsonl")
|
|
230
|
+
))
|
|
231
|
+
if not files:
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
cwd_basename = os.path.basename(cwd.rstrip("/")) if cwd else None
|
|
235
|
+
matched = []
|
|
236
|
+
for path in files:
|
|
237
|
+
# Session dir name: wd_<cwd-basename>_<8-hex>
|
|
238
|
+
# Path: <base>/wd_<cwd-basename>_<hash>/session_<uuid>/agents/main/wire.jsonl
|
|
239
|
+
wd_seg = path[len(base):].lstrip(os.sep).split(os.sep, 1)[0]
|
|
240
|
+
if cwd_basename and ("wd_%s_" % cwd_basename) in (wd_seg + "_"):
|
|
241
|
+
matched.append(path)
|
|
242
|
+
continue
|
|
243
|
+
if cycle_id and ("cycle-%s" % cycle_id) in wd_seg:
|
|
244
|
+
matched.append(path)
|
|
245
|
+
|
|
246
|
+
if not matched:
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
agg = {
|
|
250
|
+
"model": None,
|
|
251
|
+
"input_tokens": 0,
|
|
252
|
+
"output_tokens": 0,
|
|
253
|
+
"cache_creation_tokens": 0,
|
|
254
|
+
"cache_read_tokens": 0,
|
|
255
|
+
"duration_ms": None,
|
|
256
|
+
}
|
|
257
|
+
got = False
|
|
258
|
+
for path in matched:
|
|
259
|
+
s = _sum_wire_file(path)
|
|
260
|
+
if s is None:
|
|
261
|
+
continue
|
|
262
|
+
got = True
|
|
263
|
+
agg["model"] = agg["model"] or s["model"]
|
|
264
|
+
agg["input_tokens"] += s["input_tokens"]
|
|
265
|
+
agg["output_tokens"] += s["output_tokens"]
|
|
266
|
+
agg["cache_creation_tokens"] += s["cache_creation_tokens"]
|
|
267
|
+
agg["cache_read_tokens"] += s["cache_read_tokens"]
|
|
268
|
+
|
|
269
|
+
if not got:
|
|
270
|
+
return None
|
|
271
|
+
has_tokens = (
|
|
272
|
+
agg["input_tokens"] or agg["output_tokens"]
|
|
273
|
+
or agg["cache_creation_tokens"] or agg["cache_read_tokens"]
|
|
274
|
+
)
|
|
275
|
+
if not has_tokens:
|
|
276
|
+
return None
|
|
277
|
+
agg["model"] = agg["model"] or _DEFAULT_MODEL
|
|
278
|
+
return agg
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
kimi_emit — write ONE authoritative usage event for a finished kimi cycle.
|
|
4
|
+
|
|
5
|
+
Mirror of ``pi_emit.py``: invoked once by bin/roll after the agent phase
|
|
6
|
+
when ROLL_LOOP_AGENT == "kimi". Recovers the cycle's real usage from
|
|
7
|
+
kimi-code's persisted ``wire.jsonl`` files via ``kimi.usage_from_session``
|
|
8
|
+
and appends a single ``stage=="usage"`` event to the loop events file.
|
|
9
|
+
|
|
10
|
+
Exactly one event per cycle — the dashboard SUMS token fields across
|
|
11
|
+
same-label usage events, so a per-retry write path would inflate ×N.
|
|
12
|
+
|
|
13
|
+
Cost is frozen at the active price snapshot via
|
|
14
|
+
``model_prices.compute_list_cost`` in the model's native currency.
|
|
15
|
+
|
|
16
|
+
When ``usage_from_session`` finds nothing (no matching session, zero
|
|
17
|
+
tokens) we write nothing — preserving "show n/a, not a fake zero".
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
|
|
27
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
_LIB_DIR = os.path.dirname(_THIS_DIR)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_model_prices():
|
|
32
|
+
spec = importlib.util.spec_from_file_location(
|
|
33
|
+
"model_prices", os.path.join(_LIB_DIR, "model_prices.py")
|
|
34
|
+
)
|
|
35
|
+
mp = importlib.util.module_from_spec(spec)
|
|
36
|
+
spec.loader.exec_module(mp)
|
|
37
|
+
return mp
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_kimi():
|
|
41
|
+
spec = importlib.util.spec_from_file_location(
|
|
42
|
+
"agent_usage_kimi", os.path.join(_THIS_DIR, "kimi.py")
|
|
43
|
+
)
|
|
44
|
+
kimi = importlib.util.module_from_spec(spec)
|
|
45
|
+
spec.loader.exec_module(kimi)
|
|
46
|
+
return kimi
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_event(cwd=None, cycle_id=None, slug=None, base_dir=None):
|
|
50
|
+
"""Return the (line dict) usage event for a kimi cycle, or None to skip."""
|
|
51
|
+
kimi = _load_kimi()
|
|
52
|
+
u = kimi.usage_from_session(
|
|
53
|
+
cwd=cwd, cycle_id=cycle_id, slug=slug, base_dir=base_dir
|
|
54
|
+
)
|
|
55
|
+
if u is None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
mp = _load_model_prices()
|
|
59
|
+
model = u.get("model") or "kimi-k2.5"
|
|
60
|
+
totals = {
|
|
61
|
+
"input_tokens": int(u.get("input_tokens") or 0),
|
|
62
|
+
"output_tokens": int(u.get("output_tokens") or 0),
|
|
63
|
+
"cache_creation_tokens": int(u.get("cache_creation_tokens") or 0),
|
|
64
|
+
"cache_read_tokens": int(u.get("cache_read_tokens") or 0),
|
|
65
|
+
}
|
|
66
|
+
cost_list = mp.compute_list_cost(model, **totals)
|
|
67
|
+
currency = mp.currency_for(model)
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
"model": model,
|
|
71
|
+
"input_tokens": totals["input_tokens"],
|
|
72
|
+
"output_tokens": totals["output_tokens"],
|
|
73
|
+
"cache_creation_tokens": totals["cache_creation_tokens"],
|
|
74
|
+
"cache_read_tokens": totals["cache_read_tokens"],
|
|
75
|
+
"duration_ms": u.get("duration_ms"),
|
|
76
|
+
"cost_list_usd": cost_list,
|
|
77
|
+
"cost_currency": currency,
|
|
78
|
+
"prices_version": getattr(mp, "VERSION", None),
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
82
|
+
"stage": "usage",
|
|
83
|
+
"label": cycle_id,
|
|
84
|
+
"detail": payload,
|
|
85
|
+
"outcome": "ok",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_events_path(slug, shared):
|
|
90
|
+
base = shared or os.environ.get("LOOP_SHARED_ROOT") \
|
|
91
|
+
or os.path.expanduser("~/.shared/roll")
|
|
92
|
+
return os.path.join(base, "loop", "events-%s.ndjson" % slug)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main(argv=None):
|
|
96
|
+
ap = argparse.ArgumentParser(description="emit one kimi usage event")
|
|
97
|
+
ap.add_argument("--cwd", help="cycle worktree path (authoritative match)")
|
|
98
|
+
ap.add_argument("--cycle", help="cycle id (label + dir-name fallback)")
|
|
99
|
+
ap.add_argument("--slug", help="project slug (events filename)")
|
|
100
|
+
ap.add_argument("--shared", help="shared root (for default events path)")
|
|
101
|
+
ap.add_argument("--events", help="explicit events file path (preferred)")
|
|
102
|
+
ap.add_argument("--base-dir", help="kimi sessions root override (tests)")
|
|
103
|
+
args = ap.parse_args(argv)
|
|
104
|
+
|
|
105
|
+
event = build_event(
|
|
106
|
+
cwd=args.cwd, cycle_id=args.cycle, slug=args.slug, base_dir=args.base_dir
|
|
107
|
+
)
|
|
108
|
+
if event is None:
|
|
109
|
+
return 0 # nothing recoverable — write nothing (n/a, not fake zero)
|
|
110
|
+
|
|
111
|
+
evfile = args.events or _default_events_path(args.slug, args.shared)
|
|
112
|
+
try:
|
|
113
|
+
os.makedirs(os.path.dirname(evfile), exist_ok=True)
|
|
114
|
+
with open(evfile, "a") as f:
|
|
115
|
+
f.write(json.dumps(event) + "\n")
|
|
116
|
+
except OSError as e:
|
|
117
|
+
print("[kimi_emit] failed to write %s: %s" % (evfile, e), file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openai (codex / o-series) agent usage extractor.
|
|
3
|
+
|
|
4
|
+
Unlike pi (which runs in text mode and persists usage to session files),
|
|
5
|
+
the OpenAI Codex CLI prints a token-usage summary to stdout at the end of
|
|
6
|
+
a session. So this plugin implements the standard ``extract()`` registry
|
|
7
|
+
contract: scrape the passthrough stdout lines for the usage / model lines.
|
|
8
|
+
|
|
9
|
+
Recognised lines (case-insensitive, robust to thousands separators)::
|
|
10
|
+
|
|
11
|
+
Model: gpt-4o
|
|
12
|
+
Token usage: total=18420 input=15300 output=3120
|
|
13
|
+
|
|
14
|
+
Older / alternate Codex CLI formats are also accepted::
|
|
15
|
+
|
|
16
|
+
tokens used: 12,345 (treated as total only)
|
|
17
|
+
input tokens: 15300 output tokens: 3120
|
|
18
|
+
model: o3-mini
|
|
19
|
+
|
|
20
|
+
When an explicit USD cost line isn't present, cost is computed from
|
|
21
|
+
``lib/model_prices.py`` (list price) so the dashboard never shows ``—``
|
|
22
|
+
for a recognised openai cycle. Returns None if no usage line is found,
|
|
23
|
+
so the caller falls back to the null payload (US-LOOP-010 compatible).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
from typing import Optional
|
|
30
|
+
|
|
31
|
+
# model_prices lives one level up (lib/), alongside this package.
|
|
32
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
33
|
+
try:
|
|
34
|
+
import model_prices
|
|
35
|
+
except Exception: # pragma: no cover - import guard
|
|
36
|
+
model_prices = None
|
|
37
|
+
|
|
38
|
+
# Default model when the output omits an explicit model line.
|
|
39
|
+
_DEFAULT_MODEL = "gpt-4o"
|
|
40
|
+
|
|
41
|
+
_MODEL_RE = re.compile(r"^\s*model\s*[:=]\s*([A-Za-z0-9][\w.\-]*)", re.IGNORECASE)
|
|
42
|
+
_INPUT_RE = re.compile(r"input(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
43
|
+
_OUTPUT_RE = re.compile(r"output(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
44
|
+
_TOTAL_RE = re.compile(r"(?:tokens\s+used|total)\s*[:=]\s*([\d,]+)", re.IGNORECASE)
|
|
45
|
+
_COST_RE = re.compile(r"cost\s*[:=]?\s*\$?\s*([\d.]+)\s*(?:usd)?", re.IGNORECASE)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_int(s: str) -> int:
|
|
49
|
+
"""Parse a token count string, tolerating thousands separators."""
|
|
50
|
+
return int(s.replace(",", ""))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract(stdin_lines: list[str]) -> Optional[dict]:
|
|
54
|
+
"""Parse Codex CLI stdout and return a usage dict, or None.
|
|
55
|
+
|
|
56
|
+
Scans every line (the usage summary is at the tail but may be wrapped
|
|
57
|
+
by surrounding text) and accumulates the last seen model / token / cost
|
|
58
|
+
values. Requires at least one of input/output/total tokens to be found;
|
|
59
|
+
otherwise returns None (caller falls back to null payload).
|
|
60
|
+
"""
|
|
61
|
+
if not stdin_lines:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
model = None
|
|
65
|
+
tin = tout = ttotal = None
|
|
66
|
+
cost = None
|
|
67
|
+
|
|
68
|
+
for raw in stdin_lines:
|
|
69
|
+
line = raw.rstrip("\n")
|
|
70
|
+
|
|
71
|
+
m = _MODEL_RE.match(line)
|
|
72
|
+
if m:
|
|
73
|
+
model = m.group(1)
|
|
74
|
+
|
|
75
|
+
m = _INPUT_RE.search(line)
|
|
76
|
+
if m:
|
|
77
|
+
tin = _to_int(m.group(1))
|
|
78
|
+
|
|
79
|
+
m = _OUTPUT_RE.search(line)
|
|
80
|
+
if m:
|
|
81
|
+
tout = _to_int(m.group(1))
|
|
82
|
+
|
|
83
|
+
m = _TOTAL_RE.search(line)
|
|
84
|
+
if m:
|
|
85
|
+
ttotal = _to_int(m.group(1))
|
|
86
|
+
|
|
87
|
+
m = _COST_RE.search(line)
|
|
88
|
+
if m:
|
|
89
|
+
try:
|
|
90
|
+
cost = float(m.group(1))
|
|
91
|
+
except ValueError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
# Derive input/output from total when only a total was reported.
|
|
95
|
+
if tin is None and tout is None and ttotal is None:
|
|
96
|
+
return None
|
|
97
|
+
if tin is None and tout is None and ttotal is not None:
|
|
98
|
+
# No split available — attribute the whole total to input so the
|
|
99
|
+
# cycle is non-zero; output stays 0.
|
|
100
|
+
tin = ttotal
|
|
101
|
+
tout = 0
|
|
102
|
+
else:
|
|
103
|
+
tin = tin or 0
|
|
104
|
+
tout = tout or 0
|
|
105
|
+
if ttotal is not None and tin == 0 and tout == 0:
|
|
106
|
+
tin = ttotal
|
|
107
|
+
|
|
108
|
+
model = model or _DEFAULT_MODEL
|
|
109
|
+
|
|
110
|
+
if cost is None:
|
|
111
|
+
if model_prices is not None:
|
|
112
|
+
cost = model_prices.compute_list_cost(
|
|
113
|
+
model,
|
|
114
|
+
input_tokens=tin,
|
|
115
|
+
output_tokens=tout,
|
|
116
|
+
)
|
|
117
|
+
else: # pragma: no cover - only when model_prices unimportable
|
|
118
|
+
cost = 0.0
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"model": model,
|
|
122
|
+
"input_tokens": tin,
|
|
123
|
+
"output_tokens": tout,
|
|
124
|
+
"cost_list_usd": cost,
|
|
125
|
+
"duration_ms": None,
|
|
126
|
+
}
|