@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.
- package/browser-extension/actions.js +8 -0
- package/browser-extension/manifest.json +1 -4
- package/gonext_agent_chat.py +120 -6
- package/gu_browser.py +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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"
|
package/gonext_agent_chat.py
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
13762
|
-
|
|
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.
|
|
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",
|