trantor 0.18.93 → 0.18.95

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.93",
3
+ "version": "0.18.95",
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": {
@@ -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, ocCompactionLogLine } 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,
@@ -36,7 +37,7 @@ import {
36
37
  } from "../lib/duty-nudges.mjs";
37
38
  import { reserveNudgeIds, releaseNudgeIds } from "../lib/nudge-ledger.mjs";
38
39
  import { dutyRecipientResolver } from "../lib/duty-recipient.mjs";
39
- import { cliEffortFlag } from "../lib/model-catalog.mjs";
40
+ import { cliEffortFlag, contextWindow } from "../lib/model-catalog.mjs";
40
41
  import {
41
42
  BREAKER_WINDOW, STATE_ENV, TURN_RESULT_SCHEMA,
42
43
  breakerVerdict, describeTurn, hasJsonSchemaFlag, parseEnvelope, renderCardTail, runStep,
@@ -746,6 +747,23 @@ 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: TRANTOR_OC_COMPACTION=off or invalid JSON). opencode has no absolute-threshold
763
+ // knob — auto-compact fires at window − reserved (opencode.ai/config.json; calculateCeiling in
764
+ // core/src/session/compaction.ts) — so reserved is sized from the seat model's catalog window.
765
+ const OC_COMPACTION_WINDOW = contextWindow(MODEL);
766
+ const OC_COMPACTION = ocCompactionContent(process.env, OC_COMPACTION_WINDOW);
749
767
 
750
768
  let sid = releaseState?.sid || "";
751
769
  // #6206: the watchdog is DETACHED, so a runner that dies without ending it leaves an orphan
@@ -820,6 +838,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
820
838
  log(`turn starting (${isFirst ? "fresh session" : "resume"})${MODEL ? ` · model=${MODEL}` : ""}`);
821
839
  // #7777: the one effort line — which effort parameters were set, or that the model is uncatalogued.
822
840
  if (EFFORT && EFFORT_FLAG.text) log(EFFORT_FLAG.text);
841
+ // #11559: the one compaction line — only where the block actually rides the spawn.
842
+ if (cli.pinned && OC_COMPACTION) log(ocCompactionLogLine(OC_COMPACTION_WINDOW));
823
843
  cmuxStatus("building", "#4a90d9", "hammer", { priority: 50 }); herdrAgent("working");
824
844
  // inherit stdio so the window shows the agent working live; also capture for sid-parsing.
825
845
  // Tee stderr to ERRF (still shown live in the window) so a failed turn can be classified.
@@ -930,18 +950,23 @@ exit $turn_exit`;
930
950
  // spawned. stdin is /dev/null: a background group that reads the terminal stops on SIGTTIN.
931
951
  detached: true,
932
952
  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
953
  };
954
+ const spawnEnv = { ...process.env, ...shadowEnv(secrets), RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_SESSION: SESSION, RELAY_PROJECT: PROJ,
955
+ // #6228: marks this env as belonging to PROJ, unlike a one-off RELAY_PROJECT override; crew.mjs's
956
+ // `up` guard refuses to bring up another project's crew from a shell carrying this badge.
957
+ TRANTOR_SEAT: PROJ,
958
+ // A runner-managed seat must never hand itself a baton: the runner is its lifecycle manager,
959
+ // so a handoff spawn would only leak an unmanaged interactive window.
960
+ TRANTOR_NO_HANDOFF_SPAWN: "1", TRANTOR_NO_BATON_SPAWN: "1",
961
+ // #6206: the seat's transcript dir — a real CLI ignores it, a drill's fake CLI writes
962
+ // its transcript lines there so the watchdog sees the liveness a real claude shows.
963
+ TRANTOR_TRANSCRIPT_DIR: TRANSCRIPT_DIR };
964
+ // #11559: opencode only compacts when the context is FULL, which on big-limit models is far
965
+ // past the point a seat card should reach. The merged-content injection arms compaction per
966
+ // seat (schema: opencode.ai/config.json) without touching the operator's global config.
967
+ if (cli.pinned && OC_COMPACTION) spawnEnv.OPENCODE_CONFIG_CONTENT = OC_COMPACTION;
968
+ spawnOpts.env = spawnEnv;
969
+ spawnOpts.maxBuffer = 16 * 1024 * 1024;
945
970
  // The timeout signals the shell to snapshot before sweeping, just like the normal box.
946
971
  // Its 30s margin keeps it out of the normal cut path.
947
972
  if (TURN_MAX_MS) { spawnOpts.timeout = (TURN_EXTENSIONS_MAX ? TURN_CEILING_MS : TURN_MAX_MS) + 30000; spawnOpts.killSignal = "SIGTERM"; }
@@ -1069,14 +1094,16 @@ exit $turn_exit`;
1069
1094
  // 0 means "this CLI printed no usage", never "free" — so only overwrite when the envelope
