@seanyao/roll 2026.529.5 → 2026.601.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 +57 -25
- package/README.md +10 -7
- package/bin/roll +3952 -317
- package/conventions/config.yaml +7 -0
- package/lib/__pycache__/github_sync.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop_result_eval.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_prices.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__/slides-render.cpython-314.pyc +0 -0
- package/lib/agent_usage/__init__.py +4 -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__/qwen.cpython-314.pyc +0 -0
- package/lib/agent_usage/gemini.py +127 -0
- package/lib/agent_usage/kimi.py +127 -0
- package/lib/agent_usage/openai.py +126 -0
- package/lib/agent_usage/qwen.py +128 -0
- package/lib/context_feed_budget.sh +194 -0
- package/lib/github_sync.py +876 -0
- package/lib/i18n/agent.sh +54 -0
- package/lib/i18n/init.sh +22 -0
- package/lib/i18n/peer.sh +7 -0
- package/lib/i18n/peer_help.sh +4 -0
- package/lib/i18n/skills_catalog.sh +30 -0
- package/lib/loop-exit-summary.py +393 -0
- package/lib/loop-fmt.py +93 -75
- package/lib/loop_pick_agent.py +241 -170
- package/lib/loop_result_eval.py +469 -0
- package/lib/model_prices.py +0 -10
- package/lib/roll-home.py +1 -28
- package/lib/roll-loop-status.py +330 -40
- package/lib/roll-onboard-render.py +378 -0
- package/lib/roll-peer.py +1 -1
- package/lib/roll-plan-validate.py +165 -0
- package/lib/roll_git.py +41 -0
- package/lib/slides/components/README.md +8 -2
- package/lib/slides/templates/introduction-v3.html +1 -6
- package/lib/slides-render.py +305 -15
- package/lib/slides-validate.py +195 -7
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +67 -56
- package/skills/roll-brief/SKILL.md +1 -1
- package/skills/roll-build/SKILL.md +14 -12
- package/skills/roll-deck/SKILL.md +152 -0
- package/skills/roll-design/SKILL.md +13 -6
- package/skills/roll-doc/SKILL.md +269 -6
- package/skills/roll-fix/SKILL.md +15 -9
- package/skills/roll-loop/SKILL.md +9 -7
- package/skills/roll-notes/SKILL.md +1 -1
- package/skills/roll-onboard/SKILL.md +85 -0
- package/skills/roll-peer/SKILL.md +6 -5
- package/lib/agent_routes_lint.py +0 -203
- package/skills/roll-research/SKILL.md +0 -316
- package/skills/roll-research/references/schema.json +0 -166
- package/skills/roll-research/scripts/md_to_pdf.py +0 -289
package/conventions/config.yaml
CHANGED
|
@@ -22,3 +22,10 @@ editor: ${EDITOR:-vim}
|
|
|
22
22
|
# file paths, and other potentially sensitive information.
|
|
23
23
|
#
|
|
24
24
|
# roll_records_remote: "git@github.com:you/roll-loop-records.git"
|
|
25
|
+
|
|
26
|
+
# Remote monitoring (optional) — US-OBS-014.
|
|
27
|
+
# Local checkout of your roll-meta repo. When set, each loop cycle end
|
|
28
|
+
# auto-pushes a status snapshot via ops/push-loop-status.sh so the
|
|
29
|
+
# remote-watch prompt always sees fresh data (no manual cron needed).
|
|
30
|
+
# Path supports ~ expansion; a missing path logs one WARNING and is skipped.
|
|
31
|
+
# roll_meta_dir: ~/projects/roll-meta
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -66,6 +66,10 @@ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
66
66
|
_PLUGINS = {
|
|
67
67
|
# agent name → python module name (relative to this package)
|
|
68
68
|
"pi": ".pi",
|
|
69
|
+
"openai": ".openai",
|
|
70
|
+
"gemini": ".gemini",
|
|
71
|
+
"kimi": ".kimi",
|
|
72
|
+
"qwen": ".qwen",
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
for _agent, _mod_suffix in _PLUGINS.items():
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kimi (Moonshot Kimi CLI) agent usage extractor.
|
|
3
|
+
|
|
4
|
+
Like openai and gemini (and unlike pi, which persists usage to session
|
|
5
|
+
files), the Kimi CLI prints a token-usage summary to stdout at the end of a
|
|
6
|
+
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: kimi-k2
|
|
12
|
+
Tokens: input=15300 output=3120
|
|
13
|
+
|
|
14
|
+
The Kimi CLI's "usage" / session-summary block is also accepted::
|
|
15
|
+
|
|
16
|
+
Input tokens: 15,300
|
|
17
|
+
Output tokens: 3,120
|
|
18
|
+
Total tokens: 18,420
|
|
19
|
+
model: kimi-k2
|
|
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 kimi 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 = "kimi-k2"
|
|
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 Kimi 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 kimi 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,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
|
+
}
|
|
@@ -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
|
+
}
|