@tiens.nguyen/gu-cli 1.0.790 → 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 +352 -0
- package/gu-repl.mjs +152 -1
- package/package.json +2 -1
- package/preset-apply.mjs +18 -8
- package/presets-transfer.mjs +44 -3
- package/settings-transfer.mjs +14 -1
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.
|
|
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
|
|
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
|
|
125
|
-
const where =
|
|
126
|
-
|
|
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 ${
|
|
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 ${
|
|
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);
|
package/presets-transfer.mjs
CHANGED
|
@@ -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)
|
|
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
|
|
418
|
-
|
|
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;
|
package/settings-transfer.mjs
CHANGED
|
@@ -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
|
|
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
|
*
|