@tiens.nguyen/gu-cli 1.0.836 → 1.0.838

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.
@@ -10,6 +10,9 @@
10
10
  // host.tabs() → [{id, title, url, current}] host.switchTo(tabId) → boolean
11
11
  // host.info(tabId) → {url, title, status} | null host.activate(tabId)
12
12
  // host.close(tabId)
13
+ // host.runsJs — true only where running gu's own JavaScript text is allowed (gu's own window).
14
+ // The Chrome Web Store extension leaves it unset: text sent at run time and executed is
15
+ // "remote code", which a Manifest V3 store extension may not run (#377, GitHub #164).
13
16
  // No chrome.* and no Node API in here: it runs in an extension service worker and in Node.
14
17
 
15
18
  const consoleBuf = new Map(); // tabId → [{level, text}] since the last "console" read
@@ -305,6 +308,11 @@ export async function runAction(cmd, host) {
305
308
  await sleep(Math.max(0, Math.min(Number(cmd.seconds) || 1, 30)) * 1000);
306
309
  return after(host, id);
307
310
  case "js": {
311
+ if (!host.runsJs) {
312
+ throw new Error("js is not available through the gu Chrome extension (Chrome Web Store rules: " +
313
+ "it runs only the code it ships with). Use read, console, click and type instead — or gu's own " +
314
+ "Chrome window, which has it.");
315
+ }
308
316
  const value = await evaluate(host, id, String(cmd.text || ""));
309
317
  return after(host, id, { value: value === undefined ? "undefined" : JSON.stringify(value)?.slice(0, 8000) });
310
318
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "gu browser",
4
- "version": "1.0.1",
4
+ "version": "1.0.4",
5
5
  "description": "Lets the gu coding agent open, see and operate web pages in its own tabs (tab group \"gu\") with a real mouse and keyboard.",
6
6
  "minimum_chrome_version": "116",
7
7
  "icons": {
@@ -17,9 +17,6 @@
17
17
  "alarms",
18
18
  "storage"
19
19
  ],
20
- "host_permissions": [
21
- "<all_urls>"
22
- ],
23
20
  "background": {
24
21
  "service_worker": "background.js",
25
22
  "type": "module"
@@ -2226,6 +2226,41 @@ def _announces_next_step(stripped: str) -> bool:
2226
2226
  return bool(_NEXT_STEP_RE.search(last)) and not _HANDED_TO_USER_RE.search(last)
2227
2227
 
2228
2228
 
2229
+ # A SENTENCE OF INTENT WITH NO CODE (#379, GitHub #163), measured: after a failed `cd` the model
2230
+ # replied "The shell was already in `frontend`, so `cd frontend` failed. Run without the cd." and
2231
+ # then "I forgot the code block. Run the tests with the correct workdir." — no code either time,
2232
+ # two failed steps (~94k input tokens). Neither says "let me" / "I'll", so _announces_next_step
2233
+ # missed them. A SHORT reply whose last sentence tells ITSELF to act (an imperative, not addressed
2234
+ # to "you"), or that admits leaving the code out, is a plan. A short report of finished work
2235
+ # ("Done. Run `npm start` to see it.") is not.
2236
+ _SELF_ACTION_RE = re.compile(
2237
+ r"^(?:(?:now|so|then|next|ok(?:ay)?),?\s+)?(?:run|re-?run|retry|try|check|read|open|call|use|"
2238
+ r"fix|edit|update|test|execute|search|grep|list|look|install|build|verify|inspect|add|remove|"
2239
+ r"stage|commit|push|create|write|redo|repeat|drop|skip|pass|set)\b", re.I)
2240
+ _SELF_FIX_RE = re.compile(
2241
+ r"\bI\s+(?:forgot|missed|left\s+out|omitted|didn't\s+include|did\s+not\s+include)\b[^.]*\b(?:code|block)\b"
2242
+ r"|\bno\s+code\s+block\b", re.I)
2243
+ _FINISHED_RE = re.compile(
2244
+ r"\b(?:done|committed|pushed|fixed|created|deployed|merged|finished|completed|all set)\b|✅", re.I)
2245
+
2246
+
2247
+ def _short_self_directive(stripped: str) -> bool:
2248
+ """A short, code-less reply that ends by telling itself what to do, or admits it left the
2249
+ code out. PURE, so the measured replies can be replayed."""
2250
+ t = (stripped or "").strip()
2251
+ if not t or len(t) > 300 or "\n\n" in t:
2252
+ return False
2253
+ if _SELF_FIX_RE.search(t):
2254
+ return True
2255
+ sentences = [x.strip() for x in re.split(r"(?<=[.!?])\s+", t) if x.strip()]
2256
+ if not sentences or len(sentences) > 3:
2257
+ return False
2258
+ last = sentences[-1]
2259
+ if re.search(r"\byou(?:r)?\b", last, re.I) or _FINISHED_RE.search(t):
2260
+ return False
2261
+ return bool(_SELF_ACTION_RE.match(last))
2262
+
2263
+
2229
2264
  def _looks_like_scratchpad(stripped: str) -> bool:
2230
2265
  """True if `stripped` reads like unresolved internal deliberation rather than a
2231
2266
  finished, user-facing answer — see module comment above for the signals used."""
@@ -2233,6 +2268,8 @@ def _looks_like_scratchpad(stripped: str) -> bool:
2233
2268
  return True
2234
2269
  if _announces_next_step(stripped):
2235
2270
  return True
2271
+ if _short_self_directive(stripped):
2272
+ return True
2236
2273
  return len(_DELIBERATION_RE.findall(stripped)) >= 3
2237
2274
 
2238
2275
 
@@ -9736,6 +9773,62 @@ def _run_state_path() -> str:
9736
9773
  return _RUN_STATE["path"]
9737
9774
 
9738
9775
 
9776
+ _CD_FIRST_RE = re.compile(
9777
+ r"""^\s*(?:cd|pushd|set-location|sl|chdir)\s+(?:-(?:literalpath|path)\s+)?(['"]?)([^'";&|\s]+)\1(?=\s*(?:$|&&|;|\|\||\n))""",
9778
+ re.IGNORECASE)
9779
+
9780
+
9781
+ def _cd_from_root(command: str, kept: str, root: str) -> str:
9782
+ """The folder a line should start in: `kept` (where the last shell line ended), unless the line
9783
+ begins by entering a RELATIVE folder that does not exist there but does at `root` — then
9784
+ `root`. PURE but for isdir. Anything else (absolute, ~, $VAR, `..`, already there) → `kept`."""
9785
+ if not kept or not root or os.path.realpath(kept) == os.path.realpath(root):
9786
+ return kept
9787
+ m = _CD_FIRST_RE.match(command or "")
9788
+ if not m:
9789
+ return kept
9790
+ target = m.group(2)
9791
+ if os.path.isabs(target) or target.startswith(("~", "$", ".")) or re.match(r"^[A-Za-z]:", target):
9792
+ return kept
9793
+ if os.path.isdir(os.path.join(kept, target)) or not os.path.isdir(os.path.join(root, target)):
9794
+ return kept
9795
+ return root
9796
+
9797
+
9798
+ def _patch_dotted_imports() -> bool:
9799
+ """`import urllib.request` in the agent's sandbox binds the name `urllib` (#378, GitHub #162).
9800
+
9801
+ smolagents 1.26's evaluate_import stores `import a.b` under the literal key "a.b" and never binds
9802
+ `a`, so `urllib.request.urlopen(…)` — urllib.request is an allowed import here — fails with
9803
+ "module 'urllib.error' has no attribute 'request'". Measured: three identical failing steps in
9804
+ one deploy turn before the model gave up and used curl. Python binds the TOP package for a
9805
+ dotted import; this does the same, and only when that top package is itself an allowed import
9806
+ (so `import os.path` stays refused). Idempotent. False when smolagents is not there."""
9807
+ try:
9808
+ import ast as _ast
9809
+ from importlib import import_module as _imp
9810
+ from smolagents import local_python_executor as _lpe
9811
+ except Exception: # noqa: BLE001
9812
+ return False
9813
+ if getattr(_lpe.evaluate_import, "_gu_dotted", False):
9814
+ return True
9815
+ orig = _lpe.evaluate_import
9816
+
9817
+ def evaluate_import(expression, state, authorized_imports):
9818
+ r = orig(expression, state, authorized_imports)
9819
+ if isinstance(expression, _ast.Import):
9820
+ for alias in expression.names:
9821
+ if not alias.asname and "." in alias.name:
9822
+ top = alias.name.split(".")[0]
9823
+ if _lpe.check_import_authorized(top, authorized_imports):
9824
+ state[top] = _lpe.get_safe_module(_imp(top), authorized_imports)
9825
+ return r
9826
+
9827
+ evaluate_import._gu_dotted = True
9828
+ _lpe.evaluate_import = evaluate_import
9829
+ return True
9830
+
9831
+
9739
9832
  def _run_state_cwd() -> str:
9740
9833
  """The folder the last shell line ended in, this turn — or ""."""
9741
9834
  p = _RUN_STATE["path"]
@@ -10069,6 +10162,7 @@ def run_agent_chat(cfg):
10069
10162
  f"then: gu stop && gu start"})
10070
10163
  _log(f"smolagents import FAILED on {_where}: {e}")
10071
10164
  return
10165
+ _patch_dotted_imports()
10072
10166
 
10073
10167
  messages = cfg.get("messages") or []
10074
10168
  _turn_image_refs = _take_image_markers(messages)
@@ -13755,11 +13849,12 @@ def run_agent_chat(cfg):
13755
13849
 
13756
13850
  def _sanitize_parse_errors(self, msgs):
13757
13851
  """Swap smolagents' code-parsing error text for a delimiter-free directive
13758
- (see _PARSE_ERROR_REPLACEMENT). No-op unless the backend is kind=openai, and
13852
+ (see _PARSE_ERROR_REPLACEMENT), on every backend; a
13759
13853
  no-op on every other kind of message — genuine SyntaxError feedback from a
13760
13854
  block that DID run is left intact, since that is real information."""
13761
- if not self._openai_backend:
13762
- return
13855
+ # Every backend now (#379, GitHub #163): ollama.com is an "ollama" backend, kept the
13856
+ # stock text, and its model answered it twice more without code. gu runs strong
13857
+ # models only (2026-09-25), the reason the CODE-NOW retry went ungated in #139.
13763
13858
  n = 0
13764
13859
  for m in msgs or []:
13765
13860
  if not isinstance(m, dict):
@@ -13776,8 +13871,7 @@ def run_agent_chat(cfg):
13776
13871
  part["text"] = self._PARSE_ERROR_REPLACEMENT
13777
13872
  n += 1
13778
13873
  if n:
13779
- _log(f"#113 replaced {n} code-parsing error message(s) with a "
13780
- "delimiter-free directive (openai backend)")
13874
+ _log(f"#113 replaced {n} code-parsing error message(s) with a direct directive")
13781
13875
 
13782
13876
  def _prepare_completion_kwargs(self, *args, **kwargs):
13783
13877
  ck = super()._prepare_completion_kwargs(*args, **kwargs)
@@ -15189,6 +15283,12 @@ def run_agent_chat(cfg):
15189
15283
  wd = _rag_read_allowed(_kept)
15190
15284
  except Exception: # noqa: BLE001 — left the allowed folders: start over
15191
15285
  wd = _wd_before
15286
+ # `cd sub` AGAIN FROM INSIDE sub (#378, GitHub #161), measured: after
15287
+ # `cd frontend && npx tsc` the shell stayed in frontend, the next line was
15288
+ # `cd frontend && node test…` → "no such folder: frontend", then two text-only
15289
+ # steps before the tests ran — 4 steps for one test run. A line that starts by
15290
+ # entering a folder that is not here but IS at the project root means the root.
15291
+ wd = _cd_from_root(command, wd, _wd_before)
15192
15292
  w = _ws_for_path(wd)
15193
15293
  if w is None or not w.get("allowRun"):
15194
15294
  # Contextual nudge (task #102): the #1 reason the model shells out here is
@@ -15939,7 +16039,7 @@ def run_agent_chat(cfg):
15939
16039
  """Use a REAL Chrome browser like a person does — open a page, see it, click with the mouse, type with the keyboard — to test a UI flow, check a page renders, fill a form or click through an app. The first call asks the user's permission. It uses the user's Chrome through the gu extension when that is installed, otherwise gu's own Chrome window (a separate profile: not logged in to the user's accounts). Loop: open → read (or screenshot) → click / type → read again to check what happened. Only gu's own tabs, and tabs the user shared, are used. If the answer starts BROWSER NOT AVAILABLE, stop and tell the user (the answer says what to do).
15940
16040
 
15941
16041
  Args:
15942
- action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value) · console (the page's console messages since the last call) · tabs · switch (tab) · close
16042
+ action: one of: open (url) · read (page text + visible interactive elements numbered [n] with x,y) · screenshot (an image of the page; pixels = click x,y) · click / double_click / right_click / hover (ref, or x and y) · type (text into the focused field; with ref it clicks that field first) · key (text: "Enter", "Tab", "Escape", "ctrl+a", "shift+Tab"… several separated by spaces) · scroll (direction, amount) · back · reload · wait (seconds) · js (text = a JavaScript expression run in the page; returns its value — gu's own Chrome window only: through the gu Chrome extension it is refused, so prefer read / console) · console (the page's console messages since the last call) · tabs · switch (tab) · close
15943
16043
  url: for open — a full http(s):// or file:// url (localhost:3000 is taken as http://localhost:3000)
15944
16044
  ref: an element's [n] from the last read, for click/hover/type
15945
16045
  x: page x coordinate for click/hover (from read's @x,y or the screenshot), when no ref
@@ -16090,6 +16190,16 @@ def run_agent_chat(cfg):
16090
16190
  "Finish with final_answer(<summary>): the answer in a few sentences, then the "
16091
16191
  "relevant files as `path:start-end — what is there`, one per line. At most "
16092
16192
  "about 250 words; never paste whole files.")
16193
+ # ITS OWN CONTEXT, ITS OWN "ALREADY SHOWN" (#378, GitHub #160). The sub-agent runs the
16194
+ # same tool functions, so its reads went into the turn's re-read guard — and the main
16195
+ # agent was then refused lines it had never been shown ("already in this
16196
+ # conversation"): 3 refused steps in one measured turn, then a whole-file read
16197
+ # (50k chars carried by every later step) to get round it. The guards are swapped
16198
+ # for empty ones while the sub-agent runs, and the main agent's put back after.
16199
+ _guards = (_reads, _read_ranges, _seen_calls)
16200
+ _saved = tuple(dict(g) for g in _guards)
16201
+ for g in _guards:
16202
+ g.clear()
16093
16203
  try:
16094
16204
  with contextlib.redirect_stdout(sys.stderr):
16095
16205
  out = sub.run(sub_task)
@@ -16097,6 +16207,10 @@ def run_agent_chat(cfg):
16097
16207
  msg = f"Error: explore failed: {type(e).__name__}: {_clip(str(e), 200)}"
16098
16208
  _last_obs["text"] = msg
16099
16209
  return msg
16210
+ finally:
16211
+ for g, old in zip(_guards, _saved):
16212
+ g.clear()
16213
+ g.update(old)
16100
16214
  n = sum(1 for s in getattr(sub.memory, "steps", []) if type(s).__name__ == "ActionStep")
16101
16215
  _emit({"type": "step", "text": f"Explored → {n} step(s), in its own context"})
16102
16216
  _log(f"explore: {n} sub-step(s), {len(str(out))} chars back")
package/gu_browser.py CHANGED
@@ -19,7 +19,7 @@ import urllib.request
19
19
  HERE = os.path.dirname(os.path.abspath(__file__))
20
20
  EXT_SRC = os.path.join(HERE, "browser-extension")
21
21
  EXT_FILES = ("manifest.json", "background.js", "actions.js", "popup.html", "popup.js",
22
- "icons/icon16.png", "icons/icon48.png", "icons/icon128.png")
22
+ "icons/icon16.png", "icons/icon32.png", "icons/icon48.png", "icons/icon128.png")
23
23
  BRIDGE = os.path.join(HERE, "browser-bridge.mjs")
24
24
 
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.836",
3
+ "version": "1.0.838",
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",