u-foo 3.0.1 → 3.0.2

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.
@@ -5,6 +5,7 @@ const {
5
5
  resolveKimiUpstreamCredentials,
6
6
  } = require("../agents/providers/credentials/kimi");
7
7
  const { runToolCall } = require("./dispatch");
8
+ const { runTaskRunTool } = require("./tools/taskRun");
8
9
  const { appendUsageRecord } = require("./usageStore");
9
10
  const {
10
11
  persistToolResultToContext,
@@ -60,9 +61,11 @@ const CORE_TOOL_NAMES = new Set([
60
61
  "bash",
61
62
  "artifact_read",
62
63
  "plan_graph",
64
+ "task_run",
63
65
  "ask_user",
64
66
  ]);
65
67
  const EXECUTABLE_GRAPH_TOOLS = new Set(["read", "write", "edit", "bash", "artifact_read"]);
68
+ const CONTROL_PLANE_TOOLS = new Set(["plan_graph", "task_run"]);
66
69
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
67
70
  const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
68
71
  const DEFAULT_KIMI_BASE_URL = "https://api.kimi.com/coding/v1";
@@ -78,11 +81,9 @@ const DEFAULT_NATIVE_TIMEOUT_MS = 43200000; // 12 hours
78
81
  // via UFOO_UCODE_MAX_TOKENS (positive integer).
79
82
  const DEFAULT_OPENAI_MAX_TOKENS = 131072;
80
83
  const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
81
- // Extended thinking is on by default for the anthropic transport; the budget
82
- // stays well below the 64K max_tokens cap as the Messages API requires.
83
- // UFOO_UCODE_THINKING_BUDGET_TOKENS overrides; 0 or a non-numeric value
84
- // disables thinking (the payload then omits the field entirely).
85
- const DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS = 10000;
84
+ // Extended thinking defaults live in thinkingLevels.js (medium = 10k).
85
+ // UFOO_UCODE_THINKING=off|low|medium|high|max selects a preset; numeric
86
+ // UFOO_UCODE_THINKING_BUDGET_TOKENS still overrides. 0 disables thinking.
86
87
  // Prompt caching is GA on the current Messages API: cache_control blocks need
87
88
  // no anthropic-beta header. Kept as a constant so the marker shape stays in
88
89
  // one place (system block + last history message, 2 of the 4 allowed
@@ -116,14 +117,40 @@ function resolveMaxTokens(fallback) {
116
117
  return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
117
118
  }
118
119
 
119
- function resolveThinkingBudgetTokens() {
120
- const raw = process.env.UFOO_UCODE_THINKING_BUDGET_TOKENS;
121
- if (raw === undefined || raw === null || String(raw).trim() === "") {
122
- return DEFAULT_ANTHROPIC_THINKING_BUDGET_TOKENS;
120
+ function resolveThinkingBudgetTokens(options = {}) {
121
+ const { resolveThinkingFromEnvAndConfig } = require("./thinkingLevels");
122
+ const { loadGlobalUcodeConfig } = require("../config");
123
+ let configLevel = String(options.configLevel || "").trim();
124
+ if (!configLevel) {
125
+ try {
126
+ configLevel = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
127
+ } catch {
128
+ configLevel = "";
129
+ }
123
130
  }
124
- const parsed = Number.parseInt(String(raw), 10);
125
- if (!Number.isFinite(parsed) || parsed <= 0) return 0;
126
- return Math.floor(parsed);
131
+ const resolved = resolveThinkingFromEnvAndConfig({
132
+ env: options.env || process.env,
133
+ configLevel,
134
+ });
135
+ return resolved.budgetTokens;
136
+ }
137
+
138
+ function resolveReasoningEffort(options = {}) {
139
+ const { resolveThinkingFromEnvAndConfig } = require("./thinkingLevels");
140
+ const { loadGlobalUcodeConfig } = require("../config");
141
+ let configLevel = String(options.configLevel || "").trim();
142
+ if (!configLevel) {
143
+ try {
144
+ configLevel = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
145
+ } catch {
146
+ configLevel = "";
147
+ }
148
+ }
149
+ const resolved = resolveThinkingFromEnvAndConfig({
150
+ env: options.env || process.env,
151
+ configLevel,
152
+ });
153
+ return resolved.reasoningEffort || "";
127
154
  }
128
155
 
129
156
  function toUsageInt(value) {
@@ -446,12 +473,13 @@ function buildCoreToolSpecs() {
446
473
  function: {
447
474
  name: "plan_graph",
448
475
  description: [
449
- "Manage the persistent Plan Graph and asynchronous TaskRuns.",
450
- "Use create, patch, inspect, or cancel_graph for graph operations, and control for TaskRun lifecycle.",
451
- "`control.start_task` starts a `task_loop` asynchronously and returns immediately.",
476
+ "Manage the persistent Plan Graph and graph-bound TaskRuns.",
477
+ "TaskRuns are orthogonal to Plan Mode; for a standalone TaskRun without a plan, use `task_run` instead.",
478
+ "Use create, patch, inspect, or cancel_graph for graph operations, and control for graph-bound TaskRun lifecycle.",
479
+ "`control.start_task` starts a graph `task_loop` asynchronously and returns immediately.",
452
480
  "Use `inline_llm` for work handled by the current graph owner,",
453
481
  "`expand` for tasks that must be lowered into child nodes,",
454
- "and `task_loop` for asynchronous work in an independent TaskLoop.",
482
+ "and `task_loop` for asynchronous work in an independent TaskLoop attached to a plan node.",
455
483
  "Do not call `plan_graph` together with data-plane tools in the same assistant turn.",
456
484
  ].join(" "),
457
485
  parameters: {
@@ -517,6 +545,63 @@ function buildCoreToolSpecs() {
517
545
  },
518
546
  },
519
547
  },
548
+ {
549
+ type: "function",
550
+ function: {
551
+ name: "task_run",
552
+ description: [
553
+ "Start, inspect, cancel, fail, or complete a TaskRun.",
554
+ "TaskRuns are orthogonal to Plan Mode and do not require a plan_graph.",
555
+ "Use operation=start with an objective for a standalone single-point TaskRun; it returns immediately.",
556
+ "On complex multi-goal work, decompose into concrete objectives and start one or more TaskRuns.",
557
+ "Use plan_graph control.start_task only when the TaskRun is attached to a plan_graph task_loop node.",
558
+ "Do not call `task_run` together with data-plane tools in the same assistant turn.",
559
+ ].join(" "),
560
+ parameters: {
561
+ type: "object",
562
+ properties: {
563
+ operation: {
564
+ type: "string",
565
+ enum: ["start", "cancel", "fail", "complete", "inspect"],
566
+ description: [
567
+ "start creates a standalone TaskRun from objective;",
568
+ "cancel/fail/complete/inspect address an existing taskRunId",
569
+ "(cancel/fail may also use nodeId for graph-bound runs).",
570
+ ].join(" "),
571
+ },
572
+ objective: {
573
+ type: "string",
574
+ description: "Required for start: concrete TaskRun objective.",
575
+ },
576
+ title: {
577
+ type: "string",
578
+ description: "Optional short title for start.",
579
+ },
580
+ taskRunId: {
581
+ type: "string",
582
+ description: "TaskRun id for cancel, fail, complete, or inspect.",
583
+ },
584
+ nodeId: {
585
+ type: "string",
586
+ description: "Optional graph node id for cancel/fail of a graph-bound TaskRun.",
587
+ },
588
+ reason: {
589
+ type: "string",
590
+ description: "Optional reason for cancel or fail.",
591
+ },
592
+ result: {
593
+ type: "object",
594
+ description: "Optional result payload for complete (TaskLoop owner).",
595
+ },
596
+ commandId: {
597
+ type: "string",
598
+ description: "Optional idempotency key for explicit replay.",
599
+ },
600
+ },
601
+ required: ["operation"],
602
+ },
603
+ },
604
+ },
520
605
  {
521
606
  type: "function",
522
607
  function: {
@@ -772,6 +857,51 @@ function runCoreTool({
772
857
  };
773
858
  }
774
859
 
860
+ if (normalizedTool === "task_run") {
861
+ const state = executionState && typeof executionState === "object"
862
+ ? executionState
863
+ : emptyExecutionState();
864
+ const result = runTaskRunTool(safeArgs, {
865
+ executionState: state,
866
+ runTool: ({ node, args: nestedArgs, tool: nestedTool, stepId }) => {
867
+ const nested = runCoreTool({
868
+ tool: nestedTool,
869
+ args: nestedArgs,
870
+ workspaceRoot,
871
+ onToolEvent,
872
+ sessionId,
873
+ onArtifactPersisted,
874
+ executionState: state,
875
+ origin: {
876
+ kind: "task_run",
877
+ taskRunId: String(safeArgs.taskRunId || ""),
878
+ nodeId: stepId || (node && node.id) || "",
879
+ attempt: Number(node && node.attempt) || 0,
880
+ },
881
+ });
882
+ return nested;
883
+ },
884
+ });
885
+ const ok = result.ok !== false && result.status !== "rejected";
886
+ emitToolEvent(onToolEvent, {
887
+ tool: "task_run",
888
+ phase: ok ? "end" : "error",
889
+ args: safeArgs,
890
+ result,
891
+ error: ok
892
+ ? ""
893
+ : (Array.isArray(result.errors)
894
+ ? result.errors.map((e) => e.message || e.code).join("; ")
895
+ : (result.error || "task_run rejected")),
896
+ origin,
897
+ });
898
+ return {
899
+ ...result,
900
+ ok,
901
+ executionState: result.executionState || state,
902
+ };
903
+ }
904
+
775
905
  if (normalizedTool === "ask_user") {
776
906
  const state = executionState && typeof executionState === "object"
777
907
  ? executionState
@@ -970,6 +1100,12 @@ async function runOpenAiLikeTurn({
970
1100
  // Kimi k3 rejects any temperature other than 1.
971
1101
  temperature: normalizeProvider(provider) === "kimi" ? 1 : 0,
972
1102
  };
1103
+ const reasoningEffort = resolveReasoningEffort();
1104
+ if (reasoningEffort) {
1105
+ // OpenAI-compatible gateways that support reasoning models accept this;
1106
+ // unknown fields are typically ignored by plain chat models.
1107
+ payload.reasoning_effort = reasoningEffort;
1108
+ }
973
1109
 
974
1110
  const headers = {
975
1111
  "content-type": "application/json",
@@ -1715,16 +1851,16 @@ async function runNativeLoop({
1715
1851
  await withFaultPoint("before_tool_exec", () => {});
1716
1852
 
1717
1853
  const callNames = pendingCalls.map((call) => String(call.name || "").trim().toLowerCase());
1718
- const hasPlanGraph = callNames.includes("plan_graph");
1854
+ const hasControlPlane = callNames.some((name) => CONTROL_PLANE_TOOLS.has(name));
1719
1855
  const hasAskUser = callNames.includes("ask_user");
1720
1856
  const hasDataTool = callNames.some((name) => EXECUTABLE_GRAPH_TOOLS.has(name));
1721
- if (hasPlanGraph && hasDataTool) {
1857
+ if (hasControlPlane && hasDataTool) {
1722
1858
  // prepareToolCalls already appended the assistant tool_calls / tool_use
1723
1859
  // message; every declared call must get a contiguous tool result via ledger.
1724
1860
  const rejected = {
1725
1861
  ok: false,
1726
1862
  status: "rejected",
1727
- error: "Do not mix plan_graph with data-plane tools in the same turn",
1863
+ error: "Do not mix plan_graph/task_run with data-plane tools in the same turn",
1728
1864
  code: "MIXED_PLAN_AND_DATA_TOOLS",
1729
1865
  };
1730
1866
  for (const pending of pendingCalls) {
@@ -2078,6 +2214,8 @@ module.exports = {
2078
2214
  resolveCompletionUrl,
2079
2215
  resolveAnthropicMessagesUrl,
2080
2216
  resolveTransport,
2217
+ resolveThinkingBudgetTokens,
2218
+ resolveReasoningEffort,
2081
2219
  buildCoreToolSpecs,
2082
2220
  buildAnthropicToolSpecs,
2083
2221
  };
@@ -78,7 +78,7 @@ function validateDeclaredBatch(ledger = null, {
78
78
 
79
79
  const names = calls.map((c) => c.name);
80
80
  const hasAskUser = names.includes("ask_user");
81
- const hasPlanGraph = names.includes("plan_graph");
81
+ const hasControlPlane = names.includes("plan_graph") || names.includes("task_run");
82
82
  const dataSet = dataPlaneTools instanceof Set
83
83
  ? dataPlaneTools
84
84
  : new Set(["read", "write", "edit", "bash", "artifact_read"]);
@@ -90,10 +90,10 @@ function validateDeclaredBatch(ledger = null, {
90
90
  message: "ask_user must be the only tool call in the turn",
91
91
  });
92
92
  }
93
- if (rejectPlanWithData && hasPlanGraph && hasData) {
93
+ if (rejectPlanWithData && hasControlPlane && hasData) {
94
94
  errors.push({
95
95
  code: "MIXED_PLAN_AND_DATA_TOOLS",
96
- message: "Do not mix plan_graph with data-plane tools in the same turn",
96
+ message: "Do not mix plan_graph/task_run with data-plane tools in the same turn",
97
97
  });
98
98
  }
99
99
 
@@ -4,4 +4,5 @@ module.exports = {
4
4
  ...require("./transportContract"),
5
5
  ...require("./openaiChatTransport"),
6
6
  ...require("./anthropicMessagesTransport"),
7
+ ...require("./modelsCatalog"),
7
8
  };
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Live provider model catalog via the OpenAI-compatible / Anthropic models route.
5
+ *
6
+ * Used by /model suggestions and settings validation so ucode only offers
7
+ * (and preferably accepts) models the configured endpoint actually lists.
8
+ */
9
+
10
+ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
11
+ const DEFAULT_TIMEOUT_MS = 8000;
12
+ const CACHE_TTL_MS = 60_000;
13
+
14
+ /** @type {Map<string, { at: number, result: object }>} */
15
+ const modelsCache = new Map();
16
+
17
+ function clipText(value = "", maxChars = 400) {
18
+ const text = String(value || "");
19
+ if (text.length <= maxChars) return text;
20
+ return `${text.slice(0, maxChars)}…`;
21
+ }
22
+
23
+ function resolveOpenAiModelsUrl(baseUrl = "") {
24
+ const raw = String(baseUrl || "").trim();
25
+ if (!raw) return "";
26
+ const normalized = raw.replace(/\/+$/, "");
27
+ if (/\/models$/i.test(normalized)) return normalized;
28
+ if (/\/chat\/completions$/i.test(normalized)) {
29
+ return normalized.replace(/\/chat\/completions$/i, "/models");
30
+ }
31
+ if (/\/v1$/i.test(normalized)) return `${normalized}/models`;
32
+ if (/\/api$/i.test(normalized)) return `${normalized}/v1/models`;
33
+ return `${normalized}/models`;
34
+ }
35
+
36
+ function resolveAnthropicModelsUrl(baseUrl = "") {
37
+ const raw = String(baseUrl || "").trim() || DEFAULT_ANTHROPIC_BASE_URL;
38
+ const normalized = raw.replace(/\/+$/, "");
39
+ if (/\/models$/i.test(normalized)) return normalized;
40
+ if (/\/messages$/i.test(normalized)) {
41
+ return normalized.replace(/\/messages$/i, "/models");
42
+ }
43
+ if (/\/v1$/i.test(normalized)) return `${normalized}/models`;
44
+ if (/\/api$/i.test(normalized)) return `${normalized}/v1/models`;
45
+ return `${normalized}/models`;
46
+ }
47
+
48
+ function resolveModelsUrl({ transport = "", baseUrl = "" } = {}) {
49
+ if (String(transport || "") === "anthropic-messages") {
50
+ return resolveAnthropicModelsUrl(baseUrl);
51
+ }
52
+ return resolveOpenAiModelsUrl(baseUrl);
53
+ }
54
+
55
+ function cacheKey({ transport = "", baseUrl = "", apiKey = "", provider = "" } = {}) {
56
+ const keyTail = apiKey ? String(apiKey).slice(-8) : "";
57
+ return `${provider}|${transport}|${baseUrl}|${keyTail}`;
58
+ }
59
+
60
+ function extractModelIds(payload) {
61
+ const ids = [];
62
+ const seen = new Set();
63
+ const push = (value) => {
64
+ const id = String(value || "").trim();
65
+ if (!id || seen.has(id)) return;
66
+ seen.add(id);
67
+ ids.push(id);
68
+ };
69
+
70
+ if (!payload || typeof payload !== "object") return ids;
71
+
72
+ if (Array.isArray(payload.data)) {
73
+ for (const item of payload.data) {
74
+ if (!item) continue;
75
+ if (typeof item === "string") push(item);
76
+ else push(item.id || item.model || item.name);
77
+ }
78
+ }
79
+
80
+ if (Array.isArray(payload.models)) {
81
+ for (const item of payload.models) {
82
+ if (!item) continue;
83
+ if (typeof item === "string") push(item);
84
+ else push(item.id || item.model || item.name);
85
+ }
86
+ }
87
+
88
+ return ids;
89
+ }
90
+
91
+ function buildListHeaders({ transport = "", apiKey = "" } = {}) {
92
+ const headers = {
93
+ Accept: "application/json",
94
+ };
95
+ const key = String(apiKey || "").trim();
96
+ if (!key) return headers;
97
+
98
+ if (String(transport || "") === "anthropic-messages") {
99
+ headers["x-api-key"] = key;
100
+ headers["anthropic-version"] = "2023-06-01";
101
+ } else {
102
+ headers.Authorization = `Bearer ${key}`;
103
+ }
104
+ return headers;
105
+ }
106
+
107
+ /**
108
+ * Fetch the provider's models catalog.
109
+ * @returns {Promise<{
110
+ * ok: boolean,
111
+ * models: string[],
112
+ * url: string,
113
+ * error: string,
114
+ * status: number,
115
+ * cached: boolean,
116
+ * }>}
117
+ */
118
+ async function listProviderModels(options = {}) {
119
+ const transport = String(options.transport || "openai-chat").trim() || "openai-chat";
120
+ const baseUrl = String(options.baseUrl || "").trim();
121
+ const apiKey = String(options.apiKey || "").trim();
122
+ const provider = String(options.provider || "").trim();
123
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS);
124
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : fetch;
125
+ const skipCache = options.skipCache === true;
126
+ const url = resolveModelsUrl({ transport, baseUrl });
127
+
128
+ if (!url) {
129
+ return {
130
+ ok: false,
131
+ models: [],
132
+ url: "",
133
+ error: "models url unavailable (set ucode base url)",
134
+ status: 0,
135
+ cached: false,
136
+ };
137
+ }
138
+
139
+ const key = cacheKey({ transport, baseUrl, apiKey, provider });
140
+ if (!skipCache) {
141
+ const hit = modelsCache.get(key);
142
+ if (hit && (Date.now() - hit.at) < CACHE_TTL_MS) {
143
+ return { ...hit.result, cached: true };
144
+ }
145
+ }
146
+
147
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
148
+ const timer = controller
149
+ ? setTimeout(() => {
150
+ try { controller.abort(); } catch { /* ignore */ }
151
+ }, timeoutMs)
152
+ : null;
153
+ if (timer && typeof timer.unref === "function") timer.unref();
154
+
155
+ try {
156
+ const response = await fetchImpl(url, {
157
+ method: "GET",
158
+ headers: buildListHeaders({ transport, apiKey }),
159
+ signal: controller ? controller.signal : undefined,
160
+ });
161
+ const status = Number(response && response.status) || 0;
162
+ const bodyText = await response.text().catch(() => "");
163
+ let payload = null;
164
+ try {
165
+ payload = bodyText ? JSON.parse(bodyText) : null;
166
+ } catch {
167
+ payload = null;
168
+ }
169
+
170
+ if (!response.ok) {
171
+ const result = {
172
+ ok: false,
173
+ models: [],
174
+ url,
175
+ error: `models route failed (${status}): ${clipText(bodyText || response.statusText || "unknown")}`,
176
+ status,
177
+ cached: false,
178
+ };
179
+ return result;
180
+ }
181
+
182
+ const models = extractModelIds(payload);
183
+ const result = {
184
+ ok: true,
185
+ models,
186
+ url,
187
+ error: models.length === 0 ? "models route returned an empty catalog" : "",
188
+ status,
189
+ cached: false,
190
+ };
191
+ // Cache successful responses even when empty — avoids hammering a broken gateway.
192
+ modelsCache.set(key, { at: Date.now(), result: { ...result, cached: false } });
193
+ return result;
194
+ } catch (err) {
195
+ const message = err && err.name === "AbortError"
196
+ ? `models route timed out after ${timeoutMs}ms`
197
+ : (err && err.message ? err.message : String(err || "models route failed"));
198
+ return {
199
+ ok: false,
200
+ models: [],
201
+ url,
202
+ error: message,
203
+ status: 0,
204
+ cached: false,
205
+ };
206
+ } finally {
207
+ if (timer) clearTimeout(timer);
208
+ }
209
+ }
210
+
211
+ function modelSupported(model = "", models = []) {
212
+ const needle = String(model || "").trim();
213
+ if (!needle) return false;
214
+ const list = Array.isArray(models) ? models : [];
215
+ return list.some((id) => String(id || "").trim() === needle);
216
+ }
217
+
218
+ /**
219
+ * Validate a model id against the live catalog.
220
+ * Soft mode: when the catalog cannot be fetched, allow with a warning.
221
+ */
222
+ async function confirmModelSupported(options = {}) {
223
+ const model = String(options.model || "").trim();
224
+ if (!model) {
225
+ return {
226
+ ok: false,
227
+ allowed: false,
228
+ model: "",
229
+ models: [],
230
+ error: "model id is empty",
231
+ warning: "",
232
+ catalog: null,
233
+ };
234
+ }
235
+
236
+ const catalog = await listProviderModels(options);
237
+ if (!catalog.ok) {
238
+ return {
239
+ ok: false,
240
+ allowed: options.strict === true ? false : true,
241
+ model,
242
+ models: [],
243
+ error: catalog.error || "models route unavailable",
244
+ warning: options.strict === true
245
+ ? ""
246
+ : `could not confirm model via models route (${catalog.error || "unavailable"}); accepting ${model}`,
247
+ catalog,
248
+ };
249
+ }
250
+
251
+ if (catalog.models.length === 0) {
252
+ return {
253
+ ok: false,
254
+ allowed: options.strict === true ? false : true,
255
+ model,
256
+ models: [],
257
+ error: catalog.error || "empty models catalog",
258
+ warning: options.strict === true
259
+ ? ""
260
+ : `models route returned no models; accepting ${model}`,
261
+ catalog,
262
+ };
263
+ }
264
+
265
+ if (!modelSupported(model, catalog.models)) {
266
+ const sample = catalog.models.slice(0, 8).join(", ");
267
+ const more = catalog.models.length > 8 ? ` (+${catalog.models.length - 8} more)` : "";
268
+ return {
269
+ ok: false,
270
+ allowed: false,
271
+ model,
272
+ models: catalog.models,
273
+ error: `model "${model}" is not in the provider catalog${sample ? ` (available: ${sample}${more})` : ""}`,
274
+ warning: "",
275
+ catalog,
276
+ };
277
+ }
278
+
279
+ return {
280
+ ok: true,
281
+ allowed: true,
282
+ model,
283
+ models: catalog.models,
284
+ error: "",
285
+ warning: "",
286
+ catalog,
287
+ };
288
+ }
289
+
290
+ function clearModelsCache() {
291
+ modelsCache.clear();
292
+ }
293
+
294
+ module.exports = {
295
+ resolveOpenAiModelsUrl,
296
+ resolveAnthropicModelsUrl,
297
+ resolveModelsUrl,
298
+ listProviderModels,
299
+ confirmModelSupported,
300
+ modelSupported,
301
+ extractModelIds,
302
+ clearModelsCache,
303
+ CACHE_TTL_MS,
304
+ };
package/src/code/repl.js CHANGED
@@ -113,21 +113,24 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
113
113
  }
114
114
  const modelMatch = text.match(/^(?:\/model|model)(?:\s+(.*))?$/i);
115
115
  if (modelMatch) {
116
- const nextModel = String(modelMatch[1] || "").trim();
117
- if (!nextModel) {
116
+ const rest = String(modelMatch[1] || "").trim();
117
+ if (!rest) {
118
118
  return { kind: "model", action: "show" };
119
119
  }
120
- // Reject accidental multi-token garbage; model ids are single tokens.
121
- if (/\s/.test(nextModel)) {
120
+ const parts = rest.split(/\s+/).filter(Boolean);
121
+ const modelId = parts[0] || "";
122
+ const thinking = parts[1] || "";
123
+ if (!modelId || parts.length > 2) {
122
124
  return {
123
125
  kind: "error",
124
- output: "usage: /model [model-id]",
126
+ output: "usage: /model [model-id] [off|low|medium|high|max]",
125
127
  };
126
128
  }
127
129
  return {
128
130
  kind: "model",
129
131
  action: "set",
130
- model: nextModel,
132
+ model: modelId,
133
+ thinking,
131
134
  };
132
135
  }
133
136
  const planMatch = text.match(/^(?:\/plan|plan)(?:\s+(.*))?$/i);
@@ -291,10 +294,29 @@ async function runUcodeCoreAgent({
291
294
  provider,
292
295
  model,
293
296
  });
297
+ const {
298
+ currentThinkingLevel,
299
+ } = require("./modelCommand");
300
+ const {
301
+ resolveThinkingFromEnvAndConfig,
302
+ applyThinkingLevelToEnv,
303
+ } = require("./thinkingLevels");
304
+ const { loadGlobalUcodeConfig } = require("../config");
305
+ let initialThinking = "";
306
+ try {
307
+ initialThinking = String((loadGlobalUcodeConfig() || {}).ucodeThinking || "").trim();
308
+ } catch {
309
+ initialThinking = "";
310
+ }
311
+ const thinkingResolved = resolveThinkingFromEnvAndConfig({
312
+ env: process.env,
313
+ configLevel: initialThinking,
314
+ });
294
315
  const state = {
295
316
  workspaceRoot: resolvedWorkspaceRoot,
296
317
  provider: resolvedUcode.provider,
297
318
  model: resolvedUcode.model,
319
+ thinking: currentThinkingLevel({ thinking: initialThinking }),
298
320
  engine: "ufoo-core",
299
321
  context: buildNlContext({
300
322
  appendSystemPrompt,
@@ -308,6 +330,10 @@ async function runUcodeCoreAgent({
308
330
  timeoutMs: resolveNlTaskTimeoutMs(Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : NaN),
309
331
  jsonOutput,
310
332
  };
333
+ // Named levels sync into env; leave an explicit numeric budget override alone.
334
+ if (thinkingResolved.source !== "env-budget") {
335
+ applyThinkingLevelToEnv(state.thinking, process.env);
336
+ }
311
337
  persistSessionState(state);
312
338
 
313
339
  if (shouldUseUcodeTui({
@@ -466,7 +492,9 @@ async function runUcodeCoreAgent({
466
492
  }
467
493
  }
468
494
  if (result.kind === "model") {
469
- const applied = applyUcodeModelCommand(state, result);
495
+ const applied = await applyUcodeModelCommand(state, result, {
496
+ workspaceRoot: runtimeWorkspace,
497
+ });
470
498
  stdout.write(`${applied.output}\n`);
471
499
  if (applied.ok && result.action === "set") {
472
500
  persistSessionState(state);
@@ -770,4 +798,5 @@ module.exports = {
770
798
  applyUcodeModelCommand,
771
799
  applyUcodePlanCommand,
772
800
  suggestUcodeModels,
773
- };
801
+ suggestUcodeThinkingLevels: require("./modelCommand").suggestUcodeThinkingLevels,
802
+ };