@objctp/opencode-better-prompt 0.8.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.
@@ -0,0 +1,238 @@
1
+ import type { Part } from "@opencode-ai/sdk";
2
+ import { CONFIG_DEFAULTS } from "./config";
3
+ import type { Config } from "./config";
4
+ import { resolveModel } from "./catalog";
5
+ import type { ModelRef, PipelineDeps, Usage } from "./types";
6
+
7
+ export const SUB_AGENTS = new Set([
8
+ "prompt-correction",
9
+ "prompt-translation",
10
+ "prompt-enhancement",
11
+ "prompt-summarisation",
12
+ ]);
13
+
14
+ export const FULL_REFRESH_THRESHOLD = 10;
15
+
16
+ interface PromptInfo {
17
+ cost?: number;
18
+ tokens?: {
19
+ input?: number;
20
+ output?: number;
21
+ cache?: { write?: number; read?: number };
22
+ };
23
+ }
24
+
25
+ const ZERO_USAGE: Usage = {
26
+ cost: 0,
27
+ inputTokens: 0,
28
+ outputTokens: 0,
29
+ cacheWriteTokens: 0,
30
+ cacheReadTokens: 0,
31
+ };
32
+
33
+ // :::: Agent invocation :::: ////////////////////////////////
34
+
35
+ export async function invokeAgent(
36
+ deps: PipelineDeps,
37
+ agent: string,
38
+ text: string,
39
+ model?: ModelRef,
40
+ ): Promise<{ text: string; usage: Usage }> {
41
+ const { client, toast, logError, logWarn } = deps;
42
+ let childId: string | undefined;
43
+ try {
44
+ const { data: child } = await client.session.create({
45
+ body: {},
46
+ });
47
+ if (!child) {
48
+ await toast(`${agent}: could not create session`, "error");
49
+ return { text, usage: { ...ZERO_USAGE } };
50
+ }
51
+ childId = child.id;
52
+
53
+ const { data: result } = await client.session.prompt({
54
+ body: {
55
+ agent,
56
+ parts: [{ type: "text", text }],
57
+ ...(model && { model }),
58
+ },
59
+ path: { id: child.id },
60
+ });
61
+
62
+ if (!result?.parts) {
63
+ return { text, usage: { ...ZERO_USAGE } };
64
+ }
65
+
66
+ const textPart = result.parts.find((p: Part) => p.type === "text" && "text" in p);
67
+ const resultText = textPart && "text" in textPart ? (textPart as { text: string }).text : text;
68
+
69
+ const info = (result as Record<string, unknown>).info as PromptInfo | undefined;
70
+ const usage: Usage = {
71
+ cost: info?.cost ?? 0,
72
+ inputTokens: info?.tokens?.input ?? 0,
73
+ outputTokens: info?.tokens?.output ?? 0,
74
+ cacheWriteTokens: info?.tokens?.cache?.write ?? 0,
75
+ cacheReadTokens: info?.tokens?.cache?.read ?? 0,
76
+ };
77
+
78
+ return { text: resultText, usage };
79
+ } catch (err) {
80
+ void logError(`invokeAgent(${agent}) failed`, err);
81
+ await toast(`${agent} error`, "error");
82
+ return { text, usage: { ...ZERO_USAGE } };
83
+ } finally {
84
+ if (childId) {
85
+ client.session.delete({ path: { id: childId } }).catch((err: unknown) => {
86
+ void logWarn(
87
+ `invokeAgent: failed to delete child session ${childId}`,
88
+ err instanceof Error ? err.message : String(err),
89
+ );
90
+ });
91
+ }
92
+ }
93
+ }
94
+
95
+ // :::: Context summarisation :::: ///////////////////////////
96
+
97
+ function formatFullSummaryInput(userMessages: string[], lastAssistant: string): string {
98
+ let input =
99
+ "Summarise this conversation in 3-5 sentences. Focus on the topic, technical context, user's goal, and key decisions. Be concise.\n\n";
100
+ for (const msg of userMessages) {
101
+ input += `User: ${msg}\n`;
102
+ }
103
+ if (lastAssistant) {
104
+ input += `\nAssistant: ${lastAssistant}\n`;
105
+ }
106
+ input += "\nSummary:";
107
+ return input;
108
+ }
109
+
110
+ function formatIncrementalInput(
111
+ existingSummary: string,
112
+ userMsg: string,
113
+ assistantMsg: string,
114
+ ): string {
115
+ let input = `Given this summary:\n${existingSummary}\n\nUpdate it with this new exchange:\nUser: ${userMsg}`;
116
+ if (assistantMsg) {
117
+ input += `\nAssistant: ${assistantMsg}`;
118
+ }
119
+ input +=
120
+ "\n\nProvide an updated summary in 3-5 sentences. Drop stale details if no longer relevant.\n\nUpdated summary:";
121
+ return input;
122
+ }
123
+
124
+ function extractTextFromParts(parts: unknown[]): string {
125
+ if (!parts || !Array.isArray(parts)) return "";
126
+ const texts: string[] = [];
127
+ for (const p of parts) {
128
+ const part = p as Record<string, unknown>;
129
+ if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
130
+ texts.push(part.text.trim());
131
+ }
132
+ }
133
+ return texts.join("\n");
134
+ }
135
+
136
+ export async function summariseContext(
137
+ deps: PipelineDeps,
138
+ sessionID: string,
139
+ currentMessageID: string,
140
+ config: Config,
141
+ ): Promise<{
142
+ summary: string;
143
+ lastMessageID: string;
144
+ messageCount: number;
145
+ usage: Usage;
146
+ }> {
147
+ const { sessionContexts, client, logError, logWarn } = deps;
148
+ const existing = sessionContexts.get(sessionID);
149
+
150
+ if (!existing || existing.messageCount === 0) {
151
+ return {
152
+ summary: "",
153
+ lastMessageID: existing?.lastMessageID ?? "",
154
+ messageCount: 1,
155
+ usage: { ...ZERO_USAGE },
156
+ };
157
+ }
158
+
159
+ let messages: Record<string, unknown>[];
160
+ try {
161
+ const result = await client.session.messages({ path: { id: sessionID } });
162
+ messages = Array.isArray(result) ? result : [];
163
+ } catch (err) {
164
+ void logError("summariseContext: session.messages failed", err);
165
+ return {
166
+ summary: existing?.summary ?? "",
167
+ lastMessageID: existing?.lastMessageID ?? "",
168
+ messageCount: existing?.messageCount ?? 0,
169
+ usage: { ...ZERO_USAGE },
170
+ };
171
+ }
172
+
173
+ const userMessages: string[] = [];
174
+ let lastAssistantText = "";
175
+ let latestMessageID = existing?.lastMessageID ?? "";
176
+
177
+ for (const msg of messages) {
178
+ const info = (msg?.info ?? {}) as Record<string, unknown>;
179
+ if (!info?.id) continue;
180
+
181
+ if (currentMessageID && info.id === currentMessageID) continue;
182
+
183
+ if (info.agent && SUB_AGENTS.has(info.agent as string)) continue;
184
+
185
+ const text = extractTextFromParts(msg?.parts as unknown[]);
186
+ if (!text) continue;
187
+
188
+ if (info.role === "user") {
189
+ userMessages.push(text);
190
+ } else if (info.role === "assistant") {
191
+ lastAssistantText = text;
192
+ }
193
+
194
+ latestMessageID = info.id as string;
195
+ }
196
+
197
+ if (userMessages.length === 0 && !lastAssistantText) {
198
+ return {
199
+ summary: existing?.summary ?? "",
200
+ lastMessageID: (latestMessageID || existing?.lastMessageID) ?? "",
201
+ messageCount: existing?.messageCount ?? 0,
202
+ usage: { ...ZERO_USAGE },
203
+ };
204
+ }
205
+
206
+ const needFullRefresh =
207
+ !existing || existing.messageCount >= FULL_REFRESH_THRESHOLD || existing.lastMessageID === "";
208
+
209
+ let summary: string;
210
+ let summarisationUsage: Usage = { ...ZERO_USAGE };
211
+
212
+ if (needFullRefresh) {
213
+ const model = await resolveModel(config.enhancement_model, CONFIG_DEFAULTS.enhancement_model);
214
+ const input = formatFullSummaryInput(userMessages, lastAssistantText);
215
+ const fullResult = await invokeAgent(deps, "prompt-summarisation", input, model);
216
+ summary = fullResult.text;
217
+ summarisationUsage = fullResult.usage;
218
+ } else {
219
+ const model = await resolveModel(config.correction_model, CONFIG_DEFAULTS.correction_model);
220
+ const newUserMsg = userMessages[userMessages.length - 1] || "";
221
+ const input = formatIncrementalInput(existing.summary, newUserMsg, lastAssistantText);
222
+ const incrResult = await invokeAgent(deps, "prompt-summarisation", input, model);
223
+ summary = incrResult.text;
224
+ summarisationUsage = incrResult.usage;
225
+ }
226
+
227
+ if (!summary?.trim()) {
228
+ void logWarn("summarisation: agent returned empty, keeping existing summary");
229
+ summary = existing?.summary ?? "";
230
+ }
231
+
232
+ return {
233
+ summary: summary.trim(),
234
+ lastMessageID: latestMessageID,
235
+ messageCount: existing ? existing.messageCount + 1 : 2,
236
+ usage: summarisationUsage,
237
+ };
238
+ }
@@ -0,0 +1,271 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import process from "node:process";
6
+ import type {
7
+ Catalog,
8
+ CatalogCandidate,
9
+ CatalogModel,
10
+ ModelEntry,
11
+ ModelRef,
12
+ OpenCodeConfig,
13
+ } from "./types";
14
+
15
+ // :::: Constants :::: ///////////////////////////////////////
16
+
17
+ export const TIER_CYCLE: readonly string[] = ["fast", "capable", "powerful"];
18
+
19
+ export const TIER_ALIASES: Record<string, string> = {
20
+ haiku: "fast",
21
+ sonnet: "capable",
22
+ opus: "powerful",
23
+ };
24
+
25
+ export const MODEL_DEFAULTS: Record<string, string> = {
26
+ correction_model: "haiku",
27
+ translation_model: "haiku",
28
+ enhancement_model: "sonnet",
29
+ };
30
+
31
+ const CATALOG_CACHE_PATH = join(homedir(), ".cache", "opencode", "models-dev.json");
32
+
33
+ const CATALOG_STALE_MS = 24 * 60 * 60 * 1000;
34
+
35
+ let _catalog: Catalog | null = null;
36
+
37
+ // :::: Catalog loading :::: /////////////////////////////////
38
+
39
+ export async function loadCatalog(): Promise<Catalog> {
40
+ if (_catalog) return _catalog;
41
+
42
+ const isStale = (mtime: number): boolean => Date.now() - mtime > CATALOG_STALE_MS;
43
+
44
+ try {
45
+ const info = await stat(CATALOG_CACHE_PATH);
46
+ if (!isStale(info.mtimeMs)) {
47
+ _catalog = JSON.parse(await readFile(CATALOG_CACHE_PATH, "utf-8")) as Catalog;
48
+ return _catalog;
49
+ }
50
+ } catch {
51
+ /* cache read failed, proceed to fetch */
52
+ }
53
+
54
+ try {
55
+ const res = await fetch("https://models.dev/api.json");
56
+ const data: Catalog = await res.json();
57
+ _catalog = data;
58
+ await mkdir(dirname(CATALOG_CACHE_PATH), { recursive: true });
59
+ await writeFile(CATALOG_CACHE_PATH, JSON.stringify(data));
60
+ return data;
61
+ } catch {
62
+ try {
63
+ _catalog = JSON.parse(await readFile(CATALOG_CACHE_PATH, "utf-8")) as Catalog;
64
+ return _catalog;
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+ }
70
+
71
+ // :::: OpenCode config merge :::: //////////////////////////
72
+
73
+ function deepMerge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
74
+ const result = { ...a };
75
+ for (const key of Object.keys(b) as (keyof T)[]) {
76
+ const bVal = b[key];
77
+ const aVal = a[key];
78
+ if (
79
+ bVal &&
80
+ typeof bVal === "object" &&
81
+ !Array.isArray(bVal) &&
82
+ aVal &&
83
+ typeof aVal === "object" &&
84
+ !Array.isArray(aVal)
85
+ ) {
86
+ (result as Record<string, unknown>)[key as string] = deepMerge(
87
+ aVal as Record<string, unknown>,
88
+ bVal as Record<string, unknown>,
89
+ );
90
+ } else {
91
+ (result as Record<string, unknown>)[key as string] = bVal;
92
+ }
93
+ }
94
+ return result;
95
+ }
96
+
97
+ export function loadMergedConfig(): OpenCodeConfig {
98
+ const load = (p: string): OpenCodeConfig => {
99
+ try {
100
+ return JSON.parse(readFileSync(p, "utf-8"));
101
+ } catch {
102
+ return {};
103
+ }
104
+ };
105
+ const globalCfg = load(join(homedir(), ".config", "opencode", "opencode.json"));
106
+ const projCfg = load("opencode.json");
107
+ return deepMerge(globalCfg, projCfg);
108
+ }
109
+
110
+ export async function getConnectedProviders(): Promise<Set<string>> {
111
+ const catalog = await loadCatalog();
112
+ const connected = new Set<string>();
113
+
114
+ try {
115
+ const authPath = join(homedir(), ".local", "share", "opencode", "auth.json");
116
+ const raw = readFileSync(authPath, "utf-8");
117
+ const auth = JSON.parse(raw);
118
+ for (const key of Object.keys(auth)) connected.add(key);
119
+ } catch {
120
+ /* auth file read failed */
121
+ }
122
+
123
+ for (const [pid, pdata] of Object.entries(catalog)) {
124
+ for (const envVar of pdata.env ?? []) {
125
+ if (process.env[envVar]) connected.add(pid);
126
+ }
127
+ }
128
+
129
+ const cfg = loadMergedConfig();
130
+ for (const p of Object.keys(cfg.provider ?? {})) connected.add(p);
131
+
132
+ for (const d of cfg.disabled_providers ?? []) connected.delete(d);
133
+
134
+ if (cfg.enabled_providers) {
135
+ const wl = new Set(cfg.enabled_providers);
136
+ for (const p of connected) {
137
+ if (!wl.has(p)) connected.delete(p);
138
+ }
139
+ }
140
+
141
+ return connected;
142
+ }
143
+
144
+ // :::: Tier resolution :::: ////////////////////////////////
145
+
146
+ export async function getModelTiers(): Promise<{
147
+ fast: ModelEntry[];
148
+ capable: ModelEntry[];
149
+ powerful: ModelEntry[];
150
+ }> {
151
+ const connected = await getConnectedProviders();
152
+ const catalog = await loadCatalog();
153
+ const cfg = loadMergedConfig();
154
+
155
+ const allCandidates: CatalogCandidate[] = [];
156
+ const entryMap = new Map<string, CatalogModel>();
157
+
158
+ for (const pid of connected) {
159
+ const catModels = catalog[pid]?.models ?? {};
160
+ const customModels =
161
+ (cfg.provider as Record<string, Record<string, unknown>>)?.[pid]?.models ?? {};
162
+ const merged = { ...catModels, ...customModels };
163
+
164
+ for (const [mid, m] of Object.entries(merged)) {
165
+ const model = m as CatalogModel;
166
+ const id = `${pid}/${mid}`;
167
+ allCandidates.push({
168
+ id,
169
+ context: model?.limit?.context ?? 0,
170
+ cost: (model?.cost?.input ?? 0) + (model?.cost?.output ?? 0),
171
+ toolCall: model?.tool_call ?? false,
172
+ });
173
+ entryMap.set(id, model);
174
+ }
175
+ }
176
+
177
+ const withTools = allCandidates.filter((m) => m.toolCall);
178
+ const pool = withTools.length > 0 ? withTools : allCandidates;
179
+ const byCost = [...pool].sort((a, b) => a.cost - b.cost);
180
+
181
+ const third = Math.max(1, Math.ceil(byCost.length / 3));
182
+ const fastEntries: ModelEntry[] = [];
183
+ const capableEntries: ModelEntry[] = [];
184
+ const powerfulEntries: ModelEntry[] = [];
185
+
186
+ for (let i = 0; i < byCost.length; i++) {
187
+ const c = byCost[i];
188
+ const model = entryMap.get(c.id);
189
+ const entry: ModelEntry = {
190
+ id: c.id,
191
+ providerID: c.id.split("/")[0],
192
+ modelID: c.id.split("/")[1],
193
+ tier: i < third ? "fast" : i < third * 2 ? "capable" : "powerful",
194
+ context: model?.limit?.context ?? 0,
195
+ cost: (model?.cost?.input ?? 0) + (model?.cost?.output ?? 0),
196
+ toolCall: model?.tool_call ?? false,
197
+ };
198
+
199
+ if (entry.tier === "fast") fastEntries.push(entry);
200
+ else if (entry.tier === "capable") capableEntries.push(entry);
201
+ else powerfulEntries.push(entry);
202
+ }
203
+
204
+ return {
205
+ fast: fastEntries,
206
+ capable: capableEntries,
207
+ powerful: powerfulEntries,
208
+ };
209
+ }
210
+
211
+ export async function resolveShortName(shortName: string): Promise<ModelRef | undefined> {
212
+ const tierName =
213
+ TIER_ALIASES[shortName] ?? (TIER_CYCLE.includes(shortName) ? shortName : undefined);
214
+ if (!tierName) return undefined;
215
+
216
+ const tiers = await getModelTiers();
217
+ const entries = tiers[tierName as "fast" | "capable" | "powerful"];
218
+ if (!entries || entries.length === 0) return undefined;
219
+
220
+ const entry = entries[0];
221
+ return { providerID: entry.providerID, modelID: entry.modelID };
222
+ }
223
+
224
+ export function resolveModel(
225
+ shortName: string,
226
+ defaultName: string,
227
+ ): Promise<ModelRef | undefined> {
228
+ if (shortName === defaultName) return Promise.resolve(undefined);
229
+
230
+ const slashIdx = shortName.indexOf("/");
231
+ if (slashIdx > 0) {
232
+ return Promise.resolve({
233
+ providerID: shortName.slice(0, slashIdx),
234
+ modelID: shortName.slice(slashIdx + 1),
235
+ });
236
+ }
237
+
238
+ return resolveShortName(shortName);
239
+ }
240
+
241
+ export function resolveTier(
242
+ value: string,
243
+ tiers: { fast: ModelEntry[]; capable: ModelEntry[]; powerful: ModelEntry[] } | null,
244
+ ): "fast" | "capable" | "powerful" | null {
245
+ if (TIER_ALIASES[value]) {
246
+ return TIER_ALIASES[value] as "fast" | "capable" | "powerful";
247
+ }
248
+ if (TIER_CYCLE.includes(value)) {
249
+ return value as "fast" | "capable" | "powerful";
250
+ }
251
+
252
+ if (tiers) {
253
+ for (const tier of TIER_CYCLE as ("fast" | "capable" | "powerful")[]) {
254
+ if (tiers[tier].some((e) => e.id === value)) return tier;
255
+ }
256
+ }
257
+
258
+ return null;
259
+ }
260
+
261
+ export function findModelEntry(
262
+ value: string,
263
+ tiers: { fast: ModelEntry[]; capable: ModelEntry[]; powerful: ModelEntry[] } | null,
264
+ ): ModelEntry | null {
265
+ if (!tiers) return null;
266
+ for (const tier of TIER_CYCLE as ("fast" | "capable" | "powerful")[]) {
267
+ const found = tiers[tier].find((e) => e.id === value);
268
+ if (found) return found;
269
+ }
270
+ return null;
271
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+
6
+ export const CONFIG_PATH = join(homedir(), ".config", "opencode", "better-prompt.local.md");
7
+
8
+ export const ConfigSchema = z.object({
9
+ enabled: z.boolean(),
10
+ correction: z.boolean(),
11
+ correction_model: z.string(),
12
+ translation: z.boolean(),
13
+ translation_model: z.string(),
14
+ enhancement: z.boolean(),
15
+ enhancement_model: z.string(),
16
+ audit: z.boolean(),
17
+ verbose: z.boolean(),
18
+ });
19
+
20
+ export type Config = z.infer<typeof ConfigSchema>;
21
+
22
+ export const CONFIG_DEFAULTS: Config = {
23
+ enabled: true,
24
+ correction: true,
25
+ correction_model: "haiku",
26
+ translation: false,
27
+ translation_model: "haiku",
28
+ enhancement: false,
29
+ enhancement_model: "sonnet",
30
+ audit: true,
31
+ verbose: false,
32
+ };
33
+
34
+ export const MODEL_FIELDS: ReadonlyArray<keyof Config> = [
35
+ "correction_model",
36
+ "translation_model",
37
+ "enhancement_model",
38
+ ] as const;
39
+
40
+ export function parseConfig(configPath: string): Config {
41
+ if (!existsSync(configPath)) return { ...CONFIG_DEFAULTS };
42
+
43
+ const raw = readFileSync(configPath, "utf8");
44
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
45
+ if (!fmMatch) return { ...CONFIG_DEFAULTS };
46
+
47
+ const fm = fmMatch[1];
48
+ const get = (key: string): string | undefined => {
49
+ const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
50
+ return m ? m[1].trim() : undefined;
51
+ };
52
+
53
+ const bool = (key: string, fallback: boolean): boolean => {
54
+ const v = get(key);
55
+ return v !== undefined ? v === "true" : fallback;
56
+ };
57
+
58
+ const str = (key: string, fallback: string): string => {
59
+ const v = get(key);
60
+ return v !== undefined ? v : fallback;
61
+ };
62
+
63
+ const built = {
64
+ enabled: bool("enabled", CONFIG_DEFAULTS.enabled),
65
+ correction: bool("correction", CONFIG_DEFAULTS.correction),
66
+ correction_model: str("correction_model", CONFIG_DEFAULTS.correction_model),
67
+ translation: bool("translation", CONFIG_DEFAULTS.translation),
68
+ translation_model: str("translation_model", CONFIG_DEFAULTS.translation_model),
69
+ enhancement: bool("enhancement", CONFIG_DEFAULTS.enhancement),
70
+ enhancement_model: str("enhancement_model", CONFIG_DEFAULTS.enhancement_model),
71
+ audit: bool("audit", CONFIG_DEFAULTS.audit),
72
+ verbose: bool("verbose", CONFIG_DEFAULTS.verbose),
73
+ };
74
+
75
+ return ConfigSchema.parse(built);
76
+ }
77
+
78
+ export function updateConfig(configPath: string, updates: Partial<Config>): void {
79
+ let raw = "";
80
+ if (existsSync(configPath)) {
81
+ raw = readFileSync(configPath, "utf8");
82
+ }
83
+
84
+ let fm = "";
85
+ let body = "";
86
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
87
+ if (fmMatch) {
88
+ fm = fmMatch[1];
89
+ body = fmMatch[2];
90
+ }
91
+
92
+ for (const [key, value] of Object.entries(updates)) {
93
+ if (value === undefined) continue;
94
+ const line = `${key}: ${value}`;
95
+ const regex = new RegExp(`^${key}: .+$`, "m");
96
+ if (regex.test(fm)) {
97
+ fm = fm.replace(regex, line);
98
+ } else {
99
+ fm += `\n${line}`;
100
+ }
101
+ }
102
+
103
+ writeFileSync(configPath, `---\n${fm}\n---\n${body}`);
104
+ }
@@ -0,0 +1,26 @@
1
+ export function formatCost(cost: number): string {
2
+ if (cost === 0) return "$0/M";
3
+ if (cost < 0.01) return `$${cost.toFixed(4)}/M`;
4
+ if (cost < 1) return `$${cost.toFixed(2)}/M`;
5
+ return `$${cost.toFixed(2)}/M`;
6
+ }
7
+
8
+ export function formatContext(ctx: number): string {
9
+ if (ctx >= 1_000_000) {
10
+ return `${(ctx / 1_000_000).toFixed(ctx % 1_000_000 === 0 ? 0 : 1)}M`;
11
+ }
12
+ if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`;
13
+ return String(ctx);
14
+ }
15
+
16
+ export function formatTokens(n: number): string {
17
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
18
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
19
+ return String(n);
20
+ }
21
+
22
+ export function formatDuration(ms: number | null): string {
23
+ if (ms === null) return "";
24
+ if (ms < 1000) return `${ms}ms`;
25
+ return `${(ms / 1000).toFixed(1)}s`;
26
+ }