@tiens.nguyen/gu-cli 1.0.729 → 1.0.731

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `gu full-log on|off|status [account]` and `/full-log` in the terminal (task #250) — ONE code path.
3
+ *
4
+ * An administrator turns "Enable Full Log" (#249) on or off for an account from a terminal. The
5
+ * command asks for an ADMIN TOKEN first — minted on the web admin page (Admin → Full Log → Create
6
+ * CLI token), 15 minutes, one use — and sends it to this machine's own API, which (hosted) decides
7
+ * or (Client Expert) forwards to the hosted API. The ordinary gu CLI token cannot do this: every
8
+ * account has one.
9
+ *
10
+ * THE TOKEN IS A SECRET HERE TOO: typed masked, never echoed, never printed, never logged, never
11
+ * saved. It lives in one local variable for one request.
12
+ *
13
+ * `status` for THIS machine's account needs no token (reading your own setting is harmless, and
14
+ * the worker reads it every 5 minutes anyway). Another account's status needs one.
15
+ */
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ export const USAGE = "gu full-log on|off|status [email or account id]";
21
+ /** The daemon's minute tick sees this and re-reads the policy at once (see gu-cli.mjs startLogPolicy). */
22
+ export const REFRESH_FLAG = join(homedir(), ".gonext", "log-policy.refresh");
23
+ export const TOKEN_PROMPT = "Admin token (Admin → Full Log → Create CLI token), hidden:";
24
+
25
+ /** → { action: "on"|"off"|"status", account? } | { error } */
26
+ export function parseFullLogArgs(words) {
27
+ const w = (words ?? []).map((s) => String(s).trim()).filter(Boolean);
28
+ const action = (w[0] ?? "status").toLowerCase();
29
+ if (!["on", "off", "status"].includes(action)) return { error: `unknown "${w[0]}" — ${USAGE}` };
30
+ if (w.length > 2) return { error: `too many words — ${USAGE}` };
31
+ return w[1] ? { action, account: w[1] } : { action };
32
+ }
33
+
34
+ const onOff = (b) => (b ? "ON" : "OFF");
35
+
36
+ /** Tell a running worker on this machine to re-read its policy now, instead of within 5 minutes. */
37
+ export async function requestLocalRefresh(file = REFRESH_FLAG) {
38
+ try {
39
+ await mkdir(dirname(file), { recursive: true });
40
+ await writeFile(file, new Date().toISOString() + "\n", "utf8");
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function readJson(res) {
48
+ return (await res.json().catch(() => null)) ?? {};
49
+ }
50
+
51
+ /**
52
+ * Run it. `askSecret(label)` → the typed token ("" = cancelled). `print(line)` writes one line.
53
+ * → an exit code: 0 done, 1 refused / failed, 2 usage.
54
+ */
55
+ export async function runFullLog({
56
+ action,
57
+ account,
58
+ apiBase,
59
+ workerKey,
60
+ askSecret,
61
+ print = (l) => console.log(l),
62
+ fetchImpl = fetch,
63
+ requestRefresh = requestLocalRefresh,
64
+ }) {
65
+ const base = String(apiBase ?? "").trim().replace(/\/+$/, "");
66
+ const key = String(workerKey ?? "").trim();
67
+ if (!base || !key) {
68
+ print("This computer is not signed in to gu yet — run `gu login` first.");
69
+ return 1;
70
+ }
71
+ const who = account ? account : "this computer's account";
72
+
73
+ // Your own status: no token.
74
+ if (action === "status" && !account) {
75
+ let res;
76
+ try {
77
+ res = await fetchImpl(`${base}/api/worker/log-policy`, {
78
+ headers: { "X-Worker-Key": key },
79
+ signal: AbortSignal.timeout(10_000),
80
+ });
81
+ } catch {
82
+ print(`Could not reach ${base} — try again in a moment.`);
83
+ return 1;
84
+ }
85
+ const body = await readJson(res);
86
+ if (res.status === 404) {
87
+ print("Full Log is not available on this gu service yet — this computer keeps full logs until it is.");
88
+ return 0;
89
+ }
90
+ if (!res.ok || typeof body.fullLog !== "boolean") {
91
+ print(`Could not read the log setting: ${body.error ?? `HTTP ${res.status}`}.`);
92
+ return 1;
93
+ }
94
+ print(
95
+ body.fullLog
96
+ ? "Full Log: ON for this computer's account — its logs include what was asked and answered."
97
+ : "Full Log: OFF for this computer's account — its logs carry no conversation content (the default)."
98
+ );
99
+ print(`To change it (administrators): ${USAGE.replace("on|off|status", "on|off")}`);
100
+ return 0;
101
+ }
102
+
103
+ let token = "";
104
+ try {
105
+ token = String((await askSecret(TOKEN_PROMPT)) ?? "").trim();
106
+ } catch {
107
+ token = "";
108
+ }
109
+ if (!token) {
110
+ print("No token entered — nothing changed.");
111
+ return 1;
112
+ }
113
+
114
+ const body = action === "status" ? { account } : { account, fullLog: action === "on" };
115
+ let res;
116
+ try {
117
+ res = await fetchImpl(`${base}/api/worker/log-policy/admin`, {
118
+ method: "POST",
119
+ headers: { "X-Worker-Key": key, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ signal: AbortSignal.timeout(20_000),
122
+ });
123
+ } catch {
124
+ print(`Could not reach ${base} — nothing changed.`);
125
+ return 1;
126
+ } finally {
127
+ token = ""; // one request, then gone
128
+ }
129
+ const a = await readJson(res);
130
+ if (!res.ok) {
131
+ if (res.status === 404 && typeof a.error !== "string") {
132
+ print("This computer's gu service does not support `gu full-log` yet — it needs an update. Nothing changed.");
133
+ } else {
134
+ const why = a.error ?? `HTTP ${res.status}`;
135
+ const tail = action === "status" || /nothing (was )?changed/i.test(why) ? "" : " Nothing changed.";
136
+ print(`${res.status >= 500 ? "Failed" : "Refused"}: ${why}${tail}`);
137
+ }
138
+ return 1;
139
+ }
140
+ const name = a.email ? `${a.email} (${a.userId})` : a.self ? `this computer's account (${a.userId})` : String(a.userId ?? who);
141
+ if (action === "status") {
142
+ print(`Full Log: ${onOff(a.fullLog)} for ${name}.`);
143
+ if (a.lastChange) {
144
+ const lc = a.lastChange;
145
+ print(`Last changed ${String(lc.at).replace("T", " ").slice(0, 16)} UTC by ${lc.by}${lc.via === "cli" ? " from the CLI" : ""}.`);
146
+ }
147
+ return 0;
148
+ }
149
+ print(
150
+ a.changed
151
+ ? `Full Log ${onOff(a.fullLog)} for ${name} — it was ${onOff(a.previous)}.`
152
+ : `Full Log was already ${onOff(a.fullLog)} for ${name} — nothing changed (recorded).`
153
+ );
154
+ if (a.self) {
155
+ await requestRefresh();
156
+ print("This computer picks it up within a minute; the account's other computers within 5 minutes.");
157
+ } else {
158
+ print("That account's computers pick it up within 5 minutes.");
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ /**
164
+ * A masked line from a plain terminal (no REPL) — `gu full-log` in a shell. A bullet per
165
+ * character, nothing of the value on screen; Ctrl+C / Ctrl+D cancel (→ ""). Piped stdin (a
166
+ * script: `printf '%s\n' "$TOKEN" | gu full-log on`) reads the first line, echoing nothing.
167
+ */
168
+ export function readHiddenLine(label, { input = process.stdin, output = process.stderr } = {}) {
169
+ return new Promise((resolve) => {
170
+ output.write(label + " ");
171
+ if (!input.isTTY) {
172
+ let buf = "";
173
+ const onData = (d) => {
174
+ buf += d.toString("utf8");
175
+ const nl = buf.indexOf("\n");
176
+ if (nl >= 0) finish(buf.slice(0, nl));
177
+ };
178
+ const onEnd = () => finish(buf);
179
+ const finish = (v) => {
180
+ input.off("data", onData);
181
+ input.off("end", onEnd);
182
+ input.pause();
183
+ output.write("\n");
184
+ resolve(v.replace(/\r$/, "").trim());
185
+ };
186
+ input.on("data", onData);
187
+ input.on("end", onEnd);
188
+ input.resume();
189
+ return;
190
+ }
191
+ let value = "";
192
+ input.setRawMode(true);
193
+ input.resume();
194
+ const done = (v) => {
195
+ input.off("data", onData);
196
+ input.setRawMode(false);
197
+ input.pause();
198
+ output.write("\n");
199
+ resolve(v);
200
+ };
201
+ const onData = (d) => {
202
+ for (const ch of d.toString("utf8")) {
203
+ if (ch === "\r" || ch === "\n") return done(value.trim());
204
+ if (ch === "\u0003" || ch === "\u0004") return done("");
205
+ if (ch === "\u007f" || ch === "\b") {
206
+ if (value) {
207
+ value = value.slice(0, -1);
208
+ output.write("\b \b");
209
+ }
210
+ continue;
211
+ }
212
+ if (ch < " ") continue; // other control bytes: ignored, never stored
213
+ value += ch;
214
+ output.write("•");
215
+ }
216
+ };
217
+ input.on("data", onData);
218
+ });
219
+ }
@@ -528,7 +528,7 @@ def _get_json(url, timeout=15):
528
528
  with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context_insecure()) as resp:
529
529
  return json.loads(resp.read().decode("utf-8", errors="replace"))
530
530
  except Exception as e: # noqa: BLE001
531
- _log(f"web_search fetch failed {url}: {e}")
531
+ _log(f"web_search fetch failed {_u(url)}: {e}")
532
532
  return None
533
533
 
534
534
 
@@ -558,7 +558,7 @@ def _get_text(url, timeout=6, ua=None, headers=None):
558
558
  with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context_insecure()) as resp:
559
559
  return resp.read(600_000).decode("utf-8", errors="replace")
560
560
  except Exception as e: # noqa: BLE001
561
- _log(f"web_search GET failed {url[:80]}: {e}")
561
+ _log(f"web_search GET failed {_u(url, 80)}: {e}")
562
562
  return ""
563
563
 
564
564
 
@@ -1028,7 +1028,7 @@ def _list_model_ids(base_url, api_key=""):
1028
1028
  with urllib.request.urlopen(req, timeout=10, context=_ssl_context_insecure()) as resp:
1029
1029
  payload = json.loads(resp.read().decode("utf-8", errors="replace"))
1030
1030
  except Exception as e: # noqa: BLE001
1031
- _log(f"model list failed {url}: {e}")
1031
+ _log(f"model list failed {_u(url)}: {e}")
1032
1032
  return []
1033
1033
  data = payload.get("data") if isinstance(payload, dict) else None
1034
1034
  if not isinstance(data, list):
@@ -2621,7 +2621,7 @@ def _summarize_result(task_text: str, agent_output: str,
2621
2621
  temperature=0.3,
2622
2622
  )
2623
2623
  summary = (resp.choices[0].message.content or "").strip()
2624
- _log(f"summary: {summary[:120]}")
2624
+ _log(f"summary: {_c(summary, 120)}")
2625
2625
  return summary or agent_output
2626
2626
  except Exception as e: # noqa: BLE001
2627
2627
  _log(f"summarize error: {e}")
@@ -2655,6 +2655,12 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
2655
2655
  content = _THINK_RE_LOCAL.sub("", content).strip()
2656
2656
  if not content:
2657
2657
  continue
2658
+ # An unanswered question stays in history (task #247), so two user turns can be
2659
+ # adjacent. Some chat templates (Gemma's, as served by MLX / vLLM) reject a
2660
+ # conversation whose roles do not alternate, so adjacent turns are joined into one.
2661
+ if chat_messages[-1]["role"] == role:
2662
+ chat_messages[-1]["content"] = f"{chat_messages[-1]['content']}\n\n{content}"
2663
+ continue
2658
2664
  chat_messages.append({"role": role, "content": content})
2659
2665
 
2660
2666
  # Each target carries ITS OWN key. They are usually different services — the chat model
@@ -3214,6 +3220,11 @@ def _is_billing_failure(err) -> bool:
3214
3220
  or "out of credit" in s
3215
3221
  or ("suspended" in s and ("balance" in s or "billing" in s or "payment" in s))
3216
3222
  or ("quota" in s and "exhaust" in s)
3223
+ # ollama.com (task #247): "you have reached your monthly usage limit, upgrade … or add
3224
+ # usage credits". A USAGE limit is an allowance for the period, not a request rate — a
3225
+ # "rate limit … per minute" never says "usage limit", so the transient 429s are untouched.
3226
+ or "usage limit" in s
3227
+ or "usage credits" in s
3217
3228
  )
3218
3229
 
3219
3230
 
@@ -6796,6 +6807,12 @@ def _rag_s3_client(region: str, akid: str, secret: str):
6796
6807
 
6797
6808
 
6798
6809
  def run_agent_chat(cfg):
6810
+ # Task #249: what this run may log — set FIRST, before anything is logged (the history and the
6811
+ # current task are logged early, and a real turn caught them leaking when this came later).
6812
+ # "production" (the account has no Enable Full Log): content becomes <content, N chars> and the
6813
+ # model-request dump is only what is new per step. "full" (or absent — an older worker): as before.
6814
+ global _LOG_PRODUCTION
6815
+ _LOG_PRODUCTION = str(cfg.get("logProfile") or "full").strip().lower() == "production"
6799
6816
  # One turn, one ledger (task #142): the process is per-turn today, but a stale entry would
6800
6817
  # credit RAG for tokens it never cost, and a wrong number is worse than none.
6801
6818
  _rag_ledger_reset()
@@ -7023,8 +7040,8 @@ def run_agent_chat(cfg):
7023
7040
  f"(budget {HISTORY_CHAR_BUDGET}) — exact turns sent to the agent below:"
7024
7041
  )
7025
7042
  for j, ln in enumerate(history_lines):
7026
- _log(f" history[{j}]: {ln[:240]}")
7027
- _log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1][:240]!r}")
7043
+ _log(f" history[{j}]: {_c(ln, 240)}")
7044
+ _log(f"current task (latest user message): {_c(task_text.rsplit('Current task: ', 1)[-1], 240, r=True)}")
7028
7045
 
