ucode-agent 1.63.0 → 1.65.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.
@@ -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
+ }
@@ -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
- // UCODE_API_KEY is the documented name. The provider's own variable name is
239
- // still read, so a key set up for another tool keeps working here.
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
- const reply = await streamed(request, callOpts, id);
732
- stalls = 0;
733
- return reply;
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
- return normalize(data, id, request.tools?.map((t) => t.function.name));
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
- /** The request as the model sees it: one that asks for an app carries the note. */
37
- export function withScope(input) {
38
- return asksToBuild(input) ? `${input}\n\n${SCOPE_NOTE}` : input;
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. */