@tiens.nguyen/gu-cli 1.0.726 → 1.0.728

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/cli-token.mjs CHANGED
@@ -291,7 +291,11 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
291
291
  if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
292
292
  return {
293
293
  kind: "over-limit",
294
- text: `gu is active on ${p.activeDevices} devices — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
294
+ // PLAIN WORDS (asked: "make it simple to understand"). It read "gu is active on 4 devices —
295
+ // Free covers 1", which leaves the reader to work out that "active" means "used gu in the
296
+ // last 30 days" and "covers" means "the plan allows". Kept short: the bar drops it entirely
297
+ // when it does not fit the terminal's width.
298
+ text: `${planName(p.plan)} allows ${computers(p.deviceLimit)} — you've used gu on ${p.activeDevices} this month`,
295
299
  };
296
300
  }
297
301
  const age = now.getTime() - Date.parse(p.issuedAt);
@@ -305,6 +309,11 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
305
309
  : { kind: "stale", text: "Sign in to keep getting gu updates — run gu login" };
306
310
  }
307
311
 
312
+ /** "Free plan" / "Pro plan" — the name a person uses. */
313
+ const planName = (plan) => (plan === "pro" ? "Pro plan" : "Free plan");
314
+ /** "1 computer" / "4 computers" — "computer", not "device": a device is also a phone. */
315
+ const computers = (n) => `${n} ${n === 1 ? "computer" : "computers"}`;
316
+
308
317
  /** What `gu token` prints about a valid token — plain lines, no colour, for the command to style. */