7029
7046
  # ---- send_email config (per-user email API, e.g. a Lambda the user set up) ----
7030
7047
  email_enabled = bool(cfg.get("emailEnabled"))
@@ -7715,8 +7732,8 @@ def run_agent_chat(cfg):
7715
7732
  else:
7716
7733
  doc_title = _derive_pdf_title(doc_text)
7717
7734
  _log(
7718
- f"PDF fast-path: title={doc_title!r} doc_chars={len(doc_text)}"
7719
- + (f" edit={edit_instruction[:60]!r}" if edit_instruction else "")
7735
+ f"PDF fast-path: title={_c(doc_title, r=True)} doc_chars={len(doc_text)}"
7736
+ + (f" edit={_c(edit_instruction, 60, r=True)}" if edit_instruction else "")
7720
7737
  )
7721
7738
 
7722
7739
  _emit({"type": "step", "text": "Formatting document…"})
@@ -7761,7 +7778,7 @@ def run_agent_chat(cfg):
7761
7778
  )})
7762
7779
  return
7763
7780
 
7764
- _log(f"PDF fast-path ok title={doc_title!r} bytes={len(pdf_bytes)}")
7781
+ _log(f"PDF fast-path ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
7765
7782
  _emit({"type": "final", "text": out})
7766
7783
  return
7767
7784
  # ---------------------------------------------------------------------------------
@@ -8296,7 +8313,7 @@ def run_agent_chat(cfg):
8296
8313
  sig = f"{tool_name}::{key}"
8297
8314
  prior = _seen_calls.get(sig)
8298
8315
  if prior is not None:
8299
- _log(f"{tool_name} REPEAT suppressed ({arg[:60]!r}) → nudging model forward")
8316
+ _log(f"{tool_name} REPEAT suppressed ({_c(arg, 60, r=True)}) → nudging model forward")
8300
8317
  return True, (
8301
8318
  f"You already ran {tool_name}({arg!r}) (or an equivalent query) — the "
8302
8319
  f"result would be the same as shown above. Do NOT call it again. {next_hint}"
@@ -8332,12 +8349,12 @@ def run_agent_chat(cfg):
8332
8349
  if url_key in _failed_urls:
8333
8350
  msg = f"Error: {url} already failed — try a different URL or use Python stdlib."
8334
8351
  _emit({"type": "step", "text": f"HTTP {method.upper()} {url} → (skipped, already failed)"})
8335
- _log(f"http_request skipped (already failed): {url_key}")
8352
+ _log(f"http_request skipped (already failed): {_u(url_key)}")
8336
8353
  return msg
8337
8354
  result = _http_request_impl(method, url, parsed_headers, body or None)
8338
8355
  if result.startswith("Error:"):
8339
8356
  # Retry once for flaky connections (e.g. gorok tunnels).
8340
- _log(f"http_request retry {method.upper()} {url}")
8357
+ _log(f"http_request retry {method.upper()} {_u(url)}")
8341
8358
  result = _http_request_impl(method, url, parsed_headers, body or None)
8342
8359
  if result.startswith("Error:"):
8343
8360
  # Both attempts failed — mark URL as dead so model tries something else.
@@ -8377,7 +8394,7 @@ def run_agent_chat(cfg):
8377
8394
  "server log above, or call final_answer honestly stating what's failing.]"
8378
8395
  )
8379
8396
  _emit({"type": "step", "text": f"HTTP {method.upper()} {url} → {status_line}"})
8380
- _log(f"http_request {method.upper()} {url} → {result[:80]}")
8397
+ _log(f"http_request {method.upper()} {_u(url)} → {_c(result, 80)}")
8381
8398
  _last_obs["text"] = result
8382
8399
  return result
8383
8400
 
@@ -8399,7 +8416,7 @@ def run_agent_chat(cfg):
8399
8416
  now = _dtl.now().astimezone()
8400
8417
  out = now.strftime("%A, %d %B %Y, %H:%M:%S %Z")
8401
8418
  _emit({"type": "step", "text": f"Current date/time → {out}"})
8402
- _log(f"get_current_datetime({timezone!r}) → {out}")
8419
+ _log(f"get_current_datetime({timezone!r}) → {_c(out)}")
8403
8420
  _last_obs["text"] = out
8404
8421
  return out
8405
8422
 
@@ -8436,7 +8453,7 @@ def run_agent_chat(cfg):
8436
8453
  search_base_url=(cfg.get("searchBaseURL") or "").strip(),
8437
8454
  search_model_id=(cfg.get("searchModelId") or "").strip(),
8438
8455
  )
