ucode-agent 1.63.0 → 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 +95 -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 +453 -18
- package/src/core/mcp.js +337 -0
- package/src/core/mcpcli.js +52 -0
- package/src/core/provider.js +93 -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/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 +7 -5
- package/src/ui/theme.js +23 -1
- package/ucode.js +37 -1
package/src/core/loop.js
CHANGED
|
@@ -29,10 +29,11 @@ import { spawn } from 'node:child_process';
|
|
|
29
29
|
import {
|
|
30
30
|
ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
|
|
31
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,13 +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';
|
|
57
58
|
import { startRecording, isSilent, cleanTranscript, MAX_RECORD_MS } from './voice.js';
|
|
58
59
|
import { runDoctor } from './doctor.js';
|
|
59
60
|
import { JS_LOGIC } from './jslogic.js';
|
|
60
61
|
import { deploy } from '../tools/deploy.js';
|
|
61
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';
|
|
62
70
|
|
|
63
71
|
/**
|
|
64
72
|
* Tool calls allowed in one turn.
|
|
@@ -124,7 +132,10 @@ const LOOKUP_NUDGE = 5;
|
|
|
124
132
|
/** A file with a page in it — something a browser can be pointed at. */
|
|
125
133
|
const PAGE = /\.(?:html?|tsx|jsx)$/i;
|
|
126
134
|
|
|
127
|
-
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';
|
|
128
139
|
|
|
129
140
|
/** Where TypeScript keeps what it learned, so the next check is a quick one. */
|
|
130
141
|
export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
|
|
@@ -338,7 +349,7 @@ function trace(event) {
|
|
|
338
349
|
function newStats() {
|
|
339
350
|
return {
|
|
340
351
|
started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
|
|
341
|
-
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,
|
|
342
353
|
};
|
|
343
354
|
}
|
|
344
355
|
|
|
@@ -368,6 +379,7 @@ export function statsLines(st, messages) {
|
|
|
368
379
|
const rows = [
|
|
369
380
|
['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
|
|
370
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`]] : []),
|
|
371
383
|
['Tools', top],
|
|
372
384
|
['Files', `${st.written} written · ${st.edited} edited`],
|
|
373
385
|
['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
|
|
@@ -576,7 +588,7 @@ const loadSkillTool = {
|
|
|
576
588
|
},
|
|
577
589
|
};
|
|
578
590
|
|
|
579
|
-
function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
591
|
+
function systemPrompt({ cwd, skills, mode, check, map, memory, lessons }) {
|
|
580
592
|
const list = catalogue(skills);
|
|
581
593
|
|
|
582
594
|
return [
|
|
@@ -592,6 +604,14 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
592
604
|
'',
|
|
593
605
|
memory,
|
|
594
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
|
+
] : []),
|
|
595
615
|
'',
|
|
596
616
|
'## How to work',
|
|
597
617
|
'',
|
|
@@ -599,6 +619,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
599
619
|
'A greeting, a question, a single word, or anything unclear gets a short plain',
|
|
600
620
|
'answer - or one question back about what they would like - never an app. "What',
|
|
601
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.',
|
|
602
624
|
'',
|
|
603
625
|
'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
|
|
604
626
|
'what you built as someone using it for the first time: is the button inside its',
|
|
@@ -866,6 +888,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
866
888
|
'- Point at code as path:line so the user can jump straight to it.',
|
|
867
889
|
'- Report honestly. If a command failed or you skipped something, say so.',
|
|
868
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.',
|
|
869
893
|
'- Brevity is about your prose and never about your work. What you build is',
|
|
870
894
|
' finished: every control wired, every state handled, no TODO left behind.',
|
|
871
895
|
...(list ? [
|
|
@@ -1012,6 +1036,82 @@ export class Agent {
|
|
|
1012
1036
|
});
|
|
1013
1037
|
this.skills = await loadSkills({ cwd: this.cwd });
|
|
1014
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
|
+
];
|
|
1015
1115
|
}
|
|
1016
1116
|
|
|
1017
1117
|
async start() {
|
|
@@ -1038,6 +1138,7 @@ export class Agent {
|
|
|
1038
1138
|
|
|
1039
1139
|
this.showHeader();
|
|
1040
1140
|
this.installSignals();
|
|
1141
|
+
this.startMcp();
|
|
1041
1142
|
|
|
1042
1143
|
// Checked in the background; nothing here waits on it.
|
|
1043
1144
|
autoUpdate({
|
|
@@ -1168,6 +1269,7 @@ export class Agent {
|
|
|
1168
1269
|
|
|
1169
1270
|
async shutdown() {
|
|
1170
1271
|
await closeBrowser().catch(() => {});
|
|
1272
|
+
this.mcp?.close();
|
|
1171
1273
|
this.ui.stopSpinner();
|
|
1172
1274
|
if (this.session.messages.length) {
|
|
1173
1275
|
await this.persist();
|
|
@@ -1247,6 +1349,20 @@ export class Agent {
|
|
|
1247
1349
|
this.declines = 0;
|
|
1248
1350
|
this.apps = [];
|
|
1249
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(() => {});
|
|
1250
1366
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1251
1367
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1252
1368
|
const images = await this.attachImages(input);
|
|
@@ -1268,14 +1384,15 @@ export class Agent {
|
|
|
1268
1384
|
this.session.title = titleFrom(input);
|
|
1269
1385
|
}
|
|
1270
1386
|
// What the model is told about the project, fresh for this turn.
|
|
1271
|
-
[this.map, this.memory] = await Promise.all([
|
|
1387
|
+
[this.map, this.memory, this.lessons] = await Promise.all([
|
|
1272
1388
|
projectMap(this.cwd).catch(() => ''),
|
|
1273
1389
|
loadMemory(this.cwd).catch(() => ''),
|
|
1390
|
+
lessonsText().catch(() => ''),
|
|
1274
1391
|
]);
|
|
1275
1392
|
// Nothing to look up in an empty folder, so those tools do not go out with
|
|
1276
1393
|
// the request. Decided per turn: the moment there is code, they are back.
|
|
1277
1394
|
this.fresh = !hasCode(this.map);
|
|
1278
|
-
request.content = withScope(input) + added.text;
|
|
1395
|
+
request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
|
|
1279
1396
|
this.wantsWeb = WANTS_WEB.test(input);
|
|
1280
1397
|
await this.persist();
|
|
1281
1398
|
|
|
@@ -1315,6 +1432,10 @@ export class Agent {
|
|
|
1315
1432
|
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
1316
1433
|
this.stats.workMs += Date.now() - turnStarted;
|
|
1317
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);
|
|
1318
1439
|
if (!finished) this.closeInterrupted();
|
|
1319
1440
|
const ok = finished && !this.endedSilently;
|
|
1320
1441
|
this.busy = false;
|
|
@@ -1346,10 +1467,59 @@ export class Agent {
|
|
|
1346
1467
|
const live = this.fresh
|
|
1347
1468
|
? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
|
|
1348
1469
|
: all;
|
|
1349
|
-
|
|
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() ?? [])];
|
|
1350
1472
|
return live.filter((t) => !WRITES.has(t.name));
|
|
1351
1473
|
}
|
|
1352
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
|
+
|
|
1353
1523
|
/** Model, tools, model, until it answers with prose. */
|
|
1354
1524
|
async run() {
|
|
1355
1525
|
const available = this.toolsNow();
|
|
@@ -1381,6 +1551,7 @@ export class Agent {
|
|
|
1381
1551
|
const opts = {
|
|
1382
1552
|
signal: this.abort.signal,
|
|
1383
1553
|
onWait: (text) => this.ui.updateSpinner(text),
|
|
1554
|
+
effort: this.effortFor(step, fixRounds),
|
|
1384
1555
|
};
|
|
1385
1556
|
// Only a real terminal has somewhere to stream into.
|
|
1386
1557
|
if (this.full) {
|
|
@@ -1421,10 +1592,11 @@ export class Agent {
|
|
|
1421
1592
|
content: systemPrompt({
|
|
1422
1593
|
cwd: this.cwd,
|
|
1423
1594
|
skills: this.skills,
|
|
1424
|
-
mode: this.ui.mode,
|
|
1595
|
+
mode: this.readOnly ? 'plan' : this.ui.mode,
|
|
1425
1596
|
check: this.check,
|
|
1426
1597
|
map: this.map,
|
|
1427
1598
|
memory: this.memory,
|
|
1599
|
+
lessons: this.lessons,
|
|
1428
1600
|
}),
|
|
1429
1601
|
},
|
|
1430
1602
|
...dedupe(lean(this.working)),
|
|
@@ -1532,6 +1704,7 @@ export class Agent {
|
|
|
1532
1704
|
if (problems) {
|
|
1533
1705
|
fixRounds++;
|
|
1534
1706
|
this.fixing = true;
|
|
1707
|
+
const extra = await this.beforeFixRound(problems, fixRounds);
|
|
1535
1708
|
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
1536
1709
|
this.push({
|
|
1537
1710
|
role: 'user',
|
|
@@ -1539,12 +1712,29 @@ export class Agent {
|
|
|
1539
1712
|
`ucode checked the files you changed and found errors (round ${fixRounds} of ` +
|
|
1540
1713
|
`${MAX_FIX_ROUNDS}). Fix all of them with the smallest edits that do it, then ` +
|
|
1541
1714
|
'finish. Change nothing else - no restyling, no rewrites of code that works.' +
|
|
1542
|
-
`\n\n${problems}`,
|
|
1715
|
+
`\n\n${problems}${extra}`,
|
|
1543
1716
|
});
|
|
1544
1717
|
continue;
|
|
1545
1718
|
}
|
|
1546
1719
|
}
|
|
1547
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
|
+
|
|
1548
1738
|
// It changed code and never ran anything. Send it back once — but
|
|
1549
1739
|
// only if this project's check would actually exercise what changed.
|
|
1550
1740
|
// A page and a stylesheet in a repo that happens to have `npm test`
|
|
@@ -1618,12 +1808,13 @@ export class Agent {
|
|
|
1618
1808
|
if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
|
|
1619
1809
|
fixRounds++;
|
|
1620
1810
|
this.fixing = true;
|
|
1811
|
+
const extra = await this.beforeFixRound(handed.problems, fixRounds);
|
|
1621
1812
|
this.push({
|
|
1622
1813
|
role: 'user',
|
|
1623
1814
|
content:
|
|
1624
1815
|
`ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
|
|
1625
1816
|
'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
|
|
1626
|
-
`ucode hands it to the user.\n\n${handed.problems}`,
|
|
1817
|
+
`ucode hands it to the user.\n\n${handed.problems}${extra}`,
|
|
1627
1818
|
});
|
|
1628
1819
|
await this.persist();
|
|
1629
1820
|
continue;
|
|
@@ -2256,12 +2447,85 @@ export class Agent {
|
|
|
2256
2447
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
2257
2448
|
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
2258
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);
|
|
2259
2458
|
|
|
2260
2459
|
// Output reaches the screen as the command produces it, so a slow build is
|
|
2261
2460
|
// something you watch rather than something you sit out in silence.
|
|
2262
|
-
|
|
2461
|
+
const out = await runTool(call.name, call.args ?? {}, {
|
|
2263
2462
|
onOutput: (lines) => this.ui.progress(lines),
|
|
2264
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
|
+
}
|
|
2265
2529
|
}
|
|
2266
2530
|
|
|
2267
2531
|
/**
|
|
@@ -2320,6 +2584,7 @@ export class Agent {
|
|
|
2320
2584
|
const list = (Array.isArray(items) ? items : [])
|
|
2321
2585
|
.filter((i) => i && String(i.text ?? '').trim())
|
|
2322
2586
|
.slice(0, 6);
|
|
2587
|
+
this.planItems = list;
|
|
2323
2588
|
this.ui.plan(list);
|
|
2324
2589
|
const done = list.filter((i) => i.done).length;
|
|
2325
2590
|
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
@@ -2393,7 +2658,7 @@ export class Agent {
|
|
|
2393
2658
|
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
|
|
2394
2659
|
{ role: 'user', content: String(task.instructions) },
|
|
2395
2660
|
];
|
|
2396
|
-
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));
|
|
2397
2662
|
const offered = new Set(available.map((t) => t.name));
|
|
2398
2663
|
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
2399
2664
|
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
@@ -2416,7 +2681,7 @@ export class Agent {
|
|
|
2416
2681
|
// again costs far less than carrying the whole history.
|
|
2417
2682
|
thinWorker(messages, contextLimit(workerModel) * 0.6);
|
|
2418
2683
|
try {
|
|
2419
|
-
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' });
|
|
2420
2685
|
} catch (err) {
|
|
2421
2686
|
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
2422
2687
|
if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
@@ -2664,6 +2929,13 @@ export class Agent {
|
|
|
2664
2929
|
if (failed) problems.push(failed);
|
|
2665
2930
|
}
|
|
2666
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
|
+
|
|
2667
2939
|
const live = await this.liveErrors();
|
|
2668
2940
|
if (live) problems.push(live);
|
|
2669
2941
|
|
|
@@ -2769,6 +3041,15 @@ export class Agent {
|
|
|
2769
3041
|
return { problems: `I opened the app and looked at it:\n\n${content}` };
|
|
2770
3042
|
}
|
|
2771
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
|
+
|
|
2772
3053
|
this.handOverPending = false;
|
|
2773
3054
|
// "Tip Calculator is done", with the page as a link the terminal can open.
|
|
2774
3055
|
const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
|
|
@@ -2787,6 +3068,14 @@ export class Agent {
|
|
|
2787
3068
|
return { done: true };
|
|
2788
3069
|
}
|
|
2789
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
|
+
|
|
2790
3079
|
async lookOnceThisTurn(root, changed) {
|
|
2791
3080
|
// A look that found problems is taken again after the fix round, even if
|
|
2792
3081
|
// the fix only touched the page's script. Looking once a turn let a live
|
|
@@ -3012,22 +3301,146 @@ export class Agent {
|
|
|
3012
3301
|
case '/search': return this.cmdSearch(arg);
|
|
3013
3302
|
case '/copy': return this.cmdCopy();
|
|
3014
3303
|
case '/stats': return this.cmdStats();
|
|
3015
|
-
case '/undo': return this.cmdUndo();
|
|
3304
|
+
case '/undo': return this.cmdUndo(arg);
|
|
3016
3305
|
case '/doctor': return this.cmdDoctor();
|
|
3017
3306
|
case '/look': return this.cmdLook(arg);
|
|
3018
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);
|
|
3019
3314
|
case '/mic':
|
|
3020
3315
|
if (this.ui.onMic) return this.mic('toggle');
|
|
3021
3316
|
return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
|
|
3022
3317
|
case '/exit':
|
|
3023
3318
|
case '/quit': return 'exit';
|
|
3024
3319
|
|
|
3025
|
-
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));
|
|
3026
3325
|
this.ui.write(theme.warn(` no such command: ${name}`));
|
|
3027
3326
|
this.ui.note('/help lists them.');
|
|
3327
|
+
}
|
|
3028
3328
|
}
|
|
3029
3329
|
}
|
|
3030
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
|
+
|
|
3031
3444
|
/**
|
|
3032
3445
|
* Look at the running app, because the user asked to.
|
|
3033
3446
|
*
|
|
@@ -3066,7 +3479,21 @@ ${out.content}` });
|
|
|
3066
3479
|
* one that just happened — and it says how many files it touched rather than
|
|
3067
3480
|
* listing them, the way everything else here reports work.
|
|
3068
3481
|
*/
|
|
3069
|
-
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
|
+
}
|
|
3070
3497
|
const count = changedCount();
|
|
3071
3498
|
if (!count) {
|
|
3072
3499
|
this.ui.note('nothing to undo — the last turn changed no files.');
|
|
@@ -3084,7 +3511,13 @@ ${out.content}` });
|
|
|
3084
3511
|
cmdHelp() {
|
|
3085
3512
|
const rows = [
|
|
3086
3513
|
['/help', 'this list'],
|
|
3087
|
-
['/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'],
|
|
3088
3521
|
['/stats', 'time, steps and tokens this session'],
|
|
3089
3522
|
['/doctor', 'check that everything ucode needs is working'],
|
|
3090
3523
|
['/look [url]', 'open the running app and report what is on the page'],
|
|
@@ -3101,6 +3534,8 @@ ${out.content}` });
|
|
|
3101
3534
|
['/exit', 'save and quit'],
|
|
3102
3535
|
];
|
|
3103
3536
|
|
|
3537
|
+
for (const c of this.commands?.values() ?? []) rows.push([`/${c.name}`, `yours: ${c.description}`]);
|
|
3538
|
+
|
|
3104
3539
|
this.ui.blank();
|
|
3105
3540
|
for (const [command, what] of rows) {
|
|
3106
3541
|
this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
|