codemax-cli 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/NOTICE +27 -0
- package/README.md +150 -2
- package/TRADEMARKS.md +27 -0
- package/bin/codemax +2 -0
- package/bin/codemax.cjs +77 -0
- package/package.json +63 -4
- package/src/adapters/claude.ts +43 -0
- package/src/adapters/codex.ts +101 -0
- package/src/adapters/detect.ts +16 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/simple.ts +89 -0
- package/src/adapters/types.ts +87 -0
- package/src/chat/app.ts +1422 -0
- package/src/chat/markdown.ts +77 -0
- package/src/chat/palette.ts +179 -0
- package/src/chat/permission.ts +111 -0
- package/src/chat/theme.ts +158 -0
- package/src/chat/transcript.ts +324 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +96 -0
- package/src/core/brief.ts +314 -0
- package/src/core/budget.ts +42 -0
- package/src/core/checkpoint.ts +139 -0
- package/src/core/doctor.ts +67 -0
- package/src/core/files.ts +62 -0
- package/src/core/git.ts +36 -0
- package/src/core/hotkey.ts +75 -0
- package/src/core/journal.ts +213 -0
- package/src/core/ledger.ts +153 -0
- package/src/core/menu.ts +134 -0
- package/src/core/orchestrator.ts +352 -0
- package/src/core/paths.ts +30 -0
- package/src/core/prices-live.ts +123 -0
- package/src/core/pricing.ts +162 -0
- package/src/core/pty.ts +99 -0
- package/src/core/quota.ts +55 -0
- package/src/core/spawn.ts +20 -0
- package/src/core/terminal.ts +51 -0
- package/src/core/usage.ts +116 -0
- package/src/engines/acp.ts +419 -0
- package/src/engines/codex.ts +330 -0
- package/src/engines/env.ts +17 -0
- package/src/engines/errors.ts +47 -0
- package/src/engines/index.ts +154 -0
- package/src/engines/types.ts +131 -0
- package/src/transcripts/claude-usage.ts +63 -0
- package/src/transcripts/claude.ts +69 -0
- package/src/transcripts/codex.ts +74 -0
- package/src/tui/app.ts +377 -0
- package/src/tui/menu.ts +126 -0
- package/src/tui/pane.ts +79 -0
package/src/core/pty.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { termSize } from "./terminal.ts";
|
|
2
|
+
import { IS_WINDOWS } from "./spawn.ts";
|
|
3
|
+
|
|
4
|
+
const NESTED_SESSION_ENV = /^(CLAUDECODE|CLAUDE_CODE_|CLAUDE_PID|CLAUDE_EFFORT|CODEX_SANDBOX|CODEX_THREAD_ID|CURSOR_AGENT_SESSION)/;
|
|
5
|
+
|
|
6
|
+
export interface AgentProcessOptions {
|
|
7
|
+
cmd: string[];
|
|
8
|
+
cwd: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
/** Env vars removed from the child's environment. */
|
|
11
|
+
dropEnv?: string[];
|
|
12
|
+
/** Initial PTY size. Defaults to the host terminal size. */
|
|
13
|
+
cols?: number;
|
|
14
|
+
rows?: number;
|
|
15
|
+
onData: (chunk: Uint8Array) => void;
|
|
16
|
+
onExit: (code: number | null) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One agent TUI running inside a Bun PTY. */
|
|
20
|
+
export class AgentProcess {
|
|
21
|
+
private term: Bun.Terminal;
|
|
22
|
+
private proc: Bun.Subprocess;
|
|
23
|
+
private closed = false;
|
|
24
|
+
readonly startedAt = Date.now();
|
|
25
|
+
|
|
26
|
+
constructor(private opts: AgentProcessOptions) {
|
|
27
|
+
if (IS_WINDOWS) throw new Error("The --tui and --raw modes need a Unix terminal. On Windows, run codemax without them.");
|
|
28
|
+
const { cols, rows } = opts.cols && opts.rows ? { cols: opts.cols, rows: opts.rows } : termSize();
|
|
29
|
+
this.term = new Bun.Terminal({
|
|
30
|
+
cols,
|
|
31
|
+
rows,
|
|
32
|
+
data: (_t, chunk) => {
|
|
33
|
+
if (!this.closed) opts.onData(chunk);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const env: Record<string, string | undefined> = { ...process.env, TERM: process.env.TERM ?? "xterm-256color", CODEMAX: "1", ...(opts.env ?? {}) };
|
|
37
|
+
for (const k of opts.dropEnv ?? []) delete env[k];
|
|
38
|
+
// Never let an agent think it is a child of another agent session
|
|
39
|
+
// (e.g. codemax started from inside Claude Code).
|
|
40
|
+
for (const k of Object.keys(env)) if (NESTED_SESSION_ENV.test(k)) delete env[k];
|
|
41
|
+
this.proc = Bun.spawn(opts.cmd, { cwd: opts.cwd, terminal: this.term, env });
|
|
42
|
+
this.proc.exited.then((code) => {
|
|
43
|
+
this.closed = true;
|
|
44
|
+
try {
|
|
45
|
+
this.term.close();
|
|
46
|
+
} catch {
|
|
47
|
+
/* already closed */
|
|
48
|
+
}
|
|
49
|
+
opts.onExit(code);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get pid() {
|
|
54
|
+
return this.proc.pid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get exited() {
|
|
58
|
+
return this.closed;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
write(data: string | Uint8Array) {
|
|
62
|
+
if (this.closed) return;
|
|
63
|
+
this.term.write(data);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
resize(cols: number, rows: number) {
|
|
67
|
+
if (this.closed) return;
|
|
68
|
+
try {
|
|
69
|
+
this.term.resize(cols, rows);
|
|
70
|
+
} catch {
|
|
71
|
+
/* ignore */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Force a full repaint: most TUIs redraw on SIGWINCH. */
|
|
76
|
+
jiggle() {
|
|
77
|
+
const { cols, rows } = termSize();
|
|
78
|
+
this.resize(Math.max(20, cols - 1), rows);
|
|
79
|
+
setTimeout(() => this.resize(cols, rows), 30);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async kill(graceMs = 1500): Promise<void> {
|
|
83
|
+
if (this.closed) return;
|
|
84
|
+
try {
|
|
85
|
+
this.proc.kill("SIGTERM");
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
try {
|
|
91
|
+
if (!this.closed) this.proc.kill("SIGKILL");
|
|
92
|
+
} catch {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
}, graceMs);
|
|
96
|
+
await this.proc.exited;
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AgentId, QuotaStatus } from "../adapters/types.ts";
|
|
2
|
+
import { stripAnsi } from "./terminal.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vendor-neutral phrases. Deliberately narrow: "rate limit" or "429" alone
|
|
6
|
+
* show up constantly in normal coding work and must not trip the detector.
|
|
7
|
+
*/
|
|
8
|
+
export const GENERIC_QUOTA_PATTERNS = [/usage limit (reached|exceeded)/i, /you['’]ve (hit|reached) your (usage |weekly |session |daily )?limit/i, /insufficient[_ ]quota/i];
|
|
9
|
+
|
|
10
|
+
/** How long an output-text detection counts before it is forgotten. */
|
|
11
|
+
export const OUTPUT_DETECTION_TTL_MS = 30 * 60_000;
|
|
12
|
+
|
|
13
|
+
export function isExhausted(q?: QuotaStatus, now = Date.now()): boolean {
|
|
14
|
+
if (!q?.exhausted) return false;
|
|
15
|
+
return !q.expiresAt || q.expiresAt > now;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Scans the ANSI-stripped PTY output of one agent for quota exhaustion text.
|
|
20
|
+
* Keeps a small rolling buffer so matches spanning chunks are still found.
|
|
21
|
+
*/
|
|
22
|
+
export class OutputScanner {
|
|
23
|
+
private buf = "";
|
|
24
|
+
private lastHit = 0;
|
|
25
|
+
constructor(
|
|
26
|
+
private agent: AgentId,
|
|
27
|
+
private patterns: RegExp[],
|
|
28
|
+
private onHit: (q: QuotaStatus) => void,
|
|
29
|
+
private debounceMs = 60_000,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
feed(chunk: string) {
|
|
33
|
+
this.buf = (this.buf + stripAnsi(chunk)).slice(-4096);
|
|
34
|
+
if (Date.now() - this.lastHit < this.debounceMs) return;
|
|
35
|
+
for (const re of [...this.patterns, ...GENERIC_QUOTA_PATTERNS]) {
|
|
36
|
+
const m = this.buf.match(re);
|
|
37
|
+
if (m) {
|
|
38
|
+
this.lastHit = Date.now();
|
|
39
|
+
this.onHit({ agent: this.agent, exhausted: true, source: "output", detail: `text match: "${m[0].slice(0, 80)}"`, at: Date.now(), expiresAt: Date.now() + OUTPUT_DETECTION_TTL_MS });
|
|
40
|
+
this.buf = "";
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function quotaLabel(q?: QuotaStatus): string {
|
|
48
|
+
if (!q) return "";
|
|
49
|
+
if (isExhausted(q)) {
|
|
50
|
+
const reset = q.resetsAt ? ` until ${new Date(q.resetsAt).toLocaleTimeString()}` : "";
|
|
51
|
+
return q.source === "output" ? `exhausted? (text match)${reset}` : `EXHAUSTED${reset}`;
|
|
52
|
+
}
|
|
53
|
+
if (typeof q.usedPercent === "number") return `${Math.round(q.usedPercent)}% used`;
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Cross-platform process arguments. On macOS and Linux these return argv
|
|
3
|
+
* unchanged. On Windows, CLIs installed through npm are `.cmd` shims that
|
|
4
|
+
* cannot be started directly, so they are resolved through PATH/PATHEXT and
|
|
5
|
+
* run via cmd.exe; shell commands go to cmd.exe instead of sh.
|
|
6
|
+
*/
|
|
7
|
+
export const IS_WINDOWS = process.platform === "win32";
|
|
8
|
+
|
|
9
|
+
/** argv ready for Bun.spawn on this platform. */
|
|
10
|
+
export function platformArgv(argv: string[]): string[] {
|
|
11
|
+
if (!IS_WINDOWS || argv.length === 0) return argv;
|
|
12
|
+
const [bin, ...rest] = argv as [string, ...string[]];
|
|
13
|
+
const path = Bun.which(bin) ?? bin;
|
|
14
|
+
return /\.(cmd|bat)$/i.test(path) ? ["cmd.exe", "/d", "/s", "/c", path, ...rest] : [path, ...rest];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** argv that runs one shell command line (`!cmd` in the chat). */
|
|
18
|
+
export function shellArgv(cmd: string): string[] {
|
|
19
|
+
return IS_WINDOWS ? ["cmd.exe", "/d", "/s", "/c", cmd] : ["sh", "-c", cmd];
|
|
20
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const ESC = "\x1b";
|
|
2
|
+
export const CSI = `${ESC}[`;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Put the host terminal back into a sane state after an agent TUI dies.
|
|
6
|
+
* Agents killed mid-run do not restore anything, so undo every mode a modern
|
|
7
|
+
* TUI may have switched on: alt screen, mouse, bracketed paste, focus events,
|
|
8
|
+
* synchronized output, the kitty keyboard protocol and xterm modifyOtherKeys.
|
|
9
|
+
*/
|
|
10
|
+
export const RESET_SEQ = [
|
|
11
|
+
`${CSI}?2004l`, // bracketed paste off
|
|
12
|
+
`${CSI}?1000l${CSI}?1002l${CSI}?1003l${CSI}?1005l${CSI}?1006l${CSI}?1015l`, // mouse tracking off
|
|
13
|
+
`${CSI}?1004l`, // focus events off
|
|
14
|
+
`${CSI}?2026l`, // synchronized output off
|
|
15
|
+
`${CSI}<u${CSI}<u${CSI}<u${CSI}<u`, // pop kitty keyboard flags (no-op where unsupported)
|
|
16
|
+
`${CSI}=0;1u`, // kitty keyboard flags = 0
|
|
17
|
+
`${CSI}>4;0m`, // xterm modifyOtherKeys off
|
|
18
|
+
`${CSI}?1l`, // normal cursor keys
|
|
19
|
+
`${ESC}>`, // numeric keypad
|
|
20
|
+
`${CSI}?1049l`, // leave alt screen
|
|
21
|
+
`${CSI}!p`, // DECSTR soft reset (scroll region, origin mode, SGR, cursor visible)
|
|
22
|
+
`${CSI}r`, // scroll region = full screen
|
|
23
|
+
`${CSI}?25h`, // show cursor
|
|
24
|
+
`${CSI}?7h`, // autowrap on
|
|
25
|
+
`${CSI}0m`, // reset attrs
|
|
26
|
+
].join("");
|
|
27
|
+
|
|
28
|
+
/** Clear the screen and home the cursor; used before a new agent draws. */
|
|
29
|
+
export const CLEAR_SEQ = `${CSI}2J${CSI}H`;
|
|
30
|
+
|
|
31
|
+
export const ALT_ON = `${CSI}?1049h${CSI}?25l${CSI}H${CSI}2J`;
|
|
32
|
+
export const ALT_OFF = `${CSI}?25h${CSI}?1049l`;
|
|
33
|
+
|
|
34
|
+
export const PASTE_START = `${CSI}200~`;
|
|
35
|
+
export const PASTE_END = `${CSI}201~`;
|
|
36
|
+
|
|
37
|
+
export function stripAnsi(s: string): string {
|
|
38
|
+
// CSI, OSC, and single-char escapes.
|
|
39
|
+
return s
|
|
40
|
+
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, "")
|
|
41
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
42
|
+
.replace(/\x1b[@-Z\\-_]/g, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function termSize(): { cols: number; rows: number } {
|
|
46
|
+
return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const out = (s: string) => {
|
|
50
|
+
process.stdout.write(s);
|
|
51
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AgentId } from "../adapters/types.ts";
|
|
2
|
+
import { estimateCost, fmtTokens, fmtUsd, priceFor, priceProvenance } from "./pricing.ts";
|
|
3
|
+
|
|
4
|
+
/** One usage sample: a model call, a turn, or a delta reported by the agent. */
|
|
5
|
+
export interface UsageRecord {
|
|
6
|
+
ts: number;
|
|
7
|
+
agent: AgentId;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
cacheRead: number;
|
|
13
|
+
cacheWrite: number;
|
|
14
|
+
/** Cost the agent itself reported (OpenCode, Grok do). */
|
|
15
|
+
agentCostUsd?: number;
|
|
16
|
+
/** USD frozen at record time so later price changes do not rewrite history. */
|
|
17
|
+
costUsd?: number;
|
|
18
|
+
/** Where that figure came from: "agent", "user", "openrouter@2026-10-18", "builtin@…", or "none". */
|
|
19
|
+
priceSource?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Price a fresh record now and freeze the result on it. */
|
|
23
|
+
export function priceRecord(r: UsageRecord): UsageRecord {
|
|
24
|
+
if (typeof r.agentCostUsd === "number") return { ...r, costUsd: r.agentCostUsd, priceSource: "agent" };
|
|
25
|
+
const hit = priceFor(r.model);
|
|
26
|
+
const est = estimateCost(r.model, r);
|
|
27
|
+
if (hit && est !== null) {
|
|
28
|
+
const prov = priceProvenance();
|
|
29
|
+
return { ...r, costUsd: est, priceSource: hit.source === "user" ? "user" : hit.source === "builtin" ? `builtin@${prov.source === "builtin" ? prov.asOf : "2026-10-18"}` : `${hit.source}@${prov.asOf}` };
|
|
30
|
+
}
|
|
31
|
+
return { ...r, costUsd: undefined, priceSource: "none" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UsageTotals {
|
|
35
|
+
agent: AgentId;
|
|
36
|
+
models: Set<string>;
|
|
37
|
+
calls: number;
|
|
38
|
+
input: number;
|
|
39
|
+
output: number;
|
|
40
|
+
cacheRead: number;
|
|
41
|
+
cacheWrite: number;
|
|
42
|
+
/** API-equivalent USD: agent-reported where available, else estimated from list prices. */
|
|
43
|
+
costUsd: number;
|
|
44
|
+
/** True when some records had no price and were counted at $0. */
|
|
45
|
+
partial: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function recordCost(r: UsageRecord): { usd: number; known: boolean } {
|
|
49
|
+
if (typeof r.agentCostUsd === "number") return { usd: r.agentCostUsd, known: true };
|
|
50
|
+
if (typeof r.costUsd === "number") return { usd: r.costUsd, known: true };
|
|
51
|
+
if (r.priceSource === "none") return { usd: 0, known: false };
|
|
52
|
+
const est = estimateCost(r.model, r); // legacy records without a frozen cost
|
|
53
|
+
return est === null ? { usd: 0, known: !(r.input || r.output || r.cacheRead || r.cacheWrite) } : { usd: est, known: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function totalsByAgent(records: UsageRecord[]): UsageTotals[] {
|
|
57
|
+
const map = new Map<AgentId, UsageTotals>();
|
|
58
|
+
for (const r of records) {
|
|
59
|
+
let t = map.get(r.agent);
|
|
60
|
+
if (!t) {
|
|
61
|
+
t = { agent: r.agent, models: new Set(), calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, partial: false };
|
|
62
|
+
map.set(r.agent, t);
|
|
63
|
+
}
|
|
64
|
+
if (r.model) t.models.add(r.model);
|
|
65
|
+
t.calls++;
|
|
66
|
+
t.input += r.input;
|
|
67
|
+
t.output += r.output;
|
|
68
|
+
t.cacheRead += r.cacheRead;
|
|
69
|
+
t.cacheWrite += r.cacheWrite;
|
|
70
|
+
const c = recordCost(r);
|
|
71
|
+
t.costUsd += c.usd;
|
|
72
|
+
if (!c.known) t.partial = true;
|
|
73
|
+
}
|
|
74
|
+
return [...map.values()].sort((a, b) => b.costUsd - a.costUsd);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sumCost(records: UsageRecord[]): number {
|
|
78
|
+
return records.reduce((s, r) => s + recordCost(r).usd, 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Plain-text table for /usage and `codemax usage`. */
|
|
82
|
+
export function renderUsageTable(records: UsageRecord[], opts: { title: string; sessionId?: string }): string[] {
|
|
83
|
+
const rows = totalsByAgent(records);
|
|
84
|
+
const lines: string[] = [];
|
|
85
|
+
lines.push(`${opts.title} · API-equivalent value at list prices (your subscriptions covered this)`);
|
|
86
|
+
if (!rows.length) {
|
|
87
|
+
lines.push(" no usage recorded yet");
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
const head = ["agent", "calls", "input", "output", "cache read", "cache write", "est. cost"];
|
|
91
|
+
const data = rows.map((t) => [
|
|
92
|
+
t.agent + (t.models.size ? ` (${[...t.models].map(shortModel).join(", ")})` : ""),
|
|
93
|
+
String(t.calls),
|
|
94
|
+
fmtTokens(t.input),
|
|
95
|
+
fmtTokens(t.output),
|
|
96
|
+
fmtTokens(t.cacheRead),
|
|
97
|
+
fmtTokens(t.cacheWrite),
|
|
98
|
+
fmtUsd(t.costUsd) + (t.partial ? "*" : ""),
|
|
99
|
+
]);
|
|
100
|
+
const total = ["total", String(rows.reduce((s, t) => s + t.calls, 0)), fmtTokens(rows.reduce((s, t) => s + t.input, 0)), fmtTokens(rows.reduce((s, t) => s + t.output, 0)), fmtTokens(rows.reduce((s, t) => s + t.cacheRead, 0)), fmtTokens(rows.reduce((s, t) => s + t.cacheWrite, 0)), fmtUsd(rows.reduce((s, t) => s + t.costUsd, 0))];
|
|
101
|
+
const all = [head, ...data, total];
|
|
102
|
+
const w = head.map((_, i) => Math.max(...all.map((r) => r[i]!.length)));
|
|
103
|
+
const fmt = (r: string[]) => " " + r.map((c, i) => (i === 0 ? c.padEnd(w[i]!) : c.padStart(w[i]!))).join(" ");
|
|
104
|
+
lines.push(fmt(head));
|
|
105
|
+
for (const r of data) lines.push(fmt(r));
|
|
106
|
+
lines.push(fmt(total));
|
|
107
|
+
if (rows.some((t) => t.partial)) lines.push(" * includes calls on a model with no known price; set it in ~/.codemax/prices.json");
|
|
108
|
+
const prov = priceProvenance();
|
|
109
|
+
const agentPriced = records.filter((r) => typeof r.agentCostUsd === "number").length;
|
|
110
|
+
lines.push(` prices: ${prov.source} list as of ${prov.asOf}${agentPriced ? ` · ${agentPriced} calls priced by the agent itself` : ""} · each call keeps the rate it was recorded at`);
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function shortModel(m: string): string {
|
|
115
|
+
return m.replace(/^claude-/, "").replace(/^(anthropic|openai|xai|google|opencode)\//, "");
|
|
116
|
+
}
|