trantor 0.17.45 → 0.17.46

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + context-handoff for independent AI coding agents (Claude, Codex, Gemini, …)",
9
- "version": "0.17.45"
9
+ "version": "0.17.46"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "trantor",
14
14
  "source": "./",
15
15
  "description": "The hub-world for AI agent crews. Say \"fire up the crew\" and Claude becomes the architect: a plan-aware Advisor routes the work (solo / cheap inline calls / live crew of Codex, GLM, Kimi & DeepSeek in their own terminal windows), a Kanban/flow command center with a testing gate tracks it, and an economics brain (Scrooge) keeps the receipts. Includes the relay MCP, a SessionStart auto-discovery hook, and a PreCompact context-handoff so a fresh session can take over a full window instead of compacting.",
16
- "version": "0.17.45",
16
+ "version": "0.17.46",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.45",
3
+ "version": "0.17.46",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/bin/advise.mjs CHANGED
@@ -135,10 +135,10 @@ export function advise(input, world = loadWorld()) {
135
135
  : p.difficulty === "medium"
136
136
  ? `medium → solid mid-tier (${agent}) keeps frontier seats free for hard work; ${pool === "api" ? "metered" : "quota"} pool`
137
137
  : `easy → cheapest seat (${agent})`;
138
- // OpenRouter live-select ranks by COST only (the 335-model catalog has no capability scores
139
- // yet that's the capability-ingestion follow-up), so for HARD work it can land a cheap model.
140
- // Flag it: pin a strong model explicitly (openrouter:openrouter/<vendor>/<model>) for hard work.
141
- if (agent === "openrouter" && p.difficulty === "hard") why_r += ` — ⚠️ live-select ranks by cost; PIN a strong model (e.g. openrouter:openrouter/anthropic/claude-opus-latest) for hard work until capability data lands`;
138
+ // OpenRouter live-select ranks capability×cost ACROSS the catalog once `scrooge-capabilities`
139
+ // has scored it (AA scores + price proxy + per-difficulty cost weighting hard escalates to a
140
+ // strong model, easy stays cheap). If it hasn't been run, routing falls back to cost-only.
141
+ if (agent === "openrouter" && p.difficulty === "hard") why_r += ` — OpenRouter ranks capability×cost; run \`scrooge-capabilities\` to keep the catalog scored (or pin openrouter:openrouter/<vendor>/<model>)`;
142
142
  return { ...p, executor: agent, pool, est_cost_usd: est, reason: why_r };
143
143
  });
144
144
  // crew-size rationale: seats are EMERGENT from the work, and we say so
package/bin/doctor.mjs CHANGED
@@ -64,7 +64,7 @@ const CLIS = [
64
64
  // OpenRouter — the BYOM on-ramp: ONE key fronts hundreds of models. Rides opencode; the same
65
65
  // OPENROUTER_API_KEY Scrooge already uses authenticates the crew seat (the runner sources the
66
66
  // .env files). Available the moment the key exists in env/opencode + declared `openrouter=api`.
67
- { name: "openrouter (via opencode · BYOM, hundreds of models)", bin: "opencode", wired: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.mcp?.relay, auth: () => !!process.env.OPENROUTER_API_KEY || !!read(join(H, ".config", "opencode", "opencode.json"))?.provider?.openrouter?.options?.apiKey || [join(H, ".token-scrooge", ".env"), join(H, ".agent-bus", ".env")].some(f => { try { return readFileSync(f, "utf8").includes("OPENROUTER_API_KEY"); } catch { return false; } }), login: `get a key at openrouter.ai/keys, then: echo 'OPENROUTER_API_KEY=sk-or-…' >> ~/.agent-bus/.env && trantor profile set openrouter=api. Seat: trantor up openrouter (live-selects) or pin trantor up openrouter:openrouter/<vendor>/<model>` },
67
+ { name: "openrouter (via opencode · BYOM, hundreds of models)", bin: "opencode", wired: () => !!read(join(H, ".config", "opencode", "opencode.json"))?.mcp?.relay, auth: () => !!process.env.OPENROUTER_API_KEY || !!read(join(H, ".config", "opencode", "opencode.json"))?.provider?.openrouter?.options?.apiKey || [join(H, ".token-scrooge", ".env"), join(H, ".agent-bus", ".env")].some(f => { try { return readFileSync(f, "utf8").includes("OPENROUTER_API_KEY"); } catch { return false; } }), login: `get a key at openrouter.ai/keys, then: echo 'OPENROUTER_API_KEY=sk-or-…' >> ~/.agent-bus/.env && trantor profile set openrouter=api && scrooge-capabilities (scores the catalog so the crew routes it by difficulty). Seat: trantor up openrouter (live-selects) or pin trantor up openrouter:openrouter/<vendor>/<model>` },
68
68
  ];
