trantor 0.17.3 → 0.17.5
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hub.mjs +135 -14
- package/mcp.mjs +19 -14
- package/package.json +1 -1
- package/ui.html +116 -7
|
@@ -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.
|
|
9
|
+
"version": "0.17.5"
|
|
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.
|
|
16
|
+
"version": "0.17.5",
|
|
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
|
+
"version": "0.17.5",
|
|
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, readdirSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
@@ -16,6 +16,35 @@ 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
|
+
|
|
25
|
+
// Per-turn failure telemetry lives across many ~/.agent-bus/logs/<agent>-<project>.jsonl files
|
|
26
|
+
// (written by crew-runner.mjs). Scanning them all every /learning poll would be wasteful, so cache
|
|
27
|
+
// the aggregate and only rescan when a log file changes (tracked by the dir's newest mtime).
|
|
28
|
+
const LOGDIR = join(homedir(), ".agent-bus", "logs");
|
|
29
|
+
let _telemetryCache = { maxMtimeMs: -1, turns: [] };
|
|
30
|
+
function scanTelemetry() {
|
|
31
|
+
let files = [];
|
|
32
|
+
try { files = readdirSync(LOGDIR).filter(f => f.endsWith(".jsonl")); } catch { return _telemetryCache.turns; }
|
|
33
|
+
let maxMtime = 0;
|
|
34
|
+
for (const f of files) { try { const m = statSync(join(LOGDIR, f)).mtimeMs; if (m > maxMtime) maxMtime = m; } catch {} }
|
|
35
|
+
if (maxMtime === _telemetryCache.maxMtimeMs) return _telemetryCache.turns; // nothing changed
|
|
36
|
+
const turns = [];
|
|
37
|
+
for (const f of files) {
|
|
38
|
+
let txt = ""; try { txt = readFileSync(join(LOGDIR, f), "utf8"); } catch { continue; }
|
|
39
|
+
for (const line of txt.trim().split("\n")) {
|
|
40
|
+
if (!line) continue;
|
|
41
|
+
try { const r = JSON.parse(line); if (r && r.agent) turns.push(r); } catch {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
_telemetryCache = { maxMtimeMs: maxMtime, turns };
|
|
45
|
+
return turns;
|
|
46
|
+
}
|
|
47
|
+
|
|
19
48
|
// peers: { session: { lastSeen, status, project } } ; tasks: kanban cards
|
|
20
49
|
// projectMeta: { project: { brief, by, updated } } — the "what & why" blurb per project
|
|
21
50
|
let state = { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [] };
|
|
@@ -167,22 +196,43 @@ const server = http.createServer(async (req, res) => {
|
|
|
167
196
|
dirty = true; return json(res, 200, { ok: true, count: state.lessons.length });
|
|
168
197
|
}
|
|
169
198
|
if (req.method === "GET" && P === "/economics") { // the brain's books, surfaced: scrooge ledger + quota profile
|
|
170
|
-
const out = { scrooge: null, profile: null };
|
|
199
|
+
const out = { scrooge: null, lifetime: null, profile: null };
|
|
171
200
|
try { out.profile = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
|
|
172
201
|
try {
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
m.calls++; m.cost_usd += c.cost_usd || 0;
|
|
202
|
+
const ledger = join(homedir(), ".token-scrooge", "calls.jsonl");
|
|
203
|
+
const st = statSync(ledger);
|
|
204
|
+
if (st.mtimeMs !== _ledgerCache.mtimeMs) { // ledger changed → reparse the whole file once
|
|
205
|
+
const rows = readFileSync(ledger, "utf8").trim().split("\n")
|
|
206
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
207
|
+
.filter(c => c && c.ok);
|
|
208
|
+
_ledgerCache = { mtimeMs: st.mtimeMs, rows };
|
|
181
209
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
210
|
+
const rows = _ledgerCache.rows;
|
|
211
|
+
// Roll up a set of calls into spend + the frontier-model yardstick (~$15/M in, $75/M out,
|
|
212
|
+
// same reference scrooge's own ledger uses) and the resulting savings.
|
|
213
|
+
const rollup = calls => {
|
|
214
|
+
const s = { calls: calls.length, tokens_in: 0, tokens_out: 0, cost_usd: 0, by_model: {} };
|
|
215
|
+
for (const c of calls) {
|
|
216
|
+
s.tokens_in += c.tokens_in || 0; s.tokens_out += c.tokens_out || 0; s.cost_usd += c.cost_usd || 0;
|
|
217
|
+
const m = s.by_model[c.model] ||= { calls: 0, cost_usd: 0 };
|
|
218
|
+
m.calls++; m.cost_usd += c.cost_usd || 0;
|
|
219
|
+
}
|
|
220
|
+
s.opus_equiv_usd = +(s.tokens_in * 15 / 1e6 + s.tokens_out * 75 / 1e6).toFixed(2);
|
|
221
|
+
s.cost_usd = +s.cost_usd.toFixed(4);
|
|
222
|
+
s.saved_usd = +Math.max(0, s.opus_equiv_usd - s.cost_usd).toFixed(2);
|
|
223
|
+
return s;
|
|
224
|
+
};
|
|
225
|
+
// Named rolling windows the dashboard dropdown offers, all served in one response so
|
|
226
|
+
// switching the selector is instant (no refetch) — cheap because the rows are cached.
|
|
227
|
+
const nowS = now() / 1000;
|
|
228
|
+
const WINDOWS = { "24h": 24, "week": 168, "month": 720, "quarter": 2160, "year": 8760 };
|
|
229
|
+
out.windows = {};
|
|
230
|
+
for (const [k, hrs] of Object.entries(WINDOWS)) out.windows[k] = rollup(rows.filter(c => c.ts >= nowS - hrs * 3600));
|
|
231
|
+
out.lifetime = rollup(rows); // all-time running total
|
|
232
|
+
out.lifetime.since_ts = rows.length ? rows[0].ts : null; // first ledgered call
|
|
233
|
+
out.windows.lifetime = out.lifetime;
|
|
234
|
+
// back-compat: `scrooge` is the window older dashboards read (honor ?hours= if passed)
|
|
235
|
+
out.scrooge = q.hours ? rollup(rows.filter(c => c.ts >= nowS - Number(q.hours) * 3600)) : out.windows["24h"];
|
|
186
236
|
} catch {}
|
|
187
237
|
return json(res, 200, out);
|
|
188
238
|
}
|
|
@@ -191,6 +241,77 @@ const server = http.createServer(async (req, res) => {
|
|
|
191
241
|
const ls = state.lessons.filter(l => l.scope === "global" || (agent && l.scope === agent));
|
|
192
242
|
return json(res, 200, { lessons: ls });
|
|
193
243
|
}
|
|
244
|
+
// The self-learning loop, surfaced for the dashboard "Learning" sidebar: relay lessons grouped
|
|
245
|
+
// (global / per-agent / per-project), per-LLM reliability from turn telemetry (+ daily series for
|
|
246
|
+
// charts), and the Scrooge guardrails baked into each model's prompt (+ per-model economics).
|
|
247
|
+
if (req.method === "GET" && P === "/learning") {
|
|
248
|
+
const projOf = by => (by && by.includes(":")) ? by.split(":").pop() : "";
|
|
249
|
+
// ts is ms (lessons/telemetry) or s (ledger). Null-safe: a malformed record with a missing/bad
|
|
250
|
+
// ts must not throw (new Date(NaN).toISOString() does) and 500 the whole endpoint — return null
|
|
251
|
+
// and let callers skip that day-bucket.
|
|
252
|
+
const dayOf = ts => { const n = Number(ts); if (!n) return null; const d = new Date(n > 2e10 ? n : n * 1000); return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); };
|
|
253
|
+
const out = { totals: {}, lessons: { global: [], byAgent: {}, byProject: {}, projects: [] }, agents: [], models: [] };
|
|
254
|
+
|
|
255
|
+
// relay lessons → global / by-agent / by-project (project derived from the recorder's session id)
|
|
256
|
+
const projSet = new Set();
|
|
257
|
+
for (const l of state.lessons) {
|
|
258
|
+
const rec = { text: l.text, scope: l.scope, by: l.by || "", project: projOf(l.by), ts: l.ts || 0 };
|
|
259
|
+
if (l.scope === "global") out.lessons.global.push(rec); else (out.lessons.byAgent[l.scope] ||= []).push(rec);
|
|
260
|
+
if (rec.project) { (out.lessons.byProject[rec.project] ||= []).push(rec); projSet.add(rec.project); }
|
|
261
|
+
}
|
|
262
|
+
out.lessons.projects = [...projSet].sort();
|
|
263
|
+
|
|
264
|
+
// per-LLM reliability from turn telemetry: turns, failures (exit!=0), daily fail-rate series
|
|
265
|
+
const turns = scanTelemetry();
|
|
266
|
+
const byAgent = {}; let totalTurns = 0, totalFails = 0; const modelsSeen = new Set();
|
|
267
|
+
for (const t of turns) {
|
|
268
|
+
const a = (byAgent[t.agent] ||= { agent: t.agent, turns: 0, failures: 0, models: new Set(), lastFailure: null, days: {} });
|
|
269
|
+
a.turns++; totalTurns++;
|
|
270
|
+
if (t.model) { a.models.add(t.model); if (t.model !== "default") modelsSeen.add(t.model); }
|
|
271
|
+
const dk = dayOf(t.ts); const d = dk ? (a.days[dk] ||= { turns: 0, failures: 0 }) : null; if (d) d.turns++;
|
|
272
|
+
if (t.exit && t.exit !== 0) { a.failures++; totalFails++; if (d) d.failures++; if (!a.lastFailure || t.ts > a.lastFailure.ts) a.lastFailure = { ts: t.ts, exit: t.exit, project: t.project || "" }; }
|
|
273
|
+
}
|
|
274
|
+
// lessons-accumulated-over-time per agent scope (relay lessons carry a ts; skip the unstamped older ones)
|
|
275
|
+
const lessonDays = {};
|
|
276
|
+
for (const l of state.lessons) { const d = dayOf(l.ts); if (!d) continue; (lessonDays[l.scope] ||= {}); lessonDays[l.scope][d] = (lessonDays[l.scope][d] || 0) + 1; }
|
|
277
|
+
out.agents = Object.values(byAgent).sort((a, b) => b.turns - a.turns).map(a => {
|
|
278
|
+
const days = Object.keys(a.days).sort();
|
|
279
|
+
let cum = 0; const ld = lessonDays[a.agent] || {};
|
|
280
|
+
return {
|
|
281
|
+
agent: a.agent, turns: a.turns, failures: a.failures, failRate: a.turns ? +(a.failures / a.turns).toFixed(3) : 0,
|
|
282
|
+
lastFailure: a.lastFailure, models: [...a.models],
|
|
283
|
+
series: {
|
|
284
|
+
failRate: days.map(d => ({ day: d, turns: a.days[d].turns, failures: a.days[d].failures, rate: a.days[d].turns ? +(a.days[d].failures / a.days[d].turns).toFixed(3) : 0 })),
|
|
285
|
+
lessons: Object.keys(ld).sort().map(d => ({ day: d, count: (cum += ld[d]) })),
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Scrooge guardrails (per model → per task) + per-model economics from the cached ledger
|
|
291
|
+
let guard = {}; try { guard = JSON.parse(readFileSync(join(homedir(), ".token-scrooge", "lessons.json"), "utf8")) || {}; } catch {}
|
|
292
|
+
try { const lp = join(homedir(), ".token-scrooge", "calls.jsonl"); const st = statSync(lp); if (st.mtimeMs !== _ledgerCache.mtimeMs) { const rows = readFileSync(lp, "utf8").trim().split("\n").map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(c => c && c.ok); _ledgerCache = { mtimeMs: st.mtimeMs, rows }; } } catch {}
|
|
293
|
+
const ledgerByModel = {};
|
|
294
|
+
for (const c of _ledgerCache.rows) {
|
|
295
|
+
const m = (ledgerByModel[c.model] ||= { calls: 0, ti: 0, to: 0, cost: 0, days: {} });
|
|
296
|
+
m.calls++; m.ti += c.tokens_in || 0; m.to += c.tokens_out || 0; m.cost += c.cost_usd || 0;
|
|
297
|
+
const dk = dayOf(c.ts); if (dk) { const d = (m.days[dk] ||= { cost: 0, ti: 0, to: 0 }); d.cost += c.cost_usd || 0; d.ti += c.tokens_in || 0; d.to += c.tokens_out || 0; }
|
|
298
|
+
}
|
|
299
|
+
const savedOf = (ti, to, cost) => +Math.max(0, ti * 15 / 1e6 + to * 75 / 1e6 - cost).toFixed(2);
|
|
300
|
+
let totalGuardrails = 0;
|
|
301
|
+
const mkModel = (model, g) => {
|
|
302
|
+
const gcount = Object.values(g || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0);
|
|
303
|
+
totalGuardrails += gcount; const lm = ledgerByModel[model];
|
|
304
|
+
return { model, guardrails: g || {}, guardrailCount: gcount, calls: lm ? lm.calls : 0, cost_usd: lm ? +lm.cost.toFixed(4) : 0,
|
|
305
|
+
saved_usd: lm ? savedOf(lm.ti, lm.to, lm.cost) : 0,
|
|
306
|
+
series: { saved: lm ? Object.keys(lm.days).sort().map(d => ({ day: d, saved: savedOf(lm.days[d].ti, lm.days[d].to, lm.days[d].cost) })) : [] } };
|
|
307
|
+
};
|
|
308
|
+
const modelKeys = new Set([...Object.keys(guard).filter(k => k !== "*"), ...Object.keys(ledgerByModel), ...modelsSeen]);
|
|
309
|
+
out.models = [...modelKeys].sort().map(m => mkModel(m, guard[m]));
|
|
310
|
+
if (guard["*"]) out.models.unshift(mkModel("∗ all models", guard["*"])); // guardrails that apply to every model
|
|
311
|
+
|
|
312
|
+
out.totals = { lessons: state.lessons.length, guardrails: totalGuardrails, turns: totalTurns, failures: totalFails, failRate: totalTurns ? +(totalFails / totalTurns).toFixed(3) : 0, models: out.models.length };
|
|
313
|
+
return json(res, 200, out);
|
|
314
|
+
}
|
|
194
315
|
if (req.method === "POST" && P === "/send") {
|
|
195
316
|
const b = await body(req);
|
|
196
317
|
if (!b.from || !String(b.text ?? "").trim()) return json(res, 400, { error: "from and non-empty text required" });
|
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
|
|
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
|
|
51
|
-
|
|
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
|
|
62
|
-
{ brief: z.string().describe("1-3 sentences: what this project is + why + the goal") },
|
|
63
|
-
async ({ brief }) => {
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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: `${
|
|
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
package/ui.html
CHANGED
|
@@ -11,8 +11,38 @@ header{display:flex;align-items:center;gap:13px;padding:11px 18px;border-bottom:
|
|
|
11
11
|
.dot.off{background:var(--dim);box-shadow:none}
|
|
12
12
|
.muted{color:var(--mut)}.dim{color:var(--dim)}.spacer{flex:1}
|
|
13
13
|
.pill{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:3px 11px;font-size:12px;color:var(--mut)}
|
|
14
|
-
main{flex:1;display:grid;grid-template-columns:1fr 330px;min-height:0}
|
|
14
|
+
main{flex:1;display:grid;grid-template-columns:var(--lw,44px) 1fr 330px;min-height:0}
|
|
15
|
+
main.learn-open{--lw:372px}
|
|
15
16
|
.boards{overflow-y:auto;padding:16px 18px}
|
|
17
|
+
/* Learning sidebar (collapsible left rail) — surfaces the self-learning loop */
|
|
18
|
+
.learn{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
|
19
|
+
.learn-head{display:flex;align-items:center;gap:8px;padding:11px 12px;border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;user-select:none}
|
|
20
|
+
.learn-head .lt{font-weight:700;font-size:13px;color:var(--tx)}.learn-head .lt b{color:var(--grn)}
|
|
21
|
+
.learn-head .chev{margin-left:auto;color:var(--mut);font-size:14px}
|
|
22
|
+
main:not(.learn-open) .learn-head{flex-direction:column;gap:12px;padding:14px 0;height:100%;border-bottom:none;justify-content:flex-start}
|
|
23
|
+
main:not(.learn-open) .learn-head .lt{writing-mode:vertical-rl;transform:rotate(180deg);letter-spacing:.6px}
|
|
24
|
+
main:not(.learn-open) .learn-head .chev{margin:0}
|
|
25
|
+
main:not(.learn-open) .learn-body{display:none}
|
|
26
|
+
.learn-body{overflow-y:auto;padding:10px 12px 26px;flex:1}
|
|
27
|
+
.lsum{font-size:11.5px;color:var(--mut);line-height:1.5;margin-bottom:13px}.lsum b{color:var(--grn)}
|
|
28
|
+
.lsec{margin-bottom:17px}
|
|
29
|
+
.lsec>h3{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);margin:0 0 7px;display:flex;align-items:center;gap:6px}
|
|
30
|
+
.lsel{background:var(--card);color:var(--tx);border:1px solid var(--line);border-radius:6px;font:inherit;font-size:11px;padding:2px 4px;margin-left:auto;cursor:pointer;outline:none}
|
|
31
|
+
.lcard{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:7px}
|
|
32
|
+
.lcard .lc-h{display:flex;align-items:center;gap:7px;font-size:12px;margin-bottom:5px}
|
|
33
|
+
.lcard .lc-h .nm{font-weight:600;color:var(--tx)}
|
|
34
|
+
.lcard .lc-h .mut{color:var(--mut);font-size:10.5px;margin-left:auto}
|
|
35
|
+
.lles{font-size:11.5px;color:var(--mut);line-height:1.45;padding:5px 0;border-top:1px solid var(--line)}
|
|
36
|
+
.lles:first-child{border-top:none}
|
|
37
|
+
.lles .sc{display:inline-block;font-size:9px;color:var(--dim);border:1px solid var(--line);border-radius:8px;padding:0 5px;margin-right:5px;vertical-align:1px;text-transform:uppercase;letter-spacing:.4px}
|
|
38
|
+
.lguard{font-size:11px;color:var(--mut);line-height:1.42;padding:3px 0 3px 13px;position:relative}
|
|
39
|
+
.lguard:before{content:"";position:absolute;left:3px;top:9px;width:4px;height:4px;border-radius:50%;background:var(--grn)}
|
|
40
|
+
.ltask{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin:5px 0 2px}
|
|
41
|
+
.lstat{display:inline-block;font-size:10.5px;color:var(--mut);margin-right:10px}
|
|
42
|
+
.lstat b{color:var(--tx)}.lstat.bad b{color:var(--red)}.lstat.good b{color:var(--grn)}
|
|
43
|
+
.lchart{margin-top:4px}.lchart svg{display:block;width:100%;height:42px}
|
|
44
|
+
.lbadge{font-size:8.5px;color:var(--grn);border:1px solid #1d4a44;border-radius:8px;padding:1px 6px;text-transform:none;letter-spacing:0}
|
|
45
|
+
.lempty{font-size:11px;color:var(--dim);font-style:italic;padding:4px 0}
|
|
16
46
|
.proj{background:var(--panel);border:1px solid var(--line);border-radius:14px;margin-bottom:16px;overflow:hidden}
|
|
17
47
|
.proj-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--line);background:#0e1421}
|
|
18
48
|
.proj-h .pname{font-family:'Sora',ui-sans-serif,sans-serif;font-weight:700;font-size:15px}
|
|
@@ -141,10 +171,16 @@ aside h2{font-size:10.5px;text-transform:uppercase;letter-spacing:.09em;color:va
|
|
|
141
171
|
<span class="logo">tran<b>t</b>or</span>
|
|
142
172
|
<span class="pill" id="hub">—</span>
|
|
143
173
|
<span class="spacer"></span>
|
|
144
|
-
<span class="pill" id="econ" title="
|
|
174
|
+
<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
175
|
<span class="pill"><span id="nproj">0</span> projects · <span id="nsess">0</span> live · <span id="ntask">0</span> cards</span>
|
|
146
176
|
</header>
|
|
147
177
|
<main>
|
|
178
|
+
<section class="learn" id="learn">
|
|
179
|
+
<div class="learn-head" id="learnToggle" title="Learning — lessons, per-LLM reliability, baked-in guardrails">
|
|
180
|
+
<span class="lt">🧠 <b>Learning</b></span><span class="chev" id="learnChev">›</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="learn-body" id="learnBody"></div>
|
|
183
|
+
</section>
|
|
148
184
|
<div class="boards" id="boards"><div class="empty big">no projects yet — agents register a project on connect</div></div>
|
|
149
185
|
<aside>
|
|
150
186
|
<h2>Live feed</h2>
|
|
@@ -181,15 +217,34 @@ function iconFor(s,size){const b=brandOf(s);if(!b||!ICON[b])return `<span style=
|
|
|
181
217
|
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
218
|
|
|
183
219
|
let POOLS={};
|
|
220
|
+
let ECON=null;
|
|
221
|
+
const ECON_WINS=[['24h','last 24h'],['week','last week'],['month','last month'],['quarter','last quarter'],['year','last year']];
|
|
222
|
+
let econWin=localStorage.getItem('abEconWin')||'24h';
|
|
223
|
+
const econSaved=x=>x?(x.saved_usd!=null?(+x.saved_usd):Math.max(0,(x.opus_equiv_usd||0)-(x.cost_usd||0))):0;
|
|
224
|
+
function renderEcon(){
|
|
225
|
+
if(!ECON||!ECON.lifetime||!ECON.lifetime.calls)return;
|
|
226
|
+
const el=$('#econ'); el.style.display='';
|
|
227
|
+
// lifetime running total is the fixed headline; the dropdown picks the comparison window.
|
|
228
|
+
if(!el.dataset.built){
|
|
229
|
+
el.innerHTML=`🪙 Trantor saved <b style="color:var(--grn)" id="econlife"></b> vs frontier`+
|
|
230
|
+
`<span style="opacity:.55"> · lifetime · </span>`+
|
|
231
|
+
`<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">`+
|
|
232
|
+
ECON_WINS.map(([k,l])=>`<option value="${k}">${l}</option>`).join('')+`</select>`+
|
|
233
|
+
`<span style="opacity:.85" id="econwinval"></span>`;
|
|
234
|
+
const sel=$('#econsel'); sel.value=econWin;
|
|
235
|
+
sel.onchange=()=>{econWin=sel.value; localStorage.setItem('abEconWin',econWin); renderEcon();};
|
|
236
|
+
el.dataset.built='1';
|
|
237
|
+
}
|
|
238
|
+
const w=(ECON.windows&&ECON.windows[econWin])||ECON.scrooge, wc=w?w.calls:0;
|
|
239
|
+
$('#econlife').textContent='$'+econSaved(ECON.lifetime).toFixed(2);
|
|
240
|
+
$('#econwinval').textContent=' $'+econSaved(w).toFixed(2)+' ('+wc+' call'+(wc===1?'':'s')+')';
|
|
241
|
+
const sel=$('#econsel'); if(sel)sel.value=econWin;
|
|
242
|
+
}
|
|
184
243
|
async function econ(){
|
|
185
244
|
try{
|
|
186
245
|
const e=await (await fetch('/economics')).json();
|
|
187
246
|
POOLS={}; for(const[k,v]of Object.entries(e.profile||{}))POOLS[k]=v.tier;
|
|
188
|
-
|
|
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
|
-
}
|
|
247
|
+
ECON=e; renderEcon();
|
|
193
248
|
}catch(_){}
|
|
194
249
|
}
|
|
195
250
|
econ();setInterval(econ,15000);
|
|
@@ -473,6 +528,60 @@ $('#send').onclick=async()=>{const t=$('#text').value.trim();if(!t)return;
|
|
|
473
528
|
$('#text').addEventListener('keydown',e=>{if(e.key==='Enter')$('#send').click();});
|
|
474
529
|
fetch('/recent?limit=40').then(r=>r.json()).then(d=>(d.messages||[]).forEach(m=>addMsg(m,false))).catch(()=>{});
|
|
475
530
|
$('#hub').textContent=location.host;
|
|
531
|
+
/* ---- Learning sidebar: the self-learning loop, surfaced ---- */
|
|
532
|
+
const LGRN='#2dd4bf',LRED='#ef6a6a';
|
|
533
|
+
// tiny inline-SVG charts (no library) — match the flow view's hand-rolled SVG approach
|
|
534
|
+
function lcLine(pts,key,opt){opt=opt||{};const w=300,h=42,pad=4,color=opt.color||LGRN;if(!pts||!pts.length)return '';
|
|
535
|
+
const ys=pts.map(p=>+p[key]||0),max=Math.max(1e-6,...ys),n=pts.length;
|
|
536
|
+
const X=i=>pad+(n<=1?(w-2*pad)/2:i*(w-2*pad)/(n-1)),Y=v=>h-pad-(v/max)*(h-2*pad);
|
|
537
|
+
const pl=pts.map((p,i)=>`${X(i).toFixed(1)},${Y(+p[key]||0).toFixed(1)}`).join(' ');
|
|
538
|
+
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">`+
|
|
539
|
+
(opt.fill?`<polygon points="${pad},${h-pad} ${pl} ${X(n-1).toFixed(1)},${h-pad}" fill="${color}" opacity=".13"/>`:'')+
|
|
540
|
+
`<polyline points="${pl}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round"/>`+
|
|
541
|
+
(n===1?`<circle cx="${X(0).toFixed(1)}" cy="${Y(ys[0]).toFixed(1)}" r="2.2" fill="${color}"/>`:'')+`</svg>`;}
|
|
542
|
+
function lcBars(pts,key,opt){opt=opt||{};const w=300,h=42,pad=4,color=opt.color||LGRN;if(!pts||!pts.length)return '';
|
|
543
|
+
const ys=pts.map(p=>+p[key]||0),max=Math.max(1e-6,...ys),n=pts.length,gap=(w-2*pad)/n,bw=gap*0.68;
|
|
544
|
+
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">`+pts.map((p,i)=>{const v=+p[key]||0,bh=(v/max)*(h-2*pad),x=pad+i*gap+(gap-bw)/2;
|
|
545
|
+
return `<rect x="${x.toFixed(1)}" y="${(h-pad-bh).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(0.5,bh).toFixed(1)}" rx="1" fill="${color}" opacity=".75"/>`;}).join('')+`</svg>`;}
|
|
546
|
+
let LEARN=null,learnProj=localStorage.getItem('abLearnProj')||'';
|
|
547
|
+
let learnOpen=localStorage.getItem('abLearnOpen')==='1';
|
|
548
|
+
function applyLearnState(){document.querySelector('main').classList.toggle('learn-open',learnOpen);$('#learnChev').textContent=learnOpen?'‹':'›';}
|
|
549
|
+
function renderLearnBody(){const el=$('#learnBody');if(!el)return;if(!LEARN){el.innerHTML='<div class="lempty">loading…</div>';return;}
|
|
550
|
+
const L=LEARN,t=L.totals||{};
|
|
551
|
+
let h=`<div class="lsum">Trantor has recorded <b>${t.lessons||0}</b> lessons and baked <b>${t.guardrails||0}</b> guardrails into model prompts. Across <b>${t.turns||0}</b> crew turns the failure rate is <b>${((t.failRate||0)*100).toFixed(1)}%</b> — self-healing as fixes compound.</div>`;
|
|
552
|
+
// Lessons (project filter; lifetime also shows per-agent lessons)
|
|
553
|
+
const projOpts=['<option value="">All projects (lifetime)</option>'].concat((L.lessons.projects||[]).map(p=>`<option value="${esc(p)}"${p===learnProj?' selected':''}>${esc(p)}</option>`)).join('');
|
|
554
|
+
h+=`<div class="lsec"><h3>Lessons <select class="lsel" id="lprojSel">${projOpts}</select></h3>`;
|
|
555
|
+
const lessons=learnProj?(L.lessons.byProject[learnProj]||[]):(L.lessons.global||[]);
|
|
556
|
+
if(!lessons.length)h+=`<div class="lempty">no lessons recorded for this scope yet</div>`;
|
|
557
|
+
else h+=lessons.slice(0,40).map(l=>`<div class="lles"><span class="sc">${esc(l.scope)}</span>${esc(l.text)}</div>`).join('');
|
|
558
|
+
if(!learnProj)for(const[ag,arr]of Object.entries(L.lessons.byAgent||{}))h+=`<div class="lles"><span class="sc">${esc(ag)}</span>${esc(arr[0].text)}${arr.length>1?` <span class="dim">+${arr.length-1} more</span>`:''}</div>`;
|
|
559
|
+
h+=`</div>`;
|
|
560
|
+
// Per-LLM reliability + progression charts
|
|
561
|
+
h+=`<div class="lsec"><h3>Per-LLM reliability</h3>`;
|
|
562
|
+
if(!(L.agents||[]).length)h+=`<div class="lempty">no turn telemetry yet</div>`;
|
|
563
|
+
for(const a of (L.agents||[])){const fr=(a.failRate*100).toFixed(1);
|
|
564
|
+
h+=`<div class="lcard"><div class="lc-h">${iconFor(a.agent,14)}<span class="nm">${esc(a.agent)}</span><span class="mut">${a.models.filter(m=>m!=='default').join(', ')||'default'}</span></div>`;
|
|
565
|
+
h+=`<span class="lstat"><b>${a.turns}</b> turns</span><span class="lstat ${a.failures?'bad':'good'}"><b>${fr}%</b> fail</span>${a.lastFailure?`<span class="lstat">last exit ${a.lastFailure.exit}</span>`:''}`;
|
|
566
|
+
if((a.series.failRate||[]).length>1)h+=`<div class="lchart"><div class="ltask">fail-rate / day</div>${lcLine(a.series.failRate,'rate',{color:LRED,fill:true})}</div>`;
|
|
567
|
+
if((a.series.lessons||[]).length>1)h+=`<div class="lchart"><div class="ltask">lessons learned (cumulative)</div>${lcLine(a.series.lessons,'count',{color:LGRN,fill:true})}</div>`;
|
|
568
|
+
h+=`</div>`;}
|
|
569
|
+
h+=`</div>`;
|
|
570
|
+
// Baked-in guardrails per model + savings chart
|
|
571
|
+
h+=`<div class="lsec"><h3>Baked-in improvements <span class="lbadge">auto-injected into prompts</span></h3>`;
|
|
572
|
+
const gm=(L.models||[]).filter(m=>m.guardrailCount>0);
|
|
573
|
+
if(!gm.length)h+=`<div class="lempty">no model guardrails yet</div>`;
|
|
574
|
+
for(const m of gm){h+=`<div class="lcard"><div class="lc-h"><span class="nm">${esc(m.model)}</span>${m.saved_usd?`<span class="mut">$${m.saved_usd} saved · ${m.calls} calls</span>`:''}</div>`;
|
|
575
|
+
for(const[task,arr]of Object.entries(m.guardrails||{})){h+=`<div class="ltask">${esc(task)}</div>`;for(const g of arr)h+=`<div class="lguard">${esc(g)}</div>`;}
|
|
576
|
+
if((m.series.saved||[]).length>1)h+=`<div class="lchart"><div class="ltask">savings / day</div>${lcBars(m.series.saved,'saved',{color:LGRN})}</div>`;
|
|
577
|
+
h+=`</div>`;}
|
|
578
|
+
h+=`</div>`;
|
|
579
|
+
el.innerHTML=h;
|
|
580
|
+
const ps=$('#lprojSel');if(ps)ps.onchange=()=>{learnProj=ps.value;localStorage.setItem('abLearnProj',learnProj);renderLearnBody();};}
|
|
581
|
+
async function learn(){try{LEARN=await (await fetch('/learning')).json();if(learnOpen)renderLearnBody();}catch(_){}}
|
|
582
|
+
$('#learnToggle').onclick=()=>{learnOpen=!learnOpen;localStorage.setItem('abLearnOpen',learnOpen?'1':'0');applyLearnState();if(learnOpen)renderLearnBody();};
|
|
583
|
+
applyLearnState();learn();setInterval(learn,20000);
|
|
584
|
+
|
|
476
585
|
render();setInterval(render,2500);stream();
|
|
477
586
|
// Self-heal after the laptop sleeps / the tab is backgrounded: browser timers and the SSE
|
|
478
587
|
// stream get suspended and don't reliably resume, so the board freezes on stale data. The
|