@tiens.nguyen/gu-cli 1.0.787 → 1.0.789
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/gonext_agent_chat.py +252 -14
- package/gu-repl.mjs +311 -32
- package/package.json +2 -1
- package/terminal-layout.mjs +171 -2
package/gu-repl.mjs
CHANGED
|
@@ -63,6 +63,11 @@ import {
|
|
|
63
63
|
ragSegmentLabel,
|
|
64
64
|
searchSegmentLabel,
|
|
65
65
|
runningDetailRows,
|
|
66
|
+
runningCommandLabel,
|
|
67
|
+
runningCommandRows,
|
|
68
|
+
dropRestatement,
|
|
69
|
+
isRestatement,
|
|
70
|
+
liveThoughtText,
|
|
66
71
|
formatThinkTime,
|
|
67
72
|
plainAnswer,
|
|
68
73
|
summarizeError,
|
|
@@ -128,6 +133,7 @@ import {
|
|
|
128
133
|
} from "./terminal-layout.mjs";
|
|
129
134
|
import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
|
|
130
135
|
import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
|
|
136
|
+
import { clearLogsNoticeText } from "./clear-logs.mjs";
|
|
131
137
|
import { discoverSkills, matchSkillCommand } from "./skills.mjs";
|
|
132
138
|
import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
|
|
133
139
|
import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
|
|
@@ -891,6 +897,7 @@ const COMMANDS = [
|
|
|
891
897
|
{ 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
898
|
{ name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
|
|
893
899
|
{ name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
|
|
900
|
+
{ 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
901
|
{ 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
902
|
// ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
|
|
896
903
|
// for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
|
|
@@ -1462,6 +1469,11 @@ function applyClick({ row, col }) {
|
|
|
1462
1469
|
void runPricesUpdate();
|
|
1463
1470
|
return;
|
|
1464
1471
|
}
|
|
1472
|
+
if (logsRowTarget && row === updateRowAbs &&
|
|
1473
|
+
col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
|
|
1474
|
+
void runClearLogs();
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1465
1477
|
if (agentsRowTarget && row === updateRowAbs &&
|
|
1466
1478
|
col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
|
|
1467
1479
|
void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
|
|
@@ -2680,6 +2692,9 @@ function zoneNotice(width) {
|
|
|
2680
2692
|
if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
|
|
2681
2693
|
const agents = agentsIndicator(otherAgents);
|
|
2682
2694
|
if (agents) items.push({ key: "agents", clickable: true, paint: accent, text: `${agents} running (click)` });
|
|
2695
|
+
// LAST, so it is the first to give way on a narrow window: housekeeping, never urgent (#329).
|
|
2696
|
+
const logs = clearLogsNoticeText(logsBytes);
|
|
2697
|
+
if (logs) items.push({ key: "logs", clickable: true, paint: dim, text: logs });
|
|
2683
2698
|
return noticeRow(items, { width, gutter: GUTTER });
|
|
2684
2699
|
}
|
|
2685
2700
|
|
|
@@ -2760,6 +2775,7 @@ function frozenZone() {
|
|
|
2760
2775
|
updateRowTarget = notice?.spans.update ?? null;
|
|
2761
2776
|
agentsRowTarget = notice?.spans.agents ?? null;
|
|
2762
2777
|
pricesRowTarget = notice?.spans.prices ?? null;
|
|
2778
|
+
logsRowTarget = notice?.spans.logs ?? null;
|
|
2763
2779
|
jumpSpan = notice?.spans.jump ?? null;
|
|
2764
2780
|
return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
|
|
2765
2781
|
}
|
|
@@ -2829,7 +2845,7 @@ function paintBar() {
|
|
|
2829
2845
|
// THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
|
|
2830
2846
|
const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
|
|
2831
2847
|
jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
|
|
2832
|
-
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
|
|
2848
|
+
updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
|
|
2833
2849
|
// preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
|
|
2834
2850
|
// is parked on the input line right after, so there is nothing to preserve here.
|
|
2835
2851
|
ui(
|
|
@@ -2991,6 +3007,8 @@ let updateRowAbs = 0;
|
|
|
2991
3007
|
let agentsRowTarget = null;
|
|
2992
3008
|
// …and "new prices updated <date>" (#323).
|
|
2993
3009
|
let pricesRowTarget = null;
|
|
3010
|
+
// …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
|
|
3011
|
+
let logsRowTarget = null;
|
|
2994
3012
|
// A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
|
|
2995
3013
|
// bracketed-paste and mouse-reporting teardowns.
|
|
2996
3014
|
process.on("exit", () => {
|
|
@@ -3301,7 +3319,33 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
|
|
|
3301
3319
|
collapsed,
|
|
3302
3320
|
lines: THOUGHT_COLLAPSED_LINES,
|
|
3303
3321
|
});
|
|
3304
|
-
return rows.map((r) =>
|
|
3322
|
+
return (block.lead ? [""] : []).concat(rows.map((r) => THOUGHT_GUTTER + paint(r)));
|
|
3323
|
+
};
|
|
3324
|
+
|
|
3325
|
+
/**
|
|
3326
|
+
* A THOUGHT HAS ITS OWN MARK AND ITS OWN ROOM (task #334).
|
|
3327
|
+
*
|
|
3328
|
+
* Reported with a screenshot: "● listed files · read file 7×" and, flush under it in the same
|
|
3329
|
+
* grey at the same indent, "The user wants me to use the .env file…" — which read as the rest of
|
|
3330
|
+
* the bullet. Two things were missing, both since #326 made the look-ups ONE row that stays where
|
|
3331
|
+
* it is while thoughts print under it (before that a blank or the question always sat above):
|
|
3332
|
+
*
|
|
3333
|
+
* THE MARK — a dim rule down the margin, on every row of the block, so two or twenty rows read
|
|
3334
|
+
* as one thing and never as an action ("●" means something was DONE). It takes the gutter's own
|
|
3335
|
+
* three cells, so no row gets wider and nothing that measures rows changes.
|
|
3336
|
+
*
|
|
3337
|
+
* THE BLANK ROW — owned by the BLOCK (`lead`), not written beside it. The open group's row is
|
|
3338
|
+
* rewritten in place and everything under it is found by row arithmetic that expects the blocks
|
|
3339
|
+
* to touch (stepGroupStillOpen, the merge below, the shift when a block opens); a blank that
|
|
3340
|
+
* belonged to nobody would break each of those. As the block's first row it moves, repaints
|
|
3341
|
+
* and is replaced with the block.
|
|
3342
|
+
*/
|
|
3343
|
+
const THOUGHT_GUTTER = " " + dim("│") + " ";
|
|
3344
|
+
|
|
3345
|
+
/** Is the last finished line of the transcript empty (so a block printed now needs no blank)? */
|
|
3346
|
+
const transcriptEndsBlank = () => {
|
|
3347
|
+
const lines = transcript.lines();
|
|
3348
|
+
return lines.length < 2 || stripAnsiEscapes(lines[lines.length - 2] ?? "").trim() === "";
|
|
3305
3349
|
};
|
|
3306
3350
|
|
|
3307
3351
|
/** Width available to a thought row: the screen, less the gutter it hangs in. */
|
|
@@ -3317,7 +3361,9 @@ const thoughtWidth = () =>
|
|
|
3317
3361
|
*/
|
|
3318
3362
|
function printThoughtBlock(raw, before) {
|
|
3319
3363
|
if (!sessionShowThought) return false;
|
|
3320
|
-
|
|
3364
|
+
// The question said back ("The user wants me to…") is dropped from the front (#333): it sits
|
|
3365
|
+
// directly under the question, and it was all the two collapsed rows had room for.
|
|
3366
|
+
const prose = dropRestatement(thoughtProse(raw), turnQuestionText);
|
|
3321
3367
|
if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
|
|
3322
3368
|
const rows = thoughtBlockRows(prose, {
|
|
3323
3369
|
width: thoughtWidth(),
|
|
@@ -3344,12 +3390,7 @@ function printThoughtBlock(raw, before) {
|
|
|
3344
3390
|
const prev = thoughtBlocks[thoughtBlocks.length - 1];
|
|
3345
3391
|
if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
|
|
3346
3392
|
const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
|
|
3347
|
-
const
|
|
3348
|
-
width: thoughtWidth(),
|
|
3349
|
-
collapsed: true,
|
|
3350
|
-
lines: THOUGHT_COLLAPSED_LINES,
|
|
3351
|
-
});
|
|
3352
|
-
const newEnd = transcript.replaceRange(prev.from, prev.to, grown.map((r) => GUTTER + dim(r)));
|
|
3393
|
+
const newEnd = transcript.replaceRange(prev.from, prev.to, paintThoughtRows({ ...prev, prose: merged }, false));
|
|
3353
3394
|
if (newEnd !== null) {
|
|
3354
3395
|
prev.prose = merged;
|
|
3355
3396
|
prev.to = newEnd;
|
|
@@ -3362,11 +3403,16 @@ function printThoughtBlock(raw, before) {
|
|
|
3362
3403
|
}
|
|
3363
3404
|
// Scrolled out of the buffer — fall through and print a new block rather than lose it.
|
|
3364
3405
|
}
|
|
3365
|
-
|
|
3406
|
+
// Its own blank row when what is above is not one — see THOUGHT_GUTTER.
|
|
3407
|
+
const block = { from: 0, to: 0, prose, collapsed: true, lead: !transcriptEndsBlank() };
|
|
3408
|
+
const painted = paintThoughtRows(block, false);
|
|
3409
|
+
process.stdout.write(painted.join("\n") + "\n");
|
|
3366
3410
|
// Record where it landed. Measured AFTER the write so a trim during it cannot skew the
|
|
3367
3411
|
// range: the last completed line is length-2 (the final entry is the still-open line).
|
|
3368
3412
|
const endAbs = transcript.dropped() + transcript.length() - 2;
|
|
3369
|
-
|
|
3413
|
+
block.from = endAbs - painted.length + 1;
|
|
3414
|
+
block.to = endAbs;
|
|
3415
|
+
thoughtBlocks.push(block);
|
|
3370
3416
|
if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
|
|
3371
3417
|
return true;
|
|
3372
3418
|
}
|
|
@@ -7879,6 +7925,128 @@ async function runPricesUpdate() {
|
|
|
7879
7925
|
}
|
|
7880
7926
|
}
|
|
7881
7927
|
|
|
7928
|
+
// ---------------------------------------------------------------------------------------------
|
|
7929
|
+
// "CLEAR LOGS" (#329): a quiet item at the right of the notice row once gu's logs are worth a row,
|
|
7930
|
+
// and /clear-logs for where there is no mouse. A click deletes nothing: it MEASURES — the logs,
|
|
7931
|
+
// and beside them the two things that are actually big, the RAG indexes and the downloaded API
|
|
7932
|
+
// versions — shows that with a total, then asks about each. What each "clear" may and may not
|
|
7933
|
+
// touch is decided in clear-logs.mjs; this is the terminal half only.
|
|
7934
|
+
// ---------------------------------------------------------------------------------------------
|
|
7935
|
+
let logsBytes = 0; // what the notice shows; only the logs are measured for it (a handful of stats)
|
|
7936
|
+
let clearLogsBusy = false;
|
|
7937
|
+
|
|
7938
|
+
async function refreshLogsSize() {
|
|
7939
|
+
try {
|
|
7940
|
+
const { surveyLogs } = await import("./clear-logs.mjs");
|
|
7941
|
+
const was = clearLogsNoticeText(logsBytes);
|
|
7942
|
+
logsBytes = (await surveyLogs()).bytes;
|
|
7943
|
+
if (clearLogsNoticeText(logsBytes) !== was && barPinned()) paintBar();
|
|
7944
|
+
} catch {
|
|
7945
|
+
/* a housekeeping notice is never worth an error */
|
|
7946
|
+
}
|
|
7947
|
+
}
|
|
7948
|
+
|
|
7949
|
+
async function runClearLogs() {
|
|
7950
|
+
// ONE AT A TIME, and never over a turn or another picker — reached from a fire-and-forget click
|
|
7951
|
+
// (see showOtherAgents for what a second picker over the first one does).
|
|
7952
|
+
if (clearLogsBusy || listPickerActive || approvalActive || following) return;
|
|
7953
|
+
clearLogsBusy = true;
|
|
7954
|
+
try {
|
|
7955
|
+
const cl = await import("./clear-logs.mjs");
|
|
7956
|
+
const { LOCAL_API_PORT } = await import("./client-expert-api.mjs");
|
|
7957
|
+
const width = Math.max(20, process.stdout.columns || 80);
|
|
7958
|
+
console.log("\n" + GUTTER + dim("Measuring ~/.gonext…"));
|
|
7959
|
+
// Which API version is ANSWERING decides what "old" means — ask it, do not infer it.
|
|
7960
|
+
const runningVersion = await fetch(`http://127.0.0.1:${LOCAL_API_PORT}/api/health`, { signal: AbortSignal.timeout(2000) })
|
|
7961
|
+
.then((r) => r.json())
|
|
7962
|
+
.then((j) => String(j?.version ?? ""))
|
|
7963
|
+
.catch(() => "");
|
|
7964
|
+
const survey = await cl.surveyDisk({ runningVersion });
|
|
7965
|
+
console.log("");
|
|
7966
|
+
for (const row of cl.summaryRows(survey, { width: width - GUTTER.length - 1 })) {
|
|
7967
|
+
console.log(GUTTER + (row.startsWith("Total") ? bold(row) : row));
|
|
7968
|
+
}
|
|
7969
|
+
const freed = [];
|
|
7970
|
+
// A question never wraps: at 80 columns over SSH the picker hint goes on a row of its own.
|
|
7971
|
+
const question = (q) => {
|
|
7972
|
+
const hint = `(${PICK_HINT}):`;
|
|
7973
|
+
console.log("");
|
|
7974
|
+
if (2 + q.length + 2 + hint.length < width) console.log(dim(` ${q} ${hint}`));
|
|
7975
|
+
else console.log(dim(` ${q}\n ${hint}`));
|
|
7976
|
+
};
|
|
7977
|
+
const cancelled = () => {
|
|
7978
|
+
console.log(dim(` (stopped — nothing ${freed.length ? "further " : ""}removed)\n`));
|
|
7979
|
+
paintBar();
|
|
7980
|
+
};
|
|
7981
|
+
|
|
7982
|
+
// 1. THE LOGS — what was asked for, so Yes is the first row.
|
|
7983
|
+
if (survey.logs.bytes > 0) {
|
|
7984
|
+
question(`Clear the logs — ${cl.humanSize(survey.logs.bytes)}?`);
|
|
7985
|
+
const go = await pickFromList(["Yes — clear the logs" + dim(" · the worker and the API keep running"), "No"], 0);
|
|
7986
|
+
if (go < 0) return cancelled();
|
|
7987
|
+
if (go === 0) {
|
|
7988
|
+
const r = await cl.clearLogs(survey.logs);
|
|
7989
|
+
freed.push({ what: "logs", ...r });
|
|
7990
|
+
console.log(GUTTER + accent(`✓ logs cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
7991
|
+
(r.failed.length ? yellow(` (still in use, left alone: ${r.failed.join(", ")})`) : ""));
|
|
7992
|
+
} else {
|
|
7993
|
+
console.log(GUTTER + dim("logs kept"));
|
|
7994
|
+
}
|
|
7995
|
+
} else {
|
|
7996
|
+
console.log("\n" + GUTTER + dim("No logs to clear."));
|
|
7997
|
+
}
|
|
7998
|
+
|
|
7999
|
+
// 2. RAG — costs a re-index, so No is the first row.
|
|
8000
|
+
if (survey.rag.indexes.length) {
|
|
8001
|
+
const gone = survey.rag.indexes.filter((i) => i.missing);
|
|
8002
|
+
const goneBytes = gone.reduce((n, i) => n + i.bytes, 0);
|
|
8003
|
+
const choices = [{ id: "no", label: "No — keep them" }];
|
|
8004
|
+
if (gone.length && gone.length < survey.rag.indexes.length) {
|
|
8005
|
+
choices.push({ id: "gone", label: `Only the ${gone.length} whose folder no longer exists — ${cl.humanSize(goneBytes)}` });
|
|
8006
|
+
}
|
|
8007
|
+
choices.push({ id: "all", label: `Yes — all ${survey.rag.indexes.length}` + dim(" · a folder is indexed again when it is next used") });
|
|
8008
|
+
question(`Also clear the RAG indexes — ${cl.humanSize(survey.rag.bytes)}?`);
|
|
8009
|
+
const pick = await pickFromList(choices.map((c) => c.label), 0);
|
|
8010
|
+
if (pick < 0) return cancelled();
|
|
8011
|
+
if (choices[pick].id !== "no") {
|
|
8012
|
+
const r = await cl.clearRagIndexes(choices[pick].id === "gone" ? gone : survey.rag.indexes);
|
|
8013
|
+
freed.push({ what: "rag", ...r });
|
|
8014
|
+
console.log(GUTTER + accent(`✓ ${r.removed} RAG index${r.removed === 1 ? "" : "es"} cleared — ${cl.humanSize(r.freed)} freed`) +
|
|
8015
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
8016
|
+
} else {
|
|
8017
|
+
console.log(GUTTER + dim("RAG indexes kept"));
|
|
8018
|
+
}
|
|
8019
|
+
}
|
|
8020
|
+
|
|
8021
|
+
// 3. THE API — only ever the OLDER versions; the one in use is not on offer.
|
|
8022
|
+
if (survey.api.old.length) {
|
|
8023
|
+
const n = survey.api.old.length;
|
|
8024
|
+
question(`Also remove the ${n} older API version${n === 1 ? "" : "s"} — ${cl.humanSize(survey.api.oldBytes)}?`);
|
|
8025
|
+
const go = await pickFromList(["No — keep them", `Yes — remove the ${n} older one${n === 1 ? "" : "s"}` +
|
|
8026
|
+
dim(survey.api.inUse ? ` · ${survey.api.inUse}, the one in use, stays` : "")], 0);
|
|
8027
|
+
if (go < 0) return cancelled();
|
|
8028
|
+
if (go === 1) {
|
|
8029
|
+
console.log(GUTTER + dim("Removing…"));
|
|
8030
|
+
const r = await cl.clearOldApiVersions(survey.api.old);
|
|
8031
|
+
freed.push({ what: "api", ...r });
|
|
8032
|
+
console.log(GUTTER + accent(`✓ ${r.removed} older API version${r.removed === 1 ? "" : "s"} removed — ${cl.humanSize(r.freed)} freed`) +
|
|
8033
|
+
(r.failed.length ? yellow(` (could not remove: ${r.failed.join(", ")})`) : ""));
|
|
8034
|
+
} else {
|
|
8035
|
+
console.log(GUTTER + dim("older API versions kept"));
|
|
8036
|
+
}
|
|
8037
|
+
}
|
|
8038
|
+
|
|
8039
|
+
const total = freed.reduce((n, f) => n + f.freed, 0);
|
|
8040
|
+
console.log("\n" + GUTTER + (freed.length ? bold(`${cl.humanSize(total)} freed in all.`) : dim("Nothing removed.")) + "\n");
|
|
8041
|
+
} catch (e) {
|
|
8042
|
+
console.log(GUTTER + red(`couldn't finish — ${e?.message ?? e}`) + "\n");
|
|
8043
|
+
} finally {
|
|
8044
|
+
clearLogsBusy = false;
|
|
8045
|
+
await refreshLogsSize();
|
|
8046
|
+
if (barPinned()) paintBar();
|
|
8047
|
+
}
|
|
8048
|
+
}
|
|
8049
|
+
|
|
7882
8050
|
/**
|
|
7883
8051
|
* `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
|
|
7884
8052
|
*
|
|
@@ -9708,6 +9876,10 @@ const RAGSTATE_PREFIX = "__RAGSTATE__::";
|
|
|
9708
9876
|
// The tool ABOUT to run: "<label>\t<call>" (task #177). Swallowed from scrollback like
|
|
9709
9877
|
// "Running → …" — it belongs to the live block, and the finished bullet replaces it.
|
|
9710
9878
|
const RUNTOOL_PREFIX = "__RUNTOOL__::";
|
|
9879
|
+
// The command that is running, WHOLE (task #331): one line of JSON {cmd}, sent beside the
|
|
9880
|
+
// 70-character "Running → …" line — and how many lines it has printed so far. Both swallowed.
|
|
9881
|
+
const RUNCMD_PREFIX = "__RUNCMD__::";
|
|
9882
|
+
const RUNOUT_PREFIX = "__RUNOUT__::";
|
|
9711
9883
|
const MAXSTEP_PREFIX = "__MAXSTEP__::";
|
|
9712
9884
|
const TIMEOUT_PREFIX = "__TIMEOUT__::";
|
|
9713
9885
|
const COMPACT_PREFIX = "__COMPACT__::";
|
|
@@ -10624,7 +10796,25 @@ async function askTokenAtStart() {
|
|
|
10624
10796
|
/** The token refresh started at launch — askTokenAtStart waits for it (see there). */
|
|
10625
10797
|
let startupTokenRefresh = null;
|
|
10626
10798
|
|
|
10799
|
+
/**
|
|
10800
|
+
* The fuller live thought (what the status row is driven from once it is longer than the row),
|
|
10801
|
+
* held to the same rule as the short one (#333): from its first real sentence, without the
|
|
10802
|
+
* question or gu's own instructions said back — "" while that is all there is so far.
|
|
10803
|
+
*/
|
|
10804
|
+
const liveThoughtClean = (text) => {
|
|
10805
|
+
const kept = dropRestatement(text, turnQuestionText);
|
|
10806
|
+
return isRestatement(kept, turnQuestionText) ? "" : liveThoughtText(kept);
|
|
10807
|
+
};
|
|
10808
|
+
|
|
10809
|
+
// The question the running turn is answering — what a thought is compared with so that the
|
|
10810
|
+
// question said back is not printed under the question (#333). Display only.
|
|
10811
|
+
let turnQuestionText = "";
|
|
10812
|
+
|
|
10627
10813
|
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
10814
|
+
{
|
|
10815
|
+
const asked = [...(Array.isArray(history) ? history : [])].reverse().find((m) => m?.role === "user");
|
|
10816
|
+
turnQuestionText = typeof asked?.content === "string" ? asked.content : "";
|
|
10817
|
+
}
|
|
10628
10818
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
10629
10819
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
10630
10820
|
if (licenceBlock) {
|
|
@@ -10842,6 +11032,12 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10842
11032
|
let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
|
|
10843
11033
|
let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
|
|
10844
11034
|
let runningCmd = null; // the command currently executing (from a "Running → …" event)
|
|
11035
|
+
// …the same command WHOLE, what it has printed so far, when it started, and whether the status
|
|
11036
|
+
// row is hiding some of it (i.e. a click has something to open) — task #331.
|
|
11037
|
+
let runningCmdFull = "";
|
|
11038
|
+
let runningCmdLines = 0;
|
|
11039
|
+
let runningCmdAt = 0;
|
|
11040
|
+
let runningCmdMore = false;
|
|
10845
11041
|
let runningTool = null; // { label, detail } for the tool executing right now (task #177)
|
|
10846
11042
|
// The model's CURRENT Thought (task #64): while it streams its reasoning inside the
|
|
10847
11043
|
// hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
|
|
@@ -10861,6 +11057,28 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
10861
11057
|
// became of it. A no-code retry notice discards it (the model restates the plan on the retry);
|
|
10862
11058
|
// anything else prints it. Always null under --verbose, which shows everything as it comes.
|
|
10863
11059
|
let heldThought = null;
|
|
11060
|
+
// A THOUGHT INTERRUPTED BY THE HEARTBEAT IS STILL ONE THOUGHT (#333). The worker's
|
|
11061
|
+
// "…almost completed thinking… (180s)" is a step, and a step closes the stream fence — in the
|
|
11062
|
+
// MIDDLE of the model's sentence. The fence then reopens and the rest of the sentence arrived
|
|
11063
|
+
// as a new thought: the live line read `one, call use_skill(name) FIRST…` and
|
|
11064
|
+
// `` `, `bootstrap-api-service.mjs` — add… `` (both from stored streams), and the history got
|
|
11065
|
+
// the thought in two halves. So a closing fence is only PARKED here: if nothing but blanks and
|
|
11066
|
+
// heartbeats follow and the fence opens again, the thought carries on where it stopped;
|
|
11067
|
+
// anything else finishes it exactly as before.
|
|
11068
|
+
let pausedFence = null; // { step, fence } while a fence is closed but not yet finished
|
|
11069
|
+
const finishPausedFence = () => {
|
|
11070
|
+
if (!pausedFence) return;
|
|
11071
|
+
const step = pausedFence.step;
|
|
11072
|
+
pausedFence = null;
|
|
11073
|
+
// The step's model output is complete. Print its reasoning HERE, before the tools
|
|
11074
|
+
// report what they did, so the transcript reads in the order it happened: the model
|
|
11075
|
+
// says what it intends, then the actions it took appear under it.
|
|
11076
|
+
if (!REPL_VERBOSE && step.trim() && !stepHasCode(step)) {
|
|
11077
|
+
heldThought = step; // #45: decided by the next line — see releaseHeldThought
|
|
11078
|
+
} else if (printThoughtBlock(step, onContent)) {
|
|
11079
|
+
lastWasBlank = false;
|
|
11080
|
+
}
|
|
11081
|
+
};
|
|
10864
11082
|
const releaseHeldThought = () => {
|
|
10865
11083
|
if (heldThought === null) return;
|
|
10866
11084
|
const raw = heldThought;
|
|
@@ -11062,9 +11280,23 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11062
11280
|
// to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
|
|
11063
11281
|
// "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
|
|
11064
11282
|
// the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
|
|
11065
|
-
|
|
11283
|
+
// A RUNNING COMMAND is its own kind of line (#331): "$ <first line>… (17s · 5 lines)" on one
|
|
11284
|
+
// row, and a click opens the whole command underneath instead of a thought's remainder.
|
|
11285
|
+
const cmdView = runningCmd && !runningTool
|
|
11286
|
+
? runningCommandLabel(runningCmdFull || runningCmd, {
|
|
11287
|
+
room: max,
|
|
11288
|
+
secs: runningCmdAt ? Math.max(0, Math.round((now - runningCmdAt) / 1000)) : secs,
|
|
11289
|
+
lines: runningCmdLines,
|
|
11290
|
+
expanded: thoughtExpanded,
|
|
11291
|
+
})
|
|
11292
|
+
: null;
|
|
11293
|
+
runningCmdMore = Boolean(cmdView?.more);
|
|
11294
|
+
const clickable = cmdView ? cmdView.more : !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
|
|
11066
11295
|
let label, rest;
|
|
11067
|
-
if (
|
|
11296
|
+
if (cmdView) {
|
|
11297
|
+
label = cmdView.label;
|
|
11298
|
+
rest = "";
|
|
11299
|
+
} else if (clickable) {
|
|
11068
11300
|
// Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
|
|
11069
11301
|
// space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
|
|
11070
11302
|
// Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
|
|
@@ -11084,7 +11316,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11084
11316
|
}
|
|
11085
11317
|
// When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
|
|
11086
11318
|
// the playful-word line.
|
|
11087
|
-
const expLines =
|
|
11319
|
+
const expLines = cmdView
|
|
11320
|
+
? thoughtExpanded
|
|
11321
|
+
? runningCommandRows(runningCmdFull || runningCmd, {
|
|
11322
|
+
width: Math.max(20, process.stdout.columns || 80) - 1 - GUTTER.length,
|
|
11323
|
+
})
|
|
11324
|
+
: []
|
|
11325
|
+
: thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
|
|
11088
11326
|
// WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
|
|
11089
11327
|
// this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
|
|
11090
11328
|
// the #109/#138 failure mode.
|
|
@@ -11215,14 +11453,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11215
11453
|
if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
|
|
11216
11454
|
// Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
|
|
11217
11455
|
// so we don't flicker an empty expand.
|
|
11218
|
-
if (!thoughtExpanded && !(
|
|
11456
|
+
if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
|
|
11219
11457
|
toggleThought();
|
|
11220
11458
|
};
|
|
11221
11459
|
// Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
|
|
11222
11460
|
// toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
|
|
11223
11461
|
// empty expansion.
|
|
11224
11462
|
const toggleThought = () => {
|
|
11225
|
-
if (!thoughtExpanded && !(
|
|
11463
|
+
if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
|
|
11226
11464
|
thoughtExpanded = !thoughtExpanded;
|
|
11227
11465
|
clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
|
|
11228
11466
|
};
|
|
@@ -11258,6 +11496,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11258
11496
|
clearStatus();
|
|
11259
11497
|
thinking = false;
|
|
11260
11498
|
runningCmd = null;
|
|
11499
|
+
runningCmdFull = "";
|
|
11500
|
+
runningCmdLines = 0;
|
|
11501
|
+
runningCmdAt = 0;
|
|
11261
11502
|
runningTool = null;
|
|
11262
11503
|
// A concrete action landed → the Thought that led to it is spent; clear it so the
|
|
11263
11504
|
// next step's blinking line starts from "Thinking", not last step's stale clause.
|
|
@@ -11381,6 +11622,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11381
11622
|
almostDone = /almost/i.test(line);
|
|
11382
11623
|
continue;
|
|
11383
11624
|
}
|
|
11625
|
+
// A parked fence (see pausedFence) is finished by the first real line that is not the
|
|
11626
|
+
// fence opening again.
|
|
11627
|
+
if (pausedFence && line.trim() !== "" && !isFenceLine(line)) finishPausedFence();
|
|
11384
11628
|
// #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
|
|
11385
11629
|
// under --verbose only; otherwise swallowed, together with the no-code false start the
|
|
11386
11630
|
// retry notice is about.
|
|
@@ -11405,6 +11649,20 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11405
11649
|
lastContentAt = Date.now();
|
|
11406
11650
|
continue;
|
|
11407
11651
|
}
|
|
11652
|
+
if (line.startsWith(RUNCMD_PREFIX)) {
|
|
11653
|
+
try {
|
|
11654
|
+
runningCmdFull = String(JSON.parse(line.slice(RUNCMD_PREFIX.length))?.cmd ?? "");
|
|
11655
|
+
} catch {
|
|
11656
|
+
runningCmdFull = ""; // the short "Running → …" line still names it
|
|
11657
|
+
}
|
|
11658
|
+
lastContentAt = Date.now();
|
|
11659
|
+
continue;
|
|
11660
|
+
}
|
|
11661
|
+
if (line.startsWith(RUNOUT_PREFIX)) {
|
|
11662
|
+
runningCmdLines = Number(line.slice(RUNOUT_PREFIX.length).trim()) || 0;
|
|
11663
|
+
lastContentAt = Date.now();
|
|
11664
|
+
continue;
|
|
11665
|
+
}
|
|
11408
11666
|
if (line.startsWith(RUNTOOL_PREFIX)) {
|
|
11409
11667
|
const [label, ...rest] = line.slice(RUNTOOL_PREFIX.length).split("\t");
|
|
11410
11668
|
runningTool = { label: (label || "Working").trim(), detail: rest.join("\t").trim() };
|
|
@@ -11422,17 +11680,19 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11422
11680
|
if (isFenceLine(line)) {
|
|
11423
11681
|
inStreamFence = !inStreamFence;
|
|
11424
11682
|
if (inStreamFence) {
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11683
|
+
if (pausedFence) {
|
|
11684
|
+
// …the same thought, carrying on. The fence close put a line break where the model
|
|
11685
|
+
// had none ("…When the TASK matches" / " one, call…"), so the two halves are joined
|
|
11686
|
+
// on the line they were cut in.
|
|
11687
|
+
stepThought = pausedFence.step.replace(/\n$/, "");
|
|
11688
|
+
fenceThought = pausedFence.fence.replace(/\n$/, "");
|
|
11689
|
+
pausedFence = null;
|
|
11690
|
+
} else {
|
|
11691
|
+
fenceThought = "";
|
|
11692
|
+
stepThought = "";
|
|
11435
11693
|
}
|
|
11694
|
+
} else {
|
|
11695
|
+
pausedFence = { step: stepThought, fence: fenceThought };
|
|
11436
11696
|
stepThought = "";
|
|
11437
11697
|
}
|
|
11438
11698
|
continue;
|
|
@@ -11452,11 +11712,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11452
11712
|
// Accumulate a bounded head of the stream (enough to see the Thought prose OR the
|
|
11453
11713
|
// tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
|
|
11454
11714
|
// yields a clean label — never raw code/CSS.
|
|
11455
|
-
|
|
11715
|
+
// (1200, not 400, since #333: the line is now taken from past the question said back,
|
|
11716
|
+
// and a restatement alone can run to a few hundred characters)
|
|
11717
|
+
if (fenceThought.length < 1200) {
|
|
11456
11718
|
fenceThought += line + "\n";
|
|
11457
|
-
const th = normalizeThought(fenceThought);
|
|
11719
|
+
const th = normalizeThought(fenceThought, { question: turnQuestionText });
|
|
11458
11720
|
if (th) liveThought = th;
|
|
11459
|
-
fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
|
|
11721
|
+
fullLiveThought = liveThoughtClean(thoughtFull(fenceThought)); // #96: keep the fuller thought for expand
|
|
11460
11722
|
}
|
|
11461
11723
|
continue;
|
|
11462
11724
|
}
|
|
@@ -11575,7 +11837,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11575
11837
|
// So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
|
|
11576
11838
|
if (!plainReplyFlow) {
|
|
11577
11839
|
const mRun = /^Running → (.+)$/.exec(line.trim());
|
|
11578
|
-
if (mRun) {
|
|
11840
|
+
if (mRun) {
|
|
11841
|
+
runningCmd = mRun[1];
|
|
11842
|
+
// a NEW command: its own clock and count, and it starts as one row (#331)
|
|
11843
|
+
runningCmdFull = "";
|
|
11844
|
+
runningCmdLines = 0;
|
|
11845
|
+
runningCmdAt = Date.now();
|
|
11846
|
+
thoughtExpanded = false;
|
|
11847
|
+
thinking = false;
|
|
11848
|
+
lastContentAt = Date.now();
|
|
11849
|
+
continue;
|
|
11850
|
+
}
|
|
11579
11851
|
}
|
|
11580
11852
|
// Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
|
|
11581
11853
|
// prints yet, so the thinking ticker should keep animating until completion.
|
|
@@ -11658,9 +11930,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11658
11930
|
// live clause from the partial too, so the blinking line updates mid-sentence instead
|
|
11659
11931
|
// of only once the line finally breaks (task #64).
|
|
11660
11932
|
if (inStreamFence && fenceThought.length < 400) {
|
|
11661
|
-
const th = normalizeThought(fenceThought + carry);
|
|
11933
|
+
const th = normalizeThought(fenceThought + carry, { question: turnQuestionText });
|
|
11662
11934
|
if (th) liveThought = th;
|
|
11663
|
-
fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
|
|
11935
|
+
fullLiveThought = liveThoughtClean(thoughtFull(fenceThought + carry)); // #96: fuller thought for expand
|
|
11664
11936
|
}
|
|
11665
11937
|
// A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
|
|
11666
11938
|
// a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
|
|
@@ -11861,6 +12133,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
|
|
|
11861
12133
|
lastWasBlank = false;
|
|
11862
12134
|
}
|
|
11863
12135
|
}
|
|
12136
|
+
finishPausedFence(); // #333: a fence closed and nothing followed — it is finished now
|
|
11864
12137
|
releaseHeldThought(); // #45: nothing followed it, so it was not a false start
|
|
11865
12138
|
clearStatus();
|
|
11866
12139
|
// Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
|
|
@@ -12803,6 +13076,8 @@ async function main() {
|
|
|
12803
13076
|
// #323: gu's newest published price + cache tables — same cadence, same silent failure.
|
|
12804
13077
|
void refreshPriceUpdate();
|
|
12805
13078
|
setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
|
|
13079
|
+
void refreshLogsSize();
|
|
13080
|
+
setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
|
|
12806
13081
|
// The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
|
|
12807
13082
|
startupTokenRefresh = refreshCliToken();
|
|
12808
13083
|
setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
|
|
@@ -13263,6 +13538,10 @@ async function main() {
|
|
|
13263
13538
|
await runPricesCommand(line);
|
|
13264
13539
|
continue;
|
|
13265
13540
|
}
|
|
13541
|
+
if (line === "/clear-logs") {
|
|
13542
|
+
await runClearLogs();
|
|
13543
|
+
continue;
|
|
13544
|
+
}
|
|
13266
13545
|
if (line === "/tokens") {
|
|
13267
13546
|
// Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
|
|
13268
13547
|
// 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.789",
|
|
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",
|