trantor 0.17.3 → 0.17.4

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.3"
9
+ "version": "0.17.4"
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, Gemini, 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.3",
16
+ "version": "0.17.4",
17
17
  "author": {
18
18
  "name": "Sasha Bogojevic"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
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/hub.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // machines reach it (e.g. over a Tailscale tailnet), set RELAY_HOST=0.0.0.0 — but only on a
6
6
  // private network, or add auth first. See "Always-on / remote hub" in the README (roadmap).
7
7
  import http from "node:http";
8
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
 
@@ -16,6 +16,12 @@ const DATA = join(DATA_DIR, "bus.json");
16
16
  const ONLINE_MS = Number(process.env.RELAY_ONLINE_MS || 5 * 60 * 1000);
17
17
  if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
18
18
 
19
+ // Scrooge ledger cache: /economics is polled every ~15s by the dashboard, but the ledger
20
+ // (~/.token-scrooge/calls.jsonl) only changes when a cheap-model call lands. Re-parse the whole
21
+ // file only when its mtime moves; otherwise reuse the parsed rows. Keeps the lifetime running
22
+ // total cheap to serve no matter how big the ledger grows.
23
+ let _ledgerCache = { mtimeMs: -1, rows: [] };
24
+
19
25
  // peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
20
26
  // projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
21
27
  let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [] };
