@giovannijecha/jecode 0.7.4 → 0.8.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 +25 -13
- package/dist/batch.js +1 -1
- package/dist/cli-info.js +1 -1
- package/dist/config.js +4 -1
- package/dist/controller.js +34 -11
- package/dist/duration.js +8 -0
- package/dist/openai-account.js +12 -8
- package/dist/providers/anthropic-stream.js +8 -0
- package/dist/providers/anthropic-wire.js +22 -10
- package/dist/providers/ollama-stream.js +7 -0
- package/dist/providers/ollama-wire.js +11 -3
- package/dist/providers/openai-stream.js +57 -2
- package/dist/providers/openai-wire.js +27 -12
- package/dist/sessions/codec.js +13 -5
- package/dist/settings-command.js +0 -5
- package/dist/settings.js +0 -2
- package/dist/steering.js +58 -0
- package/dist/tools/args.js +1 -1
- package/dist/transcript.js +6 -1
- package/dist/tui/activity.js +20 -5
- package/dist/tui/app-input.js +31 -6
- package/dist/tui/app-workflows.js +55 -13
- package/dist/tui/app.js +18 -3
- package/dist/tui/blocks.js +8 -2
- package/dist/tui/components/messages.js +19 -12
- package/dist/tui/components/misc.js +7 -6
- package/dist/tui/components/status.js +12 -1
- package/dist/tui/components/tool.js +143 -46
- package/dist/tui/help.js +1 -1
- package/dist/tui/motion.js +32 -0
- package/dist/tui/session-view.js +3 -2
- package/dist/tui/transcript-grammar.js +21 -0
- package/dist/tui/transcript-view.js +132 -7
- package/dist/tui/turn.js +49 -31
- package/dist/tui/view.js +4 -2
- package/dist/ui/inline.js +7 -7
- package/dist/ui/render.js +1 -3
- package/dist/ui/theme.js +19 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,18 +22,21 @@
|
|
|
22
22
|
|
|
23
23
|
<p align="center">
|
|
24
24
|
<a href="https://github.com/giovannijecha/jecode/blob/main/CHANGELOG.md">Changelog</a> ·
|
|
25
|
+
<a href="https://github.com/giovannijecha/jecode/blob/main/docs/compatibility.md">Compatibility</a> ·
|
|
25
26
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
26
27
|
</p>
|
|
27
28
|
|
|
28
29
|
> Jecode is currently pre-1.0. The core loop is usable today, but commands and
|
|
29
|
-
> terminal interactions may still evolve before 1.0.
|
|
30
|
+
> terminal interactions may still evolve before 1.0. The release-candidate
|
|
31
|
+
> surface is now frozen in the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/compatibility.md).
|
|
30
32
|
|
|
31
33
|
## Why Jecode
|
|
32
34
|
|
|
33
35
|
- **One visible loop.** One controller talks to the model, runs tools, and
|
|
34
|
-
returns control to you. Independent reads may overlap
|
|
35
|
-
and commands stay ordered.
|
|
36
|
-
|
|
36
|
+
returns control to you. Independent reads may overlap within one model
|
|
37
|
+
response; writes and commands stay ordered. You can steer an active turn
|
|
38
|
+
without starting a second loop. There are no delegated agents or hidden
|
|
39
|
+
model workers.
|
|
37
40
|
- **Terminal-native today.** The transcript, composer, searchable menus, tool
|
|
38
41
|
output, diffs, approvals, reasoning, and status share one full-screen TUI.
|
|
39
42
|
- **Permission-aware.** Tool use is visible. Dangerous actions ask first, and
|
|
@@ -245,7 +248,8 @@ Useful controls:
|
|
|
245
248
|
- **Backspace/Delete** removes one character;
|
|
246
249
|
**Ctrl+Backspace/Delete** removes one word.
|
|
247
250
|
- **Home/End** moves to the beginning or end of the composer.
|
|
248
|
-
- **Tab** completes a slash command without running it; **Enter** sends.
|
|
251
|
+
- **Tab** completes a slash command without running it; **Enter** sends. During
|
|
252
|
+
an active model turn, **Enter** queues guidance for its next safe boundary.
|
|
249
253
|
- **Alt+Enter** inserts a newline.
|
|
250
254
|
- **Esc** closes the active menu or interrupts foreground work.
|
|
251
255
|
- **Ctrl+C** interrupts, or exits while idle. **Ctrl+D** requests a clean exit.
|
|
@@ -253,9 +257,11 @@ Useful controls:
|
|
|
253
257
|
- **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
|
|
254
258
|
|
|
255
259
|
The footer keeps the active model, effort, and workspace visible. During work,
|
|
256
|
-
it adds the current state, elapsed time,
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
it adds the current state, elapsed time, steering availability or queue count,
|
|
261
|
+
and interrupt hint. Queued guidance joins the same conversation turn after the
|
|
262
|
+
provider response or complete tool batch already in progress; `Esc` remains an
|
|
263
|
+
immediate interruption. Operational feedback uses the same replaceable status
|
|
264
|
+
area instead of adding noise to the conversation or its Markdown export.
|
|
259
265
|
|
|
260
266
|
## Sessions and context
|
|
261
267
|
|
|
@@ -316,19 +322,23 @@ settings, then built-in defaults.
|
|
|
316
322
|
| `--root` | - | Current directory |
|
|
317
323
|
| `--effort` | `JECODE_EFFORT` | `high` |
|
|
318
324
|
| `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
|
|
319
|
-
| `--max-steps` | `JECODE_MAX_STEPS` |
|
|
325
|
+
| `--max-steps` | `JECODE_MAX_STEPS` | No limit |
|
|
320
326
|
| `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
|
|
321
327
|
| `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
|
|
322
328
|
| `--auto-approve` | `JECODE_AUTO_APPROVE=1` | Off |
|
|
323
329
|
| `--ephemeral` | `JECODE_EPHEMERAL=1` | Off |
|
|
324
330
|
|
|
331
|
+
`--max-steps` is an opt-in, process-only budget for deterministic automation
|
|
332
|
+
and diagnostics. Interactive work has no arbitrary model-loop ceiling, and the
|
|
333
|
+
budget is neither shown nor saved by `/settings`.
|
|
334
|
+
|
|
325
335
|
Non-secret preferences live in `~/.jecode/settings.json`. Explicitly saved API
|
|
326
336
|
keys live in `~/.jecode/credentials.json`, while ChatGPT OAuth accounts live in
|
|
327
337
|
`~/.jecode/accounts.json`. Environment credentials always take precedence.
|
|
328
338
|
Secret stores use owner-only permissions where the operating system supports
|
|
329
339
|
them.
|
|
330
340
|
|
|
331
|
-
Jecode has one current interface theme,
|
|
341
|
+
Jecode has one current interface theme, Slate. `NO_COLOR` is supported for
|
|
332
342
|
terminals and pipelines that disable colour.
|
|
333
343
|
|
|
334
344
|
## Safety model
|
|
@@ -409,15 +419,17 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
|
|
|
409
419
|
probes for long-session rendering and workspace search. Architecture and
|
|
410
420
|
security boundaries are documented in
|
|
411
421
|
[docs/architecture.md](docs/architecture.md); brand assets and usage rules live
|
|
412
|
-
in [docs/brand.md](docs/brand.md). The
|
|
413
|
-
[docs/
|
|
422
|
+
in [docs/brand.md](docs/brand.md). The public 1.0 contract lives in
|
|
423
|
+
[docs/compatibility.md](docs/compatibility.md), and the maintainer release
|
|
424
|
+
procedure lives in [docs/releasing.md](docs/releasing.md).
|
|
414
425
|
|
|
415
426
|
## Community
|
|
416
427
|
|
|
417
428
|
- Ask questions, share workflows, and explore early ideas in
|
|
418
429
|
[GitHub Discussions](https://github.com/giovannijecha/jecode/discussions).
|
|
419
430
|
- Report reproducible bugs and focused feature requests through
|
|
420
|
-
[GitHub Issues](https://github.com/giovannijecha/jecode/issues).
|
|
431
|
+
[GitHub Issues](https://github.com/giovannijecha/jecode/issues). New
|
|
432
|
+
capabilities are deferred until the 1.0 stabilization cycle is complete.
|
|
421
433
|
- Report security concerns privately through the repository Security tab.
|
|
422
434
|
|
|
423
435
|
Public pull requests are not accepted at this stage. Code changes remain a
|
package/dist/batch.js
CHANGED
|
@@ -126,7 +126,7 @@ function options(session, contextPolicy) {
|
|
|
126
126
|
maxTokens: session.config.maxTokens,
|
|
127
127
|
contextPolicy,
|
|
128
128
|
effort: session.config.effort,
|
|
129
|
-
|
|
129
|
+
maxModelRequests: session.config.maxModelRequests,
|
|
130
130
|
toolContext: { root: session.config.root },
|
|
131
131
|
};
|
|
132
132
|
}
|
package/dist/cli-info.js
CHANGED
|
@@ -15,7 +15,7 @@ Options:
|
|
|
15
15
|
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
16
16
|
--effort <level> low, medium, high, xhigh, or max
|
|
17
17
|
--max-tokens <number> output-token ceiling
|
|
18
|
-
--max-steps <number>
|
|
18
|
+
--max-steps <number> optional per-turn model-request budget
|
|
19
19
|
--compaction-percent <${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT}>
|
|
20
20
|
context usage that triggers compaction (default: ${DEFAULT_COMPACTION_PERCENT})
|
|
21
21
|
--reduced-motion use a steady terminal cursor
|
package/dist/config.js
CHANGED
|
@@ -27,6 +27,7 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
27
27
|
if (!EFFORTS.includes(effort)) {
|
|
28
28
|
throw new Error(`unknown effort "${effort}" (expected one of: ${EFFORTS.join(", ")})`);
|
|
29
29
|
}
|
|
30
|
+
const maxModelRequests = optional(flags["max-steps"], process.env.JECODE_MAX_STEPS, undefined);
|
|
30
31
|
return {
|
|
31
32
|
providerId,
|
|
32
33
|
model: pick(flags.model, process.env.JECODE_MODEL, saved.models?.[providerId] ?? ""),
|
|
@@ -37,7 +38,9 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
37
38
|
effort,
|
|
38
39
|
// Every request streams, so a large ceiling costs nothing in timeout risk.
|
|
39
40
|
maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
|
|
40
|
-
|
|
41
|
+
...(maxModelRequests === undefined
|
|
42
|
+
? {}
|
|
43
|
+
: { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
|
|
41
44
|
compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
|
|
42
45
|
root: path.resolve(pick(flags.root, undefined, process.cwd())),
|
|
43
46
|
autoApprove: flags["auto-approve"] === "true" ||
|
package/dist/controller.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { isToolCall } from "./types.js";
|
|
7
7
|
import { findTool, runTool, toolSpecs } from "./tools/index.js";
|
|
8
8
|
import { requestAssistant } from "./controller-request.js";
|
|
9
|
-
export const
|
|
9
|
+
export const MAX_TOOL_CALLS_PER_RESPONSE = 32;
|
|
10
10
|
/** Independent read calls share one bounded execution wave. */
|
|
11
11
|
export const MAX_CONCURRENT_TOOL_CALLS = 4;
|
|
12
12
|
/**
|
|
@@ -28,9 +28,25 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
28
28
|
if (projected !== undefined)
|
|
29
29
|
context = clone([...projected]);
|
|
30
30
|
};
|
|
31
|
-
|
|
31
|
+
const appendSteering = (messages) => {
|
|
32
|
+
for (const text of messages) {
|
|
33
|
+
append({ role: "user", content: [{ kind: "text", text }] });
|
|
34
|
+
}
|
|
35
|
+
for (const text of messages) {
|
|
36
|
+
events.onSteering?.(text);
|
|
37
|
+
}
|
|
38
|
+
return messages.length > 0;
|
|
39
|
+
};
|
|
40
|
+
let requests = 0;
|
|
41
|
+
while (true) {
|
|
32
42
|
throwIfAborted(signal);
|
|
33
|
-
|
|
43
|
+
if (options.maxModelRequests !== undefined &&
|
|
44
|
+
requests >= options.maxModelRequests) {
|
|
45
|
+
const requestLabel = options.maxModelRequests === 1 ? "request" : "requests";
|
|
46
|
+
throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (--max-steps limit reached)`);
|
|
47
|
+
}
|
|
48
|
+
appendSteering(options.steering?.drain() ?? []);
|
|
49
|
+
requests++;
|
|
34
50
|
// The message is displayed as it streams; what comes back here is the
|
|
35
51
|
// assembled version, which exists to be appended to the history.
|
|
36
52
|
const response = await requestAssistant(history, context, specs, options, events, signal);
|
|
@@ -41,8 +57,8 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
41
57
|
if (assistant.content.length === 0) {
|
|
42
58
|
throw new Error(`${options.provider.id} completed without an answer or tool call`);
|
|
43
59
|
}
|
|
44
|
-
if (calls.length >
|
|
45
|
-
throw new Error(`provider returned ${calls.length} tool calls in one
|
|
60
|
+
if (calls.length > MAX_TOOL_CALLS_PER_RESPONSE) {
|
|
61
|
+
throw new Error(`provider returned ${calls.length} tool calls in one response (maximum ${MAX_TOOL_CALLS_PER_RESPONSE})`);
|
|
46
62
|
}
|
|
47
63
|
assertToolCallIds(calls);
|
|
48
64
|
if (assistant.usage !== undefined)
|
|
@@ -52,6 +68,11 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
52
68
|
: response.inputTokens);
|
|
53
69
|
append(assistant);
|
|
54
70
|
if (calls.length === 0) {
|
|
71
|
+
const steering = options.steering?.drainOrClose();
|
|
72
|
+
if (steering !== undefined && appendSteering(steering.messages)) {
|
|
73
|
+
await checkpoint("checkpointed");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
55
76
|
await checkpoint("completed");
|
|
56
77
|
return; // the model is done — hand back to the user
|
|
57
78
|
}
|
|
@@ -68,14 +89,15 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
68
89
|
for (let offset = 0; offset < batch.length; offset++) {
|
|
69
90
|
throwIfAborted(signal);
|
|
70
91
|
const call = batch[offset];
|
|
71
|
-
|
|
92
|
+
const current = start + offset + 1;
|
|
93
|
+
events.onToolPreparing?.(call, current, calls.length);
|
|
72
94
|
const preview = await look(call, options, signal);
|
|
73
95
|
throwIfAborted(signal);
|
|
74
96
|
announced.add(call.id);
|
|
75
97
|
events.onToolCall(call, preview);
|
|
76
|
-
prepared.push({ call, preview });
|
|
98
|
+
prepared.push({ call, current, preview });
|
|
77
99
|
}
|
|
78
|
-
const runs = await Promise.all(prepared.map(({ call, preview }) => settle(call, options, events, signal, preview)));
|
|
100
|
+
const runs = await Promise.all(prepared.map(({ call, current, preview }) => settle(call, current, calls.length, options, events, signal, preview)));
|
|
79
101
|
for (let offset = 0; offset < runs.length; offset++) {
|
|
80
102
|
const call = prepared[offset]?.call;
|
|
81
103
|
const run = runs[offset];
|
|
@@ -113,9 +135,9 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
113
135
|
throw error;
|
|
114
136
|
}
|
|
115
137
|
append({ role: "user", content: results });
|
|
138
|
+
appendSteering(options.steering?.drain() ?? []);
|
|
116
139
|
await checkpoint("checkpointed");
|
|
117
140
|
}
|
|
118
|
-
throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
|
|
119
141
|
}
|
|
120
142
|
function nextBatch(calls, start, tools) {
|
|
121
143
|
const first = calls[start];
|
|
@@ -141,12 +163,12 @@ function assertToolCallIds(calls) {
|
|
|
141
163
|
if (call.id.trim() === "")
|
|
142
164
|
throw new Error("provider returned a tool call without an id");
|
|
143
165
|
if (seen.has(call.id)) {
|
|
144
|
-
throw new Error("provider returned duplicate tool call ids in one
|
|
166
|
+
throw new Error("provider returned duplicate tool call ids in one response");
|
|
145
167
|
}
|
|
146
168
|
seen.add(call.id);
|
|
147
169
|
}
|
|
148
170
|
}
|
|
149
|
-
async function settle(call, options, events, signal, preview) {
|
|
171
|
+
async function settle(call, current, total, options, events, signal, preview) {
|
|
150
172
|
const tool = findTool(options.tools, call.name);
|
|
151
173
|
if (tool === undefined) {
|
|
152
174
|
return refuse(call, `no such tool: ${call.name}`, "unknown tool");
|
|
@@ -158,6 +180,7 @@ async function settle(call, options, events, signal, preview) {
|
|
|
158
180
|
return refuse(call, "the user declined this call — ask them how to proceed", "declined");
|
|
159
181
|
}
|
|
160
182
|
throwIfAborted(signal);
|
|
183
|
+
events.onToolStart?.(call, current, total);
|
|
161
184
|
return runTool(tool, call, {
|
|
162
185
|
...options.toolContext,
|
|
163
186
|
signal,
|
package/dist/duration.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// One compact duration grammar shared by terminal and exported tool evidence.
|
|
2
|
+
export function toolDuration(durationMs, live = false) {
|
|
3
|
+
const bounded = Number.isFinite(durationMs) ? Math.max(0, durationMs) : 0;
|
|
4
|
+
if (!live && bounded < 1_000)
|
|
5
|
+
return `${Math.round(bounded)}ms`;
|
|
6
|
+
const seconds = bounded / 1_000;
|
|
7
|
+
return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
|
|
8
|
+
}
|
package/dist/openai-account.js
CHANGED
|
@@ -30,29 +30,27 @@ export async function removeOpenAIAccount(signal) {
|
|
|
30
30
|
}
|
|
31
31
|
return { removed: true, revokeFailed };
|
|
32
32
|
}
|
|
33
|
-
export async function openAIAuthorization(
|
|
33
|
+
export async function openAIAuthorization(rejectedToken, signal, onStatus) {
|
|
34
34
|
const account = openAICodexAccount();
|
|
35
35
|
if (account === undefined)
|
|
36
36
|
throw new Error("ChatGPT account is not connected");
|
|
37
|
-
const mustRefresh =
|
|
37
|
+
const mustRefresh = rejectedToken !== undefined || expiresSoon(account);
|
|
38
38
|
const ready = mustRefresh
|
|
39
|
-
? await refreshAccount(
|
|
39
|
+
? await refreshAccount(rejectedToken, signal, onStatus)
|
|
40
40
|
: account;
|
|
41
41
|
return { accessToken: ready.accessToken, accountId: ready.accountId };
|
|
42
42
|
}
|
|
43
|
-
async function refreshAccount(
|
|
43
|
+
async function refreshAccount(rejectedToken, signal, onStatus) {
|
|
44
44
|
if (refreshing !== undefined)
|
|
45
45
|
return abortable(refreshing, signal);
|
|
46
46
|
if (signal?.aborted === true)
|
|
47
47
|
throw abortReason(signal);
|
|
48
|
-
onStatus?.("Refreshing ChatGPT sign-in");
|
|
49
48
|
const task = updateOpenAICodexAccount(async (current) => {
|
|
50
49
|
if (current === undefined)
|
|
51
50
|
throw new Error("ChatGPT account is not connected");
|
|
52
|
-
if (
|
|
53
|
-
return current;
|
|
54
|
-
if (forceToken === undefined && !expiresSoon(current))
|
|
51
|
+
if (!requiresAuthorityRefresh(current, rejectedToken))
|
|
55
52
|
return current;
|
|
53
|
+
onStatus?.("Refreshing ChatGPT sign-in");
|
|
56
54
|
return refreshOpenAITokens(current);
|
|
57
55
|
}).then((account) => {
|
|
58
56
|
if (account === undefined)
|
|
@@ -72,6 +70,12 @@ async function refreshAccount(forceToken, signal, onStatus) {
|
|
|
72
70
|
function expiresSoon(account) {
|
|
73
71
|
return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
|
|
74
72
|
}
|
|
73
|
+
function requiresAuthorityRefresh(account, rejectedToken) {
|
|
74
|
+
// A different fresh token means another process already recovered. An
|
|
75
|
+
// expiring replacement still needs rotation, while a rejected current token
|
|
76
|
+
// must be rotated regardless of its advertised expiry.
|
|
77
|
+
return expiresSoon(account) || account.accessToken === rejectedToken;
|
|
78
|
+
}
|
|
75
79
|
function abortable(promise, signal) {
|
|
76
80
|
if (signal === undefined)
|
|
77
81
|
return promise;
|
|
@@ -9,6 +9,7 @@ import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
|
9
9
|
export async function assembleAnthropic(events, onStream) {
|
|
10
10
|
const blocks = new Map();
|
|
11
11
|
const partialJson = new Map();
|
|
12
|
+
const announcedTools = new Set();
|
|
12
13
|
const sizes = { toolArguments: 0 };
|
|
13
14
|
let stopReason;
|
|
14
15
|
let stopDetails;
|
|
@@ -23,6 +24,13 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
23
24
|
case "content_block_start": {
|
|
24
25
|
if (typeof event.index === "number" && event.content_block !== undefined) {
|
|
25
26
|
blocks.set(event.index, { ...event.content_block });
|
|
27
|
+
if (event.content_block["type"] === "tool_use" &&
|
|
28
|
+
!announcedTools.has(event.index)) {
|
|
29
|
+
announcedTools.add(event.index);
|
|
30
|
+
const rawName = event.content_block["name"];
|
|
31
|
+
const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
|
|
32
|
+
onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
|
|
33
|
+
}
|
|
26
34
|
}
|
|
27
35
|
break;
|
|
28
36
|
}
|
|
@@ -44,27 +44,39 @@ export function stopNotice(data) {
|
|
|
44
44
|
export function fromWireResponse(data) {
|
|
45
45
|
const raw = Array.isArray(data.content) ? data.content : [];
|
|
46
46
|
const content = [];
|
|
47
|
+
let suppressedToolCall = false;
|
|
47
48
|
for (const item of raw) {
|
|
48
49
|
const block = item;
|
|
49
50
|
if (block.type === "text" && typeof block.text === "string") {
|
|
50
51
|
content.push({ kind: "text", text: block.text });
|
|
51
52
|
}
|
|
52
|
-
else if (block.type === "tool_use"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
53
|
+
else if (block.type === "tool_use") {
|
|
54
|
+
if (data.stop_reason === "tool_use" &&
|
|
55
|
+
typeof block.id === "string" &&
|
|
56
|
+
typeof block.name === "string") {
|
|
57
|
+
content.push({
|
|
58
|
+
kind: "tool_call",
|
|
59
|
+
id: block.id,
|
|
60
|
+
name: block.name,
|
|
61
|
+
input: (block.input ?? {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
suppressedToolCall = true;
|
|
66
|
+
}
|
|
61
67
|
}
|
|
62
68
|
// thinking / redacted_thinking and anything future: carried in `raw` only.
|
|
63
69
|
}
|
|
64
70
|
const notice = stopNotice(data);
|
|
65
71
|
if (notice !== undefined)
|
|
66
72
|
content.push({ kind: "text", text: notice });
|
|
67
|
-
|
|
73
|
+
const retainRaw = data.stop_reason !== "max_tokens" && !suppressedToolCall;
|
|
74
|
+
return {
|
|
75
|
+
role: "assistant",
|
|
76
|
+
content,
|
|
77
|
+
...(retainRaw ? { raw, rawFrom: "anthropic" } : {}),
|
|
78
|
+
usage: normalizeUsage(data),
|
|
79
|
+
};
|
|
68
80
|
}
|
|
69
81
|
function normalizeUsage(data) {
|
|
70
82
|
const usage = data.usage;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
7
7
|
export async function assembleOllama(events, onStream) {
|
|
8
8
|
const calls = new Map();
|
|
9
|
+
const announcedTools = new Map();
|
|
9
10
|
let toolArgumentChars = 0;
|
|
10
11
|
let content = "";
|
|
11
12
|
let reasoning = "";
|
|
@@ -53,6 +54,12 @@ export async function assembleOllama(events, onStream) {
|
|
|
53
54
|
call.args += part.function.arguments;
|
|
54
55
|
}
|
|
55
56
|
calls.set(index, call);
|
|
57
|
+
const name = call.name === "" ? undefined : call.name;
|
|
58
|
+
if (!announcedTools.has(index) ||
|
|
59
|
+
(announcedTools.get(index) === undefined && name !== undefined)) {
|
|
60
|
+
announcedTools.set(index, name);
|
|
61
|
+
onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
if (finishReason === undefined) {
|
|
@@ -60,10 +60,18 @@ export function fromWireReply(reply) {
|
|
|
60
60
|
const content = [];
|
|
61
61
|
if (reply.content !== "")
|
|
62
62
|
content.push({ kind: "text", text: reply.content });
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
const acceptsToolCalls = reply.finishReason === "tool_calls";
|
|
64
|
+
if (acceptsToolCalls) {
|
|
65
|
+
for (const call of reply.toolCalls) {
|
|
66
|
+
content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
|
|
67
|
+
}
|
|
65
68
|
}
|
|
66
|
-
const
|
|
69
|
+
const notice = stopNotice(reply);
|
|
70
|
+
if (notice !== undefined)
|
|
71
|
+
content.push({ kind: "text", text: notice });
|
|
72
|
+
const suppressedToolCall = reply.toolCalls.length > 0 && !acceptsToolCalls;
|
|
73
|
+
const retainRaw = reply.finishReason !== "length" && !suppressedToolCall;
|
|
74
|
+
const raw = !retainRaw || reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
|
|
67
75
|
return {
|
|
68
76
|
role: "assistant",
|
|
69
77
|
content,
|
|
@@ -6,6 +6,7 @@
|
|
|
6
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
|
+
const announcedTools = { identities: new Set(), anonymous: false };
|
|
9
10
|
let refusal = false;
|
|
10
11
|
for await (const raw of events) {
|
|
11
12
|
const event = raw;
|
|
@@ -24,14 +25,31 @@ export async function assembleOpenAI(events, onStream) {
|
|
|
24
25
|
if (typeof event.delta === "string")
|
|
25
26
|
onStream?.({ kind: "thinking", text: event.delta });
|
|
26
27
|
break;
|
|
28
|
+
case "response.output_item.added":
|
|
29
|
+
if (isFunctionCall(event.item)) {
|
|
30
|
+
announceTool(event, event.item, announcedTools, onStream);
|
|
31
|
+
}
|
|
32
|
+
break;
|
|
33
|
+
case "response.function_call_arguments.delta":
|
|
34
|
+
case "response.function_call_arguments.done":
|
|
35
|
+
announceTool(event, undefined, announcedTools, onStream);
|
|
36
|
+
break;
|
|
27
37
|
case "response.output_item.done":
|
|
28
|
-
if (event.item !== undefined)
|
|
38
|
+
if (event.item !== undefined) {
|
|
39
|
+
if (isFunctionCall(event.item)) {
|
|
40
|
+
announceTool(event, event.item, announcedTools, onStream);
|
|
41
|
+
}
|
|
29
42
|
items.push(event.item);
|
|
43
|
+
}
|
|
30
44
|
break;
|
|
31
45
|
case "response.done":
|
|
32
46
|
case "response.completed":
|
|
47
|
+
return withDefaultStatus(reconcileOutput(event.response, items), "completed");
|
|
33
48
|
case "response.incomplete":
|
|
34
|
-
return
|
|
49
|
+
return {
|
|
50
|
+
...reconcileOutput(event.response, items),
|
|
51
|
+
status: "incomplete",
|
|
52
|
+
};
|
|
35
53
|
case "response.failed": {
|
|
36
54
|
const response = event.response;
|
|
37
55
|
throw new Error(`openai stream error: ${response?.error?.message ?? "unspecified"}`);
|
|
@@ -44,9 +62,46 @@ export async function assembleOpenAI(events, onStream) {
|
|
|
44
62
|
}
|
|
45
63
|
throw new Error("openai stream ended before a terminal response event");
|
|
46
64
|
}
|
|
65
|
+
function isFunctionCall(item) {
|
|
66
|
+
return typeof item === "object" && item !== null &&
|
|
67
|
+
item["type"] === "function_call";
|
|
68
|
+
}
|
|
69
|
+
function announceTool(event, item, announced, onStream) {
|
|
70
|
+
const identities = toolIdentities(event, item);
|
|
71
|
+
if (identities.length === 0) {
|
|
72
|
+
if (announced.anonymous)
|
|
73
|
+
return;
|
|
74
|
+
announced.anonymous = true;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const duplicate = identities.some((identity) => announced.identities.has(identity));
|
|
78
|
+
for (const identity of identities)
|
|
79
|
+
announced.identities.add(identity);
|
|
80
|
+
if (duplicate)
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const rawName = item?.name ?? event.name;
|
|
84
|
+
const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
|
|
85
|
+
onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
|
|
86
|
+
}
|
|
87
|
+
function toolIdentities(event, item) {
|
|
88
|
+
const identities = [];
|
|
89
|
+
const itemId = event.item_id ?? item?.id;
|
|
90
|
+
if (typeof itemId === "string" && itemId !== "")
|
|
91
|
+
identities.push(`item:${itemId}`);
|
|
92
|
+
if (typeof event.output_index === "number")
|
|
93
|
+
identities.push(`output:${event.output_index}`);
|
|
94
|
+
if (typeof item?.call_id === "string" && item.call_id !== "") {
|
|
95
|
+
identities.push(`call:${item.call_id}`);
|
|
96
|
+
}
|
|
97
|
+
return identities;
|
|
98
|
+
}
|
|
47
99
|
function reconcileOutput(completed, items) {
|
|
48
100
|
if (completed === undefined)
|
|
49
101
|
return { output: items };
|
|
50
102
|
const finalCount = Array.isArray(completed.output) ? completed.output.length : 0;
|
|
51
103
|
return items.length > finalCount ? { ...completed, output: items } : completed;
|
|
52
104
|
}
|
|
105
|
+
function withDefaultStatus(response, status) {
|
|
106
|
+
return response.status === undefined ? { ...response, status } : response;
|
|
107
|
+
}
|
|
@@ -40,8 +40,9 @@ export function toWireItems(message, providerId = "openai") {
|
|
|
40
40
|
}
|
|
41
41
|
export function stopNotice(data) {
|
|
42
42
|
const reason = data.incomplete_details?.reason;
|
|
43
|
-
if (reason === undefined)
|
|
44
|
-
return undefined;
|
|
43
|
+
if (reason === undefined) {
|
|
44
|
+
return data.status === "incomplete" ? "[incomplete response]" : undefined;
|
|
45
|
+
}
|
|
45
46
|
return reason === "max_output_tokens"
|
|
46
47
|
? "[truncated: hit max_output_tokens — raise --max-tokens]"
|
|
47
48
|
: `[incomplete: ${reason}]`;
|
|
@@ -49,6 +50,7 @@ export function stopNotice(data) {
|
|
|
49
50
|
export function fromWireResponse(data, providerId = "openai") {
|
|
50
51
|
const raw = Array.isArray(data.output) ? data.output : [];
|
|
51
52
|
const content = [];
|
|
53
|
+
let suppressedFunctionCall = false;
|
|
52
54
|
for (const entry of raw) {
|
|
53
55
|
const item = entry;
|
|
54
56
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
@@ -62,22 +64,35 @@ export function fromWireResponse(data, providerId = "openai") {
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
|
-
else if (item.type === "function_call"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
else if (item.type === "function_call") {
|
|
68
|
+
if (data.status === "completed" &&
|
|
69
|
+
(item.status === undefined || item.status === "completed") &&
|
|
70
|
+
typeof item.call_id === "string" &&
|
|
71
|
+
typeof item.name === "string") {
|
|
72
|
+
content.push({
|
|
73
|
+
kind: "tool_call",
|
|
74
|
+
id: item.call_id,
|
|
75
|
+
name: item.name,
|
|
76
|
+
input: parseArguments(item.arguments),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
suppressedFunctionCall = true;
|
|
81
|
+
}
|
|
74
82
|
}
|
|
75
83
|
// reasoning items and anything future: carried in `raw` only.
|
|
76
84
|
}
|
|
77
85
|
const notice = stopNotice(data);
|
|
78
86
|
if (notice !== undefined)
|
|
79
87
|
content.push({ kind: "text", text: notice });
|
|
80
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
role: "assistant",
|
|
90
|
+
content,
|
|
91
|
+
...(data.status === "completed" && !suppressedFunctionCall
|
|
92
|
+
? { raw, rawFrom: providerId }
|
|
93
|
+
: {}),
|
|
94
|
+
usage: normalizeUsage(data),
|
|
95
|
+
};
|
|
81
96
|
}
|
|
82
97
|
function normalizeUsage(data) {
|
|
83
98
|
const usage = data.usage;
|
package/dist/sessions/codec.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// can become conversation or provider input.
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
import { CONTEXT_LIMITS } from "../context/projection.js";
|
|
6
|
-
export const SESSION_SCHEMA =
|
|
6
|
+
export const SESSION_SCHEMA = 4;
|
|
7
7
|
export const SESSION_FILE_LIMITS = Object.freeze({
|
|
8
8
|
text: 1_048_576,
|
|
9
9
|
metadataBytes: 64 * 1_024,
|
|
@@ -135,7 +135,7 @@ export function decodeNode(value) {
|
|
|
135
135
|
effort: identity["effort"],
|
|
136
136
|
}),
|
|
137
137
|
messages: Object.freeze(messages.map(messageFromRecord)),
|
|
138
|
-
blocks: Object.freeze(blocks.map(blockFromRecord)),
|
|
138
|
+
blocks: Object.freeze(blocks.map((block) => blockFromRecord(block, version))),
|
|
139
139
|
...(context === undefined ? {} : { context: Object.freeze(context) }),
|
|
140
140
|
...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
|
|
141
141
|
});
|
|
@@ -266,18 +266,23 @@ function blockRecord(block) {
|
|
|
266
266
|
right: block.right,
|
|
267
267
|
tone: block.tone,
|
|
268
268
|
body: block.body?.map(detailRecord) ?? null,
|
|
269
|
+
durationMs: block.durationMs ?? null,
|
|
269
270
|
}];
|
|
270
271
|
}
|
|
271
|
-
function blockFromRecord(value) {
|
|
272
|
+
function blockFromRecord(value, version) {
|
|
272
273
|
if (!record(value) || typeof value["kind"] !== "string")
|
|
273
274
|
throw invalid();
|
|
274
275
|
if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
|
|
275
276
|
keys(value, "kind,text") && boundedText(value["text"]))
|
|
276
277
|
return { kind: value["kind"], text: value["text"] };
|
|
277
|
-
|
|
278
|
+
const toolKeys = version >= 4
|
|
279
|
+
? "body,durationMs,kind,name,right,target,tone"
|
|
280
|
+
: "body,kind,name,right,target,tone";
|
|
281
|
+
if (value["kind"] === "tool" && keys(value, toolKeys) &&
|
|
278
282
|
bounded(value["name"], 256) && boundedText(value["target"]) &&
|
|
279
283
|
boundedText(value["right"], 1_024) &&
|
|
280
284
|
(value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
|
|
285
|
+
(version < 4 || nullableInteger(value["durationMs"], 0)) &&
|
|
281
286
|
(value["body"] === null ||
|
|
282
287
|
(Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
|
|
283
288
|
const body = value["body"] === null
|
|
@@ -290,6 +295,9 @@ function blockFromRecord(value) {
|
|
|
290
295
|
right: value["right"],
|
|
291
296
|
tone: value["tone"],
|
|
292
297
|
...(body === undefined ? {} : { body }),
|
|
298
|
+
...(version < 4 || value["durationMs"] === null
|
|
299
|
+
? {}
|
|
300
|
+
: { durationMs: value["durationMs"] }),
|
|
293
301
|
};
|
|
294
302
|
}
|
|
295
303
|
throw invalid();
|
|
@@ -398,7 +406,7 @@ function integer(value, minimum) {
|
|
|
398
406
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
399
407
|
}
|
|
400
408
|
function schema(value) {
|
|
401
|
-
return value === 1 || value === 2 || value === SESSION_SCHEMA;
|
|
409
|
+
return value === 1 || value === 2 || value === 3 || value === SESSION_SCHEMA;
|
|
402
410
|
}
|
|
403
411
|
function settlement(value, version) {
|
|
404
412
|
return value === "checkpointed" || value === "completed" ||
|