@tiens.nguyen/gu-cli 1.0.822 → 1.0.824

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.
@@ -2026,7 +2026,11 @@ def _is_greeting_smalltalk(text: str) -> bool:
2026
2026
  # self-labels as "Thought"/"Thinking Process" up front (a real answer doesn't open by
2027
2027
  # naming itself as the model's own reasoning), or it's dense with self-correction
2028
2028
  # language a finished answer wouldn't contain.
2029
- _SCRATCHPAD_START_RE = re.compile(r"^\s*thought\b\s*[:\n]", re.I)
2029
+ # The label may come dressed as markdown — "## Thought" or "**Thought:**" — and is still the
2030
+ # model naming its own reasoning (#139: Kimi K3 wrote "## Thought\n\n… Let me check git status"
2031
+ # and it was delivered as the ANSWER to "commit", so nothing ran).
2032
+ _SCRATCHPAD_START_RE = re.compile(
2033
+ r"^\s*(?:#{1,6}\s*|\*\*|__)?thought(?:\s*:|\s*(?:\*\*|__)\s*:?|\s*\n)", re.I)
2030
2034
  _DELIBERATION_RE = re.compile(
2031
2035
  r"\b(wait,|actually,|let'?s (try|just|use|list|see|go)|"
2032
2036
  r"i (?:need to|should) (?:check|verify|confirm))\b",
@@ -5224,6 +5228,37 @@ def _has_runnable_block(text) -> bool:
5224
5228
  return False
5225
5229
 
5226
5230
 
5231
+ # Chat-template control tokens a served model sometimes prints instead of acting on (#139,
5232
+ # Kimi K3 on ollama.com: "Thought: …<|close|>think<|sep|><|open|>response<|sep|>"). They mark
5233
+ # where the model's own reasoning ended and its reply began.
5234
+ _TEMPLATE_TOKEN_RE = re.compile(r"<\|[a-z_]{2,24}\|>(?:\s*[a-z_]{2,24}\s*<\|[a-z_]{2,24}\|>)*", re.I)
5235
+
5236
+
5237
+ def _strip_leaked_template(content):
5238
+ """Cut leaked chat-template tokens out of a reply; keep what came after the last one.
5239
+
5240
+ Text before the last token is the model's reasoning written into the visible channel; text
5241
+ after it is the reply. An EMPTY reply stays empty, so the empty-content nudge asks the model
5242
+ to act instead of the step failing on a plan that ran nothing (seen live: three steps of
5243
+ "Thought: … now final answer.<|close|>think…" before a final_answer). Tokens inside a
5244
+ <code> block are file content, never ours to touch."""
5245
+ if not isinstance(content, str) or "<|" not in content:
5246
+ return content
5247
+ spans = [m.span() for m in re.finditer(_CODE_PAIR_RE, content, re.I)]
5248
+ last = None
5249
+ for m in _TEMPLATE_TOKEN_RE.finditer(content):
5250
+ if not any(a <= m.start() < b for a, b in spans):
5251
+ last = m
5252
+ if last is None:
5253
+ return content
5254
+ reply = content[last.end():].strip()
5255
+ if reply:
5256
+ return reply
5257
+ head = _TEMPLATE_TOKEN_RE.sub("", content[:last.start()]).strip()
5258
+ # A runnable block before the tokens is still the action — keep it.
5259
+ return head if _has_runnable_block(head) else ""
5260
+
5261
+
5227
5262
  def _code_nudge_reason(content, openai_backend: bool):
5228
5263
  """Why (if at all) this streamed turn should be re-prompted once with the CODE-NOW
5229
5264
  directive — or None to accept it as-is. Pure decision so the three triggers can be
@@ -5234,14 +5269,16 @@ def _code_nudge_reason(content, openai_backend: bool):
5234
5269
  merely TRUNCATED at the `</code>` stop sequence is not that (task #119): it is
5235
5270
  closed first and accepted as-is, so a step that already called a tool no longer
5236
5271
  pays for a second model call to be told to do what it just did.
5237
- 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. This one
5238
- is openai-only and additionally requires _looks_like_scratchpad, so a FINISHED
5239
- prose answer is never nudged into inventing another tool call. It exists because
5240
- the "</code>" visible in a smolagents trace is appended by smolagents AFTER the
5241
- model returns, so trigger 2 cannot see this shape.
5242
-
5243
- Triggers 1 and 2 keep their existing backend-agnostic behavior — the Ollama path is
5244
- unchanged, since trigger 3 is the only new one and it is gated off there."""
5272
+ 3. prose-only plan — narration of what it is ABOUT to do, no tag at all. It
5273
+ requires _looks_like_scratchpad, so a FINISHED prose answer is never nudged into
5274
+ inventing another tool call. It exists because the "</code>" visible in a
5275
+ smolagents trace is appended by smolagents AFTER the model returns, so trigger 2
5276
+ cannot see this shape.
5277
+
5278
+ All three apply on every backend. Trigger 3 was openai-only (kept off small local
5279
+ Ollama models); #139 showed the cost: Kimi K3 served by ollama.com is an "ollama"
5280
+ backend, so each plan-only reply burned a whole failed step instead of one retry.
5281
+ `openai_backend` is kept for callers and no longer changes the decision."""
5245
5282
  text = (content or "").strip()
5246
5283
  if not text:
5247
5284
  return "empty-content"
@@ -5249,7 +5286,7 @@ def _code_nudge_reason(content, openai_backend: bool):
5249
5286
  return None # a real block (closing a stop-truncated one first) — nothing to nudge
5250
5287
  if re.search(r"</?code[\s>/]", content, re.I):
5251
5288
  return "dangling code tag, no valid block"
5252
- if openai_backend and _looks_like_scratchpad(text):
5289
+ if _looks_like_scratchpad(text):
5253
5290
  return "prose-only plan, no code block"
5254
5291
  return None
5255
5292
 
@@ -11730,6 +11767,12 @@ def run_agent_chat(cfg):
11730
11767
  # anything downstream asks "did this turn produce a code block?" — otherwise a
11731
11768
  # step that called a tool correctly reads as broken. Gated on the code actually
11732
11769
  # compiling, so a genuinely dangling tag still falls through to the nudge below.
11770
+ # Leaked chat-template tokens first (#139): what came before them is reasoning.
11771
+ _unleaked = _strip_leaked_template(content)
11772
+ if _unleaked != content:
11773
+ _log(f"leaked chat-template tokens → kept the reply after them "
11774
+ f"({len(content)} → {len(_unleaked)} chars)")
11775
+ content = _unleaked
11733
11776
  content = _close_dangling_code_block(content)
11734
11777
  # TAGS FIRST, judgement second. The tag repair used to run much later, so every
11735
11778
  # decision in between — is this a code block? does it need a nudge? — was made on
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `gu` without the bin path in the macOS window title (#358) — see relaunchPlan in
3
+ * terminal-title.mjs. gu-repl.mjs imports this FIRST, so the process is replaced before any other
4
+ * module has loaded or anything has been drawn.
5
+ */
6
+ import { fileURLToPath } from "node:url";
7
+ import { relaunchPlan, restoreAfterRelaunch } from "./terminal-title.mjs";
8
+
9
+ export const RELAUNCHED = restoreAfterRelaunch(process);
10
+
11
+ if (!RELAUNCHED) {
12
+ const plan = relaunchPlan({
13
+ platform: process.platform,
14
+ hasExecve: typeof process.execve === "function",
15
+ env: process.env,
16
+ argv: process.argv,
17
+ execPath: process.execPath,
18
+ script: fileURLToPath(new URL("./gu-repl.mjs", import.meta.url)),
19
+ stdinTTY: !!process.stdin.isTTY,
20
+ stdoutTTY: !!process.stdout.isTTY,
21
+ });
22
+ if (plan) {
23
+ // Node 23–24 flag execve as experimental; that warning would be gu's first line.
24
+ const emitWarning = process.emitWarning;
25
+ process.emitWarning = (w, ...rest) =>
26
+ /execve/i.test(String(w?.message ?? w)) ? undefined : emitWarning.call(process, w, ...rest);
27
+ try {
28
+ process.execve(plan.file, plan.args, plan.env); // does not return when it works
29
+ } catch {
30
+ // not allowed here (sandbox, odd node build): start the normal way, path and all
31
+ } finally {
32
+ process.emitWarning = emitWarning;
33
+ }
34
+ }
35
+ }
package/gu-repl.mjs CHANGED
@@ -23,6 +23,7 @@
23
23
  * Requires the worker daemon to be running (it claims the job).
24
24
  * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
+ import { RELAUNCHED } from "./gu-relaunch.mjs"; // FIRST: may replace this process (#358)
26
27
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
28
  import { readStepTokens, readTurnCache } from "./step-tokens.mjs";
28
29
  import { snapshotInput, restoreInput, replaceStoppedQuestion, restoredQuestionLetGo, stopNoteLines } from "./turn-stop.mjs";
@@ -14571,3 +14572,10 @@ function releaseScreenAtExit() {
14571
14572
  }
14572
14573
 
14573
14574
  main();
14575
+ // Relaunched as `gu` (gu-relaunch.mjs), this file is Node's --import preload, and Node opens its
14576
+ // own REPL on the terminal once the preload finishes — so it does not finish. When nothing is
14577
+ // left to do, gu exits with its own code instead of Node's "unsettled top-level await" (13).
14578
+ if (RELAUNCHED) {
14579
+ process.on("beforeExit", () => process.exit(process.exitCode ?? 0));
14580
+ await new Promise(() => {});
14581
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.822",
3
+ "version": "1.0.824",
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",
@@ -116,6 +116,7 @@
116
116
  "coding-key-prompt.mjs",
117
117
  "model-fallback.mjs",
118
118
  "terminal-title.mjs",
119
+ "gu-relaunch.mjs",
119
120
  "server-keys.mjs",
120
121
  "add-server.mjs",
121
122
  "powershell-alias.mjs",
@@ -22,6 +22,8 @@
22
22
  * the bytes.
23
23
  */
24
24
 
25
+ import { pathToFileURL } from "node:url";
26
+
25
27
  /** What fits on a tab next to its neighbours. Terminals clip a title; they do not wrap it. */
26
28
  export const TITLE_MAX = 60;
27
29
 
@@ -77,6 +79,56 @@ export function idleTitle(folder) {
77
79
  return name ? `gu · ${trimTitle(name, TITLE_MAX - 5)}` : "gu";
78
80
  }
79
81
 
82
+ /**
83
+ * THE PATH AFTER THE TITLE (#358). macOS Terminal (and iTerm) put the running process and its
84
+ * arguments next to the title gu sets: `bb3d — gu · bb3d — node /opt/homebrew/Cellar/node/…/bin/gu`.
85
+ * The kernel builds that argv from the `#!/usr/bin/env node` line, so gu cannot change it in
86
+ * place — `process.title` blanks the argv area and Terminal then shows the first ENVIRONMENT
87
+ * variable instead (`gu TMPDIR=/var/folders/…`), and a child process shows as `gu ◂ node …`.
88
+ *
89
+ * What does work: replace the process (process.execve, Node ≥ 23.11) with node whose whole argv
90
+ * is `gu` and which loads this script as an --import preload. Terminal then shows `gu`. The
91
+ * user's flags travel in an env var, and restoreAfterRelaunch puts process.argv back the way
92
+ * it was, so nothing after it can tell. Node starts its own REPL once a preload finishes, so
93
+ * the relaunched gu-repl.mjs never finishes (see its last lines).
94
+ *
95
+ * Only for the plain interactive `gu` on macOS: a subcommand, --help/--version, a pipe (node
96
+ * would read a PROGRAM from a non-terminal stdin), Windows and Linux keep the old start.
97
+ * GU_NO_RELAUNCH=1 turns it off.
98
+ */
99
+ export const RELAUNCH_ENV = "GU_RELAUNCHED";
100
+
101
+ /** → { file, args, env } for process.execve, or null to start the normal way. */
102
+ export function relaunchPlan({ platform, hasExecve, env = {}, argv = [], execPath, script, stdinTTY, stdoutTTY }) {
103
+ if (platform !== "darwin" || !hasExecve || !stdinTTY || !stdoutTTY || !execPath || !script) return null;
104
+ if (env[RELAUNCH_ENV] != null || env.GU_NO_RELAUNCH === "1") return null;
105
+ const args = argv.slice(2);
106
+ if (args.some((a) => !a.startsWith("-") || a === "--help" || a === "-h" || a === "--version")) return null;
107
+ const state = JSON.stringify({ script, args, nodeOptions: env.NODE_OPTIONS ?? null });
108
+ // A file URL has no spaces (%20) and no quotes, so it is one NODE_OPTIONS word as it stands.
109
+ const nodeOptions = [`--import=${pathToFileURL(script).href}`, env.NODE_OPTIONS].filter(Boolean).join(" ");
110
+ return { file: execPath, args: ["gu"], env: { ...env, [RELAUNCH_ENV]: state, NODE_OPTIONS: nodeOptions } };
111
+ }
112
+
113
+ /**
114
+ * In the relaunched process: put process.argv and NODE_OPTIONS back, and take the marker out of
115
+ * the environment — every process gu starts (the worker, the agent's commands) inherits it, and
116
+ * a NODE_OPTIONS that preloads gu-repl.mjs would start a gu inside each node they run.
117
+ * → true when this process is the relaunched one.
118
+ */
119
+ export function restoreAfterRelaunch(proc) {
120
+ const raw = proc.env[RELAUNCH_ENV];
121
+ if (raw == null) return false;
122
+ delete proc.env[RELAUNCH_ENV];
123
+ let s;
124
+ try { s = JSON.parse(raw); } catch { return false; }
125
+ if (proc.argv.length !== 1 || !s?.script) return false; // a stale marker, not our relaunch
126
+ if (s.nodeOptions == null) delete proc.env.NODE_OPTIONS;
127
+ else proc.env.NODE_OPTIONS = s.nodeOptions;
128
+ proc.argv.splice(0, proc.argv.length, proc.execPath, s.script, ...(s.args ?? []));
129
+ return true;
130
+ }
131
+
80
132
  /** Where the agent model answers chat, from a base that may or may not already end in /v1. */
81
133
  export function chatEndpoint(url) {
82
134
  const root = String(url ?? "").trim().replace(/\/+$/, "");