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.
- package/README.md +108 -2
- package/package.json +65 -64
- package/src/core/commands.js +44 -0
- package/src/core/genericcheck.js +147 -0
- package/src/core/headless.js +73 -0
- package/src/core/lessons.js +83 -0
- package/src/core/loop.js +514 -19
- package/src/core/mcp.js +337 -0
- package/src/core/mcpcli.js +52 -0
- package/src/core/provider.js +124 -11
- package/src/core/scope.js +62 -3
- package/src/core/settings.js +148 -0
- package/src/core/snapshot.js +147 -0
- package/src/core/tests.js +14 -1
- package/src/core/voice.js +172 -0
- package/src/tools/browser.js +2 -1
- package/src/tools/search.js +74 -0
- package/src/tools/shared.js +33 -2
- package/src/tools/shell.js +4 -1
- package/src/ui/plain.js +9 -6
- package/src/ui/screen.js +57 -12
- package/src/ui/theme.js +30 -1
- package/ucode.js +37 -1
package/src/ui/plain.js
CHANGED
|
@@ -24,6 +24,9 @@ const COMMANDS = [
|
|
|
24
24
|
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
/** The tag on an approval prompt. */
|
|
28
|
+
export const badge = (risk) => ({ command: ' shell ', mcp: ' tool ', trust: ' project ' })[risk] ?? ' outside project ';
|
|
29
|
+
|
|
27
30
|
export class Plain {
|
|
28
31
|
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
29
32
|
this.cwd = cwd;
|
|
@@ -293,18 +296,18 @@ export class Plain {
|
|
|
293
296
|
return this.promptWith(`${blue('› ')}`);
|
|
294
297
|
}
|
|
295
298
|
|
|
296
|
-
async confirm({ action, detail, risk }) {
|
|
299
|
+
async confirm({ action, detail, risk, always }) {
|
|
297
300
|
this.stopSpinner();
|
|
298
|
-
|
|
299
|
-
this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
|
|
301
|
+
this.output.write(`\n${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}\n`);
|
|
300
302
|
for (const line of String(detail ?? '').split('\n')) {
|
|
301
303
|
if (line) this.output.write(dim(` ${line}\n`));
|
|
302
304
|
}
|
|
303
305
|
|
|
304
|
-
const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
|
|
306
|
+
const answer = await this.promptWith(`${blue(' go ahead? ')}${dim(always ? `[y/N, a = always allow ${always}] ` : '[y/N] ')}`);
|
|
305
307
|
// End of input is a no: never run something nobody approved.
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
+
const said = String(answer ?? '').trim();
|
|
309
|
+
const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
|
|
310
|
+
this.output.write(dim(yes === 'always' ? ` approved — ${always} is always allowed here now\n\n` : yes ? ' approved\n\n' : ' declined\n\n'));
|
|
308
311
|
return yes;
|
|
309
312
|
}
|
|
310
313
|
|
package/src/ui/screen.js
CHANGED
|
@@ -40,10 +40,11 @@ import {
|
|
|
40
40
|
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
42
|
shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
|
|
43
|
-
bannerPaint, RAIL, modeChip, ADD_CHIP, asNarrationLine } from './theme.js';
|
|
43
|
+
bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
|
|
44
44
|
import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
|
|
45
45
|
import { gitBranch } from '../core/git.js';
|
|
46
46
|
import { renderer, render } from './markdown.js';
|
|
47
|
+
import { badge } from './plain.js';
|
|
47
48
|
import { VERSION } from '../core/version.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -60,7 +61,7 @@ export function isLabel(text) {
|
|
|
60
61
|
export const COMMANDS = [
|
|
61
62
|
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
62
63
|
'/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
63
|
-
'/stats', '/doctor', '/deploy', '/look', '/undo',
|
|
64
|
+
'/stats', '/doctor', '/deploy', '/look', '/undo', '/mic',
|
|
64
65
|
];
|
|
65
66
|
|
|
66
67
|
// ANSI ----------------------------------------------------------------------
|
|
@@ -215,6 +216,12 @@ export class Screen {
|
|
|
215
216
|
this.plusTo = 0;
|
|
216
217
|
this.attachments = [];
|
|
217
218
|
this.onAttach = null;
|
|
219
|
+
// The mic button: null when idle, else 'starting' | 'listening' | 'writing'.
|
|
220
|
+
// Ctrl+T starts and stops it; while it listens, enter stops and sends.
|
|
221
|
+
this.micFrom = 0;
|
|
222
|
+
this.micTo = 0;
|
|
223
|
+
this.listening = null;
|
|
224
|
+
this.onMic = null;
|
|
218
225
|
this.onInterrupt = null;
|
|
219
226
|
this.onModeChange = null;
|
|
220
227
|
this.spinTimer = null;
|
|
@@ -808,7 +815,7 @@ export class Screen {
|
|
|
808
815
|
['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
|
|
809
816
|
branch && ['branch', value(branch)],
|
|
810
817
|
VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
|
|
811
|
-
['keys', value('/help ·
|
|
818
|
+
['keys', value('/help · ctrl+t mic · ctrl+o file · ctrl+b plan · esc stops')],
|
|
812
819
|
].filter(Boolean).slice(0, BANNER.length - 1);
|
|
813
820
|
|
|
814
821
|
while (facts.length < BANNER.length - 1) facts.push(['', '']);
|
|
@@ -986,7 +993,8 @@ export class Screen {
|
|
|
986
993
|
statusRow(width = this.width()) {
|
|
987
994
|
const inner = width - 2; // the space between the two borders
|
|
988
995
|
const chip = this.modeChip();
|
|
989
|
-
const
|
|
996
|
+
const mic = micChip(this.listening);
|
|
997
|
+
const buttons = ` ${chip} ${ADD_CHIP} ${mic}`;
|
|
990
998
|
|
|
991
999
|
// How long the turn has taken, back in the box beside the other two facts
|
|
992
1000
|
// about the session. It is not on the live line: that line says what is
|
|
@@ -996,16 +1004,24 @@ export class Screen {
|
|
|
996
1004
|
const since = this.activity?.start ?? this.status.since ?? 0;
|
|
997
1005
|
const running = this.busy() && since && now - since >= 1000;
|
|
998
1006
|
const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
|
|
1007
|
+
// On a narrow terminal the model name goes before any button does.
|
|
1008
|
+
const withModel = `${buttons} ${chalk.white(this.model || '—')}`;
|
|
1009
|
+
const left = visLen(withModel) + visLen(right) < inner ? withModel : buttons;
|
|
999
1010
|
|
|
1000
1011
|
// Where a click on the bottom row still counts as hitting the mode chip.
|
|
1001
1012
|
this.chipTo = 2 + visLen(chip);
|
|
1002
1013
|
this.plusFrom = this.chipTo + 3; // after the two spaces
|
|
1003
1014
|
this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
|
|
1015
|
+
this.micFrom = this.plusTo + 3;
|
|
1016
|
+
this.micTo = this.micFrom + visLen(mic) - 1;
|
|
1004
1017
|
|
|
1005
1018
|
const between = Math.max(1, inner - visLen(left) - visLen(right));
|
|
1006
1019
|
const files = this.attachments.map((f) => path.basename(f)).join(', ');
|
|
1007
|
-
|
|
1008
|
-
|
|
1020
|
+
// Room for the three spaces after it and at least one before.
|
|
1021
|
+
const room = between - 4;
|
|
1022
|
+
const middle = this.listening === 'listening' ? sky(clip('speak now · enter sends · ctrl+t stops · esc cancels', room))
|
|
1023
|
+
: this.flashText ? dim(clip(this.flashText, room))
|
|
1024
|
+
: files ? sky(clip(`+ ${files}`, room)) : '';
|
|
1009
1025
|
|
|
1010
1026
|
const tail = middle ? `${middle} ` : '';
|
|
1011
1027
|
const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
|
|
@@ -1188,22 +1204,23 @@ export class Screen {
|
|
|
1188
1204
|
}
|
|
1189
1205
|
|
|
1190
1206
|
/** y/n, answered on the input line. */
|
|
1191
|
-
confirm({ action, detail, risk }) {
|
|
1207
|
+
confirm({ action, detail, risk, always }) {
|
|
1192
1208
|
this.push('');
|
|
1193
|
-
this.push(`${chalk.inverse(theme.warn(risk
|
|
1209
|
+
this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}`);
|
|
1194
1210
|
for (const line of String(detail ?? '').split('\n')) {
|
|
1195
1211
|
if (line) this.push(dim(` ${line}`));
|
|
1196
1212
|
}
|
|
1197
1213
|
|
|
1198
|
-
this.pendingPrompt = 'go ahead? [y/N]';
|
|
1214
|
+
this.pendingPrompt = always ? `go ahead? [y/N, a = always allow ${always}]` : 'go ahead? [y/N]';
|
|
1199
1215
|
this.scroll = 0; // the question has to be on screen to be answered
|
|
1200
1216
|
this.render();
|
|
1201
1217
|
|
|
1202
1218
|
return this.nextLine().then((answer) => {
|
|
1203
1219
|
this.pendingPrompt = null;
|
|
1204
1220
|
// End of input counts as no. Never run something nobody approved.
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1221
|
+
const said = String(answer ?? '').trim();
|
|
1222
|
+
const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
|
|
1223
|
+
this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
|
|
1207
1224
|
this.push('');
|
|
1208
1225
|
return yes;
|
|
1209
1226
|
});
|
|
@@ -1430,12 +1447,30 @@ export class Screen {
|
|
|
1430
1447
|
if (row !== statusRow) return;
|
|
1431
1448
|
if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
|
|
1432
1449
|
else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
|
|
1450
|
+
else if (col >= g.left + this.micFrom && col <= g.left + this.micTo) this.onMic?.('toggle');
|
|
1433
1451
|
return;
|
|
1434
1452
|
}
|
|
1435
|
-
// The mode chip
|
|
1453
|
+
// The mode chip, "+ file" and mic buttons, at the left of the bottom row.
|
|
1436
1454
|
if (row !== this.rows - 1) return;
|
|
1437
1455
|
if (col >= 2 && col <= this.chipTo) this.toggleMode();
|
|
1438
1456
|
else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
|
|
1457
|
+
else if (col >= this.micFrom && col <= this.micTo) this.onMic?.('toggle');
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** The mic's state, for the chip: null, 'starting', 'listening' or 'writing'. */
|
|
1461
|
+
setListening(state) {
|
|
1462
|
+
this.listening = state;
|
|
1463
|
+
this.render();
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Words from the mic, into the box at the cursor; `send` presses enter for them. */
|
|
1467
|
+
insertText(text, { send = false } = {}) {
|
|
1468
|
+
const before = this.buffer.slice(0, this.cursor);
|
|
1469
|
+
const joined = before && !before.endsWith(' ') ? `${before} ${text}` : before + text;
|
|
1470
|
+
this.buffer = joined + this.buffer.slice(this.cursor);
|
|
1471
|
+
this.cursor = joined.length;
|
|
1472
|
+
if (send) this.onKey('\r');
|
|
1473
|
+
else this.render();
|
|
1439
1474
|
}
|
|
1440
1475
|
|
|
1441
1476
|
/** A file chosen with "+ file", to go with the next message. */
|
|
@@ -1470,6 +1505,12 @@ export class Screen {
|
|
|
1470
1505
|
return;
|
|
1471
1506
|
}
|
|
1472
1507
|
|
|
1508
|
+
// While the mic listens, enter means "done, send it" and esc means "never mind".
|
|
1509
|
+
if (this.listening === 'listening') {
|
|
1510
|
+
if (key === '\r' || key === '\n') { this.onMic?.('send'); return; }
|
|
1511
|
+
if (key === ESC || key === '\x03') { this.onMic?.('cancel'); return; }
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1473
1514
|
switch (key) {
|
|
1474
1515
|
case '\r':
|
|
1475
1516
|
case '\n': {
|
|
@@ -1516,6 +1557,10 @@ export class Screen {
|
|
|
1516
1557
|
this.onAttach?.();
|
|
1517
1558
|
return;
|
|
1518
1559
|
|
|
1560
|
+
case '\x14': // ctrl+t — the mic button: start listening, or stop and put the words in the box
|
|
1561
|
+
this.onMic?.('toggle');
|
|
1562
|
+
return;
|
|
1563
|
+
|
|
1519
1564
|
case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
|
|
1520
1565
|
if (!this.buffer) this.attachments = [];
|
|
1521
1566
|
this.buffer = this.buffer.slice(this.cursor);
|
package/src/ui/theme.js
CHANGED
|
@@ -130,6 +130,13 @@ export const modeChip = (mode) =>
|
|
|
130
130
|
/** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
|
|
131
131
|
export const ADD_CHIP = PLAN_CHIP(' + file ');
|
|
132
132
|
|
|
133
|
+
/** The mic button beside it: quiet when idle, red while it is listening. */
|
|
134
|
+
const LISTEN_CHIP = chalk.bgHex('#e5484d').hex('#ffffff').bold;
|
|
135
|
+
export const micChip = (state) =>
|
|
136
|
+
state === 'listening' ? LISTEN_CHIP(' ● listening ')
|
|
137
|
+
: state ? PLAN_CHIP(` ${state === 'starting' ? 'starting' : 'writing'}… `)
|
|
138
|
+
: PLAN_CHIP(' mic ');
|
|
139
|
+
|
|
133
140
|
/** The spinner. Braille dots, because they animate in place without jitter. */
|
|
134
141
|
export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
135
142
|
|
|
@@ -787,8 +794,30 @@ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did n
|
|
|
787
794
|
const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
|
|
788
795
|
const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
|
|
789
796
|
|
|
797
|
+
/**
|
|
798
|
+
* Words that say nothing about this app, because they are said about every
|
|
799
|
+
* app: "a sleek, modern and intuitive interface for a seamless experience".
|
|
800
|
+
*/
|
|
801
|
+
export const STOCK = /\b(?:seamless(?:ly)?|sleek|modern and clean|clean and modern|robust|intuitive|user-friendly|cutting-edge|state-of-the-art|elevate[sd]?|effortless(?:ly)?|stunning|delightful|vibrant|happy coding|feel free to|i hope (?:this|you)|let me know if)\b/i;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* The closing message without its stock sentences. A sentence goes only when
|
|
805
|
+
* it is pure filler: one that says how to try the app, or admits something is
|
|
806
|
+
* unfinished, stays whatever words it uses. Never returns nothing.
|
|
807
|
+
*/
|
|
808
|
+
export function withoutStock(text) {
|
|
809
|
+
const lines = String(text ?? '').replace(/\r/g, '').split('\n');
|
|
810
|
+
const out = [];
|
|
811
|
+
for (const line of lines) {
|
|
812
|
+
if (!STOCK.test(line)) { out.push(line); continue; }
|
|
813
|
+
const kept = line.split(/(?<=[.!?])\s+/).filter((s) => !STOCK.test(s) || ACTIONABLE.test(s) || CAVEAT.test(s));
|
|
814
|
+
if (kept.length) out.push(kept.join(' '));
|
|
815
|
+
}
|
|
816
|
+
return out.join('\n').trim() || lines.join('\n').trim();
|
|
817
|
+
}
|
|
818
|
+
|
|
790
819
|
export function trimAnswer(text, max = ANSWER_LINES) {
|
|
791
|
-
const all =
|
|
820
|
+
const all = withoutStock(text).split('\n');
|
|
792
821
|
|
|
793
822
|
let start = 0;
|
|
794
823
|
while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
|
package/ucode.js
CHANGED
|
@@ -39,6 +39,9 @@ function parseArgs(argv) {
|
|
|
39
39
|
else if (a === '--cwd' || a === '-C') args.cwd = path.resolve(argv[++i]);
|
|
40
40
|
else if (a === '--help' || a === '-h') args.help = true;
|
|
41
41
|
else if (a === '--version' || a === '-v') args.version = true;
|
|
42
|
+
else if (a === '--print' || a === '-p') args.print = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : '-';
|
|
43
|
+
else if (a === '--json') args.json = true;
|
|
44
|
+
else if (a === '--yes' || a === '-y') args.yes = true;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
return args;
|
|
@@ -59,10 +62,16 @@ async function usage() {
|
|
|
59
62
|
' -C, --cwd <dir> work in another directory\n' +
|
|
60
63
|
' --plan start in plan mode: read and research, change nothing\n' +
|
|
61
64
|
' --debug print stack traces when something breaks\n' +
|
|
65
|
+
' -p, --print <task> do one task with no keyboard, print the answer, exit\n' +
|
|
66
|
+
' --json with -p: print one JSON object about the run instead\n' +
|
|
67
|
+
' -y, --yes with -p: say yes to anything that would be asked\n' +
|
|
62
68
|
' -v, --version print the version\n' +
|
|
63
69
|
' -h, --help this message\n' +
|
|
64
70
|
' doctor check that everything ucode needs is working\n' +
|
|
65
|
-
' login <key> save your key for every folder on this machine\n
|
|
71
|
+
' login <key> save your key for every folder on this machine\n' +
|
|
72
|
+
' mcp add <name> <command> [args...] connect an MCP server (--url <url> for a remote one)\n' +
|
|
73
|
+
' mcp list | mcp remove <name>\n\n' +
|
|
74
|
+
` ${sky('Other models')} UCODE_BASE_URL=http://localhost:11434/v1 UCODE_MODEL=<model> ucode (Ollama, offline)\n\n` +
|
|
66
75
|
` ${sky('Models')}\n${models}\n\n` +
|
|
67
76
|
` Needs GEMINI_API_KEY in the environment or in ${ENV_FILE}\n` +
|
|
68
77
|
' Free keys: https://aistudio.google.com/apikey\n\n'
|
|
@@ -92,6 +101,33 @@ async function main() {
|
|
|
92
101
|
return;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
if (process.argv[2] === 'mcp') {
|
|
105
|
+
const { mcpCommand } = await import('./src/core/mcpcli.js');
|
|
106
|
+
process.stdout.write(`${await mcpCommand(process.argv.slice(3))}\n`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (args.print !== undefined) {
|
|
111
|
+
let prompt = args.print;
|
|
112
|
+
if (prompt === '-') {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
115
|
+
prompt = Buffer.concat(chunks).toString('utf8').trim();
|
|
116
|
+
}
|
|
117
|
+
if (!prompt) {
|
|
118
|
+
process.stderr.write('ucode -p needs a task: ucode -p "fix the failing test"\n');
|
|
119
|
+
process.exitCode = 2;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const [, { setModel }] = await heavy();
|
|
123
|
+
if (args.model) setModel(args.model);
|
|
124
|
+
const { runHeadless } = await import('./src/core/headless.js');
|
|
125
|
+
const code = await runHeadless({ cwd: args.cwd, prompt, json: args.json, yes: args.yes, plan: args.plan });
|
|
126
|
+
// A one-shot run ends here, whatever handle something left open.
|
|
127
|
+
process.stdout.write('', () => process.exit(code));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
95
131
|
// Update before the session starts, not after it ends.
|
|
96
132
|
//
|
|
97
133
|
// Installing in the background and taking effect "next time" means the run
|