@tiens.nguyen/gu-cli 1.0.795 → 1.0.797

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.
@@ -701,7 +701,7 @@ export async function probeLocalOllama() {
701
701
  */
702
702
  /** The shared Ollama every client machine can already reach. */
703
703
  export const SHARED_OLLAMA_URL =
704
- process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.gomarsic.cc/v1";
704
+ process.env.GONEXT_CLIENT_AGENT_URL?.trim() || "https://ollama1.getmarsic.cc/v1";
705
705
  /**
706
706
  * Chat model — the one the agent routes and thinks with, on every step of every turn.
707
707
  *
@@ -69,7 +69,7 @@ export function codingChoiceTags(choices) {
69
69
  }
70
70
 
71
71
  /**
72
- * WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.gomarsic.cc" — or "" for any
72
+ * WHERE AN OLLAMA CODER RUNS — "ollama.com", "127.0.0.1", "ollama1.getmarsic.cc" — or "" for any
73
73
  * other kind.
74
74
  *
75
75
  * Reported: "/model ✓ coding model → kimi-k3 (ollama)". The tag was the API's provider label, the
@@ -37,10 +37,10 @@ export const SECRET_KEY_HINT = "type or paste the key, then press Enter — it s
37
37
  * that does not exist and, worse, reappears on every start because it can never be satisfied.
38
38
  *
39
39
  * Reported live: a Client Expert machine whose coder is gemma4-quadro-49k on
40
- * ollama1.gomarsic.cc being asked, at every launch, for that model's "API key".
40
+ * ollama1.getmarsic.cc being asked, at every launch, for that model's "API key".
41
41
  *
42
- * Matched on the HOST, not a substring: `evil.com/?x=gomarsic.cc` and
43
- * `gomarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
42
+ * Matched on the HOST, not a substring: `evil.com/?x=getmarsic.cc` and
43
+ * `getmarsic.cc.attacker.net` must not be treated as ours — being wrong here would suppress a
44
44
  * genuine prompt and leave someone's coder quietly unusable.
45
45
  */
46
46
  export function isKeylessCodingHost(url) {
@@ -51,7 +51,7 @@ export function isKeylessCodingHost(url) {
51
51
  return false;
52
52
  }
53
53
  if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]") return true;
54
- return host === "gomarsic.cc" || host.endsWith(".gomarsic.cc");
54
+ return host === "getmarsic.cc" || host.endsWith(".getmarsic.cc");
55
55
  }
56
56
 