1070
1095
  // actually carried counts.
1071
1096
  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 —
1097
+ } // #8234: the CLIs that print no usage on stdout keep the counts in their own session records —
1074
1098
  // opencode's db, the session resolved for this turn exactly like the `-s` resume id. Sum this
1075
1099
  // turn's window there; null (unknown) leaves the stdout paths above standing — never 0 ("free").
1076
1100
  const usage = cli.pinned && sid ? ocTurnUsage(OC_DB, sid, t0, Date.now())
1077
1101
  : AGENT === "dsh" ? dshTurnUsage(join(homedir(), ".dsh", "sessions"), TURN_DIR, t0, Date.now())
1078
1102
  : null;
1079
1103
  if (usage) tokens = usageTotal(usage);
1104
+ // #11559: every token this turn spent rides the card it served. A card-0 turn (kickoff, pulse,
1105
+ // duty) belongs to no card and adds nothing; the counter resets when the served card changes.
1106
+ if (sessionCard > 0 && Number.isFinite(tokens) && tokens > 0) cardTokens += tokens;
1080
1107
  // #9724: usage RESOLVED to all zeros means no model work happened — never success; null stays
1081
1108
  // unknown, never failure, and a shipped commit is real work (stale record, not the turn).
1082
1109
  const zeroUsage = realExit === 0 && effExit === 0 && !newCommit && usageSaysNoWork(usage);
@@ -1463,7 +1490,8 @@ async function resolveWakeCard(messages, { session }) {
1463
1490
  writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
1464
1491
  wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
1465
1492
  const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
1466
- noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions] };
1493
+ noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions],
1494
+ cardTokens, cardStartedAt, budgetStop, budgetRetryAt };
1467
1495
  process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
1468
1496
  { ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
1469
1497
  } catch (error) { log(`runner restart failed: ${error.message}`); }
@@ -1485,14 +1513,15 @@ async function resolveWakeCard(messages, { session }) {
1485
1513
  log("parked — waiting for the next message or pulse");
1486
1514
  continue;
1487
1515
  }
1488
- // A due REDELIVERY runs before we go back to waiting — during an outage the bus is silent by
1489
- // definition, so the retry timer is the only thing that will ever move these messages.
1490
- // #7756: a held ask is owed but NEVER redelivered — only the assigner's answer releases it.
1491
- if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt) { await deliverWake(); continue; }
1516
+ // A due REDELIVERY runs before we go back to waiting: during an outage the bus is silent, so
1517
+ // the retryAt timer is the only thing that will ever move these messages. #7756: a held ask
1518
+ // is owed but NEVER redelivered. #11559: a budget-paused card paces its own redeliveries on
1519
+ // budgetRetryAt - a clock that never delays a poll-delivered contract for a different card.
1520
+ if (pendingWake.length && !awaitingAsk && Date.now() >= retryAt && Date.now() >= budgetRetryAt) { await deliverWake(); continue; }
1492
1521
  // cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
1493
1522
  const due = [];
