trantor 0.18.92 → 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/README.md +6 -1
- package/bin/cli.mjs +3 -0
- package/bin/crew-runner.mjs +78 -20
- package/bin/memory.mjs +34 -0
- package/bin/seat-tokens.mjs +34 -0
- package/hooks/hooks.json +11 -0
- package/hooks/lib/memory-start.mjs +63 -0
- package/hooks/memory-pin.mjs +30 -0
- package/hooks/sessionstart.mjs +3 -0
- package/hub/routes/cards.mjs +17 -1
- package/lib/dep.mjs +37 -0
- package/lib/memory-pin-write.mjs +165 -0
- package/lib/memory-pins.mjs +153 -0
- package/lib/seat-compaction.mjs +34 -0
- package/lib/seat-tokens.mjs +72 -0
- package/package.json +2 -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/README.md
CHANGED
|
@@ -436,7 +436,12 @@ trantor setup | doctor | connect | profile | provider | models
|
|
|
436
436
|
`add <name> --key … [--plan api] [--base-url <url> --models a,b]` to bring any provider (custom
|
|
437
437
|
endpoints are wired into OpenCode for you) · `remove <name>`.
|
|
438
438
|
- **`trantor models [<provider>]`** — browse the live models behind each seat + the router's pick
|
|
439
|
-
per difficulty.
|
|
439
|
+
per difficulty. A model with no known price is listed as unpriced and never picked.
|
|
440
|
+
- **`trantor memory`** — pins a project's Claude memory notes to the code they describe
|
|
441
|
+
(graft symbols and files) and flags a note **stale** when a pinned symbol's body changes, or
|
|
442
|
+
**broken** when it disappears. `check [--strict]` reports; `pin <note> | --all [--dry-run]`
|
|
443
|
+
pins or re-pins. Notes are pinned automatically when saved, and a session's first context line
|
|
444
|
+
names any stale or broken note. Terms: [GLOSSARY.md](GLOSSARY.md).
|
|
440
445
|
- **`trantor up`** — `agent:model` pins a model (`deepseek:deepseek-v4-pro`); `agent:provider
|
|
441
446
|
--task <k> --difficulty <d>` picks the **best live model** for the work at spawn
|
|
442
447
|
(`glm:zai-coding-plan --difficulty hard`); spawns are verified on the bus with one retry;
|
package/bin/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ switch (cmd) {
|
|
|
26
26
|
case "secrets": run("bin/secrets.mjs"); break;
|
|
27
27
|
case "keys": run("bin/keys.mjs"); break;
|
|
28
28
|
case "models": run("bin/models.mjs"); break;
|
|
29
|
+
case "memory": run("bin/memory.mjs"); break;
|
|
29
30
|
case "advise": run("bin/advise.mjs"); break;
|
|
30
31
|
case "verify": run("bin/crew-verify.mjs"); break;
|
|
31
32
|
case "up": runCrew(); break;
|
|
@@ -271,6 +272,8 @@ switch (cmd) {
|
|
|
271
272
|
trantor hub run the hub in the foreground (setup installs it as a service instead)
|
|
272
273
|
…or manage per-project hub pins: hub list · hub set <project> <url> · hub unset <project>
|
|
273
274
|
seats: which project lives in which directory — seats · seats add · seats up · seats login install
|
|
275
|
+
trantor memory check [--project <dir>] [--json] check note pins for stale or broken code
|
|
276
|
+
trantor memory pin <note> | --all [--dry-run] pin or re-pin notes from code mentions
|
|
274
277
|
trantor state a seat's working memory: show <seat> <card> [--json] · validate · reset --force · gc [--apply]
|
|
275
278
|
trantor seat-why WHY a seat is down (err file, logs, pids): seat-why <agent> [--json] — quota, auth, crash, or just no pane
|
|
276
279
|
trantor seat-record the per-project seat record the advisor benches from (✓/∅/↩ per difficulty) — [--project p] [--reset <seat>] [--json]
|
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);
|
|
@@ -1111,7 +1134,13 @@ exit $turn_exit`;
|
|
|
1111
1134
|
if (endStatus !== null) {
|
|
1112
1135
|
cardStatusAtEnd = endStatus;
|
|
1113
1136
|
const moved = ["testing", "done"].includes(endStatus) && !["testing", "done"].includes(cardStatusBefore);
|
|
1114
|
-
|
|
1137
|
+
// #11551: a card ALREADY at testing/done when the turn began is delivered work — the
|
|
1138
|
+
// codex-2 specimen's redelivered contract could neither commit anew nor move the card, and
|
|
1139
|
+
// the check parked the seat for work it had already shipped. It only stands while the card
|
|
1140
|
+
// still IS testing/done: a mid-turn bounce to doing (endStatus doing) reopens the contract.
|
|
1141
|
+
const alreadyDelivered = ["testing", "done"].includes(cardStatusBefore)
|
|
1142
|
+
&& ["testing", "done"].includes(endStatus);
|
|
1143
|
+
noDelivery = !newCommit && !moved && !alreadyDelivered && !askVerdict && !blockedVerdict;
|
|
1115
1144
|
}
|
|
1116
1145
|
}
|
|
1117
1146
|
lastNoDelivery = noDelivery;
|
|
@@ -1457,7 +1486,8 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1457
1486
|
writeFileSync(PENDF, JSON.stringify({ persona: PERSONA, agent: AGENT, project: PROJ, ts: Date.now(),
|
|
1458
1487
|
wake: pendingWake, bcast: pendingBcast, seen: seenLedger, ask: awaitingAsk?.id || 0, askTo: awaitingAsk?.to || "" }));
|
|
1459
1488
|
const state = { cursor, turn: TURN, sid, sessionCard, retryAt, deliveryFails,
|
|
1460
|
-
noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions]
|
|
1489
|
+
noDeliveryRetry, askReleased, lastTurnAt, attempted: [...attemptedVersions],
|
|
1490
|
+
cardTokens, cardStartedAt, budgetStop, budgetRetryAt };
|
|
1461
1491
|
process.execve(process.execPath, [process.execPath, ...process.execArgv, ...process.argv.slice(1)],
|
|
1462
1492
|
{ ...process.env, CREW_RELEASE_STATE: JSON.stringify(state) });
|
|
1463
1493
|
} catch (error) { log(`runner restart failed: ${error.message}`); }
|
|
@@ -1479,14 +1509,15 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1479
1509
|
log("parked — waiting for the next message or pulse");
|
|
1480
1510
|
continue;
|
|
1481
1511
|
}
|
|
1482
|
-
// A due REDELIVERY runs before we go back to waiting
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
|
|
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; }
|
|
1486
1517
|
// cap the long-poll hold so neither a due pulse nor a due redelivery waits out a silent 280s window
|
|
1487
1518
|
const due = [];
|
|
1488
1519
|
if (PULSE_MS) due.push(PULSE_MS - (Date.now() - lastTurnAt));
|
|
1489
|
-
if (pendingWake.length && !awaitingAsk) due.push(retryAt - Date.now());
|
|
1520
|
+
if (pendingWake.length && !awaitingAsk) due.push(Math.min(retryAt, budgetRetryAt || retryAt) - Date.now());
|
|
1490
1521
|
const holdS = due.length
|
|
1491
1522
|
? Math.max(5, Math.min(280, Math.ceil(Math.min(...due) / 1000)))
|
|
1492
1523
|
: 280;
|
|
@@ -1749,6 +1780,20 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1749
1780
|
// and never inherits the previous contract's card (#10197's judge then reads the right card).
|
|
1750
1781
|
const sessionCardForTurn = card || baseCard;
|
|
1751
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(); }
|
|
1752
1797
|
sessionCard = card;
|
|
1753
1798
|
const cited = [...new Set(wakeForTurn.flatMap(m => cardRefs(m.text)))];
|
|
1754
1799
|
const freshText = fresh
|
|
@@ -1951,6 +1996,19 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1951
1996
|
await notifyAssigners(assigners,
|
|
1952
1997
|
`✅ done on ${SESSION} (exit 0, ${secs}s) · asked: "${asked}" · check the board card and the files for what changed`);
|
|
1953
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
|
+
}
|
|
1954
2012
|
lastTurnAt = Date.now();
|
|
1955
2013
|
}
|
|
1956
2014
|
})();
|
package/bin/memory.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { checkMemory, formatMemoryCheck } from "../lib/memory-pins.mjs";
|
|
4
|
+
import { pinMemory, formatMemoryPin } from "../lib/memory-pin-write.mjs";
|
|
5
|
+
|
|
6
|
+
const usage = "Usage: trantor memory check [--project <dir>] [--json] [--strict]\n trantor memory pin <note> | --all [--dry-run] [--project <dir>] [--json]";
|
|
7
|
+
try {
|
|
8
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
9
|
+
project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean" },
|
|
10
|
+
strict: { type: "boolean" }, all: { type: "boolean" }, "dry-run": { type: "boolean" },
|
|
11
|
+
} });
|
|
12
|
+
if (values.help) {
|
|
13
|
+
console.log(usage);
|
|
14
|
+
} else {
|
|
15
|
+
const [command, note] = positionals;
|
|
16
|
+
let result;
|
|
17
|
+
let format;
|
|
18
|
+
if (command === "check" && positionals.length === 1 && !values.all && !values["dry-run"]) {
|
|
19
|
+
result = checkMemory({ project: values.project });
|
|
20
|
+
format = formatMemoryCheck;
|
|
21
|
+
if (values.strict && result.notes.some(note => note.pins.some(pin => ["stale", "broken"].includes(pin.status)))) process.exitCode = 1;
|
|
22
|
+
} else if (command === "pin" && !values.strict && positionals.length <= 2 && Boolean(note) !== Boolean(values.all)) {
|
|
23
|
+
result = pinMemory({ project: values.project, note, all: values.all, dryRun: values["dry-run"] });
|
|
24
|
+
format = formatMemoryPin;
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error(usage);
|
|
27
|
+
}
|
|
28
|
+
const output = values.json ? JSON.stringify(result, null, 2) : format(result);
|
|
29
|
+
if (output) console.log(output);
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`memory: ${error.message}`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
@@ -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/hooks/hooks.json
CHANGED
|
@@ -150,6 +150,17 @@
|
|
|
150
150
|
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ask-sidecar.mjs"
|
|
151
151
|
}
|
|
152
152
|
]
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"matcher": "Write|Edit",
|
|
156
|
+
"hooks": [
|
|
157
|
+
{
|
|
158
|
+
"type": "command",
|
|
159
|
+
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/memory-pin.mjs",
|
|
160
|
+
"async": true,
|
|
161
|
+
"timeout": 130
|
|
162
|
+
}
|
|
163
|
+
]
|
|
153
164
|
}
|
|
154
165
|
],
|
|
155
166
|
"PostToolUseFailure": [
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads";
|
|
4
|
+
import { gitCheckoutRoot } from "./git-checkout.mjs";
|
|
5
|
+
|
|
6
|
+
const SLOW = "pin check skipped (slow), run trantor memory check";
|
|
7
|
+
|
|
8
|
+
export function memoryStartLine(project) {
|
|
9
|
+
return new Promise(resolveLine => {
|
|
10
|
+
let worker;
|
|
11
|
+
let settled = false;
|
|
12
|
+
const finish = line => {
|
|
13
|
+
if (settled) return;
|
|
14
|
+
settled = true;
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
resolveLine(line);
|
|
17
|
+
if (worker) void worker.terminate().catch(() => {});
|
|
18
|
+
};
|
|
19
|
+
// Keep synchronous graph parsing and file reads off the hook's deadline timer.
|
|
20
|
+
const timer = setTimeout(() => finish(SLOW), 500);
|
|
21
|
+
try {
|
|
22
|
+
worker = new Worker(new URL(import.meta.url), { workerData: { project } });
|
|
23
|
+
worker.once("message", finish);
|
|
24
|
+
worker.once("error", () => finish(""));
|
|
25
|
+
worker.once("exit", () => finish(""));
|
|
26
|
+
} catch { finish(""); }
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function cachedMemoryLine(directory) {
|
|
31
|
+
const project = gitCheckoutRoot(directory) || resolve(directory);
|
|
32
|
+
const graphPath = join(project, "graft/.graph/wiring.json");
|
|
33
|
+
if (!existsSync(graphPath)) return "";
|
|
34
|
+
const { checkPin, memoryDirectory, notePins, pinFileNewer } = await import("../../lib/memory-pins.mjs");
|
|
35
|
+
const memoryDir = memoryDirectory(project);
|
|
36
|
+
if (!existsSync(memoryDir)) return "";
|
|
37
|
+
const graph = JSON.parse(readFileSync(graphPath, "utf8"));
|
|
38
|
+
const nodes = new Map(graph.nodes.map(node => [node.id, node]));
|
|
39
|
+
const graphTime = statSync(graphPath).mtimeMs;
|
|
40
|
+
let unchecked = 0;
|
|
41
|
+
const stale = new Set(), broken = new Set();
|
|
42
|
+
const notes = readdirSync(memoryDir, { withFileTypes: true })
|
|
43
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md");
|
|
44
|
+
for (const note of notes) {
|
|
45
|
+
try {
|
|
46
|
+
const pins = notePins(readFileSync(join(memoryDir, note.name), "utf8"));
|
|
47
|
+
for (const pin of pins) {
|
|
48
|
+
if (pinFileNewer(project, pin, graphTime)) { unchecked++; continue; }
|
|
49
|
+
const { status } = checkPin(pin, project, nodes);
|
|
50
|
+
if (status === "stale") stale.add(note.name);
|
|
51
|
+
if (status === "broken") broken.add(note.name);
|
|
52
|
+
}
|
|
53
|
+
} catch { /* A concurrent edit or invalid span must not suppress other notes. */ }
|
|
54
|
+
}
|
|
55
|
+
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
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!isMainThread) {
|
|
61
|
+
try { parentPort.postMessage(await cachedMemoryLine(workerData.project)); }
|
|
62
|
+
catch { parentPort.postMessage(""); }
|
|
63
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { pinMemory } from "../lib/memory-pin-write.mjs";
|
|
5
|
+
import { memoryDirectory } from "../lib/memory-pins.mjs";
|
|
6
|
+
import { gitCheckoutRoot } from "./lib/git-checkout.mjs";
|
|
7
|
+
|
|
8
|
+
async function pinSavedNote() {
|
|
9
|
+
let raw = "";
|
|
10
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
11
|
+
const input = JSON.parse(raw);
|
|
12
|
+
if (!["Write", "Edit"].includes(input.tool_name) || !input.cwd || !input.tool_input?.file_path) return;
|
|
13
|
+
const path = resolve(input.cwd, input.tool_input.file_path);
|
|
14
|
+
if (!path.endsWith(".md") || basename(path) === "MEMORY.md") return;
|
|
15
|
+
const memoryRoot = dirname(dirname(memoryDirectory(input.cwd)));
|
|
16
|
+
if (!path.startsWith(`${memoryRoot}/`) || basename(dirname(path)) !== "memory") return;
|
|
17
|
+
const project = gitCheckoutRoot(input.cwd) || resolve(input.cwd);
|
|
18
|
+
const memoryDir = memoryDirectory(project);
|
|
19
|
+
if (dirname(path) !== memoryDir) return;
|
|
20
|
+
// A symlink must not turn a note-save hook into a write outside project memory.
|
|
21
|
+
if (dirname(realpathSync(path)) !== realpathSync(memoryDir)) return;
|
|
22
|
+
const result = pinMemory({ project, memoryDir, note: basename(path) });
|
|
23
|
+
if (result.message) console.error(`memory-pin: ${result.message}`);
|
|
24
|
+
for (const note of result.notes) {
|
|
25
|
+
for (const diagnostic of note.diagnostics) console.error(`memory-pin: ${note.name}: ${diagnostic}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try { await pinSavedNote(); }
|
|
30
|
+
catch (error) { console.error(`memory-pin: ${error.message}`); }
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { maybeCheckBalances } from "./lib/balance-check.mjs";
|
|
|
16
16
|
import { getJSON, signedGet, signedPost, loadIdentity } from "./lib/api.mjs";
|
|
17
17
|
import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
|
|
18
18
|
import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
19
|
+
import { memoryStartLine } from "./lib/memory-start.mjs";
|
|
19
20
|
|
|
20
21
|
// The build doctrine (docs/BUILD-DOCTRINE.md) in short form, one line per rule, injected for every
|
|
21
22
|
// project's ORCHESTRATOR at boot (#6452): orchestrators that never opened the doc missed the ruling.
|
|
@@ -156,6 +157,8 @@ try {
|
|
|
156
157
|
process.stdout.write(emit(ctx, banner, ""));
|
|
157
158
|
process.exit(0);
|
|
158
159
|
}
|
|
160
|
+
const memoryLine = await memoryStartLine(projectDir);
|
|
161
|
+
if (memoryLine) additionalContext += `${memoryLine}\n`;
|
|
159
162
|
const project = resolveProject(projectDir);
|
|
160
163
|
sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
|
|
161
164
|
const session = process.env.RELAY_SESSION
|
package/hub/routes/cards.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
38
38
|
appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
|
|
39
39
|
derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
|
|
40
40
|
prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
|
|
41
|
-
ONLINE_MS, PEER_TTL_MS,
|
|
41
|
+
ONLINE_MS, PEER_TTL_MS, AUTH_MODE,
|
|
42
42
|
} = ctx;
|
|
43
43
|
if (req.method === "POST" && P === "/task") { // create a card
|
|
44
44
|
const b = await body(req);
|
|
@@ -596,6 +596,22 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
|
|
|
596
596
|
appendEvent("lesson", lProj, b.by || "", { text, scope });
|
|
597
597
|
return json(res, 200, { ok: true, count: state.lessons.length });
|
|
598
598
|
}
|
|
599
|
+
// A lesson can outlive the world it was learned in; only a global owner rewrites one, keeping its id and old text.
|
|
600
|
+
if (req.method === "POST" && P === "/lesson/update") {
|
|
601
|
+
if (AUTH_MODE !== "off" && !auth?.identity?.scopes?.some(s => s.project === "*" && s.role === "owner")) {
|
|
602
|
+
return json(res, 403, { error: "global owner permission required" });
|
|
603
|
+
}
|
|
604
|
+
const b = await body(req);
|
|
605
|
+
const lesson = state.lessons.find(l => l.id === Number(b.id));
|
|
606
|
+
if (!lesson) return json(res, 404, { error: "no such lesson" });
|
|
607
|
+
const text = stripNulText(b.text).trim().slice(0, 400);
|
|
608
|
+
if (!text) return json(res, 400, { error: "text required" });
|
|
609
|
+
lesson.revisions = [...(lesson.revisions || []), { text: lesson.text, by: lesson.by, ts: lesson.ts }].slice(-5);
|
|
610
|
+
Object.assign(lesson, { text, by: b.by || "", ts: now() });
|
|
611
|
+
markDirty();
|
|
612
|
+
appendEvent("lesson.revised", "", b.by || "", { id: lesson.id, text, scope: lesson.scope });
|
|
613
|
+
return json(res, 200, { ok: true, lesson });
|
|
614
|
+
}
|
|
599
615
|
// --- verification gates: structured "must verify before shipping" claims that travel with
|
|
600
616
|
// handoffs and surface PROMINENTLY to whoever takes over (so a safety-critical check can't be
|
|
601
617
|
// skimmed past in narrative prose — the "verify Gail coefficients" intent that got lost). ---
|
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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dep } from "./dep.mjs";
|
|
5
|
+
import { checkPin, frontMatter, memoryDirectory, notePins, pinTarget, refreshPinGraph, symbolFingerprint } from "./memory-pins.mjs";
|
|
6
|
+
|
|
7
|
+
const { stringify } = await dep("yaml");
|
|
8
|
+
|
|
9
|
+
export function replaceNotePins(text, pins) {
|
|
10
|
+
const front = frontMatter(text);
|
|
11
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
12
|
+
const value = stringify(pins, { collectionStyle: "flow", lineWidth: 0 }).trimEnd();
|
|
13
|
+
if (!front) return `---${newline}pins: ${value}${newline}---${newline}${text}`;
|
|
14
|
+
const pair = front.document.contents?.items.find(item => item.key?.value === "pins");
|
|
15
|
+
if (!pair) {
|
|
16
|
+
if (front.document.contents?.flow) throw new Error("cannot add pins to flow-style front matter without changing other keys");
|
|
17
|
+
const at = front.start + front.yaml.length;
|
|
18
|
+
return `${text.slice(0, at)}pins: ${value}${newline}${text.slice(at)}`;
|
|
19
|
+
}
|
|
20
|
+
// Value ranges exclude neighboring keys and their comments; never reserialize the whole note.
|
|
21
|
+
const [start, end] = pair.value.range;
|
|
22
|
+
const old = front.yaml.slice(start, end);
|
|
23
|
+
const ending = old.match(/\r?\n$/)?.[0] || "";
|
|
24
|
+
const spacing = start === end ? " " : "";
|
|
25
|
+
return text.slice(0, front.start + start) + spacing + value + ending + text.slice(front.start + end);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function extractMentions(text, project, nodes, siblingNotes = new Set()) {
|
|
29
|
+
const front = frontMatter(text);
|
|
30
|
+
const body = (front ? text.slice(front.end) : text)
|
|
31
|
+
.replace(/\[\[[\s\S]*?\]\]/g, " ").replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
|
|
32
|
+
const paths = new Set([...nodes.values()].map(node => node.path));
|
|
33
|
+
const mentions = [];
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const pattern = /(?<![\w./\\-])((?:\.\/)?(?:[\w@.-]+\/)*[\w@.-]+)(?:#([\w$.-]+)|:(\d+))?/g;
|
|
36
|
+
for (const match of body.matchAll(pattern)) {
|
|
37
|
+
let file = match[1].replace(/^\.\//, "").replace(/[.,]+$/, "");
|
|
38
|
+
if (!pinTarget({ file }) || file === "Node.js") continue;
|
|
39
|
+
if (siblingNotes.has(file)) continue;
|
|
40
|
+
if (!file.includes("/") && /^v?\d+(?:\.[\da-z-]+)+$/i.test(file)
|
|
41
|
+
&& !/\.(?:[cm]?jsx?|tsx?|py|rs|go|swift|c|h|cpp|hpp|java|kt|rb|sh|php|vue|svelte)$/i.test(file)) continue;
|
|
42
|
+
const prefix = `${basename(project)}/`;
|
|
43
|
+
if (!existingFile(project, file) && file.startsWith(prefix) && existingFile(project, file.slice(prefix.length))) {
|
|
44
|
+
file = file.slice(prefix.length);
|
|
45
|
+
}
|
|
46
|
+
if (!paths.has(file) && !existingFile(project, file) && !file.includes("/") && !/\.(?:[cm]?jsx?|tsx?|py|rs|go|swift|c|h|cpp|hpp|java|kt|rb|sh|php|vue|svelte|md|mdx|txt|json|ya?ml|toml|sql|css|html)$/i.test(file)) continue;
|
|
47
|
+
const symbol = match[2]?.replace(/\.+$/, "");
|
|
48
|
+
const mention = symbol ? { file, symbol: `${file}#${symbol}` }
|
|
49
|
+
: match[3] ? { file, line: Number(match[3]) } : { file };
|
|
50
|
+
const key = JSON.stringify(mention);
|
|
51
|
+
if (!seen.has(key)) { seen.add(key); mentions.push(mention); }
|
|
52
|
+
}
|
|
53
|
+
return mentions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function existingFile(project, file) {
|
|
57
|
+
try { return statSync(join(project, file)).isFile(); }
|
|
58
|
+
catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveMention(mention, nodes, project) {
|
|
62
|
+
if (!existingFile(project, mention.file)) return null;
|
|
63
|
+
if (mention.symbol) return nodes.has(mention.symbol) ? { symbol: mention.symbol, fp: symbolFingerprint(project, nodes.get(mention.symbol)) } : null;
|
|
64
|
+
if (mention.line === undefined) return { file: mention.file };
|
|
65
|
+
const lines = readFileSync(join(project, mention.file), "utf8").split(/\r?\n/);
|
|
66
|
+
if (mention.line < 1 || mention.line > lines.length) return null;
|
|
67
|
+
const enclosing = [...nodes.values()].filter(node => node.path === mention.file && node.kind !== "file")
|
|
68
|
+
.map(node => ({ node, span: /^L(\d+)-L(\d+)$/.exec(node.span) }))
|
|
69
|
+
.filter(({ span }) => span && +span[1] <= mention.line && +span[2] >= mention.line)
|
|
70
|
+
.sort((a, b) => (+a.span[2] - +a.span[1]) - (+b.span[2] - +b.span[1]));
|
|
71
|
+
return enclosing.length ? { symbol: enclosing[0].node.id, fp: symbolFingerprint(project, enclosing[0].node) } : { file: mention.file };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function planNote(text, project, nodes, date, siblingNotes) {
|
|
75
|
+
const pins = notePins(text);
|
|
76
|
+
const diagnostics = [];
|
|
77
|
+
const keys = new Set(pins.map(pin => pinTarget(pin)).filter(Boolean).map(pin => pin.symbol || pin.file));
|
|
78
|
+
for (const mention of extractMentions(text, project, nodes, siblingNotes)) {
|
|
79
|
+
if (!existingFile(project, mention.file)) {
|
|
80
|
+
diagnostics.push(`mentions a path that doesn't exist: ${mention.file}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const pin = resolveMention(mention, nodes, project);
|
|
84
|
+
if (!pin) { diagnostics.push(`unresolved mention: ${mention.symbol || `${mention.file}:${mention.line}`}`); continue; }
|
|
85
|
+
const key = pin.symbol || pin.file;
|
|
86
|
+
if (!keys.has(key)) { pins.push({ ...pin, at: date }); keys.add(key); }
|
|
87
|
+
}
|
|
88
|
+
const refreshed = pins.map(pin => {
|
|
89
|
+
const checked = checkPin(pin, project, nodes);
|
|
90
|
+
if (checked.status === "broken" || checked.status === "invalid pin") {
|
|
91
|
+
diagnostics.push(`${checked.status}: ${checked.detail}`);
|
|
92
|
+
return pin;
|
|
93
|
+
}
|
|
94
|
+
const refreshed = { ...pin };
|
|
95
|
+
if (checked.actual) { refreshed.fp = checked.actual; refreshed.at = date; }
|
|
96
|
+
return refreshed;
|
|
97
|
+
});
|
|
98
|
+
return { pins: refreshed, diagnostics, text: refreshed.length ? replaceNotePins(text, refreshed) : text };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function pinMemory({ project = process.cwd(), memoryDir = memoryDirectory(project), note, all = false, dryRun = false } = {}) {
|
|
102
|
+
project = resolve(project);
|
|
103
|
+
if (Boolean(note) === Boolean(all)) throw new Error("specify one note or --all");
|
|
104
|
+
const files = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
|
|
105
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md").map(entry => entry.name).sort() : [];
|
|
106
|
+
let selected = files;
|
|
107
|
+
if (!all) {
|
|
108
|
+
const requested = note.endsWith(".md") ? note : `${note}.md`;
|
|
109
|
+
const name = basename(requested);
|
|
110
|
+
if (name === "MEMORY.md") throw new Error("MEMORY.md is an index and cannot be pinned");
|
|
111
|
+
if (resolve(memoryDir, requested) !== resolve(memoryDir, name) || !files.includes(name)) throw new Error(`note not found in project memory: ${note}`);
|
|
112
|
+
selected = [name];
|
|
113
|
+
}
|
|
114
|
+
const siblingNotes = new Set(files);
|
|
115
|
+
const plans = selected.map(name => {
|
|
116
|
+
const plan = { name, path: join(memoryDir, name), original: undefined, pins: [], diagnostics: [], targets: [] };
|
|
117
|
+
try {
|
|
118
|
+
plan.original = readFileSync(plan.path, "utf8");
|
|
119
|
+
plan.text = plan.original;
|
|
120
|
+
plan.pins = notePins(plan.original);
|
|
121
|
+
plan.targets = [...plan.pins, ...extractMentions(plan.original, project, new Map(), siblingNotes)
|
|
122
|
+
.map(mention => ({ file: mention.file }))];
|
|
123
|
+
} catch (error) { failNote(plan, error); }
|
|
124
|
+
return plan;
|
|
125
|
+
});
|
|
126
|
+
const nodes = refreshPinGraph(project, plans.flatMap(plan => plan.targets));
|
|
127
|
+
if (!nodes) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
|
|
128
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
129
|
+
for (const plan of plans) {
|
|
130
|
+
if (plan.failed) continue;
|
|
131
|
+
try {
|
|
132
|
+
Object.assign(plan, planNote(plan.original, project, nodes, date, siblingNotes));
|
|
133
|
+
if (!dryRun && plan.text !== plan.original) atomicNoteWrite(plan);
|
|
134
|
+
} catch (error) { failNote(plan, error); }
|
|
135
|
+
}
|
|
136
|
+
return { project, status: dryRun ? "dry run" : "pinned", notes: plans.map(plan => {
|
|
137
|
+
const note = { name: plan.name, pins: plan.pins, diagnostics: plan.diagnostics, changed: plan.text !== plan.original };
|
|
138
|
+
if (plan.failed) note.failed = true;
|
|
139
|
+
return note;
|
|
140
|
+
}) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function failNote(plan, error) {
|
|
144
|
+
plan.failed = true;
|
|
145
|
+
plan.text = plan.original;
|
|
146
|
+
plan.pins = plan.original === undefined ? [] : notePins(plan.original);
|
|
147
|
+
plan.diagnostics.push(error.message);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function atomicNoteWrite(plan) {
|
|
151
|
+
const temp = join(dirname(plan.path), `.${basename(plan.path)}.${randomUUID()}.tmp`);
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(temp, plan.text, { flag: "wx", mode: statSync(plan.path).mode });
|
|
154
|
+
if (readFileSync(plan.path, "utf8") !== plan.original) throw new Error(`note changed during pinning: ${plan.name}; retry`);
|
|
155
|
+
renameSync(temp, plan.path);
|
|
156
|
+
} finally { if (existsSync(temp)) unlinkSync(temp); }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatMemoryPin(result) {
|
|
160
|
+
if (result.message) return result.message;
|
|
161
|
+
return result.notes.map(note => {
|
|
162
|
+
const pins = note.pins.map(pin => pinTarget(pin)).map(pin => pin?.symbol || pin?.file || "invalid pin");
|
|
163
|
+
return `${note.name}: ${note.failed ? "failed" : result.status} — ${pins.join(", ") || "no pins"}${note.diagnostics.map(message => `\n ${message}`).join("")}`;
|
|
164
|
+
}).join("\n");
|
|
165
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { dep } from "./dep.mjs";
|
|
7
|
+
|
|
8
|
+
const { isMap, parseDocument } = await dep("yaml");
|
|
9
|
+
const { z } = await dep("zod");
|
|
10
|
+
|
|
11
|
+
export function fingerprint(text) {
|
|
12
|
+
const normalized = text.replace(/\r\n/g, "\n").split("\n")
|
|
13
|
+
.map(line => line.trimEnd()).join("\n").replace(/^\n+|\n+$/g, "");
|
|
14
|
+
return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function memoryDirectory(project, configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")) {
|
|
18
|
+
return join(configDir, "projects", resolve(project).replace(/[^a-zA-Z0-9]/g, "-"), "memory");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function frontMatter(text) {
|
|
22
|
+
const match = /^(---\r?\n)([\s\S]*?)(^---(?:\r?\n|$))/m.exec(text);
|
|
23
|
+
if (!match || match.index !== 0) {
|
|
24
|
+
if (/^---\r?\n/.test(text)) throw new Error("unterminated front matter");
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const document = parseDocument(match[2]);
|
|
28
|
+
if (document.errors.length) throw new Error(`invalid front matter: ${document.errors[0].message.split("\n")[0]}`);
|
|
29
|
+
if (document.contents && !isMap(document.contents)) throw new Error("front matter must be a mapping");
|
|
30
|
+
return { document, yaml: match[2], start: match[1].length, end: match[0].length };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function notePins(text) {
|
|
34
|
+
try {
|
|
35
|
+
const front = frontMatter(text);
|
|
36
|
+
const data = front?.document.toJS();
|
|
37
|
+
if (!data || data.pins == null) return [];
|
|
38
|
+
return Array.isArray(data.pins) ? data.pins : [data.pins];
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return [{ invalid: error.message.split("\n")[0] }];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fileSchema = z.string().min(1).refine(file => !isAbsolute(file)
|
|
45
|
+
&& !file.split(/[\\/]/).some(part => part === ".." || part === "" || part === "."));
|
|
46
|
+
const symbolSchema = z.object({
|
|
47
|
+
symbol: z.string().refine(symbol => {
|
|
48
|
+
const split = symbol.lastIndexOf("#");
|
|
49
|
+
return split > 0 && split < symbol.length - 1 && fileSchema.safeParse(symbol.slice(0, split)).success;
|
|
50
|
+
}),
|
|
51
|
+
fp: z.string().regex(/^sha256:[0-9a-f]{16}$/),
|
|
52
|
+
}).passthrough().refine(pin => !Object.hasOwn(pin, "file"));
|
|
53
|
+
const pinSchema = z.union([
|
|
54
|
+
symbolSchema,
|
|
55
|
+
z.object({ file: fileSchema }).passthrough()
|
|
56
|
+
.refine(pin => !Object.hasOwn(pin, "symbol") && !Object.hasOwn(pin, "fp")),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export function pinTarget(pin) {
|
|
60
|
+
const parsed = pinSchema.safeParse(pin);
|
|
61
|
+
if (!parsed.success) return null;
|
|
62
|
+
const value = parsed.data;
|
|
63
|
+
return value.symbol ? { file: value.symbol.slice(0, value.symbol.lastIndexOf("#")), symbol: value.symbol }
|
|
64
|
+
: { file: value.file };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function checkPin(pin, project, nodes) {
|
|
68
|
+
try { return checkTarget(pin, project, nodes); }
|
|
69
|
+
catch (error) { return { pin, status: "invalid pin", detail: error.message }; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkTarget(pin, project, nodes) {
|
|
73
|
+
const target = pinTarget(pin);
|
|
74
|
+
if (!target) return { pin, status: "invalid pin", detail: pin?.invalid || "expected symbol + fp, or file without fp" };
|
|
75
|
+
const path = join(project, target.file);
|
|
76
|
+
if (!existsSync(path) || !statSync(path).isFile()) return { pin, status: "broken", detail: `file gone: ${target.file}` };
|
|
77
|
+
if (!target.symbol) return { pin, status: "ok" };
|
|
78
|
+
const node = nodes.get(target.symbol);
|
|
79
|
+
if (!node) return { pin, status: "broken", detail: `symbol gone: ${target.symbol}` };
|
|
80
|
+
const actual = symbolFingerprint(project, node);
|
|
81
|
+
return actual === pin.fp ? { pin, status: "ok" }
|
|
82
|
+
: { pin, status: "stale", actual, detail: `body changed: ${target.symbol} (${pin.fp} -> ${actual})` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function symbolFingerprint(project, node) {
|
|
86
|
+
const span = /^L(\d+)-L(\d+)$/.exec(node.span);
|
|
87
|
+
const lines = readFileSync(join(project, node.path), "utf8").replace(/\r\n/g, "\n").split("\n");
|
|
88
|
+
if (!span || +span[1] < 1 || +span[2] < +span[1] || +span[2] > lines.length) {
|
|
89
|
+
throw new Error(`invalid graft span for ${node.id}; run graft build`);
|
|
90
|
+
}
|
|
91
|
+
return fingerprint(lines.slice(+span[1] - 1, +span[2]).join("\n"));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function pinFileNewer(project, pin, graphTime) {
|
|
95
|
+
const target = pinTarget(pin);
|
|
96
|
+
if (!target) return false;
|
|
97
|
+
try { return statSync(join(project, target.file)).mtimeMs > graphTime; }
|
|
98
|
+
catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function refreshPinGraph(project, pins = []) {
|
|
102
|
+
const graphPath = join(project, "graft", ".graph", "wiring.json");
|
|
103
|
+
if (!existsSync(graphPath)) return null;
|
|
104
|
+
const needsBuild = () => pins.some(pin => pinFileNewer(project, pin, statSync(graphPath).mtimeMs));
|
|
105
|
+
if (needsBuild()) {
|
|
106
|
+
const lockPath = `${graphPath}.lock`;
|
|
107
|
+
const deadline = Date.now() + 125000;
|
|
108
|
+
let lock;
|
|
109
|
+
while (lock === undefined) {
|
|
110
|
+
try { lock = openSync(lockPath, "wx"); }
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error.code !== "EEXIST") throw error;
|
|
113
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for graft build lock");
|
|
114
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
// Another note writer may have refreshed the graph while we waited.
|
|
119
|
+
if (needsBuild()) {
|
|
120
|
+
const build = spawnSync("graft", ["build", project, "--no-gitignore", "--no-ignore"], {
|
|
121
|
+
encoding: "utf8", timeout: 120000, maxBuffer: 16 * 1024 * 1024,
|
|
122
|
+
});
|
|
123
|
+
if (build.error || build.status !== 0) throw new Error(`graft build failed: ${build.error?.message || build.stderr || build.stdout}`);
|
|
124
|
+
}
|
|
125
|
+
} finally { closeSync(lock); unlinkSync(lockPath); }
|
|
126
|
+
}
|
|
127
|
+
const graph = JSON.parse(readFileSync(graphPath, "utf8"));
|
|
128
|
+
if (!Array.isArray(graph.nodes)) throw new Error("invalid graft wiring graph: missing nodes");
|
|
129
|
+
return new Map(graph.nodes.map(node => [node.id, node]));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function checkMemory({ project = process.cwd(), memoryDir = memoryDirectory(project) } = {}) {
|
|
133
|
+
project = resolve(project);
|
|
134
|
+
const graphPath = join(project, "graft", ".graph", "wiring.json");
|
|
135
|
+
if (!existsSync(graphPath)) return { project, status: "pins off", message: "no graft graph, pins off", notes: [] };
|
|
136
|
+
const notes = existsSync(memoryDir) ? readdirSync(memoryDir, { withFileTypes: true })
|
|
137
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "MEMORY.md")
|
|
138
|
+
.map(entry => ({ name: entry.name, pins: notePins(readFileSync(join(memoryDir, entry.name), "utf8")) })) : [];
|
|
139
|
+
let nodes = new Map();
|
|
140
|
+
if (notes.some(note => note.pins.some(pin => pinTarget(pin)?.symbol))) {
|
|
141
|
+
try { nodes = refreshPinGraph(project, notes.flatMap(note => note.pins)); }
|
|
142
|
+
catch (error) { return { project, status: "pins off", message: `pins off: ${error.message}`, notes: [] }; }
|
|
143
|
+
}
|
|
144
|
+
return { project, status: "checked", notes: notes.map(note => ({
|
|
145
|
+
name: note.name, pins: note.pins.map(pin => checkPin(pin, project, nodes)),
|
|
146
|
+
})) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatMemoryCheck(result) {
|
|
150
|
+
if (result.message) return result.message;
|
|
151
|
+
return result.notes.flatMap(note => note.pins.filter(pin => pin.status !== "ok")
|
|
152
|
+
.map(pin => `${note.name}: ${pin.status} — ${pin.detail}`)).join("\n") || `ok: ${result.notes.length} notes, ${result.notes.reduce((sum, note) => sum + note.pins.length, 0)} pins`;
|
|
153
|
+
}
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.94",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trantor": "bin/cli.mjs"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
10
10
|
"pg": "^8.22.0",
|
|
11
|
+
"yaml": "^2.9.1",
|
|
11
12
|
"zod": "^4.4.3"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|