@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/gu-repl.mjs CHANGED
@@ -63,11 +63,6 @@ import {
63
63
  ragSegmentLabel,
64
64
  searchSegmentLabel,
65
65
  runningDetailRows,
66
- runningCommandLabel,
67
- runningCommandRows,
68
- dropRestatement,
69
- isRestatement,
70
- liveThoughtText,
71
66
  formatThinkTime,
72
67
  plainAnswer,
73
68
  summarizeError,
@@ -133,7 +128,6 @@ import {
133
128
  } from "./terminal-layout.mjs";
134
129
  import { completedFlush, splitLiveStream } from "./completed-flush.mjs";
135
130
  import { isScaffoldStep, isGroupableStep, stepGroupRows, relativizePaths } from "./step-group.mjs";
136
- import { clearLogsNoticeText } from "./clear-logs.mjs";
137
131
  import { discoverSkills, matchSkillCommand } from "./skills.mjs";
138
132
  import { appendNew, archivePathFor, archivedWorkspaceKey, markReset, seedArchive } from "./conversation-archive.mjs";
139
133
  import { addAllowRule, listServers as listMcpServers } from "./mcp-config.mjs";
@@ -897,7 +891,6 @@ const COMMANDS = [
897
891
  { name: "/use-presets", usage: "/use-presets [name]", desc: "pick a saved preset and apply it — shows what changes, backs up your setup, asks first" },
898
892
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
899
893
  { 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" },
901
894
  { 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" },
902
895
  // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
903
896
  // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
@@ -1469,11 +1462,6 @@ function applyClick({ row, col }) {
1469
1462
  void runPricesUpdate();
1470
1463
  return;
1471
1464
  }
1472
- if (logsRowTarget && row === updateRowAbs &&
1473
- col - 1 >= logsRowTarget.from && col - 1 < logsRowTarget.to) {
1474
- void runClearLogs();
1475
- return;
1476
- }
1477
1465
  if (agentsRowTarget && row === updateRowAbs &&
1478
1466
  col - 1 >= agentsRowTarget.from && col - 1 < agentsRowTarget.to) {
1479
1467
  void showOtherAgents(); // async now — it offers stop / switch-to (task #211)
@@ -2692,9 +2680,6 @@ function zoneNotice(width) {
2692
2680
  if (tokenNotice?.text) items.push({ key: "token", paint: tokenNotice.urgent ? yellow : dim, text: tokenNotice.text });
2693
2681
  const agents = agentsIndicator(otherAgents);
2694
2682
  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 });
2698
2683
  return noticeRow(items, { width, gutter: GUTTER });
2699
2684
  }
2700
2685
 
@@ -2775,7 +2760,6 @@ function frozenZone() {
2775
2760
  updateRowTarget = notice?.spans.update ?? null;
2776
2761
  agentsRowTarget = notice?.spans.agents ?? null;
2777
2762
  pricesRowTarget = notice?.spans.prices ?? null;
2778
- logsRowTarget = notice?.spans.logs ?? null;
2779
2763
  jumpSpan = notice?.spans.jump ?? null;
2780
2764
  return { ...zone, rows, statusRows: sep, noticeRow: Boolean(notice) };
2781
2765
  }
@@ -2845,7 +2829,7 @@ function paintBar() {
2845
2829
  // THE NOTICE ROW is the one directly above the separator (#318) — never the separator itself.
2846
2830
  const noticeAbs = zone.noticeRow ? sepAbs - 1 : 0;
2847
2831
  jumpRowTarget = scrolledBack() && jumpSpan ? { row: noticeAbs, ...jumpSpan } : null;
2848
- updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget || logsRowTarget ? noticeAbs : 0;
2832
+ updateRowAbs = updateRowTarget || agentsRowTarget || pricesRowTarget ? noticeAbs : 0;
2849
2833
  // preserveCaret:false — the save slot holds the OUTPUT position (see regionUi), and the caret
2850
2834
  // is parked on the input line right after, so there is nothing to preserve here.
2851
2835
  ui(
@@ -3007,8 +2991,6 @@ let updateRowAbs = 0;
3007
2991
  let agentsRowTarget = null;
3008
2992
  // …and "new prices updated <date>" (#323).
3009
2993
  let pricesRowTarget = null;
3010
- // …and "clear logs · 175 MB" (#329), shown only once the logs are worth a row.
3011
- let logsRowTarget = null;
3012
2994
  // A terminal left with a scrolling region is a broken shell — a hard guarantee, like the
3013
2995
  // bracketed-paste and mouse-reporting teardowns.
3014
2996
  process.on("exit", () => {
@@ -3319,33 +3301,7 @@ const paintThoughtRows = (block, hovered, collapsed = block.collapsed) => {
3319
3301
  collapsed,
3320
3302
  lines: THOUGHT_COLLAPSED_LINES,
3321
3303
  });
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() === "";
3304
+ return rows.map((r) => GUTTER + paint(r));
3349
3305
  };
3350
3306
 
3351
3307
  /** Width available to a thought row: the screen, less the gutter it hangs in. */
@@ -3361,9 +3317,7 @@ const thoughtWidth = () =>
3361
3317
  */
3362
3318
  function printThoughtBlock(raw, before) {
3363
3319
  if (!sessionShowThought) return false;
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);
3320
+ const prose = thoughtProse(raw);
3367
3321
  if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
3368
3322
  const rows = thoughtBlockRows(prose, {
3369
3323
  width: thoughtWidth(),
@@ -3390,7 +3344,12 @@ function printThoughtBlock(raw, before) {
3390
3344
  const prev = thoughtBlocks[thoughtBlocks.length - 1];
3391
3345
  if (prev && !prev.kind && prev.collapsed && prev.to === transcript.dropped() + transcript.length() - 2) {
3392
3346
  const merged = prev.prose.replace(/\s+$/, "") + " " + prose;
3393
- const newEnd = transcript.replaceRange(prev.from, prev.to, paintThoughtRows({ ...prev, prose: merged }, false));
3347
+ const grown = thoughtBlockRows(merged, {
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)));
3394
3353
  if (newEnd !== null) {
3395
3354
  prev.prose = merged;
3396
3355
  prev.to = newEnd;
@@ -3403,16 +3362,11 @@ function printThoughtBlock(raw, before) {
3403
3362
  }
3404
3363
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
3405
3364
  }
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");
3365
+ process.stdout.write(rows.map((r) => GUTTER + dim(r)).join("\n") + "\n");
3410
3366
  // Record where it landed. Measured AFTER the write so a trim during it cannot skew the
3411
3367
  // range: the last completed line is length-2 (the final entry is the still-open line).
3412
3368
  const endAbs = transcript.dropped() + transcript.length() - 2;
3413
- block.from = endAbs - painted.length + 1;
3414
- block.to = endAbs;
3415
- thoughtBlocks.push(block);
3369
+ thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
3416
3370
  if (thoughtBlocks.length > 200) thoughtBlocks = thoughtBlocks.slice(-200);
3417
3371
  return true;
3418
3372
  }
@@ -7925,128 +7879,6 @@ async function runPricesUpdate() {
7925
7879
  }
7926
7880
  }
7927
7881
 
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
-
8050
7882
  /**
8051
7883
  * `/embed-model` — switch the RAG embedder, the embed server, or the embed key (task #169).
8052
7884
  *
@@ -9876,10 +9708,6 @@ const RAGSTATE_PREFIX = "__RAGSTATE__::";
9876
9708
  // The tool ABOUT to run: "<label>\t<call>" (task #177). Swallowed from scrollback like
9877
9709
  // "Running → …" — it belongs to the live block, and the finished bullet replaces it.
9878
9710
  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__::";
9883
9711
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
9884
9712
  const TIMEOUT_PREFIX = "__TIMEOUT__::";
9885
9713
  const COMPACT_PREFIX = "__COMPACT__::";
@@ -10796,25 +10624,7 @@ async function askTokenAtStart() {
10796
10624
  /** The token refresh started at launch — askTokenAtStart waits for it (see there). */
10797
10625
  let startupTokenRefresh = null;
10798
10626
 
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
-
10813
10627
  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
- }
10818
10628
  // #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
10819
10629
  // startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
10820
10630
  if (licenceBlock) {
@@ -11032,12 +10842,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11032
10842
  let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
11033
10843
  let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
11034
10844
  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;
11041
10845
  let runningTool = null; // { label, detail } for the tool executing right now (task #177)
11042
10846
  // The model's CURRENT Thought (task #64): while it streams its reasoning inside the
11043
10847
  // hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
@@ -11057,28 +10861,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11057
10861
  // became of it. A no-code retry notice discards it (the model restates the plan on the retry);
11058
10862
  // anything else prints it. Always null under --verbose, which shows everything as it comes.
11059
10863
  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
- };
11082
10864
  const releaseHeldThought = () => {
11083
10865
  if (heldThought === null) return;
11084
10866
  const raw = heldThought;
@@ -11280,23 +11062,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11280
11062
  // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
11281
11063
  // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
11282
11064
  // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
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;
11065
+ const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
11295
11066
  let label, rest;
11296
- if (cmdView) {
11297
- label = cmdView.label;
11298
- rest = "";
11299
- } else if (clickable) {
11067
+ if (clickable) {
11300
11068
  // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
11301
11069
  // space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
11302
11070
  // Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
@@ -11316,13 +11084,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11316
11084
  }
11317
11085
  // When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
11318
11086
  // the playful-word line.
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) : [];
11087
+ const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
11326
11088
  // WHAT IS RUNNING, under the label (task #177). Capped at two rows and clipped, because
11327
11089
  // this block is redrawn by walking the caret over PHYSICAL rows — a wrapped row here is
11328
11090
  // the #109/#138 failure mode.
@@ -11453,14 +11215,14 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11453
11215
  if (row < top || row > top + statusContentLines - LEAD_ROWS - WORD_GAP - 2) return;
11454
11216
  // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
11455
11217
  // so we don't flicker an empty expand.
11456
- if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
11218
+ if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11457
11219
  toggleThought();
11458
11220
  };
11459
11221
  // Same expand/collapse, reachable without the mouse: Ctrl+T while a turn runs (see
11460
11222
  // toggleActiveThought). Ignored when there's nothing more to reveal, so it never flickers an
11461
11223
  // empty expansion.
11462
11224
  const toggleThought = () => {
11463
- if (!thoughtExpanded && !(runningCmd ? runningCmdMore : fullLiveThought)) return;
11225
+ if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
11464
11226
  thoughtExpanded = !thoughtExpanded;
11465
11227
  clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
11466
11228
  };
@@ -11496,9 +11258,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11496
11258
  clearStatus();
11497
11259
  thinking = false;
11498
11260
  runningCmd = null;
11499
- runningCmdFull = "";
11500
- runningCmdLines = 0;
11501
- runningCmdAt = 0;
11502
11261
  runningTool = null;
11503
11262
  // A concrete action landed → the Thought that led to it is spent; clear it so the
11504
11263
  // next step's blinking line starts from "Thinking", not last step's stale clause.
@@ -11622,9 +11381,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11622
11381
  almostDone = /almost/i.test(line);
11623
11382
  continue;
11624
11383
  }
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();
11628
11384
  // #45: retry notices and context-window bookkeeping are the agent's own machinery. Shown
11629
11385
  // under --verbose only; otherwise swallowed, together with the no-code false start the
11630
11386
  // retry notice is about.
@@ -11649,20 +11405,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11649
11405
  lastContentAt = Date.now();
11650
11406
  continue;
11651
11407
  }
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
- }
11666
11408
  if (line.startsWith(RUNTOOL_PREFIX)) {
11667
11409
  const [label, ...rest] = line.slice(RUNTOOL_PREFIX.length).split("\t");
11668
11410
  runningTool = { label: (label || "Working").trim(), detail: rest.join("\t").trim() };
@@ -11680,19 +11422,17 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11680
11422
  if (isFenceLine(line)) {
11681
11423
  inStreamFence = !inStreamFence;
11682
11424
  if (inStreamFence) {
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 = "";
11693
- }
11425
+ fenceThought = "";
11426
+ stepThought = "";
11694
11427
  } else {
11695
- pausedFence = { step: stepThought, fence: fenceThought };
11428
+ // The step's model output is complete. Print its reasoning HERE, before the tools
11429
+ // report what they did, so the transcript reads in the order it happened: the model
11430
+ // says what it intends, then the actions it took appear under it.
11431
+ if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
11432
+ heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
11433
+ } else if (printThoughtBlock(stepThought, onContent)) {
11434
+ lastWasBlank = false;
11435
+ }
11696
11436
  stepThought = "";
11697
11437
  }
11698
11438
  continue;
@@ -11712,13 +11452,11 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11712
11452
  // Accumulate a bounded head of the stream (enough to see the Thought prose OR the
11713
11453
  // tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
11714
11454
  // yields a clean label — never raw code/CSS.
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) {
11455
+ if (fenceThought.length < 400) {
11718
11456
  fenceThought += line + "\n";
11719
- const th = normalizeThought(fenceThought, { question: turnQuestionText });
11457
+ const th = normalizeThought(fenceThought);
11720
11458
  if (th) liveThought = th;
11721
- fullLiveThought = liveThoughtClean(thoughtFull(fenceThought)); // #96: keep the fuller thought for expand
11459
+ fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
11722
11460
  }
11723
11461
  continue;
11724
11462
  }
@@ -11837,17 +11575,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11837
11575
  // So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
11838
11576
  if (!plainReplyFlow) {
11839
11577
  const mRun = /^Running → (.+)$/.exec(line.trim());
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
- }
11578
+ if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
11851
11579
  }
