@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.
Files changed (181) hide show
  1. package/CHANGELOG.md +717 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -165
  4. package/bin/dream-test-quality-scan +110 -0
  5. package/bin/roll +14897 -815
  6. package/conventions/config.yaml +17 -1
  7. package/conventions/global/AGENTS.md +146 -100
  8. package/conventions/global/CLAUDE.md +1 -21
  9. package/conventions/global/GEMINI.md +8 -22
  10. package/conventions/global/project_rules.md +9 -0
  11. package/conventions/templates/backend-service/AGENTS.md +30 -81
  12. package/conventions/templates/backend-service/GEMINI.md +3 -3
  13. package/conventions/templates/backend-service/project_rules.md +16 -0
  14. package/conventions/templates/cli/AGENTS.md +31 -58
  15. package/conventions/templates/cli/CLAUDE.md +3 -5
  16. package/conventions/templates/cli/GEMINI.md +3 -3
  17. package/conventions/templates/cli/project_rules.md +16 -0
  18. package/conventions/templates/frontend-only/AGENTS.md +29 -64
  19. package/conventions/templates/frontend-only/GEMINI.md +3 -3
  20. package/conventions/templates/frontend-only/project_rules.md +14 -0
  21. package/conventions/templates/fullstack/AGENTS.md +31 -79
  22. package/conventions/templates/fullstack/CLAUDE.md +1 -1
  23. package/conventions/templates/fullstack/GEMINI.md +3 -3
  24. package/conventions/templates/fullstack/project_rules.md +15 -0
  25. package/lib/README.md +42 -0
  26. package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/loop_unstick.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/model_prices.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/roll-home.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/roll-loop-status.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/roll_git.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/roll_render.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/slides-render.cpython-314.pyc +0 -0
  37. package/lib/agent_usage/README.md +49 -0
  38. package/lib/agent_usage/__init__.py +108 -0
  39. package/lib/agent_usage/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/lib/agent_usage/__pycache__/gemini.cpython-314.pyc +0 -0
  41. package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
  42. package/lib/agent_usage/__pycache__/openai.cpython-314.pyc +0 -0
  43. package/lib/agent_usage/__pycache__/pi.cpython-314.pyc +0 -0
  44. package/lib/agent_usage/__pycache__/pi_emit.cpython-314.pyc +0 -0
  45. package/lib/agent_usage/__pycache__/qwen.cpython-314.pyc +0 -0
  46. package/lib/agent_usage/gemini.py +127 -0
  47. package/lib/agent_usage/kimi.py +278 -0
  48. package/lib/agent_usage/kimi_emit.py +123 -0
  49. package/lib/agent_usage/openai.py +126 -0
  50. package/lib/agent_usage/pi.py +200 -0
  51. package/lib/agent_usage/pi_emit.py +135 -0
  52. package/lib/agent_usage/qwen.py +128 -0
  53. package/lib/backfill-pi-usage.py +243 -0
  54. package/lib/changelog_audit.py +155 -0
  55. package/lib/changelog_generate.py +263 -0
  56. package/lib/context_feed_budget.sh +194 -0
  57. package/lib/github_sync.py +876 -0
  58. package/lib/i18n/README.md +54 -0
  59. package/lib/i18n/agent.sh +75 -0
  60. package/lib/i18n/alert.sh +20 -0
  61. package/lib/i18n/backlog.sh +96 -0
  62. package/lib/i18n/brief.sh +5 -0
  63. package/lib/i18n/changelog.sh +5 -0
  64. package/lib/i18n/ci.sh +15 -0
  65. package/lib/i18n/debug.sh +0 -0
  66. package/lib/i18n/doctor.sh +44 -0
  67. package/lib/i18n/dream.sh +0 -0
  68. package/lib/i18n/init.sh +91 -0
  69. package/lib/i18n/lang.sh +10 -0
  70. package/lib/i18n/loop.sh +140 -0
  71. package/lib/i18n/migrate.sh +74 -0
  72. package/lib/i18n/offboard.sh +31 -0
  73. package/lib/i18n/onboard.sh +0 -0
  74. package/lib/i18n/peer.sh +41 -0
  75. package/lib/i18n/peer_help.sh +25 -0
  76. package/lib/i18n/peer_reset.sh +7 -0
  77. package/lib/i18n/peer_status.sh +5 -0
  78. package/lib/i18n/prices.sh +3 -0
  79. package/lib/i18n/prices_refresh.sh +17 -0
  80. package/lib/i18n/prices_show.sh +7 -0
  81. package/lib/i18n/propose.sh +0 -0
  82. package/lib/i18n/release.sh +0 -0
  83. package/lib/i18n/research.sh +0 -0
  84. package/lib/i18n/review_pr.sh +0 -0
  85. package/lib/i18n/sentinel.sh +0 -0
  86. package/lib/i18n/setup.sh +3 -0
  87. package/lib/i18n/shared.sh +157 -0
  88. package/lib/i18n/skills/roll-brief.sh +47 -0
  89. package/lib/i18n/skills/roll-build.sh +97 -0
  90. package/lib/i18n/skills/roll-design.sh +18 -0
  91. package/lib/i18n/skills/roll-fix.sh +53 -0
  92. package/lib/i18n/skills/roll-loop.sh +28 -0
  93. package/lib/i18n/skills/roll-onboard.sh +33 -0
  94. package/lib/i18n/skills_catalog.sh +30 -0
  95. package/lib/i18n/slides.sh +3 -0
  96. package/lib/i18n/slides_build.sh +38 -0
  97. package/lib/i18n/slides_delete.sh +19 -0
  98. package/lib/i18n/slides_list.sh +14 -0
  99. package/lib/i18n/slides_logs.sh +12 -0
  100. package/lib/i18n/slides_new.sh +15 -0
  101. package/lib/i18n/slides_preview.sh +14 -0
  102. package/lib/i18n/slides_templates.sh +7 -0
  103. package/lib/i18n/status.sh +21 -0
  104. package/lib/i18n/update.sh +24 -0
  105. package/lib/i18n.sh +211 -0
  106. package/lib/loop-exit-summary.py +393 -0
  107. package/lib/loop-fmt.py +589 -0
  108. package/lib/loop_pick_agent.py +316 -0
  109. package/lib/loop_result_eval.py +469 -0
  110. package/lib/loop_unstick.py +180 -0
  111. package/lib/model_prices.py +186 -0
  112. package/lib/prices/README.md +35 -0
  113. package/lib/prices/snapshot-2026-05-22.json +22 -0
  114. package/lib/prices/snapshot-2026-05-23-deepseek.json +15 -0
  115. package/lib/prices/snapshot-2026-05-23-kimi.json +14 -0
  116. package/lib/prices_fetcher.py +285 -0
  117. package/lib/roll-backlog.py +225 -0
  118. package/lib/roll-brief.py +286 -0
  119. package/lib/roll-help.py +158 -0
  120. package/lib/roll-home.py +556 -0
  121. package/lib/roll-init.py +156 -0
  122. package/lib/roll-loop-status.py +1683 -0
  123. package/lib/roll-loop-story.py +191 -0
  124. package/lib/roll-onboard-render.py +378 -0
  125. package/lib/roll-peer.py +252 -0
  126. package/lib/roll-plan-validate.py +386 -0
  127. package/lib/roll-setup.py +102 -0
  128. package/lib/roll-status.py +367 -0
  129. package/lib/roll_git.py +41 -0
  130. package/lib/roll_render.py +414 -0
  131. package/lib/slides/components/README.md +123 -0
  132. package/lib/slides/components/cards-2.html +9 -0
  133. package/lib/slides/components/cards-3.html +9 -0
  134. package/lib/slides/components/cards-4.html +9 -0
  135. package/lib/slides/components/compare.html +22 -0
  136. package/lib/slides/components/highlight.html +9 -0
  137. package/lib/slides/components/pipeline.html +12 -0
  138. package/lib/slides/components/plain.html +7 -0
  139. package/lib/slides/components/quote.html +4 -0
  140. package/lib/slides/components/timeline.html +9 -0
  141. package/lib/slides/templates/introduction-v3.html +571 -0
  142. package/lib/slides/templates/pitch.html +0 -0
  143. package/lib/slides-render.py +778 -0
  144. package/lib/slides-validate.py +357 -0
  145. package/lib/test_quality_gate.py +143 -0
  146. package/package.json +8 -7
  147. package/skills/roll-.changelog/SKILL.md +406 -33
  148. package/skills/roll-.clarify/SKILL.md +5 -2
  149. package/skills/roll-.dream/SKILL.md +374 -0
  150. package/skills/roll-.echo/SKILL.md +5 -2
  151. package/skills/roll-.qa/SKILL.md +57 -3
  152. package/skills/roll-.review/SKILL.md +42 -3
  153. package/skills/roll-brief/SKILL.md +209 -0
  154. package/skills/roll-build/SKILL.md +308 -63
  155. package/skills/roll-debug/SKILL.md +341 -162
  156. package/skills/roll-debug/injectable-bb.js +263 -0
  157. package/skills/roll-deck/SKILL.md +296 -0
  158. package/skills/roll-design/ENGINEERING_CHECKLIST.md +1 -1
  159. package/skills/roll-design/SKILL.md +727 -94
  160. package/skills/roll-doc/SKILL.md +595 -0
  161. package/skills/roll-doctor/SKILL.md +192 -0
  162. package/skills/roll-fix/SKILL.md +149 -32
  163. package/skills/{roll-jot → roll-idea}/SKILL.md +18 -10
  164. package/skills/roll-loop/SKILL.md +578 -0
  165. package/skills/roll-notes/SKILL.md +103 -0
  166. package/skills/roll-onboard/SKILL.md +234 -0
  167. package/skills/roll-peer/SKILL.md +336 -0
  168. package/skills/roll-propose/SKILL.md +157 -0
  169. package/skills/roll-review-pr/SKILL.md +58 -0
  170. package/skills/roll-sentinel/SKILL.md +11 -2
  171. package/skills/roll-spar/SKILL.md +8 -6
  172. package/template/.github/workflows/ci.yml +5 -2
  173. package/template/AGENTS.md +20 -74
  174. package/skills/roll-research/SKILL.md +0 -307
  175. package/skills/roll-research/references/schema.json +0 -162
  176. package/skills/roll-research/scripts/md_to_pdf.py +0 -289
  177. package/tools/roll-fetch/SKILL.md +0 -182
  178. package/tools/roll-fetch/package.json +0 -15
  179. package/tools/roll-fetch/smart-web-fetch.js +0 -558
  180. package/tools/roll-probe/SKILL.md +0 -84
  181. /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
+ }