@seanyao/roll 2.602.4 → 2.603.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/bin/roll +495 -98
- package/lib/README.md +0 -1
- package/lib/__pycache__/changelog_generate.cpython-314.pyc +0 -0
- package/lib/__pycache__/loop-fmt.cpython-314.pyc +0 -0
- package/lib/__pycache__/prices_fetcher.cpython-314.pyc +0 -0
- package/lib/agent_usage/__pycache__/kimi.cpython-314.pyc +0 -0
- package/lib/changelog_generate.py +221 -32
- package/lib/loop-fmt.py +2 -2
- package/lib/prices_fetcher.py +331 -63
- package/package.json +1 -1
- package/skills/roll-.changelog/SKILL.md +1 -1
- package/skills/roll-loop/SKILL.md +7 -23
- package/lib/changelog_audit.py +0 -155
package/lib/prices_fetcher.py
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
"""
|
|
2
|
-
prices_fetcher — fetch + parse + diff + write
|
|
2
|
+
prices_fetcher — fetch + parse + diff + write multi-vendor pricing snapshots.
|
|
3
3
|
|
|
4
4
|
US-VIEW-013: replaces the hardcoded PRICES table in ``model_prices.py`` with
|
|
5
5
|
versioned JSON snapshots under ``lib/prices/``. The fetcher pulls the live
|
|
6
6
|
pricing docs page, extracts the model rate rows, and writes a new snapshot
|
|
7
7
|
only when the rates differ from the most recent one on disk.
|
|
8
8
|
|
|
9
|
+
US-VIEW-023: vendor-registry architecture — ``fetch``/``parse``/``refresh``
|
|
10
|
+
dispatch by vendor. Adding a new vendor is a registry entry, not a change to
|
|
11
|
+
the fetch/parse/refresh orchestration.
|
|
12
|
+
|
|
9
13
|
Design:
|
|
10
14
|
* ``fetch_pricing_html(url, timeout)`` — pure I/O, raises ``FetchError``
|
|
11
|
-
* ``parse_pricing_html(html)`` —
|
|
15
|
+
* ``parse_pricing_html(html, vendor)`` — dispatches to vendor parser,
|
|
16
|
+
raises ``ParseError``
|
|
12
17
|
* ``diff_prices(old, new)`` — pure diff, returns list of changes
|
|
13
18
|
* ``write_snapshot(prices, ...)`` — pure I/O, returns the path written
|
|
14
19
|
* ``refresh(...)`` — orchestrator; the only function with side effects on
|
|
@@ -22,17 +27,14 @@ import json
|
|
|
22
27
|
import os
|
|
23
28
|
import re
|
|
24
29
|
import sys
|
|
30
|
+
from dataclasses import dataclass
|
|
25
31
|
from html.parser import HTMLParser
|
|
26
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
27
33
|
from urllib.error import URLError
|
|
28
34
|
from urllib.request import Request, urlopen
|
|
29
35
|
|
|
30
|
-
DEFAULT_SOURCE_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
|
|
31
36
|
DEFAULT_TIMEOUT = 15
|
|
32
37
|
|
|
33
|
-
_MODEL_RE = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
|
|
34
|
-
_DOLLAR_RE = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
|
|
35
|
-
|
|
36
38
|
|
|
37
39
|
class FetchError(RuntimeError):
|
|
38
40
|
"""Raised when fetching the pricing page fails."""
|
|
@@ -42,7 +44,235 @@ class ParseError(ValueError):
|
|
|
42
44
|
"""Raised when the pricing HTML cannot be parsed into a prices map."""
|
|
43
45
|
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
# ─── Vendor registry ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class VendorConfig:
|
|
51
|
+
"""Configuration for a single pricing vendor."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
source_url: str
|
|
55
|
+
currency: str
|
|
56
|
+
parse: Callable[[str], Dict[str, Dict[str, float]]]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_claude_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
60
|
+
"""Parse Anthropic/Claude pricing HTML into a {model: rates} map."""
|
|
61
|
+
model_re = re.compile(r"claude-(?:opus|sonnet|haiku)-[0-9](?:-[0-9])?")
|
|
62
|
+
dollar_re = re.compile(r"\$\s*([0-9]+(?:\.[0-9]+)?)")
|
|
63
|
+
|
|
64
|
+
extractor = _TableTextExtractor()
|
|
65
|
+
extractor.feed(html)
|
|
66
|
+
|
|
67
|
+
prices: Dict[str, Dict[str, float]] = {}
|
|
68
|
+
for row in extractor.rows:
|
|
69
|
+
text = " ".join(row)
|
|
70
|
+
model_match = model_re.search(text)
|
|
71
|
+
if not model_match:
|
|
72
|
+
continue
|
|
73
|
+
model = model_match.group(0)
|
|
74
|
+
amounts = [float(m.group(1)) for m in dollar_re.finditer(text)]
|
|
75
|
+
if len(amounts) < 4:
|
|
76
|
+
continue
|
|
77
|
+
in_rate, cache_create, cache_read, out_rate = amounts[:4]
|
|
78
|
+
prices[model] = {
|
|
79
|
+
"in": in_rate,
|
|
80
|
+
"out": out_rate,
|
|
81
|
+
"cache_create": cache_create,
|
|
82
|
+
"cache_read": cache_read,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if not prices:
|
|
86
|
+
raise ParseError("no price rows found in HTML; page layout may have changed")
|
|
87
|
+
return prices
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_deepseek_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
91
|
+
"""Parse DeepSeek pricing HTML into a {model: rates} map.
|
|
92
|
+
|
|
93
|
+
Handles both the Chinese (元) and English ($) pricing pages.
|
|
94
|
+
Extracts deepseek-v4-flash and deepseek-v4-pro rates, then adds
|
|
95
|
+
deepseek-chat and deepseek-reasoner as aliases for flash.
|
|
96
|
+
"""
|
|
97
|
+
extractor = _TableTextExtractor()
|
|
98
|
+
extractor.feed(html)
|
|
99
|
+
|
|
100
|
+
# Find the header row with model names.
|
|
101
|
+
model_names: List[str] = []
|
|
102
|
+
header_idx = -1
|
|
103
|
+
for i, row in enumerate(extractor.rows):
|
|
104
|
+
if any(k in ' '.join(row) for k in ('模型', 'MODEL')):
|
|
105
|
+
# Cells after the label are model names.
|
|
106
|
+
# Strip footnote markers like (1) and HTML tags.
|
|
107
|
+
names = [
|
|
108
|
+
re.sub(r'<[^>]+>', '', re.sub(r'\s*\(\d+\)', '', cell)).strip()
|
|
109
|
+
for cell in row[1:]
|
|
110
|
+
if cell.strip()
|
|
111
|
+
]
|
|
112
|
+
if len(names) >= 2:
|
|
113
|
+
model_names = names
|
|
114
|
+
header_idx = i
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if len(model_names) < 2:
|
|
118
|
+
raise ParseError('no model header row found; page layout may have changed')
|
|
119
|
+
|
|
120
|
+
# Walk rows after header to find pricing data.
|
|
121
|
+
cache_hit: List[float] = []
|
|
122
|
+
cache_miss: List[float] = []
|
|
123
|
+
output: List[float] = []
|
|
124
|
+
|
|
125
|
+
for row in extractor.rows[header_idx + 1:]:
|
|
126
|
+
text = ' '.join(row)
|
|
127
|
+
# Skip non-pricing rows.
|
|
128
|
+
if not any(k in text for k in ('缓存命中', 'CACHE HIT', '缓存未命中', 'CACHE MISS', '输出', 'OUTPUT')):
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Extract numeric values followed by 元 or $.
|
|
132
|
+
values: List[float] = []
|
|
133
|
+
for cell in row:
|
|
134
|
+
# Match numbers like 0.02元, $0.14, 1元, etc.
|
|
135
|
+
m = re.search(r'(?:\$)?\s*([0-9]+(?:\.[0-9]+)?)\s*(?:元|¥)?', cell)
|
|
136
|
+
if m:
|
|
137
|
+
values.append(float(m.group(1)))
|
|
138
|
+
|
|
139
|
+
if len(values) < len(model_names):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if any(k in text for k in ('缓存命中', 'CACHE HIT')):
|
|
143
|
+
cache_hit = values[:len(model_names)]
|
|
144
|
+
elif any(k in text for k in ('缓存未命中', 'CACHE MISS')):
|
|
145
|
+
cache_miss = values[:len(model_names)]
|
|
146
|
+
elif any(k in text for k in ('输出', 'OUTPUT')):
|
|
147
|
+
output = values[:len(model_names)]
|
|
148
|
+
|
|
149
|
+
if not cache_miss or not output:
|
|
150
|
+
raise ParseError('no price rows found in HTML; page layout may have changed')
|
|
151
|
+
|
|
152
|
+
prices: Dict[str, Dict[str, float]] = {}
|
|
153
|
+
for idx, model in enumerate(model_names):
|
|
154
|
+
if model in ('deepseek-v4-flash', 'deepseek-v4-pro'):
|
|
155
|
+
prices[model] = {
|
|
156
|
+
'in': cache_miss[idx],
|
|
157
|
+
'out': output[idx],
|
|
158
|
+
'cache_create': cache_miss[idx],
|
|
159
|
+
'cache_read': cache_hit[idx] if cache_hit else 0.0,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if not prices:
|
|
163
|
+
raise ParseError('no price rows found in HTML; page layout may have changed')
|
|
164
|
+
|
|
165
|
+
# Aliases: deepseek-chat and deepseek-reasoner both map to v4-flash.
|
|
166
|
+
if 'deepseek-v4-flash' in prices:
|
|
167
|
+
flash = prices['deepseek-v4-flash']
|
|
168
|
+
prices['deepseek-chat'] = dict(flash)
|
|
169
|
+
prices['deepseek-reasoner'] = dict(flash)
|
|
170
|
+
|
|
171
|
+
return prices
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _try_parse_kimi_pricing(html: str) -> Optional[Dict[str, Dict[str, float]]]:
|
|
175
|
+
"""Try to parse Kimi pricing from HTML/MDX content.
|
|
176
|
+
|
|
177
|
+
Handles the JSX ``DocTable`` format used by Kimi's ``.md`` endpoints:
|
|
178
|
+
rows contain [model, unit, cache-hit, cache-miss, output, context].
|
|
179
|
+
"""
|
|
180
|
+
prices: Dict[str, Dict[str, float]] = {}
|
|
181
|
+
price_re = re.compile(r"¥\s*([0-9]+(?:\.[0-9]+)?)")
|
|
182
|
+
row_re = re.compile(
|
|
183
|
+
r'\[\s*"([^"]+)"\s*,\s*"[^"]+"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"[^"]+"\s*\]'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
for m in row_re.finditer(html):
|
|
187
|
+
model, cache_hit_str, cache_miss_str, output_str = m.groups()
|
|
188
|
+
cache_hit_m = price_re.search(cache_hit_str)
|
|
189
|
+
cache_miss_m = price_re.search(cache_miss_str)
|
|
190
|
+
output_m = price_re.search(output_str)
|
|
191
|
+
if not all((cache_hit_m, cache_miss_m, output_m)):
|
|
192
|
+
continue
|
|
193
|
+
prices[model] = {
|
|
194
|
+
"in": float(cache_miss_m.group(1)),
|
|
195
|
+
"out": float(output_m.group(1)),
|
|
196
|
+
"cache_create": float(cache_miss_m.group(1)),
|
|
197
|
+
"cache_read": float(cache_hit_m.group(1)),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return prices if prices else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_kimi_html(html: str) -> Dict[str, Dict[str, float]]:
|
|
204
|
+
"""Parse Kimi pricing HTML into a {model: rates} map.
|
|
205
|
+
|
|
206
|
+
Kimi pricing is split across sub-pages (``pricing/chat-k25``,
|
|
207
|
+
``pricing/chat-k26``). The parser first tries to extract prices from the
|
|
208
|
+
provided HTML; if none found, it fetches the ``.md`` sub-pages and parses
|
|
209
|
+
those.
|
|
210
|
+
"""
|
|
211
|
+
prices = _try_parse_kimi_pricing(html)
|
|
212
|
+
if prices:
|
|
213
|
+
if "kimi-k2.6" in prices:
|
|
214
|
+
prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
|
|
215
|
+
if "kimi-k2" not in prices:
|
|
216
|
+
prices["kimi-k2"] = {
|
|
217
|
+
"in": 1.00,
|
|
218
|
+
"out": 4.00,
|
|
219
|
+
"cache_create": 1.00,
|
|
220
|
+
"cache_read": 0.25,
|
|
221
|
+
}
|
|
222
|
+
return prices
|
|
223
|
+
|
|
224
|
+
sub_urls = [
|
|
225
|
+
"https://platform.kimi.com/docs/pricing/chat-k25.md",
|
|
226
|
+
"https://platform.kimi.com/docs/pricing/chat-k26.md",
|
|
227
|
+
]
|
|
228
|
+
combined = html
|
|
229
|
+
for url in sub_urls:
|
|
230
|
+
try:
|
|
231
|
+
combined += "\n" + fetch_pricing_html(url)
|
|
232
|
+
except FetchError as exc:
|
|
233
|
+
raise ParseError(f"could not fetch kimi sub-page {url}: {exc}")
|
|
234
|
+
|
|
235
|
+
prices = _try_parse_kimi_pricing(combined)
|
|
236
|
+
if not prices:
|
|
237
|
+
raise ParseError("no price rows found in kimi pages")
|
|
238
|
+
|
|
239
|
+
if "kimi-k2.6" in prices:
|
|
240
|
+
prices["kimi-for-coding"] = dict(prices["kimi-k2.6"])
|
|
241
|
+
if "kimi-k2" not in prices:
|
|
242
|
+
prices["kimi-k2"] = {
|
|
243
|
+
"in": 1.00,
|
|
244
|
+
"out": 4.00,
|
|
245
|
+
"cache_create": 1.00,
|
|
246
|
+
"cache_read": 0.25,
|
|
247
|
+
}
|
|
248
|
+
return prices
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
VENDOR_REGISTRY: Dict[str, VendorConfig] = {
|
|
252
|
+
"anthropic": VendorConfig(
|
|
253
|
+
name="anthropic",
|
|
254
|
+
source_url="https://platform.claude.com/docs/en/about-claude/pricing",
|
|
255
|
+
currency="USD",
|
|
256
|
+
parse=_parse_claude_html,
|
|
257
|
+
),
|
|
258
|
+
"deepseek": VendorConfig(
|
|
259
|
+
name="deepseek",
|
|
260
|
+
source_url="https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
261
|
+
currency="CNY",
|
|
262
|
+
parse=_parse_deepseek_html,
|
|
263
|
+
),
|
|
264
|
+
"kimi": VendorConfig(
|
|
265
|
+
name="kimi",
|
|
266
|
+
source_url="https://platform.kimi.com/docs/pricing/chat",
|
|
267
|
+
currency="CNY",
|
|
268
|
+
parse=_parse_kimi_html,
|
|
269
|
+
),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ─── Network I/O ──────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def fetch_pricing_html(url: str,
|
|
46
276
|
timeout: float = DEFAULT_TIMEOUT) -> str:
|
|
47
277
|
"""Fetch the pricing docs page and return its raw HTML."""
|
|
48
278
|
req = Request(url, headers={"User-Agent": "roll/prices_fetcher"})
|
|
@@ -55,6 +285,8 @@ def fetch_pricing_html(url: str = DEFAULT_SOURCE_URL,
|
|
|
55
285
|
raise FetchError(f"could not fetch {url}: {exc}") from exc
|
|
56
286
|
|
|
57
287
|
|
|
288
|
+
# ─── HTML parsing helpers ─────────────────────────────────────────────────────
|
|
289
|
+
|
|
58
290
|
class _TableTextExtractor(HTMLParser):
|
|
59
291
|
"""Walk an HTML document and yield <tr> cell-text lists per row."""
|
|
60
292
|
|
|
@@ -88,39 +320,22 @@ class _TableTextExtractor(HTMLParser):
|
|
|
88
320
|
self._cur.append(data)
|
|
89
321
|
|
|
90
322
|
|
|
91
|
-
|
|
323
|
+
# ─── Parser dispatch ──────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def parse_pricing_html(html: str, vendor: str = "anthropic") -> Dict[str, Dict[str, float]]:
|
|
92
326
|
"""Parse pricing docs HTML into a {model: rates} map.
|
|
93
327
|
|
|
94
|
-
|
|
95
|
-
one ``claude-*`` model identifier and four dollar amounts on that row, and
|
|
96
|
-
treats them as ``in / cache_create / cache_read / out`` in the order they
|
|
97
|
-
appear. (Anthropic's table renders columns in that order.)
|
|
328
|
+
Dispatches to the vendor-specific parser registered in ``VENDOR_REGISTRY``.
|
|
98
329
|
"""
|
|
99
|
-
|
|
100
|
-
|
|
330
|
+
config = VENDOR_REGISTRY.get(vendor)
|
|
331
|
+
if not config:
|
|
332
|
+
raise ParseError(
|
|
333
|
+
f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
|
|
334
|
+
)
|
|
335
|
+
return config.parse(html)
|
|
101
336
|
|
|
102
|
-
prices: Dict[str, Dict[str, float]] = {}
|
|
103
|
-
for row in parser.rows:
|
|
104
|
-
text = " ".join(row)
|
|
105
|
-
model_match = _MODEL_RE.search(text)
|
|
106
|
-
if not model_match:
|
|
107
|
-
continue
|
|
108
|
-
model = model_match.group(0)
|
|
109
|
-
amounts = [float(m.group(1)) for m in _DOLLAR_RE.finditer(text)]
|
|
110
|
-
if len(amounts) < 4:
|
|
111
|
-
continue
|
|
112
|
-
in_rate, cache_create, cache_read, out_rate = amounts[:4]
|
|
113
|
-
prices[model] = {
|
|
114
|
-
"in": in_rate,
|
|
115
|
-
"out": out_rate,
|
|
116
|
-
"cache_create": cache_create,
|
|
117
|
-
"cache_read": cache_read,
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if not prices:
|
|
121
|
-
raise ParseError("no price rows found in HTML; page layout may have changed")
|
|
122
|
-
return prices
|
|
123
337
|
|
|
338
|
+
# ─── Diff & formatting ────────────────────────────────────────────────────────
|
|
124
339
|
|
|
125
340
|
def diff_prices(old: Dict[str, Dict[str, float]],
|
|
126
341
|
new: Dict[str, Dict[str, float]]
|
|
@@ -168,10 +383,41 @@ def format_diff(changes: List[Tuple[str, str, str, Optional[float], Optional[flo
|
|
|
168
383
|
return "\n".join(lines)
|
|
169
384
|
|
|
170
385
|
|
|
386
|
+
# ─── Snapshot I/O ─────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
_SNAPSHOT_NAME_RE = re.compile(r"snapshot-(\d{4}-\d{2}-\d{2})(?:-([a-z]+))?\.json")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _extract_vendor_from_filename(name: str) -> Optional[str]:
|
|
392
|
+
"""Extract vendor from snapshot filename.
|
|
393
|
+
|
|
394
|
+
snapshot-2026-05-22.json → anthropic
|
|
395
|
+
snapshot-2026-05-22-deepseek.json → deepseek
|
|
396
|
+
snapshot-2026-06-02-kimi.json → kimi
|
|
397
|
+
"""
|
|
398
|
+
m = _SNAPSHOT_NAME_RE.match(name)
|
|
399
|
+
if not m:
|
|
400
|
+
return None
|
|
401
|
+
return m.group(2) or "anthropic"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _latest_snapshot_path(snapshot_dir: str, vendor: str = "anthropic") -> Optional[str]:
|
|
405
|
+
if not os.path.isdir(snapshot_dir):
|
|
406
|
+
return None
|
|
407
|
+
snaps = sorted(
|
|
408
|
+
os.path.join(snapshot_dir, n)
|
|
409
|
+
for n in os.listdir(snapshot_dir)
|
|
410
|
+
if _SNAPSHOT_NAME_RE.match(n) and _extract_vendor_from_filename(n) == vendor
|
|
411
|
+
)
|
|
412
|
+
return snaps[-1] if snaps else None
|
|
413
|
+
|
|
414
|
+
|
|
171
415
|
def write_snapshot(prices: Dict[str, Dict[str, float]],
|
|
172
416
|
*,
|
|
173
417
|
snapshot_dir: str,
|
|
174
|
-
source_url: str
|
|
418
|
+
source_url: str,
|
|
419
|
+
vendor: str = "anthropic",
|
|
420
|
+
currency: str = "USD",
|
|
175
421
|
effective_at: Optional[str] = None,
|
|
176
422
|
default_model: Optional[str] = None,
|
|
177
423
|
notes: Optional[str] = None) -> str:
|
|
@@ -182,12 +428,15 @@ def write_snapshot(prices: Dict[str, Dict[str, float]],
|
|
|
182
428
|
"version": today,
|
|
183
429
|
"effective_at": today,
|
|
184
430
|
"source_url": source_url,
|
|
431
|
+
"vendor": vendor,
|
|
432
|
+
"currency": currency,
|
|
185
433
|
"default_model": default_model or _pick_default(prices),
|
|
186
434
|
"prices": prices,
|
|
187
435
|
}
|
|
188
436
|
if notes:
|
|
189
437
|
payload["notes"] = notes
|
|
190
|
-
|
|
438
|
+
suffix = f"-{vendor}" if vendor != "anthropic" else ""
|
|
439
|
+
dest = os.path.join(snapshot_dir, f"snapshot-{today}{suffix}.json")
|
|
191
440
|
with open(dest, "w", encoding="utf-8") as f:
|
|
192
441
|
json.dump(payload, f, indent=2, sort_keys=False)
|
|
193
442
|
f.write("\n")
|
|
@@ -202,9 +451,12 @@ def _pick_default(prices: Dict[str, Dict[str, float]]) -> str:
|
|
|
202
451
|
return next(iter(prices))
|
|
203
452
|
|
|
204
453
|
|
|
454
|
+
# ─── Orchestrator ─────────────────────────────────────────────────────────────
|
|
455
|
+
|
|
205
456
|
def refresh(*,
|
|
206
457
|
snapshot_dir: str,
|
|
207
|
-
|
|
458
|
+
vendor: str = "anthropic",
|
|
459
|
+
url: Optional[str] = None,
|
|
208
460
|
timeout: float = DEFAULT_TIMEOUT,
|
|
209
461
|
html: Optional[str] = None,
|
|
210
462
|
) -> Tuple[str, List[Tuple[str, str, str, Optional[float], Optional[float]]]]:
|
|
@@ -215,14 +467,26 @@ def refresh(*,
|
|
|
215
467
|
``"written:<path>"`` — new snapshot written at <path>
|
|
216
468
|
``"first:<path>"`` — no prior snapshot existed; baseline written
|
|
217
469
|
"""
|
|
470
|
+
config = VENDOR_REGISTRY.get(vendor)
|
|
471
|
+
if not config:
|
|
472
|
+
raise ParseError(
|
|
473
|
+
f"unknown vendor {vendor!r}; known: {', '.join(sorted(VENDOR_REGISTRY))}"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
source_url = url or config.source_url
|
|
218
477
|
if html is None:
|
|
219
|
-
html = fetch_pricing_html(
|
|
220
|
-
new_prices = parse_pricing_html(html)
|
|
478
|
+
html = fetch_pricing_html(source_url, timeout=timeout)
|
|
479
|
+
new_prices = parse_pricing_html(html, vendor=vendor)
|
|
221
480
|
|
|
222
|
-
|
|
223
|
-
latest = _latest_snapshot_path(snapshot_dir)
|
|
481
|
+
latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
|
|
224
482
|
if latest is None:
|
|
225
|
-
dest = write_snapshot(
|
|
483
|
+
dest = write_snapshot(
|
|
484
|
+
new_prices,
|
|
485
|
+
snapshot_dir=snapshot_dir,
|
|
486
|
+
source_url=source_url,
|
|
487
|
+
vendor=vendor,
|
|
488
|
+
currency=config.currency,
|
|
489
|
+
)
|
|
226
490
|
return f"first:{dest}", diff_prices({}, new_prices)
|
|
227
491
|
|
|
228
492
|
with open(latest, "r", encoding="utf-8") as f:
|
|
@@ -230,34 +494,38 @@ def refresh(*,
|
|
|
230
494
|
changes = diff_prices(old, new_prices)
|
|
231
495
|
if not changes:
|
|
232
496
|
return "unchanged", []
|
|
233
|
-
dest = write_snapshot(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return None
|
|
240
|
-
snaps = sorted(
|
|
241
|
-
os.path.join(snapshot_dir, n)
|
|
242
|
-
for n in os.listdir(snapshot_dir)
|
|
243
|
-
if n.startswith("snapshot-") and n.endswith(".json")
|
|
497
|
+
dest = write_snapshot(
|
|
498
|
+
new_prices,
|
|
499
|
+
snapshot_dir=snapshot_dir,
|
|
500
|
+
source_url=source_url,
|
|
501
|
+
vendor=vendor,
|
|
502
|
+
currency=config.currency,
|
|
244
503
|
)
|
|
245
|
-
return
|
|
504
|
+
return f"written:{dest}", changes
|
|
246
505
|
|
|
247
506
|
|
|
248
|
-
# CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
|
|
507
|
+
# ─── CLI entry — `python3 lib/prices_fetcher.py refresh|show` is the fallback when
|
|
249
508
|
# bin/roll is unavailable (e.g. running tests directly).
|
|
250
509
|
def _main(argv: List[str]) -> int:
|
|
251
510
|
snapshot_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "prices")
|
|
252
511
|
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
253
|
-
print("usage: prices_fetcher.py refresh|show [--url URL]")
|
|
512
|
+
print("usage: prices_fetcher.py refresh|show [--url URL] [--vendor VENDOR]")
|
|
254
513
|
return 0
|
|
255
514
|
cmd = argv[0]
|
|
256
|
-
url =
|
|
257
|
-
|
|
258
|
-
|
|
515
|
+
url: Optional[str] = None
|
|
516
|
+
vendor = "anthropic"
|
|
517
|
+
i = 1
|
|
518
|
+
while i < len(argv):
|
|
519
|
+
if argv[i] == "--url" and i + 1 < len(argv):
|
|
520
|
+
url = argv[i + 1]
|
|
521
|
+
i += 2
|
|
522
|
+
elif argv[i] == "--vendor" and i + 1 < len(argv):
|
|
523
|
+
vendor = argv[i + 1]
|
|
524
|
+
i += 2
|
|
525
|
+
else:
|
|
526
|
+
i += 1
|
|
259
527
|
if cmd == "show":
|
|
260
|
-
latest = _latest_snapshot_path(snapshot_dir)
|
|
528
|
+
latest = _latest_snapshot_path(snapshot_dir, vendor=vendor)
|
|
261
529
|
if not latest:
|
|
262
530
|
print("no snapshot found", file=sys.stderr)
|
|
263
531
|
return 1
|
|
@@ -266,7 +534,7 @@ def _main(argv: List[str]) -> int:
|
|
|
266
534
|
return 0
|
|
267
535
|
if cmd == "refresh":
|
|
268
536
|
try:
|
|
269
|
-
action, changes = refresh(snapshot_dir=snapshot_dir, url=url)
|
|
537
|
+
action, changes = refresh(snapshot_dir=snapshot_dir, vendor=vendor, url=url)
|
|
270
538
|
except FetchError as exc:
|
|
271
539
|
print(f"fetch failed: {exc}", file=sys.stderr)
|
|
272
540
|
return 2
|
package/package.json
CHANGED
|
@@ -48,7 +48,7 @@ Create mode:
|
|
|
48
48
|
CHANGELOG 是给**使用者**看的,不是给维护者看的。一句话讲清"用户能做什么 / 不再被什么坑",能不写就不写。
|
|
49
49
|
|
|
50
50
|
**BACKLOG 描述写好了,CHANGELOG 就是复制 + 过滤,不是重写。**
|
|
51
|
-
如果 BACKLOG 描述已经是人话、一句话、说用户价值,直接用它(去掉 `depends-on:`
|
|
51
|
+
如果 BACKLOG 描述已经是人话、一句话、说用户价值,直接用它(去掉 `depends-on:` 等功能性标签)。
|
|
52
52
|
只有 BACKLOG 描述包含实现细节或技术黑话时,才需要改写。
|
|
53
53
|
|
|
54
54
|
**FIX 条目的 filter 规则**:BACKLOG 的 FIX 描述通常是 `<用户症状> — <修复手段>` 结构。
|
|
@@ -103,24 +103,12 @@ this point, any `🔨 In Progress` row in `.roll/backlog.md` belongs to a
|
|
|
103
103
|
previous cycle that crashed before flipping it back; reclaim it before
|
|
104
104
|
scanning.
|
|
105
105
|
|
|
106
|
-
**Important — skip `manual-only:*` rows.** A row tagged `manual-only:*`
|
|
107
|
-
means a human (or another non-loop process) has explicitly claimed it;
|
|
108
|
-
it is not loop's to reclaim. Reverting it would silently undo the
|
|
109
|
-
human's claim and cause confusing churn for `roll-brief` / dashboard
|
|
110
|
-
readers. The rule mirrors the gate in Step 2.
|
|
111
|
-
|
|
112
106
|
1. Scan .roll/backlog.md for all rows whose Status column contains `🔨 In Progress`.
|
|
113
|
-
2. For each
|
|
114
|
-
```bash
|
|
115
|
-
bash -c 'source "$(command -v roll)"; _loop_is_manual_only "<story-id>" .roll/backlog.md'
|
|
116
|
-
# exit 0 → row has `manual-only:*` → SKIP (human-claimed; not orphan)
|
|
117
|
-
# exit 1 → reclaimable orphan; continue to step 3
|
|
118
|
-
```
|
|
119
|
-
3. For each row that passes the gate: revert the status back to
|
|
107
|
+
2. For each such row: revert the status back to
|
|
120
108
|
`📋 Todo`, commit `chore: revert orphan 🔨 US-XXX to 📋`, and append
|
|
121
109
|
a line to `~/.shared/roll/loop/ALERT-<slug>.md` recording the orphan
|
|
122
110
|
id and time so the next brief surfaces it.
|
|
123
|
-
|
|
111
|
+
3. After orphan sweep, proceed to Step 1.5 (Pre-run CI health check) before scanning.
|
|
124
112
|
|
|
125
113
|
### Step 1.5 — Pre-run CI Health Check
|
|
126
114
|
|
|
@@ -157,7 +145,7 @@ Call `_loop_pr_inbox` after the pre-run CI check passes. It walks
|
|
|
157
145
|
|
|
158
146
|
| Classification | Action |
|
|
159
147
|
|---|---|
|
|
160
|
-
| `loop_self` (head ref starts with `loop/`, CI not red) |
|
|
148
|
+
| `loop_self` (head ref starts with `loop/` **or** `claude/`, CI not red) | `_loop_pr_merge_self_eager` — squash-merge directly when CI green + clean; if the PR is BEHIND/CONFLICTING with main, `_loop_pr_rebase_stale` rebases it first (circuit-gated) so it merges on a later tick. Never AI-review your own commit. (Agent-authored `claude/*` PRs are loop-owned the same way; a CI-red `claude/*` PR is **not** auto-healed — it falls through for a human to decide.) |
|
|
161
149
|
| `loop_self_ci_red` (loop/* PR whose CI went red) | **US-LOOP-062a**: `_loop_pr_heal_self` — background-heal (per-PR lock + heal budget `ROLL_LOOP_HEAL_MAX`, default 2, via `_project_agent`); on `ROLL_LOOP_NO_HEAL=1` / budget exhausted → deduped `[TYPE:loop-pr-ci-red]` ALERT (never silently dropped) |
|
|
162
150
|
| `blocked_human_request_changes` | Skip — last human review requested changes; wait for the author to push fixes |
|
|
163
151
|
| `blocked_human_approved` | **US-LOOP-062b**: `_loop_pr_merge_approved` — merge directly (`gh pr merge --squash`) when CI green + mergeable, instead of relying on repo auto-merge (which may be off); merge failure is non-fatal (retried next tick) |
|
|
@@ -202,20 +190,16 @@ bash -c 'source "$(command -v roll)"; _loop_pr_claimed_stories'
|
|
|
202
190
|
**Dependency gate** (FIX-032). For each `📋 Todo` candidate, before picking:
|
|
203
191
|
|
|
204
192
|
```bash
|
|
205
|
-
# Source bin/roll once per cycle, then call the
|
|
193
|
+
# Source bin/roll once per cycle, then call the helper per candidate.
|
|
206
194
|
source "$(command -v roll)"
|
|
207
195
|
|
|
208
|
-
bash -c 'source "$(command -v roll)"; _loop_is_manual_only "<story-id>" .roll/backlog.md'
|
|
209
|
-
# exit 0 → row has `manual-only:true` → SKIP this story, log to runs.jsonl
|
|
210
|
-
# `skipped`, append INFO line ("manual-only — requires $roll-build")
|
|
211
|
-
|
|
212
196
|
bash -c 'source "$(command -v roll)"; _loop_check_depends_on "<story-id>" .roll/backlog.md'
|
|
213
197
|
# exit 0 → all `depends-on:US-X,US-Y` are ✅ Done → eligible
|
|
214
198
|
# exit 1 → stdout lists unsatisfied dep IDs; SKIP this story, log to
|
|
215
199
|
# runs.jsonl `skipped` with reason "depends-on: <unsatisfied>"
|
|
216
200
|
```
|
|
217
201
|
|
|
218
|
-
Move to the next candidate when skipping. The
|
|
202
|
+
Move to the next candidate when skipping. The gate is a pure function
|
|
219
203
|
over .roll/backlog.md text — no side effects, no LOCK interaction.
|
|
220
204
|
|
|
221
205
|
Cap at `max_items_per_run` to limit blast radius per cycle.
|
|
@@ -244,14 +228,14 @@ Together these mean: only one loop runs at a time per project (LOCK), and within
|
|
|
244
228
|
> **US-AGENT-006 — Per-story agent routing (pre-cycle)**
|
|
245
229
|
>
|
|
246
230
|
> Before this skill even starts, the runner inner script has already:
|
|
247
|
-
> 1. Picked the next eligible Todo via `_loop_pick_next_story` (priority FIX > US > REFACTOR,
|
|
231
|
+
> 1. Picked the next eligible Todo via `_loop_pick_next_story` (priority FIX > US > REFACTOR, depends-on gate respected)
|
|
248
232
|
> 2. Read its Agent profile (est_min / risk_zone) and routed an agent via `_loop_pick_agent_for_story` (hard rules from `.roll/agent-routes.yaml` + soft preference from `runs.jsonl`)
|
|
249
233
|
> 3. Exported `ROLL_LOOP_ROUTED_STORY` / `ROLL_LOOP_ROUTED_AGENT` / `ROLL_LOOP_ROUTED_RULE` and printed `[loop] story <id> routed to <agent> via <rule_kind>` to cron.log
|
|
250
234
|
>
|
|
251
235
|
> When `ROLL_LOOP_ROUTED_STORY` is set, prefer it as `US_ID` for this cycle. The story has already been chosen by hard+soft routing rules — and, per FIX-146, the runner re-validates it against the authoritative backlog right before handing it to you (re-picking the next eligible Todo if it went ✅ Done / In Progress / ineligible between pick and handoff, emitting a `story_stale` event). So treat `ROLL_LOOP_ROUTED_STORY` as already-eligible and just work it. Only if you still find at cycle start that it is no longer 📋 Todo in BACKLOG (a residual concurrent flip), re-pick the next eligible Todo via `_loop_pick_next_story` rather than idling the whole cycle.
|
|
252
236
|
>
|
|
253
237
|
> Old single-agent fallback (`primary_agent` from `~/.roll/config.yaml`) still applies when:
|
|
254
|
-
> - no story is pickable (empty Todo / all
|
|
238
|
+
> - no story is pickable (empty Todo / all blocked by depends-on)
|
|
255
239
|
> - the matching agent-routes.yaml has no agent that fits the story profile (then `cold_start_default` is used)
|
|
256
240
|
|
|
257
241
|
For each item, **before invoking the executor skill**, mark the story 🔨 In Progress in the **main repo's** .roll/backlog.md so brief and peer agents can see it being worked on. The cycle worktree is gitignored at .roll/, so editing the worktree's own copy + committing carries no change back to main — write directly via the helper instead:
|