11852
11580
  // Skip the status-clear for a BUFFERED plain reply (STREAM_PLAIN_REPLY=false): nothing
11853
11581
  // prints yet, so the thinking ticker should keep animating until completion.
@@ -11930,9 +11658,9 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11930
11658
  // live clause from the partial too, so the blinking line updates mid-sentence instead
11931
11659
  // of only once the line finally breaks (task #64).
11932
11660
  if (inStreamFence && fenceThought.length < 400) {
11933
- const th = normalizeThought(fenceThought + carry, { question: turnQuestionText });
11661
+ const th = normalizeThought(fenceThought + carry);
11934
11662
  if (th) liveThought = th;
11935
- fullLiveThought = liveThoughtClean(thoughtFull(fenceThought + carry)); // #96: fuller thought for expand
11663
+ fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
11936
11664
  }
11937
11665
  // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
11938
11666
  // a newline), so tokens are actively flowing — keep the ticker asleep. EXCEPT a buffered
@@ -12133,7 +11861,6 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
12133
11861
  lastWasBlank = false;
12134
11862
  }
12135
11863
  }
12136
- finishPausedFence(); // #333: a fence closed and nothing followed — it is finished now
12137
11864
  releaseHeldThought(); // #45: nothing followed it, so it was not a false start
12138
11865
  clearStatus();
