ucode-agent 1.62.8 → 1.64.0

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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * settings.js — what ucode may do without asking, and what runs around its work.
3
+ *
4
+ * ~/.ucode/settings.json yours, in every project
5
+ * <project>/.ucode/settings.json this project's, on top of yours
6
+ *
7
+ * {
8
+ * "commands": "ask", ask before every command ("auto", the default, runs them)
9
+ * "allow": ["npm test"], never asked about: a command by its first words, or "mcp:server__tool"
10
+ * "hooks": {
11
+ * "afterEdit": ["npx prettier --write {files}"],
12
+ * "beforeCommand": ["node guard.js"] a non-zero exit stops the command
13
+ * }
14
+ * }
15
+ *
16
+ * A project's own hooks and MCP servers run commands on this computer, and a
17
+ * cloned repository can carry any it likes. So they run only once the user
18
+ * has said yes to them, and saying yes is remembered against exactly what was
19
+ * approved: change the hooks and ucode asks again.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { createHash } from 'node:crypto';
24
+ import { promises as fs } from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ const UCODE_DIR = path.join(os.homedir(), '.ucode');
29
+ export const USER_SETTINGS = path.join(UCODE_DIR, 'settings.json');
30
+ export const TRUST_FILE = path.join(UCODE_DIR, 'trusted.json');
31
+ export const projectSettingsFile = (cwd) => path.join(cwd, '.ucode', 'settings.json');
32
+
33
+ export async function readJson(file) {
34
+ try {
35
+ const data = JSON.parse(await fs.readFile(file, 'utf8'));
36
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ export async function writeJson(file, data) {
43
+ await fs.mkdir(path.dirname(file), { recursive: true });
44
+ const temp = `${file}.${process.pid}.tmp`;
45
+ await fs.writeFile(temp, `${JSON.stringify(data, null, 2)}\n`);
46
+ await fs.rename(temp, file);
47
+ }
48
+
49
+ const list = (v) => (Array.isArray(v) ? v : v ? [v] : []).map(String).filter((s) => s.trim());
50
+
51
+ /** Both files, merged: allow lists add up, the project's "commands" wins. */
52
+ export async function loadSettings(cwd, { userFile = USER_SETTINGS } = {}) {
53
+ const [user, project] = await Promise.all([readJson(userFile), readJson(projectSettingsFile(cwd))]);
54
+ const hooks = (s, key) => list(s.hooks?.[key]);
55
+ return {
56
+ commands: (project.commands ?? user.commands) === 'ask' ? 'ask' : 'auto',
57
+ allow: [...new Set([...list(user.allow), ...list(project.allow)])],
58
+ hooks: { afterEdit: hooks(user, 'afterEdit'), beforeCommand: hooks(user, 'beforeCommand') },
59
+ projectHooks: { afterEdit: hooks(project, 'afterEdit'), beforeCommand: hooks(project, 'beforeCommand') },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * What "always allow" remembers: "npm test" for `npm test -- --watch`. Never
65
+ * shorter than a flag: allowing `node -e "…"` once must not allow every
66
+ * `node -e` after it, so a command whose second word is a flag is kept whole.
67
+ */
68
+ export function commandKey(command) {
69
+ const words = String(command).trim().split(/\s+/).filter(Boolean);
70
+ if (!words[1] || words[1].startsWith('-')) return words.join(' ');
71
+ return words.slice(0, 2).join(' ');
72
+ }
73
+
74
+ /** Is this command (or "mcp:name") on the allow list? A whole-word prefix counts. */
75
+ export function isAllowed(allow, command) {
76
+ const c = String(command).trim();
77
+ // Chained commands are never covered by an entry for their first part.
78
+ if (/[;&|`$<>]/.test(c) && !c.startsWith('mcp:')) return false;
79
+ return allow.some((a) => c === a || c.startsWith(`${a} `));
80
+ }
81
+
82
+ /** Add an entry to this project's allow list. */
83
+ export async function addAllow(cwd, entry) {
84
+ const file = projectSettingsFile(cwd);
85
+ const settings = await readJson(file);
86
+ const allow = list(settings.allow);
87
+ if (!allow.includes(entry)) await writeJson(file, { ...settings, allow: [...allow, entry] });
88
+ }
89
+
90
+ /**
91
+ * What was approved, down to the scripts it runs. Hashing only the settings
92
+ * text would let `node .ucode/guard.js` be approved and guard.js swapped
93
+ * afterwards, so every word of the commands that names a file in the project
94
+ * adds that file's contents. (A package fetched by npx is not covered: pin
95
+ * its version in the command to approve one release.)
96
+ */
97
+ async function fingerprint(cwd, value) {
98
+ const hash = createHash('sha1').update(JSON.stringify(value));
99
+ const words = JSON.stringify(value).match(/[\w./\\@-]+\.\w+/g) ?? [];
100
+ for (const word of [...new Set(words)].sort()) {
101
+ const abs = path.resolve(cwd, word);
102
+ if (!abs.startsWith(path.resolve(cwd))) continue;
103
+ const text = await fs.readFile(abs).catch(() => null);
104
+ if (text) hash.update(`\0${word}\0`).update(text);
105
+ }
106
+ return hash.digest('hex');
107
+ }
108
+
109
+ /** Has the user approved exactly this (the project's hooks, its MCP servers) here before? */
110
+ export async function isTrusted(cwd, what, value, file = TRUST_FILE) {
111
+ const all = await readJson(file);
112
+ return all[`${path.resolve(cwd)}#${what}`] === (await fingerprint(cwd, value));
113
+ }
114
+
115
+ export async function trust(cwd, what, value, file = TRUST_FILE) {
116
+ const all = await readJson(file);
117
+ await writeJson(file, { ...all, [`${path.resolve(cwd)}#${what}`]: await fingerprint(cwd, value) });
118
+ }
119
+
120
+ /** A path safe to put on a shell command line as it is. */
121
+ const SAFE_PATH = /^[\w./\\ @+~-]+$/;
122
+
123
+ /**
124
+ * Run one hook. `{files}` becomes the changed files, quoted; they are also in
125
+ * UCODE_FILES. Resolves to { code, output } and never throws.
126
+ */
127
+ export function runHook(command, { cwd, files = [], env = {}, timeout = 30_000 } = {}) {
128
+ const quoted = files.filter((f) => SAFE_PATH.test(f)).map((f) => `"${f}"`).join(' ');
129
+ const line = String(command).includes('{files}') ? String(command).split('{files}').join(quoted) : String(command);
130
+ return new Promise((resolve) => {
131
+ let output = '';
132
+ let child;
133
+ try {
134
+ child = spawn(line, {
135
+ cwd, shell: true, windowsHide: true,
136
+ env: { ...process.env, ...env, UCODE_FILES: files.join('\n') },
137
+ });
138
+ } catch (err) {
139
+ resolve({ code: -1, output: err.message });
140
+ return;
141
+ }
142
+ const timer = setTimeout(() => { output += `\n(stopped after ${timeout / 1000}s)`; child.kill(); }, timeout);
143
+ child.stdout?.on('data', (d) => { output += d; });
144
+ child.stderr?.on('data', (d) => { output += d; });
145
+ child.on('error', (err) => { clearTimeout(timer); resolve({ code: -1, output: err.message }); });
146
+ child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? -1, output: output.trim() }); });
147
+ });
148
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * snapshot.js — the whole project as it was before each turn, for /undo.
3
+ *
4
+ * undo.js remembers files ucode's own tools write, for one turn. That misses
5
+ * everything a command changes — a script that rewrites a config, a generator
6
+ * that adds twenty files — and it forgets the moment the next turn starts. So
7
+ * the project is snapshotted before every turn into a hidden git repository
8
+ * of ucode's own, under ~/.ucode/snapshots: the project's own .git is never
9
+ * read or written, and a folder with no git at all gets the same undo.
10
+ *
11
+ * The project's .gitignore is honoured, and node_modules and build output are
12
+ * always left out: they are huge, and an install can simply be run again.
13
+ *
14
+ * ponytail: snapshots are never pruned. Git only stores what changed, so a
15
+ * long-lived project grows by its edits; `git gc` in the snapshot folder, or
16
+ * deleting it, is the upgrade path if that ever matters.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { createHash } from 'node:crypto';
21
+ import { promises as fs } from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { walk } from '../tools/shared.js';
25
+
26
+ export const SNAPSHOT_HOME = path.join(os.homedir(), '.ucode', 'snapshots');
27
+
28
+ /** Past this many files, snapshotting every turn would cost real time. */
29
+ export const MAX_FILES = 3000;
30
+
31
+ const EXCLUDE = [
32
+ 'node_modules/', '.next/', 'dist/', 'build/', 'out/', 'coverage/', '.cache/', '.turbo/',
33
+ '.venv/', 'venv/', '__pycache__/', 'target/', '.ucode/', '*.log',
34
+ ];
35
+
36
+ function run(args, { cwd }) {
37
+ return new Promise((resolve) => {
38
+ let out = '';
39
+ let err = '';
40
+ let child;
41
+ try {
42
+ child = spawn('git', args, { cwd, windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
43
+ } catch (e) {
44
+ resolve({ code: -1, out: '', err: e.message });
45
+ return;
46
+ }
47
+ child.stdout.on('data', (d) => { out += d; });
48
+ child.stderr.on('data', (d) => { err += d; });
49
+ child.on('error', (e) => resolve({ code: -1, out, err: e.message }));
50
+ child.on('close', (code) => resolve({ code, out, err }));
51
+ });
52
+ }
53
+
54
+ export class Snapshots {
55
+ constructor(cwd, { home = SNAPSHOT_HOME } = {}) {
56
+ this.cwd = path.resolve(cwd);
57
+ const id = createHash('sha1').update(this.cwd.toLowerCase()).digest('hex').slice(0, 16);
58
+ this.gitDir = path.join(home, `${id}.git`);
59
+ this.usable = null; // decided once, by ready()
60
+ }
61
+
62
+ git(args) {
63
+ return run([
64
+ `--git-dir=${this.gitDir}`, `--work-tree=${this.cwd}`,
65
+ '-c', 'core.autocrlf=false', '-c', 'core.safecrlf=false', '-c', 'core.longpaths=true',
66
+ '-c', 'user.name=ucode', '-c', 'user.email=ucode@localhost', '-c', 'commit.gpgsign=false',
67
+ ...args,
68
+ ], { cwd: this.cwd });
69
+ }
70
+
71
+ /**
72
+ * Whether this folder can be snapshotted: git is installed, the folder is a
73
+ * project rather than a home directory or a drive, and it is small enough
74
+ * that a snapshot costs a moment.
75
+ */
76
+ async ready() {
77
+ if (this.usable !== null) return this.usable;
78
+ this.usable = false;
79
+ if (process.env.UCODE_SNAPSHOTS === '0') return false;
80
+ const home = path.resolve(os.homedir());
81
+ if (this.cwd === home || this.cwd === path.parse(this.cwd).root) return false;
82
+ const files = await walk(this.cwd, { limit: MAX_FILES + 1 }).catch(() => null);
83
+ if (!files || files.length > MAX_FILES) return false;
84
+ try {
85
+ await fs.access(path.join(this.gitDir, 'HEAD'));
86
+ } catch {
87
+ await fs.mkdir(path.dirname(this.gitDir), { recursive: true });
88
+ const init = await run(['init', '-q', '--bare', this.gitDir], { cwd: path.dirname(this.gitDir) });
89
+ if (init.code !== 0) return false;
90
+ }
91
+ await fs.mkdir(path.join(this.gitDir, 'info'), { recursive: true });
92
+ await fs.writeFile(path.join(this.gitDir, 'info', 'exclude'), `${EXCLUDE.join('\n')}\n`);
93
+ this.usable = true;
94
+ return true;
95
+ }
96
+
97
+ /** Record the project as it is now. Resolves to the snapshot id, or null. */
98
+ async take(label = 'snapshot') {
99
+ if (!(await this.ready())) return null;
100
+ const add = await this.git(['add', '-A', '.']);
101
+ if (add.code !== 0) return null;
102
+ const commit = await this.git(['commit', '-q', '--allow-empty', '--no-verify', '-m', label]);
103
+ if (commit.code !== 0) return null;
104
+ const head = await this.git(['rev-parse', 'HEAD']);
105
+ return head.code === 0 ? head.out.trim() : null;
106
+ }
107
+
108
+ /** Files that differ between a snapshot and now: [{ status: 'A'|'M'|'D', file }]. */
109
+ async changedSince(id) {
110
+ const now = await this.take('now');
111
+ if (!now) return [];
112
+ const diff = await this.git(['diff', '--name-status', '--no-renames', id, now]);
113
+ if (diff.code !== 0) return [];
114
+ return diff.out.split('\n').filter(Boolean).map((line) => {
115
+ const [status, ...rest] = line.split('\t');
116
+ return { status: status[0], file: rest.join('\t') };
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Put the project back the way it was at a snapshot. Files made since are
122
+ * removed; files changed or deleted since come back.
123
+ */
124
+ async restore(id) {
125
+ const out = { restored: [], removed: [], failed: [] };
126
+ const changes = await this.changedSince(id);
127
+ for (const { status, file } of changes) {
128
+ if (status === 'A') {
129
+ try {
130
+ await fs.rm(path.join(this.cwd, file), { force: true });
131
+ out.removed.push(file);
132
+ } catch (err) {
133
+ out.failed.push(`${file}: ${err.message}`);
134
+ }
135
+ }
136
+ }
137
+ const back = changes.filter((c) => c.status !== 'A').map((c) => c.file);
138
+ // In batches: Windows caps a command line at 32K characters.
139
+ for (let i = 0; i < back.length; i += 100) {
140
+ const batch = back.slice(i, i + 100);
141
+ const checkout = await this.git(['checkout', id, '--', ...batch]);
142
+ if (checkout.code === 0) out.restored.push(...batch);
143
+ else out.failed.push(checkout.err.trim().split('\n')[0] || 'git checkout failed');
144
+ }
145
+ return out;
146
+ }
147
+ }
package/src/core/tests.js CHANGED
@@ -42,7 +42,11 @@ export async function testRunnerFor(dir) {
42
42
  ['pytest.ini', 'pyproject.toml', 'setup.cfg', 'tox.ini'].map((f) =>
43
43
  fs.access(path.join(dir, f)).then(() => true, () => false))
44
44
  );
45
- return py.some(Boolean) ? 'pytest' : null;
45
+ if (py.some(Boolean)) return 'pytest';
46
+ const at = (f) => fs.access(path.join(dir, f)).then(() => true, () => false);
47
+ if (await at('go.mod')) return 'go';
48
+ if (await at('Cargo.toml')) return 'cargo';
49
+ return null;
46
50
  }
47
51
 
48
52
  /** Quote a path for a shell, and use forward slashes so Windows agrees. */
@@ -71,6 +75,15 @@ export function relatedCommand(runner, files) {
71
75
  if (!tests.length) return null;
72
76
  return `python -m pytest -q ${tests.map(arg).join(' ')}`;
73
77
  }
78
+ // Go tests a package at a time: the packages that hold the changed files.
79
+ if (runner === 'go') {
80
+ const dirs = [...new Set(list.filter((f) => /\.go$/i.test(f)).map((f) => path.dirname(f).replace(/\\/g, '/')))];
81
+ if (!dirs.length) return null;
82
+ return `go test ${dirs.map((d) => `./${d === '.' ? '' : `${d}/`}...`).join(' ')}`;
83
+ }
84
+ if (runner === 'cargo') {
85
+ return list.some((f) => /\.rs$/i.test(f)) ? 'cargo test --quiet' : null;
86
+ }
74
87
  return null;
75
88
  }
76
89
 
@@ -0,0 +1,172 @@
1
+ /**
2
+ * voice.js — the mic button: say what you want instead of typing it.
3
+ *
4
+ * Recording uses what the computer already has, so there is nothing to
5
+ * install on Windows: its own sound recorder API (winmm, driven through
6
+ * PowerShell). macOS uses sox or ffmpeg, Linux arecord or sox, whichever is
7
+ * there. The recording is sent to the same Gemini model as everything else,
8
+ * which writes down what was said (see transcribe in provider.js).
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { promises as fs } from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ /** Longest a recording runs before it stops by itself. */
17
+ export const MAX_RECORD_MS = 2 * 60_000;
18
+
19
+ /**
20
+ * Loudest sample (of 32767) below which a recording counts as silence. A quiet
21
+ * room measured ~400 on a laptop mic, speech ~3,000 and up. UCODE_MIC_QUIET
22
+ * tunes it for a mic that reads differently.
23
+ */
24
+ export const quietBelow = () => Number(process.env.UCODE_MIC_QUIET) || 800;
25
+
26
+ // winmm's MCI strings refuse long paths (error 304), so the file sits directly
27
+ // in the temp folder with a short name. The script says "ready" once it is
28
+ // recording, and saves when a line arrives on stdin.
29
+ const WINDOWS_SCRIPT = [
30
+ '$sig = \'[DllImport("winmm.dll", CharSet = CharSet.Unicode)] public static extern int mciSendString(string c, System.Text.StringBuilder r, int l, System.IntPtr h);\'',
31
+ '$w = Add-Type -MemberDefinition $sig -Name Mci -Namespace UcodeMic -PassThru',
32
+ 'function mci($c) { $r = $w::mciSendString($c, $null, 0, [IntPtr]::Zero); if ($r -ne 0) { [Console]::Out.WriteLine("mci-error $r"); exit 1 } }',
33
+ 'mci "open new type waveaudio alias ucodemic"',
34
+ 'mci "set ucodemic bitspersample 16 channels 1 samplespersec 16000 bytespersec 32000 alignment 2"',
35
+ 'mci "record ucodemic"',
36
+ '[Console]::Out.WriteLine("ready")',
37
+ '[void][Console]::In.ReadLine()',
38
+ 'mci "stop ucodemic"',
39
+ 'mci (\'save ucodemic "\' + $env:UCODE_MIC_FILE + \'"\')',
40
+ 'mci "close ucodemic"',
41
+ ].join('\n');
42
+
43
+ /**
44
+ * The recorders to try on this platform, in order. `stop` is how each is told
45
+ * to finish and write its file: a line on stdin, "q" on stdin, or SIGINT.
46
+ */
47
+ export function recordersFor(platform, file) {
48
+ if (platform === 'win32') {
49
+ const encoded = Buffer.from(WINDOWS_SCRIPT, 'utf16le').toString('base64');
50
+ return [{
51
+ cmd: 'powershell.exe',
52
+ args: ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
53
+ stop: 'line',
54
+ saysReady: true,
55
+ }];
56
+ }
57
+ const sox = { cmd: 'rec', args: ['-q', '-c', '1', '-r', '16000', '-b', '16', file], stop: 'SIGINT' };
58
+ if (platform === 'darwin') {
59
+ return [sox, {
60
+ cmd: 'ffmpeg',
61
+ args: ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-ac', '1', '-ar', '16000', '-y', file],
62
+ stop: 'q',
63
+ }];
64
+ }
65
+ return [{ cmd: 'arecord', args: ['-q', '-f', 'S16_LE', '-r', '16000', '-c', '1', file], stop: 'SIGINT' }, sox];
66
+ }
67
+
68
+ const NO_RECORDER = {
69
+ darwin: 'No recorder found. Install one with: brew install sox',
70
+ linux: 'No recorder found. Install one with: sudo apt install alsa-utils',
71
+ };
72
+
73
+ /** Start one recorder; resolves once it is listening, rejects with the reason it could not. */
74
+ function launch(rec, file, run) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = run(rec.cmd, rec.args, {
77
+ env: { ...process.env, UCODE_MIC_FILE: file },
78
+ windowsHide: true,
79
+ stdio: ['pipe', 'pipe', 'pipe'],
80
+ });
81
+ let out = '';
82
+ let settled = false;
83
+ const done = (fn, value) => { if (!settled) { settled = true; fn(value); } };
84
+
85
+ child.on('error', (err) => done(reject, err));
86
+ child.stdout?.on('data', (d) => {
87
+ out += d;
88
+ if (rec.saysReady && out.includes('ready')) done(resolve, child);
89
+ const code = /mci-error (\d+)/.exec(out)?.[1];
90
+ if (code) done(reject, new Error(`Windows could not use the microphone (error ${code}). Check one is plugged in and allowed in Settings › Privacy › Microphone.`));
91
+ });
92
+ child.on('exit', (code) => done(reject, new Error(`The recorder stopped before listening (exit ${code}). Check a microphone is connected.`)));
93
+ if (!rec.saysReady) child.on('spawn', () => done(resolve, child));
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Start recording from the default microphone.
99
+ * Resolves to { stop() → Promise<Buffer|null>, cancel() → Promise<void> } once listening.
100
+ */
101
+ export async function startRecording({ platform = process.platform, run = spawn, dir = os.tmpdir() } = {}) {
102
+ // A private folder of its own, so no other user can guess or pre-place the file.
103
+ const folder = await fs.mkdtemp(path.join(dir, 'ucm-'));
104
+ const file = path.join(folder, 'mic.wav');
105
+ const remove = () => fs.rm(folder, { recursive: true, force: true }).catch(() => {});
106
+
107
+ let child = null;
108
+ let rec = null;
109
+ try {
110
+ for (const candidate of recordersFor(platform, file)) {
111
+ try {
112
+ child = await launch(candidate, file, run);
113
+ rec = candidate;
114
+ break;
115
+ } catch (err) {
116
+ if (err.code !== 'ENOENT') throw err;
117
+ }
118
+ }
119
+ } catch (err) {
120
+ await remove();
121
+ throw err;
122
+ }
123
+ if (!child) {
124
+ await remove();
125
+ throw new Error(NO_RECORDER[platform] ?? 'No sound recorder found on this computer.');
126
+ }
127
+
128
+ const closed = new Promise((resolve) => child.on('close', resolve));
129
+ // A recorder that will not finish is killed rather than waited on forever.
130
+ const finished = async () => {
131
+ const timer = setTimeout(() => child.kill('SIGKILL'), 10_000);
132
+ await closed;
133
+ clearTimeout(timer);
134
+ };
135
+
136
+ return {
137
+ async stop() {
138
+ if (rec.stop === 'line') child.stdin.write('\n');
139
+ else if (rec.stop === 'q') child.stdin.write('q');
140
+ else child.kill('SIGINT');
141
+ await finished();
142
+ const wav = await fs.readFile(file).catch(() => null);
143
+ await remove();
144
+ return wav;
145
+ },
146
+ async cancel() {
147
+ child.kill();
148
+ await finished();
149
+ await remove();
150
+ },
151
+ };
152
+ }
153
+
154
+ /** True when a 16-bit PCM WAV holds nothing louder than room noise, or under a third of a second. */
155
+ export function isSilent(wav, threshold = quietBelow()) {
156
+ const at = wav.indexOf('data');
157
+ const start = at >= 0 ? at + 8 : 44;
158
+ if (wav.length - start < 16000 * 2 / 3) return true;
159
+ for (let i = start; i + 1 < wav.length; i += 2) {
160
+ if (Math.abs(wav.readInt16LE(i)) >= threshold) return false;
161
+ }
162
+ return true;
163
+ }
164
+
165
+ /** What the model heard, as one line for the input box: no quotes, no line breaks. */
166
+ export function cleanTranscript(text) {
167
+ return String(text ?? '')
168
+ .replace(/\s+/g, ' ')
169
+ .trim()
170
+ .replace(/^["'“”]+|["'“”]+$/g, '')
171
+ .trim();
172
+ }
@@ -154,7 +154,8 @@ async function review(shots) {
154
154
  // tokens came back as an empty review. Keep the thinking short, and leave
155
155
  // room for the answer.
156
156
  maxOutputTokens: 4000,
157
- reasoning: { effort: 'low' },
157
+ // Gemini's own field; the OpenRouter-style `reasoning` object was ignored.
158
+ effort: 'low',
158
159
  // The free vision model is often busy. One try, and a hard cap: a review
159
160
  // that cannot run is skipped, never waited on.
160
161
  attempts: 1,
@@ -3,6 +3,7 @@
3
3
  * name pattern, and which lines match a regular expression.
4
4
  */
5
5
 
6
+ import { spawn } from 'node:child_process';
6
7
  import { promises as fs } from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import { ToolFailure } from '../core/failure.js';
@@ -11,6 +12,59 @@ import {
11
12
  SKIP, MAX_GLOB_HITS, MAX_GREP_HITS, WALK_WIDTH,
12
13
  } from './shared.js';
13
14
 
15
+ /** Whether `rg` runs here, asked once. UCODE_RIPGREP=0 never uses it. */
16
+ let hasRipgrep;
17
+ function ripgrepReady() {
18
+ if (process.env.UCODE_RIPGREP === '0') return Promise.resolve(false);
19
+ hasRipgrep ??= new Promise((resolve) => {
20
+ const child = spawn('rg', ['--version'], { windowsHide: true, stdio: 'ignore' });
21
+ child.on('error', () => resolve(false));
22
+ child.on('close', (code) => resolve(code === 0));
23
+ });
24
+ return hasRipgrep;
25
+ }
26
+
27
+ /**
28
+ * The same search through ripgrep, with the same rules as the walk below: the
29
+ * same folders skipped, hidden files and .gitignored ones searched, the same
30
+ * line format. Resolves to { hits, files }, or null when ripgrep is missing or
31
+ * cannot read the pattern.
32
+ */
33
+ async function ripgrep(pattern, dir, { filter, ignoreCase }) {
34
+ if (!(await ripgrepReady())) return null;
35
+ const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '--no-ignore', '--sort', 'path'];
36
+ for (const skip of SKIP) args.push('-g', `!${skip}/`);
37
+ if (filter) args.push('-g', filter);
38
+ if (ignoreCase) args.push('-i');
39
+ args.push('-e', pattern, '--', '.');
40
+
41
+ return new Promise((resolve) => {
42
+ const hits = [];
43
+ const files = new Set();
44
+ let rest = '';
45
+ let done = false;
46
+ const child = spawn('rg', args, { cwd: dir, windowsHide: true });
47
+ const finish = (value) => { if (!done) { done = true; resolve(value); } };
48
+ child.on('error', () => finish(null));
49
+ child.stdout.setEncoding('utf8');
50
+ child.stdout.on('data', (chunk) => {
51
+ const lines = (rest + chunk).split(/\r?\n/);
52
+ rest = lines.pop();
53
+ for (const line of lines) {
54
+ const m = /^(.*?):(\d+):(.*)$/.exec(line);
55
+ if (!m || hits.length >= MAX_GREP_HITS) continue;
56
+ const rel = m[1].replace(/^\.[\\/]/, '').replace(/\\/g, '/');
57
+ const text = m[3].trim();
58
+ files.add(rel);
59
+ hits.push(`${rel}:${m[2]}: ${text.length > 200 ? `${text.slice(0, 200)}…` : text}`);
60
+ }
61
+ if (hits.length >= MAX_GREP_HITS) { child.kill(); finish({ hits, files: files.size }); }
62
+ });
63
+ // 0 found, 1 nothing found, 2 an error such as a pattern it cannot read.
64
+ child.on('close', (code) => finish(code === 0 || code === 1 || hits.length ? { hits, files: files.size } : null));
65
+ });
66
+ }
67
+
14
68
  export async function listDir({ path: p = '.' }) {
15
69
  const target = resolveIn(p || '.', 'list_dir');
16
70
  await guard(target, `list ${target.abs}`);
@@ -131,6 +185,26 @@ export async function grep({ pattern, path: p = '.', glob: filter, ignore_case =
131
185
  throw fsFailure(err, 'searching file contents', target.show);
132
186
  });
133
187
 
188
+ // A directory goes to ripgrep when it is installed: it is many times faster
189
+ // on a real project. A pattern ripgrep reads differently (lookarounds,
190
+ // back-references) makes it fail, and the search below runs instead.
191
+ if (!stat.isFile()) {
192
+ const fast = await ripgrep(pattern, target.abs, { filter, ignoreCase: ignore_case });
193
+ if (fast) {
194
+ if (!fast.hits.length) {
195
+ return result(`No line matched /${pattern}/ under ${target.show}${filter ? ` (limited to ${filter})` : ''}.`, 'no matches');
196
+ }
197
+ const capped = fast.hits.length >= MAX_GREP_HITS
198
+ ? `\n[stopped at ${MAX_GREP_HITS} matches — narrow the pattern, or pass a glob]`
199
+ : '';
200
+ return result(
201
+ fast.hits.join('\n') + capped,
202
+ `${fast.hits.length} match${fast.hits.length === 1 ? '' : 'es'} in ` +
203
+ `${fast.files} file${fast.files === 1 ? '' : 's'}`
204
+ );
205
+ }
206
+ }
207
+
134
208
  let base = target.abs;
135
209
  let candidates;
136
210
  if (stat.isFile()) {
@@ -10,6 +10,7 @@
10
10
  import { promises as fs } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { ToolFailure, Declined } from '../core/failure.js';
13
+ import { isAllowed, commandKey } from '../core/settings.js';
13
14
 
14
15
  /**
15
16
  * How much of a result the model may see.
@@ -63,7 +64,25 @@ export function setConfirm(fn) {
63
64
  asker = fn;
64
65
  }
65
66
 
66
- export async function confirm(action, detail, risk = 'write') {
67
+ /**
68
+ * What may run without asking. `askCommands` is the "commands": "ask" setting;
69
+ * `allow` is the always-allow list; `onAlways` saves a new entry to it.
70
+ */
71
+ let policy = { askCommands: false, allow: [], onAlways: null };
72
+
73
+ export function setPolicy(next) {
74
+ policy = { ...policy, ...next };
75
+ }
76
+
77
+ /** Is this command, or "mcp:tool", on the always-allow list? */
78
+ export const allowedNow = (subject) => isAllowed(policy.allow, subject);
79
+
80
+ /**
81
+ * Ask before acting. `always` names what an "always" answer allows from then
82
+ * on: a command's first words, or "mcp:tool". Callers check allowedNow()
83
+ * against the whole command first — the entry is only what gets saved.
84
+ */
85
+ export async function confirm(action, detail, risk = 'write', always = null) {
67
86
  if (!asker) {
68
87
  throw new ToolFailure({
69
88
  kind: 'cannot_ask',
@@ -72,7 +91,19 @@ export async function confirm(action, detail, risk = 'write') {
72
91
  fix: 'Run ucode in a terminal so it can prompt before acting.',
73
92
  });
74
93
  }
75
- if (!(await asker({ action, detail, risk }))) throw new Declined(action);
94
+ const answer = await asker({ action, detail, risk, always });
95
+ if (answer === 'always' && always) {
96
+ policy = { ...policy, allow: [...policy.allow, always] };
97
+ await Promise.resolve(policy.onAlways?.(always)).catch(() => {});
98
+ return;
99
+ }
100
+ if (!answer) throw new Declined(action);
101
+ }
102
+
103
+ /** A command about to run, put to the user first when they asked for that. */
104
+ export async function approveCommand(command, where) {
105
+ if (!policy.askCommands || allowedNow(command)) return;
106
+ await confirm(`run ${String(command).trim().slice(0, 160)}`, `in ${where}`, 'command', commandKey(command));
76
107
  }
77
108
 
78
109
  // ---------------------------------------------------------------------------
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
14
14
  import os from 'node:os';
15
15
  import path from 'node:path';
16
16
  import { ToolFailure } from '../core/failure.js';
17
- import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
17
+ import { resolveIn, guard, confirm, approveCommand, result, getRoot, MAX_OUTPUT } from './shared.js';
18
18
 
19
19
  const DEFAULT_TIMEOUT = 120_000;
20
20
  const MAX_TIMEOUT = 600_000;
@@ -713,6 +713,9 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
713
713
  : { abs: getRoot(), inside: true, show: '.' };
714
714
  await guard(workdir, `run a command in ${workdir.abs}`);
715
715
 
716
+ // "commands": "ask" in settings: every command is put to the user first.
717
+ await approveCommand(command, workdir.show);
718
+
716
719
  const env = childEnv();
717
720
  const server = startsServer(command);
718
721