@tiens.nguyen/gu-cli 1.0.746 → 1.0.748

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.
@@ -2140,8 +2140,34 @@ def _usage_reports_cache(usage) -> bool:
2140
2140
  # against the corrected counter so that the OBSERVED GOOD BEHAVIOUR is preserved: one pass in
2141
2141
  # 86 steps, firing where it fired before. Raising the counter without raising the threshold
2142
2142
  # would fire earlier and more often — the nibbling this whole mechanism exists to stop.
2143
- _TRIM_FIRST_AT_CHARS = 125_000 # ≈ where a real turn's history stood when it last fired
2143
+ _TRIM_FIRST_AT_CHARS = 90_000 # was 125k: a first trim frees most of the history, so it pays
2144
+ # back fast — the payback gate below keeps that honest
2144
2145
  _TRIM_AGAIN_AFTER_CHARS = 60_000 # …and not again until it has grown this much further
2146
+ # A trim REWRITES the history into the prompt cache, so it is only worth it when the smaller
2147
+ # prompt repays that within a few steps (task #279). Prices relative to a normal input token:
2148
+ # Anthropic cache write 1.25x (5-minute TTL), cache read 0.1x. Measured 2026-09-25 on one turn:
2149
+ # trim 1 (69.7k → 28.3k tokens) paid back in ~2 steps; trim 2 (54.8k → 38.6k) needed ~15 and
2150
+ # only 3 followed, so it cost ~18k more than it saved.
2151
+ _TRIM_PAYBACK_STEPS = 5
2152
+ _CACHE_WRITE_X = 1.25
2153
+ _CACHE_READ_X = 0.1
2154
+
2155
+
2156
+ def _trim_payback_steps(history_chars: int, freed_chars: int) -> float:
2157
+ """How many later steps a trim needs to repay its cache cost. inf when it frees nothing.
2158
+
2159
+ After a trim, the history that remains is written to the cache once instead of read
2160
+ (costing WRITE - READ more per token); every later step then reads `freed` fewer tokens
2161
+ (saving READ each). Chars stand in for tokens — only the ratio matters.
2162
+ """
2163
+ if freed_chars <= 0:
2164
+ return float("inf")
2165
+ remaining = max(0, history_chars - freed_chars)
2166
+ return ((_CACHE_WRITE_X - _CACHE_READ_X) * remaining) / (_CACHE_READ_X * freed_chars)
2167
+
2168
+
2169
+ def _trim_pays_back(history_chars: int, freed_chars: int, limit: float = _TRIM_PAYBACK_STEPS) -> bool:
2170
+ return _trim_payback_steps(history_chars, freed_chars) <= limit
2145
2171
 
2146
2172
 
2147
2173
  def _step_memory_chars(steps) -> int:
@@ -7999,11 +8025,13 @@ def run_agent_chat(cfg):
7999
8025
  "truncated and slow every later step) → edit_lines to change it → run_command to "
8000
8026
  "VERIFY. edit_file only for a byte-exact unique string; create_file(overwrite=True) "
8001
8027
  "replaces a whole file in ONE call (no read+edit just to rewrite it).\n"
8002
- " EXPLORE FIRST for a broad question about the code (where is X handled, how does Y "
8003
- "work, what would a change to Z touch): explore(question) searches in a SEPARATE "
8004
- "context and returns a short summary with path:line references — its greps and reads "
8005
- "do not pile up here. Use grep_repo/read_file_lines directly when you already know "
8006
- "the file or the exact string.\n"
8028
+ " EXPLORE FIRST — before CHANGING code you have not seen yet in this conversation, and "
8029
+ "for any broad question (where is X handled, how does Y work, what would a change to Z "
8030
+ "touch): call explore(question) ONCE to map the files and lines involved. It searches in "
8031
+ "a SEPARATE context and returns a short summary with path:line references, so its greps "
8032
+ "and reads do not pile up here and are not re-sent on every later step. Then read only "
8033
+ "the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
8034
+ "know the file or the exact string.\n"
8007
8035
  " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
8008
8036
  "run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
8009
8037
  "— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
@@ -8232,7 +8260,8 @@ def run_agent_chat(cfg):
8232
8260
  # the cached reference above. Workspace/coding mode only.
8233
8261
  + ("Remember: FINISH what was asked yourself — fix blockers, VERIFY the outcome, then "
8234
8262
  "final_answer with evidence; never hand the task back. If it only asks to RECORD work "
8235
- "(create a task, a note, a plan), recording it IS the task — do not start the work.\n"
8263
+ "(create a task, a note, a plan), recording it IS the task — do not start the work. "
8264
+ "To change code you have not seen yet, map it with ONE explore() call first.\n"
8236
8265
  if _WS_AVAILABLE else "")