12139
11866
  // Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
@@ -12350,6 +12077,29 @@ async function main() {
12350
12077
  // Asking an established machine to re-pick its mode on the first run after an upgrade
12351
12078
  // implies that choosing Host would do something. It would not. Reported live on an existing
12352
12079
  // machine that had been working for months.
12080
+ //
12081
+ // THE DATABASE FIRST, on a machine whose Local setup is finished (task #329). Every line below
12082
+ // talks to the API, and the API cannot answer without MongoDB — but its health check says "up"
12083
+ // regardless, so a missing mongod surfaced as two 15s timeouts and "the API is stuck". One
12084
+ // handshake here says what is actually wrong, and on a Mac offers to install or start it.
12085
+ // BEFORE the mode block on purpose: a database that had to be INSTALLED is empty, the steps
12086
+ // that filled it are re-opened, and the resume just below then runs them in this same start.
12087
+ if (isClientExpert()) {
12088
+ let mongoOk = true;
12089
+ try {
12090
+ const cer = await import("./client-expert-run.mjs");
12091
+ const { localMongoUri } = await import("./client-expert-api.mjs");
12092
+ const st0 = await cer.loadState();
12093
+ if (st0.mongo === "done") {
12094
+ const pre = await cer.preflightLocalMongo({ ask, isTty: IS_TTY, uri: localMongoUri() });
12095
+ mongoOk = pre.ok;
12096
+ if (pre.installed) await cer.saveState(cer.reopenAfterEmptyDatabase(st0));
12097
+ }
12098
+ } catch {
12099
+ /* a check, never a new way for gu to fail to start — the errors below still name the API */
12100
+ }
12101
+ if (!mongoOk) process.exit(1);
12102
+ }
12353
12103
  try {
12354
12104
  const { loadState, saveState, askMode } = await import("./client-expert-run.mjs");
12355
12105
  const { clientExpertAvailable, expertPlanPending, shouldAskMode, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
@@ -13031,7 +12781,25 @@ async function main() {
13031
12781
  dim("\n Local mode keeps your conversations in MongoDB on this machine, and that") +
13032
12782
  dim("\n API is what reads it. It does not survive a reboot on its own.\n")
13033
12783
  );
12784
+ // ASK MONGODB ITSELF before telling the reader to (task #329). It can die AFTER the startup
12785
+ // check passed, and "is mongod running?" is a question this process answers in milliseconds.
12786
+ let mongoDown = "";
13034
12787
  if (hung) {
12788
+ try {
12789
+ const { localMongoDown } = await import("./client-expert-run.mjs");
12790
+ const { localMongoUri } = await import("./client-expert-api.mjs");
12791
+ mongoDown = await localMongoDown(localMongoUri());
12792
+ } catch {
12793
+ /* unknown — the general advice below still applies */
12794
+ }
12795
+ }
12796
+ if (mongoDown) {
12797
+ console.error(
12798
+ dim(` The API is running; MongoDB is not answering at ${mongoDown}, and the API waits on it.`) +
12799
+ dim("\n Start MongoDB (run `gu` again — it checks the database first and says how),") +
12800
+ dim("\n no API restart needed.\n")
12801
+ );
12802
+ } else if (hung) {
13035
12803
  // Do NOT try to start it — the port is taken by the process that is failing to answer.
13036
12804
  console.error(
13037
12805
  dim(" Something IS listening there but did not reply within ") +
@@ -13076,8 +12844,6 @@ async function main() {
13076
12844
  // #323: gu's newest published price + cache tables — same cadence, same silent failure.
13077
12845
  void refreshPriceUpdate();
13078
12846
  setInterval(() => void refreshPriceUpdate(), UPDATE_CHECK_MS).unref();
13079
- void refreshLogsSize();
13080
- setInterval(() => void refreshLogsSize(), UPDATE_CHECK_MS).unref();
13081
12847
  // The gu token (#232): same arrangement — background, never delays the prompt, unref'd.
13082
12848
  startupTokenRefresh = refreshCliToken();
13083
12849
  setInterval(() => void refreshCliToken(), TOKEN_TICK_MS).unref();
@@ -13538,10 +13304,6 @@ async function main() {
13538
13304
  await runPricesCommand(line);
13539
13305
  continue;
13540
13306
  }
13541
- if (line === "/clear-logs") {
13542
- await runClearLogs();
13543
- continue;
13544
- }
13545
13307
  if (line === "/tokens") {
13546
13308
  // Task #142: WHERE the last request's prompt went. The bar answers "how much is RAG";
13547
13309
  // this answers "and what displaced it", which is the part that tells you whether RAG is
package/mongo-native.mjs CHANGED
@@ -144,6 +144,28 @@ export const BREW_INSTALL_STEPS = [
144
144
  ];
145
145
  export const BREW_START_STEPS = [["services", "start", "mongodb/brew/mongodb-community"]];
146
146
 
147
+ /**
148
+ * HOMEBREW 7 REFUSES AN UNTRUSTED TAP (task #329, seen live 2026-10-02 on a fresh Homebrew 7.0.7):
149
+ * Error: Refusing to load formula mongodb/brew/mongodb-database-tools from untrusted tap
150
+ * mongodb/brew. Run … `brew trust mongodb/brew` to trust it.
151
+ * so tap → install, the steps that worked two days earlier, stopped at the install on exactly the
152
+ * machine a first install runs on. An older Homebrew has no `trust` command and must not be
153
+ * shown one, so the step is added only where the command exists.
154
+ */
155
+ export const BREW_TRUST_STEP = ["trust", "mongodb/brew"];
156
+ export function brewInstallSteps({ trust = false } = {}) {
157
+ return trust ? [BREW_INSTALL_STEPS[0], BREW_TRUST_STEP, ...BREW_INSTALL_STEPS.slice(1)] : BREW_INSTALL_STEPS;
158
+ }
159
+ /** Does this Homebrew have `brew trust`? (`brew trust --help` exits 0 only where it exists.) */
160
+ export async function brewHasTrust(brew, { run = runStreaming } = {}) {
161
+ if (!brew) return false;
162
+ return (await run(brew, ["trust", "--help"], { env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: "1" } })) === 0;
163
+ }
164
+
165
+ /** Homebrew's own one-line installer (https://brew.sh) — it asks for the password itself. */
166
+ export const HOMEBREW_INSTALL_COMMAND =
167
+ '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
168
+
147
169
  /** Where Homebrew is, or "". Apple Silicon first, then Intel. */
148
170
  export function findBrew({ exists = existsSync } = {}) {
149
171
  for (const p of ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]) if (exists(p)) return p;
@@ -153,8 +175,11 @@ export function findBrew({ exists = existsSync } = {}) {
153
175
  /** The lines a Mac with no Homebrew is shown. */
154
176
  export function noBrewLines() {
155
177
  return [
156
- "MongoDB is installed with Homebrew on a Mac, and Homebrew is not here.",
157
- "Install Homebrew (https://brew.sh), then run gu again — setup installs MongoDB from there.",
178
+ "MongoDB is installed with Homebrew on a Mac, and Homebrew is not here. Install Homebrew first:",
179
+ ` ${HOMEBREW_INSTALL_COMMAND}`,
180
+ "then run gu again — it installs MongoDB from there with:",
181
+ // A Homebrew installed today is 7+, which needs the tap trusted.
182
+ ...brewInstallSteps({ trust: true }).map((s) => ` brew ${s.join(" ")}`),
158
183
  "Or install MongoDB Community yourself: https://www.mongodb.com/try/download/community",
159
184
  ];
160
185
  }