69
69
  let installed = 0;
70
70
  for (const c of CLIS) {
@@ -171,8 +171,11 @@ def model_quality(caps, mid, metric):
171
171
  v = c.get("intelligence") # fall back to the general index
172
172
  return float(v) if isinstance(v, (int, float)) else 0.0
173
173
 
174
- def blended_cost(reg, mid):
175
- m = reg["models"].get(mid, {})
174
+ def blended_cost(reg, caps, mid):
175
+ # The curated registry carries cost for its own models; for CATALOG models (OpenRouter's
176
+ # hundreds, brought via --candidates) the per-model price rides on the capability entry
177
+ # instead — so a brought model is ranked on its real price without bloating the registry.
178
+ m = reg["models"].get(mid) or (caps.get(mid) if isinstance(caps, dict) else None) or {}
176
179
  return max(1e-6, 0.3 * m.get("cost_in", 0) + 0.7 * m.get("cost_out", 0))
177
180
 
178
181
  def weigh_candidates(reg, caps, cand_ids, task, difficulty):
@@ -187,8 +190,16 @@ def weigh_candidates(reg, caps, cand_ids, task, difficulty):
187
190
  floor = scored_q[min(len(scored_q) - 1, int(round(pct * (len(scored_q) - 1))))]
188
191
  survivors = [(mid, q) for mid, q in quals if q >= floor] or quals
189
192
  rw = reg.get("routing") or {}
190
- qw, cw = rw.get("q_weight", 1.5), rw.get("c_weight", 0.5)
191
- out = [(mid, (max(q, 1e-6) ** qw) / (blended_cost(reg, mid) ** cw)) for mid, q in survivors]
193
+ qw = rw.get("q_weight", 1.5)
194
+ # cost-weight scales DOWN as difficulty rises: EASY work optimizes price (cheap wins), HARD
195
+ # work prioritizes capability so it can escalate to a genuinely strong model instead of the
196
+ # cheapest-that-clears-the-floor (the "deepseek-flash wins everything" trap on a huge catalog).
197
+ # It still weighs cost (hard picks a strong *value* model, not a blind frontier overpay).
198
+ # Per-difficulty override via registry.routing.c_weight_<difficulty>; legacy c_weight still honored.
199
+ CW_BY_DIFF = {"easy": 0.65, "medium": 0.5, "hard": 0.1}
200
+ cw_key = "c_weight_" + str(difficulty)
201
+ cw = rw[cw_key] if cw_key in rw else (CW_BY_DIFF[difficulty] if difficulty in CW_BY_DIFF else rw.get("c_weight", 0.5))
202
+ out = [(mid, (max(q, 1e-6) ** qw) / (blended_cost(reg, caps, mid) ** cw)) for mid, q in survivors]
192
203
  out.sort(key=lambda x: -x[1])
193
204
  return out
194
205
 
@@ -22,7 +22,7 @@ Usage: scrooge-capabilities # refresh from AA (+OpenRouter), show a
22
22
  scrooge-capabilities --dry-run # fetch + match, print, but don't write
23
23
  Exit: 0 updated · 1 nothing fetched (no key / network) · 2 error.
24
24
  """
25
- import sys, os, json, argparse, urllib.request, urllib.error
25
+ import sys, os, json, argparse, math, urllib.request, urllib.error
26
26
 
27
27
  HOME = os.path.expanduser("~")
28
28
  SCROOGE_DIR = os.environ.get("SCROOGE_HOME", os.path.join(HOME, ".token-scrooge"))
@@ -122,13 +122,35 @@ def fetch_openrouter():
122
122
  if not mid:
123
123
  continue
124
124
  arch = m.get("architecture") or {}
125
- out[norm(mid.split("/")[-1])] = {
125
+ pr = m.get("pricing") or {}
126
+ def _per_m(v): # OpenRouter prices are USD PER TOKEN → $/1M tokens
127
+ try:
128
+ return round(float(v) * 1e6, 4)
129
+ except Exception:
130
+ return None
131
+ # KEY BY THE RAW LAST SEGMENT (dots intact) — this is exactly what `scrooge route` uses to
132
+ # look a candidate up (by_bare = id.split("/")[-1], no normalisation), so the keys must match.
133
+ out[mid.split("/")[-1]] = {
134
+ "full": mid,
126
135
  "context": m.get("context_length"),
127
136
  "modalities": arch.get("input_modalities"),
137
+ "cost_in": _per_m(pr.get("prompt")),
138
+ "cost_out": _per_m(pr.get("completion")),
128
139
  }
129
- sys.stderr.write(DIM(" OpenRouter: %d models fetched (context/modality).\n" % len(out)))
140
+ sys.stderr.write(DIM(" OpenRouter: %d models fetched (pricing/context/modality).\n" % len(out)))
130
141
  return out
131
142
 
143
+ def price_proxy_capability(cost_out):
144
+ """A transparent fallback capability when AA has no score for a brought model: price is a
145
+ decent proxy for tier (frontier models cost more). Monotonic in cost_out ($/1M), with an
146
+ HONEST ceiling — never claim frontier capability from price alone. Gives every catalog model
147
+ a non-zero, rank-able score so the difficulty floor can separate hard from easy work; a later
148
+ AA refresh upgrades it to a real score."""
149
+ if not isinstance(cost_out, (int, float)) or cost_out <= 0:
150
+ return 8.0 # free / unknown price → low floor
151
+ cap = 30.0 + 20.0 * math.log10(cost_out + 0.1)
152
+ return round(max(5.0, min(72.0, cap)), 1)
153
+
132
154
  def aa_scores(m):
133
155
  ev = m.get("evaluations") or {}
134
156
  g = ev.get("gpqa")
@@ -162,6 +184,7 @@ def main():
162
184
  return 1
163
185
 
164
186
  today = __import__("time").strftime("%Y-%m-%d")
187
+ by_or_norm = {norm(k): v for k, v in by_or.items()} # by_or is raw-keyed; registry ids are dash-form
165
188
  matched, unmatched = [], []
166
189
  for mid in reg["models"]:
167
190
  existing = caps.get(mid) if isinstance(caps.get(mid), dict) else {}
@@ -175,7 +198,7 @@ def main():
175
198
  matched.append(mid)
176
199
  elif by_slug:
177
200
  unmatched.append(mid)
178
- orx = by_or.get(norm(mid))
201
+ orx = by_or_norm.get(norm(mid))
179
202
  if orx:
180
203
  if orx.get("context"):
181
204
  rec["context"] = orx["context"]
@@ -184,10 +207,53 @@ def main():
184
207
  if rec:
185
208
  caps[mid] = rec
186
209
 
210
+ # ---- OpenRouter CATALOG ingestion: make every brought model routable by difficulty -------
211
+ # The crew passes OpenRouter's hundreds of models as candidates; the router ranks by
212
+ # capability (gated by a difficulty floor) ÷ price. Write each catalog model as a first-class
213
+ # entry keyed by its RAW bare name (matching `scrooge route`'s lookup): a REAL AA score when
214
+ # the model matches a slug, else a transparent price-tier PROXY — plus its real price so cost
215
+ # ranking works. Registry-curated models are never shadowed. Marked `_src` for honesty; a
216
+ # later AA refresh upgrades proxies in place.
217
+ or_real = or_proxy = 0
218
+ for bare, info in by_or.items():
219
+ if bare in reg["models"]: # curated registry model — leave it authoritative
220
+ continue
221
+ existing = caps.get(bare) if isinstance(caps.get(bare), dict) else {}
222
+ rec = dict(existing)
223
+ if info.get("cost_in") is not None:
224
+ rec["cost_in"] = info["cost_in"]
225
+ if info.get("cost_out") is not None:
226
+ rec["cost_out"] = info["cost_out"]
227
+ if info.get("context"):
228
+ rec.setdefault("context", info["context"])
229
+ has_real = isinstance(rec.get("coding"), (int, float)) or isinstance(rec.get("intelligence"), (int, float))
230
+ am = None
231
+ if by_slug:
232
+ full = info.get("full", "")
233
+ am = by_slug.get(norm(full)) or by_slug.get(norm(full.split("/")[-1])) or by_slug.get(bare)
234
+ if am:
235
+ rec.update({k: v for k, v in aa_scores(am).items() if v is not None})
236
+ rec["updated"] = today
237
+ rec["source"] = "artificialanalysis"
238
+ rec.pop("_src", None)
239
+ or_real += 1
240
+ elif not has_real or rec.get("_src") == "openrouter-price-proxy":
241
+ p = price_proxy_capability(rec.get("cost_out"))
242
+ if p is not None:
243
+ rec["coding"] = rec["intelligence"] = rec["reasoning"] = p
244
+ rec["_src"] = "openrouter-price-proxy"
245
+ rec["updated"] = today
246
+ or_proxy += 1
247
+ if rec:
248
+ caps[bare] = rec
249
+
187
250
  caps["_meta"].update({"source": "artificialanalysis.ai + openrouter", "refreshed": today,
188
- "attribution": "https://artificialanalysis.ai/"})
251
+ "attribution": "https://artificialanalysis.ai/",
252
+ "openrouter_catalog": {"aa_scored": or_real, "price_proxy": or_proxy}})
189
253
 
190
254
  sys.stderr.write(GOLD("🪙 scrooge-capabilities — %d matched, %d unmatched\n" % (len(matched), len(unmatched))))
255
+ if by_or:
256
+ sys.stderr.write(GOLD(" OpenRouter catalog — %d AA-scored, %d price-proxy (now routable by difficulty)\n" % (or_real, or_proxy)))
191
257
  for mid in matched:
192
258
  c = caps[mid]
193
259
  sys.stderr.write(" %s %-24s intel=%-5s code=%-5s reason=%-5s %st/s\n" % (
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python3
2
+ """Regression test for capability×cost routing — difficulty-aware escalation + catalog cost.
3
+
4
+ Runs the real weigh_candidates/blended_cost out of bin/scrooge against SYNTHETIC capability
5
+ data (no network, no key), asserting:
6
+ 1. catalog cost rides on the capability entry (blended_cost falls back to caps when the
7
+ registry doesn't have the model) — the OpenRouter-routing fix,
8
+ 2. cost-weight is difficulty-aware: a cheap-but-decent model wins EASY, while HARD escalates
9
+ to a genuinely stronger model instead of the cheapest-that-clears-the-floor.
10
+
11
+ Exit 0 = all pass. Used to verify the T2 OpenRouter-routes-by-difficulty change.
12
+ """
13
+ import os, sys
14
+
15
+ HERE = os.path.dirname(os.path.realpath(__file__))
16
+ SCROOGE = os.path.join(HERE, "bin", "scrooge")
17
+ g = {"__name__": "scr", "__file__": SCROOGE}
18
+ exec(compile(open(SCROOGE).read(), "scrooge", "exec"), g)
19
+
20
+ # Synthetic registry has NO catalog models — cost must come from caps (the OpenRouter case).
21
+ reg = {"models": {}, "routing": {}}
22
+ caps = {
23
+ "cheap-weak": {"coding": 20, "cost_in": 0.05, "cost_out": 0.10}, # junk-tier
24
+ "cheap-strong": {"coding": 56, "cost_in": 0.14, "cost_out": 0.28}, # deepseek-flash-like
25
+ "mid-strong": {"coding": 69, "cost_in": 0.60, "cost_out": 2.40}, # glm-5.2-like
26
+ "frontier": {"coding": 75, "cost_in": 5.00, "cost_out": 22.50}, # gpt-5.5-like
27
+ }
28
+ cands = list(caps.keys())
29
+
30
+ fails = []
31
+ def ok(name, cond):
32
+ print((" ✓ " if cond else " ✗ ") + name)
33
+ if not cond:
34
+ fails.append(name)
35
+
36
+ # 1. catalog cost via caps fallback (model absent from registry)
37
+ ok("blended_cost falls back to the capability entry for catalog models",
38
+ abs(g["blended_cost"](reg, caps, "frontier") - (0.3 * 5.0 + 0.7 * 22.5)) < 1e-6)
39
+ ok("blended_cost is 1e-6 for an entirely unknown model (no crash)",
40
+ g["blended_cost"](reg, caps, "does-not-exist") == 1e-6)
41
+
42
+ def winner(diff):
43
+ return g["weigh_candidates"](reg, caps, cands, "code", diff)[0][0]
44
+
45
+ easy, medium, hard = winner("easy"), winner("medium"), winner("hard")
46
+ print(" picks → easy=%s medium=%s hard=%s" % (easy, medium, hard))
47
+
48
+ # 2. difficulty-aware escalation
49
+ ok("easy prefers a cheap model (cost-optimized)", caps[easy]["coding"] <= caps["mid-strong"]["coding"])
50
+ ok("hard escalates to a stronger model than easy", caps[hard]["coding"] > caps[easy]["coding"])
51
+ ok("hard reaches genuine strength (>= mid-strong tier)", caps[hard]["coding"] >= caps["mid-strong"]["coding"])
52
+ ok("the junk-tier model never wins any difficulty", "cheap-weak" not in (easy, medium, hard))
53
+
54
+ print(("\nALL PASS" if not fails else "\nFAILED: %d" % len(fails)))
55
+ sys.exit(1 if fails else 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.45",
3
+ "version": "0.17.46",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -10,9 +10,9 @@
10
10
  "zod": "^4.4.3"
11
11
  },
12
12
  "scripts": {
13
- "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-focus.mjs"
13
+ "test": "node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-handoff.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-balances.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-focus.mjs && python3 engine/test-routing.py"
14
14
  },
15
- "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, Gemini, Kimi & DeepSeek as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
15
+ "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
16
16
  "files": [
17
17
  "hub.mjs",
18
18
  "mcp.mjs",