@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.
- package/full-log-command.mjs +219 -0
- package/gonext_agent_chat.py +107 -36
- package/gu-cli.mjs +73 -1
- package/gu-repl.mjs +85 -11
- package/log-policy.mjs +206 -0
- package/package.json +3 -1
- package/terminal-layout.mjs +59 -1
|
@@ -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
|
+
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
7027
|
-
_log(f"current task (latest user message): {task_text.rsplit('Current task: ', 1)[-1]
|
|
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
|
|
7719
|
-
+ (f" edit={edit_instruction
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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']
|
|
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
|
|
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): {
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11307
|
-
|
|
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
|
|
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
|
|
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.
|
|
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",
|
package/terminal-layout.mjs
CHANGED
|
@@ -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
|
-
|
|
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).
|