@tiens.nguyen/gu-cli 1.0.796 → 1.0.797

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.796",
3
+ "version": "1.0.797",
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",