@tiens.nguyen/gu-cli 1.0.747 → 1.0.749

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:
@@ -7994,16 +8020,24 @@ def run_agent_chat(cfg):
7994
8020
  # ORDER to use them in and the CONSTRAINTS that bite — so keep those, drop the third
7995
8021
  # retelling. Every rule below is a scar: token waste (#63/#79), no-shell/no-sudo,
7996
8022
  # key-only deploys (#57).
7997
- " THE EDIT LOOP: grep_repo to LOCATE → read_file_lines around the hit (a SMALL "
7998
- "range; never re-read the whole file or the same range twice — large reads are "
7999
- "truncated and slow every later step) → edit_lines to change it → run_command to "
8000
- "VERIFY. edit_file only for a byte-exact unique string; create_file(overwrite=True) "
8023
+ # Task #48 (GitHub): string edits are the default, like Claude Code's Edit tool. A
8024
+ # line-number edit needs a fresh read before every edit (each one shifts the numbers):
8025
+ # a measured turn spent 12 of its 43 steps on read → edit_lines → read → edit_lines.
8026
+ " THE EDIT LOOP: LOCATE (explore, grep_repo) → read_file_lines ONCE around what you will "
8027
+ "change (a SMALL range; never re-read the whole file or the same range twice) → make ALL "
8028
+ "of that file's edits in ONE block with edit_file(path, old_string, new_string), quoting "
8029
+ "a short UNIQUE snippet from what you read — no line numbers, so no re-read between "
8030
+ "edits → VERIFY with run_command (in the same block when you can). edit_lines only when "
8031
+ "you cannot quote a unique string (e.g. replacing a long range); create_file(overwrite=True) "
8001
8032
  "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"
8033
+ " EXPLORE FIRST — before CHANGING code you have not seen yet in this conversation "
8034
+ "(always for a change that spans several files or layers — frontend + backend, UI + API), "
8035
+ "and for any broad question (where is X handled, how does Y work, what would a change to Z "
8036
+ "touch): call explore(question) ONCE to map the files and lines involved. It searches in "
8037
+ "a SEPARATE context and returns a short summary with path:line references, so its greps "
8038
+ "and reads do not pile up here and are not re-sent on every later step. Then read only "
8039
+ "the exact ranges you will edit. Use grep_repo/read_file_lines directly when you already "
8040
+ "know the file or the exact string.\n"
8007
8041
  " run_command CHAINS: put dependent commands in ONE call with && — e.g. "
8008
8042
  "run_command(\"cd app && ./gradlew assembleDebug && adb install -r app/build/outputs/apk/debug/app-debug.apk\") "
8009
8043
  "— they run in order, `cd` sets the folder for the rest, and the first failure stops the "
@@ -8043,12 +8077,11 @@ def run_agent_chat(cfg):
8043
8077
  "- user asks to FIX A BUG / MODIFY / ADD CODE in a workspace -> "
8044
8078
  "grep_repo(pattern) when you know the exact string, or rag_search_workspace(query) "
8045
8079
  "when you do not, to FIND the file, THEN "
8046
- "read_file_lines(file, around the hit) to see exact lines, THEN "
8047
- "edit_lines(file, start, end, new_content) to change them, THEN "
8080
+ "read_file_lines(file, around the hit) ONCE, THEN edit_file(file, old_string, new_string) "
8081
+ "for EACH change to that file — all in ONE block —, THEN "
8048
8082
  "run_command('…test…') to VERIFY if run permission exists — read failures and fix, "
8049
8083
  "iterate. Finish with final_answer summarizing WHAT you changed (files + lines). "
8050
- "AFTER editing, re-read with grep_repo/read_file_lines (an indexed knowledge base may be "
8051
- "stale — do not trust rag_search for freshly edited code).\n"
8084
+ "Do not trust rag_search for freshly edited code (the index may be stale).\n"
8052
8085
  ) if _WS_AVAILABLE else ""
8053
8086
  _email_choose_line = (
8054
8087
  "- 'email …' / 'send an email to …' -> send_email(to, subject, body) "
@@ -8232,7 +8265,9 @@ def run_agent_chat(cfg):
8232
8265
  # the cached reference above. Workspace/coding mode only.
8233
8266
  + ("Remember: FINISH what was asked yourself — fix blockers, VERIFY the outcome, then "
8234
8267
  "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"
8268
+ "(create a task, a note, a plan), recording it IS the task — do not start the work. "
8269
+ "To change code you have not seen yet — especially across several files — map it with "
8270
+ "ONE explore() call first, then make each file's edits in ONE block with edit_file.\n"
8236
8271
  if _WS_AVAILABLE else "")
8237
8272
  + "\nTASK (answer THIS, choose the tool that fits it):\n"
8238
8273
  f"{task_text}\n"
@@ -9142,38 +9177,47 @@ def run_agent_chat(cfg):
9142
9177
  # context is not big enough for that trade to pay, so history is left ALONE and
9143
9178
  # every step appends cleanly onto a cached prefix.
9144
9179
  if _steps and len(_steps) > 2 and _trim_due(_mem_chars, _trim_state):
9145
- _trim_n, _trim_saved = 0, 0
9180
+ # PLAN FIRST, then apply only if it pays back its cache cost (task #279): a
9181
+ # trim rewrites the history into the cache, and one that frees little costs
9182
+ # more than it saves. Each edit is (object, attribute, new value, chars freed).
9183
+ _plan = []
9146
9184
  for _st in _steps[:-2]:
9147
9185
  _obs = getattr(_st, "observations", None)
9148
9186
  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)