1494
1523
  if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
1495
- if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
1524
+ if (pendingWake.length && !awaitingAsk) due.push(Math.min(retryAt, budgetRetryAt || retryAt) - Date.now());
1496
1525
  const holdS = due.length
1497
1526
  ? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
1498
1527
  : 280;
@@ -1755,6 +1784,20 @@ async function resolveWakeCard(messages, { session }) {
1755
1784
  // and never inherits the previous contract's card (#10197's judge then reads the right card).
1756
1785
  const sessionCardForTurn = card || baseCard;
1757
1786
  const fresh = !resumedAfterAsk && sessionCardForTurn > 0 && sessionCardForTurn !== sessionCard;
1787
+ // #11559: a pause rides the card, not the seat — a contract for a DIFFERENT card lifts it
1788
+ // (and resets the counter with the fresh session below); the paused card itself never buys
1789
+ // another CLI session. The wake stays owed and retries on a long clock, so the orchestrator
1790
+ // sees the pause, not a silent seat.
1791
+ if (budgetStop && sessionCardForTurn > 0 && sessionCardForTurn === budgetStop.card) {
1792
+ 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`);
1793
+ // Pace only the SELF-redelivery path (a non-empty queue with retryAt=0 would otherwise
1794
+ // hot-loop this branch and starve the poll): budgetRetryAt gates that line alone. A new
1795
+ // contract delivered by the poll never waits on it — a different card lifts the pause.
1796
+ budgetRetryAt = Date.now() + BUDGET_RETRY_MS;
1797
+ return;
1798
+ }
1799
+ if (budgetStop && fresh) { budgetStop = null; budgetRetryAt = 0; }
1800
+ if (fresh) { cardTokens = 0; cardStartedAt = Date.now(); }
1758
1801
  sessionCard = card;
1759
1802
  const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
1760
1803
  const freshText = fresh
@@ -1957,6 +2000,19 @@ async function resolveWakeCard(messages, { session }) {
1957
2000
  await notifyAssigners(assigners,
1958
2001
  `✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
1959
2002
  }
2003
+ // #11559: the pause is judged AFTER the classification so it never masks it — a failed turn
2004
+ // (ec !== 0) stays in its own ladder, and only a clean-exit turn hands its card to the stop.
2005
+ // The stop blocks FUTURE turns for THIS card only: a different card starts clean, and the
2006
+ // alert names the two ways out (move the card, or restart the seat, resetting the counter).
2007
+ if (!budgetStop && CARD_TOKEN_BUDGET > 0 && sessionCard > 0 && cardTokens >= CARD_TOKEN_BUDGET && ec === 0) {
2008
+ budgetStop = { card: sessionCard, tokens: cardTokens };
2009
+ const text = `⛔ #${sessionCard} hit its token budget on ${SESSION}: ${cardTokens} ≥ ${CARD_TOKEN_BUDGET} tokens this card.`
2010
+ + ` Further turns for THIS card are paused until it moves (new contract, or testing/done) or \`trantor up ${AGENT}\` resets the budget.`;
2011
+ log(`\x1b[33m${text}\x1b[0m`);
2012
+ await notifyAssigners(assigners, text);
2013
+ const orch = `${hostId()}:${PROJ}`;
2014
+ if (!assigners.some(a => a.from === orch)) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
2015
+ }
1960
2016
  lastTurnAt = Date.now();
1961
2017
  }
1962
2018
  })();
@@ -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
+ }
@@ -4,6 +4,8 @@ import { isMainThread, parentPort, Worker, workerData } from "node:worker_thread
4
4
  import { gitCheckoutRoot } from "./git-checkout.mjs";
5
5
 
6
6
  const SLOW = "pin check skipped (slow), run trantor memory check";
7
+ // Tests on a loaded CI runner raise this; the hook itself keeps 500 ms.
8
+ const BUDGET_MS = Number(process.env.TRANTOR_PIN_CHECK_BUDGET_MS) || 500;
7
9
 
