@seanyao/roll 0.5.0 → 2.602.2

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 +736 -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 +15030 -814
  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 +194 -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 +15 -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 +733 -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 +579 -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,200 @@
1
+ """
2
+ pi agent usage extractor.
3
+
4
+ pi runs in the loop as ``pi -p`` (text mode), whose stdout is ONLY the
5
+ assistant's answer text — it carries no token/cost summary. So stdout
6
+ scraping (the ``extract()`` registry contract) cannot recover usage and
7
+ always returns None for real pi output.
8
+
9
+ Instead, pi persists every session to disk at::
10
+
11
+ ~/.pi/agent/sessions/<encoded-cwd>/<ISO-ts>_<uuid>.jsonl
12
+
13
+ Each file is NDJSON: one ``{"type":"session","cwd":<abs-worktree-path>}``
14
+ header line followed by ``{"type":"message","message":{...}}`` lines.
15
+ Assistant messages carry a per-call ``usage`` block including pi's own
16
+ cost calc. The authoritative usage path is therefore ``usage_from_session``,
17
+ which sums per-message usage for a cycle's worktree. See ``pi_emit.py``
18
+ (live capture) and ``backfill-pi-usage.py`` (historical backfill).
19
+ """
20
+
21
+ import glob
22
+ import json
23
+ import os
24
+ from typing import Optional
25
+
26
+
27
+ def extract(stdin_lines: list[str]) -> Optional[dict]:
28
+ """Registry contract stub.
29
+
30
+ pi ``-p`` text-mode stdout carries no usage data, so this always
31
+ returns None and the caller falls back to the null-payload path.
32
+ Real usage is recovered from session files via ``usage_from_session``.
33
+ Kept so the agent_usage REGISTRY contract / tests stay valid.
34
+ """
35
+ return None
36
+
37
+
38
+ # ── Session-file extraction (authoritative) ────────────────────────────────
39
+
40
+ # pi reports a per-message ``cost.total``; we sum it into ``cost_reported``
41
+ # for audit only. The authoritative list cost is frozen by the writers from
42
+ # lib/prices/snapshot-*-deepseek.json in deepseek's native currency (CNY) —
43
+ # we never convert currencies (the CLI already shows the currency symbol).
44
+ def _sessions_base_dir(base_dir: Optional[str]) -> str:
45
+ """Resolve the pi sessions root: arg → env → default."""
46
+ return (
47
+ base_dir
48
+ or os.environ.get("ROLL_PI_SESSIONS_DIR")
49
+ or os.path.expanduser("~/.pi/agent/sessions")
50
+ )
51
+
52
+
53
+ def _sum_session_file(path: str) -> Optional[dict]:
54
+ """Sum per-message assistant usage in a single session jsonl.
55
+
56
+ Returns a usage dict (tokens summed) or None when the file has no
57
+ assistant usage. Field mapping from pi → roll schema:
58
+ cacheWrite→cache_creation_tokens, cacheRead→cache_read_tokens.
59
+
60
+ ``cost_reported`` carries pi's own per-message ``cost.total`` summed,
61
+ purely for audit — it is NOT the authoritative cost. The authoritative
62
+ list cost is frozen by the writers (pi_emit / backfill) from the deepseek
63
+ price snapshot in its native currency (CNY), matching claude's
64
+ ``_price_at_snapshot`` convention. We never convert currencies.
65
+ """
66
+ tin = tout = tcr = tcw = 0
67
+ cost = 0.0
68
+ model = None
69
+ seen = False
70
+ try:
71
+ with open(path) as f:
72
+ for line in f:
73
+ line = line.strip()
74
+ if not line:
75
+ continue
76
+ try:
77
+ o = json.loads(line)
78
+ except json.JSONDecodeError:
79
+ continue
80
+ if o.get("type") != "message":
81
+ continue
82
+ m = o.get("message") or {}
83
+ if m.get("role") != "assistant":
84
+ continue
85
+ u = m.get("usage")
86
+ if not u:
87
+ continue
88
+ seen = True
89
+ if m.get("model"):
90
+ model = m["model"]
91
+ tin += int(u.get("input") or 0)
92
+ tout += int(u.get("output") or 0)
93
+ tcr += int(u.get("cacheRead") or 0)
94
+ tcw += int(u.get("cacheWrite") or 0)
95
+ cost += float((u.get("cost") or {}).get("total") or 0.0)
96
+ except OSError:
97
+ return None
98
+ if not seen:
99
+ return None
100
+ return {
101
+ "model": model or "deepseek-v4-pro",
102
+ "input_tokens": tin,
103
+ "output_tokens": tout,
104
+ "cache_creation_tokens": tcw,
105
+ "cache_read_tokens": tcr,
106
+ "cost_reported": cost,
107
+ "duration_ms": None,
108
+ }
109
+
110
+
111
+ def _session_cwd(path: str) -> Optional[str]:
112
+ """Read the header ``session`` line and return its ``cwd``, or None."""
113
+ try:
114
+ with open(path) as f:
115
+ for line in f:
116
+ line = line.strip()
117
+ if not line:
118
+ continue
119
+ try:
120
+ o = json.loads(line)
121
+ except json.JSONDecodeError:
122
+ return None
123
+ if o.get("type") == "session":
124
+ return o.get("cwd")
125
+ # session header is expected first; bail after first JSON line
126
+ return None
127
+ except OSError:
128
+ return None
129
+ return None
130
+
131
+
132
+ def usage_from_session(
133
+ cwd: Optional[str] = None,
134
+ cycle_id: Optional[str] = None,
135
+ slug: Optional[str] = None,
136
+ base_dir: Optional[str] = None,
137
+ ) -> Optional[dict]:
138
+ """Recover a pi cycle's usage by reading its persisted session file(s).
139
+
140
+ Matching: scan ``<base>/*/*.jsonl`` and select files whose session
141
+ header ``cwd`` equals the target worktree path (authoritative). When
142
+ ``cwd`` isn't given but ``cycle_id`` is, also accept files whose path
143
+ contains ``cycle-<cycle_id>`` (dir-name fallback).
144
+
145
+ Retries reuse the same worktree → multiple session files may match;
146
+ their usage is SUMMED (so token totals reflect wasted retry work too).
147
+
148
+ Returns the merged usage dict (tokens + model + ``cost_reported``), or
149
+ None when nothing matches / zero tokens (callers then skip writing,
150
+ preserving "n/a not fake zero"). The authoritative list cost is left to
151
+ the writer, which freezes it from the CNY price snapshot.
152
+ """
153
+ base = _sessions_base_dir(base_dir)
154
+ files = sorted(glob.glob(os.path.join(base, "*", "*.jsonl")))
155
+ if not files:
156
+ return None
157
+
158
+ matched = []
159
+ for path in files:
160
+ if cwd is not None and _session_cwd(path) == cwd:
161
+ matched.append(path)
162
+ continue
163
+ if cycle_id is not None and ("cycle-%s" % cycle_id) in path:
164
+ matched.append(path)
165
+
166
+ if not matched:
167
+ return None
168
+
169
+ agg = {
170
+ "model": None,
171
+ "input_tokens": 0,
172
+ "output_tokens": 0,
173
+ "cache_creation_tokens": 0,
174
+ "cache_read_tokens": 0,
175
+ "cost_reported": 0.0,
176
+ "duration_ms": None,
177
+ }
178
+ got = False
179
+ for path in matched:
180
+ s = _sum_session_file(path)
181
+ if s is None:
182
+ continue
183
+ got = True
184
+ agg["model"] = agg["model"] or s["model"]
185
+ agg["input_tokens"] += s["input_tokens"]
186
+ agg["output_tokens"] += s["output_tokens"]
187
+ agg["cache_creation_tokens"] += s["cache_creation_tokens"]
188
+ agg["cache_read_tokens"] += s["cache_read_tokens"]
189
+ agg["cost_reported"] += s["cost_reported"]
190
+
191
+ if not got:
192
+ return None
193
+ has_tokens = (
194
+ agg["input_tokens"] or agg["output_tokens"]
195
+ or agg["cache_creation_tokens"] or agg["cache_read_tokens"]
196
+ )
197
+ if not has_tokens:
198
+ return None
199
+ agg["model"] = agg["model"] or "deepseek-v4-pro"
200
+ return agg
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pi_emit — write ONE authoritative usage event for a finished pi cycle.
4
+
5
+ pi runs as ``pi -p`` (text mode): its stdout carries no token/cost summary,
6
+ so loop-fmt's passthrough can only show progress, not capture usage. This
7
+ thin CLI is invoked once by bin/roll after the agent phase (when ``$WT`` and
8
+ ``$CYCLE_ID`` are still in scope). It recovers the cycle's real usage from
9
+ pi's persisted session files via ``pi.usage_from_session`` and appends a
10
+ single ``stage=="usage"`` event to the loop events file.
11
+
12
+ Exactly one event per cycle — the dashboard SUMS token fields across same-
13
+ label usage events, so emitting once (here, post-cycle) instead of once per
14
+ retry attempt (the old loop-fmt path) avoids ×N inflation.
15
+
16
+ Cost is frozen at the active price snapshot in deepseek's native currency
17
+ (CNY) via ``model_prices.compute_list_cost`` — the same convention claude
18
+ uses (US-VIEW-014). pi's own ``cost.total`` (computed in USD) is kept as
19
+ ``cost_reported_usd`` for audit only. We never convert currencies; the
20
+ dashboard already renders the right symbol from ``cost_currency``.
21
+
22
+ When ``usage_from_session`` finds nothing (no session match, zero tokens),
23
+ nothing is written — preserving "show n/a, not a fake zero".
24
+ """
25
+
26
+ import argparse
27
+ import importlib.util
28
+ import json
29
+ import os
30
+ import sys
31
+ from datetime import datetime, timezone
32
+
33
+ _THIS_DIR = os.path.dirname(os.path.abspath(__file__))
34
+ _LIB_DIR = os.path.dirname(_THIS_DIR)
35
+
36
+
37
+ def _load_model_prices():
38
+ spec = importlib.util.spec_from_file_location(
39
+ "model_prices", os.path.join(_LIB_DIR, "model_prices.py")
40
+ )
41
+ mp = importlib.util.module_from_spec(spec)
42
+ spec.loader.exec_module(mp)
43
+ return mp
44
+
45
+
46
+ def _load_pi():
47
+ spec = importlib.util.spec_from_file_location(
48
+ "agent_usage_pi", os.path.join(_THIS_DIR, "pi.py")
49
+ )
50
+ pi = importlib.util.module_from_spec(spec)
51
+ spec.loader.exec_module(pi)
52
+ return pi
53
+
54
+
55
+ def build_event(cwd=None, cycle_id=None, slug=None, base_dir=None):
56
+ """Return the (line dict) usage event for a pi cycle, or None to skip.
57
+
58
+ None means no recoverable usage — caller writes nothing.
59
+ """
60
+ pi = _load_pi()
61
+ u = pi.usage_from_session(
62
+ cwd=cwd, cycle_id=cycle_id, slug=slug, base_dir=base_dir
63
+ )
64
+ if u is None:
65
+ return None
66
+
67
+ mp = _load_model_prices()
68
+ model = u.get("model") or "deepseek-v4-pro"
69
+ totals = {
70
+ "input_tokens": int(u.get("input_tokens") or 0),
71
+ "output_tokens": int(u.get("output_tokens") or 0),
72
+ "cache_creation_tokens": int(u.get("cache_creation_tokens") or 0),
73
+ "cache_read_tokens": int(u.get("cache_read_tokens") or 0),
74
+ }
75
+ cost_list = mp.compute_list_cost(model, **totals)
76
+ currency = mp.currency_for(model)
77
+
78
+ payload = {
79
+ "model": model,
80
+ "input_tokens": totals["input_tokens"],
81
+ "output_tokens": totals["output_tokens"],
82
+ "cache_creation_tokens": totals["cache_creation_tokens"],
83
+ "cache_read_tokens": totals["cache_read_tokens"],
84
+ # pi's own per-message cost.total summed, in USD — audit only.
85
+ "cost_reported_usd": u.get("cost_reported"),
86
+ "duration_ms": u.get("duration_ms"),
87
+ # Authoritative, frozen at snapshot in native currency (CNY).
88
+ "cost_list_usd": cost_list,
89
+ "cost_currency": currency,
90
+ "prices_version": getattr(mp, "VERSION", None),
91
+ }
92
+ return {
93
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
94
+ "stage": "usage",
95
+ "label": cycle_id,
96
+ "detail": payload,
97
+ "outcome": "ok",
98
+ }
99
+
100
+
101
+ def _default_events_path(slug, shared):
102
+ base = shared or os.environ.get("LOOP_SHARED_ROOT") \
103
+ or os.path.expanduser("~/.shared/roll")
104
+ return os.path.join(base, "loop", "events-%s.ndjson" % slug)
105
+
106
+
107
+ def main(argv=None):
108
+ ap = argparse.ArgumentParser(description="emit one pi usage event")
109
+ ap.add_argument("--cwd", help="cycle worktree path (authoritative match)")
110
+ ap.add_argument("--cycle", help="cycle id (label + dir-name fallback)")
111
+ ap.add_argument("--slug", help="project slug (events filename)")
112
+ ap.add_argument("--shared", help="shared root (for default events path)")
113
+ ap.add_argument("--events", help="explicit events file path (preferred)")
114
+ ap.add_argument("--base-dir", help="pi sessions root override (tests)")
115
+ args = ap.parse_args(argv)
116
+
117
+ event = build_event(
118
+ cwd=args.cwd, cycle_id=args.cycle, slug=args.slug, base_dir=args.base_dir
119
+ )
120
+ if event is None:
121
+ return 0 # nothing recoverable — write nothing (n/a, not fake zero)
122
+
123
+ evfile = args.events or _default_events_path(args.slug, args.shared)
124
+ try:
125
+ os.makedirs(os.path.dirname(evfile), exist_ok=True)
126
+ with open(evfile, "a") as f:
127
+ f.write(json.dumps(event) + "\n")
128
+ except OSError as e:
129
+ print("[pi_emit] failed to write %s: %s" % (evfile, e), file=sys.stderr)
130
+ return 1
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ sys.exit(main())
@@ -0,0 +1,128 @@
1
+ """
2
+ qwen (Alibaba Qwen / dashscope CLI) agent usage extractor.
3
+
4
+ Like openai, gemini and kimi (and unlike pi, which persists usage to session
5
+ files), the Qwen / qwen-coder / dashscope CLI prints a token-usage summary to
6
+ stdout at the end of a session. So this plugin implements the standard
7
+ ``extract()`` registry contract: scrape the passthrough stdout lines for the
8
+ usage / model lines.
9
+
10
+ Recognised lines (case-insensitive, robust to thousands separators)::
11
+
12
+ Model: qwen-coder-plus
13
+ Tokens: input=15300 output=3120
14
+
15
+ The dashscope "usage" / session-summary block is also accepted::
16
+
17
+ Input tokens: 15,300
18
+ Output tokens: 3,120
19
+ Total tokens: 18,420
20
+ model: qwen-max
21
+
22
+ When an explicit USD cost line isn't present, cost is computed from
23
+ ``lib/model_prices.py`` (list price) so the dashboard never shows ``—``
24
+ for a recognised qwen cycle. Returns None if no usage line is found,
25
+ so the caller falls back to the null payload (US-LOOP-010 compatible).
26
+ """
27
+
28
+ import os
29
+ import re
30
+ import sys
31
+ from typing import Optional
32
+
33
+ # model_prices lives one level up (lib/), alongside this package.
34
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
35
+ try:
36
+ import model_prices
37
+ except Exception: # pragma: no cover - import guard
38
+ model_prices = None
39
+
40
+ # Default model when the output omits an explicit model line.
41
+ _DEFAULT_MODEL = "qwen-coder-plus"
42
+
43
+ _MODEL_RE = re.compile(r"^\s*model\s*[:=]\s*([A-Za-z0-9][\w.\-]*)", re.IGNORECASE)
44
+ _INPUT_RE = re.compile(r"input(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
45
+ _OUTPUT_RE = re.compile(r"output(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
46
+ _TOTAL_RE = re.compile(r"total(?:\s+tokens)?\s*[:=]\s*([\d,]+)", re.IGNORECASE)
47
+ _COST_RE = re.compile(r"cost\s*[:=]?\s*\$?\s*([\d.]+)\s*(?:usd)?", re.IGNORECASE)
48
+
49
+
50
+ def _to_int(s: str) -> int:
51
+ """Parse a token count string, tolerating thousands separators."""
52
+ return int(s.replace(",", ""))
53
+
54
+
55
+ def extract(stdin_lines: list[str]) -> Optional[dict]:
56
+ """Parse Qwen CLI stdout and return a usage dict, or None.
57
+
58
+ Scans every line (the usage summary is at the tail but may be wrapped
59
+ by surrounding text) and accumulates the last seen model / token / cost
60
+ values. Requires at least one of input/output/total tokens to be found;
61
+ otherwise returns None (caller falls back to null payload).
62
+ """
63
+ if not stdin_lines:
64
+ return None
65
+
66
+ model = None
67
+ tin = tout = ttotal = None
68
+ cost = None
69
+
70
+ for raw in stdin_lines:
71
+ line = raw.rstrip("\n")
72
+
73
+ m = _MODEL_RE.match(line)
74
+ if m:
75
+ model = m.group(1)
76
+
77
+ m = _INPUT_RE.search(line)
78
+ if m:
79
+ tin = _to_int(m.group(1))
80
+
81
+ m = _OUTPUT_RE.search(line)
82
+ if m:
83
+ tout = _to_int(m.group(1))
84
+
85
+ m = _TOTAL_RE.search(line)
86
+ if m:
87
+ ttotal = _to_int(m.group(1))
88
+
89
+ m = _COST_RE.search(line)
90
+ if m:
91
+ try:
92
+ cost = float(m.group(1))
93
+ except ValueError:
94
+ pass
95
+
96
+ # Require at least one token figure; otherwise this isn't a qwen cycle.
97
+ if tin is None and tout is None and ttotal is None:
98
+ return None
99
+ if tin is None and tout is None and ttotal is not None:
100
+ # No split available — attribute the whole total to input so the
101
+ # cycle is non-zero; output stays 0.
102
+ tin = ttotal
103
+ tout = 0
104
+ else:
105
+ tin = tin or 0
106
+ tout = tout or 0
107
+ if ttotal is not None and tin == 0 and tout == 0:
108
+ tin = ttotal
109
+
110
+ model = model or _DEFAULT_MODEL
111
+
112
+ if cost is None:
113
+ if model_prices is not None:
114
+ cost = model_prices.compute_list_cost(
115
+ model,
116
+ input_tokens=tin,
117
+ output_tokens=tout,
118
+ )
119
+ else: # pragma: no cover - only when model_prices unimportable
120
+ cost = 0.0
121
+
122
+ return {
123
+ "model": model,
124
+ "input_tokens": tin,
125
+ "output_tokens": tout,
126
+ "cost_list_usd": cost,
127
+ "duration_ms": None,
128
+ }