@tiens.nguyen/gu-cli 1.0.783 → 1.0.784

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/gu-cli.mjs CHANGED
@@ -20,7 +20,7 @@ import { execFile as execFileCallback, spawn } from "node:child_process";
20
20
  import { createHash, randomUUID } from "node:crypto";
21
21
  import { writeFileSync, unlinkSync } from "node:fs";
22
22
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
23
- import { NO_STEP, foldStepTokens, withStepTokens } from "./step-tokens.mjs";
23
+ import { NO_STEP, foldStepTokens, withStepTokens, NO_CACHE, foldTurnCache, withTurnCache } from "./step-tokens.mjs";
24
24
  import { agentStoppedLine, jobEnded, jobStatusFrom409, stopGracefully } from "./agent-stop.mjs";
25
25
  import { ragJobConfig } from "./rag-job-config.mjs";
26
26
  import { codingKeyFallbackLine, isOllamaCloudUrl } from "./coding-key-prompt.mjs";
@@ -4138,6 +4138,9 @@ async function runAgentChatJob(job) {
4138
4138
  // Task #298: the CURRENT model call's own ↑ in / ↓ out, for the thinking line (the bar
4139
4139
  // already shows the totals). Rides on the breakdown — see step-tokens.mjs.
4140
4140
  let stepTokens = NO_STEP;
4141
+ // #323: the turn's cached prompt tokens (when the provider reports them) and its first step's
4142
+ // prompt — what the terminal needs to say what the turn COST. Rides on the breakdown too.
4143
+ let turnCache = NO_CACHE;
4141
4144
  // Raw model-stream tokens (event.type === "stream") are Python code + `Thought:`
4142
4145
  // prose + <code>/<|"|> fragments. The web Thought panel renders reasoning as
4143
4146
  // markdown, which turns `#`-prefixed code comments into <h1> headings and
@@ -4305,17 +4308,20 @@ async function runAgentChatJob(job) {
4305
4308
  if (breakdown) latestPromptBreakdown = breakdown;
4306
4309
  const prevStep = stepTokens;
4307
4310
  stepTokens = foldStepTokens(stepTokens, event);
4311
+ const prevCache = turnCache;
4312
+ turnCache = foldTurnCache(turnCache, event);
4313
+ const cacheChanged = turnCache.cached !== prevCache.cached || turnCache.firstIn !== prevCache.firstIn || turnCache.cacheReported !== prevCache.cacheReported;
4308
4314
  const stepChanged =
4309
4315
  stepTokens.step !== prevStep.step ||
4310
4316
  stepTokens.stepIn !== prevStep.stepIn ||
4311
4317
  stepTokens.stepOut !== prevStep.stepOut;
4312
- if (latestPromptBreakdown) latestPromptBreakdown = withStepTokens(latestPromptBreakdown, stepTokens);
4318
+ if (latestPromptBreakdown) latestPromptBreakdown = withTurnCache(withStepTokens(latestPromptBreakdown, stepTokens), turnCache);
4313
4319
  if (
4314
4320
  nextTotal !== latestCodeTokens ||
4315
4321
  nextMax !== latestMaxCodeTokens ||
4316
4322
  nextOut !== latestCodeOutputTokens ||
4317
4323
  breakdown ||
4318
- (stepChanged && latestPromptBreakdown)
4324
+ ((stepChanged || cacheChanged) && latestPromptBreakdown)
4319
4325
  ) {
4320
4326
  latestCodeTokens = nextTotal;
4321
4327
  latestMaxCodeTokens = nextMax;
package/gu-repl.mjs CHANGED
@@ -23,9 +23,11 @@
23
23
  * Requires the worker daemon to be running (it claims the job).
24
24
  * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
+ import { appendFileSync as __afs } from "node:fs";
27
+ const __dbg = (...a) => { try { __afs("/private/tmp/claude-501/-Users-joseph-Projects-gonext/bf6ac2f6-435f-4150-a01e-cea31d8ff4d2/scratchpad/r321/dbg.log", a.map((x) => JSON.stringify(x)).join(" ") + "\n"); } catch {} };
26
28
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { readStepTokens } from "./step-tokens.mjs";
28
- import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
29
+ import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
30
+ import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
29
31
  import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync, mkdirSync } from "node:fs";
30
32
  import { spawn } from "node:child_process";
31
33
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
@@ -66,6 +68,7 @@ import {
66
68
  formatThinkTime,
67
69
  plainAnswer,
68
70
  summarizeError,
71
+ tokenRowWithPrice,
69
72
  visibleLen,
70
73
  createProbeReplyReader,
71
74
  agentPickerItems,
@@ -179,6 +182,8 @@ import {
179
182
  viewportSlice,
180
183
  paintViewportSeq,
181
184
  jumpToBottomRow,
185
+ zoneResizePlan,
186
+ blockClosedPad,
182
187
  } from "./terminal-viewport.mjs";
183
188
 
184
189
  const DIR = dirname(fileURLToPath(import.meta.url));
@@ -886,6 +891,7 @@ const COMMANDS = [
886
891
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
887
892
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
888
893
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
894
+ { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
889
895
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
890
896
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
891
897
  { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
@@ -998,6 +1004,7 @@ const OUT_RESTORE = "\x1b8";
998
1004
  const OUT_SAVE = "\x1b7";
999
1005
  /** Draw in-place UI that lives in the SCROLLING area (status block, pickers, menus). */
1000
1006
  const regionUi = (seq) => {
1007
+ if (/\n/.test(seq)) __dbg("regionUi with newline", { nl: (seq.match(/\n/g) || []).length, head: seq.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").slice(0, 60), at: new Error().stack.split("\n").slice(2, 5).map((x) => x.trim().slice(0, 80)) });
1001
1008
  if (!barPinned()) {
1002
1009
  ui(seq);
1003
1010
  return;
@@ -1450,6 +1457,11 @@ function applyClick({ row, col }) {
1450
1457
  void runUpdate();
1451
1458
  return;
1452
1459
  }
1460
+ if (pricesRowTarget && row === updateRowAbs &&
1461
+ col - 1 >= pricesRowTarget.from && col - 1 < pricesRowTarget.to) {
1462
+ void runPricesUpdate();
1463
+ return;
1464
+ }
1453
1465
  if (agentsRowTarget && row === updateRowAbs &&
1454
1466
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1455
1467
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -1561,7 +1573,8 @@ const pasteStore = new Map(); // token -> the real text
1561
1573
  // model, so a turn stopped with Ctrl+C can give the question back for editing.
1562
1574
  let lastSubmittedInput = null;
1563
1575
  // Task #302: set while the input holds a stopped turn's question; the next line sent from it
1564
- // REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
1576
+ // REPLACES that question in history when it was edited. Cleared by "never mind": Ctrl+C, or the
1577
+ // line emptied any other way (restoredQuestionLetGo) — what is typed next is a new message.
1565
1578
  let prefillFor = null;
1566
1579
  // Task #302: the backup run of the last stopped turn — what /undo reverts by default.
1567
1580
  let lastStoppedRun = null;
@@ -2454,6 +2467,12 @@ const identityLineText = () =>
2454
2467
  { orange, cyan: accent, dim, red },
2455
2468
  );
2456
2469
 
2470
+ /** "$0.334" / "~$0.334" (same rules as model-prices.mjs formatUsd, kept sync for the bar). */
2471
+ const fmtCost = (c) => {
2472
+ const v = Math.max(0, c.usd);
2473
+ return `${c.estimated ? "~" : ""}$${v >= 1 ? v.toFixed(2) : v >= 0.01 ? v.toFixed(3) : v > 0 ? v.toFixed(4) : "0"}`;
2474
+ };
2475
+
2457
2476
  const tokenLineText = () => {
2458
2477
  // TWO different numbers, each labelled, because they are routinely off by 6×:
2459
2478
  // ctx = the NEWEST request — what the model is holding right now, the one to compare
@@ -2468,7 +2487,7 @@ const tokenLineText = () => {
2468
2487
  // a terminal — it was inline here, unasserted, and wrong for months. Both figures are TRUE
2469
2488
  // measurements: nothing is netted against a floor any more (see that function's comment).
2470
2489
  const { ctx, turn: sent } = tokenRowFigures({ ctxTrue, turnTrue: lastTurnInputTokens });
2471
- return (
2490
+ const head = (
2472
2491
  // IDENTITY IS NOT ON THIS ROW ANY MORE — it has its own (identityLineText), because five
2473
2492
  // measurements plus a coder and a folder name was ~110 columns and the clip falls on the
2474
2493
  // RIGHT, so the totals were the part being cut on any normal window.
@@ -2488,11 +2507,18 @@ const tokenLineText = () => {
2488
2507
  // `ctx` and `sent`, which do drive decisions, need more.
2489
2508
  ragSegmentText() +
2490
2509
  dim(" · ") +
2491
- accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`) +
2492
- dim(" · ") +
2493
- dim("total ") +
2494
- accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`)
2510
+ accent(`↓ out ${fmtTokens(lastTurnOutputTokens)}`)
2495
2511
  );
2512
+ const total = dim(" · ") + dim("total ") + accent(`↓ ${fmtTokens(Math.max(globalOutputTokens, lastTurnTotalOutput))}`);
2513
+ // #323: what the last turn's prompt cost, LAST on the row after a "|" — "~" when the cached share
2514
+ // was assumed; nothing when the model has no price (never a misleading "| $0"). It survives a
2515
+ // narrow window: "total ↓" is dropped before it is (tokenRowWithPrice).
2516
+ return tokenRowWithPrice({
2517
+ withTotal: head + total,
2518
+ withoutTotal: head,
2519
+ price: lastTurnCost?.usd != null ? dim(" | ") + accent(fmtCost(lastTurnCost)) : "",
2520
+ room: (process.stdout.columns || 80) - 1 - visibleLen(GUTTER),
2521
+ });
2496
2522
  };
2497
2523
  // The newest RAG index file the agent reported writing (task #136), and where it lives.
2498
2524
  // Empty until something is written; only rendered under --verbose.
@@ -2613,6 +2639,14 @@ function syncBarPinning() {
2613
2639
  * update), a standing fact about the licence (the gu token notice — yellow only when urgent), and
2614
2640
  * other agents. Dropped whole, lowest first, when they do not all fit.
2615
2641
  */
2642
+ /** The notice's text (sync — zoneNotice runs on every paint): "new prices updated 1 Oct 2026". */
2643
+ function priceUpdateText() {
2644
+ const d = new Date(priceUpdate?.publishedAt ?? "");
2645
+ if (Number.isNaN(d.getTime())) return "new prices updated";
2646
+ const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getUTCMonth()];
2647
+ return `new prices updated ${d.getUTCDate()} ${m} ${d.getUTCFullYear()}`;
2648
+ }
2649
+
2616
2650
  function zoneNotice(width) {
2617
2651
  const items = [];
2618
2652
  if (scrolledBack()) {
@@ -2623,6 +2657,7 @@ function zoneNotice(width) {
2623
2657
  const up = updateRow({ ...availableUpdates, width: 400 });
2624
2658
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2625
2659
  }
2660
+ if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
2626
2661
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2627
2662
  const agents = agentsIndicator(otherAgents);
2628
2663
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
@@ -2705,6 +2740,7 @@ function frozenZone() {
2705
2740
  const sep = status.length + (notice ? 1 : 0);
2706
2741
  updateRowTarget = notice?.spans.update ?? null;
2707
2742
  agentsRowTarget = notice?.spans.agents ?? null;
2743
+ pricesRowTarget = notice?.spans.prices ?? null;
2708
2744
  jumpSpan = notice?.spans.jump ?? null;
2709
2745
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2710
2746
  }
@@ -2725,11 +2761,39 @@ function alignOutputToLineStart() {
2725
2761
  function paintBar() {
2726
2762
  if (!barPinned()) return;
2727
2763
  const zone = frozenZone();
2728
- // The zone grew or shrank (the --verbose RAG row appearing) → re-cut the region before
2729
- // painting, or its top rows would land inside the scrolling area.
2730
- const regrew = zone.rows.length !== barReserved;
2764
+ // The zone grew or shrank (a picker, the notice row, the status block, a wrapped input) →
2765
+ // re-cut the region before painting, or its top rows would land inside the scrolling area.
2766
+ const prev = barReserved;
2767
+ const regrew = zone.rows.length !== prev;
2768
+ if (regrew) { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL AT REGROW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 50))); }
2769
+ if (regrew) __dbg("ANY regrow", { prev, now: zone.rows.length, scrolledBack: scrolledBack(), rows: displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length, pad: padRows, vp: viewportRows() });
2731
2770
  if (regrew) {
2771
+ // #321: MOVE THE SCREEN LIKE A TERMINAL, never repaint it from the transcript — a repaint put
2772
+ // lines the terminal had already scrolled away back on screen, and the next output scrolled
2773
+ // them into the scrollback AGAIN (the banner and the update notice repeated after an update;
2774
+ // "auto-test off" doubled). See zoneResizePlan.
2775
+ const live = prev > 0 && !scrolledBack();
2776
+ let plan = null;
2777
+ let rows = 0;
2778
+ if (live) {
2779
+ rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1).length;
2780
+ plan = zoneResizePlan({ contentRows: rows, pad: currentPad(rows), oldHeight: viewportRows() - prev, newHeight: viewportRows() - zone.rows.length });
2781
+ // The rows the zone takes that still hold text: scroll them up and out — real newlines at
2782
+ // the OLD region's bottom, so the terminal files them in its scrollback once — and move the
2783
+ // saved output position up with the text it belongs to.
2784
+ if (plan.scrollUp > 0) {
2785
+ ui(`\x1b[${viewportRows() - prev};1H` + "\n".repeat(plan.scrollUp) + OUT_RESTORE + `\x1b[${plan.scrollUp}A` + OUT_SAVE);
2786
+ }
2787
+ }
2732
2788
  ui(scrollRegionSeq(viewportRows(), zone.rows.length, { preserveCaret: false }));
2789
+ if (live && plan.clearRows > 0) {
2790
+ // The rows the zone handed back still show its old rows — blank them, and remember them.
2791
+ let clear = "";
2792
+ for (let i = 0; i < plan.clearRows; i++) clear += `\x1b[${viewportRows() - prev + 1 + i};1H\x1b[2K`;
2793
+ ui(clear);
2794
+ }
2795
+ if (live) setPad(plan.pad, rows);
2796
+ __dbg("regrow", { prev, now: zone.rows.length, rows, plan, live });
2733
2797
  barReserved = zone.rows.length;
2734
2798
  }
2735
2799
  const top = viewportRows() - zone.rows.length + 1;
@@ -2749,19 +2813,17 @@ function paintBar() {
2749
2813
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2750
2814
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2751
2815
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2752
- updateRowAbs = updateRowTarget || agentsRowTarget ? noticeAbs : 0;
2816
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2753
2817
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2754
2818
  // is parked on the input line right after, so there is nothing to preserve here.
2755
2819
  ui(
2756
2820
  paintFrozenRowsSeq(zone.rows, viewportRows(), { preserveCaret: false }) +
2757
2821
  parkCaretSeq(zone.caretRow, zone.caretCol)
2758
2822
  );
2759
- // THE ZONE CHANGED HEIGHT — the line wrapped onto a new row, or unwrapped off one. Re-cutting
2760
- // the region does not move what is already on screen: growing freezes a row that still holds
2761
- // transcript text (and we have just painted over it), shrinking hands back a row that is
2762
- // blank. Repaint the view from the transcript so neither shows. Recurses exactly once —
2763
- // paintLiveTail ends in paintBar, by which point the count matches and this is skipped.
2764
- if (regrew && !scrolledBack()) paintLiveTail();
2823
+ // THE FIRST PIN only: before it, the screen is whatever setup printed, and one repaint puts the
2824
+ // view in gu's hands. Every later height change moved the screen above (#321) — a repaint here
2825
+ // is what filled the scrollback with copies. Recurses at most once (paintLiveTail → paintBar).
2826
+ if (regrew && !scrolledBack() && prev === 0) paintLiveTail();
2765
2827
  }
2766
2828
  /** Bytes that put the caret back on text row `row` (0-based) at 1-based `col`. */
2767
2829
  const parkCaretSeq = (row, col) => `\x1b[${inputRowAbs + Math.max(0, row | 0)};${col}H`;
@@ -2912,6 +2974,8 @@ let updateRowTarget = null;
2912
2974
  let updateRowAbs = 0;
2913
2975
  // …and the agents indicator, which shares that row when no update is pending (task #195).
2914
2976
  let agentsRowTarget = null;
2977
+ // …and "new prices updated <date>" (#323).
2978
+ let pricesRowTarget = null;
2915
2979
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2916
2980
  // bracketed-paste and mouse-reporting teardowns.
2917
2981
  process.on("exit", () => {
@@ -2928,11 +2992,25 @@ process.on("exit", () => {
2928
2992
  // the same space displayRows produces, so the highlight stays on the text it was drawn over
2929
2993
  // when the view scrolls rather than on whatever moved under that screen row.
2930
2994
  let liveSelection = null;
2931
- const screenRows = () =>
2932
- applySelectionHighlight(
2933
- displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1),
2934
- liveSelection
2935
- );
2995
+ /**
2996
+ * BLANK ROWS BELOW THE CONTENT at the live tail (#321): when the frozen zone shrinks, the rows it
2997
+ * hands back stay blank (nothing older is repainted into the view — those lines are already in the
2998
+ * terminal's scrollback), and new output fills them first. Kept as a count + the transcript's row
2999
+ * count when it was set, so the per-write path costs nothing: the pad left is what output since has
3000
+ * not consumed.
3001
+ */
3002
+ let padRows = 0;
3003
+ let padAnchor = 0;
3004
+ const currentPad = (rowCount) => (padRows > 0 ? Math.max(0, padRows - Math.max(0, rowCount - padAnchor)) : 0);
3005
+ function setPad(n, rowCount) {
3006
+ padRows = Math.max(0, n | 0);
3007
+ padAnchor = rowCount;
3008
+ }
3009
+ const screenRows = () => {
3010
+ const rows = displayRows(transcript.lines(), Math.max(20, process.stdout.columns || 80) - 1);
3011
+ const pad = currentPad(rows.length);
3012
+ return applySelectionHighlight(pad ? [...rows, ...Array(pad).fill("")] : rows, liveSelection);
3013
+ };
2936
3014
  /**
2937
3015
  * Abandon any drag in progress and take its highlight off screen (task #177).
2938
3016
  *
@@ -3770,6 +3848,9 @@ function stopPendingStatus() {
3770
3848
  // Already-printed scrollback (bullets, answers, wrapBlock output) is deliberately NOT touched:
3771
3849
  // it belongs to the terminal now, and the terminal reflows it.
3772
3850
  function onWindowResize() {
3851
+ // #321: the blank rows below the content were counted in rows of the OLD width; the resize
3852
+ // repaint lays the view out afresh.
3853
+ padRows = 0;
3773
3854
  // Whatever owns the screen, readline's next refresh must not walk up a stale row count —
3774
3855
  // at the prompt this is the fix itself; elsewhere it stops a later rl.prompt() from erasing
3775
3856
  // rows above the input.
@@ -3982,6 +4063,9 @@ if (process.stdin.isTTY) {
3982
4063
  // re-rendered the slash overlay (the line still starts with "/" from before the paste) and
3983
4064
  // re-walked the paste store. Same for a mouse report's characters, which are not keys.
3984
4065
  if (pasteBuffer !== null || inMouseKeyWindow()) return;
4066
+ // #247: the restored question was erased (Backspace, Ctrl+U, a picker taking the line) — it
4067
+ // is let go, exactly as with Ctrl+C, so the next message does not REPLACE it in history.
4068
+ if (prefillFor && (listPickerActive || restoredQuestionLetGo(rl.line, key))) prefillFor = null;
3985
4069
  // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
3986
4070
  if (listPickerActive) {
3987
4071
  onListKey(key);
@@ -7472,6 +7556,195 @@ async function fetchWorkerSettings() {
7472
7556
  return null; // offline — callers fall back to showing what they know
7473
7557
  }
7474
7558
  }
7559
+
7560
+ // ---------------------------------------------------------------------------------------------
7561
+ // WHAT A TURN COST (#323): prices from the account's own Mongo (seeded once from the table gu
7562
+ // ships), the turn's cached share from the provider when it reports one — else 90% of the later
7563
+ // steps assumed — and the result on the token row ("· ~$0.334") and in /price.
7564
+ // ---------------------------------------------------------------------------------------------
7565
+ let priceRows = null; // null = not loaded yet; [] = none (and seeding failed)
7566
+ let priceVersion = "";
7567
+ let pricePublishedAt = ""; // when gu published the tables this account last applied ("" = none)
7568
+ let lastTurnCost = null; // { usd, estimated, cachedTokens, input, model, host }
7569
+ let sessionCostUsd = 0;
7570
+ let sessionCostEstimated = false;
7571
+ let sessionCostTurns = 0;
7572
+
7573
+ async function priceApi(method, body) {
7574
+ try {
7575
+ const r = await fetch(`${apiBase}/api/worker/model-prices`, {
7576
+ method,
7577
+ headers: { "X-Worker-Key": workerKey, ...(body ? { "Content-Type": "application/json" } : {}) },
7578
+ ...(body ? { body: JSON.stringify(body) } : {}),
7579
+ signal: AbortSignal.timeout(8000),
7580
+ });
7581
+ const j = await r.json().catch(() => null);
7582
+ return r.ok ? j : { error: j?.error || `HTTP ${r.status}` };
7583
+ } catch (e) {
7584
+ return { error: e?.message ?? String(e) };
7585
+ }
7586
+ }
7587
+
7588
+ /**
7589
+ * The account's prices. An EMPTY table is seeded once with the one gu ships (an upsert — running it
7590
+ * twice changes nothing). An API without the route (an older local API) → [] and no cost shown.
7591
+ */
7592
+ async function loadPrices({ force = false } = {}) {
7593
+ if (priceRows && !force) return priceRows;
7594
+ const mp = await import("./model-prices.mjs");
7595
+ let got = await priceApi("GET");
7596
+ if (got && !got.error && Array.isArray(got.rows) && got.rows.length === 0) {
7597
+ const seeded = await priceApi("PUT", { version: mp.PRICE_TABLE.version, rows: mp.PRICE_TABLE.prices, removes: [] });
7598
+ if (!seeded?.error) got = await priceApi("GET");
7599
+ }
7600
+ priceRows = got && !got.error && Array.isArray(got.rows) ? got.rows : [];
7601
+ priceVersion = typeof got?.version === "string" ? got.version : "";
7602
+ pricePublishedAt = typeof got?.publishedAt === "string" ? got.publishedAt : "";
7603
+ return priceRows;
7604
+ }
7605
+
7606
+ /** The coder model id in use — the folder's /model pick ("<kind>::<model>") or the account default. */
7607
+ function activeCodingModelId() {
7608
+ const m = String(sessionCodingModel || "");
7609
+ return m.includes("::") ? m.slice(m.indexOf("::") + 2) : m || defaultCodingModel;
7610
+ }
7611
+
7612
+ /** After a turn: what its prompt tokens cost, added to the session total. Never throws. */
7613
+ async function noteTurnCost(inputTokens, breakdown, coder = null) {
7614
+ try {
7615
+ lastTurnCost = null;
7616
+ if (!(inputTokens > 0)) return;
7617
+ const mp = await import("./model-prices.mjs");
7618
+ const rows = await loadPrices();
7619
+ const url = coder?.url ?? activeCodingUrl();
7620
+ const model = coder?.model ?? activeCodingModelId();
7621
+ const price = mp.findPrice(rows, url, model);
7622
+ const cache = readTurnCache(breakdown) ?? { cached: 0, cacheReported: false, firstIn: 0 };
7623
+ const c = mp.turnCost({ input: inputTokens, ...cache, price });
7624
+ if (!c) {
7625
+ lastTurnCost = { usd: null, model, host: mp.hostOf(url) };
7626
+ return;
7627
+ }
7628
+ lastTurnCost = { ...c, input: inputTokens, cacheReported: cache.cacheReported, model, host: mp.hostOf(url) };
7629
+ sessionCostUsd += c.usd;
7630
+ sessionCostEstimated ||= c.estimated;
7631
+ sessionCostTurns++;
7632
+ if (barPinned()) paintBar();
7633
+ } catch {
7634
+ /* a cost is never worth a broken prompt */
7635
+ }
7636
+ }
7637
+
7638
+ /**
7639
+ * /prices (#323) — the price table and the cache table this account's estimates use, the coder in
7640
+ * use marked; what the last turn and this session cost; and, when gu has published newer tables,
7641
+ * that they are waiting. `/prices update` applies them — the same as clicking the notice.
7642
+ */
7643
+ async function runPricesCommand(line = "/prices") {
7644
+ const mp = await import("./model-prices.mjs");
7645
+ console.log("");
7646
+ if (line.trim().slice("/prices".length).trim() === "update") {
7647
+ await runPricesUpdate();
7648
+ return;
7649
+ }
7650
+ const rows = await withPendingStatus("reading prices", () => loadPrices({ force: true }));
7651
+ if (!rows.length) {
7652
+ console.log(GUTTER + yellow("no prices — the local API is older than this gu, or unreachable.") + "\n");
7653
+ return;
7654
+ }
7655
+ const width = process.stdout.columns || 80;
7656
+ const url = activeCodingUrl();
7657
+ const model = activeCodingModelId();
7658
+ const paint = (l) => (l.startsWith("▸") ? accent(l) : /^[a-z0-9.-]+\.[a-z]+(:\d+)?( |$)/i.test(l) ? l : dim(l));
7659
+ console.log(GUTTER + "Prices");
7660
+ for (const l of mp.priceTableLines(rows, { width, current: { url, model }, version: priceVersion })) console.log(GUTTER + paint(l));
7661
+ console.log("");
7662
+ console.log(GUTTER + "Cache" + dim(" — the share of each step's prompt assumed cached, used only when a provider does not report its own count"));
7663
+ for (const l of mp.cacheShareLines({ yours: rows, width, current: { url, model } })) console.log(GUTTER + paint(l));
7664
+ console.log("");
7665
+ console.log(GUTTER + dim(pricePublishedAt ? `from gu's tables published ${new Date(pricePublishedAt).toLocaleString()}` : "gu's built-in tables (none published applied yet)"));
7666
+ if (priceUpdate) console.log(GUTTER + accent(`${mp.priceUpdateLabel(priceUpdate.publishedAt)} — click it below, or /prices update`));
7667
+ if (!mp.findPrice(rows, url, model)) {
7668
+ console.log(GUTTER + yellow(`no price for ${model || "the coder"} at ${mp.hostOf(url) || "its server"} — no cost is shown for its turns.`));
7669
+ }
7670
+ if (lastTurnCost?.usd != null) {
7671
+ const share = lastTurnCost.input ? Math.round((100 * lastTurnCost.cachedTokens) / lastTurnCost.input) : 0;
7672
+ console.log(GUTTER + `last turn: ${mp.formatUsd(lastTurnCost.usd, { estimated: lastTurnCost.estimated })}` +
7673
+ dim(` — ${lastTurnCost.input.toLocaleString("en-US")} prompt tokens, ${share}% cached ${lastTurnCost.cacheReported ? "(reported by the provider)" : "(assumed)"}`));
7674
+ }
7675
+ if (sessionCostTurns) console.log(GUTTER + `this session: ${mp.formatUsd(sessionCostUsd, { estimated: sessionCostEstimated })}` + dim(` over ${sessionCostTurns} turn${sessionCostTurns === 1 ? "" : "s"} · input only — output is not counted yet`));
7676
+ console.log("");
7677
+ }
7678
+
7679
+ // ---------------------------------------------------------------------------------------------
7680
+ // "NEW PRICES UPDATED <date>" (#323): the hosted API's newest published tables, checked at start
7681
+ // and hourly beside the version check; when newer than what this account applied, a clickable item
7682
+ // on the notice row. A click (or /prices update) writes both tables into the user's own Mongo.
7683
+ // ---------------------------------------------------------------------------------------------
7684
+ let priceUpdate = null; // { publishedAt, tables } while newer tables wait
7685
+ let priceUpdateBusy = false;
7686
+
7687
+ async function fetchPublishedTables() {
7688
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
7689
+ const base = (String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
7690
+ try {
7691
+ const r = await fetch(`${base}/api/get-price-and-cache-table`, { signal: AbortSignal.timeout(8000) });
7692
+ if (!r.ok) return null;
7693
+ const j = await r.json().catch(() => null);
7694
+ return j && (j.prices || j.cacheShares) ? j : null;
7695
+ } catch {
7696
+ return null;
7697
+ }
7698
+ }
7699
+
7700
+ /** Are newer tables published? Fails silently to "no" — like the version check. */
7701
+ async function refreshPriceUpdate() {
7702
+ try {
7703
+ const mp = await import("./model-prices.mjs");
7704
+ const tables = await fetchPublishedTables();
7705
+ if (!tables) return;
7706
+ await loadPrices({ force: true });
7707
+ const was = Boolean(priceUpdate);
7708
+ priceUpdate = mp.hasNewerPublished(pricePublishedAt, tables.publishedAt) ? { publishedAt: tables.publishedAt, tables } : null;
7709
+ if (Boolean(priceUpdate) !== was && barPinned()) paintBar();
7710
+ } catch {
7711
+ /* a price notice is never worth an error */
7712
+ }
7713
+ }
7714
+
7715
+ /** Apply gu's newest published tables to this account's Mongo — the notice's click. */
7716
+ async function runPricesUpdate() {
7717
+ if (priceUpdateBusy) return;
7718
+ priceUpdateBusy = true;
7719
+ try {
7720
+ const mp = await import("./model-prices.mjs");
7721
+ const tables = (await fetchPublishedTables()) ?? priceUpdate?.tables ?? null;
7722
+ if (!tables) {
7723
+ console.log(GUTTER + yellow("couldn't reach gu's published prices — try again later.") + "\n");
7724
+ return;
7725
+ }
7726
+ const current = await loadPrices({ force: true });
7727
+ const m = mp.mergePublishedTables(current, tables);
7728
+ if (!m.ok) {
7729
+ console.log(GUTTER + yellow(`prices not updated — ${m.problem}`) + "\n");
7730
+ return;
7731
+ }
7732
+ const res = await priceApi("PUT", { version: m.version, publishedAt: m.publishedAt, rows: m.rows, removes: [] });
7733
+ if (res?.error) {
7734
+ console.log(GUTTER + red(`couldn't save the prices — ${res.error}. Nothing changed.`) + "\n");
7735
+ return;
7736
+ }
7737
+ await loadPrices({ force: true });
7738
+ priceUpdate = null;
7739
+ const d = m.diff;
7740
+ console.log(GUTTER + accent(`✓ ${mp.priceUpdateLabel(m.publishedAt).replace(/^new prices updated/, "prices updated to gu's tables of")}`) +
7741
+ dim(` — ${d.added.length} new · ${d.changed.length} changed · ${d.unchanged} unchanged. /prices shows them.`) + "\n");
7742
+ if (barPinned()) paintBar();
7743
+ } finally {
7744
+ priceUpdateBusy = false;
7745
+ }
7746
+ }
7747
+
7475
7748
  /**
7476
7749
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7477
7750
  *
@@ -9457,6 +9730,9 @@ let listDeleteRequested = false;
9457
9730
  // /use-presets' { o: "open" } (#312, #322). Null unless a caller asks, like ctrl+d above.
9458
9731
  let listKeys = null;
9459
9732
  let listKeyAction = "";
9733
+ /** #321: the screen under a picker when it opened (content rows, pad, region height), and its tallest draw. */
9734
+ let listOpenedOver = null;
9735
+ let listMaxRows = 0;
9460
9736
  function drawList(redraw) {
9461
9737
  // Gutter (task #128 Phase 2): inset each row after its line-clear. Clipped to width-1 like
9462
9738
  // every other in-place block: a long item (a model id on a narrow terminal) that wrapped
@@ -9490,6 +9766,15 @@ function drawList(redraw) {
9490
9766
  if (listNote) rows.push(clipVisible(GUTTER + yellow(listNote), width - 1));
9491
9767
  // On a redraw the caret sits one row BELOW the block (each draw ends with "\n").
9492
9768
  const up = redraw ? physicalRows(listRowsDrawn, width) : 0;
9769
+ // #321: what the screen held under the picker when it opened, and how far it reaches — so the
9770
+ // wipe can account for anything it scrolled into the scrollback (blockClosedPad).
9771
+ if (!redraw && barPinned() && !scrolledBack()) {
9772
+ const content = displayRows(transcript.lines(), width - 1).length;
9773
+ listOpenedOver = { content, pad: currentPad(content), height: viewportHeight() };
9774
+ __dbg("picker open", listOpenedOver, { lastLines: transcript.lines().slice(-3) });
9775
+ listMaxRows = 0;
9776
+ }
9777
+ listMaxRows = Math.max(listMaxRows, physicalRows(rows, width));
9493
9778
  regionUi(
9494
9779
  (up ? `\x1b[${up}A` : "") +
9495
9780
  rows.map((r) => "\x1b[K" + r).join("\n") +
@@ -9504,6 +9789,15 @@ function finishList(index) {
9504
9789
  const width = Math.max(20, process.stdout.columns || 80);
9505
9790
  const up = physicalRows(listRowsDrawn, width); // ESC[0A would still move ONE row — guard it
9506
9791
  regionUi((up ? `\x1b[${up}A` : "") + "\x1b[J"); // wipe the list block
9792
+ // #321: the rows the picker scrolled away are in the scrollback; the content now sits that much
9793
+ // higher with blank rows under it — remember them, so nothing repaints those lines back on screen.
9794
+ if (listOpenedOver && barPinned() && !scrolledBack() && listOpenedOver.height === viewportHeight()) {
9795
+ setPad(blockClosedPad({ contentRows: listOpenedOver.content, pad: listOpenedOver.pad, height: listOpenedOver.height, blockRows: listMaxRows }), listOpenedOver.content);
9796
+ { const v = viewportSlice(screenRows(), 0, viewportHeight()).rows; __dbg("MODEL VIEW", v.map((r) => r.replace(/\x1b\[[0-9;]*m/g, "").slice(0, 60))); }
9797
+ __dbg("picker closed", { ...listOpenedOver, blockRows: listMaxRows, newPad: padRows, nowRows: displayRows(transcript.lines(), width - 1).length });
9798
+ }
9799
+ listOpenedOver = null;
9800
+ listMaxRows = 0;
9507
9801
  listRowsDrawn = [];
9508
9802
  listTop = 0;
9509
9803
  const r = listResolve;
@@ -12270,6 +12564,9 @@ async function main() {
12270
12564
  // reason gu refuses to exit.
12271
12565
  void refreshAvailableUpdates();
12272
12566
  setInterval(() => void refreshAvailableUpdates(), UPDATE_CHECK_MS).unref();
12567
+ // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12568
+ void refreshPriceUpdate();
12569
+ setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
12273
12570
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12274
12571
  startupTokenRefresh = refreshCliToken();
12275
12572
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -12424,6 +12721,10 @@ async function main() {
12424
12721
  announceCliUpdate(); // deferred if a 6h refresh landed mid-turn; harmless once shown
12425
12722
  announceScaffold(); // the startup scaffold line, if it arrived while a turn was running
12426
12723
  rl.prompt();
12724
+ // #302, found live: prompt() puts the cursor at column 0, and a stopped turn's question is
12725
+ // already in the line — so what was typed went IN FRONT of it and Ctrl+U erased nothing.
12726
+ // The restored question is edited from its end, like a line just typed.
12727
+ if (prefillFor) rl.cursor = rl.line.length;
12427
12728
  paintBar();
12428
12729
  if (!barPinned()) probeCaretRow(); // fallback path still needs the caret's absolute row
12429
12730
  // Absorb blank Enter-presses SILENTLY — they never reach the agent. Lines typed while
@@ -12718,6 +13019,10 @@ async function main() {
12718
13019
  );
12719
13020
  continue;
12720
13021
  }
13022
+ if (line === "/prices" || line.startsWith("/prices ")) {
13023
+ await runPricesCommand(line);
13024
+ continue;
13025
+ }
12721
13026
  if (line === "/tokens") {
12722
13027
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
12723
13028
  // this answers "and what displaced it", which is the part that tells you whether RAG is
@@ -12905,6 +13210,9 @@ async function main() {
12905
13210
  await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
12906
13211
  try {
12907
13212
  const turnStartMs = Date.now();
13213
+ // #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
13214
+ // another model's price on it.
13215
+ const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
12908
13216
  if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
12909
13217
  const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
12910
13218
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
@@ -12927,6 +13235,8 @@ async function main() {
12927
13235
  lastTurnInputTokens = inputTokens ?? 0;
12928
13236
  lastTurnOutputTokens = turnOutputTokens ?? 0;
12929
13237
  lastTurnTotalOutput = outputTokens ?? 0;
13238
+ // #323: what this turn's prompt tokens cost — on the token row and in /price.
13239
+ void noteTurnCost(lastTurnInputTokens, lastPromptBreakdown, turnCoder);
12930
13240
  // A turn that FAILED carries the __TURN_FAILED__:: sentinel: the worker still delivers
12931
13241
  // the text (both UIs need to show it), but it is a failure notice, not a reply. Print
12932
13242
  // it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to
@@ -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.784",
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",
@@ -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",
package/step-tokens.mjs CHANGED
@@ -53,3 +53,48 @@ export function readStepTokens(breakdown) {
53
53
  const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
54
54
  return { step, stepIn: n(breakdown.liveStepIn), stepOut: n(breakdown.liveStepOut) };
55
55
  }
56
+
57
+ // ---------------------------------------------------------------------------------------------
58
+ // WHAT THE TURN COST needs two more figures (#323), riding the same breakdown for the same reason:
59
+ // · liveCached / liveCacheReported — the prompt tokens the provider served from its cache, summed
60
+ // over the turn (python's `codeCached`, present ONLY when the provider reported a count);
61
+ // · liveFirstIn — the first step's prompt: nothing is cached on it, so when the provider reports
62
+ // nothing, gu assumes 90% of every LATER step was cached (model-prices.mjs).
63
+ // ---------------------------------------------------------------------------------------------
64
+
65
+ /** The turn's cache figures before any event. */
66
+ export const NO_CACHE = Object.freeze({ cached: 0, cacheReported: false, firstIn: 0, firstStep: 0 });
67
+
68
+ /** Fold one python "tokens" event into the turn's cache figures. */
69
+ export function foldTurnCache(prev, event) {
70
+ let { cached, cacheReported, firstIn, firstStep } = prev ?? NO_CACHE;
71
+ if (!event || typeof event !== "object") return { cached, cacheReported, firstIn, firstStep };
72
+ if (Number.isFinite(event.codeCached) && event.codeCached >= 0) {
73
+ cacheReported = true;
74
+ if (event.codeCached > cached) cached = event.codeCached;
75
+ }
76
+ const step = Number.isFinite(event.step) ? event.step : 0;
77
+ if (step > 0 && (firstStep === 0 || step < firstStep)) {
78
+ firstStep = step;
79
+ firstIn = 0;
80
+ }
81
+ if (step > 0 && step === firstStep && Number.isFinite(event.stepInput) && event.stepInput > firstIn) firstIn = event.stepInput;
82
+ return { cached, cacheReported, firstIn, firstStep };
83
+ }
84
+
85
+ /** The breakdown, carrying the turn's cache figures. */
86
+ export function withTurnCache(breakdown, c) {
87
+ if (!breakdown || typeof breakdown !== "object" || !c) return breakdown;
88
+ return {
89
+ ...breakdown,
90
+ liveFirstIn: c.firstIn,
91
+ ...(c.cacheReported ? { liveCached: c.cached, liveCacheReported: true } : {}),
92
+ };
93
+ }
94
+
95
+ /** What the terminal reads back: { cached, cacheReported, firstIn }, or null from an older worker. */
96
+ export function readTurnCache(breakdown) {
97
+ if (!breakdown || typeof breakdown !== "object" || !("liveFirstIn" in breakdown)) return null;
98
+ const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0);
99
+ return { cached: n(breakdown.liveCached), cacheReported: breakdown.liveCacheReported === true, firstIn: n(breakdown.liveFirstIn) };
100
+ }
@@ -2852,3 +2852,17 @@ export function noticeRow(items, { width = 80, gutter = " ", sep = " · " }
2852
2852
  });
2853
2853
  return { row, spans };
2854
2854
  }
2855
+
2856
+ /**
2857
+ * THE TURN'S PRICE AT THE END OF THE TOKEN ROW (#323): "↑ sent 500k · ↓ out 4.3k · total ↓ 1.6M | ~$0.33".
2858
+ *
2859
+ * The row is clipped on the RIGHT at the window's width, and the price is the last thing on it — so
2860
+ * on a narrow window (80 columns over SSH) it would be the first thing lost. When the whole row does
2861
+ * not fit, the lifetime "total ↓" goes first (it changes no decision about this turn); the price
2862
+ * stays. `price` = the already-painted " | ~$0.33" segment, or "" when the model has no price.
2863
+ */
2864
+ export function tokenRowWithPrice({ withTotal = "", withoutTotal = "", price = "", room = 80 } = {}) {
2865
+ if (!price) return withTotal;
2866
+ if (visibleLen(withTotal) + visibleLen(price) <= room) return withTotal + price;
2867
+ return withoutTotal + price;
2868
+ }
@@ -674,3 +674,68 @@ export function applySelectionHighlight(rows, sel, sgr = SELECTION_SGR) {
674
674
  return highlightRow(row, from, to, sgr);
675
675
  });
676
676
  }
677
+
678
+ /**
679
+ * WHEN THE FROZEN ZONE CHANGES HEIGHT (#321) — move the screen the way a terminal would, instead of
680
+ * repainting it from the transcript.
681
+ *
682
+ * Reported: after an update, the banner and the update notice appeared again and again in the
683
+ * scrollback, and single lines ("auto-test off", "1 skill · 1 MCP server") were doubled. The cause
684
+ * was the repaint that followed every height change of the bottom zone (a picker, the notice row,
685
+ * the status block coming and going):
686
+ * · the zone SHRANK → the region grew → the repaint filled the new rows at the TOP with older
687
+ * transcript lines — lines the terminal had ALREADY scrolled into its scrollback. The next
688
+ * output scrolled them in again: one more copy per shrink;
689
+ * · the zone GREW → the repaint painted over the top rows, which never reached the scrollback.
690
+ *
691
+ * Now:
692
+ * · GROW by k: the k rows the zone takes come out of the blank rows below the content first; for
693
+ * the rest, the region is scrolled up (real newlines), so the top lines go into the scrollback
694
+ * ONCE, the normal way;
695
+ * · SHRINK by k: the content stays where it is and the k freed rows are cleared; they are
696
+ * remembered as blank rows below the content ("pad"), which new output fills first.
697
+ * gu's model of the screen is the transcript's rows plus `pad` blank rows, bottom-aligned once it
698
+ * fills the region and top-aligned before (viewportSlice), so it matches without a repaint.
699
+ *
700
+ * → { scrollUp, clearRows, pad }: lines to scroll the OLD region up by, rows to clear below the old
701
+ * region (the freed rows), and the new pad.
702
+ */
703
+ export function zoneResizePlan({ contentRows = 0, pad = 0, oldHeight, newHeight }) {
704
+ const c = Math.max(0, contentRows | 0);
705
+ const p = Math.max(0, pad | 0);
706
+ const oldH = Math.max(1, oldHeight | 0);
707
+ const newH = Math.max(1, newHeight | 0);
708
+ const full = c + p >= oldH; // bottom-aligned (the region is full) vs top-aligned
709
+ if (newH >= oldH) {
710
+ const k = newH - oldH;
711
+ return { scrollUp: 0, clearRows: k, pad: full ? p + k : p };
712
+ }
713
+ const k = oldH - newH;
714
+ const blanks = full ? p : oldH - c; // rows at the bottom holding nothing
715
+ const fromBlank = Math.min(blanks, k);
716
+ const scrollUp = k - fromBlank;
717
+ if (scrollUp > 0) return { scrollUp, clearRows: 0, pad: 0 };
718
+ return { scrollUp: 0, clearRows: 0, pad: full ? p - fromBlank : Math.max(0, Math.min(p, newH - c)) };
719
+ }
720
+
721
+ /**
722
+ * A BLOCK DRAWN BELOW THE CONTENT AND WIPED AGAIN — a picker (#321). Its rows are not transcript
723
+ * content, but drawing them can scroll the region: whatever scrolled away is in the terminal's
724
+ * scrollback now. Wiping the block leaves the content shifted up by that much with blank rows under
725
+ * it — recorded as pad, so no later repaint brings the scrolled-away lines back (each one would
726
+ * land in the scrollback a second time).
727
+ * contentRows the transcript's rows when the block was first drawn (its last is the cursor row)
728
+ * pad the blank rows below the content then (gu's pad)
729
+ * height the region's rows
730
+ * blockRows the most physical rows the block drew (it ends with a newline)
731
+ * → the pad after the wipe.
732
+ */
733
+ export function blockClosedPad({ contentRows = 0, pad = 0, height, blockRows = 0 }) {
734
+ const c = Math.max(0, contentRows | 0);
735
+ const p = Math.max(0, pad | 0);
736
+ const h = Math.max(1, height | 0);
737
+ const full = c + p >= h;
738
+ const blanks = full ? p : h - c; // rows below the cursor row's region that hold nothing
739
+ const scrolled = Math.max(0, (blockRows | 0) - blanks);
740
+ return full || scrolled > 0 ? blanks + scrolled : p;
741
+ }
package/turn-stop.mjs CHANGED
@@ -55,6 +55,23 @@ export function replaceStoppedQuestion(history, oldTask, newTask) {
55
55
  return true;
56
56
  }
57
57
 
58
+ /**
59
+ * Has the user LET GO of the restored question? (task #247 meets #302)
60
+ *
61
+ * The line going EMPTY is "never mind", however it was emptied — Ctrl+C, Backspace, Ctrl+U, a
62
+ * picker taking the line. Only Ctrl+C used to count, so erasing the question and typing "where
63
+ * were we?" was taken for an EDIT of it: the message replaced the stopped question in history and
64
+ * the model was asked where we were with nothing before it — the report #247 began with.
65
+ * What is typed after that is a new message; the stopped question stays, tagged "still stands".
66
+ *
67
+ * Enter is not an emptying: readline has already cleared the line it is submitting by the time
68
+ * the keypress is seen.
69
+ */
70
+ export function restoredQuestionLetGo(line, key) {
71
+ if (key && (key.name === "return" || key.name === "enter")) return false;
72
+ return String(line ?? "") === "";
73
+ }
74
+
58
75
  /** Short relative names for the stop note: the last two path parts. */
59
76
  function shortPath(p) {
60
77
  const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);