@tiens.nguyen/gu-cli 1.0.803 → 1.0.805

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.
@@ -796,7 +796,8 @@ def _synthesize_search(query, context, base_url, model_id, api_key="local",
796
796
  parts = []
797
797
  t0 = _t.time()
798
798
  try:
799
- stream = client.chat.completions.create(
799
+ stream = _create_with_repair(
800
+ client,
800
801
  model=model_id or "default_model",
801
802
  messages=[
802
803
  {"role": "system", "content": (
@@ -1084,9 +1085,28 @@ class _OutputBudgetExhausted(_AgentConfigError):
1084
1085
  same."""
1085
1086
 
1086
1087
 
1087
- # The retry of a budget-exhausted reply gets this much room (the first attempt had 32,000).
1088
+ # The retry of a budget-exhausted reply gets at least this much room.
1088
1089
  _BUDGET_RETRY_MAX_TOKENS = 64000
1089
1090
 
1091
+ # Native Claude default output budget when the caller sets none. Thinking shares this budget on
1092
+ # the adaptive-thinking models, so 32,000 left too little room for code (cut-offs on big
1093
+ # requests). 64,000 still finishes inside GONEXT_MAX_STREAM_SECONDS at ~50 tok/s. Unknown
1094
+ # Claude models keep the old 32,000. Override with GU_CLAUDE_MAX_TOKENS.
1095
+ _CLAUDE_DEFAULT_MAX_TOKENS = 64000
1096
+
1097
+
1098
+ def _claude_default_max_tokens(model: str) -> int:
1099
+ """Default max_tokens for a native Claude request: 64K, never above the model's known cap."""
1100
+ try:
1101
+ env = int(os.environ.get("GU_CLAUDE_MAX_TOKENS") or 0)
1102
+ except ValueError:
1103
+ env = 0
1104
+ cap = _known_max_output((model or "").lower())
1105
+ want = env or _CLAUDE_DEFAULT_MAX_TOKENS
1106
+ if cap:
1107
+ return min(want, cap)
1108
+ return env or 32000
1109
+
1090
1110
 
1091
1111
  def _output_budget_exhausted(content, finish_reason) -> bool:
1092
1112
  """Did this reply end at the output limit with nothing written? Pure.
@@ -1104,7 +1124,13 @@ def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bo
1104
1124
  kw = dict(completion_kwargs)
1105
1125
  current = int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 0)
1106
1126
  if native_claude:
1107
- kw["max_tokens"] = max(_BUDGET_RETRY_MAX_TOKENS, current)
1127
+ # More room than the first attempt: double it, up to the model's own cap (128K on the
1128
+ # Opus/Sonnet/Fable 5.x rows), never below the flat 64,000.
1129
+ _cap = _known_max_output(str(kw.get("model") or "").lower())
1130
+ # No limit of ours on the first request: the adapter's default applied, so start from it.
1131
+ current = current or _claude_default_max_tokens(str(kw.get("model") or ""))
1132
+ _want = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
1133
+ kw["max_tokens"] = max(_BUDGET_RETRY_MAX_TOKENS, current, min(_want, _cap) if _cap else current)
1108
1134
  if with_effort:
1109
1135
  kw["gu_effort"] = "low"
1110
1136
  else:
@@ -1114,6 +1140,10 @@ def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bo
1114
1140
  # server's own and the request is left as it is.
1115
1141
  key = "max_tokens" if kw.get("max_tokens") else "max_completion_tokens"
1116
1142
  kw[key] = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
1143
+ # Never ask for more than the model can give: a known cap beats the flat 64,000.
1144
+ _cap = _known_max_output(str(kw.get("model") or ""))
1145
+ if _cap:
1146
+ kw[key] = min(kw[key], _cap)
1117
1147
  return kw
1118
1148
 
1119
1149
 
@@ -1281,7 +1311,8 @@ def _anthropic_request(kw: dict) -> dict:
1281
1311
  stops = [s for s in (kw.get("stop") or []) if isinstance(s, str) and s.strip()]
1282
1312
  req = {
1283
1313
  "model": kw.get("model"),
1284
- "max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 32000),
1314
+ "max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens")
1315
+ or _claude_default_max_tokens(str(kw.get("model") or ""))),
1285
1316
  "messages": messages,
1286
1317
  # Mark 3: automatic caching for the growing tail (the step history).
1287
1318
  "cache_control": ephemeral,
@@ -1715,6 +1746,35 @@ def _clip(text: str, limit: int = 280) -> str:
1715
1746
  return f"{cut}…"
1716
1747
 
1717
1748
 
1749
+ def _trim_words(text: str, limit: int = 120) -> str:
1750
+ """Shorten a one-line command preview for a status line WITHOUT cutting mid-word
1751
+ (task #20). Ends on a whole token (a full flag, a whole path segment) and adds "…",
1752
+ so "Command finished → gh issue comment 109 --body-f" reads as "… --repo tiennsloit/gu…".
1753
+ Prefers a space boundary; falls back to the last "/" when the only space is far back
1754
+ (a long path), and to a hard cut only for one giant token. A dangling shell operator
1755
+ (&&, |, ;) is dropped so the line never ends on a half-chain."""
1756
+ text = " ".join(str(text).split())
1757
+ if len(text) <= limit:
1758
+ return text
1759
+ budget = max(1, limit - 1) # room for the ellipsis
1760
+ if len(text) > budget and text[budget] == " ":
1761
+ head = text[:budget]
1762
+ else:
1763
+ seg = text[:budget]
1764
+ sp = seg.rfind(" ")
1765
+ sl = seg.rfind("/")
1766
+ if sp >= budget * 0.6 or (sp > 0 and sl <= sp):
1767
+ head = seg[:sp]
1768
+ elif sl > 0:
1769
+ head = seg[:sl + 1]
1770
+ else:
1771
+ head = seg
1772
+ toks = head.rstrip().split(" ")
1773
+ while len(toks) > 1 and toks[-1] in ("&&", "||", "|", ";", "\\", "-", "--"):
1774
+ toks.pop()
1775
+ return " ".join(toks).rstrip() + "…"
1776
+
1777
+
1718
1778
  def _render_numbered_tool_list(tools) -> str:
1719
1779
  """Render ' N. name(args) — description' lines from the REAL registered smolagents
1720
1780
  tool objects — their actual name/inputs/docstring — instead of a hand-maintained
@@ -2942,32 +3002,260 @@ def _without_params(kwargs: dict, names: list) -> dict:
2942
3002
  return {k: v for k, v in kwargs.items() if k not in names}
2943
3003
 
2944
3004
 
3005
+ # ---------------------------------------------------------------------------------------------
3006
+ # REQUEST-SHAPE REPAIR. A user can pick ANY model, and providers reject request parameters per
3007
+ # model: newer OpenAI / reasoning models want max_completion_tokens and refuse max_tokens, kimi
3008
+ # and the o-series refuse any temperature but 1, old Ollama/vLLM/mlx servers refuse
3009
+ # max_completion_tokens, some servers refuse stop / stream_options. A list of model names goes
3010
+ # stale the day a model ships, so every call reacts to the 400 ITSELF: read what the provider
3011
+ # rejected, change exactly that, ask again, and remember the working shape per (server, model).
3012
+ # ---------------------------------------------------------------------------------------------
3013
+ _REQUEST_SHAPE_CACHE: dict = {}
3014
+ _REQUEST_SHAPE_LOADED = [False]
3015
+ _REPAIR_MAX_ATTEMPTS = 4
3016
+ # A reasoning model spends max_completion_tokens on hidden thinking first, so a tiny budget (the
3017
+ # router probe asks for 3) comes back EMPTY with finish_reason="length". Small budgets are raised.
3018
+ _REASONING_OUTPUT_FLOOR = 1024
3019
+ _REASONING_MODEL_RE = re.compile(r"^(o\d|gpt-5|gpt-6)")
3020
+ _TOKEN_KEYS = ("max_tokens", "max_completion_tokens")
3021
+ # Optional request fields that are safe to drop. NEVER model / messages / stream / tools.
3022
+ _DROPPABLE_PARAMS = ("stop", "presence_penalty", "frequency_penalty", "stream_options", "seed",
3023
+ "logprobs", "top_logprobs", "logit_bias", "n", "user",
3024
+ "parallel_tool_calls", "service_tier", "store", "metadata")
3025
+ _REJECT_WORDS = ("unsupported", "not supported", "unrecognized", "unknown parameter",
3026
+ "extra inputs", "not permitted", "unexpected keyword")
3027
+
3028
+
3029
+ def _token_key(kw: dict) -> str:
3030
+ for k in _TOKEN_KEYS:
3031
+ if kw.get(k):
3032
+ return k
3033
+ return ""
3034
+
3035
+
3036
+ def _output_limit_from_error(text) -> int:
3037
+ """The output cap a provider named while refusing a too-large max_tokens, or 0. Pure.
3038
+
3039
+ "max_tokens is too large: 200000. This model supports at most 16384 completion tokens"."""
3040
+ s = str(text or "").lower()
3041
+ if not s or "context length" in s:
3042
+ return 0
3043
+ if not any(k in s for k in ("max_tokens", "max_completion_tokens", "completion tokens",
3044
+ "output tokens")):
3045
+ return 0
3046
+ for pat in (r"(?:at most|less than or equal to|no more than|up to|"
3047
+ r"maximum (?:allowed )?(?:value )?(?:is|of))\s*:?\s*(\d[\d,]*)",
3048
+ r"(?:supports?|allows?)\s+(?:a\s+)?(?:max(?:imum)?(?: of)?\s+)?(\d[\d,]*)\s+"
3049
+ r"(?:completion|output|max)"):
3050
+ m = re.search(pat, s)
3051
+ if m:
3052
+ try:
3053
+ n = int(m.group(1).replace(",", ""))
3054
+ except ValueError:
3055
+ continue
3056
+ if n >= 256:
3057
+ return n
3058
+ return 0
3059
+
3060
+
3061
+ def _repair_request(kwargs: dict, err):
3062
+ """One fix for a 400 that rejected the request SHAPE. Pure.
3063
+
3064
+ Returns (new_kwargs, action) or None when nothing here can help. Actions: "clamp:<key>",
3065
+ "rename-token", "drop:<names>". Never touches model / messages / stream."""
3066
+ msg = str(err).lower()
3067
+ if not any(k in msg for k in ("400", "invalid_request", "invalid request") + _REJECT_WORDS):
3068
+ return None
3069
+ kw = dict(kwargs)
3070
+ tk = _token_key(kw)
3071
+ # 1. the provider named its output cap
3072
+ lim = _output_limit_from_error(msg)
3073
+ if lim and tk:
3074
+ try:
3075
+ if int(kw[tk]) > lim:
3076
+ kw[tk] = lim
3077
+ return kw, f"clamp:{tk}"
3078
+ except (TypeError, ValueError):
3079
+ pass
3080
+ rejects = any(w in msg for w in _REJECT_WORDS)
3081
+ # 2. which spelling of the output budget this model wants
3082
+ if "max_tokens" in kw and "max_tokens" in msg and (
3083
+ "max_completion_tokens" in msg or rejects):
3084
+ kw["max_completion_tokens"] = kw.pop("max_tokens")
3085
+ return kw, "rename-token"
3086
+ if ("max_completion_tokens" in kw and "max_completion_tokens" in msg
3087
+ and "max_tokens" not in msg and rejects):
3088
+ kw["max_tokens"] = kw.pop("max_completion_tokens")
3089
+ return kw, "rename-token"
3090
+ # 3. sampling parameters a reasoning model will not take
3091
+ bad = [p for p in _unsupported_sampling_params(err) if p in kw]
3092
+ if bad:
3093
+ return _without_params(kw, bad), "drop:" + ",".join(bad)
3094
+ # 4. any other optional field the provider says it does not know
3095
+ if rejects:
3096
+ names = [n for n in re.findall(r"['\"`]([a-z_]+)['\"`]", msg)
3097
+ if n in _DROPPABLE_PARAMS and n in kw]
3098
+ if not names:
3099
+ names = [n for n in _DROPPABLE_PARAMS
3100
+ if ("_" in n or n in ("seed", "logprobs")) and n in kw
3101
+ and re.search(r"(?<![a-z_])" + n + r"(?![a-z_])", msg)]
3102
+ names = list(dict.fromkeys(names))
3103
+ if names:
3104
+ return _without_params(kw, names), "drop:" + ",".join(names)
3105
+ return None
3106
+
3107
+
3108
+ def _shape_path() -> str:
3109
+ return os.path.join(os.path.expanduser("~"), ".gonext", "request-shapes.json")
3110
+
3111
+
3112
+ def _shape_entries() -> dict:
3113
+ """In-memory shapes, loaded once from disk (the agent process dies every turn)."""
3114
+ if not _REQUEST_SHAPE_LOADED[0]:
3115
+ _REQUEST_SHAPE_LOADED[0] = True
3116
+ try:
3117
+ with open(_shape_path(), "r", encoding="utf-8") as fh:
3118
+ data = json.load(fh)
3119
+ if isinstance(data, dict):
3120
+ for k, v in data.items():
3121
+ if isinstance(v, dict):
3122
+ _REQUEST_SHAPE_CACHE.setdefault(str(k), v)
3123
+ except Exception: # noqa: BLE001 — missing/corrupt just means learning again
3124
+ pass
3125
+ return _REQUEST_SHAPE_CACHE
3126
+
3127
+
3128
+ def _save_shape(key: str, entry: dict) -> None:
3129
+ """Persist one shape atomically (read-merge-write then rename, like the context windows)."""
3130
+ if not key.split("|", 1)[0]:
3131
+ return # no server identity → nothing a later process could match
3132
+ path = _shape_path()
3133
+ try:
3134
+ os.makedirs(os.path.dirname(path), exist_ok=True)
3135
+ merged = {}
3136
+ try:
3137
+ with open(path, "r", encoding="utf-8") as fh:
3138
+ loaded = json.load(fh)
3139
+ if isinstance(loaded, dict):
3140
+ merged = loaded
3141
+ except Exception: # noqa: BLE001
3142
+ merged = {}
3143
+ merged[key] = entry
3144
+ tmp = f"{path}.{os.getpid()}.tmp"
3145
+ with open(tmp, "w", encoding="utf-8") as fh:
3146
+ json.dump(merged, fh, indent=2, sort_keys=True)
3147
+ os.replace(tmp, path)
3148
+ except Exception as e: # noqa: BLE001 — never fail a turn over a cache write
3149
+ _log(f"could not persist the learned request shape: {e}")
3150
+
3151
+
3152
+ def _clamp_output_budget(kw: dict, model: str, learned_cap: int = 0) -> dict:
3153
+ """Keep the output budget inside the model's cap, and above the reasoning floor. Pure."""
3154
+ tk = _token_key(kw)
3155
+ if not tk:
3156
+ return kw
3157
+ try:
3158
+ v = int(kw[tk])
3159
+ except (TypeError, ValueError):
3160
+ return kw
3161
+ mid = (model or "").lower()
3162
+ if _REASONING_MODEL_RE.match(mid) or tk == "max_completion_tokens":
3163
+ v = max(v, _REASONING_OUTPUT_FLOOR)
3164
+ cap = int(learned_cap or 0) or _known_max_output(mid)
3165
+ if cap and v > cap:
3166
+ v = cap
3167
+ if v != kw[tk]:
3168
+ kw = dict(kw)
3169
+ kw[tk] = v
3170
+ return kw
3171
+
3172
+
3173
+ def _apply_request_shape(kwargs: dict, key: str, model: str) -> dict:
3174
+ """The request as this (server, model) is already known to accept it."""
3175
+ kw = dict(kwargs)
3176
+ entry = _shape_entries().get(key) or {}
3177
+ for old, new in (entry.get("rename") or {}).items():
3178
+ if old in kw and new not in kw:
3179
+ kw[new] = kw.pop(old)
3180
+ for p in entry.get("drop") or []:
3181
+ if p not in ("model", "messages", "stream"):
3182
+ kw.pop(p, None)
3183
+ return _clamp_output_budget(kw, model, entry.get("max_out") or 0)
3184
+
3185
+
3186
+ def _remember_shape(key: str, new_kw: dict, action: str) -> None:
3187
+ entry = dict(_shape_entries().get(key) or {})
3188
+ if action == "rename-token":
3189
+ entry["rename"] = ({"max_tokens": "max_completion_tokens"}
3190
+ if "max_completion_tokens" in new_kw
3191
+ else {"max_completion_tokens": "max_tokens"})
3192
+ elif action.startswith("drop:"):
3193
+ entry["drop"] = sorted(set(entry.get("drop") or []) | set(action[5:].split(",")))
3194
+ elif action.startswith("clamp:"):
3195
+ entry["max_out"] = int(new_kw[action[6:]])
3196
+ _REQUEST_SHAPE_CACHE[key] = entry
3197
+ _save_shape(key, entry)
3198
+
3199
+
3200
+ def _create_with_repair(client, **kwargs):
3201
+ """client.chat.completions.create that fixes a 400 about the request SHAPE and retries.
3202
+
3203
+ Up to _REPAIR_MAX_ATTEMPTS repairs; each KIND of fix is applied at most once, so it can never
3204
+ ping-pong between max_tokens and max_completion_tokens. An unfixable 400 is raised as it came."""
3205
+ base = str(getattr(client, "base_url", "") or "")
3206
+ model = str(kwargs.get("model") or "")
3207
+ key = f"{base}|{model}"
3208
+ kw = _apply_request_shape(kwargs, key, model)
3209
+ done = set()
3210
+ for _ in range(_REPAIR_MAX_ATTEMPTS):
3211
+ try:
3212
+ return client.chat.completions.create(**kw)
3213
+ except Exception as e: # noqa: BLE001
3214
+ fixed = _repair_request(kw, e)
3215
+ if not fixed:
3216
+ raise
3217
+ new_kw, action = fixed
3218
+ if action in done or new_kw == kw:
3219
+ raise
3220
+ done.add(action)
3221
+ dropped = [k for k in kw if k not in new_kw]
3222
+ added = [k for k in new_kw if k not in kw]
3223
+ if action == "rename-token":
3224
+ _new_tk = _token_key(new_kw)
3225
+ _old_tk = "max_tokens" if _new_tk == "max_completion_tokens" else "max_completion_tokens"
3226
+ _log(f"model {model!r} wants {_new_tk} instead of {_old_tk}; "
3227
+ "retrying with it (remembered)")
3228
+ elif action.startswith("clamp:"):
3229
+ _log(f"model {model!r} caps its output at {new_kw[action[6:]]} tokens; "
3230
+ "retrying with it (remembered)")
3231
+ else:
3232
+ _log(f"model {model!r} rejects {', '.join(dropped)}; "
3233
+ "retrying at the model's own default")
3234
+ _remember_shape(key, new_kw, action)
3235
+ kw = new_kw
3236
+ return client.chat.completions.create(**kw)
3237
+
3238
+
2945
3239
  def _chat_create(client, **kwargs):
2946
- """chat.completions.create with an mlx_lm safety net: if the server rejects the model
2947
- NAME as an unknown repo, retry once with 'default_model' — mlx_lm.server maps that
2948
- sentinel to whatever model it was launched with (`--model <path>`), so a server
2949
- started as e.g. `mlx_lm.server --model ~/mlx-models/Qwen3-14B-4bit` answers even when
2950
- we only know the bare name. Non-mlx servers surface their ORIGINAL error unchanged."""
3240
+ """chat.completions.create with request-shape repair (see _create_with_repair) and an
3241
+ mlx_lm safety net: if the server rejects the model NAME as an unknown repo, retry once with
3242
+ 'default_model' — mlx_lm.server maps that sentinel to whatever model it was launched with
3243
+ (`--model <path>`). Non-mlx servers surface their ORIGINAL error unchanged."""
2951
3244
  try:
2952
- return client.chat.completions.create(**kwargs)
3245
+ return _create_with_repair(client, **kwargs)
2953
3246
  except Exception as e: # noqa: BLE001
2954
3247
  # THE PROVIDER JUST NAMED ITS OWN LIMIT. Recorded before anything else is tried: this is
2955
3248
  # the only source that cannot be stale, and without it a too-generous prior would refuse
2956
3249
  # every turn forever instead of being corrected by the first one.
2957
3250
  _learn_context_window(str(getattr(client, "base_url", "") or ""),
2958
3251
  str(kwargs.get("model") or ""), e)
2959
- bad = [p for p in _unsupported_sampling_params(e) if p in kwargs]
2960
- if bad:
2961
- _log(f"model {kwargs.get('model')!r} rejects {', '.join(bad)}; "
2962
- "retrying at the model's own default")
2963
- return client.chat.completions.create(**_without_params(kwargs, bad))
2964
3252
  if kwargs.get("model") != "default_model" and _retry_as_default_model_helps(e):
2965
3253
  _log(f"model {kwargs.get('model')!r} not found on server; retrying as "
2966
3254
  "'default_model' (mlx_lm preloaded model)")
2967
3255
  try:
2968
3256
  kwargs2 = dict(kwargs)
2969
3257
  kwargs2["model"] = "default_model"
2970
- return client.chat.completions.create(**kwargs2)
3258
+ return _create_with_repair(client, **kwargs2)
2971
3259
  except Exception: # noqa: BLE001
2972
3260
  raise e # surface the clearer original error, not the retry's
2973
3261
  raise
@@ -2976,15 +3264,15 @@ def _chat_create(client, **kwargs):
2976
3264
  def _chat_create_stream(client, on_delta, **kwargs) -> str:
2977
3265
  """Like _chat_create but STREAMS the completion: on_delta(text) is called for each
2978
3266
  content piece AS IT ARRIVES (so the caller can emit it live), and the full assembled
2979
- text is returned once the stream ends. Applies the same mlx_lm model-not-found retry
2980
- as _chat_create — that failure always happens at/near the very first network
2981
- round-trip (before any token streams), so a clean retry from scratch never risks
3267
+ text is returned once the stream ends. Applies the same request-shape repair and mlx_lm
3268
+ model-not-found retry as _chat_create — those failures always happen at/near the very first
3269
+ network round-trip (before any token streams), so a clean retry from scratch never risks
2982
3270
  re-emitting a partial answer twice."""
2983
3271
  kwargs = dict(kwargs)
2984
3272
  kwargs["stream"] = True
2985
3273
 
2986
3274
  def _run(kw):
2987
- stream = client.chat.completions.create(**kw)
3275
+ stream = _create_with_repair(client, **kw)
2988
3276
  parts = []
2989
3277
  for chunk in stream:
2990
3278
  choices = getattr(chunk, "choices", None)
@@ -3002,13 +3290,6 @@ def _chat_create_stream(client, on_delta, **kwargs) -> str:
3002
3290
  # authority that cannot go stale, so it is recorded before any retry is considered.
3003
3291
  _learn_context_window(str(getattr(client, "base_url", "") or ""),
3004
3292
  str(kwargs.get("model") or ""), e)
3005
- # Same safety as the model-not-found retry below: a 400 rejects the REQUEST, so it
3006
- # lands before any delta streams and a clean re-run can't emit a partial answer twice.
3007
- bad = [p for p in _unsupported_sampling_params(e) if p in kwargs]
3008
- if bad:
3009
- _log(f"model {kwargs.get('model')!r} rejects {', '.join(bad)}; "
3010
- "retrying at the model's own default")
3011
- return _run(_without_params(kwargs, bad))
3012
3293
  if kwargs.get("model") != "default_model" and _retry_as_default_model_helps(e):
3013
3294
  _log(f"model {kwargs.get('model')!r} not found on server; retrying as "
3014
3295
  "'default_model' (mlx_lm preloaded model)")
@@ -3241,6 +3522,11 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
3241
3522
  # gets persisted to conversation history); raising lets it become a genuine job
3242
3523
  # failure instead (see docstring). Kept short/clean (no embedded newlines/HTML — a
3243
3524
  # raw nginx 502 page can be a whole document) for whichever caller logs/surfaces it.
3525
+ # The FIRST error is the model the user asked for; when the stand-in failed differently
3526
+ # (e.g. a 502 on the fallback), the real cause would otherwise be hidden.
3527
+ if first_err is not None and last_err is not first_err and str(first_err) != str(last_err):
3528
+ raise RuntimeError(f"couldn't get a response ({_clip(str(first_err), 110)}; "
3529
+ f"then {_clip(str(last_err), 70)})")
3244
3530
  raise RuntimeError(f"couldn't get a response ({_clip(str(last_err), 160)})")
3245
3531
 
3246
3532
 
@@ -3486,7 +3772,7 @@ def _model_context_window(base_url: str, model_id: str, api_key: str = "") -> in
3486
3772
  # rather than living forever. Being here at all is what stops a hosted model quietly
3487
3773
  # throwing away history on every turn.
3488
3774
  if win <= 0:
3489
- win = _known_context_window(model_id)
3775
+ win = _known_context_window(model_id, base_url)
3490
3776
  if win > 0:
3491
3777
  _log(f"context window: no source reported one for {model_id!r}; using the published "
3492
3778
  f"{win} for its family. /context-window <tokens> overrides this.")
@@ -3580,17 +3866,125 @@ _KNOWN_CTX_WINDOWS: list = [
3580
3866
  ]
3581
3867
 
3582
3868
 
3583
- def _known_context_window(model_id: str) -> int:
3584
- """A published window for a well-known family, or 0. LAST RESORT — see the note above."""
3869
+ def _known_context_window(model_id: str, base_url: str = "") -> int:
3870
+ """A published window for a well-known family, or 0. LAST RESORT — see the note above.
3871
+ The cloud token table (_cloud_limit) is asked first, then the built-in rows."""
3585
3872
  mid = (model_id or "").strip().lower()
3586
3873
  if not mid:
3587
3874
  return 0
3875
+ cloud = _cloud_limit(mid, "ctx", base_url)
3876
+ if cloud:
3877
+ return cloud
3588
3878
  for pattern, tokens, _source in _KNOWN_CTX_WINDOWS:
3589
3879
  if re.match(pattern, mid):
3590
3880
  return tokens
3591
3881
  return 0
3592
3882
 
3593
3883
 
3884
+ # OUTPUT-TOKEN CAPS — the sibling of _KNOWN_CTX_WINDOWS, for the reply instead of the prompt.
3885
+ # Only rows with a citation are listed; a model that matches nothing is left UNCLAMPED and the
3886
+ # provider's own refusal ("max_tokens is too large … at most N") is learned by _repair_request and
3887
+ # remembered per (server, model). A missing row therefore costs one retry, a wrong row would cost
3888
+ # every request, so this list stays short on purpose. Specific rows first.
3889
+ _KNOWN_MAX_OUTPUT = [
3890
+ # Anthropic models overview (https://platform.claude.com/docs/en/models/overview, read 2026-10-05):
3891
+ # Fable 5.1 / Opus 5.5 / Sonnet 5.5 = 128K max output, Haiku 4.5 = 64K.
3892
+ (r"^claude-haiku-4-5", 64_000, "https://platform.claude.com/docs/en/models/overview"),
3893
+ (r"^claude-(opus|sonnet|fable)-5", 128_000, "https://platform.claude.com/docs/en/models/overview"),
3894
+ (r"^gpt-6", 128_000, "OpenAI API models overview (read through a web search summary; "
3895
+ "GPT-6 Astra / 6.1 Sol / 6 Luna: 128,000 output tokens)"),
3896
+ (r"^gpt-5\.1-codex-max", 128_000, "https://developers.openai.com/api/docs/models/gpt-5-codex "
3897
+ "(the note beside _KNOWN_CTX_WINDOWS: 128,000 max output)"),
3898
+ (r"^gpt-4o-mini", 16_384, "OpenAI model page for GPT-4o mini, read through a web search "
3899
+ "summary: 16,384 maximum output tokens"),
3900
+ ]
3901
+
3902
+
3903
+ def _known_max_output(model_id: str, base_url: str = "") -> int:
3904
+ """A published output cap for a well-known family, or 0 (= do not clamp). Cloud table first."""
3905
+ mid = (model_id or "").strip().lower()
3906
+ if not mid:
3907
+ return 0
3908
+ cloud = _cloud_limit(mid, "out", base_url)
3909
+ if cloud:
3910
+ return cloud
3911
+ for pattern, tokens, _source in _KNOWN_MAX_OUTPUT:
3912
+ if re.match(pattern, mid):
3913
+ return tokens
3914
+ return 0
3915
+
3916
+
3917
+ # CLOUD TOKEN TABLE (GET /api/model-limits, pushed from the deploy app's "Push token table to
3918
+ # cloud" button). Read ONCE per job, best effort: a missing or failing API never stops a turn, it
3919
+ # only means the built-in tables below are used. Order at lookup time: this machine's learned
3920
+ # limits and the server's own answer come first (callers), then THIS table, then the built-in one.
3921
+ _CLOUD_LIMITS: dict = {"rows": [], "loaded": False}
3922
+
3923
+
3924
+ def _fetch_cloud_limits(api_base: str, timeout: float = 5.0) -> int:
3925
+ """Load the cloud token-limit rows into _CLOUD_LIMITS; returns how many. Never raises."""
3926
+ base = (api_base or "").rstrip("/")
3927
+ _CLOUD_LIMITS["loaded"] = True
3928
+ if not base:
3929
+ return 0
3930
+ try:
3931
+ req = urllib.request.Request(f"{base}/api/model-limits", method="GET")
3932
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
3933
+ data = json.loads(resp.read().decode("utf-8", "replace") or "{}")
3934
+ except Exception as e: # noqa: BLE001
3935
+ _log(f"cloud token table not read ({type(e).__name__}); using the built-in table")
3936
+ return 0
3937
+ rows = []
3938
+ for r in (data.get("rows") if isinstance(data, dict) else None) or []:
3939
+ if not isinstance(r, dict) or not isinstance(r.get("model"), str) or not r.get("model"):
3940
+ continue
3941
+ host = str(r.get("host") or "").strip().lower()
3942
+ rows.append({
3943
+ "host": "" if host == "*" else host,
3944
+ "model": r["model"],
3945
+ "regex": r.get("origin") == "table",
3946
+ "ctx": r.get("contextWindow") if isinstance(r.get("contextWindow"), int) and r.get("contextWindow") > 0 else 0,
3947
+ "out": r.get("maxOutput") if isinstance(r.get("maxOutput"), int) and r.get("maxOutput") > 0 else 0,
3948
+ })
3949
+ _CLOUD_LIMITS["rows"] = rows
3950
+ _log(f"cloud token table: {len(rows)} rows")
3951
+ return len(rows)
3952
+
3953
+
3954
+ def _cloud_limit(model_id: str, field: str, base_url: str = "") -> int:
3955
+ """The cloud table's value for this model (field "ctx" or "out"), or 0.
3956
+
3957
+ A row for THIS server beats an any-server row; an exact model id beats a family regex."""
3958
+ mid = (model_id or "").strip().lower()
3959
+ if not mid or not _CLOUD_LIMITS["rows"]:
3960
+ return 0
3961
+ try:
3962
+ from urllib.parse import urlparse
3963
+ host = (urlparse(base_url).hostname or "").lower() if base_url else ""
3964
+ except Exception: # noqa: BLE001
3965
+ host = ""
3966
+ best, best_rank = 0, -1
3967
+ for r in _CLOUD_LIMITS["rows"]:
3968
+ val = r.get(field) or 0
3969
+ if not val:
3970
+ continue
3971
+ if r["host"] and r["host"] != host:
3972
+ continue
3973
+ if r["regex"]:
3974
+ try:
3975
+ hit = re.match(r["model"], mid) is not None
3976
+ except re.error:
3977
+ hit = False
3978
+ else:
3979
+ hit = r["model"].strip().lower() == mid
3980
+ if not hit:
3981
+ continue
3982
+ rank = (2 if r["host"] else 0) + (1 if not r["regex"] else 0)
3983
+ if rank > best_rank:
3984
+ best, best_rank = val, rank
3985
+ return best
3986
+
3987
+
3594
3988
  # "maximum context length is 128000 tokens" · "max_tokens is too large: 200000" ·
3595
3989
  # "input length ... exceeds ... 32768" — every provider words it differently and every one of
3596
3990
  # them names the number, which is the only part that matters.
@@ -7241,7 +7635,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
7241
7635
  _srv, _, _rest = command[len("__MCPTOOL__::"):].partition("\t")
7242
7636
  _emit({"type": "step", "text": f"Awaiting your approval: MCP tool {_srv}/{_rest.split(chr(9))[0]}"})
7243
7637
  else:
7244
- _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
7638
+ _emit({"type": "step", "text": f"Awaiting your approval to run: {_trim_words(command)}"})
7245
7639
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
7246
7640
  while _t.time() < deadline:
7247
7641
  _t.sleep(1.2)
@@ -9900,6 +10294,10 @@ def run_agent_chat(cfg):
9900
10294
  # Per-turn, like _compact_state above: the high-water mark that decides when a trim
9901
10295
  # pass is worth breaking the prefix cache for. See _trim_due.
9902
10296
  _trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
10297
+ # Cloud token table (GET /api/model-limits): read before the window is resolved so the first
10298
+ # turn already uses it. Best effort, once per process; a failure only means the built-in table.
10299
+ if not _CLOUD_LIMITS["loaded"]:
10300
+ _fetch_cloud_limits(worker_api_base)
9903
10301
  _ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
9904
10302
  _compact_at = _compact_threshold(_ctx_window)
9905
10303
  _caps = _set_obs_caps(_ctx_window)
@@ -10407,7 +10805,9 @@ def run_agent_chat(cfg):
10407
10805
  self._apply_rate_limit()
10408
10806
  t0 = time.monotonic()
10409
10807
  _log("streamed generate: request issued (stream=True), awaiting first token…")
10410
- stream = self.client.chat.completions.create(**completion_kwargs)
10808
+ # Request-shape repair (max_tokens vs max_completion_tokens, sampling params the
10809
+ # model refuses, output cap named by the provider) — see _create_with_repair.
10810
+ stream = _create_with_repair(self.client, **completion_kwargs)
10411
10811
  parts = []
10412
10812
  rparts = [] # reasoning-channel text (task #111: may hold the <code> block)
10413
10813
  role = "assistant"
@@ -13099,7 +13499,7 @@ def run_agent_chat(cfg):
13099
13499
  _emit({"type": "step", "text": f"Server already up → {_urls} (reused)"})
13100
13500
  _last_obs["text"] = msg
13101
13501
  return msg
13102
- _emit({"type": "step", "text": f"Running → {command[:70]}"})
13502
+ _emit({"type": "step", "text": f"Running → {_trim_words(command)}"})
13103
13503
  t = max(5, min(int(timeout_seconds or 180), 600))
13104
13504
  # Own process group (start_new_session) + output to a log file. This is
13105
13505
  # what lets a server OUTLIVE this job process, and what lets a timeout
@@ -13154,7 +13554,7 @@ def run_agent_chat(cfg):
13154
13554
  if rc == 0 else f"FAILED (exit {rc})"
13155
13555
  )
13156
13556
  _emit({"type": "step", "text":
13157
- f"Command {'finished' if rc == 0 else 'failed'} → {command[:50]}"})
13557
+ f"Command {'finished' if rc == 0 else 'failed'} → {_trim_words(command)}"})
13158
13558
  # SAY "no output" RATHER THAN SHOW NOTHING (task #204). An empty result
13159
13559
  # was indistinguishable from a command that never ran — which is exactly
13160
13560
  # what `cd X && Y` used to do — and the model read the silence as a
package/gu-repl.mjs CHANGED
@@ -130,6 +130,7 @@ import {
130
130
  shouldCancelPrompt,
131
131
  rankSlashCommands,
132
132
  slashMenuWindow,
133
+ trimWords,
133
134
  } from "./terminal-layout.mjs";
134
135
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
135
136
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
@@ -1512,6 +1513,11 @@ function applyClick({ row, col }) {
1512
1513
  void runPricesUpdate();
1513
1514
  return;
1514
1515
  }
1516
+ if (limitsRowTarget && row === updateRowAbs &&
1517
+ col - 1 >= limitsRowTarget.from && col - 1 < limitsRowTarget.to) {
1518
+ void runLimitsSeen();
1519
+ return;
1520
+ }
1515
1521
  if (logsRowTarget && row === updateRowAbs &&
1516
1522
  col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
1517
1523
  void runClearLogs();
@@ -2814,6 +2820,7 @@ function zoneNotice(width) {
2814
2820
  if (up) items.push({ key: "update", clickable: true, paint: accent, text: up.text.slice(up.from) });
2815
2821
  }
2816
2822
  if (priceUpdate) items.push({ key: "prices", clickable: true, paint: accent, text: `${priceUpdateText()} (click)` });
2823
+ if (limitsUpdate) items.push({ key: "limits", clickable: true, paint: accent, text: `${limitsUpdate.label} (click)` });
2817
2824
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2818
2825
  const agents = agentsIndicator(otherAgents);
2819
2826
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
@@ -2900,6 +2907,7 @@ function frozenZone() {
2900
2907
  updateRowTarget = notice?.spans.update ?? null;
2901
2908
  agentsRowTarget = notice?.spans.agents ?? null;
2902
2909
  pricesRowTarget = notice?.spans.prices ?? null;
2910
+ limitsRowTarget = notice?.spans.limits ?? null;
2903
2911
  logsRowTarget = notice?.spans.logs ?? null;
2904
2912
  jumpSpan = notice?.spans.jump ?? null;
2905
2913
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
@@ -2970,7 +2978,7 @@ function paintBar() {
2970
2978
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2971
2979
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2972
2980
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2973
- updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
2981
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || limitsRowTarget || logsRowTarget ? noticeAbs : 0;
2974
2982
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2975
2983
  // is parked on the input line right after, so there is nothing to preserve here.
2976
2984
  ui(
@@ -3132,6 +3140,8 @@ let updateRowAbs = 0;
3132
3140
  let agentsRowTarget = null;
3133
3141
  // …and "new prices updated <date>" (#323).
3134
3142
  let pricesRowTarget = null;
3143
+ // …and "output token limits updated <date>" — the cloud token table changed.
3144
+ let limitsRowTarget = null;
3135
3145
  // …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
3136
3146
  let logsRowTarget = null;
3137
3147
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
@@ -7801,13 +7811,49 @@ async function runLoadSettingsCommand(line) {
7801
7811
  */
7802
7812
  async function applyTransferDoc(value, opts = {}) {
7803
7813
  const pa = await import("./preset-apply.mjs");
7804
- return pa.applyTransfer(value, {
7814
+ const result = await pa.applyTransfer(value, {
7805
7815
  ...opts,
7806
7816
  api: replPresetApi(),
7807
7817
  io: replPresetIo,
7808
7818
  backupDir: join(homedir(), ".gonext", "preset-backups"),
7809
7819
  host: hostname(),
7810
7820
  });
7821
+ if (result?.applied) await refreshAfterPresetApply(value, opts.codingDefault, result);
7822
+ return result;
7823
+ }
7824
+
7825
+ /**
7826
+ * AFTER A PRESET / SETTINGS FILE LANDS, bring the running session in line with it (task #21).
7827
+ * The settings were written, but the REPL kept what it read at startup: the old default coder
7828
+ * (defaultCodingModel/Url), the folder's stored /model pick (which outranks the default, so the
7829
+ * bar and /model kept showing the old coder), and the "no embed model" / "no agent model"
7830
+ * warnings. /model already does this after a change; applying a preset must too.
7831
+ */
7832
+ async function refreshAfterPresetApply(value, codingDefault, result) {
7833
+ try {
7834
+ // The preset's coder IS the account default now — a folder pick would shadow it.
7835
+ if (result.coder && codingDefault?.model) {
7836
+ sessionCodingModel = "";
7837
+ sessionCodingLabel = "";
7838
+ sessionCodingUrl = "";
7839
+ await saveSessionCoding(resolve(process.cwd()));
7840
+ defaultCodingModel = codingDefault.model;
7841
+ defaultCodingUrl = codingDefault.url;
7842
+ }
7843
+ // What the API now answers with is authoritative — read it back rather than assume.
7844
+ const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
7845
+ const p = probe?.payload ?? {};
7846
+ if (p.codingModelId) defaultCodingModel = p.codingModelId;
7847
+ if (p.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
7848
+ activeCoderWhere = whereOfActiveCoder(probe?.codingChoices ?? p.codingChoices, sessionCodingModel);
7849
+ if (value?.agentModel?.name || p.agentMissing === false) agentMissing = false;
7850
+ else if (p.agentMissing === true) agentMissing = true;
7851
+ if (probe?.payload) noModelAtAll = nothingToAnswerWith(p);
7852
+ await syncEmbedWarning({ repair: true });
7853
+ if (barPinned()) paintBar();
7854
+ } catch {
7855
+ /* a failed refresh must never undo a preset that applied — the next launch re-reads everything */
7856
+ }
7811
7857
  }
7812
7858
 
7813
7859
  /** The .json files directly in a folder, with enough of each to recognise a settings file. */
@@ -8059,6 +8105,88 @@ async function runPricesUpdate() {
8059
8105
  }
8060
8106
  }
8061
8107
 
8108
+ // ---------------------------------------------------------------------------------------------
8109
+ // "OUTPUT TOKEN LIMITS UPDATED <date>": the cloud token table (GET /api/model-limits, Mongo
8110
+ // "model_limits", pushed by the Deploy app's "Push token table to cloud" button) is checked at
8111
+ // start and hourly beside the version and price checks. When its updatedAt is newer than the one
8112
+ // this machine last saw (~/.gonext/model-limits-seen.json), a clickable item joins the notice
8113
+ // row. Nothing needs applying: the worker reads the cloud table on every run. The click lists
8114
+ // what changed and marks it seen.
8115
+ // ---------------------------------------------------------------------------------------------
8116
+ let limitsUpdate = null; // { updatedAt, label, rows } while unseen changes wait
8117
+ const limitsSeenFile = () => join(homedir(), ".gonext", "model-limits-seen.json");
8118
+
8119
+ async function readLimitsSeen() {
8120
+ try {
8121
+ const { readFile } = await import("node:fs/promises");
8122
+ return String(JSON.parse(await readFile(limitsSeenFile(), "utf8"))?.seenAt ?? "");
8123
+ } catch {
8124
+ return "";
8125
+ }
8126
+ }
8127
+
8128
+ async function fetchCloudLimits() {
8129
+ const { DEFAULT_API_BASE } = await import("./device-login.mjs");
8130
+ const base = (String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE).replace(/\/+$/, "");
8131
+ try {
8132
+ const r = await fetch(`${base}/api/model-limits`, { signal: AbortSignal.timeout(8000) });
8133
+ if (!r.ok) return null;
8134
+ const j = await r.json().catch(() => null);
8135
+ return j && Array.isArray(j.rows) ? j : null;
8136
+ } catch {
8137
+ return null;
8138
+ }
8139
+ }
8140
+
8141
+ /** Did the cloud token table change since this machine looked? Fails silently to "no". */
8142
+ async function refreshLimitsUpdate() {
8143
+ if (process.env.GU_NO_UPDATE_CHECK === "1") return;
8144
+ try {
8145
+ const ln = await import("./model-limits-notice.mjs");
8146
+ const cloud = await fetchCloudLimits();
8147
+ if (!cloud) return;
8148
+ const seenAt = await readLimitsSeen();
8149
+ const was = Boolean(limitsUpdate);
8150
+ if (!seenAt && cloud.updatedAt) {
8151
+ // First run on this machine: the current table is the baseline, not news.
8152
+ await markLimitsSeen(cloud.updatedAt);
8153
+ limitsUpdate = null;
8154
+ } else {
8155
+ limitsUpdate = ln.hasNewerLimits(seenAt, cloud.updatedAt)
8156
+ ? { updatedAt: cloud.updatedAt, label: ln.limitsUpdateLabel(cloud.updatedAt), rows: cloud.rows, seenAt }
8157
+ : null;
8158
+ }
8159
+ if (Boolean(limitsUpdate) !== was && barPinned()) paintBar();
8160
+ } catch {
8161
+ /* a limits notice is never worth an error */
8162
+ }
8163
+ }
8164
+
8165
+ async function markLimitsSeen(updatedAt) {
8166
+ try {
8167
+ const { mkdir, writeFile } = await import("node:fs/promises");
8168
+ await mkdir(join(homedir(), ".gonext"), { recursive: true });
8169
+ await writeFile(limitsSeenFile(), JSON.stringify({ seenAt: updatedAt }) + "\n");
8170
+ } catch {
8171
+ /* not remembering only means the notice comes back */
8172
+ }
8173
+ }
8174
+
8175
+ /** The notice's click: say what changed, then mark it seen. */
8176
+ async function runLimitsSeen() {
8177
+ const u = limitsUpdate;
8178
+ if (!u) return;
8179
+ const ln = await import("./model-limits-notice.mjs");
8180
+ const c = ln.changedLimitLines(u.rows, u.seenAt);
8181
+ console.log("\n" + GUTTER + accent(`✓ ${u.label}`) +
8182
+ dim(` — ${c.count} row${c.count === 1 ? "" : "s"} changed in the cloud token table; gu uses them from the next turn.`));
8183
+ for (const l of c.lines) console.log(GUTTER + dim(" " + l));
8184
+ console.log("");
8185
+ await markLimitsSeen(u.updatedAt);
8186
+ limitsUpdate = null;
8187
+ if (barPinned()) paintBar();
8188
+ }
8189
+
8062
8190
  // ---------------------------------------------------------------------------------------------
8063
8191
  // "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
8064
8192
  // and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
@@ -11385,7 +11513,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11385
11513
  // long CPU-bound step reads as what it's actually reasoning about, not "Thinking")
11386
11514
  // > the "almost done" heartbeat > the bare fallback.
11387
11515
  const max = Math.max(24, (process.stdout.columns || 80) - 14);
11388
- const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
11516
+ const fit = (s) => trimWords(s, max); // whole words + "…" (task #20)
11389
11517
  // Prompt tokens this turn (task #83) + the peak single request (task #84). The global
11390
11518
  // OUTPUT total (task #104) used to hang off this line too, but the bottom bar carries it
11391
11519
  // now ("… total ↓ n") one row below — showing the same figure twice read as a bug.
@@ -12011,7 +12139,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12011
12139
  continue;
12012
12140
  }
12013
12141
  const st = classifyBullet(stepText);
12014
- const bRows = wrapBlock(stepText, GUTTER, process.stdout.columns || 80);
12142
+ // A finished/failed command fits ONE row, trimmed at a word boundary with "…" (task #20).
12143
+ const mCmd = /^(Command (?:finished|failed) → )(.+)$/.exec(stepText);
12144
+ const shown = mCmd
12145
+ ? mCmd[1] + trimWords(mCmd[2], Math.max(20, (process.stdout.columns || 80) - GUTTER.length - mCmd[1].length - 1))
12146
+ : stepText;
12147
+ const bRows = wrapBlock(shown, GUTTER, process.stdout.columns || 80);
12015
12148
  // One empty line above EVERY bullet now (see openBullet), which SUBSUMES the old
12016
12149
  // special case that opened only the turn's final phase — opensAnswerPhase's blank is
12017
12150
  // just one of these. Still idempotent, so it cannot double the seam the caller owns.
@@ -13268,6 +13401,9 @@ async function main() {
13268
13401
  // #323: gu's newest published price + cache tables — same cadence, same silent failure.
13269
13402
  void refreshPriceUpdate();
13270
13403
  setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
13404
+ // The cloud token table (model_limits): "output token limits updated <date>" — same cadence.
13405
+ void refreshLimitsUpdate();
13406
+ setInterval(() => void refreshLimitsUpdate(), UPDATE_CHECK_MS).unref();
13271
13407
  void refreshLogsSize();
13272
13408
  setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
13273
13409
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * "OUTPUT TOKEN LIMITS UPDATED <date>": the cloud token table (GET /api/model-limits, Mongo
3
+ * "model_limits", written by the Deploy app's "Push token table to cloud" button through
4
+ * PUT /api/model-limits) changed since this machine last looked.
5
+ *
6
+ * The worker already READS the cloud table at the start of every run, so nothing has to be
7
+ * applied. The notice is there so a change is VISIBLE. It sits in the same place as gu's other
8
+ * updates ("⭯ updates…", "new prices updated…"). A click lists what changed and marks it seen.
9
+ *
10
+ * Pure: the fetch, the clock and the file belong to gu-repl.mjs. A failed probe means "no notice".
11
+ */
12
+
13
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
14
+
15
+ /** Is the cloud table's updatedAt newer than the one this machine saw? ("" seen = never.) */
16
+ export function hasNewerLimits(seenAt, updatedAt) {
17
+ const u = Date.parse(updatedAt ?? "");
18
+ if (!Number.isFinite(u)) return false;
19
+ const s = Date.parse(seenAt ?? "");
20
+ return !Number.isFinite(s) || u > s;
21
+ }
22
+
23
+ /** The notice: "output token limits updated 5 Oct 2026". */
24
+ export function limitsUpdateLabel(updatedAt) {
25
+ const d = new Date(updatedAt ?? "");
26
+ if (Number.isNaN(d.getTime())) return "output token limits updated";
27
+ return `output token limits updated ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
28
+ }
29
+
30
+ const fmt = (n) => (Number.isFinite(n) ? `${Math.round(n / 1000)}K` : "—");
31
+
32
+ /**
33
+ * The rows changed after `seenAt`, newest first, as lines:
34
+ * "claude-opus-5-5 · any host · max output 128K · window 1000K"
35
+ * Everything when nothing was seen yet. At most `max` lines, then "…and N more".
36
+ */
37
+ export function changedLimitLines(rows, seenAt = "", { max = 12 } = {}) {
38
+ const s = Date.parse(seenAt ?? "");
39
+ const changed = (Array.isArray(rows) ? rows : [])
40
+ .filter((r) => r && r.model && (!Number.isFinite(s) || Date.parse(r.updatedAt ?? "") > s))
41
+ .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
42
+ const lines = changed.slice(0, max).map((r) => {
43
+ const parts = [r.model, r.host ? r.host : "any host"];
44
+ if (r.maxOutput) parts.push(`max output ${fmt(r.maxOutput)}`);
45
+ if (r.contextWindow) parts.push(`window ${fmt(r.contextWindow)}`);
46
+ return parts.join(" · ");
47
+ });
48
+ if (changed.length > max) lines.push(`…and ${changed.length - max} more`);
49
+ return { count: changed.length, lines };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.803",
3
+ "version": "1.0.805",
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",
@@ -73,6 +73,7 @@
73
73
  "preset-editor.mjs",
74
74
  "preset-apply.mjs",
75
75
  "model-prices.mjs",
76
+ "model-limits-notice.mjs",
76
77
  "device-login.mjs",
77
78
  "daemon-control.mjs",
78
79
  "log-policy.mjs",
@@ -1007,6 +1007,31 @@ export const isToolStepSummary = (line) => / \| → /.test(line);
1007
1007
  // Glyph vocabulary for an action line: a FAILURE pops (✗, full-bright), a completed
1008
1008
  // action recedes (✓, dim), anything else is neutral (●, dim). Returns the KIND only —
1009
1009
  // the REPL owns the colours, so this stays pure and testable.
1010
+ /**
1011
+ * Shorten a one-line command preview WITHOUT cutting mid-word (task #20): end on a whole
1012
+ * token (a full flag, a whole path segment) and add "…". Prefers a space; falls back to the
1013
+ * last "/" when the only space is far back (a long path); hard-cuts only one giant token.
1014
+ * A dangling shell operator (&&, |, ;) is dropped. Mirrors _trim_words in gonext_agent_chat.py.
1015
+ */
1016
+ export function trimWords(text, limit) {
1017
+ const s = String(text ?? "").split(/\s+/).filter(Boolean).join(" ");
1018
+ if (s.length <= limit) return s;
1019
+ const budget = Math.max(1, limit - 1);
1020
+ let head;
1021
+ if (s[budget] === " ") head = s.slice(0, budget);
1022
+ else {
1023
+ const seg = s.slice(0, budget);
1024
+ const sp = seg.lastIndexOf(" ");
1025
+ const sl = seg.lastIndexOf("/");
1026
+ if (sp >= budget * 0.6 || (sp > 0 && sl <= sp)) head = seg.slice(0, sp);
1027
+ else if (sl > 0) head = seg.slice(0, sl + 1);
1028
+ else head = seg;
1029
+ }
1030
+ const toks = head.trimEnd().split(" ");
1031
+ while (toks.length > 1 && ["&&", "||", "|", ";", "\\", "-", "--"].includes(toks[toks.length - 1])) toks.pop();
1032
+ return toks.join(" ").trimEnd() + "…";
1033
+ }
1034
+
1010
1035
  export function bulletKind(text) {
1011
1036
  if (/\bfail(ed|s|ure)?\b|\berror(ed)?\b|→\s*(?:HTTP\s*)?[45]\d\d\b/i.test(text)) return "fail";
1012
1037
  if (/^(?:Command finished|Server up|App launched)\b|→\s*(?:HTTP\s*)?2\d\d\b|\b(?:passed|succeeded|ready)\b/i.test(text))