8237
8266
  + "\nTASK (answer THIS, choose the tool that fits it):\n"
8238
8267
  f"{task_text}\n"
@@ -9142,38 +9171,47 @@ def run_agent_chat(cfg):
9142
9171
  # context is not big enough for that trade to pay, so history is left ALONE and
9143
9172
  # every step appends cleanly onto a cached prefix.
9144
9173
  if _steps and len(_steps) > 2 and _trim_due(_mem_chars, _trim_state):
9145
- _trim_n, _trim_saved = 0, 0
9174
+ # PLAN FIRST, then apply only if it pays back its cache cost (task #279): a
9175
+ # trim rewrites the history into the cache, and one that frees little costs
9176
+ # more than it saves. Each edit is (object, attribute, new value, chars freed).
9177
+ _plan = []
9146
9178
  for _st in _steps[:-2]:
9147
9179
  _obs = getattr(_st, "observations", None)
9148
9180
  if isinstance(_obs, str) and len(_obs) > 900:
9149
- _before = len(_obs)
9150
- _st.observations = (
9151
- _obs[:400].rstrip()
9152
- + "\n…[earlier observation trimmed to save context — "
9153
- "re-read the file/dir with a small range if you still need it]")
9154
- _trim_n += 1
9155
- _trim_saved += _before - len(_st.observations)
9181
+ _new = (_obs[:400].rstrip()
9182
+ + "\n…[earlier observation trimmed to save context — "
9183
+ "re-read the file/dir with a small range if you still need it]")
9184
+ _plan.append((_st, "observations", _new, len(_obs) - len(_new)))
9156
9185
  _mo = getattr(_st, "model_output", None)
9157
9186
  if isinstance(_mo, str) and len(_mo) > 700:
9158
- _before = len(_mo)
9159
9187
  # Keep the Thought (everything before the code block); the code is
9160
9188
  # redundant with the observation that already came back.
9161
9189
  _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
9162
9190
  _kept = (_head[:500].rstrip() if _head else _mo[:300].rstrip())
9163
9191
  _new = _kept + "\n…[code trimmed to save context — its result is in the Observation]"
9164
- if len(_new) < _before:
9165
- _st.model_output = _new
9166
- _trim_n += 1
9167
- _trim_saved += _before - len(_new)
9168
- # The SAME code, a second time: smolagents keeps the parsed code
9169
- # action in step.tool_calls and renders it as its own message on
9170
- # every request, so stripping <code> from model_output above saved
9171
- # nothing on its own. Trim the duplicate too — see
9172
- # _trim_tool_call_args for why this is safe.
9173
- _tc_saved = _trim_tool_call_args(_st, 300)
9174
- if _tc_saved:
9192
+ if len(_new) < len(_mo):
9193
+ _plan.append((_st, "model_output", _new, len(_mo) - len(_new)))
9194
+ # The SAME code, a second time: smolagents keeps the parsed code action in
9195
+ # step.tool_calls and renders it as its own message on every request, so
9196
+ # stripping <code> from model_output above saves nothing on its own. See
9197
+ # _trim_tool_call_args for why trimming the duplicate is safe.
9198
+ for _tc in (getattr(_st, "tool_calls", None) or []):
9199
+ _a = getattr(_tc, "arguments", None)
9200
+ if isinstance(_a, str) and len(_a) > 300:
9201
+ _new = _a[:300].rstrip() + _CODE_TRIMMED_MARK
9202
+ if len(_new) < len(_a):
9203
+ _plan.append((_tc, "arguments", _new, len(_a) - len(_new)))
9204
+ _freed = sum(p[3] for p in _plan)
9205
+ _trim_n, _trim_saved = 0, 0
9206
+ if _trim_pays_back(_mem_chars, _freed):
9207
+ for _obj, _attr, _new, _gain in _plan:
9208
+ setattr(_obj, _attr, _new)
9175
9209
  _trim_n += 1
9176
- _trim_saved += _tc_saved
9210
+ _trim_saved += _gain
9211
+ else:
9212
+ _log(f"context trim skipped: would free ~{_freed} of {_mem_chars} chars — "
9213
+ f"repays its cache rewrite only after ~{_trim_payback_steps(_mem_chars, _freed):.0f} "
9214
+ f"steps (> {_TRIM_PAYBACK_STEPS})")
9177
9215
  # Arm the next pass off the size we actually reached, so a big win buys a