9187
+ _new = (_obs[:400].rstrip()
9188
+ + "\n…[earlier observation trimmed to save context — "
9189
+ "re-read the file/dir with a small range if you still need it]")
9190
+ _plan.append((_st, "observations", _new, len(_obs) - len(_new)))
9156
9191
  _mo = getattr(_st, "model_output", None)
9157
9192
  if isinstance(_mo, str) and len(_mo) > 700:
9158
- _before = len(_mo)
9159
9193
  # Keep the Thought (everything before the code block); the code is
9160
9194
  # redundant with the observation that already came back.
9161
9195
  _head = re.split(r"<code[\s>]|```", _mo, maxsplit=1)[0].rstrip()
9162
9196
  _kept = (_head[:500].rstrip() if _head else _mo[:300].rstrip())
9163
9197
  _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:
9198
+ if len(_new) < len(_mo):
9199
+ _plan.append((_st, "model_output", _new, len(_mo) - len(_new)))
9200
+ # The SAME code, a second time: smolagents keeps the parsed code action in
9201
+ # step.tool_calls and renders it as its own message on every request, so
9202
+ # stripping <code> from model_output above saves nothing on its own. See
9203
+ # _trim_tool_call_args for why trimming the duplicate is safe.
9204
+ for _tc in (getattr(_st, "tool_calls", None) or []):
9205
+ _a = getattr(_tc, "arguments", None)
9206
+ if isinstance(_a, str) and len(_a) > 300:
9207
+ _new = _a[:300].rstrip() + _CODE_TRIMMED_MARK
9208
+ if len(_new) < len(_a):
9209
+ _plan.append((_tc, "arguments", _new, len(_a) - len(_new)))
9210
+ _freed = sum(p[3] for p in _plan)
9211
+ _trim_n, _trim_saved = 0, 0
9212
+ if _trim_pays_back(_mem_chars, _freed):
9213
+ for _obj, _attr, _new, _gain in _plan:
9214
+ setattr(_obj, _attr, _new)
9175
9215
  _trim_n += 1
9176
- _trim_saved += _tc_saved
9216
+ _trim_saved += _gain
9217
+ else:
9218
+ _log(f"context trim skipped: would free ~{_freed} of {_mem_chars} chars — "
9219
+ f"repays its cache rewrite only after ~{_trim_payback_steps(_mem_chars, _freed):.0f} "
9220
+ f"steps (> {_TRIM_PAYBACK_STEPS})")
9177
9221
  # Arm the next pass off the size we actually reached, so a big win buys a
9178
9222
  # long quiet period and a small one cannot re-fire next step.
9179
9223
  _note_trimmed(_step_memory_chars(_steps), _trim_state)
@@ -11563,7 +11607,7 @@ def run_agent_chat(cfg):
11563
11607
 
11564
11608
  @tool
11565
11609
  def edit_lines(path: str, start_line: int, end_line: int, new_content: str) -> str:
11566
- """Replace lines start_line..end_line (inclusive, 1-based) of a workspace file with new_content. The PRIMARY edit tool — get exact line numbers from grep_repo/read_file_lines first. A backup is saved automatically.
11610
+ """Replace lines start_line..end_line (inclusive, 1-based) of a workspace file with new_content. Use it only when you cannot quote a unique string for edit_file (e.g. replacing a long range). Line numbers shift after every edit, so re-read before another edit_lines in the same file. A backup is saved automatically.
11567
11611
 
11568
11612
  Args:
11569
11613
  path: file inside a registered workspace.
@@ -11620,7 +11664,7 @@ def run_agent_chat(cfg):
11620
11664
 
11621
11665
  @tool
11622
11666
  def edit_file(path: str, old_string: str, new_string: str) -> str:
11623
- """Replace an EXACT unique text occurrence in a workspace file (must match byte-for-byte incl. whitespace). Prefer edit_lines when you have line numbers. A backup is saved automatically.
11667
+ """The PRIMARY edit tool: replace an EXACT, unique text occurrence in a workspace file (byte-for-byte, incl. whitespace). It needs no line numbers, so several edit_file calls to one file go in ONE block after a single read. A backup is saved automatically.
11624
11668
 
11625
11669
  Args:
11626
11670
  path: file inside a registered workspace.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.747",
3
+ "version": "1.0.749",
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",