@@ -167,22 +173,43 @@ const server = http.createServer(async (req, res) => {
167
173
  dirty = true; return json(res, 200, { ok: true, count: state.lessons.length });
168
174
  }
169
175
  if (req.method === "GET" && P === "/economics") { // the brain's books, surfaced: scrooge ledger + quota profile
170
- const out = { scrooge: null, profile: null };
176
+ const out = { scrooge: null, lifetime: null, profile: null };
171
177
  try { out.profile = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
172
178
  try {
173
- const since = now() / 1000 - (Number(q.hours || 24) * 3600);
174
- const lines = readFileSync(join(homedir(), ".token-scrooge", "calls.jsonl"), "utf8").trim().split("\n").slice(-3000);
175
- const calls = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(c => c && c.ts >= since && c.ok);
176
- const sum = { calls: calls.length, tokens_in: 0, tokens_out: 0, cost_usd: 0, by_model: {} };
177
- for (const c of calls) {
178
- sum.tokens_in += c.tokens_in || 0; sum.tokens_out += c.tokens_out || 0; sum.cost_usd += c.cost_usd || 0;
179
- const m = sum.by_model[c.model] ||= { calls: 0, cost_usd: 0 };
180
- m.calls++; m.cost_usd += c.cost_usd || 0;
179
+ const ledger = join(homedir(), ".token-scrooge", "calls.jsonl");
180
+ const st = statSync(ledger);
181
+ if (st.mtimeMs !== _ledgerCache.mtimeMs) { // ledger changed reparse the whole file once
182
+ const rows = readFileSync(ledger, "utf8").trim().split("\n")
183
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
184
+ .filter(c => c && c.ok);
185
+ _ledgerCache = { mtimeMs: st.mtimeMs, rows };
181
186
  }
182
- // savings vs Opus reference (~$15/M in, $75/M out — same yardstick scrooge ledger uses)
183
- sum.opus_equiv_usd = +(sum.tokens_in * 15 / 1e6 + sum.tokens_out * 75 / 1e6).toFixed(2);
184
- sum.cost_usd = +sum.cost_usd.toFixed(4);
185
- out.scrooge = sum;
187
+ const rows = _ledgerCache.rows;
188
+ // Roll up a set of calls into spend + the frontier-model yardstick (~$15/M in, $75/M out,
189
+ // same reference scrooge's own ledger uses) and the resulting savings.
190
+ const rollup = calls => {
191
+ const s = { calls: calls.length, tokens_in: 0, tokens_out: 0, cost_usd: 0, by_model: {} };
192
+ for (const c of calls) {
193
+ s.tokens_in += c.tokens_in || 0; s.tokens_out += c.tokens_out || 0; s.cost_usd += c.cost_usd || 0;
194
+ const m = s.by_model[c.model] ||= { calls: 0, cost_usd: 0 };
195
+ m.calls++; m.cost_usd += c.cost_usd || 0;
196
+ }
197
+ s.opus_equiv_usd = +(s.tokens_in * 15 / 1e6 + s.tokens_out * 75 / 1e6).toFixed(2);
198
+ s.cost_usd = +s.cost_usd.toFixed(4);
199
+ s.saved_usd = +Math.max(0, s.opus_equiv_usd - s.cost_usd).toFixed(2);
200
+ return s;
201
+ };
202
+ // Named rolling windows the dashboard dropdown offers, all served in one response so
203
+ // switching the selector is instant (no refetch) — cheap because the rows are cached.
204
+ const nowS = now() / 1000;
205
+ const WINDOWS = { "24h": 24, "week": 168, "month": 720, "quarter": 2160, "year": 8760 };
206
+ out.windows = {};
207
+ for (const [k, hrs] of Object.entries(WINDOWS)) out.windows[k] = rollup(rows.filter(c => c.ts >= nowS - hrs * 3600));
208
+ out.lifetime = rollup(rows); // all-time running total
209
+ out.lifetime.since_ts = rows.length ? rows[0].ts : null; // first ledgered call
210
+ out.windows.lifetime = out.lifetime;
211
+ // back-compat: `scrooge` is the window older dashboards read (honor ?hours= if passed)
212
+ out.scrooge = q.hours ? rollup(rows.filter(c => c.ts >= nowS - Number(q.hours) * 3600)) : out.windows["24h"];
186
213
  } catch {}
187
214
  return json(res, 200, out);
188
215
  }
package/mcp.mjs CHANGED
@@ -44,11 +44,12 @@ server.tool("relay_whoami", "Show this session's relay identity, project, and th
44
44
  return { content: [{ type: "text", text: `session=${SESSION}\nproject=${PROJECT}\nhub=${URL_BASE}` }] };
45
45
  });
46
46
 
47
- server.tool("relay_task_add", "Add a Kanban card to THIS project's board on the dashboard (what you're about to work on). Defaults: assigned to you, status 'todo'. Keep the team's progress visible.",
48
- { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)") },
49
- async ({ title, status, assignee, difficulty, model, deps }) => {
50
- const { task } = await api("POST", "/task", { project: PROJECT, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
51
- return { content: [{ type: "text", text: `card #${task.id} added to ${PROJECT}: "${title}" [${task.status}]` }] };
47
+ server.tool("relay_task_add", "Add a Kanban card to a project's board on the dashboard (what you're about to work on). Defaults: THIS project, assigned to you, status 'todo'. Pass `project` to target another board — e.g. when you orchestrate a crew that runs in a different directory than the one you launched Claude from. Keep the team's progress visible.",
48
+ { title: z.string().describe("short task title"), status: z.enum(["todo","doing","testing","failed","done","blocked"]).optional(), assignee: z.string().optional().describe("session id to assign (default: you)"), difficulty: z.enum(["easy","medium","hard"]).optional().describe("difficulty tag — drives model/agent routing (relay_advise) and shows on the board"), model: z.string().optional().describe("the model this card is routed to (from relay_advise routing, or the CLI default) — shown on the card"), deps: z.array(z.number()).optional().describe("card ids this card depends on — drawn as edges in the Flow view (e.g. integration depends on every crew card)"), project: z.string().optional().describe("board to add to (default: this session's project). Set to the crew's project when you orchestrate from a different directory") },
49
+ async ({ title, status, assignee, difficulty, model, deps, project }) => {
50
+ const proj = project || PROJECT;
51
+ const { task } = await api("POST", "/task", { project: proj, title, status: status || "todo", assignee: assignee || SESSION, difficulty, model, deps, by: SESSION });
52
+ return { content: [{ type: "text", text: `card #${task.id} added to ${proj}: "${title}" [${task.status}]` }] };
52
53
  });
53
54
 
54
55
  server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doing -> testing -> done. NEVER move straight to done: move to 'testing' when you finish, run the project's tests/typecheck, then 'done' only if green — or 'failed' (with a relay_send explaining what broke) if not. The orchestrator bounces failed cards back to doing. blocked = waiting on something external.",
@@ -58,11 +59,12 @@ server.tool("relay_task_move", "Move a Kanban card as you progress: todo -> doin
58
59
  return { content: [{ type: "text", text: `card #${id} -> ${status}` }] };
59
60
  });
60
61
 
61
- server.tool("relay_project_brief", "Set a one-paragraph brief for THIS project shown on the dashboard: what it is, why it matters, and the goal. Set it once when you start work so anyone watching the board understands the project at a glance (the board itself shows where it is in the process).",
62
- { brief: z.string().describe("1-3 sentences: what this project is + why + the goal") },
63
- async ({ brief }) => {
64
- await api("POST", "/project", { project: PROJECT, brief, by: SESSION });
65
- return { content: [{ type: "text", text: `brief set for ${PROJECT}` }] };
62
+ server.tool("relay_project_brief", "Set a one-paragraph brief for a project shown on the dashboard: what it is, why it matters, and the goal. Defaults to THIS project; pass `project` to brief a crew board you orchestrate from elsewhere. Set it once when you start work so anyone watching the board understands the project at a glance (the board itself shows where it is in the process).",
63
+ { brief: z.string().describe("1-3 sentences: what this project is + why + the goal"), project: z.string().optional().describe("board to brief (default: this session's project)") },
64
+ async ({ brief, project }) => {
65
+ const proj = project || PROJECT;
66
+ await api("POST", "/project", { project: proj, brief, by: SESSION });
67
+ return { content: [{ type: "text", text: `brief set for ${proj}` }] };
66
68
  });
67
69
 
68
70
  server.tool("relay_advise", "THE ADVISOR — ask the brain how to execute a body of work before spending tokens. Give it your work packages (with difficulty); it weighs task shape x the user's plan economics x context horizon and returns: mode (solo|scrooge|crew|hybrid), per-package executor+model routing, and a real-money estimate with quota-pool accounting. Call this at project kickoff and PRESENT the summary to the user before firing anything up.",
@@ -92,13 +94,16 @@ server.tool("relay_lesson", "Record a LESSON learned from a failure so future cr
92
94
  return { content: [{ type: "text", text: r.dedup ? "lesson already recorded" : `lesson recorded (${r.count} total)` }] };
93
95
  });
94
96
 
95
- server.tool("relay_board", "Show THIS project's Kanban board (all cards + their status + assignee).", {}, async () => {
96
- const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(PROJECT)}`);
97
- if (!tasks.length) return { content: [{ type: "text", text: `${PROJECT}: no cards yet` }] };
97
+ server.tool("relay_board", "Show a project's Kanban board (all cards + their status + assignee). Defaults to THIS project; pass `project` to read a crew board you orchestrate from elsewhere.",
98
+ { project: z.string().optional().describe("board to show (default: this session's project)") },
99
+ async ({ project }) => {
100
+ const proj = project || PROJECT;
101
+ const { tasks } = await api("GET", `/tasks?project=${encodeURIComponent(proj)}`);
102
+ if (!tasks.length) return { content: [{ type: "text", text: `${proj}: no cards yet` }] };
98
103
  const by = { todo: [], doing: [], testing: [], failed: [], done: [], blocked: [] };
99
104
  for (const t of tasks) (by[t.status] || by.todo).push(`#${t.id} ${t.title}${t.assignee ? ` (@${t.assignee})` : ""}`);
100
105
  const cols = Object.entries(by).filter(([, v]) => v.length).map(([k, v]) => `${k.toUpperCase()}:\n ${v.join("\n ")}`);
101
- return { content: [{ type: "text", text: `${PROJECT} board\n${cols.join("\n")}` }] };
106
+ return { content: [{ type: "text", text: `${proj} board\n${cols.join("\n")}` }] };
102
107
  });
103
108
 
104
109
  server.tool("relay_peers", "List other Claude sessions connected to the relay (online in last 5 min).", {}, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
package/ui.html CHANGED
@@ -141,7 +141,7 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
141
141
  <span class="logo">tran<b>t</b>or</span>
142
142
  <span class="pill" id="hub">—</span>
143
143
  <span class="spacer"></span>
144
- <span class="pill" id="econ" title="Scrooge ledger, last 24h" style="display:none"></span>
144
+ <span class="pill" id="econ" title="Trantor savings vs frontier models — lifetime running total + last 24h (from the Scrooge ledger)" style="display:none"></span>
145
145
  <span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
146
146
  </header>
147
147
  <main>
@@ -181,15 +181,34 @@ function iconFor(s,size){const b=brandOf(s);if(!b||!ICON[b])return `<span style=
181
181
  const phaseClass=ph=>/^idle ·/.test(ph)?'planned':/FAILED|blocked/.test(ph)?'blocked':/building|verifying|progress/.test(ph)?'building':/shipped|done/.test(ph)?'shipped':'planned';
182
182
 
183
183
  let POOLS={};
184
+ let ECON=null;
185
+ const ECON_WINS=[['24h','last 24h'],['week','last week'],['month','last month'],['quarter','last quarter'],['year','last year']];
186
+ let econWin=localStorage.getItem('abEconWin')||'24h';
187
+ const econSaved=x=>x?(x.saved_usd!=null?(+x.saved_usd):Math.max(0,(x.opus_equiv_usd||0)-(x.cost_usd||0))):0;
188
+ function renderEcon(){
189
+ if(!ECON||!ECON.lifetime||!ECON.lifetime.calls)return;
190
+ const el=$('#econ'); el.style.display='';
191
+ // lifetime running total is the fixed headline; the dropdown picks the comparison window.
192
+ if(!el.dataset.built){
193
+ el.innerHTML=`🪙 Trantor saved <b style="color:var(--grn)" id="econlife"></b> vs frontier`+
194
+ `<span style="opacity:.55"> · lifetime · </span>`+
195
+ `<select id="econsel" title="comparison window" style="background:#1a2030;color:inherit;border:1px solid rgba(255,255,255,.18);border-radius:4px;font:inherit;font-size:11px;padding:1px 3px;cursor:pointer;outline:none">`+
196
+ ECON_WINS.map(([k,l])=>`<option value="${k}">${l}</option>`).join('')+`</select>`+
197
+ `<span style="opacity:.85" id="econwinval"></span>`;
198
+ const sel=$('#econsel'); sel.value=econWin;
199
+ sel.onchange=()=>{econWin=sel.value; localStorage.setItem('abEconWin',econWin); renderEcon();};
200
+ el.dataset.built='1';
201
+ }
202
+ const w=(ECON.windows&&ECON.windows[econWin])||ECON.scrooge, wc=w?w.calls:0;
203
+ $('#econlife').textContent='$'+econSaved(ECON.lifetime).toFixed(2);
204
+ $('#econwinval').textContent=' $'+econSaved(w).toFixed(2)+' ('+wc+' call'+(wc===1?'':'s')+')';
205
+ const sel=$('#econsel'); if(sel)sel.value=econWin;
206
+ }
184
207
  async function econ(){
185
208
  try{
186
209
  const e=await (await fetch('/economics')).json();
187
210
  POOLS={}; for(const[k,v]of Object.entries(e.profile||{}))POOLS[k]=v.tier;
188
- if(e.scrooge&&e.scrooge.calls>0){
189
- const s=e.scrooge;
190
- const el=$('#econ'); el.style.display='';
191
- el.innerHTML=`🪙 scrooge 24h: <b style="color:var(--grn)">$${s.cost_usd}</b> · saved ~$${Math.max(0,(s.opus_equiv_usd-s.cost_usd)).toFixed(2)} · ${s.calls} calls`;
192
- }
211
+ ECON=e; renderEcon();
193
212
  }catch(_){}
194
213
  }
195
214
  econ();setInterval(econ,15000);