@giovannijecha/jecode 0.2.0 → 0.2.1
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 +4 -3
- package/dist/controller.js +43 -11
- package/dist/credential-safety.js +8 -0
- package/dist/effort.js +25 -0
- package/dist/openai-account.js +10 -7
- package/dist/provider-commands.js +67 -14
- package/dist/providers/anthropic-stream.js +8 -2
- package/dist/providers/anthropic.js +21 -5
- package/dist/providers/ollama-stream.js +3 -0
- package/dist/providers/ollama.js +3 -0
- package/dist/providers/openai-codex.js +53 -10
- package/dist/providers/openai-stream.js +1 -1
- package/dist/providers/openai-wire.js +0 -5
- package/dist/providers/openai.js +34 -13
- package/dist/settings-command.js +53 -6
- package/dist/settings.js +2 -1
- package/dist/tools/args.js +4 -3
- package/dist/tools/fs.js +8 -3
- package/dist/tools/index.js +2 -2
- package/dist/tools/shell.js +2 -0
- package/dist/tui/components/messages.js +26 -5
- package/dist/tui/turn.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,9 +255,10 @@ untrusted data.
|
|
|
255
255
|
- Dangerous tools ask by default unless explicitly allowed for the session in
|
|
256
256
|
**/permissions** or the process started with **--auto-approve**.
|
|
257
257
|
- Credential fields are masked and excluded from transcripts. Approved shell
|
|
258
|
-
commands receive no
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
commands receive no secret-bearing environment variables; `SSH_AUTH_SOCK`
|
|
259
|
+
is preserved so Git and SSH can use the user's agent, which means an approved
|
|
260
|
+
command can request that agent to authenticate. Recognized credential values
|
|
261
|
+
are redacted before tool output reaches the model, screen, history, or export.
|
|
261
262
|
- ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
|
|
262
263
|
flow. Refresh-token rotation is serialized across Jecode processes; OAuth
|
|
263
264
|
tokens are withheld and redacted like API keys.
|
package/dist/controller.js
CHANGED
|
@@ -14,6 +14,7 @@ export const MAX_TOOL_CALLS_PER_STEP = 32;
|
|
|
14
14
|
export async function runTurn(history, options, events, signal) {
|
|
15
15
|
const specs = toolSpecs(options.tools);
|
|
16
16
|
for (let step = 0; step < options.maxSteps; step++) {
|
|
17
|
+
throwIfAborted(signal);
|
|
17
18
|
events.onStep?.(step + 1, options.maxSteps);
|
|
18
19
|
// The message is displayed as it streams; what comes back here is the
|
|
19
20
|
// assembled version, which exists to be appended to the history.
|
|
@@ -28,6 +29,7 @@ export async function runTurn(history, options, events, signal) {
|
|
|
28
29
|
onStream: (event) => events.onStream(event),
|
|
29
30
|
onStatus: (status) => events.onStatus?.(status),
|
|
30
31
|
});
|
|
32
|
+
throwIfAborted(signal);
|
|
31
33
|
const calls = assistant.content.filter(isToolCall);
|
|
32
34
|
if (assistant.content.length === 0) {
|
|
33
35
|
throw new Error(`${options.provider.id} completed without an answer or tool call`);
|
|
@@ -44,14 +46,33 @@ export async function runTurn(history, options, events, signal) {
|
|
|
44
46
|
// but every result from this step goes back in a SINGLE message. Splitting
|
|
45
47
|
// them teaches the model to stop batching its calls.
|
|
46
48
|
const results = [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
const announced = new Set();
|
|
50
|
+
try {
|
|
51
|
+
for (let index = 0; index < calls.length; index++) {
|
|
52
|
+
throwIfAborted(signal);
|
|
53
|
+
const call = calls[index];
|
|
54
|
+
events.onToolProgress?.(index + 1, calls.length);
|
|
55
|
+
const preview = await look(call, options, signal);
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
events.onToolCall(call, preview);
|
|
58
|
+
announced.add(call.id);
|
|
59
|
+
const { result, summary } = await settle(call, options, events, signal, preview);
|
|
60
|
+
events.onToolResult(call, result, summary);
|
|
61
|
+
results.push(result);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (signal?.aborted !== true)
|
|
66
|
+
throw error;
|
|
67
|
+
for (const call of calls.slice(results.length)) {
|
|
68
|
+
const interrupted = refuse(call, "interrupted before completion", "interrupted");
|
|
69
|
+
if (announced.has(call.id)) {
|
|
70
|
+
events.onToolResult(call, interrupted.result, interrupted.summary);
|
|
71
|
+
}
|
|
72
|
+
results.push(interrupted.result);
|
|
73
|
+
}
|
|
74
|
+
history.push({ role: "user", content: results });
|
|
75
|
+
throw abortReason(signal);
|
|
55
76
|
}
|
|
56
77
|
history.push({ role: "user", content: results });
|
|
57
78
|
}
|
|
@@ -62,9 +83,13 @@ async function settle(call, options, events, signal, preview) {
|
|
|
62
83
|
if (tool === undefined) {
|
|
63
84
|
return refuse(call, `no such tool: ${call.name}`, "unknown tool");
|
|
64
85
|
}
|
|
65
|
-
|
|
86
|
+
throwIfAborted(signal);
|
|
87
|
+
const approved = !tool.dangerous || await events.approve(call);
|
|
88
|
+
throwIfAborted(signal);
|
|
89
|
+
if (!approved) {
|
|
66
90
|
return refuse(call, "the user declined this call — ask them how to proceed", "declined");
|
|
67
91
|
}
|
|
92
|
+
throwIfAborted(signal);
|
|
68
93
|
return runTool(tool, call, {
|
|
69
94
|
...options.toolContext,
|
|
70
95
|
signal,
|
|
@@ -85,14 +110,21 @@ function refuse(call, reason, summary) {
|
|
|
85
110
|
* file, a match that is not there — is simply no preview. Nothing about the
|
|
86
111
|
* turn depends on it, because it exists for the user, not for the model.
|
|
87
112
|
*/
|
|
88
|
-
async function look(call, options) {
|
|
113
|
+
async function look(call, options, signal) {
|
|
89
114
|
const tool = findTool(options.tools, call.name);
|
|
90
115
|
if (tool?.preview === undefined)
|
|
91
116
|
return undefined;
|
|
92
117
|
try {
|
|
93
|
-
return await tool.preview(call.input, options.toolContext);
|
|
118
|
+
return await tool.preview(call.input, { ...options.toolContext, signal });
|
|
94
119
|
}
|
|
95
120
|
catch {
|
|
96
121
|
return undefined;
|
|
97
122
|
}
|
|
98
123
|
}
|
|
124
|
+
function throwIfAborted(signal) {
|
|
125
|
+
if (signal?.aborted === true)
|
|
126
|
+
throw abortReason(signal);
|
|
127
|
+
}
|
|
128
|
+
function abortReason(signal) {
|
|
129
|
+
return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
130
|
+
}
|
|
@@ -4,6 +4,12 @@ import { accountValues } from "./accounts.js";
|
|
|
4
4
|
const REDACTED = "[credential redacted]";
|
|
5
5
|
const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
|
|
6
6
|
const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
|
|
7
|
+
const SAFE_ENVIRONMENT_NAMES = new Set([
|
|
8
|
+
"SSH_AUTH_SOCK",
|
|
9
|
+
"PWD",
|
|
10
|
+
"OLDPWD",
|
|
11
|
+
"PASSWORD_STORE_DIR",
|
|
12
|
+
]);
|
|
7
13
|
/** Preserve ordinary tool configuration while withholding credential-like values. */
|
|
8
14
|
export function shellEnvironment(source = process.env) {
|
|
9
15
|
const environment = {};
|
|
@@ -57,6 +63,8 @@ function sensitiveEnvironmentName(name) {
|
|
|
57
63
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
58
64
|
.replace(/[^a-z0-9]+/gi, "_")
|
|
59
65
|
.replace(/^_+|_+$/g, "");
|
|
66
|
+
if (SAFE_ENVIRONMENT_NAMES.has(normalized.toUpperCase()))
|
|
67
|
+
return false;
|
|
60
68
|
return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
|
|
61
69
|
COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
|
|
62
70
|
}
|
package/dist/effort.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One ordered vocabulary for reasoning depth. Providers expose the subset a
|
|
2
|
+
// selected model accepts; this module only validates and reconciles that data.
|
|
3
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
4
|
+
export function isEffort(value) {
|
|
5
|
+
return typeof value === "string" && EFFORTS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
export function requireSupportedEffort(model, requested, supported) {
|
|
8
|
+
if (supported.includes(requested))
|
|
9
|
+
return requested;
|
|
10
|
+
const choices = supported.length === 0 ? "none" : supported.join(", ");
|
|
11
|
+
throw new Error(`${model} does not support effort "${requested}" (available: ${choices})`);
|
|
12
|
+
}
|
|
13
|
+
/** Keep the requested depth when possible, otherwise choose the nearest lower level. */
|
|
14
|
+
export function compatibleEffort(requested, supported) {
|
|
15
|
+
if (supported.length === 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (supported.includes(requested))
|
|
18
|
+
return requested;
|
|
19
|
+
const requestedIndex = EFFORTS.indexOf(requested);
|
|
20
|
+
const candidates = EFFORTS.filter((value) => supported.includes(value));
|
|
21
|
+
if (requestedIndex < 0)
|
|
22
|
+
return candidates[0];
|
|
23
|
+
return [...candidates].reverse().find((value) => EFFORTS.indexOf(value) < requestedIndex)
|
|
24
|
+
?? candidates[0];
|
|
25
|
+
}
|
package/dist/openai-account.js
CHANGED
|
@@ -43,6 +43,8 @@ export async function openAIAuthorization(forceToken, signal, onStatus) {
|
|
|
43
43
|
async function refreshAccount(forceToken, signal, onStatus) {
|
|
44
44
|
if (refreshing !== undefined)
|
|
45
45
|
return abortable(refreshing, signal);
|
|
46
|
+
if (signal?.aborted === true)
|
|
47
|
+
throw abortReason(signal);
|
|
46
48
|
onStatus?.("Refreshing ChatGPT sign-in");
|
|
47
49
|
const task = updateOpenAICodexAccount(async (current) => {
|
|
48
50
|
if (current === undefined)
|
|
@@ -51,20 +53,21 @@ async function refreshAccount(forceToken, signal, onStatus) {
|
|
|
51
53
|
return current;
|
|
52
54
|
if (forceToken === undefined && !expiresSoon(current))
|
|
53
55
|
return current;
|
|
54
|
-
return refreshOpenAITokens(current
|
|
55
|
-
}
|
|
56
|
+
return refreshOpenAITokens(current);
|
|
57
|
+
}).then((account) => {
|
|
56
58
|
if (account === undefined)
|
|
57
59
|
throw new Error("ChatGPT account is not connected");
|
|
58
60
|
return account;
|
|
59
61
|
});
|
|
60
62
|
refreshing = task;
|
|
61
|
-
|
|
62
|
-
return await abortable(task, signal);
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
63
|
+
void task.then(() => {
|
|
65
64
|
if (refreshing === task)
|
|
66
65
|
refreshing = undefined;
|
|
67
|
-
}
|
|
66
|
+
}, () => {
|
|
67
|
+
if (refreshing === task)
|
|
68
|
+
refreshing = undefined;
|
|
69
|
+
});
|
|
70
|
+
return abortable(task, signal);
|
|
68
71
|
}
|
|
69
72
|
function expiresSoon(account) {
|
|
70
73
|
return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
|
|
@@ -5,6 +5,7 @@ import { readSettings } from "./settings.js";
|
|
|
5
5
|
import { authenticationNeed, ensureProviderAuthentication, } from "./credential-commands.js";
|
|
6
6
|
import { providerFailure } from "./provider-errors.js";
|
|
7
7
|
import { providerLabel } from "./provider-label.js";
|
|
8
|
+
import { compatibleEffort } from "./effort.js";
|
|
8
9
|
/**
|
|
9
10
|
* The provider menu.
|
|
10
11
|
*
|
|
@@ -61,6 +62,7 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
61
62
|
model: session.model,
|
|
62
63
|
providerId: session.config.providerId,
|
|
63
64
|
configModel: session.config.model,
|
|
65
|
+
effort: session.config.effort,
|
|
64
66
|
};
|
|
65
67
|
session.provider = chosen;
|
|
66
68
|
// The model belonged to the old provider. Carrying it across would send
|
|
@@ -68,23 +70,31 @@ export async function providersCommand(session, host, behavior = {}) {
|
|
|
68
70
|
session.model = readSettings().models?.[chosen.id] ?? chosen.defaultModel;
|
|
69
71
|
session.config.providerId = chosen.id;
|
|
70
72
|
session.config.model = session.model;
|
|
71
|
-
if (session.model === ""
|
|
72
|
-
session
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
if (session.model === "") {
|
|
74
|
+
if (!(await modelsCommand(session, host, { announce: false, save: false }))) {
|
|
75
|
+
restoreProvider(session, before);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const alignment = await alignEffort(session, host);
|
|
81
|
+
if (!alignment.ok) {
|
|
82
|
+
restoreProvider(session, before);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
77
85
|
}
|
|
78
86
|
if (behavior.save !== false) {
|
|
79
87
|
const saved = readSettings();
|
|
80
88
|
const models = { ...saved.models };
|
|
81
89
|
if (session.model !== "")
|
|
82
90
|
models[chosen.id] = session.model;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
session.config.
|
|
87
|
-
|
|
91
|
+
const patch = {
|
|
92
|
+
provider: chosen.id,
|
|
93
|
+
models,
|
|
94
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
95
|
+
};
|
|
96
|
+
if (!(await saveDefaults(host, patch))) {
|
|
97
|
+
restoreProvider(session, before);
|
|
88
98
|
return false;
|
|
89
99
|
}
|
|
90
100
|
}
|
|
@@ -152,15 +162,31 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
152
162
|
const chosen = ids[index];
|
|
153
163
|
if (chosen === undefined)
|
|
154
164
|
return false;
|
|
155
|
-
const before = {
|
|
165
|
+
const before = {
|
|
166
|
+
model: session.model,
|
|
167
|
+
configModel: session.config.model,
|
|
168
|
+
effort: session.config.effort,
|
|
169
|
+
};
|
|
156
170
|
session.model = chosen;
|
|
157
171
|
session.config.model = chosen;
|
|
172
|
+
const alignment = await alignEffort(session, host);
|
|
173
|
+
if (!alignment.ok) {
|
|
174
|
+
session.model = before.model;
|
|
175
|
+
session.config.model = before.configModel;
|
|
176
|
+
session.config.effort = before.effort;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
158
179
|
if (behavior.save !== false) {
|
|
159
180
|
const saved = readSettings();
|
|
160
181
|
const models = { ...saved.models, [provider.id]: chosen };
|
|
161
|
-
|
|
182
|
+
const patch = {
|
|
183
|
+
models,
|
|
184
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
185
|
+
};
|
|
186
|
+
if (!(await saveDefaults(host, patch))) {
|
|
162
187
|
session.model = before.model;
|
|
163
188
|
session.config.model = before.configModel;
|
|
189
|
+
session.config.effort = before.effort;
|
|
164
190
|
return false;
|
|
165
191
|
}
|
|
166
192
|
}
|
|
@@ -169,7 +195,34 @@ export async function modelsCommand(session, host, behavior = {}) {
|
|
|
169
195
|
}
|
|
170
196
|
return true;
|
|
171
197
|
}
|
|
172
|
-
|
|
198
|
+
function restoreProvider(session, before) {
|
|
199
|
+
session.provider = before.provider;
|
|
200
|
+
session.model = before.model;
|
|
201
|
+
session.config.providerId = before.providerId;
|
|
202
|
+
session.config.model = before.configModel;
|
|
203
|
+
session.config.effort = before.effort;
|
|
204
|
+
}
|
|
205
|
+
async function alignEffort(session, host) {
|
|
206
|
+
if (session.provider.efforts === undefined)
|
|
207
|
+
return { ok: true };
|
|
208
|
+
let supported;
|
|
209
|
+
try {
|
|
210
|
+
supported = await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
host.emit({
|
|
214
|
+
kind: "notice",
|
|
215
|
+
text: providerFailure(session.provider, error, true),
|
|
216
|
+
tone: "error",
|
|
217
|
+
});
|
|
218
|
+
return { ok: false };
|
|
219
|
+
}
|
|
220
|
+
const adjusted = compatibleEffort(session.config.effort, supported);
|
|
221
|
+
if (adjusted === undefined || adjusted === session.config.effort)
|
|
222
|
+
return { ok: true };
|
|
223
|
+
session.config.effort = adjusted;
|
|
224
|
+
return { ok: true, adjusted };
|
|
225
|
+
}
|
|
173
226
|
function chooser(host) {
|
|
174
227
|
if (host.choose === undefined) {
|
|
175
228
|
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
@@ -13,7 +13,8 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
13
13
|
let stopReason;
|
|
14
14
|
let stopDetails;
|
|
15
15
|
let usage;
|
|
16
|
-
|
|
16
|
+
let complete = false;
|
|
17
|
+
stream: for await (const raw of events) {
|
|
17
18
|
const event = raw;
|
|
18
19
|
switch (event.type) {
|
|
19
20
|
case "message_start":
|
|
@@ -51,10 +52,15 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
51
52
|
case "error": {
|
|
52
53
|
throw new Error(`anthropic stream error: ${event.error?.message ?? "unspecified"}`);
|
|
53
54
|
}
|
|
55
|
+
case "message_stop":
|
|
56
|
+
complete = true;
|
|
57
|
+
break stream;
|
|
54
58
|
default:
|
|
55
|
-
break; //
|
|
59
|
+
break; // ping and unknown forward-compatible events
|
|
56
60
|
}
|
|
57
61
|
}
|
|
62
|
+
if (!complete)
|
|
63
|
+
throw new Error("anthropic stream ended before message_stop");
|
|
58
64
|
const content = [...blocks.entries()]
|
|
59
65
|
.sort(([a], [b]) => a - b)
|
|
60
66
|
.map(([, block]) => block);
|
|
@@ -9,19 +9,27 @@
|
|
|
9
9
|
import { postSse } from "./http.js";
|
|
10
10
|
import { listModels } from "./catalog.js";
|
|
11
11
|
import { keyFor } from "../credentials.js";
|
|
12
|
+
import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
12
13
|
import { assembleAnthropic } from "./anthropic-stream.js";
|
|
13
14
|
import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
|
|
14
15
|
const ENDPOINT = "https://api.anthropic.com/v1/messages";
|
|
15
16
|
const MODELS = "https://api.anthropic.com/v1/models?limit=100";
|
|
16
17
|
const API_VERSION = "2023-06-01";
|
|
17
18
|
const KEY = "ANTHROPIC_API_KEY";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const ADAPTIVE = /^claude-(fable-5|opus-(5|4-[678])|sonnet-(5|4-6))/;
|
|
19
|
+
const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
|
|
20
|
+
const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
|
|
21
|
+
const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
|
|
22
22
|
export function supportsAdaptiveThinking(model) {
|
|
23
23
|
return ADAPTIVE.test(model);
|
|
24
24
|
}
|
|
25
|
+
export function anthropicEfforts(model) {
|
|
26
|
+
if (/^claude-(?:(?:opus|sonnet)-4-6|mythos-preview)(?:-|$)/.test(model)) {
|
|
27
|
+
return MAX_WITHOUT_XHIGH;
|
|
28
|
+
}
|
|
29
|
+
if (/^claude-opus-4-5(?:-|$)/.test(model))
|
|
30
|
+
return ANTHROPIC_45_EFFORTS;
|
|
31
|
+
return supportsAdaptiveThinking(model) ? EFFORTS : [];
|
|
32
|
+
}
|
|
25
33
|
export const anthropic = {
|
|
26
34
|
id: "anthropic",
|
|
27
35
|
// Sonnet is the default because it is the one that can be left running.
|
|
@@ -36,6 +44,9 @@ export const anthropic = {
|
|
|
36
44
|
models(signal, onStatus) {
|
|
37
45
|
return listModels(MODELS, headers(requireKey()), signal, onStatus);
|
|
38
46
|
},
|
|
47
|
+
async efforts(model) {
|
|
48
|
+
return anthropicEfforts(model);
|
|
49
|
+
},
|
|
39
50
|
location: () => "cloud",
|
|
40
51
|
async send(req) {
|
|
41
52
|
const key = requireKey();
|
|
@@ -49,7 +60,12 @@ export const anthropic = {
|
|
|
49
60
|
};
|
|
50
61
|
if (supportsAdaptiveThinking(req.model)) {
|
|
51
62
|
body["thinking"] = { type: "adaptive", display: "summarized" };
|
|
52
|
-
|
|
63
|
+
}
|
|
64
|
+
const efforts = anthropicEfforts(req.model);
|
|
65
|
+
if (efforts.length > 0) {
|
|
66
|
+
body["output_config"] = {
|
|
67
|
+
effort: requireSupportedEffort(req.model, req.effort, efforts),
|
|
68
|
+
};
|
|
53
69
|
}
|
|
54
70
|
const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
|
|
55
71
|
const data = await assembleAnthropic(events, req.onStream);
|
|
@@ -51,6 +51,9 @@ export async function assembleOllama(events, onStream) {
|
|
|
51
51
|
calls.set(index, call);
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
if (finishReason === undefined) {
|
|
55
|
+
throw new Error("ollama stream ended before a finish reason");
|
|
56
|
+
}
|
|
54
57
|
const toolCalls = [...calls.entries()]
|
|
55
58
|
.sort(([a], [b]) => a - b)
|
|
56
59
|
// Not every server sends an id, and the loop needs one to pair the result
|
package/dist/providers/ollama.js
CHANGED
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { openAICodexAccount } from "../accounts.js";
|
|
4
4
|
import { openAIAuthorization } from "../openai-account.js";
|
|
5
5
|
import { applicationVersion } from "../version.js";
|
|
6
|
+
import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
|
|
6
7
|
import { getJson, postSse } from "./http.js";
|
|
7
8
|
import { assembleOpenAI } from "./openai-stream.js";
|
|
8
9
|
import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
|
|
@@ -16,6 +17,8 @@ const SESSION_ID = randomUUID();
|
|
|
16
17
|
const MAX_CATALOG_ITEMS = 4_000;
|
|
17
18
|
const MAX_MODELS = 1_000;
|
|
18
19
|
const MAX_MODEL_CHARS = 256;
|
|
20
|
+
const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
|
|
21
|
+
let effortByModel = new Map();
|
|
19
22
|
export const openaiCodex = {
|
|
20
23
|
id: ID,
|
|
21
24
|
defaultModel: "",
|
|
@@ -25,12 +28,21 @@ export const openaiCodex = {
|
|
|
25
28
|
},
|
|
26
29
|
location: () => "cloud",
|
|
27
30
|
async models(signal, onStatus) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const catalog = await loadCatalog(signal, onStatus);
|
|
32
|
+
effortByModel = catalog.efforts;
|
|
33
|
+
return catalog.ids;
|
|
34
|
+
},
|
|
35
|
+
async efforts(model, signal, onStatus) {
|
|
36
|
+
const cached = effortByModel.get(model);
|
|
37
|
+
if (cached !== undefined)
|
|
38
|
+
return cached;
|
|
39
|
+
const catalog = await loadCatalog(signal, onStatus);
|
|
40
|
+
effortByModel = catalog.efforts;
|
|
41
|
+
return effortByModel.get(model) ?? fallbackEfforts(model);
|
|
32
42
|
},
|
|
33
43
|
async send(req) {
|
|
44
|
+
const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
|
|
45
|
+
const effort = requireSupportedEffort(req.model, req.effort, efforts);
|
|
34
46
|
return withAuthorization(async (authorization) => {
|
|
35
47
|
const events = await postSse(`${BASE}/responses`, {
|
|
36
48
|
...headers(authorization, randomUUID()),
|
|
@@ -44,7 +56,7 @@ export const openaiCodex = {
|
|
|
44
56
|
tools: req.tools.map(toWireTool),
|
|
45
57
|
tool_choice: "auto",
|
|
46
58
|
parallel_tool_calls: true,
|
|
47
|
-
reasoning: { effort
|
|
59
|
+
reasoning: { effort, summary: "auto" },
|
|
48
60
|
text: { verbosity: "low" },
|
|
49
61
|
include: ["reasoning.encrypted_content"],
|
|
50
62
|
prompt_cache_key: SESSION_ID,
|
|
@@ -57,6 +69,12 @@ export const openaiCodex = {
|
|
|
57
69
|
}, req.signal, req.onStatus);
|
|
58
70
|
},
|
|
59
71
|
};
|
|
72
|
+
async function loadCatalog(signal, onStatus) {
|
|
73
|
+
return withAuthorization(async (authorization) => {
|
|
74
|
+
const body = await getJson(`${BASE}/models?client_version=${CATALOG_COMPATIBILITY_VERSION}`, headers(authorization, randomUUID()), signal, onStatus);
|
|
75
|
+
return modelCatalog(body);
|
|
76
|
+
}, signal, onStatus);
|
|
77
|
+
}
|
|
60
78
|
async function withAuthorization(operation, signal, onStatus) {
|
|
61
79
|
let authorization = await openAIAuthorization(undefined, signal, onStatus);
|
|
62
80
|
try {
|
|
@@ -80,12 +98,12 @@ function headers(authorization, requestId) {
|
|
|
80
98
|
"x-client-request-id": requestId,
|
|
81
99
|
};
|
|
82
100
|
}
|
|
83
|
-
function
|
|
101
|
+
function modelCatalog(value) {
|
|
84
102
|
const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
|
|
85
103
|
if (source === undefined)
|
|
86
104
|
throw new Error("OpenAI Codex did not return a model list");
|
|
87
105
|
const seen = new Set();
|
|
88
|
-
|
|
106
|
+
const models = source
|
|
89
107
|
.slice(0, MAX_CATALOG_ITEMS)
|
|
90
108
|
.flatMap((entry) => {
|
|
91
109
|
if (!record(entry))
|
|
@@ -98,11 +116,36 @@ function modelIds(value) {
|
|
|
98
116
|
seen.has(id))
|
|
99
117
|
return [];
|
|
100
118
|
seen.add(id);
|
|
101
|
-
return [{
|
|
119
|
+
return [{
|
|
120
|
+
id,
|
|
121
|
+
priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
|
|
122
|
+
efforts: reasoningLevels(entry, id),
|
|
123
|
+
}];
|
|
102
124
|
})
|
|
103
125
|
.sort((left, right) => left.priority - right.priority)
|
|
104
|
-
.slice(0, MAX_MODELS)
|
|
105
|
-
|
|
126
|
+
.slice(0, MAX_MODELS);
|
|
127
|
+
return {
|
|
128
|
+
ids: models.map((entry) => entry.id),
|
|
129
|
+
efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function reasoningLevels(entry, model) {
|
|
133
|
+
const source = entry["supported_reasoning_levels"];
|
|
134
|
+
if (!Array.isArray(source))
|
|
135
|
+
return fallbackEfforts(model);
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const efforts = source.flatMap((level) => {
|
|
138
|
+
if (!record(level) || !isEffort(level["effort"]) || seen.has(level["effort"]))
|
|
139
|
+
return [];
|
|
140
|
+
seen.add(level["effort"]);
|
|
141
|
+
return [level["effort"]];
|
|
142
|
+
});
|
|
143
|
+
return efforts.length === 0 ? fallbackEfforts(model) : efforts;
|
|
144
|
+
}
|
|
145
|
+
function fallbackEfforts(model) {
|
|
146
|
+
if (/^gpt-5\.6-(?:sol|terra|luna)(?:-|$)/.test(model))
|
|
147
|
+
return EFFORTS;
|
|
148
|
+
return XHIGH_EFFORTS;
|
|
106
149
|
}
|
|
107
150
|
function statusOf(error) {
|
|
108
151
|
return typeof error === "object" && error !== null && "status" in error
|
|
@@ -42,7 +42,7 @@ export async function assembleOpenAI(events, onStream) {
|
|
|
42
42
|
break;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
throw new Error("openai stream ended before a terminal response event");
|
|
46
46
|
}
|
|
47
47
|
function reconcileOutput(completed, items) {
|
|
48
48
|
if (completed === undefined)
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the OpenAI Responses wire
|
|
2
2
|
// shape: a flat `input` list where tool calls and their outputs are top-level
|
|
3
3
|
// items keyed by `call_id`, rather than blocks nested inside a message.
|
|
4
|
-
// The Responses API has no `xhigh`; collapse it onto the nearest level rather
|
|
5
|
-
// than passing through a value it will reject.
|
|
6
|
-
export function normalizeEffort(effort) {
|
|
7
|
-
return effort === "xhigh" || effort === "max" ? "high" : effort;
|
|
8
|
-
}
|
|
9
4
|
export function toWireTool(tool) {
|
|
10
5
|
return {
|
|
11
6
|
type: "function",
|
package/dist/providers/openai.js
CHANGED
|
@@ -5,21 +5,38 @@
|
|
|
5
5
|
import { postSse } from "./http.js";
|
|
6
6
|
import { listModels } from "./catalog.js";
|
|
7
7
|
import { keyFor } from "../credentials.js";
|
|
8
|
+
import { EFFORTS, requireSupportedEffort } from "../effort.js";
|
|
8
9
|
import { assembleOpenAI } from "./openai-stream.js";
|
|
9
|
-
import { fromWireResponse,
|
|
10
|
+
import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
|
|
10
11
|
const ENDPOINT = "https://api.openai.com/v1/responses";
|
|
11
12
|
const MODELS = "https://api.openai.com/v1/models";
|
|
12
13
|
const KEY = "OPENAI_API_KEY";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
const RESPONSES_REASONING_MODEL = /^(?:gpt-5(?:[.-]|$)|o(?:1|3|4)(?:[.-]|$)|codex-mini(?:[.-]|$))/;
|
|
15
|
+
// Jecode's transport always streams and always declares local tools. Hide
|
|
16
|
+
// catalog entries that cannot satisfy either half of that contract.
|
|
17
|
+
const INCOMPATIBLE_MODEL = /^(?:gpt-5(?:\.[1-3])?-chat-latest|gpt-5\.5-pro|o1-mini|o(?:1|3)-pro|o3-deep-research|o4-mini-deep-research)(?:-|$)/;
|
|
18
|
+
const STANDARD_EFFORTS = ["low", "medium", "high"];
|
|
19
|
+
const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
|
|
20
|
+
const PRO_EFFORTS = ["medium", "high", "xhigh"];
|
|
21
|
+
const HIGH_ONLY_EFFORT = ["high"];
|
|
22
|
+
export function supportsOpenAIModel(model) {
|
|
23
|
+
return RESPONSES_REASONING_MODEL.test(model) && !INCOMPATIBLE_MODEL.test(model);
|
|
24
|
+
}
|
|
25
|
+
export function openAIEfforts(model) {
|
|
26
|
+
if (!supportsOpenAIModel(model))
|
|
27
|
+
return [];
|
|
28
|
+
if (/^gpt-5-pro(?:-|$)/.test(model))
|
|
29
|
+
return HIGH_ONLY_EFFORT;
|
|
30
|
+
if (/^gpt-5\.[2-5]-pro(?:-|$)/.test(model))
|
|
31
|
+
return PRO_EFFORTS;
|
|
32
|
+
if (/^gpt-5\.6(?:[.-]|$)/.test(model))
|
|
33
|
+
return EFFORTS;
|
|
34
|
+
if (/^gpt-5\.[2-5](?:[.-]|$)/.test(model))
|
|
35
|
+
return XHIGH_EFFORTS;
|
|
36
|
+
if (/^(?:o(?:1|3|4)|codex-mini)(?:[.-]|$)/.test(model))
|
|
37
|
+
return STANDARD_EFFORTS;
|
|
38
|
+
return STANDARD_EFFORTS;
|
|
39
|
+
}
|
|
23
40
|
export const openai = {
|
|
24
41
|
id: "openai",
|
|
25
42
|
defaultModel: "gpt-5",
|
|
@@ -32,19 +49,23 @@ export const openai = {
|
|
|
32
49
|
async models(signal, onStatus) {
|
|
33
50
|
const ids = await listModels(MODELS, headers(requireKey()), signal, onStatus);
|
|
34
51
|
return ids
|
|
35
|
-
.filter(
|
|
52
|
+
.filter(supportsOpenAIModel)
|
|
36
53
|
.sort((a, b) => b.localeCompare(a));
|
|
37
54
|
},
|
|
55
|
+
async efforts(model) {
|
|
56
|
+
return openAIEfforts(model);
|
|
57
|
+
},
|
|
38
58
|
location: () => "cloud",
|
|
39
59
|
async send(req) {
|
|
40
60
|
const key = requireKey();
|
|
61
|
+
const effort = requireSupportedEffort(req.model, req.effort, openAIEfforts(req.model));
|
|
41
62
|
const events = await postSse(ENDPOINT, headers(key), {
|
|
42
63
|
model: req.model,
|
|
43
64
|
instructions: req.system,
|
|
44
65
|
input: req.messages.flatMap((message) => toWireItems(message)),
|
|
45
66
|
tools: req.tools.map(toWireTool),
|
|
46
67
|
max_output_tokens: req.maxTokens,
|
|
47
|
-
reasoning: { effort
|
|
68
|
+
reasoning: { effort, summary: "auto" },
|
|
48
69
|
store: false,
|
|
49
70
|
include: ["reasoning.encrypted_content"],
|
|
50
71
|
stream: true,
|
package/dist/settings-command.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { modelsCommand, providersCommand } from "./provider-commands.js";
|
|
4
4
|
import { credentialsCommand } from "./credential-commands.js";
|
|
5
5
|
import { EFFORTS, readSettings, settingsLabel, updateSettings } from "./settings.js";
|
|
6
|
+
import { providerFailure } from "./provider-errors.js";
|
|
6
7
|
import { ollamaConnectionHint, ollamaConnectionSetting, } from "./ollama-settings-command.js";
|
|
7
8
|
import { of } from "./tui/editor.js";
|
|
8
9
|
import { heading } from "./tui/picker.js";
|
|
@@ -109,6 +110,7 @@ async function providerSetting(session, host) {
|
|
|
109
110
|
model: session.model,
|
|
110
111
|
providerId: session.config.providerId,
|
|
111
112
|
configModel: session.config.model,
|
|
113
|
+
effort: session.config.effort,
|
|
112
114
|
};
|
|
113
115
|
if (!(await providersCommand(session, host, { announce: false, save: false })))
|
|
114
116
|
return;
|
|
@@ -116,40 +118,85 @@ async function providerSetting(session, host) {
|
|
|
116
118
|
const models = { ...current.models };
|
|
117
119
|
if (session.model !== "")
|
|
118
120
|
models[session.provider.id] = session.model;
|
|
119
|
-
|
|
121
|
+
const patch = {
|
|
122
|
+
provider: session.provider.id,
|
|
123
|
+
models,
|
|
124
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
125
|
+
};
|
|
126
|
+
if (await persist(host, patch))
|
|
120
127
|
return;
|
|
121
128
|
session.provider = before.provider;
|
|
122
129
|
session.model = before.model;
|
|
123
130
|
session.config.providerId = before.providerId;
|
|
124
131
|
session.config.model = before.configModel;
|
|
132
|
+
session.config.effort = before.effort;
|
|
125
133
|
}
|
|
126
134
|
async function modelSetting(session, host) {
|
|
127
|
-
const before = {
|
|
135
|
+
const before = {
|
|
136
|
+
model: session.model,
|
|
137
|
+
configModel: session.config.model,
|
|
138
|
+
effort: session.config.effort,
|
|
139
|
+
};
|
|
128
140
|
if (!(await modelsCommand(session, host, { announce: false, save: false })))
|
|
129
141
|
return;
|
|
130
142
|
const current = readSettings();
|
|
131
143
|
const models = { ...current.models, [session.provider.id]: session.model };
|
|
132
|
-
|
|
144
|
+
const patch = {
|
|
145
|
+
models,
|
|
146
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
147
|
+
};
|
|
148
|
+
if (await persist(host, patch))
|
|
133
149
|
return;
|
|
134
150
|
session.model = before.model;
|
|
135
151
|
session.config.model = before.configModel;
|
|
152
|
+
session.config.effort = before.effort;
|
|
136
153
|
}
|
|
137
154
|
async function effortSetting(session, host) {
|
|
138
155
|
const choose = chooser(host);
|
|
139
156
|
if (choose === undefined)
|
|
140
157
|
return;
|
|
158
|
+
const efforts = await availableEfforts(session, host);
|
|
159
|
+
if (efforts === undefined)
|
|
160
|
+
return;
|
|
161
|
+
if (efforts.length === 0) {
|
|
162
|
+
host.emit({
|
|
163
|
+
kind: "notice",
|
|
164
|
+
text: `${session.model || session.provider.id} controls its own reasoning depth`,
|
|
165
|
+
tone: "info",
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
141
169
|
const current = session.config.effort;
|
|
142
170
|
const index = await choose({
|
|
143
171
|
title: heading("effort", "saved default", session.palette),
|
|
144
|
-
options:
|
|
145
|
-
index: Math.max(0,
|
|
172
|
+
options: efforts.map((value) => ({ label: value })),
|
|
173
|
+
index: Math.max(0, efforts.findIndex((value) => value === current)),
|
|
146
174
|
});
|
|
147
|
-
const value = index === undefined ? undefined :
|
|
175
|
+
const value = index === undefined ? undefined : efforts[index];
|
|
148
176
|
if (value === undefined || !(await persist(host, { effort: value })))
|
|
149
177
|
return;
|
|
150
178
|
session.config.effort = value;
|
|
151
179
|
return value;
|
|
152
180
|
}
|
|
181
|
+
async function availableEfforts(session, host) {
|
|
182
|
+
if (session.provider.efforts === undefined)
|
|
183
|
+
return EFFORTS;
|
|
184
|
+
host.status?.(`Asking ${session.provider.id}`);
|
|
185
|
+
try {
|
|
186
|
+
return await session.provider.efforts(session.model, host.signal, (status) => host.status?.(status));
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
host.emit({
|
|
190
|
+
kind: "notice",
|
|
191
|
+
text: providerFailure(session.provider, error, true),
|
|
192
|
+
tone: "error",
|
|
193
|
+
});
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
host.status?.(undefined);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
153
200
|
async function motionSetting(session, host) {
|
|
154
201
|
const choose = chooser(host);
|
|
155
202
|
if (choose === undefined)
|
package/dist/settings.js
CHANGED
|
@@ -3,10 +3,11 @@ import { readFileSync } from "node:fs";
|
|
|
3
3
|
import { chmod, mkdir } from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { atomicWrite } from "./atomic.js";
|
|
6
|
+
import { EFFORTS } from "./effort.js";
|
|
6
7
|
import { providerNames } from "./providers/index.js";
|
|
7
8
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
8
9
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
9
|
-
export
|
|
10
|
+
export { EFFORTS } from "./effort.js";
|
|
10
11
|
let saved;
|
|
11
12
|
export function readSettings() {
|
|
12
13
|
if (saved === undefined)
|
package/dist/tools/args.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Every throw here becomes an is_error tool result the model can read and
|
|
5
5
|
// correct on the next step, so the messages are written for that reader.
|
|
6
|
-
export function requireString(args, name) {
|
|
6
|
+
export function requireString(args, name, allowEmpty = false) {
|
|
7
7
|
const value = args[name];
|
|
8
|
-
if (typeof value !== "string" || value === "") {
|
|
9
|
-
|
|
8
|
+
if (typeof value !== "string" || (!allowEmpty && value === "")) {
|
|
9
|
+
const kind = allowEmpty ? "a string" : "a non-empty string";
|
|
10
|
+
throw new Error(`"${name}" is required and must be ${kind}`);
|
|
10
11
|
}
|
|
11
12
|
return value;
|
|
12
13
|
}
|
package/dist/tools/fs.js
CHANGED
|
@@ -101,7 +101,7 @@ export const writeFile = {
|
|
|
101
101
|
async preview(args, ctx) {
|
|
102
102
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
103
103
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
104
|
-
const content = requireString(args, "content");
|
|
104
|
+
const content = requireString(args, "content", true);
|
|
105
105
|
assertEditableText(content);
|
|
106
106
|
// A write against a file that is already there is a replacement, and the
|
|
107
107
|
// user is owed the difference rather than a wall of green.
|
|
@@ -110,7 +110,7 @@ export const writeFile = {
|
|
|
110
110
|
async run(args, ctx) {
|
|
111
111
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
112
112
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
113
|
-
const content = requireString(args, "content");
|
|
113
|
+
const content = requireString(args, "content", true);
|
|
114
114
|
assertEditableText(content);
|
|
115
115
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
116
116
|
const validate = () => assertDirectWritableInRoot(root, target);
|
|
@@ -248,7 +248,10 @@ function applied(before, args) {
|
|
|
248
248
|
}
|
|
249
249
|
const made = replaceAll ? occurrences : 1;
|
|
250
250
|
assertReplacementFits(before, oldText, newText, made);
|
|
251
|
-
const
|
|
251
|
+
const replacement = () => newText;
|
|
252
|
+
const after = replaceAll
|
|
253
|
+
? before.replaceAll(oldText, replacement)
|
|
254
|
+
: before.replace(oldText, replacement);
|
|
252
255
|
assertEditableText(after, "edited content");
|
|
253
256
|
return { after, made };
|
|
254
257
|
}
|
|
@@ -270,6 +273,8 @@ async function unchangedSinceApproval(target, approved) {
|
|
|
270
273
|
}
|
|
271
274
|
}
|
|
272
275
|
function count(text, noun) {
|
|
276
|
+
if (text === "")
|
|
277
|
+
return "empty";
|
|
273
278
|
return plural(text.split("\n").length, noun, `${noun}s`);
|
|
274
279
|
}
|
|
275
280
|
function plural(n, one, many) {
|
package/dist/tools/index.js
CHANGED
|
@@ -20,8 +20,8 @@ export function toolSpecs(tools) {
|
|
|
20
20
|
// and gets another turn to fix its call. Only an aborted turn propagates.
|
|
21
21
|
export async function runTool(tool, call, ctx) {
|
|
22
22
|
try {
|
|
23
|
-
const { output, summary } = await tool.run(call.input, ctx);
|
|
24
|
-
return { result: { kind: "tool_result", id: call.id, output, isError
|
|
23
|
+
const { output, summary, isError = false } = await tool.run(call.input, ctx);
|
|
24
|
+
return { result: { kind: "tool_result", id: call.id, output, isError }, summary };
|
|
25
25
|
}
|
|
26
26
|
catch (error) {
|
|
27
27
|
if (ctx.signal?.aborted === true)
|
package/dist/tools/shell.js
CHANGED
|
@@ -32,6 +32,7 @@ export const runCommand = {
|
|
|
32
32
|
return {
|
|
33
33
|
output: output === "" ? `[${summary}]` : `${output}\n[${summary}]`,
|
|
34
34
|
summary,
|
|
35
|
+
isError: result.timedOut || result.code !== 0,
|
|
35
36
|
};
|
|
36
37
|
},
|
|
37
38
|
};
|
|
@@ -45,6 +46,7 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
45
46
|
shell: true,
|
|
46
47
|
windowsHide: true,
|
|
47
48
|
detached: process.platform !== "win32",
|
|
49
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
50
|
});
|
|
49
51
|
const output = capture(onOutput);
|
|
50
52
|
let timedOut = false;
|
|
@@ -2,6 +2,8 @@ import { blank, row } from "../../ui/render.js";
|
|
|
2
2
|
import { markdown } from "../../ui/markdown.js";
|
|
3
3
|
const PAD = 1;
|
|
4
4
|
export const REASONING_PREVIEW_ROWS = 3;
|
|
5
|
+
const MIN_REASONING_PREVIEW_CHARS = 4_096;
|
|
6
|
+
const REASONING_PREVIEW_OVERSCAN = 12;
|
|
5
7
|
export function renderUser(block, width, pal) {
|
|
6
8
|
const inner = Math.max(8, width - PAD * 2);
|
|
7
9
|
const content = markdown(block.text, inner, pal, inner);
|
|
@@ -21,14 +23,21 @@ export function renderAnswer(block, width, pal) {
|
|
|
21
23
|
}
|
|
22
24
|
export function renderReasoning(block, width, pal) {
|
|
23
25
|
const inner = Math.max(8, width - PAD * 2);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
// Expanding a live stream is deferred until it is sealed. Re-parsing an
|
|
27
|
+
// ever-growing full thought on every token makes the whole TUI stall.
|
|
28
|
+
const expanded = block.expanded === true && block.live !== true;
|
|
29
|
+
const source = !expanded
|
|
30
|
+
? reasoningPreviewSource(block.text, inner)
|
|
31
|
+
: { text: block.text, truncated: false };
|
|
32
|
+
const content = markdown(source.text, inner, pal, inner);
|
|
26
33
|
const visible = expanded ? content : content.slice(-REASONING_PREVIEW_ROWS);
|
|
27
34
|
const action = expanded
|
|
28
35
|
? "ctrl+o compact"
|
|
29
|
-
:
|
|
30
|
-
? "
|
|
31
|
-
:
|
|
36
|
+
: block.live === true && block.expanded === true
|
|
37
|
+
? "full when done"
|
|
38
|
+
: source.truncated || content.length > REASONING_PREVIEW_ROWS
|
|
39
|
+
? "ctrl+o full"
|
|
40
|
+
: undefined;
|
|
32
41
|
return [
|
|
33
42
|
"",
|
|
34
43
|
row(width, [
|
|
@@ -41,3 +50,15 @@ export function renderReasoning(block, width, pal) {
|
|
|
41
50
|
...visible.map((line) => row(width, line.segs.map((seg) => ({ ...seg, fg: pal.ink.muted, italic: true })), [], undefined, PAD)),
|
|
42
51
|
];
|
|
43
52
|
}
|
|
53
|
+
export function reasoningPreviewSource(text, width) {
|
|
54
|
+
const limit = Math.max(MIN_REASONING_PREVIEW_CHARS, width * REASONING_PREVIEW_ROWS * REASONING_PREVIEW_OVERSCAN);
|
|
55
|
+
if (text.length <= limit)
|
|
56
|
+
return { text, truncated: false };
|
|
57
|
+
// A compact view only needs its visible tail. The complete text remains on
|
|
58
|
+
// the block for expansion after the reasoning stream is sealed.
|
|
59
|
+
let start = text.length - limit;
|
|
60
|
+
const code = text.charCodeAt(start);
|
|
61
|
+
if (code >= 0xdc00 && code <= 0xdfff)
|
|
62
|
+
start--;
|
|
63
|
+
return { text: text.slice(start), truncated: true };
|
|
64
|
+
}
|
package/dist/tui/turn.js
CHANGED
|
@@ -110,7 +110,10 @@ export function transcribe(stage) {
|
|
|
110
110
|
const block = tools.get(call.id);
|
|
111
111
|
if (block === undefined || block.kind !== "tool")
|
|
112
112
|
return;
|
|
113
|
-
|
|
113
|
+
// Explicit refusal stays a refusal. Cancellation can first settle an
|
|
114
|
+
// approval overlay as `no`, though, so its synthesized result must be
|
|
115
|
+
// allowed to reconcile that rail with the interrupted history.
|
|
116
|
+
if (block.tone === "deny" && summary !== "interrupted") {
|
|
114
117
|
stage.render(block);
|
|
115
118
|
return;
|
|
116
119
|
}
|