9178
9216
  # long quiet period and a small one cannot re-fire next step.
9179
9217
  _note_trimmed(_step_memory_chars(_steps), _trim_state)
package/gu-repl.mjs CHANGED
@@ -18,10 +18,10 @@
18
18
  * Conversation history is saved to ~/.gonext/sessions/<hash-of-folder-path>.json after
19
19
  * every turn and reloaded on the next `gu` in the SAME folder — so closing the
20
20
  * terminal and coming back later still has context (e.g. "continue" after an
21
- * interrupted investigation actually resumes it). /reset clears it.
21
+ * interrupted investigation actually resumes it). /new clears it.
22
22
  *
23
23
  * Requires the worker daemon to be running (it claims the job).
24
- * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
24
+ * Commands: /exit /model /new /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
27
  import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
@@ -577,11 +577,12 @@ if (cliVerdict.kind === "run") {
577
577
  " -v, --verbose show RAG index-file activity in the bottom bar (each shard written)\n" +
578
578
  " -h, --help this help\n\n" +
579
579
  "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
580
- "Inside the REPL: /exit · /model · /reset · /help — Ctrl-C stops following a turn.\n" +
580
+ "Inside the REPL: /exit · /model · /new · /help — Ctrl-C stops following a turn.\n" +
581
581
  "Questions are enqueued like web questions and executed by the RUNNING\n" +
582
582
  "gu-cli daemon — its terminal shows the full [gu-agent] logs.\n" +
583
583
  "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
584
- "you run `gu` in the same folder; /reset forgets it and starts fresh."
584
+ "you run `gu` in the same folder; /new starts a new conversation and keeps the old one\n" +
585
+ "in ~/.gonext/sessions/archive."
585
586
  );
586
587
  process.exit(0);
587
588
  }
@@ -814,6 +815,9 @@ const COMMANDS = [
814
815
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
815
816
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
816
817
  { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
818
+ // ONE name, /new (user, #278). /reset and /clear still work (HIDDEN_COMMANDS) for habit and
819
+ // for Claude Code users — not to be confused with `gu clear` in a shell, which wipes gu's state.
820
+ { name: "/new", desc: "start a new conversation in this folder — the old one is kept (archived); the folder's settings stay" },
817
821
  { name: "/hooks", usage: "/hooks [list|approve|unapprove]", desc: "hooks: commands gu runs on onStart, onAsk, onToolDone, onTurnEnd — list them, approve this project's" },
818
822
  { name: "/mcp", usage: "/mcp [list|tools|add|remove|enable|disable|approve|allow|disallow|reconnect]", desc: "MCP servers: see which connect, add one (asks step by step), choose which tools run without asking" },
819
823
  { name: "/help", desc: "list these commands" },
@@ -11162,7 +11166,7 @@ async function main() {
11162
11166
  if (history.length > 0) {
11163
11167
  const turns = history.filter((m) => m.role === "user").length;
11164
11168
  console.log(
11165
- wrapGutter(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.`, dim) + "\n"
11169
+ wrapGutter(`Resumed session: ${turns} prior turn(s) from last time in this folder — /new starts a new conversation (this one is kept).`, dim) + "\n"
11166
11170
  );
11167
11171
  }
11168
11172
  // Ctrl-C arrives on ONE of two paths depending on the readline mode: with
@@ -11516,7 +11520,7 @@ async function main() {
11516
11520
  );
11517
11521
  continue;
11518
11522
  }
11519
- if (line === "/reset" || line === "/new") {
11523
+ if (line === "/reset" || line === "/new" || line === "/clear") {
11520
11524
  const kept = history.length;
11521
11525
  const where = await startNewConversation(cwd, history);
11522
11526
  history.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.746",
3
+ "version": "1.0.748",
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",
@@ -2077,8 +2077,8 @@ export const HIDDEN_COMMANDS = [
2077
2077
  "/rag-cloud", // they say — unlisted so /help offers one way into the setting
2078
2078
  "/thought", // the reasoning display stays ON; only the toggle is unlisted
2079
2079
  "/hover", // in-turn mouse capture — behaviour unchanged, just hidden
2080
- "/reset",
2081
- "/new",
2080
+ "/reset", // the old name of /new (#278) — still works, one name is offered
2081
+ "/clear", // Claude Code's name for it — still works, likewise unlisted
2082
2082
  "/reset-output-token-global", // pending a rename to /reset-global-token that also resets
2083
2083
  // the INPUT total — which does not exist yet as a counter
2084
2084
  ];