@tiens.nguyen/gu-cli 1.0.791 → 1.0.792

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/clear-logs.mjs ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * "clear logs" (#329) — what is taking the disk under ~/.gonext, and a way to give it back.
3
+ *
4
+ * Asked for after this Mac's disk filled three times in one day and took the local MongoDB down
5
+ * with it (#328). A click on the notice row's "clear logs" (or /clear-logs) DELETES NOTHING at
6
+ * first: it measures, shows the logs beside the two things that are actually big — the RAG indexes
7
+ * and the downloaded API versions — with a total, and then asks about each of the three.
8
+ *
9
+ * THREE DIFFERENT KINDS OF "CLEAR", because losing each costs something different:
10
+ *
11
+ * logs nothing. But the worker and the local API hold their log OPEN: a deleted open file frees
12
+ * no space on macOS/Linux until the holder closes it (and the holder keeps writing into
13
+ * the unlinked file), and on Windows the delete fails outright. So the live "<name>.log"
14
+ * is TRUNCATED IN PLACE and only rotations and finished run-logs are deleted — the same
15
+ * rule rotateIfLarge (log-policy.mjs) already lives by.
16
+ * rag minutes of embedding per folder: an index is rebuilt the next time its folder is used.
17
+ * api nothing, PROVIDED the version in use stays. One folder per installed version, none ever
18
+ * removed; deleting the running one would break every open terminal's turn. So "clear
19
+ * api" only ever means the OLDER versions.
20
+ *
21
+ * SELECTED BY AN EXPLICIT LIST, never by a "*.log*" / "*.jsonl" sweep of the home: sessions/archive
22
+ * is conversation history (#278) and rag/ shards are also .jsonl. Anything not named here is not
23
+ * touched, whatever the answers.
24
+ *
25
+ * Everything takes `home` so the tests run against a real temp folder, not this machine's.
26
+ */
27
+ import { readdir, readFile, rm, rmdir, stat, truncate, unlink } from "node:fs/promises";
28
+ import { existsSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { pickUsableVersion, compareVersions } from "./version-check.mjs";
32
+
33
+ /** The logs gu writes at the top of its home. `<name>` is live; `<name>.N` are its rotations. */
34
+ export const LIVE_LOGS = ["worker.log", "local-api.log", "model.log", "embed.log"];
35
+ /** Folders that hold nothing but finished log files. */
36
+ export const LOG_DIRS = ["run-logs", "logs"];
37
+ /** The link appears once the logs pass this — below it the row stays quiet. */
38
+ export const LOGS_NOTICE_BYTES = 50 * 1024 * 1024;
39
+
40
+ const guHome = (home = homedir()) => join(home, ".gonext");
41
+
42
+ /** "740 KB", "15 MB", "4.9 GB" — two significant figures, the way `du -h` reads. */
43
+ export function humanSize(bytes) {
44
+ const n = Math.max(0, Number(bytes) || 0);
45
+ const units = [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]];
46
+ for (const [unit, size] of units) {
47
+ if (n < size) continue;
48
+ const v = n / size;
49
+ // 9.96 would print as "10.0" with one decimal — let it round up to the integer form instead.
50
+ return `${v < 9.95 ? v.toFixed(1) : Math.round(v)} ${unit}`;
51
+ }
52
+ return `${Math.round(n)} B`;
53
+ }
54
+
55
+ const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
56
+
57
+ /**
58
+ * What a file takes ON DISK — its blocks, which is what clearing gives back and what `du` reports.
59
+ * The byte length undercounts thousands of small files (an API version read as 85 MB here against
60
+ * du's 120 MB). Windows reports no blocks; the length is the best there is.
61
+ */
62
+ const onDisk = (s) => (s.blocks > 0 ? s.blocks * 512 : s.size);
63
+
64
+ async function fileSize(p) {
65
+ try {
66
+ const s = await stat(p);
67
+ return s.isFile() ? onDisk(s) : -1;
68
+ } catch {
69
+ return -1;
70
+ }
71
+ }
72
+
73
+ /** Bytes under a folder. Children are walked together: an API version is thousands of files. */
74
+ async function dirSize(p) {
75
+ let entries;
76
+ try {
77
+ entries = await readdir(p, { withFileTypes: true });
78
+ } catch {
79
+ return 0;
80
+ }
81
+ const sizes = await Promise.all(
82
+ entries.map(async (e) => {
83
+ const full = join(p, e.name);
84
+ if (e.isDirectory()) return dirSize(full);
85
+ if (e.isSymbolicLink()) return 0; // never followed: a link out of the home is not ours to count
86
+ try {
87
+ return onDisk(await stat(full));
88
+ } catch {
89
+ return 0;
90
+ }
91
+ })
92
+ );
93
+ return sizes.reduce((a, b) => a + b, 0);
94
+ }
95
+
96
+ /**
97
+ * The logs: each live file and its rotations by name, each log folder as a count.
98
+ * → { items: [{ name, bytes, kind: "live" | "rotation" | "dir", files?, paths? }], bytes, files }
99
+ */
100
+ export async function surveyLogs({ home = homedir() } = {}) {
101
+ const root = guHome(home);
102
+ let names = [];
103
+ try {
104
+ names = await readdir(root);
105
+ } catch {
106
+ /* no home yet — nothing to report */
107
+ }
108
+ const items = [];
109
+ for (const live of LIVE_LOGS) {
110
+ const bytes = await fileSize(join(root, live));
111
+ if (bytes >= 0) items.push({ name: live, bytes, kind: "live", path: join(root, live) });
112
+ const rotations = names
113
+ .filter((n) => n.startsWith(live + ".") && /^\d+$/.test(n.slice(live.length + 1)))
114
+ .sort((a, b) => Number(a.slice(live.length + 1)) - Number(b.slice(live.length + 1)));
115
+ for (const r of rotations) {
116
+ const b = await fileSize(join(root, r));
117
+ if (b >= 0) items.push({ name: r, bytes: b, kind: "rotation", path: join(root, r) });
118
+ }
119
+ }
120
+ for (const dir of LOG_DIRS) {
121
+ let files = [];
122
+ try {
123
+ files = (await readdir(join(root, dir), { withFileTypes: true })).filter(
124
+ (e) => e.isFile() && /\.log(\.\d+)?$/.test(e.name)
125
+ );
126
+ } catch {
127
+ continue;
128
+ }
129
+ const paths = files.map((e) => join(root, dir, e.name));
130
+ let bytes = 0;
131
+ for (const p of paths) bytes += Math.max(0, await fileSize(p));
132
+ if (paths.length) items.push({ name: dir, bytes, kind: "dir", files: paths.length, paths });
133
+ }
134
+ return {
135
+ items,
136
+ bytes: items.reduce((n, i) => n + i.bytes, 0),
137
+ files: items.reduce((n, i) => n + (i.kind === "dir" ? i.files : 1), 0),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Every local index: ~/.gonext/rag/<folder hash>/<source hash>/. The hashes say nothing to a
143
+ * person, so the name comes from the manifest the agent wrote (`sourceUrl`). `missing` = that
144
+ * source was a folder on this machine and is gone — the index can never be used again.
145
+ */
146
+ export async function surveyRag({ home = homedir() } = {}) {
147
+ const root = join(guHome(home), "rag");
148
+ const indexes = [];
149
+ let folders = [];
150
+ try {
151
+ folders = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory());
152
+ } catch {
153
+ return { indexes, bytes: 0 };
154
+ }
155
+ for (const f of folders) {
156
+ let sources = [];
157
+ try {
158
+ sources = (await readdir(join(root, f.name), { withFileTypes: true })).filter((e) => e.isDirectory());
159
+ } catch {
160
+ continue;
161
+ }
162
+ for (const s of sources) {
163
+ const dir = join(root, f.name, s.name);
164
+ let source = "";
165
+ try {
166
+ source = String(JSON.parse(await readFile(join(dir, "manifest.json"), "utf8"))?.sourceUrl ?? "");
167
+ } catch {
168
+ /* no manifest — an interrupted index; still counted, still clearable */
169
+ }
170
+ const isPath = source.startsWith("/") || /^[A-Za-z]:[\\/]/.test(source);
171
+ indexes.push({
172
+ dir,
173
+ parent: join(root, f.name),
174
+ source,
175
+ label: source ? source.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || source : `${s.name.slice(0, 8)}…`,
176
+ missing: isPath && !existsSync(source),
177
+ bytes: await dirSize(dir),
178
+ });
179
+ }
180
+ }
181
+ indexes.sort((a, b) => b.bytes - a.bytes);
182
+ return { indexes, bytes: indexes.reduce((n, i) => n + i.bytes, 0) };
183
+ }
184
+
185
+ /**
186
+ * The installed API versions, and which must stay.
187
+ *
188
+ * KEPT: the version answering on the port right now (`runningVersion`, from /api/health), the one
189
+ * `gu-cli api start` would pick (newest that has its dependencies — #181), and the newest folder
190
+ * even when it is not runnable yet (an upgrade in progress). Everything else is "old".
191
+ */
192
+ export async function surveyApi({ home = homedir(), runningVersion = "" } = {}) {
193
+ const root = join(guHome(home), "api");
194
+ let names = [];
195
+ try {
196
+ names = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
197
+ } catch {
198
+ return { versions: [], bytes: 0, oldBytes: 0, old: [], inUse: "" };
199
+ }
200
+ names.sort((a, b) => compareVersions(a, b));
201
+ const usable = pickUsableVersion(names, (v) => existsSync(join(root, v, "node_modules", "mongodb", "package.json")));
202
+ const running = names.includes(runningVersion) ? runningVersion : "";
203
+ const keep = new Set([running, usable, names[names.length - 1]].filter(Boolean));
204
+ const sizes = await Promise.all(names.map((v) => dirSize(join(root, v))));
205
+ const versions = names.map((version, i) => ({ version, dir: join(root, version), bytes: sizes[i], keep: keep.has(version) }));
206
+ const old = versions.filter((v) => !v.keep);
207
+ return {
208
+ versions,
209
+ old,
210
+ bytes: versions.reduce((n, v) => n + v.bytes, 0),
211
+ oldBytes: old.reduce((n, v) => n + v.bytes, 0),
212
+ inUse: running || usable || "",
213
+ };
214
+ }
215
+
216
+ /** All three, measured together. */
217
+ export async function surveyDisk({ home = homedir(), runningVersion = "" } = {}) {
218
+ const [logs, rag, api] = await Promise.all([surveyLogs({ home }), surveyRag({ home }), surveyApi({ home, runningVersion })]);
219
+ return { logs, rag, api, total: logs.bytes + rag.bytes + api.bytes };
220
+ }
221
+
222
+ /** Lay `parts` out after `lead`, breaking only between parts; later rows start at `indent`. */
223
+ function wrapParts(lead, parts, width, { sep = " · ", indent = lead.length } = {}) {
224
+ const pad = " ".repeat(indent);
225
+ const rows = [];
226
+ let cur = lead;
227
+ let empty = true;
228
+ for (const part of parts) {
229
+ if (!empty && cur.length + sep.length + part.length > width) {
230
+ rows.push(cur);
231
+ cur = pad + part;
232
+ } else {
233
+ cur += (empty ? "" : sep) + part;
234
+ }
235
+ empty = false;
236
+ }
237
+ rows.push(cur.trimEnd());
238
+ // a single part longer than the row is cut, never wrapped — a wrapped row moves everything under it
239
+ return rows.map((r) => (r.length > width ? r.slice(0, Math.max(1, width - 1)) + "…" : r));
240
+ }
241
+
242
+ /**
243
+ * What the click shows before it asks anything: the logs with their files, RAG and API with what
244
+ * they are, and the total. Plain text rows that fit `width` — the caller paints and indents them.
245
+ * A part that is not on this machine (no api/ outside Local mode) has no row.
246
+ */
247
+ export function summaryRows(survey, { width = 76, top = 3 } = {}) {
248
+ const w = Math.max(30, width | 0);
249
+ const { logs, rag, api } = survey;
250
+ const parts = [
251
+ { name: "Logs", bytes: logs.bytes, detail: logs.items.map((i) => i.kind === "dir"
252
+ ? `${i.name} ${plural(i.files, "file")} ${humanSize(i.bytes)}`
253
+ : `${i.name} ${humanSize(i.bytes)}`), none: "none" },
254
+ ];
255
+ if (rag.indexes.length) {
256
+ const shown = rag.indexes.slice(0, top);
257
+ const rest = rag.indexes.slice(top);
258
+ const detail = shown.map((i) => `${i.label} ${humanSize(i.bytes)}`);
259
+ if (rest.length) detail.push(`${rest.length} more ${humanSize(rest.reduce((n, i) => n + i.bytes, 0))}`);
260
+ parts.push({ name: "RAG", bytes: rag.bytes, intro: plural(rag.indexes.length, "folder index", "folder indexes") + ":", detail });
261
+ }
262
+ if (api.versions.length) {
263
+ const detail = [plural(api.versions.length, "version")];
264
+ if (api.inUse) detail.push(`${api.inUse} in use`);
265
+ if (api.old.length) detail.push(`${plural(api.old.length, "older one")} ${humanSize(api.oldBytes)}`);
266
+ parts.push({ name: "API", bytes: api.bytes, detail });
267
+ }
268
+ const nameW = Math.max(5, ...parts.map((p) => p.name.length));
269
+ const sizeW = Math.max(humanSize(survey.total).length, ...parts.map((p) => humanSize(p.bytes).length));
270
+ const lead = (name, bytes) => `${name.padEnd(nameW)} ${humanSize(bytes).padStart(sizeW)} `;
271
+ const rows = [];
272
+ for (const p of parts) {
273
+ const head = lead(p.name, p.bytes);
274
+ // "22 folder indexes:" introduces the list — the list wraps under the first index, the
275
+ // other parts under their own first word
276
+ rows.push(...wrapParts(head + (p.intro ? p.intro + " " : ""), p.detail.length ? p.detail : [p.none ?? ""], w, { indent: head.length }));
277
+ }
278
+ rows.push(lead("Total", survey.total).trimEnd());
279
+ return rows;
280
+ }
281
+
282
+ /**
283
+ * Clear the logs `surveyLogs` found: truncate the live ones, delete rotations and log folders'
284
+ * files. → { freed, truncated, deleted, failed: [name] } — `freed` is what really went, so a file
285
+ * Windows refused to let go of is reported, not counted.
286
+ */
287
+ export async function clearLogs(logs) {
288
+ const out = { freed: 0, truncated: 0, deleted: 0, failed: [] };
289
+ for (const item of logs?.items ?? []) {
290
+ if (item.kind === "live") {
291
+ try {
292
+ const before = Math.max(0, await fileSize(item.path));
293
+ await truncate(item.path, 0);
294
+ out.freed += before;
295
+ out.truncated += 1;
296
+ } catch {
297
+ out.failed.push(item.name);
298
+ }
299
+ continue;
300
+ }
301
+ for (const p of item.kind === "dir" ? item.paths : [item.path]) {
302
+ try {
303
+ const before = Math.max(0, await fileSize(p));
304
+ await unlink(p);
305
+ out.freed += before;
306
+ out.deleted += 1;
307
+ } catch (e) {
308
+ if (e?.code === "ENOENT") continue; // pruned meanwhile — gone is what was wanted
309
+ if (!out.failed.includes(item.name)) out.failed.push(item.name);
310
+ }
311
+ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ /** Remove the given indexes (surveyRag's rows). An emptied folder-hash directory goes with them. */
317
+ export async function clearRagIndexes(indexes) {
318
+ const out = { freed: 0, removed: 0, failed: [] };
319
+ for (const idx of indexes ?? []) {
320
+ try {
321
+ await rm(idx.dir, { recursive: true, force: true });
322
+ out.freed += idx.bytes;
323
+ out.removed += 1;
324
+ await rmdir(idx.parent).catch(() => {}); // only succeeds when nothing else lives there
325
+ } catch {
326
+ out.failed.push(idx.label);
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /** Remove the OLD API versions (surveyApi's `old`). A row marked keep is refused, whoever asks. */
333
+ export async function clearOldApiVersions(versions) {
334
+ const out = { freed: 0, removed: 0, failed: [] };
335
+ for (const v of versions ?? []) {
336
+ if (v.keep) continue;
337
+ try {
338
+ await rm(v.dir, { recursive: true, force: true });
339
+ out.freed += v.bytes;
340
+ out.removed += 1;
341
+ } catch {
342
+ out.failed.push(v.version);
343
+ }
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /** The notice-row item's text, or "" while the logs are too small to be worth a row. */
349
+ export function clearLogsNoticeText(logBytes, { threshold = LOGS_NOTICE_BYTES } = {}) {
350
+ const n = Number(logBytes) || 0;
351
+ return n >= threshold ? `clear logs · ${humanSize(n)} (click)` : "";
352
+ }
package/gu-repl.mjs CHANGED
@@ -128,6 +128,7 @@ import {
128
128
  } from "./terminal-layout.mjs";
129
129
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
130
130
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
131
+ import { clearLogsNoticeText } from "./clear-logs.mjs";
131
132
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
132
133
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
133
134
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -891,6 +892,7 @@ const COMMANDS = [
891
892
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
892
893
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
893
894
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
895
+ { name: "/clear-logs", desc: "show what gu's logs, RAG indexes and old API versions take on disk, then ask what to clear — the logs first" },
894
896
  { name: "/prices", usage: "/prices [update]", desc: "the price table and the cache table, and what the last turn and this session cost — \"update\" applies gu's newest published tables" },
895
897
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
896
898
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
@@ -1462,6 +1464,11 @@ function applyClick({ row, col }) {
1462
1464
  void runPricesUpdate();
1463
1465
  return;
1464
1466
  }
1467
+ if (logsRowTarget && row === updateRowAbs &&
1468
+ col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
1469
+ void runClearLogs();
1470
+ return;
1471
+ }
1465
1472
  if (agentsRowTarget && row === updateRowAbs &&
1466
1473
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1467
1474
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -2680,6 +2687,9 @@ function zoneNotice(width) {
2680
2687
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2681
2688
  const agents = agentsIndicator(otherAgents);
2682
2689
  if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
2690
+ // LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
2691
+ const logs = clearLogsNoticeText(logsBytes);
2692
+ if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
2683
2693
  return noticeRow(items, { width, gutter: GUTTER });
2684
2694
  }
2685
2695
 
@@ -2760,6 +2770,7 @@ function frozenZone() {
2760
2770
  updateRowTarget = notice?.spans.update ?? null;
2761
2771
  agentsRowTarget = notice?.spans.agents ?? null;
2762
2772
  pricesRowTarget = notice?.spans.prices ?? null;
2773
+ logsRowTarget = notice?.spans.logs ?? null;
2763
2774
  jumpSpan = notice?.spans.jump ?? null;
2764
2775
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2765
2776
  }
@@ -2829,7 +2840,7 @@ function paintBar() {
2829
2840
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2830
2841
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2831
2842
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2832
- updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2843
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
2833
2844
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2834
2845
  // is parked on the input line right after, so there is nothing to preserve here.
2835
2846
  ui(
@@ -2991,6 +3002,8 @@ let updateRowAbs = 0;
2991
3002
  let agentsRowTarget = null;
2992
3003
  // …and "new prices updated <date>" (#323).
2993
3004
  let pricesRowTarget = null;
3005
+ // …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
3006
+ let logsRowTarget = null;
2994
3007
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
2995
3008
  // bracketed-paste and mouse-reporting teardowns.
2996
3009
  process.on("exit", () => {
@@ -7071,6 +7084,16 @@ function writePresetFile(pr, file, name, parsed) {
7071
7084
  const v = pr.validatePresetFile(JSON.stringify(out));
7072
7085
  console.log(GUTTER + `Preset "${name}" saved to ${file}:`);
7073
7086
  if (v.ok && v.full) for (const l of pr.fullPresetSummary(v.value, v.codingDefault)) console.log(GUTTER + dim(l));
7087
+ // SAY WHAT IS ACTUALLY IN IT (reported 2026-10-02). This line used to read "It CONTAINS API
7088
+ // keys" under a summary that said "no key" four times: the pasted text held key NAMES only, and
7089
+ // the person carried the file to another machine believing the keys were inside.
7090
+ const n = pr.presetKeyCounts(out);
7091
+ if (!n.withValue) {
7092
+ console.log(GUTTER + yellow("It contains NO API keys") + dim(n.nameOnly ? ` — only the names of ${n.nameOnly}. Using it will ask you to paste each one.` : "."));
7093
+ console.log(GUTTER + dim("To carry keys: on the computer that HAS them run /open-presets and copy (keys included)."));
7094
+ return;
7095
+ }
7096
+ if (n.nameOnly) console.log(GUTTER + yellow(`${n.nameOnly} of its keys ${n.nameOnly === 1 ? "is" : "are"} a name only`) + dim(" — their values are not in the file."));
7074
7097
  } else {
7075
7098
  writeFileSync(file, JSON.stringify(pr.buildPreset({ name, preset: parsed.preset, host: hostname() }), null, 2) + "\n", { mode: 0o600 });
7076
7099
  console.log(GUTTER + `Preset "${name}" saved to ${file}`);
@@ -7879,6 +7902,128 @@ async function runPricesUpdate() {
7879
7902
  }
7880
7903
  }
7881
7904
 
7905
+ // ---------------------------------------------------------------------------------------------
7906
+ // "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
7907
+ // and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
7908
+ // and beside them the two things that are actually big, the RAG indexes and the downloaded API
7909
+ // versions — shows that with a total, then asks about each. What each "clear" may and may not
7910
+ // touch is decided in clear-logs.mjs; this is the terminal half only.
7911
+ // ---------------------------------------------------------------------------------------------
7912
+ let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
7913
+ let clearLogsBusy = false;
7914
+
7915
+ async function refreshLogsSize() {
7916
+ try {
7917
+ const { surveyLogs } = await import("./clear-logs.mjs");
7918
+ const was = clearLogsNoticeText(logsBytes);
7919
+ logsBytes = (await surveyLogs()).bytes;
7920
+ if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
7921
+ } catch {
7922
+ /* a housekeeping notice is never worth an error */
7923
+ }
7924
+ }
7925
+
7926
+ async function runClearLogs() {
7927
+ // ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
7928
+ // (see showOtherAgents for what a second picker over the first one does).
7929
+ if (clearLogsBusy || listPickerActive || approvalActive || following) return;
7930
+ clearLogsBusy = true;
7931
+ try {
7932
+ const cl = await import("./clear-logs.mjs");
7933
+ const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
7934
+ const width = Math.max(20, process.stdout.columns || 80);
7935
+ console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
7936
+ // Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
7937
+ const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
7938
+ .then((r) => r.json())
7939
+ .then((j) => String(j?.version ?? ""))
7940
+ .catch(() => "");
7941
+ const survey = await cl.surveyDisk({ runningVersion });
7942
+ console.log("");
7943
+ for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
7944
+ console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
7945
+ }
7946
+ const freed = [];
7947
+ // A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
7948
+ const question = (q) => {
7949
+ const hint = `(${PICK_HINT}):`;
7950
+ console.log("");
7951
+ if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
7952
+ else console.log(dim(` ${q}\n ${hint}`));
7953
+ };
7954
+ const cancelled = () => {
7955
+ console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
7956
+ paintBar();
7957
+ };
7958
+
7959
+ // 1. THE LOGS — what was asked for, so Yes is the first row.
7960
+ if (survey.logs.bytes > 0) {
7961
+ question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
7962
+ const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
7963
+ if (go < 0) return cancelled();
7964
+ if (go === 0) {
7965
+ const r = await cl.clearLogs(survey.logs);
7966
+ freed.push({ what: "logs", ...r });
7967
+ console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
7968
+ (r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
7969
+ } else {
7970
+ console.log(GUTTER + dim("logs kept"));
7971
+ }
7972
+ } else {
7973
+ console.log("\n" + GUTTER + dim("No logs to clear."));
7974
+ }
7975
+
7976
+ // 2. RAG — costs a re-index, so No is the first row.
7977
+ if (survey.rag.indexes.length) {
7978
+ const gone = survey.rag.indexes.filter((i) => i.missing);
7979
+ const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
7980
+ const choices = [{ id: "no", label: "No — keep them" }];
7981
+ if (gone.length && gone.length < survey.rag.indexes.length) {
7982
+ choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
7983
+ }
7984
+ choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
7985
+ question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
7986
+ const pick = await pickFromList(choices.map((c) => c.label), 0);
7987
+ if (pick < 0) return cancelled();
7988
+ if (choices[pick].id !== "no") {
7989
+ const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
7990
+ freed.push({ what: "rag", ...r });
7991
+ console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
7992
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
7993
+ } else {
7994
+ console.log(GUTTER + dim("RAG indexes kept"));
7995
+ }
7996
+ }
7997
+
7998
+ // 3. THE API — only ever the OLDER versions; the one in use is not on offer.
7999
+ if (survey.api.old.length) {
8000
+ const n = survey.api.old.length;
8001
+ question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
8002
+ const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
8003
+ dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
8004
+ if (go < 0) return cancelled();
8005
+ if (go === 1) {
8006
+ console.log(GUTTER + dim("Removing…"));
8007
+ const r = await cl.clearOldApiVersions(survey.api.old);
8008
+ freed.push({ what: "api", ...r });
8009
+ console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
8010
+ (r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
8011
+ } else {
8012
+ console.log(GUTTER + dim("older API versions kept"));
8013
+ }
8014
+ }
8015
+
8016
+ const total = freed.reduce((n, f) => n + f.freed, 0);
8017
+ console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
8018
+ } catch (e) {
8019
+ console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
8020
+ } finally {
8021
+ clearLogsBusy = false;
8022
+ await refreshLogsSize();
8023
+ if (barPinned()) paintBar();
8024
+ }
8025
+ }
8026
+
7882
8027
  /**
7883
8028
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
7884
8029
  *
@@ -12844,6 +12989,8 @@ async function main() {
12844
12989
  // #323: gu's newest published price + cache tables — same cadence, same silent failure.
12845
12990
  void refreshPriceUpdate();
12846
12991
  setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
12992
+ void refreshLogsSize();
12993
+ setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
12847
12994
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
12848
12995
  startupTokenRefresh = refreshCliToken();
12849
12996
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -13304,6 +13451,10 @@ async function main() {
13304
13451
  await runPricesCommand(line);
13305
13452
  continue;
13306
13453
  }
13454
+ if (line === "/clear-logs") {
13455
+ await runClearLogs();
13456
+ continue;
13457
+ }
13307
13458
  if (line === "/tokens") {
13308
13459
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
13309
13460
  // this answers "and what displaced it", which is the part that tells you whether RAG is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.791",
3
+ "version": "1.0.792",
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",
@@ -77,6 +77,7 @@
77
77
  "poll-errors.mjs",
78
78
  "node-version.mjs",
79
79
  "clear-state.mjs",
80
+ "clear-logs.mjs",
80
81
  "platform-tools.mjs",
81
82
  "job-pools.mjs",
82
83
  "agent-stop.mjs",
package/preset-apply.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { mkdirSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
- import { buildFullPreset, keysToApply } from "./presets-transfer.mjs";
21
+ import { buildFullPreset, keysToApply, keyHiddenInName, shownKeyName } from "./presets-transfer.mjs";
22
22
  import { planLoad, toSettingsPatch } from "./settings-transfer.mjs";
23
23
  import { existingBackendRows, providerRowForUrl } from "./coding-provider-setup.mjs";
24
24
 
@@ -86,7 +86,7 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
86
86
  : plan.lines;
87
87
  for (const l of lines) say(l);
88
88
  if (summary && plan.replaces) say(`replaces the ${plan.replaces} coding provider${plan.replaces === 1 ? "" : "s"} on this account.`);
89
- for (const w of warnings) say("⚠ " + w, "warn");
89
+ for (const w of new Set(warnings)) say("⚠ " + w, "warn");
90
90
  if (carriesSecrets) say("this file contains API keys in plain text — delete it when you are done.", "warn");
91
91
  const go = String((await io.ask("apply this? [y/N] ")) ?? "").trim().toLowerCase();
92
92
  if (go !== "y" && go !== "yes") {
@@ -116,22 +116,32 @@ export async function applyTransfer(value, { api, io, fromLine = "", carriesSecr
116
116
  const rows = existingBackendRows(fresh);
117
117
  const { add, kept } = keysToApply(value, { currentKeys: keys, rows, missing: plan.missingKeys });
118
118
  for (const k of kept) {
119
- say(`kept your existing key ${k.name} for ${k.backendUrl} (the preset's copy was not used).`);
119
+ say(`kept your existing key ${shownKeyName(k)} for ${k.backendUrl} (the preset's copy was not used).`);
120
120
  if (k.default && k.rowId && k.existingId && !(await api.setDefaultKey(k.rowId, k.existingId))) {
121
121
  say(`could not make ${k.name} the default for ${k.backendUrl} — pick it in /model with ctrl+k.`, "err");
122
122
  }
123
123
  }
124
- for (const k of add) {
125
- const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
126
- const typed = k.key || String((await io.ask(`key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
124
+ for (const k0 of add) {
125
+ const where = k0.backendUrl ? ` for ${k0.backendUrl}` : "";
126
+ // A NAME THAT IS THE KEY ITSELF (see keyHiddenInName): use it as the value it always was, and
127
+ // give the key a real name — its server — so the secret stops being printed as a label.
128
+ const hidden = k0.key ? "" : keyHiddenInName(k0);
129
+ let hostName = "";
130
+ try {
131
+ hostName = new URL(k0.backendUrl).hostname;
132
+ } catch { /* no server — keep a neutral name */ }
133
+ const k = hidden ? { ...k0, key: hidden, name: hostName || "key" } : k0;
134
+ const shown = shownKeyName(k0);
135
+ if (hidden) say(`the key named ${shown}${where} IS the key — using it, saved as "${k.name}".`);
136
+ const typed = k.key || String((await io.ask(`key "${shown}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)) ?? "").trim();
127
137
  if (!typed) {
128
- say(`skipped ${k.name} — add it later with ctrl+k.`);
138
+ say(`skipped ${shown}${where} — its value is not in the preset; add it later with ctrl+k.`);
129
139
  continue;
130
140
  }
131
141
  const id = await api.addKey(k.name, typed, k.rowId);
132
142
  if (!id) {
133
143
  out.failed.push(k.name);
134
- say(`could not add ${k.name}${where} — add it in /model with ctrl+k.`, "err");
144
+ say(`could not add ${shown}${where} — add it in /model with ctrl+k.`, "err");
135
145
  continue;
136
146
  }
137
147
  out.added.push(k.name);
@@ -337,7 +337,12 @@ export function validateFullPreset(doc) {
337
337
  }
338
338
  }
339
339
  const noKey = v.backends.filter((b) => !v.keys.some((k) => k.key && sameServer(k.backendUrl, b.url)));
340
- for (const b of noKey) warnings.push(`${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
340
+ for (const b of noKey) {
341
+ const named = v.keys.some((k) => !k.key && sameServer(k.backendUrl, b.url));
342
+ warnings.push(named
343
+ ? `${b.url}: the preset names its key but does not carry the value — you will be asked to paste it.`
344
+ : `${b.url} has no key in the preset — it keeps whatever key this machine has for it (or none).`);
345
+ }
341
346
  // THE DEFAULT CODER must be a server and model the preset itself sets up.
342
347
  const cd = doc.codingDefault && typeof doc.codingDefault === "object" ? doc.codingDefault : {};
343
348
  if (cd.url !== undefined && cd.url !== "" && !isHttpUrl(cd.url)) {
@@ -404,6 +409,35 @@ const mask = (k) => {
404
409
  return v.length <= 8 ? "…" + v.slice(-2) : `${v.startsWith("sk-") ? "sk-" : v.slice(0, 3)}…${v.slice(-4)}`;
405
410
  };
406
411
 
412
+ /**
413
+ * IS THIS KEY'S *NAME* ACTUALLY THE KEY? (reported 2026-10-02) → the key, or "".
414
+ * A key pasted into the NAME question is stored as a name, and from then on it is printed in full
415
+ * wherever a name is shown ("skipped sk-E8Iv…3E66 — add it later") and travels in every names-only
416
+ * export — while its value is asked for again. The tail settles it: a name that is key-shaped AND
417
+ * ends in the recorded last four characters is the key.
418
+ */
419
+ export function keyHiddenInName(k) {
420
+ const name = clean(k?.name);
421
+ const tail = clean(k?.tail);
422
+ if (name.length < 20 || /\s/.test(name) || keyProblem(name)) return "";
423
+ if (tail ? !name.endsWith(tail) : !/^(sk|pk|rk|xai|gsk|ghp|hf)[-_]/i.test(name)) return "";
424
+ return name;
425
+ }
426
+
427
+ /** A key NAME as it may be shown: a name that is itself a key is masked like one. */
428
+ export function shownKeyName(k) {
429
+ const name = clean(typeof k === "string" ? k : k?.name);
430
+ const secret = keyHiddenInName(typeof k === "string" ? { name } : k);
431
+ return secret ? `${mask(secret)} (a key saved as its own name)` : name;
432
+ }
433
+
434
+ /** How many of a preset's keys carry a VALUE, and how many are only a name. */
435
+ export function presetKeyCounts(v) {
436
+ const keys = Array.isArray(v?.keys) ? v.keys : [];
437
+ const withValue = keys.filter((k) => k.key).length;
438
+ return { withValue, nameOnly: keys.length - withValue };
439
+ }
440
+
407
441
  /** The whole preset with every key MASKED — what is printed; the file / the copy keep the real keys. */
408
442
  export function maskedFullPreset(doc) {
409
443
  return { ...doc, keys: (Array.isArray(doc?.keys) ? doc.keys : []).map((k) => ({ ...k, ...(k.key ? { key: mask(k.key) } : {}) })) };
@@ -414,8 +448,15 @@ export function fullPresetSummary(v, codingDefault) {
414
448
  const lines = [];
415
449
  for (const b of v.backends) {
416
450
  const models = [...new Set([b.defaultModel, ...(b.models ?? [])].filter(Boolean))];
417
- const key = v.keys.find((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
418
- lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${key?.key ? ` · key ${key.name} ${mask(key.key)}` : " · no key"}`);
451
+ const mine = v.keys.filter((k) => k.backendUrl && ensureV1Root(k.backendUrl).toLowerCase() === ensureV1Root(b.url).toLowerCase());
452
+ const key = mine.find((k) => k.key);
453
+ // "no key" was printed for a server whose key the preset NAMES but does not carry — the same
454
+ // words as a server with no key at all, so a names-only preset read as an empty one.
455
+ const named = mine.filter((k) => !k.key).map((k) => `${shownKeyName(k)}${k.tail && !keyHiddenInName(k) ? ` …${k.tail}` : ""}`);
456
+ const tell = key?.key
457
+ ? ` · key ${shownKeyName(key)} ${mask(key.key)}`
458
+ : named.length ? ` · key NAME only (${named.join(", ")}) — the value is not in this preset` : " · no key";
459
+ lines.push(` ${b.url} ${models.join(", ") || "(no models)"}${tell}`);
419
460
  }
420
461
  if (codingDefault?.url || codingDefault?.model) lines.push(`coding model: ${codingDefault.model || "?"} at ${codingDefault.url || "?"}`);
421
462
  return lines;
@@ -344,7 +344,7 @@ export function planLoad(file, current = {}) {
344
344
  }
345
345
  if (missingKeys.length) {
346
346
  lines.push(`${missingKeys.length} API key${missingKeys.length === 1 ? "" : "s"} this account does not have — gu will ask for ${missingKeys.length === 1 ? "it" : "them"} after:`);
347
- for (const k of missingKeys) lines.push(` ${k.name}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
347
+ for (const k of missingKeys) lines.push(` ${listedKeyName(k)}${k.tail ? ` (…${k.tail})` : ""}${k.backendUrl ? ` for ${k.backendUrl}` : ""}`);
348
348
  }
349
349
  // The account default is NOT in the file and is not touched — said out loud, because someone
350
350
  // loading "their models" onto a machine reasonably expects the coder to change too.
@@ -352,6 +352,19 @@ export function planLoad(file, current = {}) {
352
352
  return { lines, missingKeys, replaces: currentRows.length, adds: file.backends.length };
353
353
  }
354
354
 
355
+ /**
356
+ * A key NAME as this list may print it. A key typed into the name question is stored AS the name,
357
+ * and the full secret was then printed here (reported 2026-10-02). A key-shaped name ending in the
358
+ * key's recorded tail is masked. (presets-transfer.mjs keyHiddenInName is the same rule, with the
359
+ * stricter key checks it has; it imports this module, so the rule cannot be shared the other way.)
360
+ */
361
+ function listedKeyName(k) {
362
+ const name = String(k?.name ?? "").trim();
363
+ const tail = String(k?.tail ?? "").trim();
364
+ if (name.length >= 20 && !/\s/.test(name) && tail && name.endsWith(tail)) return `${name.slice(0, 3)}… (a key saved as its own name)`;
365
+ return name;
366
+ }
367
+
355
368
  /**
356
369
  * The POST body for /api/worker/settings. Only fields a worker key may write.
357
370
  *