8439
- _log(f"web_search {query[:60]!r} → {result[:80]}")
8456
+ _log(f"web_search {_c(query, 60, r=True)} → {_c(result, 80)}")
8440
8457
  _last_obs["text"] = result
8441
8458
  if result and not result.startswith("Error"):
8442
8459
  _gathered.append(result)
@@ -8466,19 +8483,19 @@ def run_agent_chat(cfg):
8466
8483
  status, ctype, raw = _fetch_page_impl(url)
8467
8484
  if status is None:
8468
8485
  msg = f"Error: could not fetch {url}: {raw.decode('utf-8', 'replace')[:200]}"
8469
- _log(f"fetch_url error {url} → {msg[:80]}")
8486
+ _log(f"fetch_url error {_u(url)} → {msg[:80]}")
8470
8487
  _last_obs["text"] = msg
8471
8488
  return msg
8472
8489
  if isinstance(status, int) and status >= 400:
8473
8490
  msg = f"Error: {url} returned HTTP {status}. Try a different URL or use web_search()."
8474
- _log(f"fetch_url {url} → HTTP {status}")
8491
+ _log(f"fetch_url {_u(url)} → HTTP {status}")
8475
8492
  _last_obs["text"] = msg
8476
8493
  return msg
8477
8494
  # PDFs / binaries don't strip to useful text.
8478
8495
  if "application/pdf" in ctype or raw[:5].lstrip().startswith(b"%PDF"):
8479
8496
  msg = (f"{url} is a PDF, not an HTML page — fetch_url cannot read PDFs. "
8480
8497
  "Tell the user this needs a PDF-reading tool.")
8481
- _log(f"fetch_url {url} → PDF, cannot read")
8498
+ _log(f"fetch_url {_u(url)} → PDF, cannot read")
8482
8499
  _last_obs["text"] = msg
8483
8500
  return msg
8484
8501
  # 8000, not the 3000 default: a research page's real content (fixture tables,
@@ -8488,7 +8505,7 @@ def run_agent_chat(cfg):
8488
8505
  text = (_html_to_text(raw.decode("utf-8", errors="replace"), limit=8000)
8489
8506
  or "(page had no readable text)")
8490
8507
  out = f"{url}\n{text}"
8491
- _log(f"fetch_url {url} → {len(text)} chars")
8508
+ _log(f"fetch_url {_u(url)} → {len(text)} chars")
8492
8509
  _last_obs["text"] = out
8493
8510
  _gathered.append(out)
8494
8511
  return out
@@ -8506,7 +8523,7 @@ def run_agent_chat(cfg):
8506
8523
  """
8507
8524
  _emit({"type": "step", "text": f"Calculating → {expression[:80]}"})
8508
8525
  result = _calc_impl(expression)
8509
- _log(f"calculate({expression!r}) → {result[:80]}")
8526
+ _log(f"calculate({_c(expression, r=True)}) → {_c(result, 80)}")
8510
8527
  _last_obs["text"] = result
8511
8528
  return result
8512
8529
 
@@ -8531,7 +8548,7 @@ def run_agent_chat(cfg):
8531
8548
  if email_allow and not _email_allowed(to_addr, email_allow):
8532
8549
  msg = (f"Error: {to_addr} is not in the allowed recipients list — refusing to "
8533
8550
  "send. Add it to the allow-list in Settings if this is intended.")
8534
- _log(f"send_email blocked by allow-list: {to_addr}")
8551
+ _log(f"send_email blocked by allow-list: {_c(to_addr)}")
8535
8552
  _last_obs["text"] = msg
8536
8553
  return msg
8537
8554
  preview = (
@@ -8544,7 +8561,7 @@ def run_agent_chat(cfg):
8544
8561
  # shown — otherwise just preview (never send on the first call).
8545
8562
  if not _email_confirm_pending():
8546
8563
  _emit({"type": "step", "text": f"Email preview → {to_addr} (awaiting confirm)"})
8547
- _log(f"send_email preview (awaiting confirm) → {to_addr}")
8564
+ _log(f"send_email preview (awaiting confirm) → {_c(to_addr)}")
8548
8565
  _last_obs["text"] = preview
8549
8566
  return preview
8550
8567
  filled = _email_fill_template(email_body_template, {
@@ -8566,7 +8583,7 @@ def run_agent_chat(cfg):
8566
8583
  out = f"✅ Email sent to {to_addr} (Subject: {subject or '(no subject)'})."
8567
8584
  else:
8568
8585
  out = f"Email send failed ({first}). Check the email API settings."
8569
- _log(f"send_email {to_addr} → {first}")
8586
+ _log(f"send_email {_c(to_addr)} → {_c(first)}")
8570
8587
  _last_obs["text"] = out
8571
8588
  return out
8572
8589
 
@@ -8582,7 +8599,7 @@ def run_agent_chat(cfg):
8582
8599
  """
8583
8600
  _emit({"type": "step", "text": f"Reading PDF → {url[:80]}"})
8584
8601
  result = _extract_pdf_text_impl(url)
8585
- _log(f"extract_text_from_pdf {url} → {result[:80]}")
8602
+ _log(f"extract_text_from_pdf {_u(url)} → {_c(result, 80)}")
8586
8603
  _last_obs["text"] = result
8587
8604
  return result
8588
8605
 
@@ -8624,7 +8641,7 @@ def run_agent_chat(cfg):
8624
8641
  or (_pdf_state.get("timeout_pending")
8625
8642
  and _pdf_titles_similar(prev["title"], doc_title))
8626
8643
  ):
8627
- _log(f"create_pdf retry detected (prev title={prev['title']!r}) "
8644
+ _log(f"create_pdf retry detected (prev title={_c(prev['title'], r=True)}) "
8628
8645
  "→ returning the existing PDF instead of re-rendering")
8629
8646
  _emit({"type": "step",
8630
8647
  "text": f"PDF already created → {prev['title']}.pdf (reusing link)"})
@@ -8697,7 +8714,7 @@ def run_agent_chat(cfg):
8697
8714
  f"Download it here (link valid for a limited time):\n{download_url}"
8698
8715
  )
8699
8716
  _emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
