@tiens.nguyen/gu-cli 1.0.796 → 1.0.798
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/gonext_agent_chat.py +204 -23
- package/gu-cli.mjs +37 -2
- package/gu-repl.mjs +202 -178
- package/log-level.mjs +193 -0
- package/package.json +2 -1
- package/terminal-layout.mjs +49 -0
package/log-level.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HOW MUCH THIS MACHINE LOGS (task #352) — almost nothing, unless a `gu --log` run is open.
|
|
3
|
+
*
|
|
4
|
+
* The owner's rule (2026-10-04): "reduce logs to super minimum — only log like currently if the
|
|
5
|
+
* user does `gu --log`; minimum is only if something starts, something stops, or an error occurs",
|
|
6
|
+
* and "--log is per run".
|
|
7
|
+
*
|
|
8
|
+
* Measured on the Mac it was asked for: nearly every line of worker.log was the agent's per-step
|
|
9
|
+
* narration relayed by the worker — "=== MODEL REQUEST", "step N ← model output", "heartbeat:
|
|
10
|
+
* model call in flight" — about 150 lines a turn, none of which say that anything went wrong.
|
|
11
|
+
*
|
|
12
|
+
* TWO LEVELS:
|
|
13
|
+
* · "minimal" (the default) — a line when something STARTS (the worker, a job), one when it
|
|
14
|
+
* STOPS (how it ended, how long it took), and every ERROR or warning. Nothing per step, per
|
|
15
|
+
* request, per heartbeat.
|
|
16
|
+
* · "detail" — exactly what was logged before this existed (the account's profile, "production"
|
|
17
|
+
* or "full", see log-policy.mjs). On only while a `gu --log` terminal is open.
|
|
18
|
+
*
|
|
19
|
+
* PER RUN, AND THE WORKER OUTLIVES THE RUN. The terminal that was started with --log writes a
|
|
20
|
+
* LEASE — a small file holding its pid. The worker reads it; a lease whose process is gone is no
|
|
21
|
+
* lease. So closing that terminal, or killing it, puts the machine back at minimal with nothing
|
|
22
|
+
* to switch off and no setting left behind.
|
|
23
|
+
*/
|
|
24
|
+
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
25
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const LEASE_FILE = join(homedir(), ".gonext", "log-detail.json");
|
|
30
|
+
|
|
31
|
+
/** Is that process still running? (signal 0 asks without sending anything.) */
|
|
32
|
+
export function pidAlive(pid) {
|
|
33
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// EPERM: it exists, it is just not ours to signal.
|
|
39
|
+
return e?.code === "EPERM";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a lease file's text says: the pid of the `gu --log` run, or 0 when it is not a lease. */
|
|
44
|
+
export function leasePid(text) {
|
|
45
|
+
try {
|
|
46
|
+
const pid = JSON.parse(String(text ?? ""))?.pid;
|
|
47
|
+
return Number.isInteger(pid) && pid > 1 ? pid : 0;
|
|
48
|
+
} catch {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The level a lease's text selects: "detail" only while its process is alive. Pure given `alive`. */
|
|
54
|
+
export function levelFor(leaseText, alive = pidAlive) {
|
|
55
|
+
const pid = leasePid(leaseText);
|
|
56
|
+
return pid && alive(pid) ? "detail" : "minimal";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The level right now. Synchronous and never throws: it sits on the path of every log line. */
|
|
60
|
+
export function currentLevel({ file = LEASE_FILE, alive = pidAlive } = {}) {
|
|
61
|
+
try {
|
|
62
|
+
return levelFor(readFileSync(file, "utf8"), alive);
|
|
63
|
+
} catch {
|
|
64
|
+
return "minimal"; // no lease file
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `gu --log` started: take the lease for this run. */
|
|
69
|
+
export async function takeLease({ file = LEASE_FILE, pid = process.pid, now = new Date() } = {}) {
|
|
70
|
+
const tmp = `${file}.${pid}.tmp`;
|
|
71
|
+
await writeFile(tmp, JSON.stringify({ pid, since: now.toISOString() }) + "\n", "utf8");
|
|
72
|
+
await rename(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** That run is ending: give the lease back — but only if it is still OURS (a later --log run may hold it). */
|
|
76
|
+
export async function releaseLease({ file = LEASE_FILE, pid = process.pid } = {}) {
|
|
77
|
+
try {
|
|
78
|
+
if (leasePid(await readFile(file, "utf8")) === pid) await unlink(file);
|
|
79
|
+
} catch {
|
|
80
|
+
/* nothing to release */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The same, for an "exit" handler — which may only do synchronous work. */
|
|
85
|
+
export function releaseLeaseSync({ file = LEASE_FILE, pid = process.pid } = {}) {
|
|
86
|
+
try {
|
|
87
|
+
if (leasePid(readFileSync(file, "utf8")) === pid) unlinkSync(file);
|
|
88
|
+
} catch {
|
|
89
|
+
/* nothing to release */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// What is kept at "minimal". Two lists, read as: is this line about a START or a STOP, or does it
|
|
94
|
+
// say something went WRONG? Everything else is narration.
|
|
95
|
+
//
|
|
96
|
+
// LIFECYCLE — the worker and each job, coming and going.
|
|
97
|
+
const LIFECYCLE = [
|
|
98
|
+
/\[gu-worker\] gu-cli v/, // the worker started, and which version wrote this log
|
|
99
|
+
/\[gu-worker\] daemon (started|RUNNING|stopped)/,
|
|
100
|
+
/\[gu-worker\] (started|completed|cancelled|stopped|stopping|shutting down)\b/,
|
|
101
|
+
/\[gu-worker\] log (level|profile):/,
|
|
102
|
+
/\bAPI listening on\b/,
|
|
103
|
+
/\[mongo\] (connected|disconnected)/,
|
|
104
|
+
];
|
|
105
|
+
// TROUBLE — written to be generous: a line kept by mistake costs a line; a dropped error costs
|
|
106
|
+
// the afternoon it takes to find out why a turn failed.
|
|
107
|
+
const TROUBLE =
|
|
108
|
+
/\b(error|errors|errored|fail|failed|failure|fatal|exception|traceback|panic|refused|unreachable|unavailable|overloaded|denied|forbidden|unauthori[sz]ed|invalid|rejected|aborted|killed|crash(ed)?|timed out|timeout|not found|cannot|can't|could ?n[o']t|couldn't|unable|warning|warn|degrad\w*|fall(ing|s)? back|fell back|fallback|skipped|lost)\b|✗|⚠|HTTP [45]\d\d|status[= ][45]\d\d/i;
|
|
109
|
+
// System error codes — ENOENT, ECONNRESET, EPERM, ETIMEDOUT. CASE-SENSITIVE and on its own: under
|
|
110
|
+
// the /i above it matched every word starting with "e" ("earlier", "exact", "explicit"), which
|
|
111
|
+
// kept half the narration. Measured on a real worker.log before this line was split out.
|
|
112
|
+
const ERRNO = /\bE[A-Z]{4,}\b/;
|
|
113
|
+
|
|
114
|
+
/** Does a line survive at the minimal level? Pure. */
|
|
115
|
+
export function keepAtMinimal(line) {
|
|
116
|
+
const text = String(line ?? "");
|
|
117
|
+
if (!text.trim()) return false;
|
|
118
|
+
if (LIFECYCLE.some((re) => re.test(text))) return true;
|
|
119
|
+
return TROUBLE.test(text) || ERRNO.test(text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Filter what the process prints, a LINE at a time, while `level()` says "minimal".
|
|
124
|
+
*
|
|
125
|
+
* worker.log IS this process's stdout/stderr (the launcher appends them to the file), so this is
|
|
126
|
+
* the one place every line passes through — the worker's own and the agent's relayed ones — which
|
|
127
|
+
* is why the rule lives here and not at 300 call sites.
|
|
128
|
+
*
|
|
129
|
+
* · stdout: a line is written only if keepAtMinimal says so. A chunk may hold several lines or
|
|
130
|
+
* end mid-line, so text is held until its newline arrives.
|
|
131
|
+
* · stderr: everything passes. console.error / console.warn are errors and warnings by intent,
|
|
132
|
+
* and a Node stack trace must arrive whole.
|
|
133
|
+
*
|
|
134
|
+
* Install it AFTER the timestamps (log-policy.mjs installTimestamps): each wrapper wraps the
|
|
135
|
+
* stream as it finds it, so the last one installed is the first a line meets — the filter decides,
|
|
136
|
+
* and only a kept line goes on to be stamped. Returns an undo.
|
|
137
|
+
*/
|
|
138
|
+
export function installLevelFilter(level, { out = process.stdout } = {}) {
|
|
139
|
+
const write = out.write.bind(out);
|
|
140
|
+
let held = "";
|
|
141
|
+
let dropped = 0;
|
|
142
|
+
out.write = (chunk, encoding, cb) => {
|
|
143
|
+
const done = typeof encoding === "function" ? encoding : cb;
|
|
144
|
+
if ((typeof chunk !== "string" && !Buffer.isBuffer(chunk)) || level() !== "minimal") {
|
|
145
|
+
if (held) {
|
|
146
|
+
// The level just rose mid-line: what was held belongs to the detailed log.
|
|
147
|
+
const h = held;
|
|
148
|
+
held = "";
|
|
149
|
+
write(h);
|
|
150
|
+
}
|
|
151
|
+
return write(chunk, encoding, cb);
|
|
152
|
+
}
|
|
153
|
+
held += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
154
|
+
let keep = "";
|
|
155
|
+
let at;
|
|
156
|
+
while ((at = held.indexOf("\n")) !== -1) {
|
|
157
|
+
const line = held.slice(0, at);
|
|
158
|
+
held = held.slice(at + 1);
|
|
159
|
+
if (keepAtMinimal(line)) keep += line + "\n";
|
|
160
|
+
else dropped++;
|
|
161
|
+
}
|
|
162
|
+
// A runaway line with no newline must not grow in memory for ever.
|
|
163
|
+
if (held.length > 65536) held = held.slice(-4096);
|
|
164
|
+
if (keep) return write(keep, undefined, done);
|
|
165
|
+
if (typeof done === "function") done();
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
const undo = () => {
|
|
169
|
+
out.write = write;
|
|
170
|
+
};
|
|
171
|
+
/** Lines dropped since the last call — for the "stopped" line, so a quiet log says it was quiet on purpose. */
|
|
172
|
+
undo.takeDropped = () => {
|
|
173
|
+
const n = dropped;
|
|
174
|
+
dropped = 0;
|
|
175
|
+
return n;
|
|
176
|
+
};
|
|
177
|
+
return undo;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** run-logs/ at minimal: the last day's, at most 20 files (production keeps 7 days / 200). */
|
|
181
|
+
export const MINIMAL_RUN_LOGS = { maxAgeMs: 86_400_000, maxFiles: 20 };
|
|
182
|
+
|
|
183
|
+
/** The profile an agent run is told to log at: "minimal", or the account's own while --log is open. */
|
|
184
|
+
export function agentLogProfile(level, accountProfile) {
|
|
185
|
+
return level === "detail" ? accountProfile || "full" : "minimal";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One line for `gu status` and the worker's log. */
|
|
189
|
+
export function levelLine(level) {
|
|
190
|
+
return level === "detail"
|
|
191
|
+
? "log level: detail — a `gu --log` terminal is open; back to minimal when it closes"
|
|
192
|
+
: "log level: minimal — starts, stops and errors only (`gu --log` for the detail)";
|
|
193
|
+
}
|
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.798",
|
|
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",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"device-login.mjs",
|
|
77
77
|
"daemon-control.mjs",
|
|
78
78
|
"log-policy.mjs",
|
|
79
|
+
"log-level.mjs",
|
|
79
80
|
"full-log-command.mjs",
|
|
80
81
|
"poll-errors.mjs",
|
|
81
82
|
"node-version.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -1950,6 +1950,55 @@ export function describeInstallFailure({ status = 0, stderr = "", error = null }
|
|
|
1950
1950
|
*/
|
|
1951
1951
|
export const CLI_UPDATE_SHELL_COMMAND = `${CLI_INSTALL_COMMAND} --prefer-online`;
|
|
1952
1952
|
|
|
1953
|
+
/**
|
|
1954
|
+
* WHERE the update installs, so it lands on the gu that is RUNNING (task #16 follow-up).
|
|
1955
|
+
*
|
|
1956
|
+
* A bare `npm install -g` uses whichever npm is first on PATH — which, with nvm AND Homebrew (or
|
|
1957
|
+
* several nvm versions) installed, is often not the npm that owns this gu. The install then
|
|
1958
|
+
* "succeeds" into the OTHER prefix and the next `gu` still starts the old version. So:
|
|
1959
|
+
* · `--prefix <prefix>` — the global prefix THIS package lives under (globalPrefixOf), when
|
|
1960
|
+
* the caller found one (a checkout / npm link has none, and keeps the plain command);
|
|
1961
|
+
* · the running node's bin dir goes first on PATH, so `npm` is the one that matches this node.
|
|
1962
|
+
* POSIX only: Windows resolves npm.cmd through the shell and keeps its own prefix rules.
|
|
1963
|
+
* → { command, env, prefix }
|
|
1964
|
+
*/
|
|
1965
|
+
export function cliUpdatePlan({ prefix = "", execPath = "", platform = process.platform, env = {} } = {}) {
|
|
1966
|
+
const posix = platform !== "win32";
|
|
1967
|
+
let command = CLI_UPDATE_SHELL_COMMAND;
|
|
1968
|
+
let usedPrefix = "";
|
|
1969
|
+
// A prefix with shell metacharacters is not worth quoting cleverly: fall back to the plain command.
|
|
1970
|
+
if (posix && prefix && !/["$`\\\n]/.test(prefix)) {
|
|
1971
|
+
command += ` --prefix "${prefix}"`;
|
|
1972
|
+
usedPrefix = prefix;
|
|
1973
|
+
}
|
|
1974
|
+
let nextEnv = { ...env };
|
|
1975
|
+
if (posix && execPath && execPath.includes("/")) {
|
|
1976
|
+
const bin = execPath.slice(0, execPath.lastIndexOf("/")) || "/";
|
|
1977
|
+
nextEnv.PATH = env.PATH ? `${bin}:${env.PATH}` : bin;
|
|
1978
|
+
}
|
|
1979
|
+
return { command, env: nextEnv, prefix: usedPrefix };
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
/**
|
|
1983
|
+
* npm exited 0 — but is the gu on disk really the version we meant to install?
|
|
1984
|
+
* → null when fine (or unknowable), otherwise the sentence to show. Compares numeric parts, so a
|
|
1985
|
+
* NEWER version published in between is fine; only an installed version OLDER than wanted fails.
|
|
1986
|
+
*/
|
|
1987
|
+
export function installedVersionShortfall(installed = "", wanted = "") {
|
|
1988
|
+
const parse = (v) => String(v ?? "").split("-")[0].split(".").map((n) => parseInt(n, 10));
|
|
1989
|
+
const a = parse(installed), b = parse(wanted);
|
|
1990
|
+
if (!installed || !wanted || a.some(Number.isNaN) || b.some(Number.isNaN)) return null;
|
|
1991
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
1992
|
+
const x = a[i] ?? 0, y = b[i] ?? 0;
|
|
1993
|
+
if (x > y) return null;
|
|
1994
|
+
if (x < y) {
|
|
1995
|
+
return `npm finished, but the gu on disk is still ${installed} (wanted ${wanted}) — ` +
|
|
1996
|
+
"run `npm install -g @tiens.nguyen/gu-cli@latest` in a terminal, then restart gu.";
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
return null;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
1953
2002
|
/**
|
|
1954
2003
|
* Did the install fail only because npm has not caught up with a JUST-published version?
|
|
1955
2004
|
* (ETARGET / notarget — "No matching version found".) Worth one retry after a short wait, and
|