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.
- package/.claude-plugin/plugin.json +1 -1
- package/bin/crew-runner.mjs +76 -20
- package/bin/seat-tokens.mjs +34 -0
- package/hooks/lib/memory-start.mjs +10 -5
- package/lib/dep.mjs +37 -0
- package/lib/memory-pin-write.mjs +3 -1
- package/lib/memory-pins.mjs +4 -2
- package/lib/model-catalog.mjs +9 -0
- package/lib/seat-compaction.mjs +57 -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.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": {
|
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, 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
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
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
|
+
}
|
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")
|
package/lib/model-catalog.mjs
CHANGED
|
@@ -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
|
+
}
|