8700
- _log(f"create_pdf ok title={doc_title!r} bytes={len(pdf_bytes)}")
8717
+ _log(f"create_pdf ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
8701
8718
  _pdf_state["last"] = {
8702
8719
  "sig": _pdf_sig(doc_title, text), "title": doc_title, "msg": out,
8703
8720
  }
@@ -9121,12 +9138,12 @@ def run_agent_chat(cfg):
9121
9138
  raw = getattr(last, "content", "")
9122
9139
  if isinstance(raw, list):
9123
9140
  raw = " ".join(p.get("text", "") for p in raw if isinstance(p, dict))
9124
- _log(f"step {step_num} → model input (tail): {str(raw)[:400]}")
9141
+ _log(f"step {step_num} → model input (tail): {_c(raw, 400)}")
9125
9142
 
9126
9143
  # Log what the model generated (the Python code block).
9127
9144
  model_output = getattr(step_log, "model_output", None)
9128
9145
  if model_output:
9129
- _log(f"step {step_num} ← model output: {str(model_output)[:400]}")
9146
+ _log(f"step {step_num} ← model output: {_c(model_output, 400)}")
9130
9147
 
9131
9148
  try:
9132
9149
  text = _summarise_step(step_log)
@@ -9153,7 +9170,7 @@ def run_agent_chat(cfg):
9153
9170
  if not text or text.rstrip().endswith("| →"):
9154
9171
  _log(f"step {step_num} (empty obs, skipped)")
9155
9172
  return
9156
- _log(f"step {step_num}: {text[:200]}")
9173
+ _log(f"step {step_num}: {_c(text, 200)}")
9157
9174
  _emit({"type": "step", "text": text})
9158
9175
 
9159
9176
  # Wrap the model so we can see EXACTLY what smolagents posts to the model
@@ -9928,6 +9945,7 @@ def run_agent_chat(cfg):
9928
9945
  "text": "Coding model is busy (429) — retrying…"})
9929
9946
  _log(f"generate {kind} error (attempt {attempt}/{max_attempts}): "
9930
9947
  f"{_clip(emsg, 160)} — retrying in {delay:.0f}s")
9948
+ _DUMP_SEEN["n"] = 0 # the retried request is logged in full (#249)
9931
9949
  time.sleep(delay)
9932
9950
  elif timed_out:
9933
9951
  # Out of attempts on OUR deadline. Nothing about this is a
@@ -10351,8 +10369,11 @@ def run_agent_chat(cfg):
10351
10369
  _log(f"/no_think inject error: {e}")
10352
10370
  try:
10353
10371
  msgs = ck.get("messages", []) or []
10354
- _log(f"=== MODEL REQUEST: {len(msgs)} message(s) sent to the model ===")
10372
+ _start, _suffix = _request_dump_range(len(msgs))
10373
+ _log(f"=== MODEL REQUEST: {len(msgs)} message(s) sent to the model{_suffix} ===")
10355
10374
  for i, m in enumerate(msgs):
10375
+ if i < _start:
10376
+ continue
10356
10377
  role = m.get("role") if isinstance(m, dict) else getattr(m, "role", "?")
10357
10378
  content = (
10358
10379
  m.get("content") if isinstance(m, dict)
@@ -10366,7 +10387,7 @@ def run_agent_chat(cfg):
10366
10387
  else:
10367
10388
  text = str(content)
10368
10389
  text = text.replace("\n", " ")
10369
- _log(f" [{i}] {role} ({len(text)} chars): {text[:600]}")
10390
+ _log(f" [{i}] {role} ({len(text)} chars): {_c(text, 600)}")
10370
10391
  _log("=== END MODEL REQUEST ===")
10371
10392
  except Exception as e: # noqa: BLE001
10372
10393
  _log(f"MODEL REQUEST log error: {e}")
@@ -10426,13 +10447,13 @@ def run_agent_chat(cfg):
10426
10447
  )
10427
10448
  if isinstance(out, str) and out.startswith("✅"):
10428
10449
  return ChatMessage(role=MessageRole.ASSISTANT, content=out)
10429
- _log(f"deterministic create_pdf did not succeed: {str(out)[:120]}")
10450
+ _log(f"deterministic create_pdf did not succeed: {_c(out, 120)}")
10430
10451
  except Exception as e: # noqa: BLE001
10431
10452
  _log(f"deterministic create_pdf error: {type(e).__name__}: {e}")
10432
10453
  _maxsteps_partial["hit"] = True
10433
10454
  text = (_last_obs.get("text") or "").strip()
10434
10455
  if text:
10435
- _log(f"max-steps fallback (last tool obs) → {text[:80]}")
10456
+ _log(f"max-steps fallback (last tool obs) → {_c(text, 80)}")
10436
10457
  # Summarize WHAT WAS TRIED + the current blocker, not just the last tool
10437
10458
  # observation (task #79): a bare last obs ('Stopped npm run dev') reads as a
10438
10459
  # non-answer and hides the loop the run got stuck in. Build a compact bullet
@@ -11067,7 +11088,7 @@ def run_agent_chat(cfg):
11067
11088
  topk = scored[: (k if k and k > 0 else rag_top_k)]
11068
11089
  # Log WHAT matched and HOW WELL: a search that returns junk looks identical to
11069
11090
  # one that returns nothing useful unless the scores and files are visible.
