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/mcp.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp.js — tools from MCP servers: GitHub, docs, databases, whatever the user connects.
|
|
3
|
+
*
|
|
4
|
+
* ~/.ucode/mcp.json servers for every project
|
|
5
|
+
* <project>/.ucode/mcp.json servers for this one (asked about once, see settings.js)
|
|
6
|
+
*
|
|
7
|
+
* { "servers": {
|
|
8
|
+
* "context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] },
|
|
9
|
+
* "github": { "url": "https://api.githubcopilot.com/mcp/",
|
|
10
|
+
* "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } } } }
|
|
11
|
+
*
|
|
12
|
+
* "mcpServers" is read too, so a block copied from another tool's docs works.
|
|
13
|
+
* ${NAME} in args, env and headers is taken from the environment, so a token
|
|
14
|
+
* never has to sit in the file.
|
|
15
|
+
*
|
|
16
|
+
* The protocol is JSON-RPC over a child's stdin and stdout, or over HTTP. The
|
|
17
|
+
* official SDK pulls in a web server framework to speak it, which every
|
|
18
|
+
* install of ucode would then download; the client half is this file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { readJson, writeJson } from './settings.js';
|
|
25
|
+
import { VERSION } from './version.js';
|
|
26
|
+
|
|
27
|
+
export const USER_MCP = path.join(os.homedir(), '.ucode', 'mcp.json');
|
|
28
|
+
export const projectMcpFile = (cwd) => path.join(cwd, '.ucode', 'mcp.json');
|
|
29
|
+
|
|
30
|
+
const PROTOCOL = '2025-06-18';
|
|
31
|
+
const START_MS = 30_000;
|
|
32
|
+
const CALL_MS = 120_000;
|
|
33
|
+
const MAX_RESULT = 12_000;
|
|
34
|
+
|
|
35
|
+
const expand = (value) => String(value ?? '').replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? '');
|
|
36
|
+
const expandAll = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, expand(v)]));
|
|
37
|
+
|
|
38
|
+
/** The servers a file lists, each { name, command?, args?, env?, url?, headers? }. */
|
|
39
|
+
export async function readServers(file) {
|
|
40
|
+
const data = await readJson(file);
|
|
41
|
+
const servers = data.servers ?? data.mcpServers ?? {};
|
|
42
|
+
return Object.entries(servers)
|
|
43
|
+
.filter(([, s]) => s && typeof s === 'object' && (s.command || s.url) && s.disabled !== true)
|
|
44
|
+
.map(([name, s]) => ({ name, ...s }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Add or replace one server in a config file. */
|
|
48
|
+
export async function addServer(file, name, spec) {
|
|
49
|
+
const data = await readJson(file);
|
|
50
|
+
const key = data.mcpServers && !data.servers ? 'mcpServers' : 'servers';
|
|
51
|
+
await writeJson(file, { ...data, [key]: { ...(data[key] ?? {}), [name]: spec } });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Take a server out of a config file. Resolves to whether it was there. */
|
|
55
|
+
export async function removeServer(file, name) {
|
|
56
|
+
const data = await readJson(file);
|
|
57
|
+
const key = data.servers?.[name] ? 'servers' : data.mcpServers?.[name] ? 'mcpServers' : null;
|
|
58
|
+
if (!key) return false;
|
|
59
|
+
const { [name]: _gone, ...rest } = data[key];
|
|
60
|
+
await writeJson(file, { ...data, [key]: rest });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A name a function call can carry: letters, digits, _ and -, at most 64. */
|
|
65
|
+
export const toolName = (server, tool) =>
|
|
66
|
+
`${server}__${tool}`.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^[^A-Za-z_]/, '_$&').slice(0, 64);
|
|
67
|
+
|
|
68
|
+
const KEEP = new Set(['type', 'description', 'properties', 'required', 'items', 'enum', 'anyOf', 'nullable',
|
|
69
|
+
'minimum', 'maximum', 'minItems', 'maxItems', 'minLength', 'maxLength', 'title']);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An MCP input schema in the subset Gemini accepts. Anything else in a schema
|
|
73
|
+
* ($schema, additionalProperties, $ref, formats other than date-time) gets the
|
|
74
|
+
* whole request refused, and one server's odd schema would take every other
|
|
75
|
+
* tool down with it.
|
|
76
|
+
*/
|
|
77
|
+
export function cleanSchema(schema, root = true) {
|
|
78
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
79
|
+
return root ? { type: 'object', properties: {} } : { type: 'string' };
|
|
80
|
+
}
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
83
|
+
if (!KEEP.has(key)) continue;
|
|
84
|
+
if (key === 'type' && Array.isArray(value)) {
|
|
85
|
+
const real = value.filter((t) => t !== 'null');
|
|
86
|
+
out.type = real[0] ?? 'string';
|
|
87
|
+
if (real.length < value.length) out.nullable = true;
|
|
88
|
+
} else if (key === 'properties' && value && typeof value === 'object') {
|
|
89
|
+
out.properties = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cleanSchema(v, false)]));
|
|
90
|
+
} else if (key === 'items') {
|
|
91
|
+
out.items = cleanSchema(Array.isArray(value) ? value[0] : value, false);
|
|
92
|
+
} else if (key === 'anyOf' && Array.isArray(value)) {
|
|
93
|
+
out.anyOf = value.map((v) => cleanSchema(v, false));
|
|
94
|
+
} else if (key === 'enum' && Array.isArray(value)) {
|
|
95
|
+
out.enum = value.filter((v) => typeof v === 'string');
|
|
96
|
+
} else {
|
|
97
|
+
out[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (schema.oneOf && !out.anyOf) out.anyOf = schema.oneOf.map((v) => cleanSchema(v, false));
|
|
101
|
+
if (schema.format === 'date-time') out.format = 'date-time';
|
|
102
|
+
if (out.enum && !out.enum.length) delete out.enum;
|
|
103
|
+
if (root || out.type === 'object' || out.properties) {
|
|
104
|
+
out.type = 'object';
|
|
105
|
+
out.properties ??= {};
|
|
106
|
+
if (Array.isArray(out.required)) out.required = out.required.filter((k) => k in out.properties);
|
|
107
|
+
} else if (!out.type && !out.anyOf) {
|
|
108
|
+
out.type = 'string';
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One server's reply to a tools/call, as text for the model. */
|
|
114
|
+
export function resultText(result) {
|
|
115
|
+
const parts = (result?.content ?? []).map((c) => {
|
|
116
|
+
if (c.type === 'text') return c.text;
|
|
117
|
+
if (c.type === 'resource') return c.resource?.text ?? `[resource ${c.resource?.uri ?? ''}]`;
|
|
118
|
+
if (c.type === 'resource_link') return `[${c.name ?? 'link'}: ${c.uri}]`;
|
|
119
|
+
return `[${c.type} not shown]`;
|
|
120
|
+
});
|
|
121
|
+
if (!parts.length && result?.structuredContent) parts.push(JSON.stringify(result.structuredContent, null, 2));
|
|
122
|
+
const text = parts.join('\n').trim() || '(the tool returned nothing)';
|
|
123
|
+
return text.length > MAX_RESULT ? `${text.slice(0, MAX_RESULT)}\n[cut at ${MAX_RESULT} characters]` : text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Quote one argument for cmd.exe. Inside double quotes cmd treats & | < > ^
|
|
128
|
+
* as text; a quote is doubled, and % is broken up so no variable expands.
|
|
129
|
+
*/
|
|
130
|
+
const winQuote = (a) => (/^[\w./:=@\\-]+$/.test(a)
|
|
131
|
+
? a
|
|
132
|
+
: `"${String(a).replace(/"/g, '""').replace(/%/g, '"%"')}"`);
|
|
133
|
+
|
|
134
|
+
/** A server spoken to over its stdin and stdout, one JSON message per line. */
|
|
135
|
+
class StdioLink {
|
|
136
|
+
constructor(spec, cwd) {
|
|
137
|
+
this.pending = new Map();
|
|
138
|
+
this.next = 1;
|
|
139
|
+
const args = (spec.args ?? []).map(expand);
|
|
140
|
+
const env = { ...process.env, ...expandAll(spec.env) };
|
|
141
|
+
// npx and friends are .cmd files on Windows, which only a shell can start.
|
|
142
|
+
this.child = process.platform === 'win32'
|
|
143
|
+
? spawn([spec.command, ...args].map(winQuote).join(' '), { cwd, env, shell: true, windowsHide: true })
|
|
144
|
+
: spawn(spec.command, args, { cwd, env, windowsHide: true });
|
|
145
|
+
this.errors = '';
|
|
146
|
+
let rest = '';
|
|
147
|
+
this.child.stdout.setEncoding('utf8');
|
|
148
|
+
this.child.stdout.on('data', (chunk) => {
|
|
149
|
+
const lines = (rest + chunk).split('\n');
|
|
150
|
+
rest = lines.pop();
|
|
151
|
+
for (const line of lines) if (line.trim()) this.receive(line);
|
|
152
|
+
});
|
|
153
|
+
this.child.stderr.on('data', (d) => { this.errors = (this.errors + d).slice(-2000); });
|
|
154
|
+
const fail = (why) => {
|
|
155
|
+
for (const { reject } of this.pending.values()) reject(new Error(why));
|
|
156
|
+
this.pending.clear();
|
|
157
|
+
this.closed = why;
|
|
158
|
+
};
|
|
159
|
+
this.child.on('error', (err) => fail(err.message));
|
|
160
|
+
this.child.on('close', (code) => fail(`the server stopped (exit ${code})${this.errors ? `: ${this.errors.trim().split('\n').pop()}` : ''}`));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
receive(line) {
|
|
164
|
+
let msg;
|
|
165
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
166
|
+
// A request from the server: answer ping, decline the rest.
|
|
167
|
+
if (msg.method && msg.id !== undefined) {
|
|
168
|
+
this.send(msg.method === 'ping'
|
|
169
|
+
? { jsonrpc: '2.0', id: msg.id, result: {} }
|
|
170
|
+
: { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'ucode does not support that' } });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const waiting = this.pending.get(msg.id);
|
|
174
|
+
if (!waiting) return;
|
|
175
|
+
this.pending.delete(msg.id);
|
|
176
|
+
if (msg.error) waiting.reject(new Error(msg.error.message ?? 'the server returned an error'));
|
|
177
|
+
else waiting.resolve(msg.result);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
send(msg) {
|
|
181
|
+
if (!this.closed) this.child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
request(method, params, ms) {
|
|
185
|
+
if (this.closed) return Promise.reject(new Error(this.closed));
|
|
186
|
+
const id = this.next++;
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`no answer after ${ms / 1000}s`)); }, ms);
|
|
189
|
+
this.pending.set(id, {
|
|
190
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
191
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
192
|
+
});
|
|
193
|
+
this.send({ jsonrpc: '2.0', id, method, params });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
notify(method, params) {
|
|
198
|
+
this.send({ jsonrpc: '2.0', method, ...(params ? { params } : {}) });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
close() {
|
|
202
|
+
try { this.child.stdin.end(); this.child.kill(); } catch { /* already gone */ }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A server spoken to over HTTP: each message a POST, the answer JSON or an event stream. */
|
|
207
|
+
class HttpLink {
|
|
208
|
+
constructor(spec) {
|
|
209
|
+
this.url = expand(spec.url);
|
|
210
|
+
this.headers = expandAll(spec.headers);
|
|
211
|
+
this.next = 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async post(body, ms) {
|
|
215
|
+
const res = await fetch(this.url, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: {
|
|
218
|
+
'content-type': 'application/json',
|
|
219
|
+
accept: 'application/json, text/event-stream',
|
|
220
|
+
'mcp-protocol-version': PROTOCOL,
|
|
221
|
+
...(this.session ? { 'mcp-session-id': this.session } : {}),
|
|
222
|
+
...this.headers,
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify(body),
|
|
225
|
+
signal: AbortSignal.timeout(ms),
|
|
226
|
+
});
|
|
227
|
+
this.session = res.headers.get('mcp-session-id') ?? this.session;
|
|
228
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
229
|
+
return res;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async request(method, params, ms) {
|
|
233
|
+
const id = this.next++;
|
|
234
|
+
const res = await this.post({ jsonrpc: '2.0', id, method, params }, ms);
|
|
235
|
+
const type = res.headers.get('content-type') ?? '';
|
|
236
|
+
let msg;
|
|
237
|
+
if (type.includes('text/event-stream')) {
|
|
238
|
+
const text = await res.text();
|
|
239
|
+
for (const event of text.split(/\r?\n\r?\n/)) {
|
|
240
|
+
const data = event.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
|
|
241
|
+
if (!data) continue;
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(data);
|
|
244
|
+
if (parsed.id === id) { msg = parsed; break; }
|
|
245
|
+
} catch { /* not a message */ }
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
msg = await res.json();
|
|
249
|
+
}
|
|
250
|
+
if (!msg) throw new Error('the server sent no answer');
|
|
251
|
+
if (msg.error) throw new Error(msg.error.message ?? 'the server returned an error');
|
|
252
|
+
return msg.result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
notify(method, params) {
|
|
256
|
+
this.post({ jsonrpc: '2.0', method, ...(params ? { params } : {}) }, 10_000).catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
close() {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Every connected server, and the tools they offer. */
|
|
263
|
+
export class McpHub {
|
|
264
|
+
constructor() {
|
|
265
|
+
this.servers = new Map(); // name -> { ok, error, tools: [{ name, remote, description, schema }], link }
|
|
266
|
+
this.byTool = new Map(); // exposed tool name -> { server, remote }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Connect to each server at once. A server that fails is noted, never fatal. */
|
|
270
|
+
async start(specs, { cwd = process.cwd() } = {}) {
|
|
271
|
+
await Promise.all(specs.map((spec) => this.connect(spec, cwd)));
|
|
272
|
+
return this.status();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async connect(spec, cwd) {
|
|
276
|
+
let link;
|
|
277
|
+
try {
|
|
278
|
+
link = spec.url ? new HttpLink(spec) : new StdioLink(spec, cwd);
|
|
279
|
+
await link.request('initialize', {
|
|
280
|
+
protocolVersion: PROTOCOL,
|
|
281
|
+
capabilities: {},
|
|
282
|
+
clientInfo: { name: 'ucode', version: VERSION },
|
|
283
|
+
}, START_MS);
|
|
284
|
+
link.notify('notifications/initialized');
|
|
285
|
+
const tools = [];
|
|
286
|
+
let cursor;
|
|
287
|
+
do {
|
|
288
|
+
const page = await link.request('tools/list', cursor ? { cursor } : {}, START_MS);
|
|
289
|
+
tools.push(...(page?.tools ?? []));
|
|
290
|
+
cursor = page?.nextCursor;
|
|
291
|
+
} while (cursor);
|
|
292
|
+
const exposed = tools.map((t) => ({
|
|
293
|
+
name: toolName(spec.name, t.name),
|
|
294
|
+
remote: t.name,
|
|
295
|
+
description: String(t.description ?? `${t.name} from ${spec.name}`).slice(0, 1000),
|
|
296
|
+
schema: cleanSchema(t.inputSchema),
|
|
297
|
+
}));
|
|
298
|
+
for (const t of exposed) this.byTool.set(t.name, { server: spec.name, remote: t.remote });
|
|
299
|
+
this.servers.set(spec.name, { ok: true, tools: exposed, link, spec });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
link?.close();
|
|
302
|
+
this.servers.set(spec.name, { ok: false, error: err.message, tools: [], spec });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Tool definitions in ucode's neutral format. */
|
|
307
|
+
tools() {
|
|
308
|
+
return [...this.servers.values()].flatMap((s) => s.tools.map((t) => ({
|
|
309
|
+
name: t.name,
|
|
310
|
+
description: `[${s.spec.name}] ${t.description}`,
|
|
311
|
+
parameters: t.schema,
|
|
312
|
+
})));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
has(name) {
|
|
316
|
+
return this.byTool.has(name);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
serverOf(name) {
|
|
320
|
+
return this.byTool.get(name)?.server;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async call(name, args) {
|
|
324
|
+
const { server, remote } = this.byTool.get(name);
|
|
325
|
+
const s = this.servers.get(server);
|
|
326
|
+
const result = await s.link.request('tools/call', { name: remote, arguments: args ?? {} }, CALL_MS);
|
|
327
|
+
return { text: resultText(result), isError: Boolean(result?.isError) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
status() {
|
|
331
|
+
return [...this.servers.entries()].map(([name, s]) => ({ name, ok: s.ok, tools: s.tools.length, error: s.error }));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
close() {
|
|
335
|
+
for (const s of this.servers.values()) s.link?.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcpcli.js — `ucode mcp add | list | remove`, the way to connect a server
|
|
3
|
+
* without editing JSON by hand. Servers go in ~/.ucode/mcp.json (every
|
|
4
|
+
* project) unless --project puts them in this folder's .ucode/mcp.json.
|
|
5
|
+
*
|
|
6
|
+
* ucode mcp add context7 npx -y @upstash/context7-mcp
|
|
7
|
+
* ucode mcp add github --url https://api.githubcopilot.com/mcp/ --header "Authorization=Bearer ${GITHUB_TOKEN}"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { USER_MCP, projectMcpFile, readServers, addServer, removeServer, McpHub } from './mcp.js';
|
|
11
|
+
|
|
12
|
+
export async function mcpCommand(argv, { cwd = process.cwd() } = {}) {
|
|
13
|
+
const project = argv.includes('--project');
|
|
14
|
+
const args = argv.filter((a) => a !== '--project');
|
|
15
|
+
const file = project ? projectMcpFile(cwd) : USER_MCP;
|
|
16
|
+
const [action, name, ...rest] = args;
|
|
17
|
+
|
|
18
|
+
if (action === 'add') {
|
|
19
|
+
if (!name || !rest.length) return 'usage: ucode mcp add <name> <command> [args...] or ucode mcp add <name> --url <url>';
|
|
20
|
+
let spec;
|
|
21
|
+
if (rest[0] === '--url') {
|
|
22
|
+
const headers = {};
|
|
23
|
+
for (let i = 2; i < rest.length; i++) {
|
|
24
|
+
if (rest[i] === '--header' && rest[i + 1]?.includes('=')) {
|
|
25
|
+
const [k, ...v] = rest[++i].split('=');
|
|
26
|
+
headers[k] = v.join('=');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
spec = { url: rest[1], ...(Object.keys(headers).length ? { headers } : {}) };
|
|
30
|
+
} else {
|
|
31
|
+
spec = { command: rest[0], args: rest.slice(1) };
|
|
32
|
+
}
|
|
33
|
+
await addServer(file, name, spec);
|
|
34
|
+
// Try it now, so a typo shows up here rather than mid-build.
|
|
35
|
+
const hub = new McpHub();
|
|
36
|
+
const [status] = await hub.start([{ name, ...spec }], { cwd });
|
|
37
|
+
hub.close();
|
|
38
|
+
return status.ok
|
|
39
|
+
? `added ${name} to ${file} — ${status.tools} tools, ready next time you start ucode`
|
|
40
|
+
: `added ${name} to ${file}, but it did not start: ${status.error}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (action === 'remove' || action === 'rm') {
|
|
44
|
+
if (!name) return 'usage: ucode mcp remove <name>';
|
|
45
|
+
return (await removeServer(file, name)) ? `removed ${name}` : `no server called ${name} in ${file}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [mine, here] = await Promise.all([readServers(USER_MCP), readServers(projectMcpFile(cwd))]);
|
|
49
|
+
const line = (s, where) => ` ${s.name.padEnd(16)} ${where.padEnd(8)} ${s.url ?? [s.command, ...(s.args ?? [])].join(' ')}`;
|
|
50
|
+
const rows = [...mine.map((s) => line(s, 'yours')), ...here.map((s) => line(s, 'project'))];
|
|
51
|
+
return rows.length ? rows.join('\n') : 'no MCP servers yet — ucode mcp add <name> <command> [args...]';
|
|
52
|
+
}
|
package/src/core/provider.js
CHANGED
|
@@ -146,6 +146,73 @@ let stalls = 0;
|
|
|
146
146
|
let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
|
|
147
147
|
let client = null;
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Another OpenAI-compatible server instead of Google: Ollama on this computer,
|
|
151
|
+
* or any host the user points UCODE_BASE_URL at. Its models are its own, so
|
|
152
|
+
* any id is accepted, and it gets none of the Google-only pacing.
|
|
153
|
+
*/
|
|
154
|
+
export function customProvider() {
|
|
155
|
+
const url = process.env.UCODE_BASE_URL;
|
|
156
|
+
return Boolean(url) && !/googleapis\.com/i.test(url);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How hard the model thinks, per request: 'low', 'medium' or 'high'.
|
|
161
|
+
*
|
|
162
|
+
* Measured on Flash-Lite: with nothing set it does not think at all, and got a
|
|
163
|
+
* two-step riddle wrong that 'low' got right for under a second more. On a
|
|
164
|
+
* whole-app write 'low' thinks nothing and costs nothing, while 'medium' and
|
|
165
|
+
* 'high' spend about 2,800 thinking tokens (+5s). So the loop asks for 'low'
|
|
166
|
+
* on ordinary steps and saves the dearer levels for where they pay.
|
|
167
|
+
*
|
|
168
|
+
* UCODE_THINK=0 sends no level at all. A server that rejects the field is
|
|
169
|
+
* remembered and not sent it again.
|
|
170
|
+
*/
|
|
171
|
+
let effortRejected = false;
|
|
172
|
+
const thinkingOn = () => process.env.UCODE_THINK !== '0' && !effortRejected;
|
|
173
|
+
|
|
174
|
+
/** Requests actually sent this process — what the free daily limit counts. */
|
|
175
|
+
let requests = 0;
|
|
176
|
+
export const requestCount = () => requests;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Google's free tier counts requests per minute, per model: about 15 on
|
|
180
|
+
* Flash-Lite and 10 on Flash. Going over costs a refusal and then a wait of up
|
|
181
|
+
* to a minute; spacing the requests out beforehand costs a second or two, and
|
|
182
|
+
* only when a build is actually running that fast. One under the limit, since
|
|
183
|
+
* workers share it. UCODE_RPM sets the number; 0 turns pacing off.
|
|
184
|
+
*/
|
|
185
|
+
export function rpmFor(id) {
|
|
186
|
+
const set = process.env.UCODE_RPM;
|
|
187
|
+
if (set !== undefined && set !== '') return Math.max(0, Number(set) || 0);
|
|
188
|
+
if (customProvider()) return 0;
|
|
189
|
+
return /flash-lite/.test(id) ? 14 : 9;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** How long to wait before the next request, given when the recent ones went out. */
|
|
193
|
+
export function paceDelay(times, now, rpm) {
|
|
194
|
+
if (!rpm) return 0;
|
|
195
|
+
const recent = times.filter((t) => now - t < 60_000);
|
|
196
|
+
if (recent.length < rpm) return 0;
|
|
197
|
+
return Math.max(0, recent[recent.length - rpm] + 60_000 - now);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const sentAt = new Map(); // model id -> times its requests went out
|
|
201
|
+
|
|
202
|
+
async function pace(id, opts) {
|
|
203
|
+
// One shared list per model, read fresh after every wait and added to with
|
|
204
|
+
// no await in between: parallel workers each see the others' requests.
|
|
205
|
+
for (;;) {
|
|
206
|
+
const wait = paceDelay(sentAt.get(id) ?? [], Date.now(), rpmFor(id));
|
|
207
|
+
if (!wait || opts.signal?.aborted) break;
|
|
208
|
+
opts.onWait?.(`keeping under Google's free per-minute limit — ${Math.ceil(wait / 1000)}s`);
|
|
209
|
+
await pause(Math.min(1000, wait));
|
|
210
|
+
}
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
sentAt.set(id, [...(sentAt.get(id) ?? []).filter((t) => now - t < 60_000), now]);
|
|
213
|
+
requests++;
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
export function model() {
|
|
150
217
|
return current;
|
|
151
218
|
}
|
|
@@ -160,7 +227,7 @@ export function setModel(id) {
|
|
|
160
227
|
fix: `Pick one of: ${Object.keys(MODELS).join(', ')}`,
|
|
161
228
|
});
|
|
162
229
|
}
|
|
163
|
-
if (!MODELS[wanted]) {
|
|
230
|
+
if (!MODELS[wanted] && !customProvider()) {
|
|
164
231
|
throw new Failure({
|
|
165
232
|
kind: 'bad_model',
|
|
166
233
|
attempted: `switching to "${wanted}"`,
|
|
@@ -235,8 +302,8 @@ export function providerKey() {
|
|
|
235
302
|
}
|
|
236
303
|
|
|
237
304
|
function apiKey() {
|
|
238
|
-
//
|
|
239
|
-
|
|
305
|
+
// A local server such as Ollama wants no key, but the client insists on one.
|
|
306
|
+
if (customProvider()) return (process.env.UCODE_API_KEY || providerKey() || 'local').trim();
|
|
240
307
|
const key = providerKey();
|
|
241
308
|
if (!key) {
|
|
242
309
|
throw new Failure({
|
|
@@ -711,6 +778,7 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
711
778
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
712
779
|
if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
|
|
713
780
|
if (opts.reasoning) request.reasoning = opts.reasoning;
|
|
781
|
+
if (opts.effort && thinkingOn()) request.reasoning_effort = opts.effort;
|
|
714
782
|
|
|
715
783
|
// A side call (the design review) passes fewer: it is better skipped than
|
|
716
784
|
// waited on through a string of rate-limit pauses.
|
|
@@ -727,21 +795,33 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
727
795
|
|
|
728
796
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
729
797
|
try {
|
|
798
|
+
await pace(id, opts);
|
|
799
|
+
let reply;
|
|
730
800
|
if (opts.onText) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
801
|
+
reply = await streamed(request, callOpts, id);
|
|
802
|
+
} else {
|
|
803
|
+
const { data, response } = await connection().chat.completions
|
|
804
|
+
.create(request, { signal: opts.signal })
|
|
805
|
+
.withResponse();
|
|
806
|
+
noteLimits(response?.headers);
|
|
807
|
+
reply = normalize(data, id, request.tools?.map((t) => t.function.name));
|
|
734
808
|
}
|
|
735
|
-
const { data, response } = await connection().chat.completions
|
|
736
|
-
.create(request, { signal: opts.signal })
|
|
737
|
-
.withResponse();
|
|
738
|
-
noteLimits(response?.headers);
|
|
739
809
|
stalls = 0;
|
|
740
|
-
|
|
810
|
+
if (request.reasoning_effort === undefined && opts.effort && thinkingOn()) effortRejected = true;
|
|
811
|
+
return reply;
|
|
741
812
|
} catch (err) {
|
|
742
813
|
noteLimits(err?.headers);
|
|
743
814
|
problem = explain(err, id);
|
|
744
815
|
|
|
816
|
+
// A server that does not know reasoning_effort rejects the whole request.
|
|
817
|
+
// Asked once more without it; only if that works is it left off for good,
|
|
818
|
+
// so a 400 for some other reason never switches thinking off.
|
|
819
|
+
if (problem.kind === 'bad_request' && request.reasoning_effort !== undefined && printed === 0) {
|
|
820
|
+
delete request.reasoning_effort;
|
|
821
|
+
attempt--;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
|
|
745
825
|
// Retrying a model that keeps freezing only repeats the wait. After a
|
|
746
826
|
// few in a row, say so and hand the choice back.
|
|
747
827
|
if (problem.detail?.stalled && ++stalls >= MAX_STALLS) {
|
|
@@ -923,6 +1003,7 @@ async function streamed(request, opts, id) {
|
|
|
923
1003
|
promptTokens: usage?.prompt_tokens ?? 0,
|
|
924
1004
|
outputTokens: usage?.completion_tokens ?? 0,
|
|
925
1005
|
totalTokens: usage?.total_tokens ?? 0,
|
|
1006
|
+
cachedTokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
|
926
1007
|
},
|
|
927
1008
|
finishReason,
|
|
928
1009
|
model: id,
|
|
@@ -1178,6 +1259,7 @@ function normalize(data, id, names) {
|
|
|
1178
1259
|
promptTokens: u.prompt_tokens ?? 0,
|
|
1179
1260
|
outputTokens: u.completion_tokens ?? 0,
|
|
1180
1261
|
totalTokens: u.total_tokens ?? 0,
|
|
1262
|
+
cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
1181
1263
|
},
|
|
1182
1264
|
finishReason,
|
|
1183
1265
|
model: id,
|
package/src/core/scope.js
CHANGED
|
@@ -33,9 +33,68 @@ const QUESTION = /\?\s*$|^\s*(?:what|how|why|when|where|who|which|can|could|does
|
|
|
33
33
|
/** Does this message ask for something to be built? Never for a question. */
|
|
34
34
|
export const asksToBuild = (input) => !QUESTION.test(input) && BUILDS.some((re) => re.test(input));
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
/**
|
|
37
|
+
* A design direction for one build, so two apps never come out the same.
|
|
38
|
+
*
|
|
39
|
+
* Left to itself the model reaches for the same tone, the same system font and
|
|
40
|
+
* whatever accent the starter shipped with. Handing it a direction - a tone,
|
|
41
|
+
* a pair of typefaces, an accent - is a choice already made, which is free; a
|
|
42
|
+
* generic build caught afterwards costs a fix round. Every font is a free
|
|
43
|
+
* Google Font with a system fallback, so an offline page still renders.
|
|
44
|
+
*/
|
|
45
|
+
export const DIRECTIONS = [
|
|
46
|
+
{ tone: 'calm', display: 'DM Serif Display', body: 'DM Sans', accent: '#c2410c', base: 'light' },
|
|
47
|
+
{ tone: 'technical', display: 'JetBrains Mono', body: 'IBM Plex Sans', accent: '#16a34a', base: 'dark' },
|
|
48
|
+
{ tone: 'playful', display: 'Baloo 2', body: 'Nunito', accent: '#e11d48', base: 'light' },
|
|
49
|
+
{ tone: 'editorial', display: 'Fraunces', body: 'Source Sans 3', accent: '#b45309', base: 'light' },
|
|
50
|
+
{ tone: 'clinical', display: 'IBM Plex Sans', body: 'IBM Plex Sans', accent: '#0369a1', base: 'light' },
|
|
51
|
+
{ tone: 'industrial', display: 'Space Grotesk', body: 'Space Grotesk', accent: '#eab308', base: 'dark' },
|
|
52
|
+
{ tone: 'warm', display: 'Bricolage Grotesque', body: 'Figtree', accent: '#ea580c', base: 'light' },
|
|
53
|
+
{ tone: 'dense', display: 'Manrope', body: 'Manrope', accent: '#2563eb', base: 'light' },
|
|
54
|
+
{ tone: 'retro', display: 'Righteous', body: 'Rubik', accent: '#db2777', base: 'dark' },
|
|
55
|
+
{ tone: 'natural', display: 'Lora', body: 'Karla', accent: '#4d7c0f', base: 'light' },
|
|
56
|
+
{ tone: 'bold', display: 'Archivo Black', body: 'Archivo', accent: '#dc2626', base: 'light' },
|
|
57
|
+
{ tone: 'soft', display: 'Quicksand', body: 'Mulish', accent: '#0e7490', base: 'light' },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** The direction note for one build. `pick` is for tests. */
|
|
61
|
+
export function directionNote(pick = Math.random) {
|
|
62
|
+
const d = DIRECTIONS[Math.floor(pick() * DIRECTIONS.length) % DIRECTIONS.length];
|
|
63
|
+
const type = d.display === d.body ? `"${d.display}"` : `"${d.display}" for headings and "${d.body}" for text`;
|
|
64
|
+
return '(From ucode: design direction for this build, unless the request sets its own - ' +
|
|
65
|
+
`tone ${d.tone}; type ${type}, from Google Fonts with a system fallback; accent ${d.accent} ` +
|
|
66
|
+
`on a ${d.base} base. In the same reply as your first tool call, say in one line the app's name, ` +
|
|
67
|
+
'its tone, its accent and the one memorable detail it will have, then build to exactly that.)';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A change to code that already exists - the other half of what ucode is for.
|
|
72
|
+
*
|
|
73
|
+
* Every rule in the system prompt about building is about a new app: design
|
|
74
|
+
* it, write it whole, hand it over. Asked to fix a bug in a real project, a
|
|
75
|
+
* model following those rules redesigns the page. So a change request in a
|
|
76
|
+
* folder with code in it carries its own note instead.
|
|
77
|
+
*/
|
|
78
|
+
export const EDIT_NOTE =
|
|
79
|
+
'(From ucode: this is a change to an existing project. Find the code first - find_symbol, grep ' +
|
|
80
|
+
'or outline - then read only the files involved, together in one read_files. Make the smallest ' +
|
|
81
|
+
'change that does it, in the style the code already uses. Do not redesign, rename or reformat ' +
|
|
82
|
+
'what works, and do not start a new app. Then run the project\'s checks.)';
|
|
83
|
+
|
|
84
|
+
const CHANGE = /\b(?:fix|bug|error|broken|crash|fails?|change|update|refactor|rename|add|remove|delete|improve|optimi[sz]e|clean ?up|move|replace|support|implement|make it|make the)\b/i;
|
|
85
|
+
|
|
86
|
+
/** Does this message ask for a change to code that is already here? */
|
|
87
|
+
export const asksToChange = (input) => !asksToBuild(input) && CHANGE.test(input);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The request as the model sees it. One that asks for an app carries the
|
|
91
|
+
* scope note and a design direction; a change in a folder with code in it
|
|
92
|
+
* carries the note for working in an existing project.
|
|
93
|
+
*/
|
|
94
|
+
export function withScope(input, { hasCode = false, pick = Math.random } = {}) {
|
|
95
|
+
if (asksToBuild(input)) return `${input}\n\n${directionNote(pick)}\n${SCOPE_NOTE}`;
|
|
96
|
+
if (hasCode && asksToChange(input)) return `${input}\n\n${EDIT_NOTE}`;
|
|
97
|
+
return input;
|
|
39
98
|
}
|
|
40
99
|
|
|
41
100
|
/** A message with no letters or digits in it ("+", "?", "...") — nothing to answer or build. */
|