trantor 0.18.93 → 0.18.94
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/plugin.json +1 -1
- package/bin/crew-runner.mjs +71 -19
- package/bin/seat-tokens.mjs +34 -0
- package/lib/dep.mjs +37 -0
- package/lib/memory-pin-write.mjs +3 -1
- package/lib/memory-pins.mjs +4 -2
- package/lib/seat-compaction.mjs +34 -0
- package/lib/seat-tokens.mjs +72 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.94",
|
|
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/crew-runner.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "../lib/turn-policy.mjs";
|
|
30
30
|
import { snapshotBeforeCut } from "./cut-snapshot.mjs";
|
|
31
31
|
import { ocTurnUsage, dshTurnUsage, usageTotal } from "../lib/turn-usage.mjs";
|
|
32
|
+
import { ocCompactionContent } from "../lib/seat-compaction.mjs";
|
|
32
33
|
import { listContainers, newContainers, recordContainers, statePathFor as dockerStatePathFor, sweep as dockerSweep } from "../lib/docker-janitor.mjs";
|
|
33
34
|
import {
|
|
34
35
|
auditDutyNudges, claimDutyNudges, claudeTranscriptDir, dutyEscalations, dutyNudgeDirective,
|
|
@@ -746,6 +747,21 @@ const PARK_WINDOW_MS = Math.max(0, Number(process.env.TRANTOR_PARK_WINDOW_MS ||
|
|
|
746
747
|
// The card the CURRENT CLI session belongs to (#6134). 0 = the kickoff session, which belongs to
|
|
747
748
|
// no card, so the first contract that names one starts a session of its own.
|
|
748
749
|
let sessionCard = releaseState?.sessionCard || 0;
|
|
750
|
+
// #11559: per-card token accounting from the runner's own ledger (stdout usage lines, or the
|
|
751
|
+
// provider's session DB when the CLI prints none). A card that crosses its budget pauses — no
|
|
752
|
+
// further turn is bought for THAT card — until the card changes or the seat restarts, both of
|
|
753
|
+
// which reset the counter. The budget itself is an env knob, not a constant in the wall.
|
|
754
|
+
const CARD_TOKEN_BUDGET = Math.max(0, Number(process.env.TRANTOR_CARD_TOKEN_BUDGET || 1_000_000));
|
|
755
|
+
const BUDGET_RETRY_MS = 10 * 60 * 1000; // a paused card's OWN redelivery pace, never other cards'
|
|
756
|
+
let cardTokens = releaseState?.cardTokens || 0;
|
|
757
|
+
let cardStartedAt = releaseState?.cardStartedAt || 0;
|
|
758
|
+
let budgetStop = releaseState?.budgetStop || null; // { card, tokens } once the card crossed
|
|
759
|
+
let budgetRetryAt = releaseState?.budgetRetryAt || 0; // paces the paused card's OWN redeliveries
|
|
760
|
+
|
|
761
|
+
// #11559: the compaction block injected to opencode-family seats via OPENCODE_CONFIG_CONTENT.
|
|
762
|
+
// "" injects nothing (the operator set TRANTOR_OC_COMPACTION=off, or its JSON is invalid — the
|
|
763
|
+
// seat then behaves exactly as before, never with a half-armed config).
|
|
764
|
+
const OC_COMPACTION = ocCompactionContent(process.env);
|
|
749
765
|
|
|
750
766
|
let sid = releaseState?.sid || "";
|
|
751
767
|
// #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
|
|
@@ -930,18 +946,23 @@ exit $turn_exit`;
|
|
|
930
946
|
// spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
|
|
931
947
|
detached: true,
|
|
932
948
|
cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : ["ignore", "inherit", "inherit"],
|
|
933
|
-
env: { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
934
|
-
// #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
|
|
935
|
-
// `up` guard refuses to bring up another project's crew from a shell carrying this badge.
|
|
936
|
-
TRANTOR_SEAT: PROJ,
|
|
937
|
-
// A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
|
|
938
|
-
// so a handoff spawn would only leak an unmanaged interactive window.
|
|
939
|
-
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
|
|
940
|
-
// #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
|
|
941
|
-
// its transcript lines there so the watchdog sees the liveness a real claude shows.
|
|
942
|
-
TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR },
|
|
943
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
944
949
|
};
|
|
950
|
+
const spawnEnv = { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
|
|
951
|
+
// #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
|
|
952
|
+
// `up` guard refuses to bring up another project's crew from a shell carrying this badge.
|
|
953
|
+
TRANTOR_SEAT: PROJ,
|
|
954
|
+
// A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
|
|
955
|
+
// so a handoff spawn would only leak an unmanaged interactive window.
|
|
956
|
+
TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
|
|
957
|
+
// #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
|
|
958
|
+
// its transcript lines there so the watchdog sees the liveness a real claude shows.
|
|
959
|
+
TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR };
|
|
960
|
+
// #11559: opencode only compacts when the context is FULL, which on big-limit models is far
|
|
961
|
+
// past the point a seat card should reach. The merged-content injection arms compaction per
|
|
962
|
+
// seat (schema: opencode.ai/config.json) without touching the operator's global config.
|
|
963
|
+
if (cli.pinned && OC_COMPACTION) spawnEnv.OPENCODE_CONFIG_CONTENT = OC_COMPACTION;
|
|
964
|
+
spawnOpts.env = spawnEnv;
|
|
965
|
+
spawnOpts.maxBuffer = 16 * 1024 * 1024;
|
|
945
966
|
// The timeout signals the shell to snapshot before sweeping, just like the normal box.
|
|
946
967
|
// Its 30s margin keeps it out of the normal cut path.
|
|
947
968
|
if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGTERM"; }
|
|
@@ -1069,14 +1090,16 @@ exit $turn_exit`;
|
|
|
1069
1090
|
// 0 means "this CLI printed no usage", never "free" — so only overwrite when the envelope
|
|
1070
1091
|
// actually carried counts.
|
|
1071
1092
|
if (c) tokens = c.input + c.output + c.cache_read + c.cache_creation;
|
|
1072
|
-
}
|
|
1073
|
-
// #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
|
|
1093
|
+
} // #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
|
|
1074
1094
|
// opencode's db, the session resolved for this turn exactly like the `-s` resume id. Sum this
|
|
1075
1095
|
// turn's window there; null (unknown) leaves the stdout paths above standing — never 0 ("free").
|
|
1076
1096
|
const usage = cli.pinned && sid ? ocTurnUsage(OC_DB, sid, t0, Date.now())
|
|
1077
1097
|
: AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
|
|
1078
1098
|
: null;
|
|
1079
1099
|
if (usage) tokens = usageTotal(usage);
|
|
1100
|
+
// #11559: every token this turn spent rides the card it served. A card-0 turn (kickoff, pulse,
|
|
1101
|
+
// duty) belongs to no card and adds nothing; the counter resets when the served card changes.
|
|
1102
|
+
if (sessionCard > 0 && Number.isFinite(tokens) && tokens > 0) cardTokens += tokens;
|
|
1080
1103
|
// #9724: usage RESOLVED to all zeros means no model work happened — never success; null stays
|
|
1081
1104
|
// unknown, never failure, and a shipped commit is real work (stale record, not the turn).
|
|
1082
1105
|
const zeroUsage = realExit === 0 && effExit === 0 && !newCommit && usageSaysNoWork(usage);
|
|
@@ -1463,7 +1486,8 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1463
1486
|
writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
|
|
1464
1487
|
wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
|
|
1465
1488
|
const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
|
|
1466
|
-
noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions]
|
|
1489
|
+
noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions],
|
|
1490
|
+
cardTokens, cardStartedAt, budgetStop, budgetRetryAt };
|
|
1467
1491
|
process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
|
|
1468
1492
|
{ ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
|
|
1469
1493
|
} catch (error) { log(`runner restart failed: ${error.message}`); }
|
|
@@ -1485,14 +1509,15 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1485
1509
|
log("parked — waiting for the next message or pulse");
|
|
1486
1510
|
continue;
|
|
1487
1511
|
}
|
|
1488
|
-
// A due REDELIVERY runs before we go back to waiting
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
|
|
1512
|
+
// A due REDELIVERY runs before we go back to waiting: during an outage the bus is silent, so
|
|
1513
|
+
// the retryAt timer is the only thing that will ever move these messages. #7756: a held ask
|
|
1514
|
+
// is owed but NEVER redelivered. #11559: a budget-paused card paces its own redeliveries on
|
|
1515
|
+
// budgetRetryAt - a clock that never delays a poll-delivered contract for a different card.
|
|
1516
|
+
if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt && Date.now() >= budgetRetryAt) { await deliverWake(); continue; }
|
|
1492
1517
|
// cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
|
|
1493
1518
|
const due = [];
|
|
1494
1519
|
if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
|
|
1495
|
-
if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
|
|
1520
|
+
if (pendingWake.length && !awaitingAsk) due.push(Math.min(retryAt, budgetRetryAt || retryAt) - Date.now());
|
|
1496
1521
|
const holdS = due.length
|
|
1497
1522
|
? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
|
|
1498
1523
|
: 280;
|
|
@@ -1755,6 +1780,20 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1755
1780
|
// and never inherits the previous contract's card (#10197's judge then reads the right card).
|
|
1756
1781
|
const sessionCardForTurn = card || baseCard;
|
|
1757
1782
|
const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
|
|
1783
|
+
// #11559: a pause rides the card, not the seat — a contract for a DIFFERENT card lifts it
|
|
1784
|
+
// (and resets the counter with the fresh session below); the paused card itself never buys
|
|
1785
|
+
// another CLI session. The wake stays owed and retries on a long clock, so the orchestrator
|
|
1786
|
+
// sees the pause, not a silent seat.
|
|
1787
|
+
if (budgetStop && sessionCardForTurn > 0 && sessionCardForTurn === budgetStop.card) {
|
|
1788
|
+
log(`\x1b[33mqueued — #${budgetStop.card} is over its token budget (${budgetStop.tokens} ≥ ${CARD_TOKEN_BUDGET}); paused until the card moves or \`trantor up ${AGENT}\` resets the budget\x1b[0m`);
|
|
1789
|
+
// Pace only the SELF-redelivery path (a non-empty queue with retryAt=0 would otherwise
|
|
1790
|
+
// hot-loop this branch and starve the poll): budgetRetryAt gates that line alone. A new
|
|
1791
|
+
// contract delivered by the poll never waits on it — a different card lifts the pause.
|
|
1792
|
+
budgetRetryAt = Date.now() + BUDGET_RETRY_MS;
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (budgetStop && fresh) { budgetStop = null; budgetRetryAt = 0; }
|
|
1796
|
+
if (fresh) { cardTokens = 0; cardStartedAt = Date.now(); }
|
|
1758
1797
|
sessionCard = card;
|
|
1759
1798
|
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1760
1799
|
const freshText = fresh
|
|
@@ -1957,6 +1996,19 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1957
1996
|
await notifyAssigners(assigners,
|
|
1958
1997
|
`✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
|
|
1959
1998
|
}
|
|
1999
|
+
// #11559: the pause is judged AFTER the classification so it never masks it — a failed turn
|
|
2000
|
+
// (ec !== 0) stays in its own ladder, and only a clean-exit turn hands its card to the stop.
|
|
2001
|
+
// The stop blocks FUTURE turns for THIS card only: a different card starts clean, and the
|
|
2002
|
+
// alert names the two ways out (move the card, or restart the seat, resetting the counter).
|
|
2003
|
+
if (!budgetStop && CARD_TOKEN_BUDGET > 0 && sessionCard > 0 && cardTokens >= CARD_TOKEN_BUDGET && ec === 0) {
|
|
2004
|
+
budgetStop = { card: sessionCard, tokens: cardTokens };
|
|
2005
|
+
const text = `⛔ #${sessionCard} hit its token budget on ${SESSION}: ${cardTokens} ≥ ${CARD_TOKEN_BUDGET} tokens this card.`
|
|
2006
|
+
+ ` Further turns for THIS card are paused until it moves (new contract, or testing/done) or \`trantor up ${AGENT}\` resets the budget.`;
|
|
2007
|
+
log(`\x1b[33m${text}\x1b[0m`);
|
|
2008
|
+
await notifyAssigners(assigners, text);
|
|
2009
|
+
const orch = `${hostId()}:${PROJ}`;
|
|
2010
|
+
if (!assigners.some(a => a.from === orch)) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
2011
|
+
}
|
|
1960
2012
|
lastTurnAt = Date.now();
|
|
1961
2013
|
}
|
|
1962
2014
|
})();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trantor seat-tokens (#11559) — per-provider token totals for the seat fleet from opencode's
|
|
3
|
+
// session store, the 30-day shape compared week over week: sessions, assistant steps, tokens,
|
|
4
|
+
// the single biggest step, and cost. Read-only; a missing or locked DB prints "no data".
|
|
5
|
+
// node bin/seat-tokens.mjs [--days 30] [--db <path>] [--json]
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { seatTokensReport } from "../lib/seat-tokens.mjs";
|
|
9
|
+
|
|
10
|
+
const argv = process.argv.slice(2);
|
|
11
|
+
const val = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
12
|
+
const DAYS = Math.max(1, Number(val("--days", 30)) || 30);
|
|
13
|
+
const DB = val("--db", join(process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), "opencode", "opencode.db"));
|
|
14
|
+
const since = Date.now() - DAYS * 24 * 60 * 60 * 1000;
|
|
15
|
+
const rows = seatTokensReport({ dbPath: DB, sinceMs: since });
|
|
16
|
+
const fmt = (n) => Number(n).toLocaleString("en-US");
|
|
17
|
+
|
|
18
|
+
if (val("--json", "")) { console.log(JSON.stringify({ db: DB, days: DAYS, providers: rows }, null, 1)); process.exit(0); }
|
|
19
|
+
if (!rows.length) { console.log(`no session data in ${DB} for the last ${DAYS} day(s)`); process.exit(0); }
|
|
20
|
+
console.log(`opencode seat usage, last ${DAYS} day(s) — ${DB}\n`);
|
|
21
|
+
console.log("provider sessions steps input output cache-read cache-write peak-step cost");
|
|
22
|
+
for (const r of rows) {
|
|
23
|
+
console.log(
|
|
24
|
+
String(r.provider).padEnd(18),
|
|
25
|
+
String(r.sessions).padStart(8),
|
|
26
|
+
String(r.steps).padStart(8),
|
|
27
|
+
fmt(r.input).padStart(12),
|
|
28
|
+
fmt(r.output).padStart(10),
|
|
29
|
+
fmt(r.cacheRead).padStart(14),
|
|
30
|
+
fmt(r.cacheWrite).padStart(14),
|
|
31
|
+
fmt(r.peakInput).padStart(12),
|
|
32
|
+
fmt(Math.round(r.cost)).padStart(6),
|
|
33
|
+
);
|
|
34
|
+
}
|
package/lib/dep.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Load an npm dependency from code that may run inside the Claude plugin snapshot, which ships
|
|
2
|
+
// no node_modules: plain import first, then borrow from the globally installed trantor (as mcp.mjs does).
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const req = createRequire(import.meta.url);
|
|
11
|
+
let fallbackRoots = null;
|
|
12
|
+
|
|
13
|
+
function borrowRoots() {
|
|
14
|
+
if (fallbackRoots) return fallbackRoots;
|
|
15
|
+
const roots = [];
|
|
16
|
+
const add = p => { if (p && !roots.includes(p)) roots.push(p); };
|
|
17
|
+
if (process.env.npm_config_prefix) add(join(process.env.npm_config_prefix, "lib", "node_modules"));
|
|
18
|
+
add(join(dirname(process.execPath), "..", "lib", "node_modules"));
|
|
19
|
+
for (const prefix of ["/opt/homebrew", "/usr/local"]) add(join(prefix, "lib", "node_modules"));
|
|
20
|
+
if (!roots.some(r => existsSync(join(r, "trantor")))) {
|
|
21
|
+
try { add(execSync("npm root -g", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim()); } catch {}
|
|
22
|
+
}
|
|
23
|
+
fallbackRoots = roots.flatMap(r => [join(r, "trantor"), r]);
|
|
24
|
+
return fallbackRoots;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function dep(spec) {
|
|
28
|
+
try {
|
|
29
|
+
return await import(spec);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code !== "ERR_MODULE_NOT_FOUND" && error?.code !== "ERR_PACKAGE_PATH_NOT_EXPORTED") throw error;
|
|
32
|
+
}
|
|
33
|
+
for (const path of [HERE, ...borrowRoots()]) {
|
|
34
|
+
try { return await import(pathToFileURL(req.resolve(spec, { paths: [path] })).href); } catch {}
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`cannot resolve '${spec}': this plugin snapshot has no node_modules and no global trantor install to borrow from (npm i -g trantor)`);
|
|
37
|
+
}
|
package/lib/memory-pin-write.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { dep } from "./dep.mjs";
|
|
5
5
|
import { checkPin, frontMatter, memoryDirectory, notePins, pinTarget, refreshPinGraph, symbolFingerprint } from "./memory-pins.mjs";
|
|
6
6
|
|
|
7
|
+
const { stringify } = await dep("yaml");
|
|
8
|
+
|
|
7
9
|
export function replaceNotePins(text, pins) {
|
|
8
10
|
const front = frontMatter(text);
|
|
9
11
|
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
package/lib/memory-pins.mjs
CHANGED
|
@@ -3,8 +3,10 @@ import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, u
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import { dep } from "./dep.mjs";
|
|
7
|
+
|
|
8
|
+
const { isMap, parseDocument } = await dep("yaml");
|
|
9
|
+
const { z } = await dep("zod");
|
|
8
10
|
|
|
9
11
|
export function fingerprint(text) {
|
|
10
12
|
const normalized = text.replace(/\r\n/g, "\n").split("\n")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// #11559: seats run long cards in ONE opencode session (#6134's design), and opencode only
|
|
2
|
+
// auto-compacts when the context is FULL - far past 100K on big-limit models. The knob is the
|
|
3
|
+
// top-level `compaction` block (schema: opencode.ai/config.json), injected per seat via
|
|
4
|
+
// OPENCODE_CONFIG_CONTENT (override: TRANTOR_OC_COMPACTION), which MERGES with the operator's global config.
|
|
5
|
+
|
|
6
|
+
export const OC_COMPACTION_ENV = "TRANTOR_OC_COMPACTION";
|
|
7
|
+
|
|
8
|
+
// The seat default: compaction on, old tool outputs pruned, a small recent window preserved.
|
|
9
|
+
// `reserved` is the buffer subtracted from the model's window before opencode calls the context
|
|
10
|
+
// full, so a larger value fires compaction earlier. Override the whole block with
|
|
11
|
+
// TRANTOR_OC_COMPACTION=<json>, or set it to "off" to ship no config content at all.
|
|
12
|
+
export const OC_COMPACTION_DEFAULT = {
|
|
13
|
+
auto: true,
|
|
14
|
+
prune: true,
|
|
15
|
+
tail_turns: 4,
|
|
16
|
+
preserve_recent_tokens: 24_000,
|
|
17
|
+
reserved: 64_000,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/// A JSON string for OPENCODE_CONFIG_CONTENT, or "" when nothing should be injected: "off" opts
|
|
21
|
+
/// out, invalid JSON opts out loudly at the caller (never arms a half-parsed block), custom JSON
|
|
22
|
+
/// is wrapped as { compaction: <the value> } so the env stays one knob.
|
|
23
|
+
export function ocCompactionContent(env = process.env) {
|
|
24
|
+
const raw = String(env[OC_COMPACTION_ENV] || "").trim();
|
|
25
|
+
if (raw === "off") return "";
|
|
26
|
+
if (!raw) return JSON.stringify({ compaction: OC_COMPACTION_DEFAULT });
|
|
27
|
+
let parsed;
|
|
28
|
+
try { parsed = JSON.parse(raw); } catch { return ""; }
|
|
29
|
+
// SAFETY: the operator's raw JSON arrives here untyped — a bare number, string or null must
|
|
30
|
+
// opt OUT (no config), never arm a half-shaped compaction block at every opencode seat.
|
|
31
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof
|
|
32
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
|
33
|
+
return JSON.stringify({ compaction: parsed });
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// #11559: the seat fleet's token burn was invisible — mined by hand from opencode's DB. This
|
|
2
|
+
// reader makes it a command: per-provider 30-day session/step/token totals, read-only, so a
|
|
3
|
+
// week-over-week comparison is one line. No DB at all is "no data", never zero. Read path
|
|
4
|
+
// mirrors lib/turn-usage.mjs (sqlite3 CLI, -readonly, fail-open).
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const num = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 0 ? n : 0; };
|
|
8
|
+
|
|
9
|
+
/// "provider/model" → "provider"; a bare model reads "?" so the row still groups. The 1.16.x DB
|
|
10
|
+
/// also stores whole JSON blobs ({"id":…,"providerID":…,"variant":…}) in session.model — the
|
|
11
|
+
/// provider then lives at $.providerID. The SQL mirrors this in CASE/WHEN form.
|
|
12
|
+
export function providerOf(model) {
|
|
13
|
+
const s = String(model || "").trim();
|
|
14
|
+
if (s.startsWith("{")) {
|
|
15
|
+
try { return String(JSON.parse(s).providerID || "?"); } catch { return "?"; }
|
|
16
|
+
}
|
|
17
|
+
const at = s.indexOf("/");
|
|
18
|
+
return at > 0 ? s.slice(0, at) : "?";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PROVIDER_SQL = (col) => `CASE WHEN substr(${col},1,1)='{' THEN COALESCE(json_extract(${col},'$.providerID'),'?')`
|
|
22
|
+
+ ` WHEN instr(${col},'/')>1 THEN substr(${col},1,instr(${col},'/')-1) ELSE '?' END`;
|
|
23
|
+
|
|
24
|
+
export const SESSIONS_SQL = (sinceMs) => `SELECT ${SESSION_COLS} FROM session`
|
|
25
|
+
+ ` WHERE time_updated>=${Math.floor(sinceMs)} GROUP BY p ORDER BY total DESC;`;
|
|
26
|
+
const SESSION_COLS = `${PROVIDER_SQL("model")} AS p, COUNT(*) AS sessions,`
|
|
27
|
+
+ ` COALESCE(SUM(tokens_input),0), COALESCE(SUM(tokens_output),0), COALESCE(SUM(tokens_cache_read),0),`
|
|
28
|
+
+ ` COALESCE(SUM(tokens_cache_write),0), COALESCE(SUM(cost),0), COALESCE(MIN(time_created),0),`
|
|
29
|
+
+ ` COALESCE(MAX(time_updated),0), COALESCE(SUM(tokens_input+tokens_output+tokens_cache_read+tokens_cache_write),0) AS total`;
|
|
30
|
+
|
|
31
|
+
export const STEPS_SQL = (sinceMs) => `SELECT ${PROVIDER_SQL("s.model")} AS p,`
|
|
32
|
+
+ ` COUNT(m.id), COALESCE(MAX(json_extract(m.data,'$.tokens.input')),0)`
|
|
33
|
+
+ ` FROM message m JOIN session s ON s.id=m.session_id`
|
|
34
|
+
+ ` WHERE json_extract(m.data,'$.role')='assistant' AND m.time_created>=${Math.floor(sinceMs)} GROUP BY p;`;
|
|
35
|
+
|
|
36
|
+
const pipeRows = (text, cols) => String(text || "").split("\n").filter((l) => l.trim() !== "")
|
|
37
|
+
.map((line) => {
|
|
38
|
+
const parts = line.split("|");
|
|
39
|
+
return parts.length === cols ? parts : null;
|
|
40
|
+
})
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
|
|
43
|
+
/// { provider, sessions, input, output, cacheRead, cacheWrite, cost, firstAt, lastAt, tokens,
|
|
44
|
+
/// steps, peakInput } per provider, tokens descending. [] when both queries return nothing —
|
|
45
|
+
/// an empty fleet and an unreadable DB print the same "no data" line, never zeros that lie.
|
|
46
|
+
export function seatTokensReport({ dbPath, sinceMs, run = spawnSync } = {}) {
|
|
47
|
+
if (!dbPath || !Number.isFinite(sinceMs)) return [];
|
|
48
|
+
const q = (sql) => {
|
|
49
|
+
let r;
|
|
50
|
+
try { r = run("sqlite3", ["-readonly", dbPath, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }); }
|
|
51
|
+
catch { return null; }
|
|
52
|
+
return !r || r.error || r.status !== 0 ? null : r.stdout;
|
|
53
|
+
};
|
|
54
|
+
const sessions = pipeRows(q(SESSIONS_SQL(sinceMs)), 10);
|
|
55
|
+
const steps = pipeRows(q(STEPS_SQL(sinceMs)), 3);
|
|
56
|
+
const byProvider = new Map(sessions.map((c) => [c[0], {
|
|
57
|
+
provider: c[0],
|
|
58
|
+
sessions: num(c[1]),
|
|
59
|
+
input: num(c[2]), output: num(c[3]), cacheRead: num(c[4]), cacheWrite: num(c[5]),
|
|
60
|
+
cost: Number(c[6]) || 0,
|
|
61
|
+
firstAt: num(c[7]), lastAt: num(c[8]),
|
|
62
|
+
tokens: num(c[9]),
|
|
63
|
+
steps: 0, peakInput: 0,
|
|
64
|
+
}]));
|
|
65
|
+
for (const [p, count, peak] of steps) {
|
|
66
|
+
const row = byProvider.get(p);
|
|
67
|
+
if (!row) continue;
|
|
68
|
+
row.steps = num(count);
|
|
69
|
+
row.peakInput = num(peak);
|
|
70
|
+
}
|
|
71
|
+
return [...byProvider.values()].sort((a, b) => b.tokens - a.tokens);
|
|
72
|
+
}
|