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/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({
|
|
@@ -276,6 +343,37 @@ export function resetConnection() {
|
|
|
276
343
|
client = null;
|
|
277
344
|
}
|
|
278
345
|
|
|
346
|
+
const TRANSCRIBE_PROMPT =
|
|
347
|
+
'Transcribe the speech in this recording word for word, in the language spoken. ' +
|
|
348
|
+
'Reply with only the words spoken - no quotes, no notes. If nothing is said, reply with nothing.';
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Write down what was said in a WAV recording (the mic button). Uses the
|
|
352
|
+
* session's model, and the backup model when that one is overloaded.
|
|
353
|
+
*/
|
|
354
|
+
export async function transcribe(wav, { timeout = 45_000 } = {}) {
|
|
355
|
+
const request = (id) => connection().chat.completions.create({
|
|
356
|
+
model: id,
|
|
357
|
+
messages: [{
|
|
358
|
+
role: 'user',
|
|
359
|
+
content: [
|
|
360
|
+
{ type: 'text', text: TRANSCRIBE_PROMPT },
|
|
361
|
+
{ type: 'input_audio', input_audio: { data: wav.toString('base64'), format: 'wav' } },
|
|
362
|
+
],
|
|
363
|
+
}],
|
|
364
|
+
}, { timeout });
|
|
365
|
+
|
|
366
|
+
let res;
|
|
367
|
+
try {
|
|
368
|
+
res = await request(current);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const backup = backupFor(current);
|
|
371
|
+
if (!backup) throw explain(err, current);
|
|
372
|
+
res = await request(backup).catch((again) => { throw explain(again, backup); });
|
|
373
|
+
}
|
|
374
|
+
return res.choices?.[0]?.message?.content ?? '';
|
|
375
|
+
}
|
|
376
|
+
|
|
279
377
|
// ---------------------------------------------------------------------------
|
|
280
378
|
// Live quota, taken from whatever rate-limit headers come back
|
|
281
379
|
// ---------------------------------------------------------------------------
|
|
@@ -680,6 +778,7 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
680
778
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
681
779
|
if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
|
|
682
780
|
if (opts.reasoning) request.reasoning = opts.reasoning;
|
|
781
|
+
if (opts.effort && thinkingOn()) request.reasoning_effort = opts.effort;
|
|
683
782
|
|
|
684
783
|
// A side call (the design review) passes fewer: it is better skipped than
|
|
685
784
|
// waited on through a string of rate-limit pauses.
|
|
@@ -696,21 +795,33 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
696
795
|
|
|
697
796
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
698
797
|
try {
|
|
798
|
+
await pace(id, opts);
|
|
799
|
+
let reply;
|
|
699
800
|
if (opts.onText) {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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));
|
|
703
808
|
}
|
|
704
|
-
const { data, response } = await connection().chat.completions
|
|
705
|
-
.create(request, { signal: opts.signal })
|
|
706
|
-
.withResponse();
|
|
707
|
-
noteLimits(response?.headers);
|
|
708
809
|
stalls = 0;
|
|
709
|
-
|
|
810
|
+
if (request.reasoning_effort === undefined && opts.effort && thinkingOn()) effortRejected = true;
|
|
811
|
+
return reply;
|
|
710
812
|
} catch (err) {
|
|
711
813
|
noteLimits(err?.headers);
|
|
712
814
|
problem = explain(err, id);
|
|
713
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
|
+
|
|
714
825
|
// Retrying a model that keeps freezing only repeats the wait. After a
|
|
715
826
|
// few in a row, say so and hand the choice back.
|
|
716
827
|
if (problem.detail?.stalled && ++stalls >= MAX_STALLS) {
|
|
@@ -892,6 +1003,7 @@ async function streamed(request, opts, id) {
|
|
|
892
1003
|
promptTokens: usage?.prompt_tokens ?? 0,
|
|
893
1004
|
outputTokens: usage?.completion_tokens ?? 0,
|
|
894
1005
|
totalTokens: usage?.total_tokens ?? 0,
|
|
1006
|
+
cachedTokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
|
895
1007
|
},
|
|
896
1008
|
finishReason,
|
|
897
1009
|
model: id,
|
|
@@ -1147,6 +1259,7 @@ function normalize(data, id, names) {
|
|
|
1147
1259
|
promptTokens: u.prompt_tokens ?? 0,
|
|
1148
1260
|
outputTokens: u.completion_tokens ?? 0,
|
|
1149
1261
|
totalTokens: u.total_tokens ?? 0,
|
|
1262
|
+
cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0,
|
|
1150
1263
|
},
|
|
1151
1264
|
finishReason,
|
|
1152
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. */
|