8
10
  export function memoryStartLine(project) {
9
11
  return new Promise(resolveLine => {
@@ -17,7 +19,7 @@ export function memoryStartLine(project) {
17
19
  if (worker) void worker.terminate().catch(() => {});
18
20
  };
19
21
  // Keep synchronous graph parsing and file reads off the hook's deadline timer.
20
- const timer = setTimeout(() => finish(SLOW), 500);
22
+ const timer = setTimeout(() => finish(SLOW), BUDGET_MS);
21
23
  try {
22
24
  worker = new Worker(new URL(import.meta.url), { workerData: { project } });
23
25
  worker.once("message", finish);
@@ -37,7 +39,7 @@ async function cachedMemoryLine(directory) {
37
39
  const graph = JSON.parse(readFileSync(graphPath, "utf8"));
38
40
  const nodes = new Map(graph.nodes.map(node => [node.id, node]));
39
41
  const graphTime = statSync(graphPath).mtimeMs;
40
- let unchecked = 0;
42
+ const unchecked = new Set();
41
43
  const stale = new Set(), broken = new Set();
42
44
  const notes = readdirSync(memoryDir, { withFileTypes: true })
43
45
  .filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md");
@@ -45,7 +47,7 @@ async function cachedMemoryLine(directory) {
45
47
  try {
46
48
  const pins = notePins(readFileSync(join(memoryDir, note.name), "utf8"));
47
49
  for (const pin of pins) {
48
- if (pinFileNewer(project, pin, graphTime)) { unchecked++; continue; }
50
+ if (pinFileNewer(project, pin, graphTime)) { unchecked.add(note.name); continue; }
49
51
  const { status } = checkPin(pin, project, nodes);
50
52
  if (status === "stale") stale.add(note.name);
51
53
  if (status === "broken") broken.add(note.name);
@@ -53,8 +55,11 @@ async function cachedMemoryLine(directory) {
53
55
  } catch { /* A concurrent edit or invalid span must not suppress other notes. */ }
54
56
  }
55
57
  const names = [...new Set([...stale, ...broken])].sort().map(name => name.replace(/[\s\x00-\x1f\x7f]+/g, " "));
56
- const suffix = unchecked ? ` (${unchecked} unchecked: graph older than code, run trantor memory check)` : "";
57
- return names.length || unchecked ? `${stale.size} stale, ${broken.size} broken notes: ${names.join(", ")}; run trantor memory check${suffix}` : "";
58
+ const parts = [];
59
+ if (names.length) parts.push(`${stale.size} stale, ${broken.size} broken notes: ${names.join(", ")}`);
60
+ if (unchecked.size) parts.push(`${unchecked.size} pinned notes unchecked (graph older than code)`);
61
+ if (!parts.length) return "";
62
+ return `${parts.join("; ")}${unchecked.size ? "," : ";"} run trantor memory check`;
58
63
  }
59
64
 
60
65
  if (!isMainThread) {
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
+ }
@@ -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 { stringify } from "yaml";
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";
@@ -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 { isMap, parseDocument } from "yaml";
7
- import { z } from "zod";
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")
@@ -40,6 +40,15 @@ export function lookup(modelId, cat = loadCatalog()) {
40
40
  return uncatalogued(id);
41
41
  }
42
42
 
43
+ // The model's context window from its catalog entry: a positive finite number, or undefined
44
+ // when the id is uncatalogued or its context is absent/invalid — callers keep their own
45
+ // default instead of guessing a window.
46
+ export function contextWindow(modelId, cat = loadCatalog()) {
47
+ const entry = lookup(modelId, cat);
48
+ const context = entry.found ? entry.context : undefined;
49
+ return Number.isFinite(context) && context > 0 ? context : undefined;
50
+ }
51
+
43
52
  // The request parameters one difficulty level maps to for one API kind. Returns null when the
44
53
  // model is uncatalogued (or the level is missing); an EMPTY object means catalogued but the
45
54
  // model takes no effort parameters (provider default — e.g. K2.7 Code thinks always).
@@ -0,0 +1,57 @@
1
+ // #11559: seats run long cards in one opencode session (#6134), and opencode auto-compacts only at
2
+ // window − reserved — there is NO absolute-threshold knob (opencode.ai/config.json,
3
+ // additionalProperties:false; calculateCeiling in packages/core/src/session/compaction.ts). So the
4
+ // seat target "compact near 100K" is reached by sizing reserved from the model's window.
5
+
6
+ export const OC_COMPACTION_ENV = "TRANTOR_OC_COMPACTION";
7
+ // The context level a seat card should stay near: on a 1M-window model reserved becomes 900K
8
+ // so auto-compact fires at ~100K, not the ~936K a fixed 64K buffer leaves.
9
+ export const OC_COMPACT_AT = 100_000;
10
+ // The buffer floor: kept when the model's window is unknown, or smaller than would still
11
+ // leave OC_COMPACT_AT free.
12
+ export const OC_RESERVED_FLOOR = 64_000;
13
+
14
+ // The seat default: compaction on, old tool outputs pruned, a small recent window preserved.
15
+ // Override the whole block with TRANTOR_OC_COMPACTION=<json>, or set it to "off" to ship no
16
+ // config content at all.
17
+ export const OC_COMPACTION_DEFAULT = {
18
+ auto: true,
19
+ prune: true,
20
+ tail_turns: 4,
21
+ preserve_recent_tokens: 24_000,
22
+ reserved: OC_RESERVED_FLOOR,
23
+ };
24
+
25
+ // reservedForContext(window) — the buffer that makes opencode compact near OC_COMPACT_AT, or
26
+ // undefined when the window is unknown/invalid (the caller keeps the floor default).
27
+ export function reservedForContext(window) {
28
+ if (!Number.isFinite(window) || window <= 0) return undefined;
29
+ return Math.max(OC_RESERVED_FLOOR, window - OC_COMPACT_AT);
30
+ }
31
+
32
+ /// A JSON string for OPENCODE_CONFIG_CONTENT, or "" when nothing should be injected: "off" opts
33
+ /// out, invalid JSON opts out loudly at the caller (never arms a half-parsed block), custom JSON
34
+ /// is wrapped as { compaction: <the value> } so the env stays one knob. Given the model's catalog
35
+ /// window the DEFAULT block's reserved is sized; a custom TRANTOR_OC_COMPACTION wins verbatim.
36
+ export function ocCompactionContent(env = process.env, window = undefined) {
37
+ const raw = String(env[OC_COMPACTION_ENV] || "").trim();
38
+ if (raw === "off") return "";
39
+ if (!raw) {
40
+ const reserved = reservedForContext(window) ?? OC_COMPACTION_DEFAULT.reserved;
41
+ return JSON.stringify({ compaction: { ...OC_COMPACTION_DEFAULT, reserved } });
42
+ }
43
+ let parsed;
44
+ try { parsed = JSON.parse(raw); } catch { return ""; }
45
+ // SAFETY: the operator's raw JSON arrives here untyped — a bare number, string or null must
46
+ // opt OUT (no config), never arm a half-shaped compaction block at every opencode seat.
47
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
49
+ return JSON.stringify({ compaction: parsed });
50
+ }
51
+
52
+ // The one per-turn runner line: what the injected block will actually do for this seat.
53
+ export function ocCompactionLogLine(window = undefined) {
54
+ const reserved = reservedForContext(window);
55
+ if (reserved === undefined) return `compaction: reserved=${OC_RESERVED_FLOOR} (floor — model window unknown)`;
56
+ return `compaction: reserved=${reserved} — auto-compact fires near ${OC_COMPACT_AT} tokens (window ${window} − reserved; opencode has no absolute-threshold knob)`;
57
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.93",
3
+ "version": "0.18.95",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"