@tiens.nguyen/gu-cli 1.0.893 → 1.0.895
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/agent-deps-preflight.mjs +5 -2
- package/browser-command.mjs +21 -11
- package/client-expert-run.mjs +32 -6
- package/embed-model-command.mjs +5 -5
- package/gonext_agent_chat.py +269 -42
- package/gu-cli.mjs +14 -4
- package/gu-repl.mjs +80 -27
- package/model-doctor.mjs +35 -22
- package/model-fallback.mjs +4 -4
- package/mongo-windows.mjs +63 -0
- package/package.json +2 -1
- package/preset-apply.mjs +1 -1
- package/python-windows.mjs +91 -0
- package/quick-models.mjs +3 -3
- package/terminal-layout.mjs +114 -2
- package/theme.mjs +9 -3
package/agent-deps-preflight.mjs
CHANGED
|
@@ -14,6 +14,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
|
|
17
|
+
// Warnings are yellow text, no icon (GH #378: "⚠" draws as a broken emoji on Windows 11).
|
|
18
|
+
const yellow = (s) => (process.stdout.isTTY && !process.env.NO_COLOR ? `\x1b[33m${s}\x1b[0m` : String(s));
|
|
19
|
+
|
|
17
20
|
export const DECLINED_FILE = join(homedir(), ".gonext", "agent-deps-declined");
|
|
18
21
|
|
|
19
22
|
/** Is this start one that should check? Interactive terminals only, and not when a test or script opts out. Pure. */
|
|
@@ -74,13 +77,13 @@ export async function agentDepsPreflight({
|
|
|
74
77
|
const out = await install();
|
|
75
78
|
const ok = out && (out.agentLibs === "installed" || out.agentLibs === "present");
|
|
76
79
|
if (!ok) {
|
|
77
|
-
log("
|
|
80
|
+
log(yellow(" the agent framework did not install — run `gu-cli deps` to see why. Starting gu anyway."));
|
|
78
81
|
return "failed";
|
|
79
82
|
}
|
|
80
83
|
log(" ✓ the agent framework is installed.\n");
|
|
81
84
|
return "installed";
|
|
82
85
|
} catch (e) {
|
|
83
|
-
log(`
|
|
86
|
+
log(yellow(` ${e instanceof Error ? e.message : e}`) + `\n Starting gu anyway — run \`gu-cli deps\` to retry.`);
|
|
84
87
|
return "failed";
|
|
85
88
|
}
|
|
86
89
|
}
|
package/browser-command.mjs
CHANGED
|
@@ -140,36 +140,46 @@ export function allowIn(folder, allow = true) {
|
|
|
140
140
|
writeFileSync(allowPath(), JSON.stringify({ folders }, null, 2));
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* WHICH LINES ARE WARNINGS, without an icon to say so (GH #378 — "⚠" draws broken on Windows 11).
|
|
145
|
+
* The report stays a plain string array; `.warnings` is the set of its lines the caller paints
|
|
146
|
+
* yellow.
|
|
147
|
+
*/
|
|
148
|
+
function withWarnings(lines, firstN = 0, extra = []) {
|
|
149
|
+
lines.warnings = new Set([...lines.slice(0, firstN), ...extra]);
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
|
|
143
153
|
export function browserReport({ connected, version, installType, refused = [], running = true, error = "" }, { allowed = false, storeUrl = STORE_URL, storeInstalled = false } = {}) {
|
|
144
154
|
// The bridge itself would not start (#170): that is gu's fault, and says so — not "nothing to install".
|
|
145
155
|
if (!running && error) {
|
|
146
|
-
return [
|
|
147
|
-
|
|
148
|
-
"
|
|
156
|
+
return withWarnings([
|
|
157
|
+
`gu's browser bridge did not start: ${error}`,
|
|
158
|
+
"This is a problem in gu, not in Chrome — no browser needs installing.",
|
|
149
159
|
"Update gu: npm install -g @tiens.nguyen/gu-cli@latest — then restart gu.",
|
|
150
160
|
"The bridge's log: ~/.gonext/browser-bridge.log",
|
|
151
|
-
];
|
|
161
|
+
], 2);
|
|
152
162
|
}
|
|
153
163
|
// Two copies installed (the store one next to a developer one): the bridge kept one (#148).
|
|
154
164
|
const twoCopies = refused.length
|
|
155
|
-
? [
|
|
156
|
-
"
|
|
157
|
-
"
|
|
165
|
+
? [`A second gu extension (${refused.map((r) => r.id).join(", ")}) tried to connect and was`,
|
|
166
|
+
"turned away — two copies are installed. Keep one: /browser cleanup removes",
|
|
167
|
+
"the developer (unpacked) copy."]
|
|
158
168
|
: [];
|
|
159
169
|
const here = allowed
|
|
160
170
|
? "In this folder it may, without asking (/browser forget to undo)."
|
|
161
171
|
: "In this folder it asks first (/browser allow to stop asking).";
|
|
162
172
|
if (connected) {
|
|
163
173
|
const kind = installType === "development" ? ", developer — loaded unpacked" : installType === "normal" ? ", Chrome Web Store" : "";
|
|
164
|
-
return [
|
|
174
|
+
return withWarnings([
|
|
165
175
|
`✓ The gu Chrome extension is connected (v${version}${kind}).`,
|
|
166
176
|
"The agent uses your Chrome, in its own tabs (the \"gu\" tab group) and tabs",
|
|
167
177
|
"you share from the extension's icon → Let gu use this tab.",
|
|
168
178
|
here,
|
|
169
179
|
...twoCopies,
|
|
170
|
-
];
|
|
180
|
+
], 0, twoCopies);
|
|
171
181
|
}
|
|
172
|
-
return [
|
|
182
|
+
return withWarnings([
|
|
173
183
|
"Nothing to install. When the agent needs a browser it asks you first, then",
|
|
174
184
|
"opens gu's own Chrome window (a separate profile — not signed in to your",
|
|
175
185
|
"accounts) and works in it with a real mouse and keyboard.",
|
|
@@ -184,7 +194,7 @@ export function browserReport({ connected, version, installType, refused = [], r
|
|
|
184
194
|
: ["To let it use YOUR Chrome (your logins, a tab you share): /browser install"]),
|
|
185
195
|
here,
|
|
186
196
|
...twoCopies,
|
|
187
|
-
];
|
|
197
|
+
], 0, twoCopies);
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
// /browser install — the extension, for the user's own Chrome. The store page when there is one;
|
package/client-expert-run.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { promisify } from "node:util";
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import { localModeNodeProblem } from "./node-version.mjs";
|
|
24
|
-
import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION } from "./mongo-windows.mjs";
|
|
24
|
+
import { downloadMongodExe, startPortableMongod, portableMongoInstalled, mongoWinPaths, mongodCommandLine, MONGO_WIN_VERSION, mongodRuns, isDllNotFoundExit, installVcRuntime, VC_REDIST_URL } from "./mongo-windows.mjs";
|
|
25
25
|
import {
|
|
26
26
|
apiDepsInstalled,
|
|
27
27
|
installApiDeps,
|
|
@@ -415,7 +415,7 @@ export function runInTerminal(cmd, { platform = process.platform, spawnFn = spaw
|
|
|
415
415
|
* administrator prompt. Shown, asked, then done, like the other platforms. If the download fails
|
|
416
416
|
* the winget commands are printed as the way out.
|
|
417
417
|
*/
|
|
418
|
-
export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod } = {}) {
|
|
418
|
+
export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => console.log(l), plan = null, waitFor = null, download = downloadMongodExe, start = startPortableMongod, runs = mongodRuns, installRuntime = installVcRuntime } = {}) {
|
|
419
419
|
const installing = !mongo.installed;
|
|
420
420
|
const paths = mongoWinPaths();
|
|
421
421
|
const { host, port } = mongoHostPort(LOCAL_MONGO_URI);
|
|
@@ -445,6 +445,32 @@ export async function ensureNativeMongoWindows(ask, mongo, { log = (l) => consol
|
|
|
445
445
|
return { ok: false, explained: true };
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
|
+
// A new Windows 11 has no Visual C++ runtime, and mongod.exe then dies at once with 0xC0000135 — started
|
|
449
|
+
// detached, that shows up only as "not answering". Ask mongod itself first, and say the real reason (GH #363).
|
|
450
|
+
const probe = await runs(paths);
|
|
451
|
+
if (!probe.ok && isDllNotFoundExit(probe.code)) {
|
|
452
|
+
log("\n " + red("✗ ") + "MongoDB cannot start: this Windows is missing Microsoft's Visual C++ runtime");
|
|
453
|
+
log(dim(" (mongod.exe stops with 0xC0000135, \"a DLL was not found\"). It is a free Microsoft download, about 25 MB;"));
|
|
454
|
+
log(dim(" installing it asks for administrator permission once. " + VC_REDIST_URL));
|
|
455
|
+
const ok = await readYesNo(ask, " Install the Visual C++ runtime now? [Y/n]: ", true, log);
|
|
456
|
+
if (!ok) {
|
|
457
|
+
log(dim("\n Not installed — install it from the link above, then run gu again."));
|
|
458
|
+
return { ok: false, explained: true };
|
|
459
|
+
}
|
|
460
|
+
log("\n " + bold("Installing the Visual C++ runtime"));
|
|
461
|
+
const vc = await installRuntime({});
|
|
462
|
+
if (!vc.ok) {
|
|
463
|
+
log("\n " + red("✗ ") + `could not install the Visual C++ runtime: ${vc.error ?? "unknown error"}`);
|
|
464
|
+
log(dim(" Install it yourself from " + VC_REDIST_URL + " , then run gu again."));
|
|
465
|
+
return { ok: false, explained: true };
|
|
466
|
+
}
|
|
467
|
+
log(" " + cyan("✓ Visual C++ runtime installed"));
|
|
468
|
+
const again = await runs(paths);
|
|
469
|
+
if (!again.ok) {
|
|
470
|
+
log("\n " + red("✗ ") + "mongod.exe still cannot run" + (again.code != null ? ` (exit code ${again.code})` : "") + ". A restart of Windows may be needed; then run gu again.");
|
|
471
|
+
return { ok: false, explained: true };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
448
474
|
let started;
|
|
449
475
|
try {
|
|
450
476
|
started = await start({ port, paths });
|
|
@@ -813,7 +839,7 @@ export async function askMongoCredentials(ask, askSecret = null) {
|
|
|
813
839
|
return { uri, custom: true };
|
|
814
840
|
}
|
|
815
841
|
if (verdict.unverifiable) {
|
|
816
|
-
console.log("\n " + yellow("
|
|
842
|
+
console.log("\n " + yellow("could not verify: ") + dim(verdict.why));
|
|
817
843
|
if (await readYesNo(ask, " Continue with these credentials anyway? [Y/n]: ", true)) {
|
|
818
844
|
return { uri, custom: true, unverified: true };
|
|
819
845
|
}
|
|
@@ -1399,7 +1425,7 @@ export async function runClientExpertSetup({
|
|
|
1399
1425
|
}
|
|
1400
1426
|
} catch (e) {
|
|
1401
1427
|
// Non-fatal, but say so: a stale daemon is the difference between "set up" and "works".
|
|
1402
|
-
console.log("\n " + yellow("
|
|
1428
|
+
console.log("\n " + yellow("could not restart the background worker: ") + e.message);
|
|
1403
1429
|
console.log(dim(" Run `gu-cli restart` — until you do, turns will be enqueued but"));
|
|
1404
1430
|
console.log(dim(" never picked up, because the running worker is still polling the old API.\n"));
|
|
1405
1431
|
}
|
|
@@ -1544,7 +1570,7 @@ export async function runClientExpertSetup({
|
|
|
1544
1570
|
for (const r of await saveSetupKeys({ workerKey: creds.workerKey, models: chosenModels })) {
|
|
1545
1571
|
console.log(r.ok
|
|
1546
1572
|
? " " + cyan("✓ API key stored") + dim(` for ${r.host} — the server's default key`)
|
|
1547
|
-
: " " + yellow("
|
|
1573
|
+
: " " + yellow("could not store the key") + dim(` for ${r.host} — add it in /model with ctrl+k`));
|
|
1548
1574
|
}
|
|
1549
1575
|
}
|
|
1550
1576
|
if (skipModels) {
|
|
@@ -1588,7 +1614,7 @@ export async function runClientExpertSetup({
|
|
|
1588
1614
|
} catch (e) {
|
|
1589
1615
|
// NOT fatal: everything else works, and the model can be set from the terminal. But say so
|
|
1590
1616
|
// clearly, because the symptom otherwise is the terminal refusing to start.
|
|
1591
|
-
console.log("\n " + yellow("
|
|
1617
|
+
console.log("\n " + yellow("could not seed the model settings: ") + e.message);
|
|
1592
1618
|
console.log(dim(" The terminal may say no agent model is configured. Set one with `/model`,"));
|
|
1593
1619
|
console.log(dim(" or run `gu doctor` to set up a local one.\n"));
|
|
1594
1620
|
state = recordStep(state, "models", "failed", { reason: e.message });
|
package/embed-model-command.mjs
CHANGED
|
@@ -220,8 +220,8 @@ export function embedSaveFields({ url, model }) {
|
|
|
220
220
|
/** The bar's row for a state that is not "on" — "" when RAG is on. One short row (#317). */
|
|
221
221
|
export function embedNotice({ state, model } = {}) {
|
|
222
222
|
if (state === "on") return "";
|
|
223
|
-
if (state === "off" && model) return
|
|
224
|
-
return "
|
|
223
|
+
if (state === "off" && model) return `RAG is switched off — ${model} is set · /embed-model turns it on`;
|
|
224
|
+
return "no embed model — RAG is off · /embed-model";
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
/**
|
|
@@ -233,9 +233,9 @@ export function embedNotice({ state, model } = {}) {
|
|
|
233
233
|
* a RAG tool, or the user opened /embed-model), then the embed row.
|
|
234
234
|
*/
|
|
235
235
|
export function setupNoticeRows({ noModel = false, agentMissing = false, embedMissing = false, embedNeeded = false, embedWhy } = {}) {
|
|
236
|
-
if (noModel) return ["
|
|
236
|
+
if (noModel) return ["coding model not set · /model"];
|
|
237
237
|
return [
|
|
238
|
-
...(agentMissing ? ["
|
|
239
|
-
...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "
|
|
238
|
+
...(agentMissing ? ["agent model not set — using the coding model · /agent-model"] : []),
|
|
239
|
+
...(embedMissing && embedNeeded ? [embedNotice(embedWhy) || "no embed model — RAG is off · /embed-model"] : []),
|
|
240
240
|
];
|
|
241
241
|
}
|
package/gonext_agent_chat.py
CHANGED
|
@@ -2800,7 +2800,7 @@ def _lexical_fallback_search(query: str, files, max_files: int = 8, per_file: in
|
|
|
2800
2800
|
total = 0
|
|
2801
2801
|
for abs_path, _rel in files:
|
|
2802
2802
|
try:
|
|
2803
|
-
with
|
|
2803
|
+
with _open_project_text(abs_path) as fh:
|
|
2804
2804
|
found, hits, lines = set(), 0, []
|
|
2805
2805
|
for i, line in enumerate(fh, 1):
|
|
2806
2806
|
matched = [t for t, rx in rxs if rx.search(line)]
|
|
@@ -5798,11 +5798,23 @@ _RAG_TEXT_EXTS = {
|
|
|
5798
5798
|
".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".yaml", ".yml",
|
|
5799
5799
|
".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", ".sql",
|
|
5800
5800
|
".graphql", ".proto", ".gradle", ".dockerfile", ".tsv", ".csv",
|
|
5801
|
+
# WINDOWS / .NET (#490). This list is also what grep_repo can SEE, so a file type missing
|
|
5802
|
+
# here is invisible to search and RAG alike — on a Windows project that was the scripts,
|
|
5803
|
+
# the project files and half the UI: a .csproj, a web.config, a .xaml view, a .ps1 build
|
|
5804
|
+
# step could not be found by any tool, and nothing said so.
|
|
5805
|
+
".ps1", ".psm1", ".psd1", ".bat", ".cmd", ".vb", ".fs", ".fsx", ".razor", ".cshtml",
|
|
5806
|
+
".vbhtml", ".xaml", ".axaml", ".csproj", ".vbproj", ".fsproj", ".vcxproj", ".sln",
|
|
5807
|
+
".props", ".targets", ".config", ".resx", ".manifest", ".reg",
|
|
5808
|
+
# Other common source types the list never had.
|
|
5809
|
+
".dart", ".scala", ".lua", ".pl", ".pm", ".r", ".ex", ".exs", ".erl", ".hs", ".clj",
|
|
5810
|
+
".m", ".mm", ".tf", ".hcl", ".cmake", ".groovy", ".kts", ".astro", ".prisma",
|
|
5801
5811
|
}
|
|
5802
5812
|
_RAG_SKIP_DIRS = {
|
|
5803
5813
|
"node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
|
|
5804
5814
|
"dist", "build", ".next", ".nuxt", "out", ".cache", "bin", "obj", ".idea",
|
|
5805
5815
|
".gradle", "vendor", "target",
|
|
5816
|
+
# Visual Studio's per-machine cache (IntelliSense DBs, solution state) — never project code.
|
|
5817
|
+
".vs",
|
|
5806
5818
|
# ANOTHER AGENT'S SESSION LOG IS NOT PROJECT STATE. Reported live: asked to "continue",
|
|
5807
5819
|
# the agent listed .claude-memory/, read latest-checkpoint.md, and reasoned from it —
|
|
5808
5820
|
# "a partial session about d365-sharepoint-upload … 25 edits … resume from memory" — as if
|
|
@@ -6480,6 +6492,44 @@ def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
|
|
|
6480
6492
|
return dest_dir, names, False
|
|
6481
6493
|
|
|
6482
6494
|
|
|
6495
|
+
def _sniff_text_encoding(path: str) -> str:
|
|
6496
|
+
"""The encoding to read a project file with: a BOM decides, else UTF-8.
|
|
6497
|
+
|
|
6498
|
+
WINDOWS WRITES UTF-16 (#490). Windows PowerShell 5.1's `>` and Out-File write UTF-16LE, and
|
|
6499
|
+
some Visual Studio / Notepad files are UTF-16 too. Read as UTF-8, every character carries a
|
|
6500
|
+
NUL after it, so no pattern ever matched and the index embedded noise. UTF-8 WITH a BOM
|
|
6501
|
+
(Visual Studio's default for .cs) read fine except line 1, which began with U+FEFF and so
|
|
6502
|
+
never matched `^using`. Only a BOM is trusted: guessing UTF-16 from NUL counts would also
|
|
6503
|
+
"detect" it in binary files.
|
|
6504
|
+
"""
|
|
6505
|
+
try:
|
|
6506
|
+
with open(path, "rb") as fh:
|
|
6507
|
+
head = fh.read(4)
|
|
6508
|
+
except OSError:
|
|
6509
|
+
return "utf-8"
|
|
6510
|
+
if head.startswith(b"\xef\xbb\xbf"):
|
|
6511
|
+
return "utf-8-sig"
|
|
6512
|
+
if head.startswith(b"\xff\xfe") or head.startswith(b"\xfe\xff"):
|
|
6513
|
+
return "utf-16" # the codec reads the BOM and picks LE/BE
|
|
6514
|
+
return "utf-8"
|
|
6515
|
+
|
|
6516
|
+
|
|
6517
|
+
def _open_project_text(path: str):
|
|
6518
|
+
"""Open a project file for READING ONLY, in the encoding its BOM names (see above)."""
|
|
6519
|
+
return open(path, "r", encoding=_sniff_text_encoding(path), errors="replace")
|
|
6520
|
+
|
|
6521
|
+
|
|
6522
|
+
def _rag_wanted_name(fn: str) -> bool:
|
|
6523
|
+
"""Is a file with this NAME one the project tools read (text type, not a lockfile/minified)? Pure.
|
|
6524
|
+
One rule for the RAG walk and both grep engines, so ripgrep and Python see the same files (#380)."""
|
|
6525
|
+
import os
|
|
6526
|
+
ext = os.path.splitext(fn)[1].lower()
|
|
6527
|
+
base = fn.lower()
|
|
6528
|
+
if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
|
|
6529
|
+
return False
|
|
6530
|
+
return not (base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES))
|
|
6531
|
+
|
|
6532
|
+
|
|
6483
6533
|
def _rag_iter_text_files(root: str):
|
|
6484
6534
|
"""Yield (abs_path, rel_path, ext) for indexable text files under root."""
|
|
6485
6535
|
import os
|
|
@@ -6490,10 +6540,7 @@ def _rag_iter_text_files(root: str):
|
|
|
6490
6540
|
dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
|
|
6491
6541
|
for fn in filenames:
|
|
6492
6542
|
ext = os.path.splitext(fn)[1].lower()
|
|
6493
|
-
|
|
6494
|
-
if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
|
|
6495
|
-
continue
|
|
6496
|
-
if base in _RAG_SKIP_FILES or base.endswith(_RAG_SKIP_SUFFIXES):
|
|
6543
|
+
if not _rag_wanted_name(fn):
|
|
6497
6544
|
continue
|
|
6498
6545
|
abs_path = os.path.join(dirpath, fn)
|
|
6499
6546
|
try:
|
|
@@ -6501,7 +6548,181 @@ def _rag_iter_text_files(root: str):
|
|
|
6501
6548
|
continue
|
|
6502
6549
|
except OSError:
|
|
6503
6550
|
continue
|
|
6504
|
-
|
|
6551
|
+
# "/" ON EVERY OS (#490). This is the RAG index's key for the file, and what search
|
|
6552
|
+
# results show. With os.sep it was `src\a.py` on Windows and `src/a.py` elsewhere,
|
|
6553
|
+
# so an index could not be shared between the two, and results printed differently
|
|
6554
|
+
# per machine. Windows accepts "/" in every path the tools take.
|
|
6555
|
+
yield abs_path, os.path.relpath(abs_path, root).replace(os.sep, "/"), ext
|
|
6556
|
+
|
|
6557
|
+
|
|
6558
|
+
# ---- grep: ripgrep when there is one, Python when not (#380) ---------------------------------
|
|
6559
|
+
#
|
|
6560
|
+
# Claude Code searches with ripgrep on every OS, Windows 11 included (#379). The Python walk below
|
|
6561
|
+
# needs nothing installed but decodes every line of every file in Python — slow on a big repo.
|
|
6562
|
+
# So grep_repo asks ripgrep first and keeps Python as the fallback. Both must return THE SAME hits:
|
|
6563
|
+
# the same files (skip dirs, text types, lockfiles, 2 MB cap — ripgrep's .gitignore handling is
|
|
6564
|
+
# OFF, because the Python walk never read .gitignore), the same line format, the same cap, and the
|
|
6565
|
+
# same "an invalid regex is searched as plain text". A regex ripgrep cannot run (Python-only
|
|
6566
|
+
# syntax such as look-around or back-references) falls back to Python instead of failing.
|
|
6567
|
+
|
|
6568
|
+
def _rg_binary(env=None):
|
|
6569
|
+
"""The ripgrep to use, or "" for none. GU_RG=0 turns it off; GU_RG=<path> picks one; then PATH,
|
|
6570
|
+
then gu's own copy in ~/.gonext/bin."""
|
|
6571
|
+
import os
|
|
6572
|
+
import shutil
|
|
6573
|
+
env = os.environ if env is None else env
|
|
6574
|
+
want = (env.get("GU_RG") or "").strip()
|
|
6575
|
+
if want.lower() in ("0", "off", "false", "no"):
|
|
6576
|
+
return ""
|
|
6577
|
+
if want:
|
|
6578
|
+
return want if os.path.isfile(want) and os.access(want, os.X_OK) else ""
|
|
6579
|
+
found = shutil.which("rg", path=env.get("PATH"))
|
|
6580
|
+
if found:
|
|
6581
|
+
return found
|
|
6582
|
+
own = os.path.join(os.path.expanduser("~"), ".gonext", "bin", "rg.exe" if os.name == "nt" else "rg")
|
|
6583
|
+
return own if os.path.isfile(own) and os.access(own, os.X_OK) else ""
|
|
6584
|
+
|
|
6585
|
+
|
|
6586
|
+
def _grep_hit(path: str, line_no: int, line: str) -> str:
|
|
6587
|
+
"""One hit, as both engines print it: ABSOLUTE path (read_file_lines resolves relative paths
|
|
6588
|
+
against the WORKSPACE root, not the search root), line number, the line cut at 200 chars."""
|
|
6589
|
+
return f"{path}:{line_no}: {line.rstrip()[:200]}"
|
|
6590
|
+
|
|
6591
|
+
|
|
6592
|
+
def _grep_hit_order(hit: str):
|
|
6593
|
+
"""Sort key: path, then line number as a number (so :9 comes before :10)."""
|
|
6594
|
+
m = re.match(r"^(.*?):(\d+): ", hit)
|
|
6595
|
+
return (m.group(1), int(m.group(2))) if m else (hit, 0)
|
|
6596
|
+
|
|
6597
|
+
|
|
6598
|
+
def _grep_compile(pattern: str):
|
|
6599
|
+
"""→ (compiled regex, literal?). An invalid regex is searched as plain text."""
|
|
6600
|
+
try:
|
|
6601
|
+
return re.compile(pattern), False
|
|
6602
|
+
except re.error:
|
|
6603
|
+
return re.compile(re.escape(pattern)), True
|
|
6604
|
+
|
|
6605
|
+
|
|
6606
|
+
def _grep_python(root: str, pattern: str, glob: str = "", limit: int = 100) -> list:
|
|
6607
|
+
import fnmatch
|
|
6608
|
+
import os
|
|
6609
|
+
rx, _ = _grep_compile(pattern)
|
|
6610
|
+
hits = []
|
|
6611
|
+
for abs_path, rel, _ext in _rag_iter_text_files(root):
|
|
6612
|
+
if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) and not fnmatch.fnmatch(rel, glob):
|
|
6613
|
+
continue
|
|
6614
|
+
try:
|
|
6615
|
+
with _open_project_text(abs_path) as fh:
|
|
6616
|
+
for i, line in enumerate(fh, 1):
|
|
6617
|
+
if rx.search(line):
|
|
6618
|
+
hits.append(_grep_hit(abs_path, i, line))
|
|
6619
|
+
if len(hits) >= limit:
|
|
6620
|
+
return hits
|
|
6621
|
+
except OSError:
|
|
6622
|
+
continue
|
|
6623
|
+
return hits
|
|
6624
|
+
|
|
6625
|
+
|
|
6626
|
+
def _grep_rg_args(rg: str, root: str, pattern: str, literal: bool) -> list:
|
|
6627
|
+
"""The ripgrep command line that selects the files the Python walk would. Pure."""
|
|
6628
|
+
args = [rg, "--no-config", "--no-ignore", "--hidden", "--no-messages", "--null", "--line-number",
|
|
6629
|
+
"--with-filename", "--no-heading", "--color=never", "--max-filesize", str(_RAG_MAX_FILE_BYTES)]
|
|
6630
|
+
# No --sort: it makes ripgrep single-threaded — measured 12 s instead of 2.3 s over 85 GB of
|
|
6631
|
+
# projects. The hits that come back are sorted instead; past the cap, which 100 is "first
|
|
6632
|
+
# found", as it always was with the Python walk (directory order).
|
|
6633
|
+
if literal:
|
|
6634
|
+
args.append("--fixed-strings")
|
|
6635
|
+
for d in sorted(_RAG_SKIP_DIRS):
|
|
6636
|
+
args += ["--glob", f"!{d}/"]
|
|
6637
|
+
for e in sorted(_RAG_TEXT_EXTS):
|
|
6638
|
+
args += ["--iglob", f"*{e}"]
|
|
6639
|
+
for n in ("dockerfile", "makefile", ".gitignore"):
|
|
6640
|
+
args += ["--iglob", n]
|
|
6641
|
+
return args + ["--regexp", pattern, "--", root]
|
|
6642
|
+
|
|
6643
|
+
|
|
6644
|
+
def _grep_rg(rg: str, root: str, pattern: str, glob: str = "", limit: int = 100, timeout: float = 60.0):
|
|
6645
|
+
"""→ hits, or None when ripgrep could not do it (missing, crashed, or a regex it does not support)
|
|
6646
|
+
so the caller falls back to Python. The file list is filtered by the SAME rule as the walk."""
|
|
6647
|
+
import fnmatch
|
|
6648
|
+
import os
|
|
6649
|
+
import subprocess
|
|
6650
|
+
import time
|
|
6651
|
+
_rx, literal = _grep_compile(pattern)
|
|
6652
|
+
single_file = os.path.isfile(root)
|
|
6653
|
+
try:
|
|
6654
|
+
proc = subprocess.Popen(_grep_rg_args(rg, root, pattern, literal), stdout=subprocess.PIPE,
|
|
6655
|
+
stderr=subprocess.PIPE, stdin=subprocess.DEVNULL)
|
|
6656
|
+
except OSError:
|
|
6657
|
+
return None
|
|
6658
|
+
hits = []
|
|
6659
|
+
started = time.monotonic()
|
|
6660
|
+
try:
|
|
6661
|
+
for raw in proc.stdout:
|
|
6662
|
+
name, sep, rest = raw.partition(b"\0")
|
|
6663
|
+
if not sep:
|
|
6664
|
+
continue
|
|
6665
|
+
num, _, text = rest.partition(b":")
|
|
6666
|
+
path = os.path.normpath(name.decode("utf-8", "surrogateescape"))
|
|
6667
|
+
if not single_file:
|
|
6668
|
+
base = os.path.basename(path)
|
|
6669
|
+
if not _rag_wanted_name(base):
|
|
6670
|
+
continue
|
|
6671
|
+
if glob:
|
|
6672
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
6673
|
+
if not fnmatch.fnmatch(base, glob) and not fnmatch.fnmatch(rel, glob):
|
|
6674
|
+
continue
|
|
6675
|
+
try:
|
|
6676
|
+
line_no = int(num)
|
|
6677
|
+
except ValueError:
|
|
6678
|
+
continue
|
|
6679
|
+
hits.append(_grep_hit(path, line_no, text.decode("utf-8", "replace")))
|
|
6680
|
+
if len(hits) >= limit or time.monotonic() - started > timeout:
|
|
6681
|
+
break
|
|
6682
|
+
finally:
|
|
6683
|
+
if proc.poll() is None:
|
|
6684
|
+
proc.kill()
|
|
6685
|
+
try:
|
|
6686
|
+
err = proc.stderr.read().decode("utf-8", "replace")
|
|
6687
|
+
except Exception: # noqa: BLE001
|
|
6688
|
+
err = ""
|
|
6689
|
+
proc.wait()
|
|
6690
|
+
proc.stdout.close()
|
|
6691
|
+
proc.stderr.close()
|
|
6692
|
+
# 0 = hits, 1 = none; 2 = an error. Killed on purpose after the cap → keep what we have.
|
|
6693
|
+
if proc.returncode == 2 and not hits:
|
|
6694
|
+
_log(f"grep: ripgrep could not search ({err.strip()[:200]}) — using the Python search")
|
|
6695
|
+
return None
|
|
6696
|
+
return sorted(hits, key=_grep_hit_order)
|
|
6697
|
+
|
|
6698
|
+
|
|
6699
|
+
def _grep_search(root: str, pattern: str, glob: str = "", limit: int = 100, env=None):
|
|
6700
|
+
"""→ (hits, engine) with engine "ripgrep" or "python"."""
|
|
6701
|
+
rg = _rg_binary(env)
|
|
6702
|
+
if rg:
|
|
6703
|
+
hits = _grep_rg(rg, root, pattern, glob, limit)
|
|
6704
|
+
if hits is not None:
|
|
6705
|
+
return hits, "ripgrep"
|
|
6706
|
+
return _grep_python(root, pattern, glob, limit), "python"
|
|
6707
|
+
|
|
6708
|
+
|
|
6709
|
+
def _rag_posix_chunk(c: dict, sep: str = "") -> dict:
|
|
6710
|
+
"""Move a chunk from an index built with `\\` paths (Windows, before #490) to `/` paths.
|
|
6711
|
+
|
|
6712
|
+
In place and without re-embedding: only the file key and the location line at the top of
|
|
6713
|
+
its text change, so an existing Windows index keeps its vectors instead of re-embedding
|
|
6714
|
+
the whole project once. A no-op off Windows, where `\\` can be part of a real file name.
|
|
6715
|
+
"""
|
|
6716
|
+
if (sep or os.sep) != "\\":
|
|
6717
|
+
return c
|
|
6718
|
+
f = c.get("file")
|
|
6719
|
+
if isinstance(f, str) and "\\" in f:
|
|
6720
|
+
new = f.replace("\\", "/")
|
|
6721
|
+
t = c.get("text")
|
|
6722
|
+
if isinstance(t, str) and t.startswith(f + ":"):
|
|
6723
|
+
c["text"] = new + t[len(f):]
|
|
6724
|
+
c["file"] = new
|
|
6725
|
+
return c
|
|
6505
6726
|
|
|
6506
6727
|
|
|
6507
6728
|
# A line that STARTS a definition — where a chunk should be cut, and what names it. Kept
|
|
@@ -8376,6 +8597,30 @@ def _pipe_hidden_failure_note(line: str, ps: bool = False) -> str:
|
|
|
8376
8597
|
+ ("." if ps else ", or start the line with `set -o pipefail;`."))
|
|
8377
8598
|
|
|
8378
8599
|
|
|
8600
|
+
# A UNIX SEARCH COMMAND IN POWERSHELL (#490). Windows has no grep/rg/xargs, and its `find` is
|
|
8601
|
+
# FIND.EXE — a different program that answers `find . -name x` with "Parameter format not
|
|
8602
|
+
# correct". The model learned search as a shell habit, so it reaches for these anyway; the
|
|
8603
|
+
# error alone sends it to guess at Select-String syntax next. grep_repo / list_dir do the same
|
|
8604
|
+
# job inside gu on every OS, so the failure names them.
|
|
8605
|
+
_PS_NO_SEARCH_TOOL_RE = re.compile(
|
|
8606
|
+
r"The term '(grep|egrep|fgrep|rg|ag|ack|xargs|locate|fd)' is not recognized"
|
|
8607
|
+
r"|^(FIND): (?:Parameter format not correct|Invalid switch)",
|
|
8608
|
+
re.M | re.I)
|
|
8609
|
+
|
|
8610
|
+
|
|
8611
|
+
def _ps_search_tool_hint(out: str) -> str:
|
|
8612
|
+
"""A pointer to grep_repo when PowerShell could not run a Unix search command, else ""."""
|
|
8613
|
+
m = _PS_NO_SEARCH_TOOL_RE.search(out or "")
|
|
8614
|
+
if not m:
|
|
8615
|
+
return ""
|
|
8616
|
+
prog = (m.group(1) or m.group(2) or "").lower()
|
|
8617
|
+
what = ("`find` here is Windows' FIND.EXE, not Unix find" if prog == "find"
|
|
8618
|
+
else f"`{prog}` does not exist on Windows")
|
|
8619
|
+
return (f"\n\nNOTE — {what}. Search file CONTENTS with grep_repo(pattern, path, glob) "
|
|
8620
|
+
"and list files with list_dir(path); both run inside gu on every OS. Do not retry "
|
|
8621
|
+
"with Select-String or Get-ChildItem -Recurse.")
|
|
8622
|
+
|
|
8623
|
+
|
|
8379
8624
|
# THE PROJECT'S OWN TEST COMMANDS (#360 review). The model guessed `python3 -m pytest` in a repo
|
|
8380
8625
|
# whose package.json says `python3 -m unittest discover tests -v && node --test` — pytest was not
|
|
8381
8626
|
# even installed. The manifests already say how each part of the project is tested; reading them
|
|
@@ -9320,11 +9565,15 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
|
|
|
9320
9565
|
import tempfile
|
|
9321
9566
|
_ws_record_change(action, path, backup)
|
|
9322
9567
|
target = os.path.realpath(path)
|
|
9568
|
+
# KEEP THE FILE'S ENCODING (#490). Reads decode a BOM'd UTF-8 or UTF-16 file, so an edit
|
|
9569
|
+
# must write it back the same way — a UTF-16 .resx rewritten as UTF-8 is a different file to
|
|
9570
|
+
# Visual Studio, and dropping a BOM shows up as a whole-file diff. No BOM = UTF-8, as before.
|
|
9571
|
+
enc = _sniff_text_encoding(target) if os.path.isfile(target) else "utf-8"
|
|
9323
9572
|
_STOP["writing"] += 1
|
|
9324
9573
|
try:
|
|
9325
9574
|
fd, tmp = tempfile.mkstemp(prefix=".gu-write-", suffix=".tmp", dir=os.path.dirname(target) or ".")
|
|
9326
9575
|
try:
|
|
9327
|
-
with os.fdopen(fd, "w", encoding=
|
|
9576
|
+
with os.fdopen(fd, "w", encoding=enc) as fh:
|
|
9328
9577
|
fh.write(content)
|
|
9329
9578
|
fh.flush()
|
|
9330
9579
|
os.fsync(fh.fileno())
|
|
@@ -9340,7 +9589,7 @@ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
|
|
|
9340
9589
|
except PermissionError:
|
|
9341
9590
|
if not _IS_WINDOWS:
|
|
9342
9591
|
raise
|
|
9343
|
-
with open(target, "w", encoding=
|
|
9592
|
+
with open(target, "w", encoding=enc) as fh:
|
|
9344
9593
|
fh.write(content)
|
|
9345
9594
|
os.unlink(tmp)
|
|
9346
9595
|
_agent_created_add(target)
|
|
@@ -14480,7 +14729,7 @@ def run_agent_chat(cfg):
|
|
|
14480
14729
|
_emit_read("Reading file", path, "skipped — already read, unchanged")
|
|
14481
14730
|
_last_obs["text"] = _rb
|
|
14482
14731
|
return _rb
|
|
14483
|
-
with
|
|
14732
|
+
with _open_project_text(rp) as fh:
|
|
14484
14733
|
data = fh.read(max(500, min(int(max_chars or 20000), 100000)))
|
|
14485
14734
|
out = _ws_cap_obs(data or "(empty file)", cap=_OBS_CAPS["read"])
|
|
14486
14735
|
_emit_read("Reading file", path, f"{data.count(chr(10)) + 1} line(s)"
|
|
@@ -14546,7 +14795,7 @@ def run_agent_chat(cfg):
|
|
|
14546
14795
|
# drop what was deleted. Nothing extra is needed to catch the agent's OWN edits:
|
|
14547
14796
|
# writing a file changes its mtime.
|
|
14548
14797
|
import os as _os_idx
|
|
14549
|
-
_prev = _rag_load_all(source_key) if not replaced else []
|
|
14798
|
+
_prev = [_rag_posix_chunk(_c) for _c in (_rag_load_all(source_key) if not replaced else [])]
|
|
14550
14799
|
_prev_stamp = {}
|
|
14551
14800
|
for _c in _prev:
|
|
14552
14801
|
_st = _c.get("stamp")
|
|
@@ -14576,7 +14825,7 @@ def run_agent_chat(cfg):
|
|
|
14576
14825
|
unchanged_files += 1
|
|
14577
14826
|
continue
|
|
14578
14827
|
try:
|
|
14579
|
-
with
|
|
14828
|
+
with _open_project_text(abs_path) as fh:
|
|
14580
14829
|
text = fh.read()
|
|
14581
14830
|
except OSError:
|
|
14582
14831
|
skipped += 1
|
|
@@ -15156,33 +15405,9 @@ def run_agent_chat(cfg):
|
|
|
15156
15405
|
try:
|
|
15157
15406
|
_emit_running("Searching code",
|
|
15158
15407
|
_fmt_call("grep_repo", pattern=pattern, path=path, glob=glob))
|
|
15159
|
-
import fnmatch
|
|
15160
|
-
import os
|
|
15161
15408
|
root = _rag_read_allowed((path or "").strip() or _default_ws_root())
|
|
15162
|
-
|
|
15163
|
-
|
|
15164
|
-
except re.error:
|
|
15165
|
-
rx = re.compile(re.escape(pattern))
|
|
15166
|
-
hits = []
|
|
15167
|
-
for abs_path, rel, _ext in _rag_iter_text_files(root):
|
|
15168
|
-
if glob and not fnmatch.fnmatch(os.path.basename(rel), glob) \
|
|
15169
|
-
and not fnmatch.fnmatch(rel, glob):
|
|
15170
|
-
continue
|
|
15171
|
-
try:
|
|
15172
|
-
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
15173
|
-
for i, line in enumerate(fh, 1):
|
|
15174
|
-
if rx.search(line):
|
|
15175
|
-
# ABSOLUTE path — read_file_lines/read_text_file resolve
|
|
15176
|
-
# relative paths against the WORKSPACE root, not this
|
|
15177
|
-
# search root, so a path relative to `root` (e.g. when
|
|
15178
|
-
# searching a subfolder) would be silently wrong there.
|
|
15179
|
-
hits.append(f"{abs_path}:{i}: {line.rstrip()[:200]}")
|
|
15180
|
-
if len(hits) >= 100:
|
|
15181
|
-
break
|
|
15182
|
-
except OSError:
|
|
15183
|
-
continue
|
|
15184
|
-
if len(hits) >= 100:
|
|
15185
|
-
break
|
|
15409
|
+
# ripgrep when there is one (as Claude Code does, #379/#380), else the Python walk.
|
|
15410
|
+
hits, _engine = _grep_search(root, pattern, glob, 100)
|
|
15186
15411
|
_search_tally_bump("grep", len(hits), pattern)
|
|
15187
15412
|
_emit({"type": "step", "text": f"Searching code → {pattern[:60]} ({len(hits)} hits)"})
|
|
15188
15413
|
if not hits:
|
|
@@ -15220,7 +15445,7 @@ def run_agent_chat(cfg):
|
|
|
15220
15445
|
defs, calls, tests = [], [], []
|
|
15221
15446
|
for abs_path, rel, _ext in _rag_iter_text_files(root):
|
|
15222
15447
|
try:
|
|
15223
|
-
with
|
|
15448
|
+
with _open_project_text(abs_path) as fh:
|
|
15224
15449
|
text = fh.read()
|
|
15225
15450
|
except OSError:
|
|
15226
15451
|
continue
|
|
@@ -15302,7 +15527,7 @@ def run_agent_chat(cfg):
|
|
|
15302
15527
|
start_line=int(start_line or 1),
|
|
15303
15528
|
end_line=int(end_line or 0)))
|
|
15304
15529
|
rp = _rag_read_allowed((path or "").strip())
|
|
15305
|
-
with
|
|
15530
|
+
with _open_project_text(rp) as fh:
|
|
15306
15531
|
lines = fh.readlines()
|
|
15307
15532
|
s = max(1, int(start_line or 1))
|
|
15308
15533
|
e = int(end_line or 0) or len(lines)
|
|
@@ -15350,7 +15575,7 @@ def run_agent_chat(cfg):
|
|
|
15350
15575
|
_last_obs["text"] = msg
|
|
15351
15576
|
return msg
|
|
15352
15577
|
backup = _ws_snapshot(rp)
|
|
15353
|
-
with
|
|
15578
|
+
with _open_project_text(rp) as fh:
|
|
15354
15579
|
lines = fh.readlines()
|
|
15355
15580
|
s, e = int(start_line), int(end_line)
|
|
15356
15581
|
if not (1 <= s <= e <= len(lines)):
|
|
@@ -15403,7 +15628,7 @@ def run_agent_chat(cfg):
|
|
|
15403
15628
|
msg = f"Error: not a file: {path}"
|
|
15404
15629
|
_last_obs["text"] = msg
|
|
15405
15630
|
return msg
|
|
15406
|
-
with
|
|
15631
|
+
with _open_project_text(rp) as fh:
|
|
15407
15632
|
text = fh.read()
|
|
15408
15633
|
n = text.count(old_string)
|
|
15409
15634
|
if n == 0:
|
|
@@ -15964,6 +16189,8 @@ def run_agent_chat(cfg):
|
|
|
15964
16189
|
result += _pipe_hidden_failure_note(_hidden, ps=_ps)
|
|
15965
16190
|
_emit({"type": "step", "text":
|
|
15966
16191
|
f"Command failed behind a pipe → {_trim_words(_hidden)}"})
|
|
16192
|
+
if _ps:
|
|
16193
|
+
result += _ps_search_tool_hint(out)
|
|
15967
16194
|
if rc != 0 and _use_shell and (_PS_QUOTE_ERR_RE if _ps else _SHELL_QUOTE_ERR_RE).search(out or ""):
|
|
15968
16195
|
result += _PS_HEREDOC_HINT if _ps else _HEREDOC_HINT
|
|
15969
16196
|
# Say where the shell now is, so a next `cd sub` is not done twice (#362).
|
|
@@ -16968,7 +17195,7 @@ def run_agent_chat(cfg):
|
|
|
16968
17195
|
# "the real attempt never got to run" (seen live: workspace summarize request →
|
|
16969
17196
|
# coding model timed out 3x on a slow/unreachable remote Ollama box → silent
|
|
16970
17197
|
# fallback claimed "no access to your workspace").
|
|
16971
|
-
note = f"
|
|
17198
|
+
note = f"I couldn't finish the full investigation ({_clip(str(e), 120)}) — here's what I can say without it:\n\n{fallback}"
|
|
16972
17199
|
# Append the workspace's ACTUAL top-level contents (computed locally, no model
|
|
16973
17200
|
# call — see _workspace_overview) so the user sees their files are really there
|
|
16974
17201
|
# even during a total coding-model outage, instead of trusting a plain-chat
|