@giovannijecha/jecode 0.1.9 → 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 +36 -18
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +1 -1
- package/dist/controller.js +46 -11
- package/dist/credential-commands.js +32 -4
- package/dist/credential-safety.js +10 -1
- package/dist/effort.js +25 -0
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +94 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/provider-commands.js +79 -38
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic-stream.js +8 -2
- package/dist/providers/anthropic.js +22 -6
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama-stream.js +3 -0
- package/dist/providers/ollama.js +4 -1
- package/dist/providers/openai-codex.js +157 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -9
- package/dist/providers/openai.js +36 -15
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +64 -9
- 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/app-workflows.js +5 -0
- package/dist/tui/components/messages.js +26 -5
- package/dist/tui/feedback.js +10 -6
- package/dist/tui/session-view.js +10 -4
- package/dist/tui/turn.js +4 -1
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ChatGPT-backed Codex Responses, kept separate from the OpenAI API provider.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { openAICodexAccount } from "../accounts.js";
|
|
4
|
+
import { openAIAuthorization } from "../openai-account.js";
|
|
5
|
+
import { applicationVersion } from "../version.js";
|
|
6
|
+
import { EFFORTS, isEffort, requireSupportedEffort } from "../effort.js";
|
|
7
|
+
import { getJson, postSse } from "./http.js";
|
|
8
|
+
import { assembleOpenAI } from "./openai-stream.js";
|
|
9
|
+
import { fromWireResponse, stopNotice, toWireItems, toWireTool, } from "./openai-wire.js";
|
|
10
|
+
const ID = "openai-codex";
|
|
11
|
+
const BASE = "https://chatgpt.com/backend-api/codex";
|
|
12
|
+
// Jecode's product version is unrelated to the Codex protocol gate. OpenAI's
|
|
13
|
+
// own catalogue updater uses this sentinel to request the complete current
|
|
14
|
+
// manifest; Jecode then keeps only entries explicitly visible in that manifest.
|
|
15
|
+
const CATALOG_COMPATIBILITY_VERSION = "99.99.99";
|
|
16
|
+
const SESSION_ID = randomUUID();
|
|
17
|
+
const MAX_CATALOG_ITEMS = 4_000;
|
|
18
|
+
const MAX_MODELS = 1_000;
|
|
19
|
+
const MAX_MODEL_CHARS = 256;
|
|
20
|
+
const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
|
|
21
|
+
let effortByModel = new Map();
|
|
22
|
+
export const openaiCodex = {
|
|
23
|
+
id: ID,
|
|
24
|
+
defaultModel: "",
|
|
25
|
+
auth: { kind: "oauth", account: ID, label: "ChatGPT" },
|
|
26
|
+
blocked() {
|
|
27
|
+
return openAICodexAccount() === undefined ? "ChatGPT account is not connected" : undefined;
|
|
28
|
+
},
|
|
29
|
+
location: () => "cloud",
|
|
30
|
+
async models(signal, onStatus) {
|
|
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);
|
|
42
|
+
},
|
|
43
|
+
async send(req) {
|
|
44
|
+
const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
|
|
45
|
+
const effort = requireSupportedEffort(req.model, req.effort, efforts);
|
|
46
|
+
return withAuthorization(async (authorization) => {
|
|
47
|
+
const events = await postSse(`${BASE}/responses`, {
|
|
48
|
+
...headers(authorization, randomUUID()),
|
|
49
|
+
"openai-beta": "responses=experimental",
|
|
50
|
+
}, {
|
|
51
|
+
model: req.model,
|
|
52
|
+
store: false,
|
|
53
|
+
stream: true,
|
|
54
|
+
instructions: req.system,
|
|
55
|
+
input: req.messages.flatMap((message) => toWireItems(message, ID)),
|
|
56
|
+
tools: req.tools.map(toWireTool),
|
|
57
|
+
tool_choice: "auto",
|
|
58
|
+
parallel_tool_calls: true,
|
|
59
|
+
reasoning: { effort, summary: "auto" },
|
|
60
|
+
text: { verbosity: "low" },
|
|
61
|
+
include: ["reasoning.encrypted_content"],
|
|
62
|
+
prompt_cache_key: SESSION_ID,
|
|
63
|
+
}, req.signal, req.onStatus);
|
|
64
|
+
const data = await assembleOpenAI(events, req.onStream);
|
|
65
|
+
const notice = stopNotice(data);
|
|
66
|
+
if (notice !== undefined)
|
|
67
|
+
req.onStream?.({ kind: "text", text: `\n${notice}` });
|
|
68
|
+
return fromWireResponse(data, ID);
|
|
69
|
+
}, req.signal, req.onStatus);
|
|
70
|
+
},
|
|
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
|
+
}
|
|
78
|
+
async function withAuthorization(operation, signal, onStatus) {
|
|
79
|
+
let authorization = await openAIAuthorization(undefined, signal, onStatus);
|
|
80
|
+
try {
|
|
81
|
+
return await operation(authorization);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (statusOf(error) !== 401)
|
|
85
|
+
throw error;
|
|
86
|
+
authorization = await openAIAuthorization(authorization.accessToken, signal, onStatus);
|
|
87
|
+
return operation(authorization);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function headers(authorization, requestId) {
|
|
91
|
+
const version = applicationVersion();
|
|
92
|
+
return {
|
|
93
|
+
authorization: `Bearer ${authorization.accessToken}`,
|
|
94
|
+
"chatgpt-account-id": authorization.accountId,
|
|
95
|
+
originator: "jecode",
|
|
96
|
+
"user-agent": `jecode/${version} (${process.platform}; ${process.arch})`,
|
|
97
|
+
"session-id": SESSION_ID,
|
|
98
|
+
"x-client-request-id": requestId,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function modelCatalog(value) {
|
|
102
|
+
const source = record(value) && Array.isArray(value["models"]) ? value["models"] : undefined;
|
|
103
|
+
if (source === undefined)
|
|
104
|
+
throw new Error("OpenAI Codex did not return a model list");
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
const models = source
|
|
107
|
+
.slice(0, MAX_CATALOG_ITEMS)
|
|
108
|
+
.flatMap((entry) => {
|
|
109
|
+
if (!record(entry))
|
|
110
|
+
return [];
|
|
111
|
+
const id = entry["slug"];
|
|
112
|
+
if (typeof id !== "string" ||
|
|
113
|
+
id === "" ||
|
|
114
|
+
id.length > MAX_MODEL_CHARS ||
|
|
115
|
+
entry["visibility"] !== "list" ||
|
|
116
|
+
seen.has(id))
|
|
117
|
+
return [];
|
|
118
|
+
seen.add(id);
|
|
119
|
+
return [{
|
|
120
|
+
id,
|
|
121
|
+
priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
|
|
122
|
+
efforts: reasoningLevels(entry, id),
|
|
123
|
+
}];
|
|
124
|
+
})
|
|
125
|
+
.sort((left, right) => left.priority - right.priority)
|
|
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;
|
|
149
|
+
}
|
|
150
|
+
function statusOf(error) {
|
|
151
|
+
return typeof error === "object" && error !== null && "status" in error
|
|
152
|
+
? error.status
|
|
153
|
+
: undefined;
|
|
154
|
+
}
|
|
155
|
+
function record(value) {
|
|
156
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
157
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// Reassembling an OpenAI Responses reply from its event stream.
|
|
2
2
|
//
|
|
3
|
-
// Unlike Anthropic,
|
|
4
|
-
// `response.completed
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Unlike Anthropic, a standard Responses stream ends with the whole finished
|
|
4
|
+
// response in `response.completed`. The ChatGPT Codex backend can instead send
|
|
5
|
+
// an empty final `output` after complete `response.output_item.done` events, so
|
|
6
|
+
// those streamed items remain the fallback when the final envelope is empty.
|
|
7
7
|
export async function assembleOpenAI(events, onStream) {
|
|
8
8
|
const items = [];
|
|
9
|
-
let completed;
|
|
10
9
|
let refusal = false;
|
|
11
10
|
for await (const raw of events) {
|
|
12
11
|
const event = raw;
|
|
@@ -29,11 +28,10 @@ export async function assembleOpenAI(events, onStream) {
|
|
|
29
28
|
if (event.item !== undefined)
|
|
30
29
|
items.push(event.item);
|
|
31
30
|
break;
|
|
31
|
+
case "response.done":
|
|
32
32
|
case "response.completed":
|
|
33
33
|
case "response.incomplete":
|
|
34
|
-
|
|
35
|
-
completed = event.response;
|
|
36
|
-
break;
|
|
34
|
+
return reconcileOutput(event.response, items);
|
|
37
35
|
case "response.failed": {
|
|
38
36
|
const response = event.response;
|
|
39
37
|
throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
|
|
@@ -44,5 +42,11 @@ export async function assembleOpenAI(events, onStream) {
|
|
|
44
42
|
break;
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
|
-
|
|
45
|
+
throw new Error("openai stream ended before a terminal response event");
|
|
46
|
+
}
|
|
47
|
+
function reconcileOutput(completed, items) {
|
|
48
|
+
if (completed === undefined)
|
|
49
|
+
return { output: items };
|
|
50
|
+
const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
|
|
51
|
+
return items.length > finalCount ? { ...completed, output: items } : completed;
|
|
48
52
|
}
|
|
@@ -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",
|
|
@@ -14,8 +9,8 @@ export function toWireTool(tool) {
|
|
|
14
9
|
parameters: tool.input,
|
|
15
10
|
};
|
|
16
11
|
}
|
|
17
|
-
export function toWireItems(message) {
|
|
18
|
-
if (message.rawFrom ===
|
|
12
|
+
export function toWireItems(message, providerId = "openai") {
|
|
13
|
+
if (message.rawFrom === providerId && Array.isArray(message.raw)) {
|
|
19
14
|
return message.raw;
|
|
20
15
|
}
|
|
21
16
|
const items = [];
|
|
@@ -50,7 +45,7 @@ export function stopNotice(data) {
|
|
|
50
45
|
? "[truncated: hit max_output_tokens — raise --max-tokens]"
|
|
51
46
|
: `[incomplete: ${reason}]`;
|
|
52
47
|
}
|
|
53
|
-
export function fromWireResponse(data) {
|
|
48
|
+
export function fromWireResponse(data, providerId = "openai") {
|
|
54
49
|
const raw = Array.isArray(data.output) ? data.output : [];
|
|
55
50
|
const content = [];
|
|
56
51
|
for (const entry of raw) {
|
|
@@ -81,7 +76,7 @@ export function fromWireResponse(data) {
|
|
|
81
76
|
const notice = stopNotice(data);
|
|
82
77
|
if (notice !== undefined)
|
|
83
78
|
content.push({ kind: "text", text: notice });
|
|
84
|
-
return { role: "assistant", content, raw, rawFrom:
|
|
79
|
+
return { role: "assistant", content, raw, rawFrom: providerId, usage: normalizeUsage(data) };
|
|
85
80
|
}
|
|
86
81
|
function normalizeUsage(data) {
|
|
87
82
|
const usage = data.usage;
|
package/dist/providers/openai.js
CHANGED
|
@@ -5,25 +5,42 @@
|
|
|
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",
|
|
26
|
-
keyVar: KEY,
|
|
43
|
+
auth: { kind: "api-key", keyVar: KEY },
|
|
27
44
|
blocked() {
|
|
28
45
|
return apiKey() === undefined ? `${KEY} is not set` : undefined;
|
|
29
46
|
},
|
|
@@ -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
|
-
input: req.messages.flatMap(toWireItems),
|
|
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/providers/sse.js
CHANGED
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";
|
|
@@ -75,13 +76,21 @@ function settingsItems(values) {
|
|
|
75
76
|
}]),
|
|
76
77
|
{ action: "model", option: { label: "model", hint: values.model || "choose a model" } },
|
|
77
78
|
{ action: "effort", option: { label: "effort", hint: values.effort } },
|
|
78
|
-
|
|
79
|
+
...(values.maxTokens === undefined
|
|
80
|
+
? []
|
|
81
|
+
: [{
|
|
82
|
+
action: "maxTokens",
|
|
83
|
+
option: { label: "max output tokens", hint: String(values.maxTokens) },
|
|
84
|
+
}]),
|
|
79
85
|
{ action: "maxSteps", option: { label: "max tool steps", hint: String(values.maxSteps) } },
|
|
80
86
|
{
|
|
81
87
|
action: "reducedMotion",
|
|
82
88
|
option: { label: "reduced motion", hint: values.reducedMotion ? "on" : "off" },
|
|
83
89
|
},
|
|
84
|
-
{
|
|
90
|
+
{
|
|
91
|
+
action: "credentials",
|
|
92
|
+
option: { label: "authentication", hint: "manage API keys and accounts" },
|
|
93
|
+
},
|
|
85
94
|
];
|
|
86
95
|
}
|
|
87
96
|
function settingsValues(session) {
|
|
@@ -90,7 +99,7 @@ function settingsValues(session) {
|
|
|
90
99
|
model: session.model,
|
|
91
100
|
...(session.provider.id === "ollama" ? { ollamaConnection: ollamaConnectionHint() } : {}),
|
|
92
101
|
effort: session.config.effort,
|
|
93
|
-
maxTokens: session.config.maxTokens,
|
|
102
|
+
...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
|
|
94
103
|
maxSteps: session.config.maxSteps,
|
|
95
104
|
reducedMotion: session.config.reducedMotion,
|
|
96
105
|
};
|
|
@@ -101,6 +110,7 @@ async function providerSetting(session, host) {
|
|
|
101
110
|
model: session.model,
|
|
102
111
|
providerId: session.config.providerId,
|
|
103
112
|
configModel: session.config.model,
|
|
113
|
+
effort: session.config.effort,
|
|
104
114
|
};
|
|
105
115
|
if (!(await providersCommand(session, host, { announce: false, save: false })))
|
|
106
116
|
return;
|
|
@@ -108,40 +118,85 @@ async function providerSetting(session, host) {
|
|
|
108
118
|
const models = { ...current.models };
|
|
109
119
|
if (session.model !== "")
|
|
110
120
|
models[session.provider.id] = session.model;
|
|
111
|
-
|
|
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))
|
|
112
127
|
return;
|
|
113
128
|
session.provider = before.provider;
|
|
114
129
|
session.model = before.model;
|
|
115
130
|
session.config.providerId = before.providerId;
|
|
116
131
|
session.config.model = before.configModel;
|
|
132
|
+
session.config.effort = before.effort;
|
|
117
133
|
}
|
|
118
134
|
async function modelSetting(session, host) {
|
|
119
|
-
const before = {
|
|
135
|
+
const before = {
|
|
136
|
+
model: session.model,
|
|
137
|
+
configModel: session.config.model,
|
|
138
|
+
effort: session.config.effort,
|
|
139
|
+
};
|
|
120
140
|
if (!(await modelsCommand(session, host, { announce: false, save: false })))
|
|
121
141
|
return;
|
|
122
142
|
const current = readSettings();
|
|
123
143
|
const models = { ...current.models, [session.provider.id]: session.model };
|
|
124
|
-
|
|
144
|
+
const patch = {
|
|
145
|
+
models,
|
|
146
|
+
...(session.config.effort === before.effort ? {} : { effort: session.config.effort }),
|
|
147
|
+
};
|
|
148
|
+
if (await persist(host, patch))
|
|
125
149
|
return;
|
|
126
150
|
session.model = before.model;
|
|
127
151
|
session.config.model = before.configModel;
|
|
152
|
+
session.config.effort = before.effort;
|
|
128
153
|
}
|
|
129
154
|
async function effortSetting(session, host) {
|
|
130
155
|
const choose = chooser(host);
|
|
131
156
|
if (choose === undefined)
|
|
132
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
|
+
}
|
|
133
169
|
const current = session.config.effort;
|
|
134
170
|
const index = await choose({
|
|
135
171
|
title: heading("effort", "saved default", session.palette),
|
|
136
|
-
options:
|
|
137
|
-
index: Math.max(0,
|
|
172
|
+
options: efforts.map((value) => ({ label: value })),
|
|
173
|
+
index: Math.max(0, efforts.findIndex((value) => value === current)),
|
|
138
174
|
});
|
|
139
|
-
const value = index === undefined ? undefined :
|
|
175
|
+
const value = index === undefined ? undefined : efforts[index];
|
|
140
176
|
if (value === undefined || !(await persist(host, { effort: value })))
|
|
141
177
|
return;
|
|
142
178
|
session.config.effort = value;
|
|
143
179
|
return value;
|
|
144
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
|
+
}
|
|
145
200
|
async function motionSetting(session, host) {
|
|
146
201
|
const choose = chooser(host);
|
|
147
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;
|
|
@@ -5,6 +5,7 @@ import { updateSettings } from "../settings.js";
|
|
|
5
5
|
import { saveTranscript } from "../transcript-export.js";
|
|
6
6
|
import { recordUsage } from "../usage.js";
|
|
7
7
|
import { answerAt } from "./approve.js";
|
|
8
|
+
import { cancel as cancelOpen } from "./overlay.js";
|
|
8
9
|
import { controllerOptions, turnFailure } from "./session-view.js";
|
|
9
10
|
import { transcribe } from "./turn.js";
|
|
10
11
|
const WAITING = "Waiting";
|
|
@@ -26,6 +27,10 @@ export function appWorkflows(options) {
|
|
|
26
27
|
state.open = { picker, settle: resolve };
|
|
27
28
|
options.render();
|
|
28
29
|
}),
|
|
30
|
+
dismiss: () => {
|
|
31
|
+
state.open = state.open === undefined ? undefined : cancelOpen(state.open);
|
|
32
|
+
options.render();
|
|
33
|
+
},
|
|
29
34
|
type: (field) => new Promise((resolve) => {
|
|
30
35
|
state.open = { field, settle: resolve };
|
|
31
36
|
options.render();
|
|
@@ -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
|
+
}
|