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/core/loop.js
CHANGED
|
@@ -28,11 +28,12 @@ import { spawn } from 'node:child_process';
|
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
30
|
ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
|
|
31
|
-
estimateConversation, MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor, backupFor,
|
|
31
|
+
estimateConversation, MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor, backupFor, transcribe,
|
|
32
|
+
requestCount, customProvider,
|
|
32
33
|
} from './provider.js';
|
|
33
34
|
import {
|
|
34
35
|
tools, runTool, describe, setRoot, setConfirm, setRequest,
|
|
35
|
-
PARALLEL_SAFE, WRITES, FILE_WRITES,
|
|
36
|
+
PARALLEL_SAFE, WRITES, FILE_WRITES, MUTATING,
|
|
36
37
|
} from '../tools/index.js';
|
|
37
38
|
import { projectMap, loadMemory, hasCode, remember, MEMORY_FILE } from './context.js';
|
|
38
39
|
import { autoUpdate } from './updater.js';
|
|
@@ -52,12 +53,20 @@ import { serversReadySince } from '../tools/shell.js';
|
|
|
52
53
|
import { formatDuration } from '../ui/activity.js';
|
|
53
54
|
import { MAX_FILE_OUTPUT } from '../tools/shared.js';
|
|
54
55
|
import { openInBrowser } from './opener.js';
|
|
55
|
-
import { withScope, isNoise } from './scope.js';
|
|
56
|
+
import { withScope, isNoise, asksToBuild, asksToChange } from './scope.js';
|
|
56
57
|
import { chooseFile, projectFiles, loadAttachments } from './attach.js';
|
|
58
|
+
import { startRecording, isSilent, cleanTranscript, MAX_RECORD_MS } from './voice.js';
|
|
57
59
|
import { runDoctor } from './doctor.js';
|
|
58
60
|
import { JS_LOGIC } from './jslogic.js';
|
|
59
61
|
import { deploy } from '../tools/deploy.js';
|
|
60
62
|
import { normaliseFiles } from '../tools/scaffold.js';
|
|
63
|
+
import { setPolicy, allowedNow, confirm } from '../tools/shared.js';
|
|
64
|
+
import { genericLook, genericMessage } from './genericcheck.js';
|
|
65
|
+
import { noteMistakes, lessonsText } from './lessons.js';
|
|
66
|
+
import { Snapshots } from './snapshot.js';
|
|
67
|
+
import { loadSettings, addAllow, isTrusted, trust, runHook } from './settings.js';
|
|
68
|
+
import { McpHub, readServers, USER_MCP, projectMcpFile } from './mcp.js';
|
|
69
|
+
import { loadCommands, expandCommand } from './commands.js';
|
|
61
70
|
|
|
62
71
|
/**
|
|
63
72
|
* Tool calls allowed in one turn.
|
|
@@ -123,7 +132,10 @@ const LOOKUP_NUDGE = 5;
|
|
|
123
132
|
/** A file with a page in it — something a browser can be pointed at. */
|
|
124
133
|
const PAGE = /\.(?:html?|tsx|jsx)$/i;
|
|
125
134
|
|
|
126
|
-
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html
|
|
135
|
+
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?|go|rs)$/i;
|
|
136
|
+
|
|
137
|
+
/** The model that is asked, with the user's say-so, when Flash-Lite cannot fix something. */
|
|
138
|
+
const STRONGER = 'gemini-3.5-flash';
|
|
127
139
|
|
|
128
140
|
/** Where TypeScript keeps what it learned, so the next check is a quick one. */
|
|
129
141
|
export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
|
|
@@ -337,7 +349,7 @@ function trace(event) {
|
|
|
337
349
|
function newStats() {
|
|
338
350
|
return {
|
|
339
351
|
started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
|
|
340
|
-
tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
|
|
352
|
+
tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0, requests: 0,
|
|
341
353
|
};
|
|
342
354
|
}
|
|
343
355
|
|
|
@@ -367,6 +379,7 @@ export function statsLines(st, messages) {
|
|
|
367
379
|
const rows = [
|
|
368
380
|
['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
|
|
369
381
|
['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
|
|
382
|
+
...(st.requests ? [['Requests', `${n(st.requests)} sent — what the free daily limit counts`]] : []),
|
|
370
383
|
['Tools', top],
|
|
371
384
|
['Files', `${st.written} written · ${st.edited} edited`],
|
|
372
385
|
['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
|
|
@@ -575,7 +588,7 @@ const loadSkillTool = {
|
|
|
575
588
|
},
|
|
576
589
|
};
|
|
577
590
|
|
|
578
|
-
function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
591
|
+
function systemPrompt({ cwd, skills, mode, check, map, memory, lessons }) {
|
|
579
592
|
const list = catalogue(skills);
|
|
580
593
|
|
|
581
594
|
return [
|
|
@@ -591,6 +604,14 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
591
604
|
'',
|
|
592
605
|
memory,
|
|
593
606
|
] : []),
|
|
607
|
+
...(lessons ? [
|
|
608
|
+
'',
|
|
609
|
+
'## Mistakes ucode has caught in earlier builds',
|
|
610
|
+
'',
|
|
611
|
+
'These keep coming up. Avoid them from the start - each one caught later costs a fix round.',
|
|
612
|
+
'',
|
|
613
|
+
lessons,
|
|
614
|
+
] : []),
|
|
594
615
|
'',
|
|
595
616
|
'## How to work',
|
|
596
617
|
'',
|
|
@@ -598,6 +619,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
598
619
|
'A greeting, a question, a single word, or anything unclear gets a short plain',
|
|
599
620
|
'answer - or one question back about what they would like - never an app. "What',
|
|
600
621
|
'can you do?" is answered in words; "make a quiz app" is built.',
|
|
622
|
+
'A question about this project or its code is answered from the files, never from a guess:',
|
|
623
|
+
'read the README and the files involved first (one read_files), then answer.',
|
|
601
624
|
'',
|
|
602
625
|
'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
|
|
603
626
|
'what you built as someone using it for the first time: is the button inside its',
|
|
@@ -865,6 +888,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
865
888
|
'- Point at code as path:line so the user can jump straight to it.',
|
|
866
889
|
'- Report honestly. If a command failed or you skipped something, say so.',
|
|
867
890
|
'- Length tracks the question: a one-line question gets a one-line answer.',
|
|
891
|
+
'- Never call your work sleek, seamless, modern, intuitive, robust or stunning. Those',
|
|
892
|
+
' words are said about every app; say what this one does instead.',
|
|
868
893
|
'- Brevity is about your prose and never about your work. What you build is',
|
|
869
894
|
' finished: every control wired, every state handled, no TODO left behind.',
|
|
870
895
|
...(list ? [
|
|
@@ -1011,6 +1036,82 @@ export class Agent {
|
|
|
1011
1036
|
});
|
|
1012
1037
|
this.skills = await loadSkills({ cwd: this.cwd });
|
|
1013
1038
|
await this.detectCheck();
|
|
1039
|
+
await this.loadSettings();
|
|
1040
|
+
this.snaps = new Snapshots(this.cwd);
|
|
1041
|
+
this.undoStack = [];
|
|
1042
|
+
this.commands = await loadCommands(this.cwd);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** settings.json, both of them: what runs without asking, and the hooks. */
|
|
1046
|
+
async loadSettings() {
|
|
1047
|
+
this.settings = await loadSettings(this.cwd);
|
|
1048
|
+
setPolicy({
|
|
1049
|
+
askCommands: this.settings.commands === 'ask',
|
|
1050
|
+
allow: this.settings.allow,
|
|
1051
|
+
onAlways: (entry) => addAllow(this.cwd, entry),
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* MCP servers: yours start at once, in the background, so the first prompt
|
|
1057
|
+
* never waits on one; this project's wait until you have approved them, the
|
|
1058
|
+
* first time a turn runs (see trustProject). Never throws.
|
|
1059
|
+
*/
|
|
1060
|
+
startMcp({ project = false } = {}) {
|
|
1061
|
+
const run = async () => {
|
|
1062
|
+
const specs = project
|
|
1063
|
+
? await readServers(projectMcpFile(this.cwd))
|
|
1064
|
+
: await readServers(USER_MCP);
|
|
1065
|
+
const fresh = specs.filter((s) => !this.mcp?.servers.has(s.name));
|
|
1066
|
+
if (!fresh.length) return;
|
|
1067
|
+
this.mcp ??= new McpHub();
|
|
1068
|
+
const status = await this.mcp.start(fresh, { cwd: this.cwd });
|
|
1069
|
+
for (const s of status.filter((x) => fresh.some((f) => f.name === x.name))) {
|
|
1070
|
+
this.ui.note(s.ok
|
|
1071
|
+
? `MCP: ${s.name} connected · ${s.tools} tool${s.tools === 1 ? '' : 's'}`
|
|
1072
|
+
: `MCP: ${s.name} did not start — ${s.error}`);
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
const started = run().catch((err) => this.ui.note(`MCP: ${err.message}`));
|
|
1076
|
+
this.mcpStarting = Promise.all([this.mcpStarting, started]);
|
|
1077
|
+
return started;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* A project's own hooks and MCP servers run commands on this computer, so
|
|
1082
|
+
* they wait for a yes - once, and again whenever they change.
|
|
1083
|
+
*/
|
|
1084
|
+
async trustProject() {
|
|
1085
|
+
if (this.trustChecked) return;
|
|
1086
|
+
this.trustChecked = true;
|
|
1087
|
+
const hooks = this.settings?.projectHooks ?? { afterEdit: [], beforeCommand: [] };
|
|
1088
|
+
const servers = await readServers(projectMcpFile(this.cwd));
|
|
1089
|
+
const wanted = { hooks, servers };
|
|
1090
|
+
const asks = [
|
|
1091
|
+
...hooks.afterEdit.map((h) => `after each edit: ${h}`),
|
|
1092
|
+
...hooks.beforeCommand.map((h) => `before each command: ${h}`),
|
|
1093
|
+
...servers.map((s) => `MCP server ${s.name}: ${s.url ?? [s.command, ...(s.args ?? [])].join(' ')}`),
|
|
1094
|
+
];
|
|
1095
|
+
if (!asks.length) return;
|
|
1096
|
+
let yes = await isTrusted(this.cwd, 'project', wanted);
|
|
1097
|
+
if (!yes) {
|
|
1098
|
+
yes = await Promise.resolve(this.ui.confirm({
|
|
1099
|
+
action: 'this project wants to run its own commands',
|
|
1100
|
+
detail: `${asks.join('\n')}\nFrom .ucode/ in this folder. Only say yes if you trust where it came from.`,
|
|
1101
|
+
risk: 'trust',
|
|
1102
|
+
})).catch(() => false);
|
|
1103
|
+
if (yes) await trust(this.cwd, 'project', wanted).catch(() => {});
|
|
1104
|
+
}
|
|
1105
|
+
this.projectTrusted = Boolean(yes);
|
|
1106
|
+
if (yes && servers.length) this.startMcp({ project: true });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** The hooks that may run: yours, and the project's once approved. */
|
|
1110
|
+
hooks(kind) {
|
|
1111
|
+
return [
|
|
1112
|
+
...(this.settings?.hooks?.[kind] ?? []),
|
|
1113
|
+
...(this.projectTrusted ? this.settings?.projectHooks?.[kind] ?? [] : []),
|
|
1114
|
+
];
|
|
1014
1115
|
}
|
|
1015
1116
|
|
|
1016
1117
|
async start() {
|
|
@@ -1028,6 +1129,7 @@ export class Agent {
|
|
|
1028
1129
|
};
|
|
1029
1130
|
this.ui.onModeChange = () => this.showHeader({ clear: false });
|
|
1030
1131
|
this.ui.onAttach = () => { this.attachFile().catch(() => {}); };
|
|
1132
|
+
this.ui.onMic = (action) => { this.mic(action).catch((err) => this.ui.error(err, { debug: this.debug })); };
|
|
1031
1133
|
}
|
|
1032
1134
|
|
|
1033
1135
|
for (const problem of this.skills.problems ?? []) {
|
|
@@ -1036,6 +1138,7 @@ export class Agent {
|
|
|
1036
1138
|
|
|
1037
1139
|
this.showHeader();
|
|
1038
1140
|
this.installSignals();
|
|
1141
|
+
this.startMcp();
|
|
1039
1142
|
|
|
1040
1143
|
// Checked in the background; nothing here waits on it.
|
|
1041
1144
|
autoUpdate({
|
|
@@ -1166,6 +1269,7 @@ export class Agent {
|
|
|
1166
1269
|
|
|
1167
1270
|
async shutdown() {
|
|
1168
1271
|
await closeBrowser().catch(() => {});
|
|
1272
|
+
this.mcp?.close();
|
|
1169
1273
|
this.ui.stopSpinner();
|
|
1170
1274
|
if (this.session.messages.length) {
|
|
1171
1275
|
await this.persist();
|
|
@@ -1245,6 +1349,20 @@ export class Agent {
|
|
|
1245
1349
|
this.declines = 0;
|
|
1246
1350
|
this.apps = [];
|
|
1247
1351
|
this.wrote = new Map();
|
|
1352
|
+
this.genericChecked = false;
|
|
1353
|
+
this.escalated = false;
|
|
1354
|
+
this.lastProblems = null;
|
|
1355
|
+
this.planItems = [];
|
|
1356
|
+
this.askedAboutPlan = false;
|
|
1357
|
+
this.mutated = false;
|
|
1358
|
+
this.building = asksToBuild(input);
|
|
1359
|
+
this.changing = asksToChange(input);
|
|
1360
|
+
const sentBefore = requestCount();
|
|
1361
|
+
// The whole project as it is now, for /undo — taken while the model
|
|
1362
|
+
// thinks, so nothing waits on it; the first write waits for it (dispatch).
|
|
1363
|
+
this.snapshotting = (this.snaps?.take(`before: ${titleFrom(input)}`) ?? Promise.resolve(null))
|
|
1364
|
+
.then((id) => (id ? { id, input } : null), () => null);
|
|
1365
|
+
if (this.snaps) await this.trustProject().catch(() => {});
|
|
1248
1366
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1249
1367
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1250
1368
|
const images = await this.attachImages(input);
|
|
@@ -1266,14 +1384,15 @@ export class Agent {
|
|
|
1266
1384
|
this.session.title = titleFrom(input);
|
|
1267
1385
|
}
|
|
1268
1386
|
// What the model is told about the project, fresh for this turn.
|
|
1269
|
-
[this.map, this.memory] = await Promise.all([
|
|
1387
|
+
[this.map, this.memory, this.lessons] = await Promise.all([
|
|
1270
1388
|
projectMap(this.cwd).catch(() => ''),
|
|
1271
1389
|
loadMemory(this.cwd).catch(() => ''),
|
|
1390
|
+
lessonsText().catch(() => ''),
|
|
1272
1391
|
]);
|
|
1273
1392
|
// Nothing to look up in an empty folder, so those tools do not go out with
|
|
1274
1393
|
// the request. Decided per turn: the moment there is code, they are back.
|
|
1275
1394
|
this.fresh = !hasCode(this.map);
|
|
1276
|
-
request.content = withScope(input) + added.text;
|
|
1395
|
+
request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
|
|
1277
1396
|
this.wantsWeb = WANTS_WEB.test(input);
|
|
1278
1397
|
await this.persist();
|
|
1279
1398
|
|
|
@@ -1313,6 +1432,10 @@ export class Agent {
|
|
|
1313
1432
|
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
1314
1433
|
this.stats.workMs += Date.now() - turnStarted;
|
|
1315
1434
|
this.stats.turns++;
|
|
1435
|
+
this.stats.requests += requestCount() - sentBefore;
|
|
1436
|
+
// A turn that changed nothing has nothing for /undo to take back.
|
|
1437
|
+
const before = await this.snapshotting;
|
|
1438
|
+
if (before && this.mutated) this.undoStack.push(before);
|
|
1316
1439
|
if (!finished) this.closeInterrupted();
|
|
1317
1440
|
const ok = finished && !this.endedSilently;
|
|
1318
1441
|
this.busy = false;
|
|
@@ -1344,10 +1467,59 @@ export class Agent {
|
|
|
1344
1467
|
const live = this.fresh
|
|
1345
1468
|
? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
|
|
1346
1469
|
: all;
|
|
1347
|
-
|
|
1470
|
+
// MCP tools can do anything their server does, so a read-only turn gets none.
|
|
1471
|
+
if (this.ui.mode !== 'plan' && !this.readOnly) return [...live, ...(this.mcp?.tools() ?? [])];
|
|
1348
1472
|
return live.filter((t) => !WRITES.has(t.name));
|
|
1349
1473
|
}
|
|
1350
1474
|
|
|
1475
|
+
/**
|
|
1476
|
+
* How hard the model thinks on this step (see provider.js for the numbers).
|
|
1477
|
+
* The first step of a build or a change is where the plan and the design
|
|
1478
|
+
* get decided, so it gets more; a fix that has already failed once gets the
|
|
1479
|
+
* most; everything else gets the cheap level that is still real thinking.
|
|
1480
|
+
*/
|
|
1481
|
+
effortFor(step, fixRounds) {
|
|
1482
|
+
if (this.fixing && fixRounds >= 2) return 'high';
|
|
1483
|
+
if (step === 0 && (this.building || this.changing)) return 'medium';
|
|
1484
|
+
return 'low';
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Everything that goes with sending problems back to the model: counting
|
|
1489
|
+
* them for next time, noticing when the last fix did not work, and — once,
|
|
1490
|
+
* with the user's say-so — moving a stuck Flash-Lite to the stronger model.
|
|
1491
|
+
* Returns text to add to the fix-round message.
|
|
1492
|
+
*/
|
|
1493
|
+
async beforeFixRound(problems, round) {
|
|
1494
|
+
noteMistakes(problems).catch(() => {});
|
|
1495
|
+
|
|
1496
|
+
const keys = new Set(String(problems).split('\n').map((l) => l.trim()).filter((l) => l.length > 12));
|
|
1497
|
+
const repeated = this.lastProblems && [...keys].some((k) => this.lastProblems.has(k));
|
|
1498
|
+
this.lastProblems = keys;
|
|
1499
|
+
let extra = repeated
|
|
1500
|
+
? '\n\nThe same problem came back after your last fix, so that fix did not work. Do not repeat ' +
|
|
1501
|
+
'it: read the failing code again and take a different approach.'
|
|
1502
|
+
: '';
|
|
1503
|
+
|
|
1504
|
+
// Only when a fix has already failed: a new problem is not being stuck.
|
|
1505
|
+
if (repeated && round >= 2 && !this.escalated && /flash-lite/.test(model()) && MODELS[STRONGER] && !customProvider()) {
|
|
1506
|
+
this.escalated = true;
|
|
1507
|
+
const yes = await Promise.resolve(this.ui.confirm({
|
|
1508
|
+
action: `${modelName()} is stuck on this. Use ${modelName(STRONGER)} for the rest of this job?`,
|
|
1509
|
+
detail: `${modelName(STRONGER)} is smarter, but has only about 20 free requests a day.`,
|
|
1510
|
+
risk: 'model',
|
|
1511
|
+
})).catch(() => false);
|
|
1512
|
+
if (yes) {
|
|
1513
|
+
setModel(STRONGER);
|
|
1514
|
+
this.cooldownUntil = 0; // the next turn goes back to the model you chose
|
|
1515
|
+
this.ui.note(`switched to ${modelName(STRONGER)} for this job`);
|
|
1516
|
+
if (this.full) this.showHeader({ clear: false });
|
|
1517
|
+
extra += '\n\nA stronger model has taken over for this fix.';
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return extra;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1351
1523
|
/** Model, tools, model, until it answers with prose. */
|
|
1352
1524
|
async run() {
|
|
1353
1525
|
const available = this.toolsNow();
|
|
@@ -1379,6 +1551,7 @@ export class Agent {
|
|
|
1379
1551
|
const opts = {
|
|
1380
1552
|
signal: this.abort.signal,
|
|
1381
1553
|
onWait: (text) => this.ui.updateSpinner(text),
|
|
1554
|
+
effort: this.effortFor(step, fixRounds),
|
|
1382
1555
|
};
|
|
1383
1556
|
// Only a real terminal has somewhere to stream into.
|
|
1384
1557
|
if (this.full) {
|
|
@@ -1419,10 +1592,11 @@ export class Agent {
|
|
|
1419
1592
|
content: systemPrompt({
|
|
1420
1593
|
cwd: this.cwd,
|
|
1421
1594
|
skills: this.skills,
|
|
1422
|
-
mode: this.ui.mode,
|
|
1595
|
+
mode: this.readOnly ? 'plan' : this.ui.mode,
|
|
1423
1596
|
check: this.check,
|
|
1424
1597
|
map: this.map,
|
|
1425
1598
|
memory: this.memory,
|
|
1599
|
+
lessons: this.lessons,
|
|
1426
1600
|
}),
|
|
1427
1601
|
},
|
|
1428
1602
|
...dedupe(lean(this.working)),
|
|
@@ -1530,6 +1704,7 @@ export class Agent {
|
|
|
1530
1704
|
if (problems) {
|
|
1531
1705
|
fixRounds++;
|
|
1532
1706
|
this.fixing = true;
|
|
1707
|
+
const extra = await this.beforeFixRound(problems, fixRounds);
|
|
1533
1708
|
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
1534
1709
|
this.push({
|
|
1535
1710
|
role: 'user',
|
|
@@ -1537,12 +1712,29 @@ export class Agent {
|
|
|
1537
1712
|
`ucode checked the files you changed and found errors (round ${fixRounds} of ` +
|
|
1538
1713
|
`${MAX_FIX_ROUNDS}). Fix all of them with the smallest edits that do it, then ` +
|
|
1539
1714
|
'finish. Change nothing else - no restyling, no rewrites of code that works.' +
|
|
1540
|
-
`\n\n${problems}`,
|
|
1715
|
+
`\n\n${problems}${extra}`,
|
|
1541
1716
|
});
|
|
1542
1717
|
continue;
|
|
1543
1718
|
}
|
|
1544
1719
|
}
|
|
1545
1720
|
|
|
1721
|
+
// The plan it wrote, checked against what it ticked. Items left open
|
|
1722
|
+
// at the end are the commonest way part of a request goes missing.
|
|
1723
|
+
// Once a turn, and only when the turn actually changed something.
|
|
1724
|
+
const open = (this.planItems ?? []).filter((i) => !i.done).map((i) => String(i.text).trim());
|
|
1725
|
+
if (open.length && this.touched.size && !this.askedAboutPlan) {
|
|
1726
|
+
this.askedAboutPlan = true;
|
|
1727
|
+
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
1728
|
+
this.push({
|
|
1729
|
+
role: 'user',
|
|
1730
|
+
content:
|
|
1731
|
+
`Your plan still has ${open.length === 1 ? 'this item' : 'these items'} open: ${open.join('; ')}. ` +
|
|
1732
|
+
'Finish them now. If they are already done, tick them with update_plan and finish; if one ' +
|
|
1733
|
+
'cannot be done, say which and why.',
|
|
1734
|
+
});
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1546
1738
|
// It changed code and never ran anything. Send it back once — but
|
|
1547
1739
|
// only if this project's check would actually exercise what changed.
|
|
1548
1740
|
// A page and a stylesheet in a repo that happens to have `npm test`
|
|
@@ -1616,12 +1808,13 @@ export class Agent {
|
|
|
1616
1808
|
if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
|
|
1617
1809
|
fixRounds++;
|
|
1618
1810
|
this.fixing = true;
|
|
1811
|
+
const extra = await this.beforeFixRound(handed.problems, fixRounds);
|
|
1619
1812
|
this.push({
|
|
1620
1813
|
role: 'user',
|
|
1621
1814
|
content:
|
|
1622
1815
|
`ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
|
|
1623
1816
|
'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
|
|
1624
|
-
`ucode hands it to the user.\n\n${handed.problems}`,
|
|
1817
|
+
`ucode hands it to the user.\n\n${handed.problems}${extra}`,
|
|
1625
1818
|
});
|
|
1626
1819
|
await this.persist();
|
|
1627
1820
|
continue;
|
|
@@ -1827,6 +2020,59 @@ export class Agent {
|
|
|
1827
2020
|
}
|
|
1828
2021
|
}
|
|
1829
2022
|
|
|
2023
|
+
/**
|
|
2024
|
+
* The mic button (ctrl+t, a click, or /mic). 'toggle' starts listening, or
|
|
2025
|
+
* stops and puts the words in the box; 'send' stops and sends them; 'cancel'
|
|
2026
|
+
* throws the recording away.
|
|
2027
|
+
*/
|
|
2028
|
+
async mic(action = 'toggle') {
|
|
2029
|
+
const state = this.ui.listening;
|
|
2030
|
+
if (state === 'starting' || state === 'writing') return;
|
|
2031
|
+
|
|
2032
|
+
if (state !== 'listening') {
|
|
2033
|
+
if (action !== 'toggle') return;
|
|
2034
|
+
this.ui.setListening('starting');
|
|
2035
|
+
try {
|
|
2036
|
+
this.recording = await startRecording();
|
|
2037
|
+
} catch (err) {
|
|
2038
|
+
this.ui.setListening(null);
|
|
2039
|
+
this.ui.note(`mic: ${err.message}`);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
this.ui.setListening('listening');
|
|
2043
|
+
this.micTimer = setTimeout(() => this.ui.onMic?.('toggle'), MAX_RECORD_MS);
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
clearTimeout(this.micTimer);
|
|
2048
|
+
// Taken and the state moved on before any await, so a second press
|
|
2049
|
+
// while this one finishes finds nothing to stop.
|
|
2050
|
+
const recording = this.recording;
|
|
2051
|
+
this.recording = null;
|
|
2052
|
+
if (!recording) return;
|
|
2053
|
+
if (action === 'cancel') {
|
|
2054
|
+
this.ui.setListening(null);
|
|
2055
|
+
this.ui.flash('stopped listening');
|
|
2056
|
+
await recording.cancel();
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
this.ui.setListening('writing');
|
|
2061
|
+
let text = '';
|
|
2062
|
+
try {
|
|
2063
|
+
const wav = await recording.stop();
|
|
2064
|
+
if (!wav || isSilent(wav)) {
|
|
2065
|
+
this.ui.note('mic: heard nothing — check the microphone is on, then speak a little louder');
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
text = cleanTranscript(await transcribe(wav));
|
|
2069
|
+
if (!text) this.ui.note('mic: could not make out any words — try again, closer to the microphone');
|
|
2070
|
+
} finally {
|
|
2071
|
+
this.ui.setListening(null);
|
|
2072
|
+
}
|
|
2073
|
+
if (text) this.ui.insertText(text, { send: action === 'send' });
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1830
2076
|
/** Show a URL or a page in the project in the desktop browser (see opener.js). UCODE_OPEN=0 turns it off. */
|
|
1831
2077
|
openInBrowser(target) {
|
|
1832
2078
|
if (!this.full || process.env.UCODE_OPEN === '0') return false;
|
|
@@ -2201,12 +2447,85 @@ export class Agent {
|
|
|
2201
2447
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
2202
2448
|
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
2203
2449
|
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
2450
|
+
if (this.mcp?.has(call.name)) return this.callMcp(call);
|
|
2451
|
+
|
|
2452
|
+
// The snapshot /undo goes back to has to be on disk before anything changes.
|
|
2453
|
+
if (MUTATING.has(call.name)) {
|
|
2454
|
+
this.mutated = true;
|
|
2455
|
+
await this.snapshotting;
|
|
2456
|
+
}
|
|
2457
|
+
if (call.name === 'run_command' || call.name === 'run_commands') await this.beforeCommand(call);
|
|
2204
2458
|
|
|
2205
2459
|
// Output reaches the screen as the command produces it, so a slow build is
|
|
2206
2460
|
// something you watch rather than something you sit out in silence.
|
|
2207
|
-
|
|
2461
|
+
const out = await runTool(call.name, call.args ?? {}, {
|
|
2208
2462
|
onOutput: (lines) => this.ui.progress(lines),
|
|
2209
2463
|
});
|
|
2464
|
+
if (FILE_WRITES.has(call.name)) await this.afterEdit(call, out);
|
|
2465
|
+
return out;
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
/** A tool from an MCP server: asked about first, unless always allowed. */
|
|
2469
|
+
async callMcp(call) {
|
|
2470
|
+
const subject = `mcp:${call.name}`;
|
|
2471
|
+
if (!allowedNow(subject)) {
|
|
2472
|
+
await confirm(`use ${call.name}`, clip(JSON.stringify(call.args ?? {}), 300), 'mcp', subject);
|
|
2473
|
+
}
|
|
2474
|
+
let answer;
|
|
2475
|
+
try {
|
|
2476
|
+
answer = await this.mcp.call(call.name, call.args ?? {});
|
|
2477
|
+
} catch (err) {
|
|
2478
|
+
throw new ToolFailure({
|
|
2479
|
+
kind: 'mcp_failed',
|
|
2480
|
+
attempted: `calling ${call.name}`,
|
|
2481
|
+
failed: err.message,
|
|
2482
|
+
fix: 'Try once more if it looks temporary; otherwise carry on without it and say so.',
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
if (answer.isError) {
|
|
2486
|
+
throw new ToolFailure({
|
|
2487
|
+
kind: 'tool_error',
|
|
2488
|
+
attempted: `calling ${call.name}`,
|
|
2489
|
+
failed: clip(answer.text, 2000),
|
|
2490
|
+
fix: 'Correct the arguments and call it again, or carry on without it.',
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
return { content: answer.text, summary: `${this.mcp.serverOf(call.name)} answered` };
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
/** beforeCommand hooks: any that exits non-zero stops the command. */
|
|
2497
|
+
async beforeCommand(call) {
|
|
2498
|
+
const hooks = this.hooks('beforeCommand');
|
|
2499
|
+
if (!hooks.length) return;
|
|
2500
|
+
const commands = call.name === 'run_commands'
|
|
2501
|
+
? (Array.isArray(call.args?.commands) ? call.args.commands : []).map((c) => c?.command)
|
|
2502
|
+
: [call.args?.command];
|
|
2503
|
+
for (const command of commands.filter(Boolean)) {
|
|
2504
|
+
for (const hook of hooks) {
|
|
2505
|
+
const { code, output } = await runHook(hook, { cwd: this.cwd, env: { UCODE_COMMAND: String(command) } });
|
|
2506
|
+
if (code !== 0) {
|
|
2507
|
+
throw new ToolFailure({
|
|
2508
|
+
kind: 'blocked_by_hook',
|
|
2509
|
+
attempted: `running ${clip(String(command), 80)}`,
|
|
2510
|
+
failed: `A beforeCommand hook stopped it (${hook}): ${clip(output || `exit ${code}`, 500)}`,
|
|
2511
|
+
fix: 'Do it another way, or ask the user to change the hook in .ucode/settings.json.',
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
/** afterEdit hooks (a formatter, say) on the files a write just changed. */
|
|
2519
|
+
async afterEdit(call, out) {
|
|
2520
|
+
const hooks = this.hooks('afterEdit');
|
|
2521
|
+
const files = pathsOf(call);
|
|
2522
|
+
if (!hooks.length || !files.length) return;
|
|
2523
|
+
for (const hook of hooks) {
|
|
2524
|
+
const { code, output } = await runHook(hook, { cwd: this.cwd, files });
|
|
2525
|
+
if (code !== 0 && out && typeof out.content === 'string') {
|
|
2526
|
+
out.content += `\n\n(The afterEdit hook \`${hook}\` failed: ${clip(output || `exit ${code}`, 400)})`;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2210
2529
|
}
|
|
2211
2530
|
|
|
2212
2531
|
/**
|
|
@@ -2265,6 +2584,7 @@ export class Agent {
|
|
|
2265
2584
|
const list = (Array.isArray(items) ? items : [])
|
|
2266
2585
|
.filter((i) => i && String(i.text ?? '').trim())
|
|
2267
2586
|
.slice(0, 6);
|
|
2587
|
+
this.planItems = list;
|
|
2268
2588
|
this.ui.plan(list);
|
|
2269
2589
|
const done = list.filter((i) => i.done).length;
|
|
2270
2590
|
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
@@ -2338,7 +2658,7 @@ export class Agent {
|
|
|
2338
2658
|
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
|
|
2339
2659
|
{ role: 'user', content: String(task.instructions) },
|
|
2340
2660
|
];
|
|
2341
|
-
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
2661
|
+
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name) && !this.mcp?.has(t.name));
|
|
2342
2662
|
const offered = new Set(available.map((t) => t.name));
|
|
2343
2663
|
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
2344
2664
|
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
@@ -2361,7 +2681,7 @@ export class Agent {
|
|
|
2361
2681
|
// again costs far less than carrying the whole history.
|
|
2362
2682
|
thinWorker(messages, contextLimit(workerModel) * 0.6);
|
|
2363
2683
|
try {
|
|
2364
|
-
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
2684
|
+
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel, effort: 'low' });
|
|
2365
2685
|
} catch (err) {
|
|
2366
2686
|
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
2367
2687
|
if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
@@ -2609,6 +2929,13 @@ export class Agent {
|
|
|
2609
2929
|
if (failed) problems.push(failed);
|
|
2610
2930
|
}
|
|
2611
2931
|
|
|
2932
|
+
// A Next.js app is not handed over by handOver, so its look is checked here.
|
|
2933
|
+
const app = this.apps?.at(-1);
|
|
2934
|
+
if (app && this.appTemplate === 'next-shadcn') {
|
|
2935
|
+
const generic = await this.genericOnce(app);
|
|
2936
|
+
if (generic) problems.push(generic);
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2612
2939
|
const live = await this.liveErrors();
|
|
2613
2940
|
if (live) problems.push(live);
|
|
2614
2941
|
|
|
@@ -2714,6 +3041,15 @@ export class Agent {
|
|
|
2714
3041
|
return { problems: `I opened the app and looked at it:\n\n${content}` };
|
|
2715
3042
|
}
|
|
2716
3043
|
|
|
3044
|
+
// It works; now whether it looks like every other generated app. Once a
|
|
3045
|
+
// turn, milliseconds of reading files, and a fix round only on a hit.
|
|
3046
|
+
const generic = await this.genericOnce(app);
|
|
3047
|
+
if (generic) {
|
|
3048
|
+
this.handOverPending = true;
|
|
3049
|
+
this.ui.runStat?.('looks generic');
|
|
3050
|
+
return { problems: generic };
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2717
3053
|
this.handOverPending = false;
|
|
2718
3054
|
// "Tip Calculator is done", with the page as a link the terminal can open.
|
|
2719
3055
|
const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
|
|
@@ -2732,6 +3068,14 @@ export class Agent {
|
|
|
2732
3068
|
return { done: true };
|
|
2733
3069
|
}
|
|
2734
3070
|
|
|
3071
|
+
/** The generated-look check (genericcheck.js) on the app just built, once a turn. */
|
|
3072
|
+
async genericOnce(app) {
|
|
3073
|
+
if (this.genericChecked || process.env.UCODE_DESIGN_CHECK === '0') return null;
|
|
3074
|
+
this.genericChecked = true;
|
|
3075
|
+
const found = await genericLook(app, this.appTemplate ?? 'plain-html').catch(() => []);
|
|
3076
|
+
return found.length ? genericMessage(found) : null;
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2735
3079
|
async lookOnceThisTurn(root, changed) {
|
|
2736
3080
|
// A look that found problems is taken again after the fix round, even if
|
|
2737
3081
|
// the fix only touched the page's script. Looking once a turn let a live
|
|
@@ -2957,19 +3301,146 @@ export class Agent {
|
|
|
2957
3301
|
case '/search': return this.cmdSearch(arg);
|
|
2958
3302
|
case '/copy': return this.cmdCopy();
|
|
2959
3303
|
case '/stats': return this.cmdStats();
|
|
2960
|
-
case '/undo': return this.cmdUndo();
|
|
3304
|
+
case '/undo': return this.cmdUndo(arg);
|
|
2961
3305
|
case '/doctor': return this.cmdDoctor();
|
|
2962
3306
|
case '/look': return this.cmdLook(arg);
|
|
2963
3307
|
case '/deploy': return this.cmdDeploy(arg);
|
|
3308
|
+
case '/init': return this.cmdInit();
|
|
3309
|
+
case '/diff': return this.cmdDiff();
|
|
3310
|
+
case '/commit': return this.cmdCommit(arg);
|
|
3311
|
+
case '/review': return this.cmdReview();
|
|
3312
|
+
case '/mcp': return this.cmdMcp();
|
|
3313
|
+
case '/permissions': return this.cmdPermissions(arg);
|
|
3314
|
+
case '/mic':
|
|
3315
|
+
if (this.ui.onMic) return this.mic('toggle');
|
|
3316
|
+
return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
|
|
2964
3317
|
case '/exit':
|
|
2965
3318
|
case '/quit': return 'exit';
|
|
2966
3319
|
|
|
2967
|
-
default:
|
|
3320
|
+
default: {
|
|
3321
|
+
// One of the user's own, from .ucode/commands/<name>.md.
|
|
3322
|
+
this.commands = await loadCommands(this.cwd);
|
|
3323
|
+
const own = this.commands.get(name.slice(1).toLowerCase());
|
|
3324
|
+
if (own) return this.turn(expandCommand(own.body, arg));
|
|
2968
3325
|
this.ui.write(theme.warn(` no such command: ${name}`));
|
|
2969
3326
|
this.ui.note('/help lists them.');
|
|
3327
|
+
}
|
|
2970
3328
|
}
|
|
2971
3329
|
}
|
|
2972
3330
|
|
|
3331
|
+
/** Run git in the project; resolves to { code, out }. */
|
|
3332
|
+
git(args) {
|
|
3333
|
+
return new Promise((resolve) => {
|
|
3334
|
+
let out = '';
|
|
3335
|
+
const child = spawn('git', args, { cwd: this.cwd, windowsHide: true });
|
|
3336
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
3337
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
3338
|
+
child.on('error', (err) => resolve({ code: -1, out: err.message }));
|
|
3339
|
+
child.on('close', (code) => resolve({ code, out: out.trim() }));
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
/** /init: read the project and write what ucode should know into UCODE.md. */
|
|
3344
|
+
cmdInit() {
|
|
3345
|
+
return this.turn(
|
|
3346
|
+
`Read this project - its package files, README, config and the main source folders - and write ${MEMORY_FILE} ` +
|
|
3347
|
+
'in the project root for a coding agent that has never seen it: what it is, the stack, the commands to ' +
|
|
3348
|
+
'install, run, test and build, the folder layout, and the conventions the code follows. Under 60 lines, ' +
|
|
3349
|
+
`facts only. If ${MEMORY_FILE} exists, update it and keep anything the user wrote. Do not change any other file.`,
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
/** /diff: what changed — since the session started, or uncommitted in git. */
|
|
3354
|
+
async cmdDiff() {
|
|
3355
|
+
const first = this.undoStack?.[0]?.id;
|
|
3356
|
+
if (first && (await this.snaps?.ready())) {
|
|
3357
|
+
const changes = await this.snaps.changedSince(first);
|
|
3358
|
+
if (!changes.length) { this.ui.note('nothing has changed this session'); return; }
|
|
3359
|
+
const word = { A: 'added', M: 'changed', D: 'deleted' };
|
|
3360
|
+
this.ui.blank();
|
|
3361
|
+
for (const c of changes) this.ui.write(` ${dim((word[c.status] ?? c.status).padEnd(8))} ${c.file}`);
|
|
3362
|
+
this.ui.blank();
|
|
3363
|
+
return;
|
|
3364
|
+
}
|
|
3365
|
+
const { code, out } = await this.git(['status', '--short']);
|
|
3366
|
+
if (code !== 0) { this.ui.note('nothing to compare yet — this folder is not a git repository and no turn has changed it'); return; }
|
|
3367
|
+
this.ui.write(out ? out.split('\n').map((l) => ` ${l}`).join('\n') : dim(' no uncommitted changes'));
|
|
3368
|
+
}
|
|
3369
|
+
|
|
3370
|
+
/** /commit [message]: commit everything, with a message written from the diff when none is given. */
|
|
3371
|
+
async cmdCommit(message) {
|
|
3372
|
+
const status = await this.git(['status', '--porcelain']);
|
|
3373
|
+
if (status.code !== 0) { this.ui.note('this folder is not a git repository'); return; }
|
|
3374
|
+
if (!status.out) { this.ui.note('nothing to commit'); return; }
|
|
3375
|
+
let text = message;
|
|
3376
|
+
if (!text) {
|
|
3377
|
+
this.ui.startSpinner('writing a commit message');
|
|
3378
|
+
const diff = (await this.git(['diff', 'HEAD'])).out || status.out;
|
|
3379
|
+
try {
|
|
3380
|
+
const reply = await ask([
|
|
3381
|
+
{ role: 'system', content: 'Write a git commit message for this diff: a subject line under 70 characters in the imperative, then a blank line and up to three short lines on why. Reply with the message only.' },
|
|
3382
|
+
{ role: 'user', content: diff.slice(0, 30_000) },
|
|
3383
|
+
], [], { effort: 'low' });
|
|
3384
|
+
text = reply.text.replace(/^```\w*\n?|```$/g, '').trim();
|
|
3385
|
+
} finally {
|
|
3386
|
+
this.ui.stopSpinner();
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
if (!text) { this.ui.note('no message — /commit <message> to give one'); return; }
|
|
3390
|
+
this.ui.write(`\n${text.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
3391
|
+
const yes = await this.ui.confirm({ action: 'commit everything with this message', detail: `${status.out.split('\n').length} files`, risk: 'command' });
|
|
3392
|
+
if (!yes) { this.ui.note('not committed'); return; }
|
|
3393
|
+
await this.git(['add', '-A']);
|
|
3394
|
+
const done = await this.git(['commit', '-m', text]);
|
|
3395
|
+
this.ui.write(done.code === 0 ? ` ${theme.ok('✓')} committed` : theme.error(` ${done.out.split('\n')[0]}`));
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
/** /review: read the uncommitted changes for bugs, without changing anything. */
|
|
3399
|
+
async cmdReview() {
|
|
3400
|
+
const diff = await this.git(['diff', 'HEAD']);
|
|
3401
|
+
if (diff.code !== 0 || !diff.out) { this.ui.note('no uncommitted changes to review (this needs git)'); return; }
|
|
3402
|
+
this.readOnly = true;
|
|
3403
|
+
try {
|
|
3404
|
+
await this.turn(
|
|
3405
|
+
'Review these uncommitted changes for bugs: wrong logic, missed edge cases, broken error handling, ' +
|
|
3406
|
+
'security holes. Read the surrounding code where you need it. List each real problem as file:line, ' +
|
|
3407
|
+
`what goes wrong and the fix - most serious first. If there are none, say so in one line.\n\n\`\`\`diff\n${diff.out.slice(0, 40_000)}\n\`\`\``,
|
|
3408
|
+
);
|
|
3409
|
+
} finally {
|
|
3410
|
+
this.readOnly = false;
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
/** /mcp: which servers are connected, and how to add one. */
|
|
3415
|
+
async cmdMcp() {
|
|
3416
|
+
await this.mcpStarting;
|
|
3417
|
+
const status = this.mcp?.status() ?? [];
|
|
3418
|
+
this.ui.blank();
|
|
3419
|
+
if (!status.length) this.ui.write(dim(' no MCP servers yet'));
|
|
3420
|
+
for (const s of status) {
|
|
3421
|
+
this.ui.write(` ${s.ok ? theme.ok('●') : theme.error('●')} ${blue(s.name)} ${dim(s.ok ? `${s.tools} tools` : s.error)}`);
|
|
3422
|
+
}
|
|
3423
|
+
this.ui.blank();
|
|
3424
|
+
this.ui.write(dim(' add one: ucode mcp add <name> <command> [args...] or ucode mcp add <name> --url <url>'));
|
|
3425
|
+
this.ui.write(dim(` or edit ${USER_MCP} (every project) or .ucode/mcp.json (this one)`));
|
|
3426
|
+
this.ui.blank();
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
/** /permissions [ask|auto]: whether commands are asked about, and what is always allowed. */
|
|
3430
|
+
async cmdPermissions(arg) {
|
|
3431
|
+
const want = arg.trim().toLowerCase();
|
|
3432
|
+
if (want === 'ask' || want === 'auto') {
|
|
3433
|
+
const { projectSettingsFile, readJson, writeJson } = await import('./settings.js');
|
|
3434
|
+
const file = projectSettingsFile(this.cwd);
|
|
3435
|
+
await writeJson(file, { ...(await readJson(file)), commands: want });
|
|
3436
|
+
await this.loadSettings();
|
|
3437
|
+
}
|
|
3438
|
+
this.ui.blank();
|
|
3439
|
+
this.ui.write(` commands ${blue(this.settings.commands === 'ask' ? 'ask first' : 'run without asking')} ${dim('/permissions ask · /permissions auto')}`);
|
|
3440
|
+
this.ui.write(` allowed ${this.settings.allow.length ? this.settings.allow.join(' · ') : dim('nothing yet — answer "a" at a prompt to add')}`);
|
|
3441
|
+
this.ui.blank();
|
|
3442
|
+
}
|
|
3443
|
+
|
|
2973
3444
|
/**
|
|
2974
3445
|
* Look at the running app, because the user asked to.
|
|
2975
3446
|
*
|
|
@@ -3008,7 +3479,21 @@ ${out.content}` });
|
|
|
3008
3479
|
* one that just happened — and it says how many files it touched rather than
|
|
3009
3480
|
* listing them, the way everything else here reports work.
|
|
3010
3481
|
*/
|
|
3011
|
-
async cmdUndo() {
|
|
3482
|
+
async cmdUndo(arg = '') {
|
|
3483
|
+
// Snapshots first: they cover what commands changed, and go back further.
|
|
3484
|
+
const n = Math.max(1, Number.parseInt(arg, 10) || 1);
|
|
3485
|
+
if (this.undoStack?.length) {
|
|
3486
|
+
const take = Math.min(n, this.undoStack.length);
|
|
3487
|
+
const target = this.undoStack[this.undoStack.length - take];
|
|
3488
|
+
const { restored, removed, failed } = await this.snaps.restore(target.id);
|
|
3489
|
+
this.undoStack.splice(this.undoStack.length - take, take);
|
|
3490
|
+
const parts = [];
|
|
3491
|
+
if (restored.length) parts.push(`${restored.length} file${restored.length === 1 ? '' : 's'} put back`);
|
|
3492
|
+
if (removed.length) parts.push(`${removed.length} removed`);
|
|
3493
|
+
this.ui.write(` ${theme.ok('✓')} ${parts.join(', ') || 'nothing to do'} — back to before "${clip(target.input, 50)}"`);
|
|
3494
|
+
for (const f of failed) this.ui.write(theme.error(` could not undo ${f}`));
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3012
3497
|
const count = changedCount();
|
|
3013
3498
|
if (!count) {
|
|
3014
3499
|
this.ui.note('nothing to undo — the last turn changed no files.');
|
|
@@ -3026,11 +3511,18 @@ ${out.content}` });
|
|
|
3026
3511
|
cmdHelp() {
|
|
3027
3512
|
const rows = [
|
|
3028
3513
|
['/help', 'this list'],
|
|
3029
|
-
['/undo', 'put back
|
|
3514
|
+
['/undo [n]', 'put the project back as it was before the last turn (or n turns)'],
|
|
3515
|
+
['/diff', 'what has changed this session'],
|
|
3516
|
+
['/commit [msg]', 'commit the changes, with a message written for you'],
|
|
3517
|
+
['/review', 'check the uncommitted changes for bugs, changing nothing'],
|
|
3518
|
+
['/init', `read the project and write ${MEMORY_FILE} for it`],
|
|
3519
|
+
['/mcp', 'connected MCP servers and their tools'],
|
|
3520
|
+
['/permissions', 'ask before commands, or run them; what is always allowed'],
|
|
3030
3521
|
['/stats', 'time, steps and tokens this session'],
|
|
3031
3522
|
['/doctor', 'check that everything ucode needs is working'],
|
|
3032
3523
|
['/look [url]', 'open the running app and report what is on the page'],
|
|
3033
3524
|
['/deploy [folder]', 'put the app online and get its link'],
|
|
3525
|
+
['/mic', 'say what you want instead of typing it (or ctrl+t)'],
|
|
3034
3526
|
['/model', 'show the models and switch between them'],
|
|
3035
3527
|
['/resume', 'pick up an earlier conversation'],
|
|
3036
3528
|
['/new', 'save this one and start fresh'],
|
|
@@ -3042,6 +3534,8 @@ ${out.content}` });
|
|
|
3042
3534
|
['/exit', 'save and quit'],
|
|
3043
3535
|
];
|
|
3044
3536
|
|
|
3537
|
+
for (const c of this.commands?.values() ?? []) rows.push([`/${c.name}`, `yours: ${c.description}`]);
|
|
3538
|
+
|
|
3045
3539
|
this.ui.blank();
|
|
3046
3540
|
for (const [command, what] of rows) {
|
|
3047
3541
|
this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
|
|
@@ -3049,6 +3543,7 @@ ${out.content}` });
|
|
|
3049
3543
|
this.ui.blank();
|
|
3050
3544
|
this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
|
|
3051
3545
|
this.ui.write(dim(' ctrl+b swaps plan and build · + file (or ctrl+o) adds a picture or file · esc stops a running turn · ctrl+d quits'));
|
|
3546
|
+
this.ui.write(dim(' ctrl+t (or the mic button) listens: speak, then enter to send, ctrl+t to check it first, esc to cancel'));
|
|
3052
3547
|
this.ui.blank();
|
|
3053
3548
|
}
|
|
3054
3549
|
|