309
318
  export function tokenSummary(payload, now = new Date()) {
310
319
  const lines = [];
@@ -317,7 +326,7 @@ export function tokenSummary(payload, now = new Date()) {
317
326
  }
318
327
  if (Number.isInteger(payload.activeDevices)) {
319
328
  const n = payload.activeDevices;
320
- lines.push(`Active on ${n} ${n === 1 ? "device" : "devices"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
329
+ lines.push(`Used on ${computers(n)} in the last 30 days — the ${planName(payload.plan)} allows ${payload.deviceLimit}.`);
321
330
  }
322
331
  lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
323
332
  return lines;
@@ -370,8 +379,8 @@ export function enforcementDecision({ token, publicKeyB64, now = new Date(), sit
370
379
  enforced: true,
371
380
  allowed: false,
372
381
  why:
373
- `This account is active on ${p.activeDevices} devices and Pro covers ${p.deviceLimit}. ` +
374
- `Sign out of one, or remove it at ${accountUrl}.`,
382
+ `This account has used gu on ${computers(p.activeDevices)} in the last 30 days, and the ` +
383
+ `${planName(p.plan)} allows ${p.deviceLimit}. Sign out of one, or remove it at ${accountUrl}.`,
375
384
  };
376
385
  }
377
386
  return { enforced: true, allowed: true, why: "" };
@@ -1757,15 +1757,15 @@ def _load_prompt_scaffold(api_base: str, worker_key: str):
1757
1757
  file is NOT quietly replaced by the database version, or an edit that broke it would look like
1758
1758
  it had no effect."""
1759
1759
  if os.path.isfile(_LOCAL_SCAFFOLD_PATH):
1760
- where = "~/.gonext/prompt-scaffolds/code-agent.txt"
1760
+ where = _psc.LOCAL_SCAFFOLD_LABEL # never the path — see prompt_scaffold_check.py
1761
1761
  try:
1762
1762
  with open(_LOCAL_SCAFFOLD_PATH, encoding="utf-8") as f:
1763
1763
  text = f.read()
1764
1764
  except Exception as e: # noqa: BLE001
1765
1765
  raise _AgentConfigError(
1766
- f"The agent couldn't start: your local scaffold {where} could not be read "
1766
+ f"The agent couldn't start: {where} could not be read "
1767
1767
  f"({type(e).__name__}). Fix it, or delete it to use the gu service's.") from None
1768
- problem = _scaffold_problem(text, f"your local scaffold {where}")
1768
+ problem = _scaffold_problem(text, where)
1769
1769
  if problem:
1770
1770
  raise _AgentConfigError(
1771
1771
  f"The agent couldn't start: {problem}. Fix it, or delete it to use the gu service's.")
@@ -2356,7 +2356,8 @@ def _render_prompt(messages, max_chars: int = 400_000) -> str:
2356
2356
  # the API and shown on the token-usage page — so it would hand the hidden text to
2357
2357
  # anyone who opens a step. Its size stays visible, the text does not.
2358
2358
  v = _PROMPT_SCAFFOLD_VERSION["v"]
2359
- label = " (your local file)" if v == "local" else (f" v{v}" if v is not None else "")
2359
+ # The same words whatever the source — a local file is never announced (the user's call).
2360
+ label = f" v{v}" if isinstance(v, int) else ""
2360
2361
  text = (f"[prompt scaffold{label} with the tool signatures — "
2361
2362
  f"{len(text)} characters, not shown]")
2362
2363
  out.append(f"───── {role or 'message'} ─────\n{text}")
@@ -12177,10 +12178,8 @@ def run_agent_chat(cfg):
12177
12178
  # TURN_FAILED) instead of quietly running on a different prompt. The version is
12178
12179
  # logged; the text never is.
12179
12180
  _scaffold, _scaffold_version, _scaffold_source = _load_prompt_scaffold(worker_api_base, worker_api_key)
12180
- if _scaffold_source != "api":
12181
- # SAID, every turn it applies: an old file left lying around must never quietly
12182
- # change the agent. One line in the thinking area, not a banner.
12183
- _emit({"type": "step", "text": f"using your local prompt scaffold ({_scaffold_source})"})
12181
+ # A LOCAL FILE IS USED SILENTLY — no line, no location (the user's call: "if it is on
12182
+ # .gonext location, not say anything"). Only a BROKEN one speaks, and without the path.
12184
12183
  try:
12185
12184
  import yaml as _yaml
12186
12185
  import os as _os
package/gu-cli.mjs CHANGED
@@ -679,7 +679,6 @@ const currentApiVersion = async () => {
679
679
  // machine that was already paired, i.e. every machine that could need an upgrade, had no
680
680
  // path to one. A published API fix reached nobody.
681
681
  const run = await import("./client-expert-run.mjs");
682
- const { isNewer } = await import("./version-check.mjs");
683
682
  const running = await currentVersion();
684
683
 
685
684
  // TOP UP THE SEEDED SETTINGS FIRST, AND UNCONDITIONALLY.
@@ -719,8 +718,19 @@ const currentApiVersion = async () => {
719
718
  const dir = run.apiInstallDir(manifest.version);
720
719
  const depsIn = await ce.apiDepsInstalled(dir);
721
720
  const present = (await run.probeApiInstall(manifest.version)).installed;
722
- if (!isNewer(manifest.version, running) && depsIn) {
723
- console.log(`\n Already on ${running} — nothing newer published.\n`);
721
+ // DECIDED FROM WHAT IS RUNNING, not only what is on disk (see upgradePlan). A build already
722
+ // downloaded but never started used to answer "Already on 1.0.161 — nothing newer published"
723
+ // and leave yesterday's 1.0.153 process serving. Reported from the startup scaffold line.
724
+ const { upgradePlan } = await import("./version-check.mjs");
725
+ const liveVersion = String((await ce.probeLocalApi()).body?.version ?? "").trim();
726
+ const plan = upgradePlan({
727
+ published: manifest.version,
728
+ installed: running,
729
+ running: liveVersion,
730
+ ready: present && depsIn,
731
+ });
732
+ if (plan.action === "none") {
733
+ console.log(`\n ${plan.say}\n`);
724
734
  process.exit(0);
725
735
  }
726
736
  // A version already on disk but unusable is a RESUME, not an upgrade — someone whose
@@ -730,12 +740,12 @@ const currentApiVersion = async () => {
730
740
  // Caught by running it: "Finishing an interrupted install of 1.0.30" on a box going
731
741
  // cleanly from 1.0.29.
732
742
  console.log(
733
- present && !depsIn
743
+ plan.action === "install" && present && !depsIn
734
744
  ? `\n Finishing an interrupted install of ${manifest.version}`
735
- : `\n Upgrading the API: ${running || "none"} → ${manifest.version}`
745
+ : `\n ${plan.say}`
736
746
  );
737
747
  try {
738
- if (!present || !depsIn) {
748
+ if (plan.action === "install" && (!present || !depsIn)) {
739
749
  await run.downloadApiPackage(manifest, { onProgress: (l) => console.log(` ${l}`) });
740
750
  await ce.installApiDeps(dir, {
741
751
  onLine: (l) => l && l.length < 160 && console.log(` ${l}`),
@@ -760,12 +770,19 @@ const currentApiVersion = async () => {
760
770
  console.log(` Starting ${version}…`);
761
771
  await ce.startLocalApi({ apiDir: run.apiInstallDir(version) });
762
772
  const health = await ce.waitForApi({});
773
+ // "An API answers" is not "the NEW API answers": an old process that never stopped answers
774
+ // too, and this line once reported the new version while the old one kept serving.
775
+ const serving = String((await ce.probeLocalApi()).body?.version ?? "").trim();
776
+ const wrong = health.ok && Boolean(serving) && serving !== version;
763
777
  console.log(
764
- health.ok
765
- ? ` API is up on ${ce.LOCAL_API_BASE} (${version})\n`
766
- : ` API did not come up: ${health.why}\n Log: ${ce.API_LOG_FILE}\n`
778
+ !health.ok
779
+ ? ` API did not come up: ${health.why}\n Log: ${ce.API_LOG_FILE}\n`
780
+ : wrong
781
+ ? ` ${serving} is still the one answering on ${ce.LOCAL_API_BASE}, not ${version}.\n` +
782
+ ` Stop it (gu-cli api stop), then: gu-cli api start\n`
783
+ : ` API is up on ${ce.LOCAL_API_BASE} (${serving || version})\n`
767
784
  );
768
- process.exit(health.ok ? 0 : 1);
785
+ process.exit(health.ok && !wrong ? 0 : 1);
769
786
  }
770
787
 
771
788
  if (sub === "stop") {
package/gu-repl.mjs CHANGED
@@ -8744,14 +8744,14 @@ const wrapThought = (s, width, maxLines) => {
8744
8744
  * own conversation.
8745
8745
  */
8746
8746
  /**
8747
- * WHERE THIS SESSION'S SCAFFOLD COMES FROM, said at `gu` start (task #246) — "scaffold loaded" from
8748
- * the user's own ~/.gonext/prompt-scaffolds/code-agent.txt, or "scaffold fetched" from the gu
8749
- * service. Background: startup never waits on it (an offline private-mode machine would otherwise
8747
+ * WHERE THIS SESSION'S SCAFFOLD COMES FROM, said at `gu` start (task #246) — "scaffold fetched" from
8748
+ * the gu service; NOTHING at all when it is the user's own local file (by request — its location is
8749
+ * never printed), unless that file is broken. Background: startup never waits on it (an offline private-mode machine would otherwise
8750
8750
  * sit for 10s); the line prints when the answer arrives, or at the next prompt if a turn is running.
8751
8751
  *
8752
8752
  * · A LOCAL FILE is checked with the worker's OWN checker (prompt_scaffold_check.py — size,
8753
- * placeholders, a trial render), so "loaded" means it passed, not merely that it exists. The API
8754
- * is not asked: turns use the file.
8753
+ * placeholders, a trial render); a good one is silent, a broken one is reported. The API is not
8754
+ * asked: turns use the file.
8755
8755
  * · Otherwise the API is asked to REFRESH — the fetch that the rest of the session then reuses
8756
8756
  * ("keep in cache until the user type gu").
8757
8757
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.726",
3
+ "version": "1.0.728",
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",
@@ -6,9 +6,9 @@ at startup (`python3 prompt_scaffold_check.py`) so the "scaffold loaded" line it
6
6
  file really passed — not just that a file exists. Needs only jinja2 (a smolagents dependency), so
7
7
  the startup check is quick.
8
8
 
9
- python3 prompt_scaffold_check.py [path] → one JSON line: {"exists", "path", "chars", "problem"}
9
+ python3 prompt_scaffold_check.py [path] → one JSON line: {"exists", "chars", "problem"}
10
10
 
11
- It never prints the scaffold text.
11
+ It never prints the scaffold text, nor where the file is.
12
12
  """
13
13
  import json
14
14
  import os
@@ -19,7 +19,10 @@ import sys
19
19
  # file — the scaffold stays off users' disks unless they put it there themselves — so it is an
20
20
  # override for trying a wording before inserting it into production, not a cache.
21
21
  LOCAL_SCAFFOLD_PATH = os.path.join(os.path.expanduser("~"), ".gonext", "prompt-scaffolds", "code-agent.txt")
22
- LOCAL_SCAFFOLD_LABEL = "~/.gonext/prompt-scaffolds/code-agent.txt"
22
+ # How the file is NAMED in anything a person reads: never its location. Asked for: "dont say out loud
23
+ # where is the location of the scaffold file … not say anything". The deploy app's own Commands
24
+ # window is the one place the path is written down, for its owner.
25
+ LOCAL_SCAFFOLD_LABEL = "your local scaffold file"
23
26
 
24
27
  # What a scaffold must keep — the same list the API and the deploy app use.
25
28
  SCAFFOLD_REQUIRED = (
@@ -97,18 +100,18 @@ def scaffold_problem(template, source: str) -> str:
97
100
  def check_file(path: str = LOCAL_SCAFFOLD_PATH, label: str = LOCAL_SCAFFOLD_LABEL) -> dict:
98
101
  """The local override's state, for `gu` at startup. Never includes the text."""
99
102
  if not os.path.isfile(path):
100
- return {"exists": False, "path": label}
103
+ return {"exists": False}
101
104
  try:
102
105
  with open(path, encoding="utf-8") as f:
103
106
  text = f.read()
104
107
  except Exception as e: # noqa: BLE001
105
- return {"exists": True, "path": label, "chars": 0,
106
- "problem": f"your local scaffold {label} could not be read ({type(e).__name__})"}
107
- return {"exists": True, "path": label, "chars": len(text),
108
- "problem": scaffold_problem(text, f"your local scaffold {label}")}
108
+ return {"exists": True, "chars": 0,
109
+ "problem": f"{label} could not be read ({type(e).__name__})"}
110
+ return {"exists": True, "chars": len(text),
111
+ "problem": scaffold_problem(text, label)}
109
112
 
110
113
 
111
114
  if __name__ == "__main__":
112
115
  target = sys.argv[1] if len(sys.argv) > 1 else LOCAL_SCAFFOLD_PATH
113
- label = LOCAL_SCAFFOLD_LABEL if len(sys.argv) <= 1 else target
116
+ label = LOCAL_SCAFFOLD_LABEL
114
117
  print(json.dumps(check_file(target, label)))
@@ -1770,20 +1770,22 @@ export function lazyModuleNames(text) {
1770
1770
 
1771
1771
  /**
1772
1772
  * The one line `gu` prints at startup about the agent's prompt scaffold (task #246) — asked for so
1773
- * the user KNOWS where this session's scaffold came from: "scaffold is loaded" (their own file) or
1774
- * "scaffold is fetched" (the gu service). Pure; gu-repl gathers the facts.
1773
+ * the user knows where this session's scaffold came from — the gu service ("fetched") — and hears
1774
+ * NOTHING when it is their own local file (by request). Pure; gu-repl gathers the facts.
1775
1775
  *
1776
1776
  * local — prompt_scaffold_check.py's answer: { exists, path, chars, problem } (null = not run)
1777
1777
  * refresh — POST /api/worker/prompt-scaffold/refresh: { status, ok, version, error, kept }
1778
1778
  * private — private mode (the local API), which decides what "update" advice means
1779
- * Returns { kind: "ok" | "warn" | "err", text }.
1779
+ * Returns { kind: "ok" | "warn" | "err", text }, or null when there is nothing to say.
1780
1780
  */
1781
1781
  export function scaffoldStartupNotice({ local = null, refresh = null, private: privateMode = false } = {}) {
1782
- const n = (x) => Number(x ?? 0).toLocaleString("en-US");
1782
+ // YOUR LOCAL FILE, WORKING: nothing is said — no line, no location (the user's call: "dont say
1783
+ // out loud where is the location of the scaffold file … not say anything"). null = print nothing.
1784
+ // A BROKEN one still speaks, without the path: otherwise every turn fails with no explanation.
1783
1785
  if (local?.exists) {
1784
1786
  return local.problem
1785
1787
  ? { kind: "err", text: `scaffold: ${local.problem} — fix it, or delete it to use the gu service's` }
1786
- : { kind: "ok", text: `scaffold loaded from ${local.path} (${n(local.chars)} chars)` };
1788
+ : null;
1787
1789
  }
1788
1790
  if (!refresh) return { kind: "warn", text: "scaffold: couldn't ask the API — the first turn will try again" };
1789
1791
  if (refresh.status === 404) {
package/version-check.mjs CHANGED
@@ -70,3 +70,37 @@ export function updatesAvailable({ cliRunning, cliLatest, apiRunning, apiLatest
70
70
  const api = isNewer(apiLatest, apiRunning) ? String(apiLatest) : "";
71
71
  return { cli, api, any: Boolean(cli || api) };
72
72
  }
73
+
74
+ /**
75
+ * WHAT `gu api upgrade` SHOULD DO — decided from the version that is RUNNING, not only the one on
76
+ * disk. Pure, so every case is tested.
77
+ *
78
+ * REPORTED: the startup line said "your local gu API is older than this gu — run gu api upgrade",
79
+ * and upgrading did nothing. 1.0.161 was already downloaded (by the in-app updater), so the old
80
+ * check compared "published 1.0.161" with "installed 1.0.161", answered "Already on 1.0.161 —
81
+ * nothing newer published" and exited — while a process started the day before from 1.0.153 kept
82
+ * serving. `gu api status` had learnt this difference long ago; `upgrade` had not.
83
+ *
84
+ * published — the manifest's version installed — the newest usable build on disk
85
+ * running — what /api/health reports ("" when the API is down or too old to say)
86
+ * ready — `installed` has its dependencies (a runnable install)
87
+ * Returns { action: "none" | "install" | "restart", say }:
88
+ * install → download/finish the published build, then restart onto it;
89
+ * restart → nothing to download, but the running process is not the installed build.
90
+ */
91
+ export function upgradePlan({ published = "", installed = "", running = "", ready = false } = {}) {
92
+ const target = published && (!installed || isNewer(published, installed)) ? published : installed;
93
+ if (target === published && published && (!ready || installed !== published)) {
94
+ return { action: "install", say: `Upgrading the API: ${running || installed || "none"} → ${published}` };
95
+ }
96
+ if (running && (running === target || isNewer(running, target))) {
97
+ // Running something at least as new as anything here — never "restart" onto an older build.
98
+ return { action: "none", say: `Already on ${running} — nothing newer published.` };
99
+ }
100
+ return {
101
+ action: "restart",
102
+ say: running
103
+ ? `${target} is installed but ${running} is still running — restarting onto ${target}`
104
+ : `Starting ${target}`,
105
+ };
106
+ }