atom-agent 0.3.0

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/dist/auth.js ADDED
@@ -0,0 +1,122 @@
1
+ // Auth store mirroring opencode's auth.json (manual-key only).
2
+ // Credentials in ~/.atom/auth.json shaped:
3
+ // {version:1, providers:{"<id>":{apiKey, baseURL?}}}
4
+ // 0600 perms on POSIX via chmod; best-effort on Windows.
5
+ // Key resolution per provider: standard env var wins when set, else stored.
6
+ // Env names: OPENCODE_ZEN_API_KEY (zen), OPENAI_API_KEY, ANTHROPIC_API_KEY,
7
+ // DEEPSEEK_API_KEY, MISTRAL_API_KEY, GEMINI_API_KEY (also GOOGLE_API_KEY
8
+ // alias). openai-compatible: stored key + stored baseURL only.
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+ import { getProvider } from "./providers.js";
13
+ export const AUTH_VERSION = 1;
14
+ export function homeDir() {
15
+ return (process.env.ATOM_HOME ??
16
+ process.env.HOME ??
17
+ process.env.USERPROFILE ??
18
+ os.homedir());
19
+ }
20
+ export function atomDir(home) {
21
+ return path.join(home ?? homeDir(), ".atom");
22
+ }
23
+ export function authFilePath(home) {
24
+ return path.join(atomDir(home), "auth.json");
25
+ }
26
+ export function emptyAuth() {
27
+ return { version: AUTH_VERSION, providers: {} };
28
+ }
29
+ // Load auth.json; missing/corrupt file yields empty auth (never throws).
30
+ export function loadAuth(home) {
31
+ try {
32
+ const p = authFilePath(home);
33
+ if (!existsSync(p))
34
+ return emptyAuth();
35
+ const raw = readFileSync(p, "utf8");
36
+ const data = JSON.parse(raw);
37
+ if (typeof data !== "object" || data === null)
38
+ return emptyAuth();
39
+ const o = data;
40
+ const providers = {};
41
+ const src = o["providers"];
42
+ if (typeof src === "object" && src !== null) {
43
+ for (const [k, v] of Object.entries(src)) {
44
+ if (typeof v !== "object" || v === null)
45
+ continue;
46
+ const e = v;
47
+ if (typeof e["apiKey"] !== "string")
48
+ continue;
49
+ const entry = { apiKey: e["apiKey"] };
50
+ if (typeof e["baseURL"] === "string" && e["baseURL"].length > 0) {
51
+ entry.baseURL = e["baseURL"];
52
+ }
53
+ providers[k] = entry;
54
+ }
55
+ }
56
+ return { version: AUTH_VERSION, providers };
57
+ }
58
+ catch {
59
+ return emptyAuth();
60
+ }
61
+ }
62
+ // Save auth.json (mkdir -p + 0600 on POSIX, best-effort on Windows).
63
+ export function saveAuth(auth, home) {
64
+ const dir = atomDir(home);
65
+ mkdirSync(dir, { recursive: true });
66
+ const p = path.join(dir, "auth.json");
67
+ const payload = { version: AUTH_VERSION, providers: auth.providers ?? {} };
68
+ writeFileSync(p, JSON.stringify(payload, null, 2) + "\n", "utf8");
69
+ try {
70
+ chmodSync(p, 0o600);
71
+ }
72
+ catch {
73
+ // best-effort on Windows; ignore
74
+ }
75
+ }
76
+ export function getStoredAuth(auth, id) {
77
+ return auth.providers[id];
78
+ }
79
+ export function getStoredKey(auth, id) {
80
+ return auth.providers[id]?.apiKey ?? "";
81
+ }
82
+ export function getStoredBaseURL(auth, id) {
83
+ return auth.providers[id]?.baseURL ?? "";
84
+ }
85
+ // Env key for a provider (first non-empty env var wins). Empty when none.
86
+ export function getEnvKey(id) {
87
+ const def = getProvider(id);
88
+ if (!def)
89
+ return "";
90
+ for (const name of def.envVars) {
91
+ const v = process.env[name];
92
+ if (typeof v === "string" && v.length > 0)
93
+ return v;
94
+ }
95
+ return "";
96
+ }
97
+ // Resolved key: env wins when set, else stored. openai-compatible has no
98
+ // env vars, so it is stored-only by construction.
99
+ export function resolveApiKey(id, auth) {
100
+ return getEnvKey(id) || getStoredKey(auth, id);
101
+ }
102
+ export function hasKey(id, auth) {
103
+ return resolveApiKey(id, auth).length > 0;
104
+ }
105
+ // Set (or replace) the stored key for a provider; returns the updated auth.
106
+ export function setStoredKey(auth, id, apiKey, baseURL) {
107
+ const next = {
108
+ version: AUTH_VERSION,
109
+ providers: { ...auth.providers },
110
+ };
111
+ const prev = next.providers[id];
112
+ const entry = { apiKey };
113
+ const base = baseURL !== undefined ? baseURL : prev?.baseURL;
114
+ if (typeof base === "string" && base.length > 0)
115
+ entry.baseURL = base;
116
+ next.providers[id] = entry;
117
+ return next;
118
+ }
119
+ export function setStoredBaseURL(auth, id, baseURL) {
120
+ const prev = auth.providers[id];
121
+ return setStoredKey(auth, id, prev?.apiKey ?? "", baseURL);
122
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./App.js";
5
+ import { DEFAULT_ENDPOINT, DEFAULT_MODEL, endpointConfig, } from "./zen.js";
6
+ import { loadAuth, resolveApiKey } from "./auth.js";
7
+ const args = process.argv.slice(2);
8
+ if (args.includes("--help") || args.includes("-h")) {
9
+ console.log(`Atom chatbot (Ink TUI)
10
+ Usage: npm start
11
+ Env:
12
+ OPENCODE_ZEN_API_KEY optional when ~/.atom/auth.json has a zen key (get one at https://opencode.ai/auth)
13
+ OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
14
+ OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL})
15
+ OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
16
+ Commands: /model (model picker) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /mode | /yolo (toggle) | /plan (read-only plan mode) | /clear | /resume (restore last saved session) | /help | /exit | /quit
17
+ Providers: opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one).
18
+ Note: reasoning_effort is sent only for opencode-zen supported models.`);
19
+ process.exit(0);
20
+ }
21
+ const { endpoint, apiKey: envKey, model } = endpointConfig();
22
+ // Stored zen key (from a previous /provider paste) applies when no env key.
23
+ const storedZen = resolveApiKey("opencode-zen", loadAuth());
24
+ const apiKey = envKey || storedZen;
25
+ // Always start the TUI (even without a key) so /provider can paste one.
26
+ // Chatting without a key for the active provider errors inline with a
27
+ // /provider pointer; nothing is POSTed.
28
+ render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: model }));
@@ -0,0 +1,277 @@
1
+ // Context compaction (Claude-Code/opencode-style) for the Ink chatbot.
2
+ // AUTO-COMPACT at ~83% of the model's verified context window plus manual
3
+ // `/compact [focus]` (see App.tsx slash registry).
4
+ //
5
+ // Mechanics mirrored from research (verified this session):
6
+ // - Claude Code: auto-compact = ONE extra request (same system prompt +
7
+ // tools + history, summarization instruction appended); history replaced
8
+ // by the summary; manual `/compact [focus]`; thrashing guard.
9
+ // - opencode V2: preflight estimate = JSON-serialized request size at
10
+ // 4 chars/token; summary via session model with TOOLS DISABLED, ≤4096
11
+ // output tokens, structured template; newest tail retained (~8000 tokens,
12
+ // tool outputs capped 2000 chars); overflow-recovery retry once.
13
+ //
14
+ // This module is pure + testable (mocked fetch only in tests, never live).
15
+ // The App owns session refs (load/streak/disabled/pending) and the atomic
16
+ // swap + save; this module owns math, splitting, instruction, and the
17
+ // summary POST (tools disabled, 4096 cap).
18
+ import { contextWindowFor } from "./context-windows.js";
19
+ import { historyChars, messageChars, chatCompletionForProvider, } from "./zen.js";
20
+ // ---- Constants ----
21
+ export const COMPACT_PCT_DEFAULT = 0.83;
22
+ export const COMPACT_KEEP_TOKENS = 8000;
23
+ export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
24
+ export const COMPACT_TOOL_OUTPUT_CAP = 2000;
25
+ // opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
26
+ // estimated tokens. Used for load fallback + tail split only, never for the
27
+ // `token: n/a` honesty rule or the NK cumulative spend.
28
+ export const COMPACT_CHARS_PER_TOKEN = 4;
29
+ export const COMPACT_THRASH_LIMIT = 3;
30
+ // ---- Threshold ----
31
+ function clampPctPercent(n) {
32
+ return Math.min(Math.max(n, 50), 95) / 100;
33
+ }
34
+ // Auto-compact threshold as a fraction (default 0.83). Env ATOM_COMPACT_PCT
35
+ // is a percent (e.g. "83"), clamped 50–95; invalid/unset → default.
36
+ export function compactPct() {
37
+ const raw = process.env.ATOM_COMPACT_PCT;
38
+ if (raw === undefined)
39
+ return COMPACT_PCT_DEFAULT;
40
+ const text = raw.trim();
41
+ if (!/^\d+(\.\d+)?$/.test(text))
42
+ return COMPACT_PCT_DEFAULT;
43
+ const n = Number(text);
44
+ if (!Number.isFinite(n))
45
+ return COMPACT_PCT_DEFAULT;
46
+ return clampPctPercent(n);
47
+ }
48
+ // ---- Load metric ----
49
+ export function estimateTokensForChars(chars) {
50
+ // opencode's 4ch/token heuristic.
51
+ const c = Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0;
52
+ return Math.floor(c / COMPACT_CHARS_PER_TOKEN);
53
+ }
54
+ // Load = last POST's reported prompt_tokens when available, else the
55
+ // 4ch/token estimate of the sent history chars.
56
+ export function computeContextLoad(lastPromptTokens, sentHistoryChars) {
57
+ if (typeof lastPromptTokens === "number" &&
58
+ Number.isFinite(lastPromptTokens) &&
59
+ lastPromptTokens >= 0) {
60
+ return Math.floor(lastPromptTokens);
61
+ }
62
+ return estimateTokensForChars(sentHistoryChars);
63
+ }
64
+ export function shouldAutoCompact(load, model, pctOverride) {
65
+ const window = contextWindowFor(model);
66
+ if (window === undefined)
67
+ return false; // never invent a window
68
+ const pct = typeof pctOverride === "number" && Number.isFinite(pctOverride)
69
+ ? pctOverride
70
+ : compactPct();
71
+ return load / window >= pct;
72
+ }
73
+ // ---- Turn helpers ----
74
+ export function countUserTurns(history) {
75
+ let n = 0;
76
+ for (const m of history) {
77
+ if (m?.role === "user")
78
+ n += 1;
79
+ }
80
+ return n;
81
+ }
82
+ function turnStarts(history) {
83
+ const starts = [];
84
+ for (let i = 1; i < history.length; i++) {
85
+ if (history[i]?.role === "user")
86
+ starts.push(i);
87
+ }
88
+ return starts;
89
+ }
90
+ function turnEnd(history, startIdx, starts) {
91
+ const pos = starts.indexOf(startIdx);
92
+ if (pos < 0)
93
+ return history.length;
94
+ return pos + 1 < starts.length ? starts[pos + 1] : history.length;
95
+ }
96
+ function turnChars(history, start, end) {
97
+ let n = 0;
98
+ for (let i = start; i < end; i++)
99
+ n += messageChars(history[i]);
100
+ return n;
101
+ }
102
+ export function capToolOutputsInTail(tail) {
103
+ return tail.map((m) => {
104
+ if (m.role === "tool" && typeof m.content === "string" && m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
105
+ return {
106
+ ...m,
107
+ content: m.content.slice(0, COMPACT_TOOL_OUTPUT_CAP) +
108
+ "\n[truncated: tool output exceeded 2000 chars]",
109
+ };
110
+ }
111
+ return { ...m };
112
+ });
113
+ }
114
+ // Split history (after system) into head + retained newest tail of whole
115
+ // user-turns up to KEEP_TOKENS estimated tokens (chars/4). Tool outputs in
116
+ // the tail are capped at 2000 chars each. Never drops history[0]; always
117
+ // keeps at least the newest turn; when everything fits but there is more
118
+ // than one turn, keeps only the newest turn in the tail so manual /compact
119
+ // still has an older turn to summarize.
120
+ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOKENS) {
121
+ if (history.length <= 1)
122
+ return { head: [], tail: [], olderTurnCount: 0 };
123
+ const starts = turnStarts(history);
124
+ if (starts.length === 0)
125
+ return { head: [], tail: [], olderTurnCount: 0 };
126
+ let totalChars = 0;
127
+ let tailStart = starts[starts.length - 1];
128
+ for (let s = starts.length - 1; s >= 0; s--) {
129
+ const start = starts[s];
130
+ const end = turnEnd(history, start, starts);
131
+ totalChars += turnChars(history, start, end);
132
+ const est = estimateTokensForChars(totalChars);
133
+ if (est <= keepTokens) {
134
+ tailStart = start;
135
+ }
136
+ else {
137
+ break;
138
+ }
139
+ }
140
+ // Everything fits but >1 turn: keep only the newest turn in the tail so
141
+ // manual /compact still has an older turn to summarize (auto never
142
+ // reaches here — its load would be far below threshold when everything
143
+ // fits in 8000 tokens).
144
+ if (tailStart === starts[0] && starts.length > 1) {
145
+ tailStart = starts[starts.length - 1];
146
+ }
147
+ const head = history.slice(1, tailStart);
148
+ const rawTail = history.slice(tailStart);
149
+ const tail = capToolOutputsInTail(rawTail);
150
+ let olderTurnCount = 0;
151
+ for (const m of head)
152
+ if (m?.role === "user")
153
+ olderTurnCount += 1;
154
+ return { head, tail, olderTurnCount };
155
+ }
156
+ // ---- Instruction template ----
157
+ export function buildCompactionInstruction(focusText) {
158
+ const focus = typeof focusText === "string" && focusText.trim().length > 0
159
+ ? `\nFocus for this summary: ${focusText.trim()}\n`
160
+ : "";
161
+ return (`Summarize the conversation so far for context compaction. Be concise but preserve all information needed to continue the work without re-reading the full history.` +
162
+ `${focus}\n` +
163
+ `Structure your summary with these headings (omit a section only when it has no content):\n` +
164
+ `## Objective\n` +
165
+ `## Requirements\n` +
166
+ `## Decisions\n` +
167
+ `## Completed work\n` +
168
+ `## Active work\n` +
169
+ `## Blockers\n` +
170
+ `## Next moves\n` +
171
+ `## Relevant files\n` +
172
+ `Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
173
+ }
174
+ export function buildSummaryMessages(systemContent, head, focusText) {
175
+ return [
176
+ { role: "system", content: systemContent },
177
+ ...head.map((m) => ({ ...m })),
178
+ { role: "user", content: buildCompactionInstruction(focusText) },
179
+ ];
180
+ }
181
+ export function buildCompactedHistory(systemMessage, summaryText, tail, olderTurnCount, nowISO) {
182
+ const iso = nowISO ?? new Date().toISOString();
183
+ const summaryUser = {
184
+ role: "user",
185
+ content: `[Compacted context ${iso}: summary of ${olderTurnCount} older turns]\n${summaryText}`,
186
+ };
187
+ return [
188
+ { ...systemMessage },
189
+ summaryUser,
190
+ ...tail.map((m) => ({ ...m })),
191
+ ];
192
+ }
193
+ export function compactBoundaryLine(olderTurnCount) {
194
+ return `(context compacted: ${olderTurnCount} turns → summary)`;
195
+ }
196
+ // ---- Thrash guard ----
197
+ export function isThrashDisabled(streak) {
198
+ return streak >= COMPACT_THRASH_LIMIT;
199
+ }
200
+ // ---- Size-error detection + head truncation for the once-retry ----
201
+ export function isSizeError(e) {
202
+ const msg = e instanceof Error ? e.message : String(e ?? "");
203
+ if (/HTTP\s+(400|413)\b/.test(msg))
204
+ return true;
205
+ return /context|too long|maximum|too many|overflow|too large|token[^.]{0,40}limit|length|exceed/i.test(msg);
206
+ }
207
+ // Truncate head to budget ONCE: drop the oldest half of its user-turns,
208
+ // preserving assistant/tool pairing (turn boundaries).
209
+ export function truncateHeadForRetry(head) {
210
+ if (head.length <= 1)
211
+ return [...head];
212
+ const starts = [];
213
+ for (let i = 0; i < head.length; i++) {
214
+ if (head[i]?.role === "user")
215
+ starts.push(i);
216
+ }
217
+ if (starts.length <= 1) {
218
+ // No turn structure: keep the newest half of messages.
219
+ return head.slice(Math.ceil(head.length / 2));
220
+ }
221
+ const keepTurns = Math.max(1, Math.ceil(starts.length / 2));
222
+ const keepFrom = starts[starts.length - keepTurns];
223
+ return head.slice(keepFrom);
224
+ }
225
+ // Summary POST: SAME provider/model via the existing chat path but TOOLS
226
+ // DISABLED (no `tools` key in the POST body) and output capped at 4096
227
+ // (max_tokens/maxOutputTokens per kind — see zen.ts/adapters.ts). Returns
228
+ // the summary text. On size-overflow it truncates head to budget ONCE and
229
+ // retries once, then gives up with a `/clear` suggestion; other failures
230
+ // throw immediately with history untouched (caller must not swap).
231
+ export async function requestCompactSummary(req) {
232
+ const attempt = async (head) => {
233
+ const messages = buildSummaryMessages(req.systemContent, head, req.focusText);
234
+ const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
235
+ baseURL: req.baseURL,
236
+ endpointOverride: req.endpointOverride,
237
+ disableTools: true,
238
+ maxOutputTokens: COMPACT_SUMMARY_MAX_TOKENS,
239
+ ...(req.signal ? { signal: req.signal } : {}),
240
+ });
241
+ // Totals keep accumulating: forward real summary usage when present.
242
+ // The caller must NOT feed this into lastPromptTokens (load tracks the
243
+ // main context, not the head-sized summary request).
244
+ if (res.usage !== undefined) {
245
+ try {
246
+ req.onUsage?.(res.usage);
247
+ }
248
+ catch {
249
+ // observer errors never break compaction
250
+ }
251
+ }
252
+ const text = (res.content ?? "").trim();
253
+ if (!text)
254
+ throw new Error("Empty reply from model (unexpected payload).");
255
+ return text;
256
+ };
257
+ try {
258
+ return await attempt(req.head);
259
+ }
260
+ catch (e) {
261
+ if (!isSizeError(e))
262
+ throw e;
263
+ // Overflow/fails-from-size: truncate head to budget ONCE and retry once.
264
+ if (req.head.length <= 1) {
265
+ throw new Error(`${e instanceof Error ? e.message : String(e)} (compact failed — use /clear)`);
266
+ }
267
+ const truncated = truncateHeadForRetry(req.head);
268
+ try {
269
+ return await attempt(truncated);
270
+ }
271
+ catch (e2) {
272
+ throw new Error(`${e2 instanceof Error ? e2.message : String(e2)} (compact failed — use /clear)`);
273
+ }
274
+ }
275
+ }
276
+ // Re-export for callers that need the post-turn history size.
277
+ export { historyChars };
@@ -0,0 +1,112 @@
1
+ export const CONTEXT_WINDOWS = {
2
+ // DeepSeek V4 family: 1M context.
3
+ // Sources: https://www.deepseek.com/en/news/v4-preview/
4
+ // ("1M context is now the default"), https://arxiv.org/abs/2606.19348
5
+ // ("supporting a context length of one million tokens").
6
+ "deepseek-v4-pro": 1_000_000,
7
+ "deepseek-v4-flash": 1_000_000,
8
+ "deepseek-v4-flash-vision-exp": 1_000_000,
9
+ // Kimi K2.5 / K2.6: 256K context (262144 tokens).
10
+ // Sources: https://github.com/MoonshotAI/Kimi-K2.5 (Context Length 256K),
11
+ // https://huggingface.co/moonshotai/Kimi-K2.5 (Context Length 256K),
12
+ // https://platform.kimi.ai/docs/guide/kimi-k2-6-quickstart
13
+ // ("kimi-k2.7-code and kimi-k2.6 models both provide a 256K context window").
14
+ "kimi-k2.5": 262_144,
15
+ "kimi-k2.6": 262_144,
16
+ // Kimi K2.7 Code: 256K context.
17
+ // Source: https://www.kimi.com/code/docs/en/kimi-code/models.html
18
+ // (kimi-for-coding / K2.7 Code: 256k context window).
19
+ "kimi-k2.7-code": 262_144,
20
+ // Kimi K3: 1M context (1048576 tokens).
21
+ // Sources: https://github.com/MoonshotAI/Kimi-K3 (Context Length 1048576),
22
+ // https://docs.api.nvidia.com/nim/reference/moonshotai-kimi-k3
23
+ // (Input Context Length: 1,048,576 tokens).
24
+ "kimi-k3": 1_048_576,
25
+ // GLM-5.1: 200K context.
26
+ // Sources: https://llm-stats.com/models/compare/glm-5.1-vs-glm-5.3
27
+ // (GLM-5.1: 200,000 tokens), https://z.ai/blog/glm-5.1
28
+ // (eval settings "with a 200K context window").
29
+ "glm-5.1": 200_000,
30
+ // GLM-5.2: solid 1M context.
31
+ // Source: https://z.ai/blog/glm-5.2 ("a solid 1M-token context").
32
+ "glm-5.2": 1_000_000,
33
+ // GLM-5.3: 1M context.
34
+ // Sources: https://docs.aimlapi.com/api-references/text-models-llm/zhipu/glm-5.3
35
+ // ("1M-token context window"), https://kie.ai/blog/what-is-glm-5-3
36
+ // ("Context window 1M tokens").
37
+ "glm-5.3": 1_000_000,
38
+ // MiniMax M2.5 / M2.7: 204800 context; MiniMax M3: 1M context.
39
+ // Sources: https://platform.minimax.io/docs/guides/text-generation
40
+ // (context-window table: M3 1,000,000; M2.7/M2.5 204,800),
41
+ // https://www.minimax.io/models/text/m3 ("up to 1M tokens context window").
42
+ "minimax-m2.5": 204_800,
43
+ "minimax-m2.7": 204_800,
44
+ "minimax-m3": 1_000_000,
45
+ // OpenAI flagship chat models: 1.05M context window.
46
+ // Source: https://platform.openai.com/docs/models (Context window 1.05M
47
+ // for GPT-6 Astra, GPT-5.6 Sol/Terra/Luna).
48
+ "gpt-6-astra": 1_050_000,
49
+ "gpt-5.6-sol": 1_050_000,
50
+ "gpt-5.6-terra": 1_050_000,
51
+ "gpt-5.6-luna": 1_050_000,
52
+ // Anthropic Claude: 1M context (Opus 5 / Sonnet 5 / Fable 5.1),
53
+ // 200K for Haiku 4.5.
54
+ // Source: https://platform.claude.com/docs/en/models/overview
55
+ // (Context window row: 1M tokens / 1M tokens / 1M tokens / 200K tokens).
56
+ "claude-fable-5-1": 1_000_000,
57
+ "claude-opus-5": 1_000_000,
58
+ "claude-sonnet-5": 1_000_000,
59
+ "claude-haiku-4-5": 200_000,
60
+ "claude-haiku-4-5-20251001": 200_000,
61
+ // Google Gemini 3 family: 1M context (1,048,576 tokens).
62
+ // Sources: https://ai.google.dev/gemini-api/docs/gemini-3
63
+ // (Context Window 1M), https://ai.google.dev/gemini-api/docs/long-context
64
+ // ("large context windows of 1 million or more tokens"),
65
+ // https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/gemini/3-7-flash
66
+ // (Context window 1,048,576).
67
+ "gemini-3-flash-preview": 1_048_576,
68
+ "gemini-3.1-pro-preview": 1_048_576,
69
+ "gemini-3.1-flash-lite": 1_048_576,
70
+ "gemini-3.5-flash": 1_048_576,
71
+ "gemini-3.7-flash": 1_048_576,
72
+ "gemini-3.8-flash": 1_048_576,
73
+ };
74
+ // Context window for a model id, or undefined when the model has no
75
+ // verified window (callers render the bare `token: NK` form).
76
+ export function contextWindowFor(model) {
77
+ return CONTEXT_WINDOWS[model];
78
+ }
79
+ // Total session tokens: prefer usage.total_tokens when present, else
80
+ // prompt_tokens + completion_tokens (missing keys count as 0).
81
+ export function totalTokens(usage) {
82
+ if (typeof usage.total_tokens === "number") {
83
+ return Math.max(0, Math.floor(usage.total_tokens));
84
+ }
85
+ const prompt = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0;
86
+ const completion = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0;
87
+ return Math.max(0, Math.floor(prompt + completion));
88
+ }
89
+ // Footer token segment, EXACT format:
90
+ // - no usage reported yet: `token: n/a` (never estimated)
91
+ // - known window: `token: (P%) NK` (NK = round(total/1024) + "K" from the
92
+ // CUMULATIVE session spend; P = round(100*load/window) from the CURRENT
93
+ // context load — prompt_tokens of the last POST, else the 4ch/token
94
+ // estimate. Cumulative spend keeps growing after compaction, so it must
95
+ // NOT drive P; load does. Pass load explicitly; when omitted it falls
96
+ // back to the cumulative total for backward compat.)
97
+ // - unknown window: `token: NK` (never invent a window)
98
+ // Zero usage with a known window is `token: (0%) 0K`; without one `token: 0K`.
99
+ export function formatTokenSegment(usage, model, load) {
100
+ if (!usage)
101
+ return "token: n/a";
102
+ const total = totalTokens(usage);
103
+ const k = `${Math.round(total / 1024)}K`;
104
+ const window = contextWindowFor(model);
105
+ if (window === undefined)
106
+ return `token: ${k}`;
107
+ const loadTokens = typeof load === "number" && Number.isFinite(load) && load >= 0
108
+ ? Math.floor(load)
109
+ : total;
110
+ const pct = Math.round((100 * loadTokens) / window);
111
+ return `token: (${pct}%) ${k}`;
112
+ }