57
57
  /**
package/device-login.mjs CHANGED
@@ -8,7 +8,7 @@ import { homedir, platform } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import dotenv from "dotenv";
10
10
 
11
- export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
11
+ export const DEFAULT_API_BASE = "https://chat-api-v2.getmarsic.cc";
12
12
  const ENV_FILE = join(homedir(), ".gonext", "worker.env");
13
13
  const POLL_INTERVAL_MS = 2500;
14
14
  const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
@@ -186,3 +186,40 @@ export function describeEmbedChange(plan) {
186
186
  lines.push("They will be ignored at search time and rebuilt on the next search.");
187
187
  return lines;
188
188
  }
189
+
190
+ /**
191
+ * IS RAG ACTUALLY ON, AND IF NOT, WHY? (task #347)
192
+ *
193
+ * Reported: "Choose embed model but exit and gu again, it keeps asking 'embed model not set',
194
+ * again and again." Two settings decide it and they were treated as one: WHICH embedder
195
+ * (`ragEmbedModel` + where it is served) and WHETHER RAG is on (`ragEnabled`). /embed-model saved
196
+ * the first and never the second, and the bar's warning is driven by the second — so the choice
197
+ * was stored, never took effect, and the warning came back at every start.
198
+ *
199
+ * → { state, model }
200
+ * "on" an embedder is chosen and RAG is on
201
+ * "off" an embedder IS chosen, RAG is switched off — not "no embed model"
202
+ * "unset" no embedder was ever chosen
203
+ * Pure. `settings` is the worker-settings view (null/undefined = unknown → "unset").
204
+ */
205
+ export function embedState(settings) {
206
+ const model = String(settings?.ragEmbedModel ?? "").trim();
207
+ const where = String(settings?.ragEmbedUrl ?? "").trim() || String(settings?.ragEmbedModelPath ?? "").trim();
208
+ if (!model || !where) return { state: "unset", model };
209
+ return { state: settings?.ragEnabled === true ? "on" : "off", model };
210
+ }
211
+
212
+ /**
213
+ * What saving an embedder must write: the model, its server — AND RAG on. Choosing an embedder
214
+ * is the act of turning RAG on; a choice that leaves it off is the bug above.
215
+ */
216
+ export function embedSaveFields({ url, model }) {
217
+ return { ragEmbedUrl: url, ragEmbedModel: model, ragEmbedKeyId: "", ragEnabled: true };
218
+ }
219
+
220
+ /** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
221
+ export function embedNotice({ state, model } = {}) {
222
+ if (state === "on") return "";
223
+ if (state === "off" && model) return `⚠ RAG is switched off — ${model} is set · /embed-model turns it on`;
224
+ return "⚠ no embed model — RAG is off · /embed-model";
225
+ }
@@ -1072,6 +1072,75 @@ class _AgentConfigError(RuntimeError):
1072
1072
  worth a plain-reply degrade — the user needs the message itself to fix Settings."""
1073
1073
 
1074
1074
 
1075
+ class _OutputBudgetExhausted(_AgentConfigError):
1076
+ """The coding model used its WHOLE output budget and wrote nothing — twice (issue #104).
1077
+
1078
+ Found in a real turn: Claude returned 0 characters for exactly max_tokens (32,000) output
1079
+ tokens, six calls in a row, ~6.5 minutes each. Every one was retried unchanged, and the turn
1080
+ finally stopped with "the coding model kept replying with explanations instead of a runnable
1081
+ code block" — which is not what happened. A subclass of the config error so the turn ends as
1082
+ FAILED with this message verbatim: no degrade to a plain reply, no three more steps of the
1083
+ same."""
1084
+
1085
+
1086
+ # The retry of a budget-exhausted reply gets this much room (the first attempt had 32,000).
1087
+ _BUDGET_RETRY_MAX_TOKENS = 64000
1088
+
1089
+
1090
+ def _output_budget_exhausted(content, finish_reason) -> bool:
1091
+ """Did this reply end at the output limit with nothing written? Pure.
1092
+
1093
+ Only the pair counts: an empty reply that stopped normally is the "thought but didn't act"
1094
+ case the code-now retry exists for, and a reply cut at the limit WITH text is a truncated
1095
+ block, handled where blocks are closed."""
1096
+ return not (content or "").strip() and (finish_reason or "") == "length"
1097
+
1098
+
1099
+ def _budget_retry_kwargs(completion_kwargs, native_claude: bool, with_effort: bool = True) -> dict:
1100
+ """The ONE retry of a budget-exhausted reply: more room, and (native Claude) lower effort.
1101
+
1102
+ Never the same request again — that is what cost 192,000 output tokens. Pure."""
1103
+ kw = dict(completion_kwargs)
1104
+ current = int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 0)
1105
+ if native_claude:
1106
+ kw["max_tokens"] = max(_BUDGET_RETRY_MAX_TOKENS, current)
1107
+ if with_effort:
1108
+ kw["gu_effort"] = "low"
1109
+ else:
1110
+ kw.pop("gu_effort", None)
1111
+ elif current:
1112
+ # An OpenAI-compatible server: only a limit WE set can be raised; with none, it is the
1113
+ # server's own and the request is left as it is.
1114
+ key = "max_tokens" if kw.get("max_tokens") else "max_completion_tokens"
1115
+ kw[key] = max(_BUDGET_RETRY_MAX_TOKENS, current * 2)
1116
+ return kw
1117
+
1118
+
1119
+ def _no_code_message(model) -> str:
1120
+ """Three steps in a row with no runnable code block. Pure.
1121
+
1122
+ It used to end "…or switch the coding backend back to Ollama in Settings" — said to someone
1123
+ whose coder was a hosted Claude model (issue #104). It now names the model and the two
1124
+ things that are always available."""
1125
+ return (
1126
+ f"I had to stop: {model or 'the coding model'} replied three times in a row without a "
1127
+ "runnable code block, so none of those steps could execute. Some models put their answer "
1128
+ "in their thinking and leave the reply empty. Type continue to try again, or pick another "
1129
+ "coding model with /model."
1130
+ )
1131
+
1132
+
1133
+ def _budget_exhausted_message(model, out_tokens) -> str:
1134
+ """What the user is told when the retry came back empty at the limit too. Pure."""
1135
+ spent = f"{int(out_tokens):,} output tokens" if out_tokens else "its whole output allowance"
1136
+ return (
1137
+ f"I had to stop: {model or 'the coding model'} used {spent} on this step — twice — and "
1138
+ "wrote nothing. It ran out of output while thinking, before it got to the answer; this is "
1139
+ "not a problem with your request. Nothing was changed by this step. Type continue to try "
1140
+ "again, ask for the work in smaller pieces, or pick another coding model with /model."
1141
+ )
1142
+
1143
+
1075
1144
  class _AgentOffline(_AgentConfigError):
1076
1145
  """The network was gone for longer than the wait budget (task #302, part 3). Ends the turn
1077
1146
  as FAILED — never as a stop — with the work so far kept and "continue" as the way on. A
@@ -1223,6 +1292,13 @@ def _anthropic_request(kw: dict) -> dict:
1223
1292
  # NO temperature / top_p: current Claude models reject sampling parameters with a 400, and
1224
1293
  # the thinking / effort settings are left at the model's defaults so they never vary per
1225
1294
  # request (a change there invalidates the cache).
1295
+ #
1296
+ # ONE EXCEPTION (issue #104): the retry of a reply that used its WHOLE output budget and wrote
1297
+ # nothing asks for lower effort, so the second attempt cannot spend the budget the same way.
1298
+ # A one-off request — its cache miss is the price of not repeating a 32,000-token blank.
1299
+ effort = str(kw.get("gu_effort") or "").strip().lower()
1300
+ if effort in ("low", "medium", "high"):
1301
+ req["output_config"] = {"effort": effort}
1226
1302
  return req
1227
1303
 
1228
1304
 
@@ -1319,6 +1395,10 @@ class _AnthropicChatAdapter:
1319
1395
  usage["output_tokens"] = int(out)
1320
1396
  if stop_reason == "refusal":
1321
1397
  _log("claude: the model declined this request (stop_reason=refusal)")
1398
+ elif stop_reason == "max_tokens":
1399
+ # Issue #104: six calls ended this way with no text and nothing in the log said so.
1400
+ _log(f"warning: claude stopped at the output limit (stop_reason=max_tokens, "
1401
+ f"max_tokens={req.get('max_tokens')}, output {usage.get('output_tokens', 0)} tokens)")
1322
1402
  _log_anthropic_usage(usage)
1323
1403
  yield chunk(content="", finish=_ANTHROPIC_FINISH.get(stop_reason, "stop"))
1324
1404
  yield chunk(usage=_anthropic_usage(usage))
@@ -1426,7 +1506,7 @@ def _resolve_coding_backend(kind, base_url, probe=None):
1426
1506
  budget). Every branch now keys off this one value.
1427
1507
 
1428
1508
  Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
1429
- (ollama1.gomarsic.cc) still resolves to "ollama" and keeps its own treatment.
1509
+ (ollama1.getmarsic.cc) still resolves to "ollama" and keeps its own treatment.
1430
1510
 
1431
1511
  explicit kind → that value
1432
1512
  probe says Ollama → "ollama"
@@ -1467,7 +1547,7 @@ def _sentinel_against_ollama(agent_base_url, agent_model_id, probe=None):
1467
1547
 
1468
1548
  — an error naming a model the user never chose, on a machine whose settings look fine. That
1469
1549
  is the whole defect: not that it fails, but that it fails illegibly and the reader has no
1470
- thread to pull. Verified live against ollama1.gomarsic.cc, 2026-08-28.
1550
+ thread to pull. Verified live against ollama1.getmarsic.cc, 2026-08-28.
1471
1551
 
1472
1552
  WHY IT CANNOT BE FIXED WHERE IT IS PRODUCED. The API builds that payload with no idea which
1473
1553
  KIND of server the agent URL points at — there is no agentModelKind, only
@@ -1505,7 +1585,7 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
1505
1585
  Extracted from run_agent_chat unchanged (task #213). It decides the single most consequential
1506
1586
  thing about a turn — the model that writes every code block — and it lived in the middle of a
1507
1587
  400-line function, so no test could execute it. The Client Expert default puts the chat model,
1508
- the coder and the embedder on ONE Ollama box (ollama1.gomarsic.cc), which is exactly the shape
1588
+ the coder and the embedder on ONE Ollama box (ollama1.getmarsic.cc), which is exactly the shape
1509
1589
  where "same server" and "different model" collide, and where a silent fall-back to the chat
1510
1590
  model is invisible: both answers are a real model on a real server and the turn still works,
1511
1591
  just not with the coder that was chosen.
@@ -3877,7 +3957,7 @@ def _is_request_timeout(err) -> bool:
3877
3957
  likely STILL RUNNING on the box. Re-sending the identical request — streamed or
3878
3958
  not — starts that same multi-minute generation again from zero.
3879
3959
 
3880
- Live (2026-08-30, qwen3.8:27b on ollama1.gomarsic.cc): a 7,251-token prompt produced no
3960
+ Live (2026-08-30, qwen3.8:27b on ollama1.getmarsic.cc): a 7,251-token prompt produced no
3881
3961
  first token inside the 600s client timeout. We logged "streamed generate failed (Request
3882
3962
  timed out.) → falling back to non-streaming", told the user "Streaming unavailable", and
3883
3963
  re-sent it unstreamed — buying a second 600s wait with no first-token signal at all.
@@ -7485,21 +7565,42 @@ def _ws_distill_output(out: str, cap_head: int = 1200, cap_tail: int = 4000) ->
7485
7565
  # d6ad7211 walked a 6,665-line file in slices for ~40 steps. With cache reads at 0.1x a bigger
7486
7566
  # read costs about what the extra step did, and steps are what we save. Claude Code's own Read
7487
7567
  # allows 25k tokens. Small or unknown windows keep 3,000 — a 16k local Ollama is unchanged.
7568
+ #
7569
+ # VERY LARGE WINDOWS GET MORE (GitHub #105 / task #355) — for ANY model the user picks, by its
7570
+ # window, never by its name. The 24,000-char ceiling was reached at a 200k window and then stood
7571
+ # still: a 1,000,000-token coder was allowed the same read as a 200k one — 0.6% of its window —
7572
+ # and read_file_lines stopped at 400 lines whatever the model. A 6,665-line file is 17 reads at
7573
+ # 400 lines, 4 at 2,000. So above 200k the same 3% share keeps applying, up to 80,000 chars
7574
+ # (~20k tokens — about what Claude Code's own Read allows), and the line limit follows the cap.
7575
+ # Nothing changes at or below 200k: those rows of the table are exactly what they were.
7576
+ #
7577
+ # The ceiling is about COST, not fit: every read stays in the step memory and is sent again on
7578
+ # each later step (cached at ~0.1x on hosted coders, re-evaluated in full on a self-hosted one),
7579
+ # so "it fits" is not a reason to hand over a whole 500k-char file.
7488
7580
  _OBS_CAP_FLOOR = 3000
7489
- _OBS_CAP_READ_MAX = 24_000 # ≈ read_file_lines' own 400-line limit
7490
- _OBS_CAP_SEARCH_MAX = 12_000 # ≈ grep_repo's 100 hits / list_dir's 300 entries
7491
- _OBS_CAP_WINDOW_SHARE = 0.03 # one read may take ~3% of the window
7492
- _OBS_CAPS = {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
7581
+ _OBS_CAP_READ_MAX = 24_000 # the ceiling up to a 200k window ≈ a 400-line read
7582
+ _OBS_CAP_READ_MAX_LARGE = 80_000 # …and above it (reached at ~667k tokens)
7583
+ _OBS_CAP_SEARCH_MAX = 12_000 # ≈ grep_repo's 100 hits / list_dir's 300 entries
7584
+ _OBS_CAP_SEARCH_MAX_LARGE = 24_000
7585
+ _OBS_CAP_LARGE_WINDOW = 200_000 # tokens; above this the large ceilings apply
7586
+ _OBS_CAP_WINDOW_SHARE = 0.03 # one read may take ~3% of the window
7587
+ _OBS_LINES_FLOOR = 400 # read_file_lines' limit, as it always was
7588
+ _OBS_LINES_MAX = 2000
7589
+ _OBS_CHARS_PER_LINE = 60 # 24,000 chars ≈ 400 lines — the ratio the old pair already had
7590
+ _OBS_CAPS = {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR, "lines": _OBS_LINES_FLOOR}
7493
7591
 
7494
7592
 
7495
7593
  def _obs_caps_for_window(window_tokens: int) -> dict:
7496
- """{read, search} character caps for a model with this context window (0 = unknown)."""
7594
+ """{read, search} character caps and the `lines` limit of one read, for a model with this
7595
+ context window (0 = unknown). Pure — the whole rule is this function."""
7497
7596
  if not window_tokens or window_tokens <= 0:
7498
- return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR}
7597
+ return {"read": _OBS_CAP_FLOOR, "search": _OBS_CAP_FLOOR, "lines": _OBS_LINES_FLOOR}
7598
+ large = window_tokens > _OBS_CAP_LARGE_WINDOW
7499
7599
  read = int(window_tokens * 4 * _OBS_CAP_WINDOW_SHARE) # ~4 chars per token
7500
- read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX, read))
7501
- search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX, read // 2))
7502
- return {"read": read, "search": search}
7600
+ read = max(_OBS_CAP_FLOOR, min(_OBS_CAP_READ_MAX_LARGE if large else _OBS_CAP_READ_MAX, read))
7601
+ search = max(_OBS_CAP_FLOOR, min(_OBS_CAP_SEARCH_MAX_LARGE if large else _OBS_CAP_SEARCH_MAX, read // 2))
7602
+ lines = max(_OBS_LINES_FLOOR, min(_OBS_LINES_MAX, read // _OBS_CHARS_PER_LINE))
7603
+ return {"read": read, "search": search, "lines": lines}
7503
7604
 
7504
7605
 
7505
7606
  def _set_obs_caps(window_tokens: int) -> dict:
@@ -7664,8 +7765,12 @@ def run_agent_chat(cfg):
7664
7765
  # current task are logged early, and a real turn caught them leaking when this came later).
7665
7766
  # "production" (the account has no Enable Full Log): content becomes <content, N chars> and the
7666
7767
  # model-request dump is only what is new per step. "full" (or absent — an older worker): as before.
7667
- global _LOG_PRODUCTION
7668
- _LOG_PRODUCTION = str(cfg.get("logProfile") or "full").strip().lower() == "production"
7768
+ global _LOG_PRODUCTION, _LOG_MINIMAL
7769
+ _profile = str(cfg.get("logProfile") or "full").strip().lower()
7770
+ # "minimal" (task #352) is production's rules — no conversation content — plus: only trouble
7771
+ # is logged at all (see _log).
7772
+ _LOG_MINIMAL = _profile == "minimal"
7773
+ _LOG_PRODUCTION = _profile in ("production", "minimal")
7669
7774
  # One turn, one ledger (task #142): the process is per-turn today, but a stale entry would
7670
7775
  # credit RAG for tokens it never cost, and a wrong number is worse than none.
7671
7776
  _rag_ledger_reset()
@@ -9703,7 +9808,8 @@ def run_agent_chat(cfg):
9703
9808
  _ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
9704
9809
  _compact_at = _compact_threshold(_ctx_window)
9705
9810
  _caps = _set_obs_caps(_ctx_window)
9706
- _log(f"read caps: {_caps['read']} chars per read, {_caps['search']} per search (issue #49)")
9811
+ _log(f"read caps: {_caps['read']} chars / {_caps['lines']} lines per read, {_caps['search']} chars "
9812
+ f"per search — for a {_ctx_window or 'unknown'}-token window (issues #49, #105)")
9707
9813
  if _ctx_window:
9708
9814
  # SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
9709
9815
  # claim, and a table entry presented as fact is how a stale number survives unquestioned.
@@ -10088,7 +10194,7 @@ def run_agent_chat(cfg):
10088
10194
  # stream_agent: request the completion with stream=True and reassemble the
10089
10195
  # deltas into one message. Enabled for Ollama, where a long single-shot
10090
10196
  # (non-stream) generation sends nothing until the very end — a reverse proxy
10091
- # in front of the box (ollama1.gomarsic.cc) then hits its idle read-timeout
10197
+ # in front of the box (ollama1.getmarsic.cc) then hits its idle read-timeout
10092
10198
  # and kills the request mid-generation. Streaming keeps tokens flowing so the
10093
10199
  # idle timer never fires. smolagents still receives the COMPLETE text, so its
10094
10200
  # <code>/final_answer parsing is unaffected.
@@ -10178,6 +10284,11 @@ def run_agent_chat(cfg):
10178
10284
  return super().generate(*args, **kwargs)
10179
10285
  try:
10180
10286
  return self._streamed_generate(*args, **kwargs)
10287
+ except _AgentConfigError:
10288
+ # A final reason to stop (issue #104), not a streaming problem: the unstreamed
10289
+ # re-send below would be the same blank a third time — and it marked streaming
10290
+ # as "refused by the server" for the rest of the process.
10291
+ raise
10181
10292
  except Exception as e: # noqa: BLE001
10182
10293
  _started = getattr(self, "_first_token_seen", False)
10183
10294
  if _stream_fallback_decision(e, _started) == "raise":
@@ -10523,6 +10634,47 @@ def run_agent_chat(cfg):
10523
10634
  # OpenAI-compatible backend, not a failure. _close_dangling_code_block runs in
10524
10635
  # _run_stream (and again inside _code_nudge_reason) so it is already excluded.
10525
10636
  _pair_re = _CODE_PAIR_RE
10637
+ # THE WHOLE OUTPUT BUDGET, AND NOTHING WRITTEN (issue #104). Checked BEFORE the
10638
+ # code-now retry, which would resend the same request and get the same blank: this
10639
+ # reply is retried ONCE with room to answer (and lower effort on the native Claude
10640
+ # path); a second blank at the limit ends the turn with what really happened.
10641
+ if _output_budget_exhausted(content, getattr(self, "_last_finish_reason", "")):
10642
+ _native = isinstance(self.client, _AnthropicChatAdapter)
10643
+ _log(f"warning: the model used its whole output budget ({out_tok} tokens) and wrote "
10644
+ "nothing (finish_reason=length) → retrying ONCE with a larger budget"
10645
+ + (" and lower effort" if _native else ""))
10646
+ _emit({"type": "step", "text": (
10647
+ f"{_VERBOSE_PREFIX}The model ran out of output before writing anything — "
10648
+ "asking once more with room to answer…")})
10649
+ _first_out = out_tok
10650
+ _retry = _budget_retry_kwargs(completion_kwargs, _native)
10651
+ _retry["messages"] = list(completion_kwargs.get("messages") or []) + [
10652
+ {"role": "user", "content": self._CODE_NOW_DIRECTIVE}
10653
+ ]
10654
+ try:
10655
+ r_content, r_role, r_in, r_out, _ = self._run_stream(_retry)
10656
+ except Exception as _e: # noqa: BLE001
10657
+ # A server that does not know the effort setting answers 400 — the larger
10658
+ # budget alone is still a different request, so try that before giving up.
10659
+ if _native and _http_status_of(_e) == 400:
10660
+ _log(f"warning: the lower-effort retry was refused (HTTP 400) — retrying "
10661
+ "with the larger budget only")
10662
+ _retry = _budget_retry_kwargs(completion_kwargs, _native, with_effort=False)
10663
+ _retry["messages"] = list(completion_kwargs.get("messages") or []) + [
10664
+ {"role": "user", "content": self._CODE_NOW_DIRECTIVE}
10665
+ ]
10666
+ r_content, r_role, r_in, r_out, _ = self._run_stream(_retry)
10667
+ else:
10668
+ raise
10669
+ out_tok += r_out # generated and billed, whatever came back (#112)
10670
+ if _output_budget_exhausted(r_content, getattr(self, "_last_finish_reason", "")) \
10671
+ or not (r_content or "").strip():
10672
+ _log("error: the retry used its output budget and wrote nothing too → ending "
10673
+ "the turn instead of repeating it")
10674
+ raise _OutputBudgetExhausted(
10675
+ _budget_exhausted_message(self.model_id, r_out or _first_out))
10676
+ content, role = r_content, r_role
10677
+ in_tok = r_in or in_tok
10526
10678
  why = _code_nudge_reason(content, self._openai_backend)
10527
10679
  if why:
10528
10680
  _log(f"{why} turn (reasoning={reasoning_len} chars) → retrying once with a "
@@ -10747,6 +10899,11 @@ def run_agent_chat(cfg):
10747
10899
  _emit({"type": "step", "text": "Connection back — continuing"})
10748
10900
  _log(f"connection back after {time.monotonic() - offline['since']:.0f}s")
10749
10901
  break
10902
+ except _AgentConfigError:
10903
+ # Already a final, user-ready reason to stop (issue #104: a reply that
10904
+ # used its whole output budget twice). Retrying it here would repeat
10905
+ # exactly the calls it exists to prevent.
10906
+ raise
10750
10907
  except Exception as e: # noqa: BLE001
10751
10908
  emsg = str(e)
10752
10909
  # A 404 "model not found" is a config error (wrong model NAME
@@ -11234,14 +11391,7 @@ def run_agent_chat(cfg):
11234
11391
  if self._no_code_streak >= 3:
11235
11392
  _log("no-code streak hit limit → ending the turn honestly "
11236
11393
  "instead of looping on a coder that won't emit code")
11237
- _nocode_msg = (
11238
- "I had to stop: the coding model kept replying with "
11239
- "explanations instead of a runnable code block, so none of "
11240
- "my steps could actually execute. This is usually a "
11241
- "reasoning model putting its answer in the thinking channel. "
11242
- "Try a non-reasoning coding model, or switch the coding "
11243
- "backend back to Ollama in Settings, then run this again."
11244
- )
11394
+ _nocode_msg = _no_code_message(self.model_id)
11245
11395
  msg.content = ("<code>\nfinal_answer("
11246
11396
  + repr(_nocode_msg) + ")\n</code>")
11247
11397
  self._no_code_streak = 0
@@ -12379,7 +12529,9 @@ def run_agent_chat(cfg):
12379
12529
  Args:
12380
12530
  path: local file path.
12381
12531
  start_line: first line to show (1-based).
12382
- end_line: last line to show inclusive; 0 = rest of file (max 400 lines shown).
12532
+ end_line: last line to show inclusive; 0 = rest of file. One read shows at most 400
12533
+ lines (more with a large-context model) — the first line of the result says the
12534
+ range shown and the file's total, so read the next range if there is more.
12383
12535
  """
12384
12536
  try:
12385
12537
  _emit_running("Reading lines",
@@ -12391,7 +12543,9 @@ def run_agent_chat(cfg):
12391
12543
  lines = fh.readlines()
12392
12544
  s = max(1, int(start_line or 1))
12393
12545
  e = int(end_line or 0) or len(lines)
12394
- e = min(e, len(lines), s + 399)
12546
+ # The line limit follows the coder's window (#105): 400, up to 2,000 on a very
12547
+ # large one — see _obs_caps_for_window.
12548
+ e = min(e, len(lines), s + int(_OBS_CAPS.get("lines") or _OBS_LINES_FLOOR) - 1)
12395
12549
  # AFTER normalising, never before: end_line=0 means "to the end, capped at
12396
12550
  # 400", so the range the model asked for and the range it actually gets are
12397
12551
  # different numbers. Comparing the raw arguments would miss every whole-file
@@ -13590,13 +13744,40 @@ def run_agent_chat(cfg):
13590
13744
  _emit({"type": "final", "text": note})
13591
13745
 
13592
13746
 
13747
+ # What still gets logged at the MINIMAL level (task #352): a line that says something went wrong.
13748
+ # The same list as log-level.mjs TROUBLE — the worker filters again, but dropping the narration
13749
+ # HERE means it is never built into an event, sent down the pipe and parsed just to be discarded
13750
+ # (about 150 lines a turn, measured).
13751
+ _LOG_TROUBLE = re.compile(
13752
+ r"\b(error|errors|errored|fail|failed|failure|fatal|exception|traceback|panic|refused|unreachable|"
13753
+ r"unavailable|overloaded|denied|forbidden|unauthori[sz]ed|invalid|rejected|aborted|killed|crash(ed)?|"
13754
+ r"timed out|timeout|not found|cannot|can't|could ?n[o']t|couldn't|unable|warning|warn|degrad\w*|"
13755
+ r"fall(ing|s)? back|fell back|fallback|skipped|lost)\b|✗|⚠|HTTP [45]\d\d|status[= ][45]\d\d",
13756
+ re.IGNORECASE,
13757
+ )
13758
+ # System error codes (ENOENT, ECONNRESET…) — case-sensitive, or it matches every word in "e…".
13759
+ _LOG_ERRNO = re.compile(r"\bE[A-Z]{4,}\b")
13760
+
13761
+
13762
+ def _log_kept_at_minimal(text) -> bool:
13763
+ """Is this line trouble? Pure."""
13764
+ t = str(text or "")
13765
+ return bool(_LOG_TROUBLE.search(t) or _LOG_ERRNO.search(t))
13766
+
13767
+
13593
13768
  def _log(text: str):
13594
- """Emit a log event — worker prints it to console, not forwarded to chat."""
13769
+ """Emit a log event — worker prints it to console, not forwarded to chat.
13770
+
13771
+ At the minimal level (the default, task #352) only trouble is emitted; the rest is what
13772
+ `gu --log` is for."""
13773
+ if _LOG_MINIMAL and not _log_kept_at_minimal(text):
13774
+ return
13595
13775
  _emit({"type": "log", "text": text})
13596
13776
 
13597
13777
 
13598
13778
  _CLIENT_MODE = False # task #134: agent runs here, models live on another machine
13599
13779
  _LOG_PRODUCTION = False # task #249: set from cfg.logProfile; False = log as always
13780
+ _LOG_MINIMAL = False # task #352: only trouble is logged
13600
13781
  # How many messages of THIS run's model requests are already in the log (task #249, production
13601
13782
  # only). One python process runs one job, so module state is per turn.
13602
13783
  _DUMP_SEEN = {"n": 0}
package/gu-cli.mjs CHANGED
@@ -36,6 +36,7 @@ import { createRequire } from "node:module";
36
36
  import dotenv from "dotenv";
37
37
  import OpenAI from "openai";
38
38
  import { resolvePython } from "./platform-tools.mjs";
39
+ import { MINIMAL_RUN_LOGS, agentLogProfile, currentLevel, installLevelFilter, levelLine } from "./log-level.mjs";
39
40
  import { skillsForTurn } from "./skills.mjs";
40
41
  import { mcpForTurn } from "./mcp-config.mjs";
41
42
  import { hooksForTurn, sessionForTurn } from "./hooks-config.mjs";
@@ -398,6 +399,7 @@ if (["start", "stop", "status", "logs", "restart"].includes(args[0])) {
398
399
  if (s.running) {
399
400
  const who = s.pidFilePid ? `pid ${s.pidFilePid}` : `pid ${s.pids.join(", ")}`;
400
401
  console.log(`[gu-worker] daemon RUNNING (${who}). Logs: ${ctl.LOG_FILE}`);
402
+ console.log(`[gu-worker] ${levelLine(currentLevel())}`); // #352: which level it is writing at
401
403
  } else {
402
404
  console.log("[gu-worker] daemon is NOT running. Start it with: gu-cli start");
403
405
  }
@@ -1926,7 +1928,7 @@ const AGENT_WARMUP_IDLE_MS = 30 * 60_000;
1926
1928
  const AGENT_WARMUP_INTERVAL_MS = 240_000; // 4 min — under Ollama's 5-min default unload
1927
1929
  // OCR translation/correction is hardcoded to a managed Ollama service + model.
1928
1930
  // (The user-configurable "OCR translation model" chooser was removed.)
1929
- const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.gomarsic.cc";
1931
+ const OCR_TRANSLATE_OLLAMA_URL = "https://ollama1.getmarsic.cc";
1930
1932
  const OCR_TRANSLATE_OLLAMA_MODEL = "qwen3:14b";
1931
1933
  // Ollama defaults num_ctx to 2048 and silently truncates longer inputs (keeping
1932
1934
  // only the tail), which would drop the start of a long OCR page. Pin the context
@@ -3068,7 +3070,7 @@ function normalizeCorrection(raw, originalText) {
3068
3070
 
3069
3071
  /**
3070
3072
  * Call an Ollama host's native chat endpoint (POST {base}/api/chat) for text
3071
- * correction/translation, e.g. base "https://ollama1.gomarsic.cc" model "qwen3:14b".
3073
+ * correction/translation, e.g. base "https://ollama1.getmarsic.cc" model "qwen3:14b".
3072
3074
  * `think:false` suppresses reasoning models' <think> preamble so we get clean text.
3073
3075
  */
