@tiens.nguyen/gu-cli 1.0.783 → 1.0.785

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,484 @@
1
+ /**
2
+ * MODEL PRICES AND WHAT A TURN COST (#323).
3
+ *
4
+ * The user: "estimate cost of USD after each turn" — from each provider's price per 1 million INPUT
5
+ * tokens (output ignored for now, but stored so turning it on needs no migration), with the cheaper
6
+ * CACHED-input price for the part of each step the provider served from its prompt cache.
7
+ *
8
+ * HOW MUCH WAS CACHED. The agent reads the provider's own count when it reports one (OpenAI
9
+ * usage.prompt_tokens_details.cached_tokens, Anthropic cache_read_input_tokens, Kimi cached_tokens
10
+ * — see _cached_tokens_from_usage in gonext_agent_chat.py) and the cost then uses it: "$0.04".
11
+ * When nothing is reported, gu ASSUMES 90% of every step after the first was cached — what the
12
+ * user sees on the Kimi and Claude consoles for gu's turns — and says so: "~$0.04".
13
+ *
14
+ * Prices live in the user's own Mongo (one row per server + model, via the local API); this file is
15
+ * pure — the table it ships (PRICE_TABLE) is what an empty Mongo is seeded with, and what the Deploy
16
+ * app's "Prices" button exports for the admin to publish.
17
+ */
18
+
19
+ export const PRICE_TABLE_VERSION = "1.0";
20
+ /** Of a step after the first, the share assumed served from the cache when the provider says nothing. */
21
+ export const ASSUMED_CACHED_SHARE = 0.9;
22
+ export const MAX_PRICE_ROWS = 500;
23
+ const MAX_PRICE = 1000; // USD per 1M tokens — anything above is a typo (per-token, per-1k…)
24
+
25
+ const ANTHROPIC = "https://platform.claude.com/docs/en/about-claude/pricing";
26
+ const OPENAI = "https://developers.openai.com/api/docs/pricing";
27
+ const KIMI = "https://platform.kimi.ai/docs/pricing/chat";
28
+ const OLLAMA = "https://ollama.com/pricing";
29
+ const CHECKED = "2026-10-01";
30
+
31
+ /** [host, model, input, cachedInput, output, cacheWrite?] — USD per 1M tokens. */
32
+ const ROWS = [
33
+ // Anthropic — cache write (5 minutes) is 1.25× input; gu does not price writes yet (noted per row).
34
+ ["api.anthropic.com", "claude-fable-5-1", 10, 0.25, 50, 12.5],
35
+ ["api.anthropic.com", "claude-opus-5-5", 4, 0.2, 20, 5],
36
+ ["api.anthropic.com", "claude-opus-5", 5, 0.5, 25, 6.25],
37
+ ["api.anthropic.com", "claude-opus-4-8", 5, 0.5, 25, 6.25],
38
+ ["api.anthropic.com", "claude-sonnet-5-5", 2, 0.2, 10, 2.5],
39
+ ["api.anthropic.com", "claude-sonnet-5", 2, 0.2, 10, 2.5],
40
+ ["api.anthropic.com", "claude-sonnet-4-6", 3, 0.3, 15, 3.75],
41
+ ["api.anthropic.com", "claude-sonnet-4-5", 3, 0.3, 15, 3.75],
42
+ ["api.anthropic.com", "claude-haiku-4-5", 1, 0.1, 5, 1.25],
43
+ // OpenAI (standard tier)
44
+ ["api.openai.com", "gpt-6-astra", 10, 1, 50],
45
+ ["api.openai.com", "gpt-6.1-sol", 2, 0.1, 10],
46
+ ["api.openai.com", "gpt-6-sol", 2, 0.2, 10],
47
+ ["api.openai.com", "gpt-6-luna", 0.1, 0.01, 0.5],
48
+ ["api.openai.com", "gpt-5.6-sol", 4, 0.4, 20],
49
+ ["api.openai.com", "gpt-5.6-terra", 2, 0.2, 12],
50
+ ["api.openai.com", "gpt-5.6-luna", 0.2, 0.02, 1.2],
51
+ ["api.openai.com", "gpt-5.5", 5, 0.5, 30],
52
+ ["api.openai.com", "gpt-5.4", 2.5, 0.25, 15],
53
+ ["api.openai.com", "gpt-5.3-codex", 1.75, 0.175, 14],
54
+ ["api.openai.com", "gpt-5.2", 1.75, 0.175, 14],
55
+ ["api.openai.com", "gpt-5.1", 1.25, 0.125, 10],
56
+ ["api.openai.com", "gpt-5", 1.25, 0.125, 10],
57
+ ["api.openai.com", "gpt-4.1", 2, 0.5, 8],
58
+ ["api.openai.com", "gpt-4o", 2.5, 1.25, 10],
59
+ // Moonshot / Kimi
60
+ ["api.moonshot.ai", "kimi-k3", 3, 0.3, 15, 3], // cache write (5 min) = the input price
61
+ ["api.moonshot.ai", "kimi-k2.7-code", 0.95, 0.19, 4],
62
+ ["api.moonshot.ai", "kimi-k2.7-code-highspeed", 1.9, 0.38, 8],
63
+ ["api.moonshot.ai", "kimi-k2.6", 0.95, 0.16, 4],
64
+ // Ollama cloud — pay-as-you-go credits (peak price; off-peak is about half)
65
+ ["ollama.com", "kimi-k3", 3, 0.3, 15],
66
+ ["ollama.com", "kimi-k2.7-code", 0.95, 0.19, 4],
67
+ ["ollama.com", "kimi-k2.6", 0.95, 0.16, 4],
68
+ ["ollama.com", "glm-5.3", 1.4, 0.26, 4.4],
69
+ ["ollama.com", "glm-5.3-flash", 0.15, 0.03, 0.5],
70
+ ["ollama.com", "glm-5.2", 1.4, 0.26, 4.4],
71
+ ["ollama.com", "deepseek-v4-pro", 1.32, 0.044, 3.96],
72
+ ["ollama.com", "deepseek-v4.1-flash", 0.3, 0.006, 1.2],
73
+ ["ollama.com", "minimax-m3", 0.6, 0.12, 2.4],
74
+ ["ollama.com", "minimax-m2.7", 0.3, 0.06, 1.2],
75
+ ["ollama.com", "gpt-oss:120b", 0.15, 0.014, 0.6],
76
+ ["ollama.com", "gpt-oss:20b", 0.07, 0.035, 0.3],
77
+ ["ollama.com", "gemma4", 0.14, 0.05, 0.4],
78
+ ["ollama.com", "mistral-large-3", 0.5, null, 1.5],
79
+ ["ollama.com", "nemotron-3-nano", 0.06, null, 0.24],
80
+ ["ollama.com", "nemotron-3-super", 0.015, 0.015, 0.6],
81
+ ["ollama.com", "nemotron-3-ultra", 0.1, 0.1, 3],
82
+ ];
83
+ const PROVIDER = { "api.anthropic.com": "Anthropic", "api.openai.com": "OpenAI", "api.moonshot.ai": "Moonshot (Kimi)", "ollama.com": "Ollama cloud" };
84
+ const SOURCE = { "api.anthropic.com": ANTHROPIC, "api.openai.com": OPENAI, "api.moonshot.ai": KIMI, "ollama.com": OLLAMA };
85
+
86
+ /** The table gu ships — what an empty Mongo is seeded with and what the Deploy app exports. */
87
+ export const PRICE_TABLE = Object.freeze({
88
+ gu: "prices",
89
+ version: PRICE_TABLE_VERSION,
90
+ publishedAt: CHECKED,
91
+ currency: "USD",
92
+ unit: "per 1M tokens",
93
+ note: "input = uncached prompt tokens; cachedInput = prompt tokens served from the provider's cache; output is stored but not yet used. Anthropic cache writes (cacheWrite) are not priced yet. Ollama cloud: peak prices — off-peak is about half.",
94
+ prices: ROWS.map(([host, model, input, cachedInput, output, cacheWrite]) => ({
95
+ provider: PROVIDER[host],
96
+ host,
97
+ model,
98
+ input,
99
+ cachedInput,
100
+ output,
101
+ ...(cacheWrite != null ? { cacheWrite } : {}),
102
+ // Used only when the provider does not report its cached count: Kimi and OpenAI cache prompt
103
+ // prefixes automatically; Anthropic only when the request asks (gu's Claude path does); Ollama
104
+ // cloud lists cache prices. No cache price → no discount, so no share.
105
+ ...(cachedInput != null ? { assumedCacheShare: ASSUMED_CACHED_SHARE } : {}),
106
+ source: SOURCE[host],
107
+ checkedAt: CHECKED,
108
+ })),
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------------------------
112
+ // keys and lookup
113
+ // ---------------------------------------------------------------------------------------------
114
+
115
+ /** A server URL (or bare host) → its host: "https://api.moonshot.ai/v1/" → "api.moonshot.ai". */
116
+ export function hostOf(urlOrHost) {
117
+ const raw = String(urlOrHost ?? "").trim();
118
+ if (!raw) return "";
119
+ try {
120
+ return new URL(raw.includes("://") ? raw : `https://${raw}`).host.toLowerCase();
121
+ } catch {
122
+ return raw.replace(/^[a-z]+:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
123
+ }
124
+ }
125
+
126
+ /** One row's identity: host + model, lower-cased. */
127
+ export const priceKey = (host, model) => `${hostOf(host)}|${String(model ?? "").trim().toLowerCase()}`;
128
+
129
+ /**
130
+ * The price for a server + model, or null. Exact first; then without a dated suffix
131
+ * ("claude-haiku-4-5-20251001" → "claude-haiku-4-5"); then without an Ollama tag ("kimi-k3:cloud",
132
+ * "kimi-k3:latest" → "kimi-k3"). Never a different host — the same model costs differently there.
133
+ */
134
+ export function findPrice(rows, url, model) {
135
+ const host = hostOf(url);
136
+ const m = String(model ?? "").trim().toLowerCase();
137
+ if (!host || !m || !Array.isArray(rows)) return null;
138
+ const byKey = new Map(rows.map((r) => [priceKey(r.host, r.model), r]));
139
+ const tries = [m, m.replace(/-\d{8}$/, ""), m.replace(/:(cloud|latest)$/, "")];
140
+ for (const t of tries) {
141
+ const r = byKey.get(`${host}|${t}`);
142
+ if (r) return r;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------------------------
148
+ // what a turn cost
149
+ // ---------------------------------------------------------------------------------------------
150
+
151
+ /**
152
+ * The input cost of one turn.
153
+ * input prompt tokens sent, summed over every step of the turn
154
+ * cached prompt tokens the provider served from its cache (summed), when it reported them
155
+ * cacheReported whether the provider reported a cached count at all
156
+ * firstIn the first step's prompt tokens (nothing is cached on the first step of a turn)
157
+ * → { usd, estimated, cachedTokens } — or null when there is no price (never a misleading $0).
158
+ */
159
+ export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn = 0, price, assumedShare } = {}) {
160
+ if (!price || !Number.isFinite(price.input)) return null;
161
+ // The row's own assumption wins (providers cache differently), else the 90% default.
162
+ const share = Number.isFinite(assumedShare) ? assumedShare
163
+ : Number.isFinite(price.assumedCacheShare) ? price.assumedCacheShare
164
+ : ASSUMED_CACHED_SHARE;
165
+ const sent = Math.max(0, Number(input) || 0);
166
+ let cachedTokens;
167
+ let estimated = false;
168
+ if (cacheReported) {
169
+ cachedTokens = Math.min(sent, Math.max(0, Number(cached) || 0));
170
+ } else {
171
+ cachedTokens = Math.round(Math.min(1, Math.max(0, share)) * Math.max(0, sent - Math.max(0, Number(firstIn) || 0)));
172
+ estimated = cachedTokens > 0;
173
+ }
174
+ const cachedRate = Number.isFinite(price.cachedInput) ? price.cachedInput : price.input; // no cache price → no discount
175
+ const usd = ((sent - cachedTokens) * price.input + cachedTokens * cachedRate) / 1e6;
176
+ return { usd, estimated, cachedTokens };
177
+ }
178
+
179
+ /** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
180
+ export function formatUsd(usd, { estimated = false } = {}) {
181
+ if (!Number.isFinite(usd)) return "";
182
+ const v = Math.max(0, usd);
183
+ const s = v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0";
184
+ return `${estimated ? "~" : ""}$${s}`;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------------------------
188
+ // checking a price table (the published one, before it is applied)
189
+ // ---------------------------------------------------------------------------------------------
190
+
191
+ const ROW_FIELDS = new Set(["provider", "host", "url", "model", "input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "source", "checkedAt", "remove"]);
192
+ const price = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_PRICE);
193
+
194
+ /**
195
+ * Check a price table (the shape PRICE_TABLE has; a bare array of rows is accepted too).
196
+ * → { ok, version, rows, removes, warnings } | { ok: false, problem }.
197
+ * A row with "remove": true deletes that server + model — nothing is ever deleted otherwise.
198
+ */
199
+ export function validatePriceTable(input) {
200
+ let doc = input;
201
+ if (typeof input === "string") {
202
+ const src = input.replace(/^[ \t]*\/\/.*$/gm, "");
203
+ if (!src.trim()) return { ok: false, empty: true, problem: "nothing was pasted." };
204
+ try {
205
+ doc = JSON.parse(src);
206
+ } catch (e) {
207
+ return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
208
+ }
209
+ }
210
+ const list = Array.isArray(doc) ? doc : doc?.prices;
211
+ if (!Array.isArray(list)) return { ok: false, problem: 'expected { "prices": [ … ] } or a list of rows.' };
212
+ if (list.length === 0) return { ok: false, problem: "the table has no rows." };
213
+ if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
214
+ const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
215
+ const rows = [];
216
+ const removes = [];
217
+ const warnings = [];
218
+ const seen = new Set();
219
+ for (let i = 0; i < list.length; i++) {
220
+ const r = list[i];
221
+ const at = `row ${i + 1}`;
222
+ if (!r || typeof r !== "object" || Array.isArray(r)) return { ok: false, problem: `${at} is not an object.` };
223
+ const host = hostOf(r.host ?? r.url);
224
+ const model = typeof r.model === "string" ? r.model.trim() : "";
225
+ if (!host) return { ok: false, problem: `${at} needs "host" (or "url") — the server it is priced on.` };
226
+ if (!model || model.length > 120) return { ok: false, problem: `${at} needs "model" (at most 120 characters).` };
227
+ const key = priceKey(host, model);
228
+ if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
229
+ seen.add(key);
230
+ const extra = Object.keys(r).filter((k) => !ROW_FIELDS.has(k));
231
+ if (extra.length) warnings.push(`${at} (${model}): ignored ${extra.map((k) => `"${k}"`).join(", ")}`);
232
+ if (r.remove === true) {
233
+ removes.push({ host, model });
234
+ continue;
235
+ }
236
+ if (!(typeof r.input === "number" && price(r.input))) {
237
+ return { ok: false, problem: `${at} (${model}): "input" must be a number of USD per 1M tokens, 0 to ${MAX_PRICE}.` };
238
+ }
239
+ for (const f of ["cachedInput", "output", "cacheWrite"]) {
240
+ if (r[f] !== undefined && !price(r[f])) return { ok: false, problem: `${at} (${model}): "${f}" must be a number 0 to ${MAX_PRICE}, or null.` };
241
+ }
242
+ if (r.assumedCacheShare !== undefined && !(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
243
+ return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
244
+ }
245
+ if (typeof r.cachedInput === "number" && r.cachedInput > r.input) warnings.push(`${at} (${model}): cachedInput is MORE than input — check it`);
246
+ rows.push({
247
+ ...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
248
+ host,
249
+ model,
250
+ input: r.input,
251
+ cachedInput: r.cachedInput ?? null,
252
+ output: r.output ?? null,
253
+ ...(typeof r.cacheWrite === "number" ? { cacheWrite: r.cacheWrite } : {}),
254
+ ...(typeof r.assumedCacheShare === "number" ? { assumedCacheShare: r.assumedCacheShare } : {}),
255
+ ...(typeof r.source === "string" && /^https?:\/\//.test(r.source) ? { source: r.source.slice(0, 300) } : {}),
256
+ ...(typeof r.checkedAt === "string" && !Number.isNaN(Date.parse(r.checkedAt)) ? { checkedAt: r.checkedAt.slice(0, 30) } : {}),
257
+ });
258
+ }
259
+ return { ok: true, version, rows, removes, warnings };
260
+ }
261
+
262
+ const COMPARED = ["input", "cachedInput", "output", "cacheWrite", "assumedCacheShare", "provider"];
263
+
264
+ /** What applying `incoming` to `current` would do. → { added, changed:[{row, was, fields}], unchanged, removed, missingRemoves } */
265
+ export function diffPrices(current, incoming, removes = []) {
266
+ const now = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), r]));
267
+ const added = [];
268
+ const changed = [];
269
+ let unchanged = 0;
270
+ for (const r of incoming) {
271
+ const was = now.get(priceKey(r.host, r.model));
272
+ if (!was) {
273
+ added.push(r);
274
+ continue;
275
+ }
276
+ const fields = COMPARED.filter((f) => (r[f] ?? null) !== (was[f] ?? null) && !(f === "provider" && r[f] == null));
277
+ if (fields.length) changed.push({ row: r, was, fields });
278
+ else unchanged++;
279
+ }
280
+ const removed = removes.filter((x) => now.has(priceKey(x.host, x.model)));
281
+ const missingRemoves = removes.filter((x) => !now.has(priceKey(x.host, x.model)));
282
+ return { added, changed, unchanged, removed, missingRemoves };
283
+ }
284
+
285
+ /** The diff as lines, for the "apply? [y/N]" question. */
286
+ export function diffLines(d) {
287
+ const p = (v) => (v == null ? "—" : `$${v}`);
288
+ const out = [];
289
+ for (const r of d.added) out.push(`+ ${r.host} ${r.model} in ${p(r.input)} · cached ${p(r.cachedInput)}`);
290
+ const pct = (v) => (v == null ? "—" : `${Math.round(v * 100)}%`);
291
+ const show = (f, v) => (f === "assumedCacheShare" ? pct(v) : p(v));
292
+ for (const c of d.changed) out.push(`~ ${c.row.host} ${c.row.model} ${c.fields.map((f) => (f === "provider" ? `provider → ${c.row.provider}` : `${f} ${show(f, c.was[f])} → ${show(f, c.row[f])}`)).join(", ")}`);
293
+ for (const r of d.removed) out.push(`- ${r.host} ${r.model} (removed)`);
294
+ out.push(`${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged${d.removed.length ? ` · ${d.removed.length} removed` : ""}`);
295
+ return out;
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------------------------
299
+ // /price
300
+ // ---------------------------------------------------------------------------------------------
301
+
302
+ /**
303
+ * The table for /price, grouped by server, newest-first nowhere — alphabetical, so a model is easy
304
+ * to find. `current` = { url, model } of the coder in use: its row is marked "▸".
305
+ * Columns: model · input · cached · output (USD per 1M). Fits `width` (80 on a server over SSH).
306
+ */
307
+ export function priceTableLines(rows, { width = 80, current = null, version = "" } = {}) {
308
+ const cur = current ? findPrice(rows, current.url, current.model) : null;
309
+ const money = (v) => (v == null ? "—" : `$${Number(v) < 0.1 && v !== 0 ? Number(v).toString() : Number(v).toFixed(2)}`);
310
+ const numW = 9;
311
+ const modelW = Math.max(12, Math.min(34, width - 4 - 2 - numW * 3));
312
+ const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
313
+ const out = [`USD per 1M tokens${version ? ` · table v${version}` : ""}`];
314
+ out.push(` ${"model".padEnd(modelW)}${"input".padStart(numW)}${"cached".padStart(numW)}${"output".padStart(numW)}`);
315
+ const hosts = [...new Set(rows.map((r) => r.host))].sort();
316
+ for (const h of hosts) {
317
+ const group = rows.filter((r) => r.host === h).sort((a, b) => a.model.localeCompare(b.model));
318
+ out.push(`${h}${group[0]?.provider ? ` (${group[0].provider})` : ""}`);
319
+ for (const r of group) {
320
+ out.push(`${r === cur ? "▸ " : " "}${cut(r.model, modelW)}${money(r.input).padStart(numW)}${money(r.cachedInput).padStart(numW)}${money(r.output).padStart(numW)}`);
321
+ }
322
+ }
323
+ return out;
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------------------------
327
+ // THE ASSUMED-CACHE-SHARE TABLE (#323 follow-up) — its own table so the admin can review and
328
+ // publish it on its own (Deploy app → "Cache shares" → Update to API), and users can read it with
329
+ // /prices. Made FROM the price table (the rows that have a cache price), so the two cannot
330
+ // disagree.
331
+ // ---------------------------------------------------------------------------------------------
332
+
333
+ /** Why each provider's share is what it is — shown beside the number, so it can be reviewed. */
334
+ export const CACHE_SHARE_WHY = Object.freeze({
335
+ "api.moonshot.ai": "automatic prefix caching (Kimi reports cached_tokens)",
336
+ "api.openai.com": "automatic prefix caching for prompts ≥ 1024 tokens (reports cached_tokens)",
337
+ "api.anthropic.com": "cached only when the request asks — gu's Claude path does (reports cache reads)",
338
+ "ollama.com": "lists cache prices; whether it reports a count is not yet verified",
339
+ });
340
+
341
+ export const CACHE_SHARE_TABLE = Object.freeze({
342
+ gu: "cache-shares",
343
+ version: PRICE_TABLE_VERSION,
344
+ publishedAt: PRICE_TABLE.publishedAt,
345
+ note: "The share of a step's prompt (after a turn's first step) assumed served from the provider's cache, used ONLY when the provider does not report its own cached count. 0.9 = 90%.",
346
+ shares: PRICE_TABLE.prices
347
+ .filter((r) => r.cachedInput != null)
348
+ .map((r) => ({ provider: r.provider, host: r.host, model: r.model, assumedCacheShare: r.assumedCacheShare ?? ASSUMED_CACHED_SHARE, why: CACHE_SHARE_WHY[r.host] ?? "" })),
349
+ });
350
+
351
+ /** Check a cache-share table → { ok, version, shares } | { ok: false, problem }. */
352
+ export function validateCacheShareTable(input) {
353
+ let doc = input;
354
+ if (typeof input === "string") {
355
+ try {
356
+ doc = JSON.parse(input.replace(/^[ \t]*\/\/.*$/gm, ""));
357
+ } catch (e) {
358
+ return { ok: false, problem: `that is not valid JSON — ${String(e?.message ?? e).replace(/\s*in JSON at position \d+.*$/, "")}.` };
359
+ }
360
+ }
361
+ const list = Array.isArray(doc) ? doc : doc?.shares;
362
+ if (!Array.isArray(list) || list.length === 0) return { ok: false, problem: 'expected { "shares": [ … ] } with at least one row.' };
363
+ if (list.length > MAX_PRICE_ROWS) return { ok: false, problem: `${list.length} rows — at most ${MAX_PRICE_ROWS}.` };
364
+ const seen = new Set();
365
+ const shares = [];
366
+ for (const [i, r] of list.entries()) {
367
+ const at = `row ${i + 1}`;
368
+ const host = hostOf(r?.host ?? r?.url);
369
+ const model = typeof r?.model === "string" ? r.model.trim() : "";
370
+ if (!host || !model) return { ok: false, problem: `${at} needs "host" and "model".` };
371
+ if (!(typeof r.assumedCacheShare === "number" && r.assumedCacheShare >= 0 && r.assumedCacheShare <= 1)) {
372
+ return { ok: false, problem: `${at} (${model}): "assumedCacheShare" must be a number from 0 to 1 (0.9 = 90%).` };
373
+ }
374
+ const key = priceKey(host, model);
375
+ if (seen.has(key)) return { ok: false, problem: `${at}: ${host} ${model} appears twice.` };
376
+ seen.add(key);
377
+ shares.push({
378
+ ...(typeof r.provider === "string" && r.provider.trim() ? { provider: r.provider.trim().slice(0, 60) } : {}),
379
+ host,
380
+ model,
381
+ assumedCacheShare: r.assumedCacheShare,
382
+ ...(typeof r.why === "string" && r.why.trim() ? { why: r.why.trim().slice(0, 200) } : {}),
383
+ });
384
+ }
385
+ const version = !Array.isArray(doc) && doc.version != null ? String(doc.version).slice(0, 40) : "";
386
+ return { ok: true, version, shares };
387
+ }
388
+
389
+ /**
390
+ * The cache table in /prices: the share each model is assumed to cache — THIS ACCOUNT's (its price rows, what its
391
+ * estimates use) beside the one gu PUBLISHED (when the API has one), marking where they differ.
392
+ * Fits `width`; the coder in use is marked "▸".
393
+ */
394
+ export function cacheShareLines({ yours = [], published = null, width = 80, current = null } = {}) {
395
+ const pub = new Map((published?.shares ?? []).map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
396
+ const mine = (yours ?? []).filter((r) => r.cachedInput != null || pub.has(priceKey(r.host, r.model)));
397
+ const cur = current ? findPrice(mine, current.url, current.model) : null;
398
+ const pct = (v) => (Number.isFinite(v) ? `${Math.round(v * 100)}%` : "—");
399
+ const colW = 8;
400
+ const modelW = Math.max(12, Math.min(34, width - 4 - 2 - colW * 2 - 2));
401
+ const cut = (s, w) => (s.length > w ? s.slice(0, w - 1) + "…" : s.padEnd(w));
402
+ const out = [];
403
+ out.push(` ${"model".padEnd(modelW)}${"yours".padStart(colW)}${published ? "gu".padStart(colW) : ""}`);
404
+ const keys = new Set([...mine.map((r) => priceKey(r.host, r.model)), ...pub.keys()]);
405
+ const rows = [...keys].map((k) => {
406
+ const r = mine.find((x) => priceKey(x.host, x.model) === k);
407
+ const s = published?.shares?.find((x) => priceKey(x.host, x.model) === k);
408
+ return { host: r?.host ?? s.host, model: r?.model ?? s.model, yours: r ? (Number.isFinite(r.assumedCacheShare) ? r.assumedCacheShare : ASSUMED_CACHED_SHARE) : undefined, gu: pub.get(k), row: r };
409
+ });
410
+ for (const h of [...new Set(rows.map((r) => r.host))].sort()) {
411
+ out.push(h);
412
+ for (const r of rows.filter((x) => x.host === h).sort((a, b) => a.model.localeCompare(b.model))) {
413
+ const differs = published && r.gu !== undefined && r.yours !== undefined && Math.abs(r.gu - r.yours) > 1e-9;
414
+ out.push(`${r.row && r.row === cur ? "▸ " : " "}${cut(r.model, modelW)}${pct(r.yours).padStart(colW)}${published ? pct(r.gu).padStart(colW) : ""}${differs ? " *" : ""}`);
415
+ }
416
+ }
417
+ return out;
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------------------------
421
+ // "NEW PRICES UPDATED <date>" (#323): gu checks the hosted API's GET /api/get-price-and-cache-table
422
+ // (the newest tables the admin published from the Deploy app); when they are newer than what this
423
+ // account last applied, the notice row offers them, and a click writes both into the user's Mongo.
424
+ // ---------------------------------------------------------------------------------------------
425
+
426
+ /** Is the published stamp newer than the one this account applied? ("" applied = never.) */
427
+ export function hasNewerPublished(appliedAt, publishedAt) {
428
+ const p = Date.parse(publishedAt ?? "");
429
+ if (!Number.isFinite(p)) return false;
430
+ const a = Date.parse(appliedAt ?? "");
431
+ return !Number.isFinite(a) || p > a;
432
+ }
433
+
434
+ /** The notice: "new prices updated 1 Oct 2026". */
435
+ export function priceUpdateLabel(publishedAt) {
436
+ const d = new Date(publishedAt);
437
+ if (Number.isNaN(d.getTime())) return "new prices updated";
438
+ const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
439
+ return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
440
+ }
441
+
442
+ /**
443
+ * The published tables onto this account's rows → what to write.
444
+ * · the published PRICE table's rows replace the same server + model here (others are kept —
445
+ * a self-hosted server priced by hand stays);
446
+ * · the published CACHE table's share is set on each matching row (published price rows and the
447
+ * account's own);
448
+ * · nothing is deleted.
449
+ * → { ok, rows: the rows that change (to PUT), diff, version, publishedAt } | { ok: false, problem }
450
+ */
451
+ export function mergePublishedTables(current, cloud) {
452
+ const pub = cloud ?? {};
453
+ const priceRows = [];
454
+ if (pub.prices) {
455
+ const c = validatePriceTable(pub.prices);
456
+ if (!c.ok) return { ok: false, problem: `the published price table is not usable — ${c.problem}` };
457
+ priceRows.push(...c.rows);
458
+ }
459
+ let shares = [];
460
+ if (pub.cacheShares) {
461
+ const c = validateCacheShareTable(pub.cacheShares);
462
+ if (!c.ok) return { ok: false, problem: `the published cache table is not usable — ${c.problem}` };
463
+ shares = c.shares;
464
+ }
465
+ if (!priceRows.length && !shares.length) return { ok: false, problem: "nothing has been published yet." };
466
+ const share = new Map(shares.map((s) => [priceKey(s.host, s.model), s.assumedCacheShare]));
467
+ const byKey = new Map((current ?? []).map((r) => [priceKey(r.host, r.model), { ...r }]));
468
+ for (const r of priceRows) {
469
+ const was = byKey.get(priceKey(r.host, r.model));
470
+ // A published price row without its own share keeps the one this account has, until the cache
471
+ // table says otherwise.
472
+ byKey.set(priceKey(r.host, r.model), { ...r, ...(r.assumedCacheShare == null && was?.assumedCacheShare != null ? { assumedCacheShare: was.assumedCacheShare } : {}) });
473
+ }
474
+ for (const [k, row] of byKey) if (share.has(k) && row.cachedInput != null) row.assumedCacheShare = share.get(k);
475
+ const merged = [...byKey.values()];
476
+ const diff = diffPrices(current ?? [], merged);
477
+ return {
478
+ ok: true,
479
+ rows: [...diff.added, ...diff.changed.map((c) => c.row)],
480
+ diff,
481
+ version: pub.prices?.version || pub.cacheShares?.version || "",
482
+ publishedAt: pub.publishedAt || [pub.prices?.publishedAt, pub.cacheShares?.publishedAt].filter(Boolean).sort().at(-1) || "",
483
+ };
484
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.783",
3
+ "version": "1.0.785",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "check:image": "node scripts/image-paste-check.mjs",
42
42
  "check:image-clipboard": "node scripts/image-paste-check.mjs --clipboard",
43
43
  "test:presets": "node --test tests/open-presets.test.mjs tests/preset-safety.test.mjs tests/preset-more.test.mjs tests/preset-apply.test.mjs tests/preset-full.test.mjs tests/presets-transfer.test.mjs tests/preset-editor.test.mjs tests/load-preset-paste.test.mjs",
44
- "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
44
+ "test:skills": "python3 scripts/py-tap.py tests/test_skills_mcp_turn.py tests/test_hooks_turn.py tests/test_claude_hooks.py tests/test_mcp_client.py && node --test tests/skills.test.mjs tests/mcp-config.test.mjs tests/hooks-config.test.mjs tests/switch-skill.test.mjs tests/slash-menu.test.mjs tests/conversation-archive.test.mjs",
45
45
  "capture:turn": "GONEXT_OLLAMA1_LIVE=1 node scripts/capture-turn-fixture.mjs",
46
46
  "test:bearer": "node --test tests/bearer-routing.test.mjs tests/bearer-verify.test.mjs",
47
47
  "test:bearer-e2e": "bash scripts/bearer-e2e.sh"
@@ -69,6 +69,7 @@
69
69
  "image-capable-coders.json",
70
70
  "preset-editor.mjs",
71
71
  "preset-apply.mjs",
72
+ "model-prices.mjs",
72
73
  "device-login.mjs",
73
74
  "daemon-control.mjs",
74
75
  "log-policy.mjs",
@@ -108,6 +109,7 @@
108
109
  "powershell-alias.mjs",
109
110
  "install-path.mjs",
110
111
  "skills.mjs",
112
+ "skill-source.mjs",
111
113
  "mcp-config.mjs",
112
114
  "mcp-command.mjs",
113
115
  "hooks-config.mjs",
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WHOSE SKILLS THIS FOLDER USES — gu's or Claude Code's (task #325, /switch-skill).
3
+ *
4
+ * "gu" ~/.gonext and <project>/.gonext (the default; a folder that never chose)
5
+ * "claude" ~/.claude and <project>/.claude Claude Code's own files, read where they are
6
+ *
7
+ * One choice covers skills, MCP servers and hooks, because they depend on each other: a skill names
8
+ * an MCP server's tools, and the hooks feed the same database that server reads. Switching one of
9
+ * the three alone would leave them talking about different things.
10
+ *
11
+ * ONE-WAY. gu reads Claude Code's setup; Claude Code is never expected to read gu's. So nothing of
12
+ * gu's own is written into Claude's files: which project servers and hooks you approved, and the
13
+ * allow rules, stay in ~/.gonext whichever source is chosen.
14
+ *
15
+ * PER FOLDER, IN THE FOLDER'S SESSION FILE. The terminal writes it; the worker — a different
16
+ * process, which builds each turn's skills, servers and hooks — reads the same file. Every reader
17
+ * in this package resolves the source through readSkillSource, so the banner and the turn cannot
18
+ * disagree about which setup is in use.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export const SKILL_SOURCES = ["gu", "claude"];
26
+ export const DEFAULT_SKILL_SOURCE = "gu";
27
+ /** The session file's key. */
28
+ export const SKILL_SOURCE_KEY = "skillSource";
29
+
30
+ export const normalizeSkillSource = (v) => (v === "claude" ? "claude" : DEFAULT_SKILL_SOURCE);
31
+
32
+ /** The folder each source keeps its files in — under the home directory and under the project. */
33
+ export const sourceDirName = (source) => (normalizeSkillSource(source) === "claude" ? ".claude" : ".gonext");
34
+
35
+ /** What a picker and a status line call it. */
36
+ export const sourceLabel = (source) => (normalizeSkillSource(source) === "claude" ? "Claude's skills" : "gu's skills");
37
+
38
+ /** The terminal's per-folder session file — the same name gu-repl.mjs uses. */
39
+ export function sessionFileFor(cwd, home = homedir()) {
40
+ const hash = createHash("sha256").update(String(cwd)).digest("hex").slice(0, 32);
41
+ return join(home, ".gonext", "sessions", `${hash}.json`);
42
+ }
43
+
44
+ /**
45
+ * This folder's choice. No folder, no session file, a file that cannot be read, any other value:
46
+ * gu's — the behaviour before there was a choice, and never an error.
47
+ */
48
+ export function readSkillSource(cwd, home = homedir()) {
49
+ if (!cwd) return DEFAULT_SKILL_SOURCE;
50
+ try {
51
+ const raw = JSON.parse(readFileSync(sessionFileFor(cwd, home), "utf8"));
52
+ return normalizeSkillSource(raw?.[SKILL_SOURCE_KEY]);
53
+ } catch {
54
+ return DEFAULT_SKILL_SOURCE;
55
+ }
56
+ }
57
+
58
+ /** `source` when a caller names one, else the folder's own. */
59
+ export const resolveSkillSource = ({ source, cwd, home = homedir() } = {}) =>
60
+ source ? normalizeSkillSource(source) : readSkillSource(cwd, home);
package/skills.mjs CHANGED
@@ -4,6 +4,10 @@
4
4
  * ~/.gonext/skills/<name>/SKILL.md yours, every folder
5
5
  * <project>/.gonext/skills/<name>/SKILL.md this project only
6
6
  *
7
+ * …or Claude Code's own, when the folder chose them with /switch-skill (#325, skill-source.mjs):
8
+ * ~/.claude/skills/<name>/SKILL.md and <project>/.claude/skills/<name>/SKILL.md
9
+ * The same file in the same layout — only the folder differs, so nothing is converted.
10
+ *
7
11
  * A SKILL.md is YAML frontmatter (name, description) and a body of instructions. Only the name and
8
12
  * description go into every prompt; the body is read when the agent calls use_skill(name) or the
9
13
  * user types /<name> — never pasted into every turn. Same split as Claude Code, and for the same
@@ -24,16 +28,17 @@
24
28
  import { readdirSync, readFileSync, statSync } from "node:fs";
25
29
  import { homedir } from "node:os";
26
30
  import { join } from "node:path";
31
+ import { resolveSkillSource, sourceDirName } from "./skill-source.mjs";
27
32
 
28
33
  export const SKILL_FILE = "SKILL.md";
29
34
  /** Claude Code's rule: lowercase letters, digits and hyphens, at most 64. It is also a slash command. */
30
35
  export const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
31
36
 
32
- export function userSkillsDir(home = homedir()) {
33
- return join(home, ".gonext", "skills");
37
+ export function userSkillsDir(home = homedir(), source = "gu") {
38
+ return join(home, sourceDirName(source), "skills");
34
39
  }
35
- export function projectSkillsDir(cwd) {
36
- return join(cwd, ".gonext", "skills");
40
+ export function projectSkillsDir(cwd, source = "gu") {
41
+ return join(cwd, sourceDirName(source), "skills");
37
42
  }
38
43
 
39
44
  /** Strip one pair of matching quotes. */
@@ -138,13 +143,17 @@ function scanDir(dir, scope) {
138
143
  * the built-in wins, and the skill is still usable by the agent, just not as a slash command —
139
144
  * that is `slash: false`.
140
145
  *
141
- * → { skills: [{ name, description, path, dir, scope, slash }], warnings: string[] }
146
+ * `source` = whose skills ("gu" | "claude"); left out, it is the folder's own choice (/switch-skill).
147
+ * Only that source's skills are listed — the two are never mixed.
148
+ *
149
+ * → { skills: [{ name, description, path, dir, scope, slash, source }], warnings: string[], source }
142
150
  */
143
- export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [] } = {}) {
144
- const user = scanDir(userSkillsDir(home), "user");
151
+ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved = [], source } = {}) {
152
+ const src = resolveSkillSource({ source, cwd, home });
153
+ const user = scanDir(userSkillsDir(home, src), "user");
145
154
  const project =
146
- cwd && projectSkillsDir(cwd) !== userSkillsDir(home)
147
- ? scanDir(projectSkillsDir(cwd), "project")
155
+ cwd && projectSkillsDir(cwd, src) !== userSkillsDir(home, src)
156
+ ? scanDir(projectSkillsDir(cwd, src), "project")
148
157
  : { skills: [], warnings: [] };
149
158
  const byName = new Map();
150
159
  for (const s of user.skills) byName.set(s.name, s);
@@ -152,17 +161,20 @@ export function discoverSkills({ cwd = process.cwd(), home = homedir(), reserved
152
161
  const taken = new Set(reserved.map((r) => r.replace(/^\//, "")));
153
162
  const skills = [...byName.values()]
154
163
  .sort((a, b) => a.name.localeCompare(b.name))
155
- .map((s) => ({ ...s, slash: !taken.has(s.name) }));
156
- return { skills, warnings: [...user.warnings, ...project.warnings] };
164
+ .map((s) => ({ ...s, slash: !taken.has(s.name), source: src }));
165
+ return { skills, warnings: [...user.warnings, ...project.warnings], source: src };
157
166
  }
158
167
 
159
168
  /** What the worker hands the agent for one turn: enough to list them and to read one. */
160
169
  export function skillsForTurn(opts) {
161
- return discoverSkills(opts).skills.map(({ name, description, path, dir }) => ({
170
+ // `source` goes along only for Claude's: the agent then reads the skill as one written for
171
+ // Claude Code's tools (gu_skills.py). gu's own keep the shape they always had.
172
+ return discoverSkills(opts).skills.map(({ name, description, path, dir, source }) => ({
162
173
  name,
163
174
  description,
164
175
  path,
165
176
  dir,
177
+ ...(source === "claude" ? { source } : {}),
166
178
  }));
167
179
  }
168
180