@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.
- package/CHANGELOG.md +736 -0
- package/LICENSE +21 -0
- package/README.md +65 -165
- package/bin/dream-test-quality-scan +110 -0
- package/bin/roll +15030 -814
- 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 +194 -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 +15 -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 +733 -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 +579 -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,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
|
+
}
|