3074
3076
  const OCR_CORRECT_SYSTEM_PROMPT =
@@ -4081,7 +4083,9 @@ async function runAgentChatJob(job) {
4081
4083
  alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
4082
4084
  // What the agent may log (task #249): "production" = no conversation content, and the
4083
4085
  // model-request dump only what is new per step. "full" = as it always has.
4084
- logProfile: logPolicy?.profile() ?? "full",
4086
+ // #352: "minimal" (starts, stops, errors) unless a `gu --log` terminal is open right now —
4087
+ // then the account's own profile, i.e. what was logged before the minimal level existed.
4088
+ logProfile: agentLogProfile(logLevelNow(), logPolicy?.profile() ?? "full"),
4085
4089
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
4086
4090
  autoTest: payload?.autoTest === true,
4087
4091
  // Deploy target chosen via the terminal /server picker (task #69). Host/user only.
@@ -5222,6 +5226,7 @@ let shuttingDown = false;
5222
5226
  async function failInFlightJobsAndExit(signal, onExit = () => {}) {
5223
5227
  if (shuttingDown) return;
5224
5228
  shuttingDown = true;
5229
+ console.log(`[gu-worker] stopping (${signal})`); // #352: the STOP line
5225
5230
  const ids = [...inFlightJobs.keys()];
5226
5231
  if (ids.length) {
5227
5232
  console.log(
@@ -5338,6 +5343,9 @@ async function pollOnce() {
5338
5343
  return;
5339
5344
  }
5340
5345
  if (job.jobType === "agent_chat") {
5346
+ // #352: the START line of a turn — with "completed"/"failed" it is all a turn leaves in the
5347
+ // minimal log.
5348
+ console.log(`[gu-worker] started agent_chat ${String(job.jobId ?? job._id ?? job.id ?? "")}`);
5341
5349
  // Fire-and-forget into the pool — the same shape local_health has used all along. The
5342
5350
  // `await … return` this replaces is the entire bug: it held the poll loop for the whole
5343
5351
  // turn, so no other folder could be served (task #152).
@@ -5385,6 +5393,14 @@ async function pollOnce() {
5385
5393
  * Log, or the setting not readable yet) is exactly today's logging. See log-policy.mjs.
5386
5394
  */
5387
5395
  let logPolicy = null;
5396
+ // #352: the level is read from the `gu --log` lease, at most twice a second — it sits on the path
5397
+ // of every line printed.
5398
+ let _levelCache = { at: 0, level: "minimal" };
5399
+ function logLevelNow() {
5400
+ const now = Date.now();
5401
+ if (now - _levelCache.at > 500) _levelCache = { at: now, level: currentLevel() };
5402
+ return _levelCache.level;
5403
+ }
5388
5404
  async function startLogPolicy() {
5389
5405
  try {
5390
5406
  const lp = await import("./log-policy.mjs");
@@ -5399,15 +5415,34 @@ async function startLogPolicy() {
5399
5415
  : "[gu-worker] log profile: full — Enable Full Log is on for this account"
5400
5416
  ),
5401
5417
  });
5402
- lp.installTimestamps(() => logPolicy.profile() === "production");
5418
+ // #352: at the minimal level every line is stamped too — a log of starts, stops and errors is
5419
+ // only useful if each says WHEN.
5420
+ lp.installTimestamps(() => logPolicy.profile() === "production" || logLevelNow() === "minimal");
5421
+ // THE LEVEL FILTER, last so it is the first thing a line meets (see log-level.mjs).
5422
+ installLevelFilter(logLevelNow);
5403
5423
  await logPolicy.refresh();
5404
5424
  const runLogs = join(homedir(), ".gonext", "run-logs");
5405
5425
  const housekeep = async () => {
5426
+ if (logLevelNow() === "minimal") {
5427
+ await lp.rotateIfLarge(LOG_FILE);
5428
+ await lp.pruneRunLogs(runLogs, MINIMAL_RUN_LOGS);
5429
+ return;
5430
+ }
5406
5431
  if (logPolicy.profile() !== "production") return;
5407
5432
  await lp.rotateIfLarge(LOG_FILE);
5408
5433
  await lp.pruneRunLogs(runLogs);
5409
5434
  };
5410
5435
  await housekeep();
5436
+ // Say which level this log is written at — and say it again whenever it changes, so a stretch
5437
+ // of detail in the file is explained by the line above it.
5438
+ let saidLevel = logLevelNow();
5439
+ console.log(`[gu-worker] ${levelLine(saidLevel)}`);
5440
+ setInterval(() => {
5441
+ const now = logLevelNow();
5442
+ if (now === saidLevel) return;
5443
+ saidLevel = now;
5444
+ console.log(`[gu-worker] ${levelLine(now)}`);
5445
+ }, 2_000).unref();
5411
5446
  let ticks = 0;
5412
5447
  const { REFRESH_FLAG } = await import("./full-log-command.mjs");
5413
5448
  setInterval(() => {