@tiens.nguyen/gu-cli 1.0.703 → 1.0.705

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.
@@ -466,3 +466,37 @@ export function agentProviderChoices(settings) {
466
466
  out.push({ action: "add", label: "add a new OpenAI-compatible provider…" });
467
467
  return out;
468
468
  }
469
+
470
+ // ---------------------------------------------------------------------------------------------
471
+ // The Ollama CLOUD's key is its OWN — and a key is never a key's name.
472
+ // ---------------------------------------------------------------------------------------------
473
+
474
+ /**
475
+ * The key saved FOR this provider row, or null.
476
+ *
477
+ * Reported: /agent-model → Ollama → https://ollama.com listed "20 on https://ollama.com" and saved the
478
+ * agent without asking for a key; the coder on ollama.com then failed "REJECTED the coding API key".
479
+ * Two things hid it. ollama.com's /api/tags is PUBLIC (200 with no key), so a successful list proves
480
+ * nothing about a key. And "a key for this provider" counted UNBOUND keys — by design usable with any
481
+ * OpenAI-compatible provider — so the account's sk-… key was taken as ollama.com's, and refused there.
482
+ * For the Ollama cloud only a key BOUND to its row counts.
483
+ */
484
+ export function boundKeyFor(keys, rowId) {
485
+ const id = String(rowId ?? "").trim();
486
+ if (!id || !Array.isArray(keys)) return null;
487
+ return keys.find((k) => k && k.backendId === id) ?? null;
488
+ }
489
+
490
+ /**
491
+ * Does what was typed as a key's NAME look like the key itself?
492
+ *
493
+ * Reported in the same log: a key named "sk-E8Iv…3E66" — the secret pasted into the name prompt — is
494
+ * printed in full wherever keys are listed ("API key sk-E8Iv… ····3E66"). Names are shown; secrets
495
+ * must not be. Caught on the known prefixes, and on anything long, unbroken and mixed like a token.
496
+ */
497
+ export function looksLikeSecret(name) {
498
+ const v = String(name ?? "").trim();
499
+ if (!v) return false;
500
+ if (/^(sk-|sk_|pk-|rk-|ghp_|gho_|github_pat_|xox[abp]-|AKIA|AIza|hf_|gsk_|ollama_)/i.test(v)) return true;
501
+ return v.length >= 24 && !/\s/.test(v) && /[A-Za-z]/.test(v) && /\d/.test(v);
502
+ }
@@ -753,14 +753,17 @@ export function mergeOllamaServerBackends(existing, fresh, { isCloud } = {}) {
753
753
  const cloud = typeof isCloud === "function" ? isCloud : () => false;
754
754
  const norm = (u) => String(u ?? "").trim().replace(/\/+$/, "").toLowerCase();
755
755
  const rows = Array.isArray(existing) ? existing.filter(Boolean) : [];
756
- const replaced = rows.filter((b) => b.kind === "ollama" && !cloud(b.url));
757
- const kept = rows.filter((b) => !(b.kind === "ollama" && !cloud(b.url)));
756
+ const freshOllama = (Array.isArray(fresh) ? fresh : []).filter((f) => f.kind === "ollama");
757
+ const newUrls = new Set(freshOllama.map((f) => norm(f.url)));
758
+ // A row already at the NEW server's URL is the same provider — the new server takes it over, id and
759
+ // all, so a key bound to it (the Ollama cloud's own key) keeps working and no twin row appears.
760
+ const sameServer = rows.filter((b) => newUrls.has(norm(b.url)));
761
+ const replaced = [...sameServer, ...rows.filter((b) => b.kind === "ollama" && !cloud(b.url) && !sameServer.includes(b))];
762
+ const kept = rows.filter((b) => !replaced.includes(b));
758
763
  const out = [];
759
- for (const f of Array.isArray(fresh) ? fresh : []) {
760
- if (f.kind === "ollama") {
761
- const id = replaced.find((b) => b.id)?.id;
762
- out.push(id ? { ...f, id } : f);
763
- }
764
+ for (const f of freshOllama) {
765
+ const id = (sameServer.find((b) => b.id && norm(b.url) === norm(f.url)) ?? replaced.find((b) => b.id))?.id;
766
+ out.push(id ? { ...f, id } : f);
764
767
  }
765
768
  out.push(...kept);
766
769
  for (const f of Array.isArray(fresh) ? fresh : []) {
@@ -1329,6 +1329,11 @@ def _render_numbered_tool_list(tools) -> str:
1329
1329
  return ("\n".join(lines) + "\n") if lines else ""
1330
1330
 
1331
1331
 
1332
+ # A step line only `gu --verbose` shows (#45): retry notices and context trimming are how the
1333
+ # agent keeps itself going, and the user can do nothing with them.
1334
+ _VERBOSE_PREFIX = "__VERBOSE__::"
1335
+
1336
+
1332
1337
  def _summarise_step(step_log):
1333
1338
  """Return a short human-readable description of an agent step."""
1334
1339
  tool_calls = getattr(step_log, "tool_calls", None) or []
@@ -1337,6 +1342,9 @@ def _summarise_step(step_log):
1337
1342
  step_num = getattr(step_log, "step_number", None)
1338
1343
 
1339
1344
  parts = []
1345
+ # A retry the agent recovers from by itself is machinery, not progress: the terminal shows
1346
+ # it only under `gu --verbose` (#45). The model still gets smolagents' exact error text.
1347
+ verbose_only = False
1340
1348
  for tc in tool_calls:
1341
1349
  name = getattr(tc, "name", "")
1342
1350
  args = getattr(tc, "arguments", None)
@@ -1404,15 +1412,18 @@ def _summarise_step(step_log):
1404
1412
  # step loop), so the model keeps the precise wording it needs to self-correct.
1405
1413
  _msg = "the model wrote no code this step — asking again"
1406
1414
  parts.append(f"→ {_msg}" if parts else _msg)
1415
+ verbose_only = True
1407
1416
  elif "code parsing failed" in err.lower() or "syntaxerror" in err.lower():
1408
1417
  # The sibling shape: there WAS a block, and its body would not compile.
1409
1418
  _msg = "the model's code wouldn't run — asking again"
1410
1419
  parts.append(f"→ {_msg}" if parts else _msg)
1420
+ verbose_only = True
1411
1421
  else:
1412
1422
  parts.append(f"→ Error: {_clip(err, 160)}")
1413
1423
 
1414
1424
  # No numeric "Step N:" prefix — show only the semantic action.
1415
- return (" | ".join(parts) if parts else "thinking…")
1425
+ text = " | ".join(parts) if parts else "thinking…"
1426
+ return f"{_VERBOSE_PREFIX}{text}" if verbose_only else text
1416
1427
 
1417
1428
 
1418
1429
  # Keywords that strongly indicate the user wants to make an HTTP/network request,
@@ -8689,7 +8700,7 @@ def run_agent_chat(cfg):
8689
8700
  # once, and that answer is remembered. The line exists so the figure is not mistaken
8690
8701
  # for something the endpoint reported — see the "measured vs remembered" note above.
8691
8702
  _emit({"type": "step", "text": (
8692
- f"context window {_ctx_window // 1000}k for {coding_model_id} (published default — "
8703
+ f"{_VERBOSE_PREFIX}context window {_ctx_window // 1000}k for {coding_model_id} (published default — "
8693
8704
  f"this endpoint does not report one). Corrects itself only if it is too HIGH; "
8694
8705
  f"/context-window to set it yourself")})
8695
8706
  else:
@@ -8705,7 +8716,7 @@ def run_agent_chat(cfg):
8705
8716
  # command immediately and cannot act on an env var without leaving, editing a file and
8706
8717
  # restarting — which is how this stayed unset long enough to matter.
8707
8718
  _emit({"type": "step", "text": (
8708
- f"context window unknown — compacting at ~{_compact_at // 1000}k tokens; "
8719
+ f"{_VERBOSE_PREFIX}context window unknown — compacting at ~{_compact_at // 1000}k tokens; "
8709
8720
  "run /context-window <tokens> to use more")})
8710
8721
 
8711
8722
  # Task #90 Phase 3 — completion gate (code-enforced, complements the prompt). Fires at
@@ -8958,7 +8969,7 @@ def run_agent_chat(cfg):
8958
8969
  if _cs and not _compact_state["told"]:
8959
8970
  _compact_state["told"] = True
8960
8971
  _emit({"type": "step", "text": (
8961
- f"Context reached ~{_in_tok // 1000}k tokens — trimming older "
8972
+ f"{_VERBOSE_PREFIX}Context reached ~{_in_tok // 1000}k tokens — trimming older "
8962
8973
  "steps to stay fast (files are re-readable)")})
8963
8974
  if _cs:
8964
8975
  _log(f"context compact: saved ~{_cs} chars "
@@ -9477,9 +9488,9 @@ def run_agent_chat(cfg):
9477
9488
  # to say. On empty-content there is genuinely nothing to show — the model spent
9478
9489
  # the turn in its reasoning channel and wrote nothing — so a short line stays.
9479
9490
  _emit({"type": "step", "text": (
9480
- "The model thought but didn't act — asking it again…"
9491
+ f"{_VERBOSE_PREFIX}The model thought but didn't act — asking it again…"
9481
9492
  if why == "empty-content"
9482
- else "Asking the model to take the action it described…")})
9493
+ else f"{_VERBOSE_PREFIX}Asking the model to take the action it described…")})
9483
9494
  retry_kwargs = dict(completion_kwargs)
9484
9495
  retry_kwargs["messages"] = list(completion_kwargs.get("messages") or []) + [
9485
9496
  {"role": "user", "content": self._CODE_NOW_DIRECTIVE}
package/gu-repl.mjs CHANGED
@@ -112,7 +112,7 @@ import {
112
112
  shouldCancelPrompt,
113
113
  } from "./terminal-layout.mjs";
114
114
  import { completedFlush } from "./completed-flush.mjs";
115
- import { codingBannerLabel, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
115
+ import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
116
116
  import { codingChoiceTags, coderDisplayName, ollamaWhere } from "./coding-choice-tags.mjs";
117
117
  import {
118
118
  isDoubleClick,
@@ -137,6 +137,7 @@ import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } fro
137
137
  // plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
138
138
  // sixteen-colour magenta from what this one actually claims to support.
139
139
  import { accent } from "./accent.mjs";
140
+ import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
140
141
  import {
141
142
  createTranscript,
142
143
  displayRows,
@@ -4955,6 +4956,28 @@ async function reconfigureOllamaServer() {
4955
4956
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
4956
4957
  return;
4957
4958
  }
4959
+ // THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
4960
+ // (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
4961
+ // key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
4962
+ {
4963
+ const cloudUrls = [coder?.normalized?.url, agent?.normalized?.url].filter(
4964
+ (u, i, all) => u && all.indexOf(u) === i && isOllamaCloudUrl(u)
4965
+ );
4966
+ if (cloudUrls.length) {
4967
+ const fresh = await withPendingStatus("checking the Ollama cloud key", fetchWorkerSettings);
4968
+ const keys = await fetchCodingKeys();
4969
+ const ps = await import("./coding-provider-setup.mjs");
4970
+ for (const u of cloudUrls) {
4971
+ const row = ps
4972
+ .existingBackendRows(fresh ?? {})
4973
+ .find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === ps.ensureV1Root(u).toLowerCase());
4974
+ if (row && !am.boundKeyFor(keys, row.id)) {
4975
+ console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
4976
+ await askKeyForRow(row, hostOf(u));
4977
+ }
4978
+ }
4979
+ }
4980
+ }
4958
4981
  // THE ACCOUNT DEFAULT JUST CHANGED — say so in memory, or the bar reports the old one.
4959
4982
  //
4960
4983
  // The override is cleared just below, correctly: what was picked here IS the account default
@@ -5514,7 +5537,7 @@ async function addCodingProvider(roleName = "coding") {
5514
5537
  // NOT DEFAULTED, by request. An empty name becomes "Unknown" at the API, which is the same
5515
5538
  // thing every other add-a-key path produces — the URL default was asked for, this was not.
5516
5539
  console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
5517
- const name = (await ask(" key name: ")).trim();
5540
+ const name = await askKeyName();
5518
5541
  console.log(GUTTER + dim(SECRET_KEY_HINT));
5519
5542
  const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
5520
5543
  if (!secret) {
@@ -6048,10 +6071,25 @@ async function saveAgentModel(agentUrl, model, host) {
6048
6071
  return true;
6049
6072
  }
6050
6073
 
6074
+ /**
6075
+ * Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
6076
+ * listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
6077
+ */
6078
+ async function askKeyName(prompt = " key name: ") {
6079
+ const am = await import("./agent-model-command.mjs");
6080
+ for (let attempt = 0; attempt < 2; attempt++) {
6081
+ const name = (await ask(prompt)).trim();
6082
+ if (!am.looksLikeSecret(name)) return name;
6083
+ console.log(yellow(" that looks like the API key itself — the NAME is shown on screen, so it must not be the key."));
6084
+ console.log(GUTTER + dim('type a short label like "work", or press Enter for none — the key comes next.'));
6085
+ }
6086
+ return "";
6087
+ }
6088
+
6051
6089
  /** Ask for a key and store it BOUND to this provider row. Returns the secret, or "" if cancelled. */
6052
6090
  async function askKeyForRow(row, host) {
6053
6091
  console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
6054
- const name = (await ask(" key name: ")).trim();
6092
+ const name = await askKeyName();
6055
6093
  console.log(GUTTER + dim(SECRET_KEY_HINT));
6056
6094
  const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6057
6095
  if (!secret) {
@@ -6186,9 +6224,15 @@ async function addOllamaAgent() {
6186
6224
  : null;
6187
6225
  let key = "";
6188
6226
  let newRow = null;
6227
+ let hasBoundKey = false;
6189
6228
  if (target.cloud) {
6190
- if (row) key = (await withPendingStatus(`reading the key for ${target.host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6191
- if (!key) {
6229
+ // A KEY SAVED FOR THIS PROVIDER — not any key the account holds. ollama.com's model list answers
6230
+ // without a key, so listing cannot catch a borrowed one; only asking can.
6231
+ if (row) {
6232
+ const keys = await withPendingStatus(`reading the keys for ${target.host}`, fetchCodingKeys);
6233
+ hasBoundKey = Boolean(am.boundKeyFor(keys, row.id));
6234
+ }
6235
+ if (!hasBoundKey) {
6192
6236
  if (!row) {
6193
6237
  if (existing.length >= ps.MAX_BACKENDS) {
6194
6238
  console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
@@ -6251,7 +6295,7 @@ async function addOllamaAgent() {
6251
6295
  row = newRow;
6252
6296
  console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
6253
6297
  }
6254
- if (target.cloud && key && !(await fetchCodingKey(String(row?.id ?? ""))).key) {
6298
+ if (target.cloud && key && !hasBoundKey) {
6255
6299
  const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
6256
6300
  if (!id) {
6257
6301
  console.log(red(" couldn't save the key — the agent was not changed.\n"));
@@ -7191,7 +7235,7 @@ async function deleteCodingKey(id) {
7191
7235
  */
7192
7236
  async function promptNewCodingKey(backendId = "", provider = "") {
7193
7237
  console.log("\n" + GUTTER + dim("A name just tells them apart — \"work\", \"personal\".\n"));
7194
- const name = (await ask(" Name for this key: ")).trim();
7238
+ const name = await askKeyName(" Name for this key: ");
7195
7239
  console.log(GUTTER + dim(SECRET_KEY_HINT));
7196
7240
  const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
7197
7241
  if (!secret) {
@@ -8423,6 +8467,16 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8423
8467
  // 400 chars — enough for a one-line label, not for the paragraphs we now print). Bounded so a
8424
8468
  // runaway generation cannot grow without limit; the prose we keep is far shorter anyway.
8425
8469
  let stepThought = "";
8470
+ // #45: the prose of a step whose output had NO code, held back until the next line says what
8471
+ // became of it. A no-code retry notice discards it (the model restates the plan on the retry);
8472
+ // anything else prints it. Always null under --verbose, which shows everything as it comes.
8473
+ let heldThought = null;
8474
+ const releaseHeldThought = () => {
8475
+ if (heldThought === null) return;
8476
+ const raw = heldThought;
8477
+ heldThought = null;
8478
+ if (printThoughtBlock(raw, onContent)) lastWasBlank = false;
8479
+ };
8426
8480
  let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
8427
8481
  let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
8428
8482
  let lastDsrAt = 0; // throttle the cursor-position probe
@@ -8851,7 +8905,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8851
8905
  carry += stripThinkTags(chunk);
8852
8906
  let nl;
8853
8907
  while ((nl = carry.indexOf("\n")) >= 0) {
8854
- const line = carry.slice(0, nl);
8908
+ let line = carry.slice(0, nl);
8855
8909
  carry = carry.slice(nl + 1);
8856
8910
  const alreadyFlushed = carryFlushedLen;
8857
8911
  carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
@@ -8864,6 +8918,21 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8864
8918
  almostDone = /almost/i.test(line);
8865
8919
  continue;
8866
8920
  }
8921
+ // #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
8922
+ // under --verbose only; otherwise swallowed, together with the no-code false start the
8923
+ // retry notice is about.
8924
+ {
8925
+ const v = splitVerboseLine(line);
8926
+ if (v.verboseOnly) {
8927
+ if (!REPL_VERBOSE) {
8928
+ if (isNoCodeRetry(v.text)) heldThought = null;
8929
+ lastContentAt = Date.now();
8930
+ continue;
8931
+ }
8932
+ line = v.text;
8933
+ }
8934
+ }
8935
+ if (heldThought !== null && line.trim() !== "") releaseHeldThought();
8867
8936
  // Task #136: a RAG index file was written. Swallow the line — it belongs in the
8868
8937
  // BOTTOM BAR, not in the action list — and remember the newest one. Kept out of the
8869
8938
  // bullet stream deliberately: indexing writes a shard per batch, so printing them
@@ -8896,7 +8965,11 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8896
8965
  // The step's model output is complete. Print its reasoning HERE, before the tools
8897
8966
  // report what they did, so the transcript reads in the order it happened: the model
8898
8967
  // says what it intends, then the actions it took appear under it.
8899
- if (printThoughtBlock(stepThought, onContent)) lastWasBlank = false;
8968
+ if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
8969
+ heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
8970
+ } else if (printThoughtBlock(stepThought, onContent)) {
8971
+ lastWasBlank = false;
8972
+ }
8900
8973
  stepThought = "";
8901
8974
  }
8902
8975
  continue;
@@ -9285,6 +9358,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9285
9358
  lastWasBlank = false;
9286
9359
  }
9287
9360
  }
9361
+ releaseHeldThought(); // #45: nothing followed it, so it was not a false start
9288
9362
  clearStatus();
9289
9363
  // Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
9290
9364
  // total (all turns for this user + coding server) so the caller can print a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.703",
3
+ "version": "1.0.705",
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",
@@ -60,6 +60,7 @@
60
60
  "job-pools.mjs",
61
61
  "rag-job-config.mjs",
62
62
  "completed-flush.mjs",
63
+ "verbose-lines.mjs",
63
64
  "agent-model-label.mjs",
64
65
  "coding-choice-tags.mjs",
65
66
  "coding-provider-setup.mjs",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Step lines only `gu --verbose` shows (#45).
3
+ *
4
+ * The agent keeps itself going with retries ("the model wrote no code this step — asking
5
+ * again") and context bookkeeping ("context window 128k … published default", "Context reached
6
+ * ~40k tokens — trimming older steps"). The user can do nothing with either, so the agent tags
7
+ * them with this prefix and the terminal drops them unless it runs with --verbose.
8
+ */
9
+ export const VERBOSE_PREFIX = "__VERBOSE__::";
10
+
11
+ /** Untag a line: `{ verboseOnly, text }`. Lines without the prefix come back unchanged. */
12
+ export function splitVerboseLine(line) {
13
+ const s = String(line ?? "");
14
+ return s.startsWith(VERBOSE_PREFIX)
15
+ ? { verboseOnly: true, text: s.slice(VERBOSE_PREFIX.length) }
16
+ : { verboseOnly: false, text: s };
17
+ }
18
+
19
+ /**
20
+ * The notice that the model's LAST reply carried no code at all. That reply's prose is then a
21
+ * false start: the model is asked again and restates it, so printing both shows the same plan
22
+ * twice around a notice the user does not need.
23
+ */
24
+ export function isNoCodeRetry(text) {
25
+ return /wrote no code this step|thought but didn't act|take the action it described/i.test(
26
+ String(text ?? "")
27
+ );
28
+ }
29
+
30
+ /** Did a step's raw model output contain a code block? */
31
+ export function stepHasCode(raw) {
32
+ return /<code[\s>]/i.test(String(raw ?? ""));
33
+ }