@tiens.nguyen/gu-cli 1.0.795 → 1.0.797

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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/model-prices.mjs CHANGED
@@ -177,6 +177,33 @@ export function turnCost({ input = 0, cached = 0, cacheReported = false, firstIn
177
177
  }
178
178
 
179
179
  /** "$1.23", "$0.123", "$0.0042"; "~" in front when the cached share was assumed. */
180
+ /**
181
+ * WHAT THE BAR SAYS ABOUT THE LAST TURN'S COST (task #349).
182
+ *
183
+ * Reported: "price and cost is not shown on this gu terminal on this Mac." The coder there was a
184
+ * self-hosted Ollama model, which is in no price table — and for a model with no price the bar
185
+ * showed NOTHING, by design ("never a misleading | $0"). Right about the $0, wrong about the
186
+ * silence: a missing figure looked like a broken feature. So the three cases are told apart:
187
+ *
188
+ * a price was found → { kind: "cost", text: "$0.334" | "~$0.334" }
189
+ * the turn's model has none → { kind: "none", text: "no price" } — never "$0": it is not free,
190
+ * it is not priced
191
+ * no turn has run yet → null (there is nothing to say)
192
+ *
193
+ * `last` is the terminal's lastTurnCost: { usd, estimated } or { usd: null, model, host }.
194
+ */
195
+ export function costSegment(last) {
196
+ if (!last) return null;
197
+ if (last.usd == null) return { kind: "none", text: "no price" };
198
+ return { kind: "cost", text: formatUsd(last.usd, { estimated: last.estimated === true }) };
199
+ }
200
+
201
+ /** The line /prices prints when the coder in use has no price — why, and that nothing is broken. */
202
+ export function noPriceLine({ model = "", host = "" } = {}) {
203
+ const what = `${model || "the coder"}${host ? ` at ${host}` : ""}`;
204
+ return `no price for ${what} — its turns show "no price" on the bar instead of a cost. The table prices hosted providers; a model on your own server has none.`;
205
+ }
206
+
180
207
  export function formatUsd(usd, { estimated = false } = {}) {
181
208
  if (!Number.isFinite(usd)) return "";
182
209
  const v = Math.max(0, usd);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * THE WAY OUT OF THE MODEL QUESTIONS, and the key question that follows a typed server (#301).
3
3
  *
4
- * Reported: at "Agent + embedding server [https://ollama1.gomarsic.cc]:" there was no visible way
4
+ * Reported: at "Agent + embedding server [https://ollama1.getmarsic.cc]:" there was no visible way
5
5
  * to NOT choose. Enter takes the default server; a decline existed ("n", "no", "skip", "-") but no
6
6
  * prompt said so, and one hint even claimed "press Enter with no address to skip", which was the
7
7
  * opposite of what Enter does. Some people want no model setup at all and to load a saved preset.
package/ollama-setup.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pointing a Client Expert machine at ITS OWN Ollama box (task #220).
3
3
  *
4
- * Setup used to seed https://ollama1.gomarsic.cc and three model names, so anyone whose box is
4
+ * Setup used to seed https://ollama1.getmarsic.cc and three model names, so anyone whose box is
5
5
  * somewhere else — a company LAN, http://192.168.1.40:11434, their own dnsname — had to know to
6
6
  * override environment variables they had never heard of. This module is the pure half of asking
7
7
  * instead: normalise whatever URL they type, and turn the list that box actually reports into the
@@ -180,8 +180,8 @@ export function describeNotReady(host, missing) {
180
180
  * 192.168.1.40:11434 → http://192.168.1.40:11434/v1
181
181
  * ollama.local → http://ollama.local:11434/v1
182
182
  * http://box:11434/v1 → http://box:11434/v1
183
- * https://ollama1.gomarsic.cc → https://ollama1.gomarsic.cc/v1
184
- * https://ollama1.gomarsic.cc/v1/ → https://ollama1.gomarsic.cc/v1
183
+ * https://ollama1.getmarsic.cc → https://ollama1.getmarsic.cc/v1
184
+ * https://ollama1.getmarsic.cc/v1/ → https://ollama1.getmarsic.cc/v1
185
185
  *
186
186
  * TWO DEFAULTS, AND THEY ARE NOT SYMMETRICAL:
187
187
  *
@@ -243,7 +243,7 @@ export function normalizeOllamaUrl(raw) {
243
243
  * Every address a bare hostname could reasonably mean, in the order worth trying.
244
244
  *
245
245
  * ONE GUESS IS NOT ENOUGH, and this was found by typing a real hostname at a real prompt:
246
- * `ollama2.gomarsic.cc` became `http://ollama2.gomarsic.cc:11434` and timed out, because that
246
+ * `ollama2.getmarsic.cc` became `http://ollama2.getmarsic.cc:11434` and timed out, because that
247
247
  * box is behind a reverse proxy on HTTPS:443 — exactly like ollama1. The single default is
248
248
  * right for a LAN box and wrong for a public name, and the reader has no way to know which
249
249
  * assumption we made beyond a timeout that looks like their server is down.
@@ -390,7 +390,7 @@ export async function localOllamaDefault(fallbackUrl, fetchTags, { log } = {}) {
390
390
  *
391
391
  * DISPLAY ONLY. Every settings field in gu stores the /v1 base, because that is the
392
392
  * OpenAI-compatible endpoint the API actually calls; nothing about what is SAVED changes here.
393
- * What changes is what we show: a default of "https://ollama1.gomarsic.cc/v1" reads as an
393
+ * What changes is what we show: a default of "https://ollama1.getmarsic.cc/v1" reads as an
394
394
  * instruction, and the reader is left wondering whether their own address needs the suffix too
395
395
  * (reported 2026-09-04: "it makes user confused if he/she should input v1 to the url").
396
396
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.795",
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",
package/proxy-config.mjs CHANGED
@@ -126,8 +126,8 @@ export function isLoopbackUrl(url) {
126
126
  /**
127
127
  * The origin (scheme + host + port) of a URL, which is what "the same endpoint" means here.
128
128
  *
129
- * A configured model is https://ollama1.gomarsic.cc/v1 while the actual call is
130
- * https://ollama1.gomarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
129
+ * A configured model is https://ollama1.getmarsic.cc/v1 while the actual call is
130
+ * https://ollama1.getmarsic.cc/v1/chat/completions — comparing full URLs would match neither, and
131
131
  * comparing hostnames alone would proxy a DIFFERENT service that happens to share the host.
132
132
  * Origin is the level that means "this endpoint", and URL normalizes the default port for us.
133
133
  */
@@ -141,7 +141,7 @@ export function originOf(url) {
141
141
 
142
142
  /**
143
143
  * The three model endpoints, read from the settings row rather than hardcoded — the user can
144
- * change any of them at any time (/model, the web app), and a hardcoded ollama1.gomarsic.cc
144
+ * change any of them at any time (/model, the web app), and a hardcoded ollama1.getmarsic.cc
145
145
  * would keep proxying an endpoint they have already moved away from.
146
146
  */
147
147
  export function modelOrigins(settings = {}) {
@@ -63,10 +63,38 @@ export function applyToggleShift(blocks, block, newEnd) {
63
63
  * write(seq) → put bytes on screen.
64
64
  * afterPaint() → put the caret back where the user is typing.
65
65
  */
66
+ /**
67
+ * DOES A CLICK ON THIS BLOCK CHANGE ANYTHING? (task #350) — the ONE question hover and click
68
+ * both ask.
69
+ *
70
+ * Reported: "hover an item and it becomes whiter; some items, you click and nothing expands — so
71
+ * the user is confused. Only allow hover to turn white if it is expandable." A highlight is a
72
+ * promise that a click does something. Every thought and every look-up row made that promise;
73
+ * a short thought that is already shown whole could not keep it.
74
+ *
75
+ * A block has two forms, collapsed and expanded. It can be toggled exactly when they are not the
76
+ * same rows — which answers every case without a list of kinds:
77
+ * · a long thought, collapsed → different rows → highlight, click expands
78
+ * · the same thought, expanded → different rows → highlight, click collapses
79
+ * · a thought that fits as it is → identical rows → no highlight, click does nothing
80
+ * · one look-up that fits its row → identical rows → no highlight
81
+ * …and it follows the WINDOW: the rows are laid out for the current width, so a thought that
82
+ * fits at 160 columns and is cut at 80 is expandable at 80 only.
83
+ */
84
+ export function rowsDiffer(collapsedRows, expandedRows) {
85
+ const a = collapsedRows ?? [];
86
+ const b = expandedRows ?? [];
87
+ if (a.length !== b.length) return true;
88
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return true;
89
+ return false;
90
+ }
91
+
66
92
  export function createHoverController({
67
93
  pinned = () => true,
68
94
  transcriptRows = () => 1,
69
95
  blockAtRow = () => null,
96
+ // #350: may this block be highlighted — i.e. would a click on it change anything?
97
+ canToggle = () => true,
70
98
  paintRows = () => [],
71
99
  replaceRange = () => null,
72
100
  repaintSeq = () => "",
@@ -101,7 +129,10 @@ export function createHoverController({
101
129
  if (key === lastKey) return false;
102
130
  lastKey = key;
103
131
 
104
- const block = blockAtRow(row);
132
+ // ONLY WHAT A CLICK WOULD CHANGE (#350). A block that is already shown whole is treated as
133
+ // no block at all: nothing lights up, and whatever was lit before is put back.
134
+ const under = blockAtRow(row);
135
+ const block = under && canToggle(under) ? under : null;
105
136
  if (block === hovered) return false;
106
137
  const previous = hovered;
107
138
  hovered = block;
@@ -528,6 +528,61 @@ export function buildFooterRows({
528
528
  ];
529
529
  }
530
530
 
531
+ /**
532
+ * A SUBMITTED QUESTION, LAID OUT FOR THE TRANSCRIPT (task #348): every row at the column where
533
+ * the first one's text starts — after ">> ".
534
+ *
535
+ * Reported with a screenshot: a pasted question of many lines was echoed as ">> Build the first
536
+ * playable…" and then every other line hard against the left edge. The echo was the prompt plus
537
+ * the text exactly as typed, so only the first line had anything in front of it; a long line was
538
+ * left for the terminal to soft-wrap, back to column 0 as well.
539
+ *
540
+ * → rows WITHOUT the prompt: the caller puts ">> " before the first and `indent` spaces before
541
+ * the rest. Blank lines stay empty (no trailing spaces). A line's own leading spaces are kept —
542
+ * pasted code and nested lists keep their shape — and its wrapped rows start under its first
543
+ * word. A word longer than the row is cut rather than left to overflow.
544
+ */
545
+ export function questionEchoRows(text, { width = 80, indent = 3 } = {}) {
546
+ const rows = [];
547
+ for (const line of String(text ?? "").replace(/\r/g, "").split("\n")) {
548
+ if (line.trim() === "") {
549
+ rows.push("");
550
+ continue;
551
+ }
552
+ const lead = /^[ \t]*/.exec(line)[0].replace(/\t/g, " ");
553
+ const avail = Math.max(8, (width || 80) - 1 - indent - visibleLen(lead));
554
+ let cur = "";
555
+ for (let word of line.trim().split(/\s+/)) {
556
+ while (visibleLen(word) > avail) {
557
+ if (cur) {
558
+ rows.push(lead + cur);
559
+ cur = "";
560
+ }
561
+ const head = clipVisible(word, avail);
562
+ rows.push(lead + head);
563
+ word = word.slice(head.length);
564
+ }
565
+ if (!word) continue;
566
+ const next = cur ? `${cur} ${word}` : word;
567
+ if (visibleLen(next) <= avail) cur = next;
568
+ else {
569
+ rows.push(lead + cur);
570
+ cur = word;
571
+ }
572
+ }
573
+ if (cur) rows.push(lead + cur);
574
+ }
575
+ return rows;
576
+ }
577
+
578
+ /** The echo itself: ">> " on the first row, the same width of spaces on every other. */
579
+ export function questionEchoText(text, { width = 80, prompt = ">> ", paintPrompt = (s) => s } = {}) {
580
+ const indent = visibleLen(prompt);
581
+ const rows = questionEchoRows(text, { width, indent });
582
+ if (rows.length === 0) return paintPrompt(prompt);
583
+ return rows.map((r, i) => (i === 0 ? paintPrompt(prompt) + r : r === "" ? "" : " ".repeat(indent) + r)).join("\n");
584
+ }
585
+
531
586
  /**
532
587
  * The bytes that print a NEW thought block into the transcript (task #344): its painted rows,
533
588
  * with ONE empty line above when the row above is not already blank (`gap`).