11070
- _log(f"RAG search {query[:60]!r} over {len(chunks)} chunk(s) "
11091
+ _log(f"RAG search {_c(query, 60, r=True)} over {len(chunks)} chunk(s) "
11071
11092
  f"in {_t.time() - _s0:.1f}s → top {len(topk)}: "
11072
11093
  + ", ".join(f"{c.get('file','?')}({score:.2f})" for score, c in topk))
11073
11094
  if topk and topk[0][0] < 0.25:
@@ -11720,7 +11741,7 @@ def run_agent_chat(cfg):
11720
11741
  # alternative cost two hour-long turns and a false report to the user.
11721
11742
  _shell_err = _shell_syntax_error(command, argv)
11722
11743
  if _shell_err:
11723
- _log(f"run_command refused shell syntax: {_clip(command, 120)}")
11744
+ _log(f"run_command refused shell syntax: {_c(_clip(command, 120))}")
11724
11745
  _last_obs["text"] = _shell_err
11725
11746
  return _shell_err
11726
11747
  # Allow-by-default policy. Match on the runner's basename too, so
@@ -12309,7 +12330,7 @@ def run_agent_chat(cfg):
12309
12330
  _retr = _retrieval_summary(_SEARCH_TALLY, _RAG_STATE.get("s", ""), True, _SEARCH_LOG)
12310
12331
  if _retr:
12311
12332
  _emit({"type": "step", "text": _retr})
12312
- _log(f"retrieval summary: {_retr}")
12333
+ _log(f"retrieval summary: {_c(_retr)}")
12313
12334
  _emit({"type": "step", "text": "Composing answer…"})
12314
12335
  final_text = _strip_tool_tags(str(result).strip()) or "[No result]"
12315
12336
  _log(f"done (deterministic, no summarizer call): {len(final_text)} chars")
@@ -12434,6 +12455,56 @@ def _log(text: str):
12434
12455
 
12435
12456
 
12436
12457
  _CLIENT_MODE = False # task #134: agent runs here, models live on another machine
12458
+ _LOG_PRODUCTION = False # task #249: set from cfg.logProfile; False = log as always
12459
+ # How many messages of THIS run's model requests are already in the log (task #249, production
12460
+ # only). One python process runs one job, so module state is per turn.
12461
+ _DUMP_SEEN = {"n": 0}
12462
+
12463
+
12464
+ def _c(text, limit=None, r=False) -> str:
12465
+ """Conversation CONTENT inside a log line (task #249).
12466
+
12467
+ Content = the user's words, the model's words, and what tools were given or returned (search
12468
+ queries, expressions, commands, email addresses, page text, observations, document titles).
12469
+ Under the production profile (the account has no "Enable Full Log") it becomes a placeholder
12470
+ that keeps only the size: `<content, 2227 chars>`. Under Full Log it is the text, clipped to
12471
+ `limit` as before, and repr()'d when `r` (what the old `!r` did).
12472
+
12473
+ MARKED AT THE CALL SITE, never filtered afterwards: a pattern that tries to spot content in
12474
+ finished lines misses one eventually, and one miss is a leak.
12475
+ """
12476
+ t = "" if text is None else str(text)
12477
+ if _LOG_PRODUCTION:
12478
+ return f"<content, {len(t)} chars>"
12479
+ t = t[:limit] if limit else t
12480
+ return repr(t) if r else t
12481
+
12482
+
12483
+ def _u(url, limit=None) -> str:
12484
+ """A URL inside a log line (task #249): the host only under production — a path or a query
12485
+ string can carry a token or the user's own data — and the URL as before under Full Log."""
12486
+ t = "" if url is None else str(url)
12487
+ if _LOG_PRODUCTION:
12488
+ m = re.match(r"^([a-z][a-z0-9+.-]*://[^/?#]+)", t, re.I)
12489
+ return (m.group(1) + "/…") if m else _c(t)
12490
+ return t[:limit] if limit else t
12491
+
12492
+
12493
+ def _request_dump_range(n_msgs: int) -> tuple:
12494
+ """Which messages of a model request to log: (start, header_suffix) — task #249.
12495
+
12496
+ MEASURED: this dump was 94% of a 161 MB worker.log, because every step re-logged EVERY message
12497
+ (the agent's memory only grows), so a turn cost O(steps²). In production each step logs only
12498
+ the messages added since the last request. The whole list is logged again when there is no
12499
+ earlier dump, when the list SHRANK (older steps trimmed or compacted — the old indices no
12500
+ longer line up), and on the request after an error (_DUMP_SEEN is reset there). Full Log logs
12501
+ everything, every step, exactly as before.
12502
+ """
12503
+ prev = _DUMP_SEEN["n"]
12504
+ _DUMP_SEEN["n"] = n_msgs
12505
+ if not _LOG_PRODUCTION or prev <= 0 or n_msgs < prev:
12506
+ return 0, ""
12507
+ return prev, f" — {n_msgs - prev} new; [0]..[{prev - 1}] were logged at an earlier step"
12437
12508
  _EMIT_LOCK = threading.Lock()
12438
12509
 
12439
12510
 
package/gu-cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { requireSupportedNode } from "./node-version.mjs";
15
15
  // The brand accent, shared with the REPL — see accent.mjs.
16
16
  import { accent } from "./accent.mjs";
17
17
  requireSupportedNode();
18
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
18
+ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
21
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
@@ -76,6 +76,9 @@ gu-cli
76
76
  Usage:
77
77
  gu-cli
78
78
  gu-cli login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
79
+ gu-cli full-log on|off|status [account] # administrators: Enable Full Log for an account (asks for
80
+ # an admin token from the web admin page first); status
81
+ # alone shows this computer's account, no token needed
79
82
  gu-cli token [<token>] # show this computer's gu token, or save one pasted from
80
83
  # the Account page (Client Expert machines)
81
84
  gu-cli set <workerKey> [--api-base <url>]
@@ -220,6 +223,24 @@ if (args[0] === "token") {
220
223
  process.exit(0);
221
224
  }
222
225
 
226
+ if (args[0] === "full-log") {
227
+ // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
228
+ // token minted on the web admin page. Same code as /full-log in the terminal UI.
229
+ const fl = await import("./full-log-command.mjs");
230
+ const parsed = fl.parseFullLogArgs(args.slice(1));
231
+ if (parsed.error) {
232
+ console.error(parsed.error);
233
+ process.exit(2);
234
+ }
235
+ const code = await fl.runFullLog({
236
+ ...parsed,
237
+ apiBase: process.env.GONEXT_API_BASE,
238
+ workerKey: process.env.GONEXT_WORKER_KEY,
239
+ askSecret: (label) => fl.readHiddenLine(label),
240
+ });
241
+ process.exit(code);
242
+ }
243
+
223
244
  if (args[0] === "login") {
224
245
  // Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
225
246
  // which API to pair against (defaults to the production API).
@@ -3761,6 +3782,9 @@ async function runAgentChatJob(job) {
3761
3782
  // Session opt-in (task #80): auto-extend past the step budget without re-prompting
3762
3783
  // (the REPL sets this once the user picks "yes, don't ask again").
3763
3784
  alwaysExtendOnMaxStep: payload?.alwaysExtendOnMaxStep === true,
3785
+ // What the agent may log (task #249): "production" = no conversation content, and the
3786
+ // model-request dump only what is new per step. "full" = as it always has.
3787
+ logProfile: logPolicy?.profile() ?? "full",
3764
3788
  // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
3765
3789
  autoTest: payload?.autoTest === true,
3766
3790
  // Deploy target chosen via the terminal /server picker (task #69). Host/user only.
@@ -5033,6 +5057,53 @@ async function pollOnce() {
5033
5057
  }
5034
5058
  }
5035
5059
 
5060
+ /**
5061
+ * WHAT THIS MACHINE LOGS (task #249). Read from the account's API at startup and every 5 minutes;
5062
+ * "production" (the default once the setting exists) timestamps every line, rotates worker.log and
5063
+ * prunes run-logs/, and tells each agent run to log no conversation content. "full" (Enable Full
5064
+ * Log, or the setting not readable yet) is exactly today's logging. See log-policy.mjs.
5065
+ */
5066
+ let logPolicy = null;
5067
+ async function startLogPolicy() {
5068
+ try {
5069
+ const lp = await import("./log-policy.mjs");
5070
+ const { LOG_FILE } = await import("./daemon-control.mjs");
5071
+ logPolicy = lp.createLogPolicy({
5072
+ apiBase,
5073
+ workerKey,
5074
+ onChange: (profile) =>
5075
+ console.log(
5076
+ profile === "production"
5077
+ ? "[gu-worker] log profile: production — Full Log is off for this account (no conversation content, timestamps, rotation)"
5078
+ : "[gu-worker] log profile: full — Enable Full Log is on for this account"
5079
+ ),
5080
+ });
5081
+ lp.installTimestamps(() => logPolicy.profile() === "production");
5082
+ await logPolicy.refresh();
5083
+ const runLogs = join(homedir(), ".gonext", "run-logs");
5084
+ const housekeep = async () => {
5085
+ if (logPolicy.profile() !== "production") return;
5086
+ await lp.rotateIfLarge(LOG_FILE);
5087
+ await lp.pruneRunLogs(runLogs);
5088
+ };
5089
+ await housekeep();
5090
+ let ticks = 0;
5091
+ const { REFRESH_FLAG } = await import("./full-log-command.mjs");
5092
+ setInterval(() => {
5093
+ ticks++;
5094
+ void (async () => {
5095
+ // `gu full-log` just changed this machine's account (#250): re-read now, not in 5 minutes.
5096
+ const asked = await unlink(REFRESH_FLAG).then(() => true, () => false);
5097
+ if (asked || ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
5098
+ await housekeep();
5099
+ })().catch(() => {});
5100
+ }, 60_000).unref();
5101
+ } catch (e) {
5102
+ // Never fatal: without a policy the worker keeps today's logging.
5103
+ console.log(`[gu-worker] log policy unavailable (${e?.message ?? e}) — logging as before`);
5104
+ }
5105
+ }
5106
+
5036
5107
  async function main() {
5037
5108
  // Task #127, Phase 2: record our own pidfile so `status`/`stop` find us whether we were
5038
5109
  // launched by `start` (detached), by the REPL's auto-ensure, or run in the foreground.
@@ -5060,6 +5131,7 @@ async function main() {
5060
5131
  /* pidfile is a convenience; polling works without it */
5061
5132
  }
5062
5133
  MY_INSTANCE_ID = await (await import("./daemon-control.mjs")).getInstanceId();
5134
+ await startLogPolicy();
5063
5135
  console.log(
5064
5136
  `[gu-worker] polling ${apiBase} every ${pollMs}ms (local_health concurrency=${localHealthConcurrency}` +
5065
5137
  `, agent concurrency=${agentConcurrency})` +
package/gu-repl.mjs CHANGED
@@ -47,6 +47,9 @@ import {
47
47
  promptTotal,
48
48
  resumeTask,
49
49
  isResumeWord,
50
+ isPendingQuestion,
51
+ interruptStatus,
52
+ messagesForModel,
50
53
  retryAdvice,
51
54
  classifyTurnFailure,
52
55
  budgetMinutes,
@@ -529,7 +532,7 @@ const argv = process.argv.slice(2);
529
532
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
530
533
  const CLI_SUBCOMMANDS = [
531
534
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
532
- "clear", "reset", "api", "token-usage", "tokens", "token",
535
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
533
536
  ];
534
537
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
535
538
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -803,6 +806,7 @@ const COMMANDS = [
803
806
  { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
804
807
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
805
808
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
809
+ { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
806
810
  { name: "/help", desc: "list these commands" },
807
811
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
808
812
  ];
@@ -4405,6 +4409,16 @@ async function updateSessionFile(cwd, mutate) {
4405
4409
  return queued;
4406
4410
  }
4407
4411
 
4412
+ /** Tag the unanswered question with how its turn ended and persist it (task #247). */
4413
+ async function markInterrupted(cwd, history, task, how) {
4414
+ const last = history[history.length - 1];
4415
+ if (!isPendingQuestion(history, task)) return;
4416
+ const status = interruptStatus(how);
4417
+ if (!status) return;
4418
+ last.status = status;
4419
+ await saveSession(cwd, history, { unfinishedTask: sessionUnfinishedTask || task });
4420
+ }
4421
+
4408
4422
  async function rememberUnfinishedTask(cwd, task) {
4409
4423
  // NEVER remember a resume word as the task (task #201). The invariant is enforced at the
4410
4424
  // point of WRITING rather than at each of the three failure paths that call this: storing
@@ -4789,7 +4803,9 @@ async function saveSessionSkip(cwd, key, on) {
4789
4803
  }
4790
4804
  }
4791
4805
 
4792
- async function saveSession(cwd, history) {
4806
+ // `unfinishedTask` overrides what goes to DISK only: the question saved before its turn runs
4807
+ // (task #247) must read as pending there, while the in-memory resume stays one-shot.
4808
+ async function saveSession(cwd, history, { unfinishedTask = sessionUnfinishedTask } = {}) {
4793
4809
  try {
4794
4810
  const trimmed = history.slice(-MAX_PERSISTED_MESSAGES);
4795
4811
  // MERGE, DO NOT REPLACE. This used to write a whole fresh object, which silently dropped
@@ -4806,7 +4822,7 @@ async function saveSession(cwd, history) {
4806
4822
  maxCodeTokens: sessionMaxCodeTokens, // peak per-request tokens (task #84)
4807
4823
  ragStorage: sessionRagStorage, // where this folder's RAG index lives (#133/#180)
4808
4824
  showThought: sessionShowThought, // print the model's reasoning (task #148)
4809
- unfinishedTask: sessionUnfinishedTask, // what a bare "continue" resumes (#150/#189)
4825
+ unfinishedTask, // what a bare "continue" resumes (#150/#189)
4810
4826
  // The /model pick, so it survives an exit (see loadSessionCoding). Empty means "the
4811
4827
  // account default" and is written as such rather than omitted, so choosing the
4812
4828
  // default again CLEARS a previous override instead of leaving it stored.
@@ -7070,6 +7086,28 @@ async function runS3Command(line) {
7070
7086
  await runTest({});
7071
7087
  }
7072
7088
 
7089
+ /**
7090
+ * /full-log (#250) — the same code as `gu full-log`. The admin token goes through askSecret: masked
7091
+ * in the frozen row, dropped from readline history, never echoed (#191).
7092
+ */
7093
+ async function runFullLogCommand(line) {
7094
+ const fl = await import("./full-log-command.mjs");
7095
+ const parsed = fl.parseFullLogArgs(line.split(/\s+/).slice(1));
7096
+ if (parsed.error) {
7097
+ console.log(red(parsed.error) + "\n");
7098
+ return;
7099
+ }
7100
+ // A cancelled token prompt (Esc / Ctrl+C) reads as "no token entered" inside runFullLog.
7101
+ await fl.runFullLog({
7102
+ ...parsed,
7103
+ apiBase,
7104
+ workerKey,
7105
+ askSecret: (label) => askSecret(` ${label}`),
7106
+ print: (l) => console.log(gutterLines(l)),
7107
+ });
7108
+ console.log("");
7109
+ }
7110
+
7073
7111
  async function runProxyCommand(line) {
7074
7112
  const cmd = await import("./proxy-command.mjs");
7075
7113
  const store = await import("./proxy-store.mjs");
@@ -8867,7 +8905,8 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8867
8905
  // cwd = this terminal's folder → agent puts download/unzip output here (when it's
8868
8906
  // a registered workspace). codingModelOverride = the /model choice for this session.
8869
8907
  body: JSON.stringify({
8870
- messages: history,
8908
+ // Earlier questions that were stopped or failed carry their status tag (task #247).
8909
+ messages: messagesForModel(history),
8871
8910
  cwd: resolve(process.cwd()),
8872
8911
  // The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
8873
8912
  // asks instead of hard-blocking it (interactive command-approval gate).
@@ -8987,6 +9026,9 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8987
9026
  let pollFailures = 0;
8988
9027
  const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
8989
9028
  let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
9029
+ // Sticky: the agent RAN, so the coding model was called ("thinking…" was on screen). A
9030
+ // Ctrl+C after this point is "stopped", never "changed my mind" (task #247).
9031
+ let reachedModel = false;
8990
9032
  let answerShownLive = false; // plain-reply content already printed — don't re-print it
8991
9033
  // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
8992
9034
  // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
@@ -9833,6 +9875,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
9833
9875
  continue;
9834
9876
  }
9835
9877
  jobRunning = job.jobStatus === "running";
9878
+ if (jobRunning) reachedModel = true;
9836
9879
  // Claimed → the turn's own ticker takes the screen from here (task #140). Also drop the
9837
9880
  // pre-turn block for ANY non-pending status, so a job that goes straight to completed
9838
9881
  // (a fast plain reply) can't print its answer over it.
@@ -10055,7 +10098,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10055
10098
  // turn (main() drops it from history).
10056
10099
  clearStatus();
10057
10100
  process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
10058
- return { text: "", shown: false, cancelled: true };
10101
+ return { text: "", shown: false, cancelled: true, started: reachedModel };
10059
10102
  }
10060
10103
  if (job.jobStatus === "failed") {
10061
10104
  clearStatus();
@@ -11045,6 +11088,10 @@ async function main() {
11045
11088
  await saveSession(cwd, history); // remember the pick for this folder
11046
11089
  continue;
11047
11090
  }
11091
+ if (line === "/full-log" || line.startsWith("/full-log ")) {
11092
+ await runFullLogCommand(line);
11093
+ continue;
11094
+ }
11048
11095
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11049
11096
  await runProxyCommand(line);
11050
11097
  continue;
@@ -11301,10 +11348,32 @@ async function main() {
11301
11348
  const shown = taskLine.length > 80 ? taskLine.slice(0, 80) + "…" : taskLine;
11302
11349
  console.log(GUTTER + dim(`↻ retrying: ${shown}\n`));
11303
11350
  }
11304
- history.push({ role: "user", content: taskLine });
11351
+ // SAVE THE QUESTION BEFORE THE MODEL IS ASKED (task #247). It stays in history however the
11352
+ // turn ends — answered, failed, cancelled, or the terminal killed — because it WAS asked.
11353
+ // It used to be popped on every failure and kept only as a one-shot resume, so "retry" and
11354
+ // "where were we?" reached the model with no history and it said nothing had been asked.
11355
+ // Only the failure NOTICE stays out of history (task #144). A resume, or the same question
11356
+ // typed again after a failure, is already the last entry and is not pushed a second time.
11357
+ if (isPendingQuestion(history, taskLine)) {
11358
+ delete history[history.length - 1].status; // it is the current task again, not a stopped one
11359
+ } else {
11360
+ history.push({ role: "user", content: taskLine });
11361
+ }
11362
+ await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
11305
11363
  try {
11306
- const { text: answer, shown, cancelled, inputTokens, outputTokens, turnOutputTokens,
11307
- elapsedMs } = await runAgentTurn(history);
11364
+ const turn = await runAgentTurn(history);
11365
+ // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
11366
+ // Nothing began, and "continue" can send it once the cause is fixed (task #247).
11367
+ if (!turn) {
11368
+ await rememberUnfinishedTask(cwd, taskLine);
11369
+ await markInterrupted(cwd, history, taskLine, "cancelled");
11370
+ continue;
11371
+ }
11372
+ const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
11373
+ turnOutputTokens, elapsedMs } = turn;
11374
+ // Detached: the agent keeps running on the worker, so the question is neither answered
11375
+ // nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
11376
+ if (detached) continue;
11308
11377
  // Feed the bottom bar's token row (task #104). Assigned for EVERY turn — including one
11309
11378
  // that ended without an answer — so the row never shows a stale count from an older
11310
11379
  // turn. It replaces the print-once-per-turn footer that used to live here: the bar is
@@ -11318,7 +11387,9 @@ async function main() {
11318
11387
  // it as one and DROP it — persisting it would re-send "⚠️ your account is suspended" to
11319
11388
  // the model as an assistant turn for the rest of the session (task #144).
11320
11389
  if (answer && answer.startsWith(TURN_FAILED_PREFIX)) {
11321
- history.pop(); // …the user's question goes too: nothing answered it
11390
+ // The question stays in history (saved before the turn, task #247); only this notice
11391
+ // is kept out of it. Its status says it was not done, and still stands.
11392
+ await markInterrupted(cwd, history, taskLine, "failed");
11322
11393
  if (_lineQueue.length > 0) _lineQueue.length = 0;
11323
11394
  // …but REMEMBER it, so "continue" retries the real question once the cause is fixed
11324
11395
  // (typically /model to a working coder). Deliberately not the same thing as putting it
@@ -11373,7 +11444,6 @@ async function main() {
11373
11444
  registerCopyBlock(block.rows, plain, 1);
11374
11445
  }
11375
11446
  } else {
11376
- history.pop(); // aborted/failed/cancelled turn — don't poison the next request
11377
11447
  // A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
11378
11448
  // silently launch another turn. Any keystrokes the user typed DURING the dead turn
11379
11449
  // sit in `_lineQueue` — they land there (not ignored) whenever `following` was still
@@ -11393,13 +11463,17 @@ async function main() {
11393
11463
  // next bare "continue" resumes it — even if it was cancelled during step 1,
11394
11464
  // before any worker-side checkpoint step was written.
11395
11465
  await rememberUnfinishedTask(cwd, taskLine);
11466
+ // Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
11467
+ // request still stands (task #247, the user's rule). Before that, nothing had begun.
11468
+ await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
11396
11469
  } else {
11470
+ await markInterrupted(cwd, history, taskLine, "failed");
11397
11471
  // The turn ended cleanly but empty — no exception to summarize, so say exactly that.
11398
11472
  printTurnError("the run ended without an answer (aborted or failed)");
11399
11473
  }
11400
11474
  }
11401
11475
  } catch (err) {
11402
- history.pop();
11476
+ await markInterrupted(cwd, history, taskLine, "failed");
11403
11477
  // Same reasoning as the no-answer branch (bug #92): don't let input buffered during a
11404
11478
  // turn that just threw auto-fire the next one.
11405
11479
  if (_lineQueue.length > 0) _lineQueue.length = 0;
package/log-policy.mjs ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * WHAT THIS MACHINE LOGS — "Enable Full Log" per account (task #249).
3
+ *
4
+ * Two profiles:
5
+ * · "full" — exactly what gu has always logged. For accounts an administrator turned
6
+ * "Enable Full Log" ON for (developers; a customer while their issue is traced).
7
+ * · "production" — the default for every account: the model-request dump reduced to what is NEW
8
+ * per step, a timestamp on every worker.log line, worker.log rotated, run-logs/
9
+ * kept 7 days / 200 files, and no conversation content.
10
+ *
11
+ * THE ROLLOUT GUARD. Only an explicit `fullLog: false` from the account's API selects "production".
12
+ * Never asked yet, an API that does not know the route (404 — an older gu service), a network
13
+ * failure with nothing cached: all keep "full", i.e. TODAY's behaviour. Nothing changes for anyone
14
+ * until the setting exists and has been read — the owner's rule, so a developer machine does not
15
+ * silently lose its logs mid-development. Once read, the answer is cached on disk, and a later
16
+ * failure keeps the last answer (a network hiccup never flips a machine either way).
17
+ */
18
+ import { copyFile, readdir, readFile, rename, stat, truncate, unlink, writeFile } from "node:fs/promises";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ export const POLICY_FILE = join(homedir(), ".gonext", "log-policy.json");
23
+ export const REFRESH_MS = 5 * 60_000;
24
+ export const ROTATE = { maxBytes: 20 * 1024 * 1024, keep: 3 };
25
+ export const RUN_LOGS = { maxAgeMs: 7 * 86_400_000, maxFiles: 200 };
26
+
27
+ /** The profile a known-or-unknown `fullLog` selects. Only an explicit false is production. */
28
+ export function profileFor(fullLog) {
29
+ return fullLog === false ? "production" : "full";
30
+ }
31
+
32
+ /** Ask this machine's API. → { ok: true, fullLog } | { ok: false, status } — never throws. */
33
+ export async function fetchLogPolicy({ apiBase, workerKey, fetchImpl = fetch, timeoutMs = 8000 }) {
34
+ const base = String(apiBase ?? "").replace(/\/+$/, "");
35
+ if (!base || !workerKey) return { ok: false, status: 0 };
36
+ try {
37
+ const res = await fetchImpl(`${base}/api/worker/log-policy`, {
38
+ headers: { "X-Worker-Key": workerKey },
39
+ signal: AbortSignal.timeout(timeoutMs),
40
+ });
41
+ if (!res.ok) return { ok: false, status: res.status };
42
+ const body = await res.json().catch(() => null);
43
+ return typeof body?.fullLog === "boolean" ? { ok: true, fullLog: body.fullLog } : { ok: false, status: 502 };
44
+ } catch {
45
+ return { ok: false, status: 0 };
46
+ }
47
+ }
48
+
49
+ export async function readCachedPolicy(file = POLICY_FILE) {
50
+ try {
51
+ const s = JSON.parse(await readFile(file, "utf8"));
52
+ return typeof s?.fullLog === "boolean" ? s.fullLog : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function writeCachedPolicy(file, fullLog, now) {
59
+ try {
60
+ const tmp = `${file}.${process.pid}.tmp`;
61
+ await writeFile(tmp, JSON.stringify({ fullLog, fetchedAt: new Date(now).toISOString() }) + "\n", "utf8");
62
+ await rename(tmp, file);
63
+ } catch {
64
+ /* the cache is a convenience: the next refresh asks again */
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The machine's live policy: `profile()` answers now, `refresh()` asks the API (startup, then every
70
+ * REFRESH_MS). `onChange(profile)` fires when the profile actually changes.
71
+ */
72
+ export function createLogPolicy({ apiBase, workerKey, file = POLICY_FILE, fetchImpl = fetch, now = () => Date.now(), onChange = () => {} }) {
73
+ let fullLog = null; // null = unknown
74
+ let loaded = false;
75
+ const set = (value) => {
76
+ const before = profileFor(fullLog);
77
+ fullLog = value;
78
+ const after = profileFor(fullLog);
79
+ if (after !== before) onChange(after);
80
+ };
81
+ return {
82
+ profile: () => profileFor(fullLog),
83
+ fullLog: () => fullLog,
84
+ async refresh() {
85
+ if (!loaded) {
86
+ loaded = true;
87
+ const cached = await readCachedPolicy(file);
88
+ if (cached !== null) set(cached);
89
+ }
90
+ const r = await fetchLogPolicy({ apiBase, workerKey, fetchImpl });
91
+ if (r.ok) {
92
+ set(r.fullLog);
93
+ await writeCachedPolicy(file, r.fullLog, now());
94
+ }
95
+ return r;
96
+ },
97
+ };
98
+ }
99
+
100
+ /** "2026-09-24 06:31:02" — local time, sortable, and what a person asks the log about. */
101
+ export function stamp(date = new Date()) {
102
+ const p = (n) => String(n).padStart(2, "0");
103
+ return (
104
+ `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())} ` +
105
+ `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`
106
+ );
107
+ }
108
+
109
+ /** Put the time in front of EVERY line of a message — continuation lines too, so any line can be placed. */
110
+ export function stampLines(text, date = new Date()) {
111
+ const t = stamp(date);
112
+ return String(text)
113
+ .split("\n")
114
+ .map((line) => (line ? `${t} ${line}` : line))
115
+ .join("\n");
116
+ }
117
+
118
+ /**
119
+ * Timestamp everything the process prints, while `enabled()` says so. worker.log IS this process's
120
+ * stdout/stderr (the launcher appends them to the file), so this is the one place every line —
121
+ * [gu-worker] and relayed [gu-agent] alike — passes through.
122
+ */
123
+ export function installTimestamps(enabled, { out = process.stdout, err = process.stderr, now = () => new Date() } = {}) {
124
+ const wrap = (stream) => {
125
+ const write = stream.write.bind(stream);
126
+ let atLineStart = true;
127
+ stream.write = (chunk, encoding, cb) => {
128
+ if (!enabled() || typeof chunk !== "string" && !Buffer.isBuffer(chunk)) return write(chunk, encoding, cb);
129
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
130
+ const t = stamp(now());
131
+ let outText = "";
132
+ for (const piece of text.split(/(?<=\n)/)) {
133
+ outText += (atLineStart && piece !== "\n" ? `${t} ` : "") + piece;
134
+ atLineStart = piece.endsWith("\n");
135
+ }
136
+ return write(outText, typeof encoding === "string" ? encoding : undefined, typeof encoding === "function" ? encoding : cb);
137
+ };
138
+ return () => {
139
+ stream.write = write;
140
+ };
141
+ };
142
+ const undoOut = wrap(out);
143
+ const undoErr = wrap(err);
144
+ return () => {
145
+ undoOut();
146
+ undoErr();
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Rotate `file` once it passes maxBytes: file.2 → file.3, file.1 → file.2, file → file.1, then the
152
+ * file is TRUNCATED IN PLACE. Copy-then-truncate, not rename, because the launcher holds the file
153
+ * open for appending: a renamed file would keep receiving every line. Returns true if it rotated.
154
+ */
155
+ export async function rotateIfLarge(file, { maxBytes = ROTATE.maxBytes, keep = ROTATE.keep } = {}) {
156
+ let size;
157
+ try {
158
+ size = (await stat(file)).size;
159
+ } catch {
160
+ return false;
161
+ }
162
+ if (size < maxBytes) return false;
163
+ try {
164
+ await unlink(`${file}.${keep}`).catch(() => {});
165
+ for (let i = keep - 1; i >= 1; i--) await rename(`${file}.${i}`, `${file}.${i + 1}`).catch(() => {});
166
+ await copyFile(file, `${file}.1`);
167
+ await truncate(file, 0);
168
+ return true;
169
+ } catch {
170
+ // Windows can refuse to truncate a file another process holds; try again next time.
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /** Keep run-logs/ to maxFiles files, none older than maxAgeMs. Returns how many were removed. */
176
+ export async function pruneRunLogs(dir, { maxAgeMs = RUN_LOGS.maxAgeMs, maxFiles = RUN_LOGS.maxFiles, now = Date.now() } = {}) {
177
+ let names;
178
+ try {
179
+ names = await readdir(dir);
180
+ } catch {
181
+ return 0;
182
+ }
183
+ const files = [];
184
+ for (const name of names) {
185
+ if (!name.endsWith(".log")) continue;
186
+ try {
187
+ const s = await stat(join(dir, name));
188
+ if (s.isFile()) files.push({ name, mtime: s.mtimeMs });
189
+ } catch {
190
+ /* vanished meanwhile */
191
+ }
192
+ }
193
+ files.sort((a, b) => b.mtime - a.mtime); // newest first
194
+ let removed = 0;
195
+ for (const [i, f] of files.entries()) {
196
+ if (i < maxFiles && now - f.mtime <= maxAgeMs) continue;
197
+ try {
198
+ await unlink(join(dir, f.name));
199
+ removed++;
200
+ } catch {
201
+ /* in use or gone */
202
+ }
203
+ }
204
+ return removed;
205
+ }
206
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.729",
3
+ "version": "1.0.731",
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",
@@ -56,6 +56,8 @@
56
56
  "settings-transfer.mjs",
57
57
  "device-login.mjs",
58
58
  "daemon-control.mjs",
59
+ "log-policy.mjs",
60
+ "full-log-command.mjs",
59
61
  "poll-errors.mjs",
60
62
  "node-version.mjs",
61
63
  "clear-state.mjs",
@@ -1059,7 +1059,9 @@ export function thoughtBlockRows(prose, { width = 76, collapsed = true, lines =
1059
1059
  * with nothing behind it now runs nothing, and `pending` is re-checked here as well — a
1060
1060
  * session file poisoned by the old build must not be able to restart the loop.
1061
1061
  */
1062
- const RESUME_WORDS = /^(continue|resume|go on|keep going|carry on|proceed)\.?$/i;
1062
+ // "retry" / "try again" joined in task #247: the reported user typed "retry" after a cancel, it ran
1063
+ // as a new task with no history, and the model answered that nothing had ever been asked.
1064
+ const RESUME_WORDS = /^(continue|resume|go on|keep going|carry on|proceed|retry|try again)\.?$/i;
1063
1065
 
1064
1066
  /** Is this line a bare resume word? Shared so the guards cannot drift from the reader. */
1065
1067
  export function isResumeWord(line) {
@@ -1075,6 +1077,62 @@ export function resumeTask(line, pending) {
1075
1077
  return { task: real, resumed: true, nothingToResume: false };
1076
1078
  }
1077
1079
 
1080
+ /**
1081
+ * Is `task` already the unanswered last turn of `history`? (task #247)
1082
+ *
1083
+ * The question is saved to history BEFORE its turn runs, and a turn that fails leaves it there.
1084
+ * So when the next turn resumes it ("continue", "retry") or the user types it again, it is
1085
+ * already the last entry, and pushing it again would send the model the same request twice.
1086
+ */
1087
+ export function isPendingQuestion(history, task) {
1088
+ const last = Array.isArray(history) ? history[history.length - 1] : null;
1089
+ return last?.role === "user" && String(last.content ?? "").trim() === String(task ?? "").trim();
1090
+ }
1091
+
1092
+ /**
1093
+ * How an unanswered question's turn ended, as stored on its history entry (task #247).
1094
+ *
1095
+ * stopped — Ctrl+C AFTER the coding model was called. THE USER'S RULE: that is "too slow or
1096
+ * stuck", never "I changed my mind", so the request still stands.
1097
+ * failed — the backend refused or the turn threw. Also still stands.
1098
+ * cancelled — Ctrl+C before the model was called: nothing began.
1099
+ */
1100
+ const INTERRUPT_STATUSES = new Set(["stopped", "failed", "cancelled"]);
1101
+ export function interruptStatus(how) {
1102
+ return INTERRUPT_STATUSES.has(how) ? how : "";
1103
+ }
1104
+
1105
+ // Every tag starts with RECEIVED (the user's rule, task #247): a question that is in history is one
1106
+ // the terminal received from the user. The reported model, seeing no answer, told the user "It looks
1107
+ // like the earlier request didn't come through". It did; only the answer is missing.
1108
+ const INTERRUPT_TAGS = {
1109
+ stopped:
1110
+ "[RECEIVED by the terminal and started, then STOPPED before it finished: the user interrupted " +
1111
+ "it because it was taking too long, NOT because they changed their mind. It still stands and " +
1112
+ "is not finished; some steps may already have run, so check what exists before redoing anything.]",
1113
+ failed:
1114
+ "[RECEIVED by the terminal, but NOT FINISHED: it failed (the model backend errored) before it " +
1115
+ "was answered. It still stands; some steps may already have run.]",
1116
+ cancelled:
1117
+ "[RECEIVED by the terminal, then cancelled by the user before any work began: nothing was done.]",
1118
+ };
1119
+
1120
+ /**
1121
+ * The history as the model should see it (task #247): each earlier unanswered question carries a
1122
+ * one-line tag saying how it ended. Without one, an unanswered question reads exactly like an
1123
+ * answered one, and the model may claim it was done.
1124
+ *
1125
+ * The tag goes only into what is SENT; the stored text stays the user's own words. The LAST entry
1126
+ * is the current task and is never tagged. Returns new objects of {role, content} only.
1127
+ */
1128
+ export function messagesForModel(history) {
1129
+ if (!Array.isArray(history)) return [];
1130
+ return history.map((m, i) => {
1131
+ const tag = i < history.length - 1 && m?.role === "user" ? INTERRUPT_TAGS[m.status] : "";
1132
+ return { role: m?.role, content: tag ? `${m.content}\n${tag}` : m?.content };
1133
+ });
1134
+ }
1135
+
1078
1136
 
1079
1137
  /**
1080
1138
  * Commands that still WORK but are no longer offered (task #153).