@tiens.nguyen/gu-cli 1.0.789 → 1.0.791
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/client-expert-run.mjs +274 -47
- package/client-expert-setup.mjs +56 -7
- package/gonext_agent_chat.py +14 -252
- package/gu-cli.mjs +16 -3
- package/gu-repl.mjs +73 -311
- package/mongo-native.mjs +27 -2
- package/package.json +1 -2
- package/terminal-layout.mjs +2 -171
- package/clear-logs.mjs +0 -352
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.791",
|
|
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,7 +77,6 @@
|
|
|
77
77
|
"poll-errors.mjs",
|
|
78
78
|
"node-version.mjs",
|
|
79
79
|
"clear-state.mjs",
|
|
80
|
-
"clear-logs.mjs",
|
|
81
80
|
"platform-tools.mjs",
|
|
82
81
|
"job-pools.mjs",
|
|
83
82
|
"agent-stop.mjs",
|
package/terminal-layout.mjs
CHANGED
|
@@ -2434,104 +2434,11 @@ export function firstToolArg(codePart) {
|
|
|
2434
2434
|
return { name: m[1], arg: String(value).trim() };
|
|
2435
2435
|
}
|
|
2436
2436
|
|
|
2437
|
-
/**
|
|
2438
|
-
* WHERE A THOUGHT LINE REALLY STARTS (task #333).
|
|
2439
|
-
*
|
|
2440
|
-
* The live line once read `one, call use_skill(name) FIRST, read what it returns, and follow it".
|
|
2441
|
-
* I'll…` for 83 seconds, and later `` `, `bootstrap-api-service.mjs` — add … `` — both the tail of
|
|
2442
|
-
* a sentence whose head had been cut away. The main cause is fixed where it happened (a heartbeat
|
|
2443
|
-
* splitting the thought, see the fence handling in gu-repl.mjs); this is the guard for every other
|
|
2444
|
-
* way a line can arrive headless (a poll boundary, a buffer cap): a line that opens with closing
|
|
2445
|
-
* punctuation, or with a lower-case word, is the middle of something. Start at the next sentence
|
|
2446
|
-
* instead — and when there is none, say nothing ("" keeps whatever the line showed before).
|
|
2447
|
-
*/
|
|
2448
|
-
const _SENTENCE_END = /[.!?]["'”’`)\]]*\s+(?=["'“‘`(\[]?[A-Z0-9])/g;
|
|
2449
|
-
|
|
2450
|
-
export function thoughtLineStart(line) {
|
|
2451
|
-
const s = String(line ?? "").trim();
|
|
2452
|
-
if (!s) return "";
|
|
2453
|
-
const headless =
|
|
2454
|
-
/^[`'"”’)\]}]*\s*[,.;:!?)\]}]/.test(s) || // "`, `bootstrap…", ", and then", ") so"
|
|
2455
|
-
/^[a-z]/.test(s); // "one, call use_skill…" — a sentence starts with a capital
|
|
2456
|
-
if (!headless) return s;
|
|
2457
|
-
_SENTENCE_END.lastIndex = 0;
|
|
2458
|
-
const m = _SENTENCE_END.exec(s);
|
|
2459
|
-
return m ? s.slice(m.index + m[0].length).trim() : "";
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
/**
|
|
2463
|
-
* gu's OWN INSTRUCTIONS, recited (task #333). A model weighing its prompt quotes it — "Rules say
|
|
2464
|
-
* call use_skill FIRST when the task matches" — and the status line then shows gu's scaffolding as
|
|
2465
|
-
* if it were news about the user's task. Matched by the fixed phrases of the prompt blocks the
|
|
2466
|
-
* worker writes (gu_skills.prompt_block, the workspace directives); a sentence carrying one is
|
|
2467
|
-
* not shown on the live line.
|
|
2468
|
-
*/
|
|
2469
|
-
const _PROMPT_ECHO = /use_skill\(name\)|call use_skill\b[^.]*\bFIRST\b|When the TASK matches|WORK LIKE A SENIOR DEVELOPER|packaged instructions for particular kinds of work/i;
|
|
2470
|
-
|
|
2471
|
-
/** Split prose into sentences, keeping each one's own punctuation. */
|
|
2472
|
-
function _sentences(text) {
|
|
2473
|
-
const s = String(text ?? "").trim();
|
|
2474
|
-
const out = [];
|
|
2475
|
-
let from = 0;
|
|
2476
|
-
_SENTENCE_END.lastIndex = 0;
|
|
2477
|
-
let m;
|
|
2478
|
-
while ((m = _SENTENCE_END.exec(s))) {
|
|
2479
|
-
out.push(s.slice(from, m.index + m[0].length).trim());
|
|
2480
|
-
from = m.index + m[0].length;
|
|
2481
|
-
}
|
|
2482
|
-
if (from < s.length) out.push(s.slice(from).trim());
|
|
2483
|
-
return out.filter(Boolean);
|
|
2484
|
-
}
|
|
2485
|
-
|
|
2486
|
-
/** The line to show live: from its first real sentence, past any recital of gu's own prompt. */
|
|
2487
|
-
export function liveThoughtText(line) {
|
|
2488
|
-
const sentences = _sentences(thoughtLineStart(line));
|
|
2489
|
-
const at = sentences.findIndex((x) => !_PROMPT_ECHO.test(x));
|
|
2490
|
-
return at < 0 ? "" : sentences.slice(at).join(" ");
|
|
2491
|
-
}
|
|
2492
|
-
|
|
2493
|
-
/**
|
|
2494
|
-
* THE QUESTION, SAID BACK (task #333). A thought that opens "The user wants me to use the .env
|
|
2495
|
-
* file and add a template file." sits directly under ">> currently we are not using the .env
|
|
2496
|
-
* file, please use that and also add a template file" — the same thing twice, and on the two
|
|
2497
|
-
* collapsed rows that are all most people read. Measured on this machine's stored turns: 169 of
|
|
2498
|
-
* 296 first thoughts (57%) open this way.
|
|
2499
|
-
*
|
|
2500
|
-
* Leading sentences that restate are dropped; what the model said NEXT — what it will look at,
|
|
2501
|
-
* what it found — becomes the block's first row. Two tests, either is enough: the sentence is
|
|
2502
|
-
* shaped like a restatement ("The user wants/says/asks…", "Let me understand the task"), or most
|
|
2503
|
-
* of its words are the question's own. Only from the START, and never the whole thought: when
|
|
2504
|
-
* every sentence restates, the last one is kept, so a thought is shortened, not lost.
|
|
2505
|
-
* Display only — what the model wrote, and what is sent back to it, are untouched.
|
|
2506
|
-
*/
|
|
2507
|
-
const _RESTATE_SHAPE = /^(?:(?:ok(?:ay)?|so|alright|right|now|first)[,.]?\s+)*(?:let me (?:first\s+)?(?:understand|re-?read|restate|parse|break down|think about)\b[^.]*\b(?:task|request|question|ask)|the user(?:'s (?:request|question|task|message))?\s+(?:wants?|says?|said|is asking|asks?|asked|would like|needs?|is|has asked|requests?)|the (?:task|request|question)(?:\s+is)?\s*[:,]|(?:the )?user (?:request|query|message)\s*[:,]|the (?:system prompt|instructions?|rules?|skill)\s+(?:says?|tells?|states?|wants?|requires?)|(?:rules?|instructions?) say\b)/i;
|
|
2508
|
-
|
|
2509
|
-
const _words = (t) => String(t ?? "").toLowerCase().match(/[a-z0-9_.-]{3,}/g) ?? [];
|
|
2510
|
-
|
|
2511
|
-
/** Is this ONE sentence the question (or gu's instructions) said back? */
|
|
2512
|
-
export function isRestatement(sentence, question = "") {
|
|
2513
|
-
if (_RESTATE_SHAPE.test(sentence)) return true;
|
|
2514
|
-
const asked = new Set(_words(question));
|
|
2515
|
-
if (asked.size < 4) return false;
|
|
2516
|
-
const w = _words(sentence);
|
|
2517
|
-
if (w.length < 4) return false;
|
|
2518
|
-
return w.filter((x) => asked.has(x)).length / w.length >= 0.7;
|
|
2519
|
-
}
|
|
2520
|
-
|
|
2521
|
-
export function dropRestatement(prose, question = "") {
|
|
2522
|
-
const sentences = _sentences(prose);
|
|
2523
|
-
if (sentences.length < 2) return String(prose ?? "").trim();
|
|
2524
|
-
const restates = (sentence) => isRestatement(sentence, question);
|
|
2525
|
-
let at = 0;
|
|
2526
|
-
while (at < sentences.length - 1 && restates(sentences[at])) at++;
|
|
2527
|
-
return sentences.slice(at).join(" ");
|
|
2528
|
-
}
|
|
2529
|
-
|
|
2530
2437
|
/**
|
|
2531
2438
|
* Raw in-fence stream → a short, meaningful status: a real Thought clause if the model wrote
|
|
2532
2439
|
* one, else a label naming the tool it is calling, else "" (the caller shows "Thinking…").
|
|
2533
2440
|
*/
|
|
2534
|
-
export function normalizeThought(buf
|
|
2441
|
+
export function normalizeThought(buf) {
|
|
2535
2442
|
const t = String(buf || "");
|
|
2536
2443
|
const ci = t.search(/<code[\s>]/i);
|
|
2537
2444
|
const head = ci >= 0 ? t.slice(0, ci) : t;
|
|
@@ -2548,24 +2455,7 @@ export function normalizeThought(buf, { question = null } = {}) {
|
|
|
2548
2455
|
.map((x) => x.trim())
|
|
2549
2456
|
.find(Boolean);
|
|
2550
2457
|
const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
|
|
2551
|
-
if (proseIsClause && !_thoughtLooksCodey(prose))
|
|
2552
|
-
// …from where a sentence starts, and never gu's own prompt said back (#333). A line with
|
|
2553
|
-
// nothing left falls through to the tool label / "", like any other non-thought.
|
|
2554
|
-
//
|
|
2555
|
-
// WITH THE QUESTION KNOWN (the terminal passes it), the line is taken from the whole head
|
|
2556
|
-
// rather than its first line, minus the question said back: "The user wants me to read
|
|
2557
|
-
// package.json…" sat on the status row for the whole wait while the next sentence — what
|
|
2558
|
-
// the model was actually about to do — was already there. While all there is so far IS the
|
|
2559
|
-
// restatement, nothing is shown: the row keeps "Thinking" for the second that takes.
|
|
2560
|
-
let text = prose;
|
|
2561
|
-
if (question !== null) {
|
|
2562
|
-
const whole = head.replace(/^\s*Thought:\s*/i, "").replace(/\s+/g, " ").trim();
|
|
2563
|
-
text = dropRestatement(whole, question);
|
|
2564
|
-
if (isRestatement(text, question)) text = "";
|
|
2565
|
-
}
|
|
2566
|
-
const shown = liveThoughtText(text);
|
|
2567
|
-
if (shown.length >= 6 && shown.split(/\s+/).length >= 2) return shown.slice(0, 100);
|
|
2568
|
-
}
|
|
2458
|
+
if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
|
|
2569
2459
|
// 2) otherwise name the tool being called (from the code part).
|
|
2570
2460
|
const codePart = ci >= 0 ? t.slice(ci) : t;
|
|
2571
2461
|
const call = firstToolArg(codePart);
|
|
@@ -2696,65 +2586,6 @@ export function runningDetailRows(detail, { width = 80, indent = 5, maxRows = 2
|
|
|
2696
2586
|
];
|
|
2697
2587
|
}
|
|
2698
2588
|
|
|
2699
|
-
/**
|
|
2700
|
-
* THE COMMAND THAT IS RUNNING, on the live status row (task #331): "$ find . -type f … (17s · 5 lines)".
|
|
2701
|
-
*
|
|
2702
|
-
* It used to read "Running <first 70 characters>… (17s)" with every newline squashed to a space,
|
|
2703
|
-
* so a heredoc was one run-on line and a long pipeline stopped mid-flag. Now: the shell's "$", the
|
|
2704
|
-
* command's FIRST LINE (a heredoc's first line is what names it), and at the right what the user
|
|
2705
|
-
* is waiting to learn — how long it has run and how much it has printed.
|
|
2706
|
-
*
|
|
2707
|
-
* ONE ROW, ALWAYS — the status block is redrawn in place and a taller block moves the input line.
|
|
2708
|
-
* The rest of the command is a click (or Ctrl+T) away: see runningCommandRows. → { label, more }
|
|
2709
|
-
* `more` = there is more of the command than this row shows, i.e. the click has something to open.
|
|
2710
|
-
*/
|
|
2711
|
-
export function runningCommandLabel(command, { room = 66, secs = 0, lines = 0, expanded = false } = {}) {
|
|
2712
|
-
const all = String(command ?? "").replace(/\r/g, "").split("\n").map((l) => l.replace(/\t/g, " ").replace(/\s+$/, "")).filter((l) => l.trim());
|
|
2713
|
-
const n = Math.max(0, Number(lines) || 0);
|
|
2714
|
-
const suffix = `(${Math.max(0, secs | 0)}s${n ? ` · ${n} line${n === 1 ? "" : "s"}` : ""})`;
|
|
2715
|
-
const multi = all.length > 1;
|
|
2716
|
-
// opened: the rows underneath ARE the command, so the row above them just says what they are
|
|
2717
|
-
if (expanded) return { label: `Running command ${suffix}`, more: true };
|
|
2718
|
-
const head = `$ ${(all[0] ?? "").trim()}`;
|
|
2719
|
-
const space = Math.max(8, (room | 0) - suffix.length - 2);
|
|
2720
|
-
const cut = head.length + (multi ? 2 : 0) > space;
|
|
2721
|
-
const shown = cut ? head.slice(0, Math.max(1, space - 1)).replace(/\s+$/, "") + "…" : head + (multi ? " …" : "");
|
|
2722
|
-
return { label: `${shown} ${suffix}`, more: cut || multi };
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
/**
|
|
2726
|
-
* The whole command, opened under the status row: "└ $ …" with a hanging indent, its own line
|
|
2727
|
-
* breaks kept (a heredoc reads as the script it is), long lines wrapped at a space, and a cap —
|
|
2728
|
-
* past it the last row ends in "…". Rows NEVER exceed `width`: the block is redrawn by walking
|
|
2729
|
-
* physical rows, and a row that wraps on its own breaks every row under it (#109, #138).
|
|
2730
|
-
*/
|
|
2731
|
-
export function runningCommandRows(command, { width = 76, maxRows = 5 } = {}) {
|
|
2732
|
-
const lead = "└ $ ";
|
|
2733
|
-
const pad = " ".repeat(lead.length);
|
|
2734
|
-
const room = Math.max(12, (width | 0) - lead.length);
|
|
2735
|
-
const out = [];
|
|
2736
|
-
for (const raw of String(command ?? "").replace(/\r/g, "").split("\n")) {
|
|
2737
|
-
let rest = raw.replace(/\t/g, " ").replace(/\s+$/, "");
|
|
2738
|
-
if (!rest.trim()) continue;
|
|
2739
|
-
while (rest.length > room) {
|
|
2740
|
-
const sp = rest.lastIndexOf(" ", room);
|
|
2741
|
-
// …but never break inside the leading indentation, or a wrapped line loses its shape
|
|
2742
|
-
const cut = sp > room * 0.5 ? sp : room;
|
|
2743
|
-
out.push(rest.slice(0, cut));
|
|
2744
|
-
rest = rest.slice(cut).replace(/^ /, "");
|
|
2745
|
-
}
|
|
2746
|
-
out.push(rest);
|
|
2747
|
-
}
|
|
2748
|
-
if (!out.length) return [];
|
|
2749
|
-
const cap = Math.max(1, maxRows | 0);
|
|
2750
|
-
const rows = out.slice(0, cap);
|
|
2751
|
-
if (out.length > cap) {
|
|
2752
|
-
const last = rows[cap - 1];
|
|
2753
|
-
rows[cap - 1] = (last.length >= room ? last.slice(0, room - 1) : last) + "…";
|
|
2754
|
-
}
|
|
2755
|
-
return rows.map((r, i) => (i ? pad : lead) + r);
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
2589
|
/**
|
|
2759
2590
|
* How long the turn took, for the line above the answer (task #178).
|
|
2760
2591
|
*
|
package/clear-logs.mjs
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
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
|
-
}
|