@giovannijecha/jecode 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -29
- package/dist/atomic.js +14 -0
- package/dist/batch.js +5 -2
- package/dist/cli-info.js +1 -1
- package/dist/config.js +4 -1
- package/dist/context/policy.js +14 -7
- package/dist/controller-request.js +1 -1
- package/dist/controller.js +39 -15
- package/dist/duration.js +8 -0
- package/dist/oauth-http.js +2 -1
- package/dist/openai-account.js +12 -8
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/anthropic-stream.js +8 -0
- package/dist/providers/anthropic-wire.js +22 -10
- package/dist/providers/http.js +4 -3
- package/dist/providers/ollama-stream.js +7 -0
- package/dist/providers/ollama-wire.js +11 -3
- package/dist/providers/openai-codex.js +0 -2
- package/dist/providers/openai-stream.js +57 -2
- package/dist/providers/openai-wire.js +27 -12
- package/dist/providers/sse.js +115 -38
- package/dist/sessions/codec.js +13 -5
- package/dist/sessions/store.js +2 -1
- package/dist/settings-command.js +0 -5
- package/dist/settings.js +0 -2
- package/dist/steering.js +58 -0
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/args.js +1 -1
- package/dist/tools/fs.js +5 -0
- package/dist/tools/text-boundary.js +1 -31
- 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 +57 -14
- package/dist/tui/app.js +18 -3
- package/dist/tui/blocks.js +7 -2
- package/dist/tui/components/messages.js +20 -17
- package/dist/tui/components/misc.js +7 -6
- package/dist/tui/components/status.js +12 -1
- package/dist/tui/components/tool.js +152 -50
- 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 +25 -0
- package/dist/tui/transcript-view.js +132 -7
- package/dist/tui/turn.js +78 -39
- package/dist/tui/view.js +4 -2
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +17 -27
- package/dist/ui/theme.js +19 -18
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- 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
|
|
@@ -53,10 +56,32 @@
|
|
|
53
56
|
|
|
54
57
|
Jecode requires **Node.js 22.18+ on the 22.x line, or Node.js 24+**, and npm.
|
|
55
58
|
|
|
59
|
+
### Install or update
|
|
60
|
+
|
|
61
|
+
Install the current stable release. Running the same command again updates an
|
|
62
|
+
existing installation:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
npm install --global @giovannijecha/jecode@latest
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Confirm the installed version:
|
|
69
|
+
|
|
56
70
|
```console
|
|
57
|
-
npm install --global @giovannijecha/jecode
|
|
58
71
|
jecode --version
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Start Jecode
|
|
75
|
+
|
|
76
|
+
Open the project you want Jecode to work on:
|
|
77
|
+
|
|
78
|
+
```console
|
|
59
79
|
cd path/to/your/project
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Then start Jecode:
|
|
83
|
+
|
|
84
|
+
```console
|
|
60
85
|
jecode
|
|
61
86
|
```
|
|
62
87
|
|
|
@@ -71,33 +96,41 @@ change to improve startup performance.
|
|
|
71
96
|
Use `jecode --root path/to/project` to select another workspace, or
|
|
72
97
|
`jecode --ephemeral` when the conversation must stay memory-only.
|
|
73
98
|
|
|
74
|
-
Resume a
|
|
99
|
+
### Resume a conversation
|
|
100
|
+
|
|
101
|
+
Open the searchable resume picker for the current workspace:
|
|
75
102
|
|
|
76
103
|
```console
|
|
77
104
|
jecode resume
|
|
78
|
-
jecode resume --latest
|
|
79
105
|
```
|
|
80
106
|
|
|
81
|
-
|
|
82
|
-
covered by the project test matrix.
|
|
107
|
+
Resume the most recently updated conversation directly:
|
|
83
108
|
|
|
84
|
-
|
|
109
|
+
```console
|
|
110
|
+
jecode resume --latest
|
|
111
|
+
```
|
|
85
112
|
|
|
86
|
-
|
|
87
|
-
opt-in prerelease:
|
|
113
|
+
List every startup option:
|
|
88
114
|
|
|
89
115
|
```console
|
|
90
|
-
|
|
116
|
+
jecode --help
|
|
91
117
|
```
|
|
92
118
|
|
|
119
|
+
Windows, Ubuntu, and macOS are covered by the project test matrix.
|
|
120
|
+
|
|
121
|
+
### Prereleases
|
|
122
|
+
|
|
123
|
+
Prereleases exist only during an announced release-candidate cycle. When one is
|
|
124
|
+
active, its GitHub release provides the exact installation command. Outside an
|
|
125
|
+
active cycle, the stable npm package is the only supported installation
|
|
126
|
+
artifact.
|
|
127
|
+
|
|
93
128
|
Git URL installs are intentionally unsupported: the source tree contains no
|
|
94
129
|
generated runtime and defines no install-time build hook.
|
|
95
130
|
|
|
96
|
-
###
|
|
131
|
+
### Uninstall
|
|
97
132
|
|
|
98
133
|
```console
|
|
99
|
-
npm install --global @giovannijecha/jecode
|
|
100
|
-
jecode --version
|
|
101
134
|
npm uninstall --global @giovannijecha/jecode
|
|
102
135
|
```
|
|
103
136
|
|
|
@@ -105,12 +138,19 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
|
|
|
105
138
|
you intentionally want to erase saved settings, credentials, accounts, and
|
|
106
139
|
sessions.
|
|
107
140
|
|
|
108
|
-
|
|
109
|
-
|
|
141
|
+
### Replace a legacy installation
|
|
142
|
+
|
|
143
|
+
If an older GitHub installation still owns the `jecode` executable, first
|
|
144
|
+
remove the legacy unscoped package:
|
|
110
145
|
|
|
111
146
|
```console
|
|
112
147
|
npm uninstall --global jecode
|
|
113
|
-
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then install the current scoped package:
|
|
151
|
+
|
|
152
|
+
```console
|
|
153
|
+
npm install --global @giovannijecha/jecode@latest
|
|
114
154
|
```
|
|
115
155
|
|
|
116
156
|
Do not work around the resulting `EEXIST` error with `--force`.
|
|
@@ -119,14 +159,39 @@ Do not work around the resulting `EEXIST` error with `--force`.
|
|
|
119
159
|
|
|
120
160
|
WSL uses its own Node.js installation and `PATH`; the Node.js version installed
|
|
121
161
|
on Windows does not apply inside it. Keep user-installed npm commands in the
|
|
122
|
-
Linux user path:
|
|
162
|
+
Linux user path. Set the user-level npm prefix:
|
|
123
163
|
|
|
124
164
|
```console
|
|
125
165
|
npm config set prefix "$HOME/.local"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Add it to the current shell's `PATH`:
|
|
169
|
+
|
|
170
|
+
```console
|
|
126
171
|
export PATH="$HOME/.local/bin:$PATH"
|
|
127
|
-
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Install or update Jecode:
|
|
175
|
+
|
|
176
|
+
```console
|
|
177
|
+
npm install --global @giovannijecha/jecode@latest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Refresh the command cache:
|
|
181
|
+
|
|
182
|
+
```console
|
|
128
183
|
hash -r
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Confirm which executable will run:
|
|
187
|
+
|
|
188
|
+
```console
|
|
129
189
|
command -v jecode
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Verify the installed version:
|
|
193
|
+
|
|
194
|
+
```console
|
|
130
195
|
jecode --version
|
|
131
196
|
```
|
|
132
197
|
|
|
@@ -183,7 +248,8 @@ Useful controls:
|
|
|
183
248
|
- **Backspace/Delete** removes one character;
|
|
184
249
|
**Ctrl+Backspace/Delete** removes one word.
|
|
185
250
|
- **Home/End** moves to the beginning or end of the composer.
|
|
186
|
-
- **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.
|
|
187
253
|
- **Alt+Enter** inserts a newline.
|
|
188
254
|
- **Esc** closes the active menu or interrupts foreground work.
|
|
189
255
|
- **Ctrl+C** interrupts, or exits while idle. **Ctrl+D** requests a clean exit.
|
|
@@ -191,9 +257,11 @@ Useful controls:
|
|
|
191
257
|
- **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
|
|
192
258
|
|
|
193
259
|
The footer keeps the active model, effort, and workspace visible. During work,
|
|
194
|
-
it adds the current state, elapsed time,
|
|
195
|
-
|
|
196
|
-
|
|
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.
|
|
197
265
|
|
|
198
266
|
## Sessions and context
|
|
199
267
|
|
|
@@ -254,19 +322,23 @@ settings, then built-in defaults.
|
|
|
254
322
|
| `--root` | - | Current directory |
|
|
255
323
|
| `--effort` | `JECODE_EFFORT` | `high` |
|
|
256
324
|
| `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
|
|
257
|
-
| `--max-steps` | `JECODE_MAX_STEPS` |
|
|
325
|
+
| `--max-steps` | `JECODE_MAX_STEPS` | No limit |
|
|
258
326
|
| `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
|
|
259
327
|
| `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
|
|
260
328
|
| `--auto-approve` | `JECODE_AUTO_APPROVE=1` | Off |
|
|
261
329
|
| `--ephemeral` | `JECODE_EPHEMERAL=1` | Off |
|
|
262
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
|
+
|
|
263
335
|
Non-secret preferences live in `~/.jecode/settings.json`. Explicitly saved API
|
|
264
336
|
keys live in `~/.jecode/credentials.json`, while ChatGPT OAuth accounts live in
|
|
265
337
|
`~/.jecode/accounts.json`. Environment credentials always take precedence.
|
|
266
338
|
Secret stores use owner-only permissions where the operating system supports
|
|
267
339
|
them.
|
|
268
340
|
|
|
269
|
-
Jecode has one current interface theme,
|
|
341
|
+
Jecode has one current interface theme, Slate. `NO_COLOR` is supported for
|
|
270
342
|
terminals and pipelines that disable colour.
|
|
271
343
|
|
|
272
344
|
## Safety model
|
|
@@ -347,14 +419,17 @@ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
|
|
|
347
419
|
probes for long-session rendering and workspace search. Architecture and
|
|
348
420
|
security boundaries are documented in
|
|
349
421
|
[docs/architecture.md](docs/architecture.md); brand assets and usage rules live
|
|
350
|
-
in [docs/brand.md](docs/brand.md).
|
|
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).
|
|
351
425
|
|
|
352
426
|
## Community
|
|
353
427
|
|
|
354
428
|
- Ask questions, share workflows, and explore early ideas in
|
|
355
429
|
[GitHub Discussions](https://github.com/giovannijecha/jecode/discussions).
|
|
356
430
|
- Report reproducible bugs and focused feature requests through
|
|
357
|
-
[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.
|
|
358
433
|
- Report security concerns privately through the repository Security tab.
|
|
359
434
|
|
|
360
435
|
Public pull requests are not accepted at this stage. Code changes remain a
|
package/dist/atomic.js
CHANGED
|
@@ -8,20 +8,29 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
8
8
|
let handle;
|
|
9
9
|
let identity;
|
|
10
10
|
try {
|
|
11
|
+
throwIfAborted(options.signal);
|
|
11
12
|
await options.validate?.("before-open");
|
|
13
|
+
throwIfAborted(options.signal);
|
|
12
14
|
const permissions = options.mode ?? (await existingMode(file));
|
|
15
|
+
throwIfAborted(options.signal);
|
|
13
16
|
handle = await open(temporary, "wx", permissions);
|
|
14
17
|
identity = fileIdentity(await handle.stat());
|
|
18
|
+
throwIfAborted(options.signal);
|
|
15
19
|
await options.validate?.("before-write");
|
|
20
|
+
throwIfAborted(options.signal);
|
|
16
21
|
await assertNamedFile(temporary, identity);
|
|
17
22
|
await handle.writeFile(content, "utf8");
|
|
23
|
+
throwIfAborted(options.signal);
|
|
18
24
|
if (permissions !== undefined && process.platform !== "win32") {
|
|
19
25
|
await handle.chmod(permissions);
|
|
20
26
|
}
|
|
21
27
|
await handle.sync();
|
|
28
|
+
throwIfAborted(options.signal);
|
|
22
29
|
identity = fileIdentity(await handle.stat());
|
|
23
30
|
await options.validate?.("before-rename");
|
|
31
|
+
throwIfAborted(options.signal);
|
|
24
32
|
await assertNamedFile(temporary, identity);
|
|
33
|
+
throwIfAborted(options.signal);
|
|
25
34
|
await rename(temporary, file);
|
|
26
35
|
const completed = handle;
|
|
27
36
|
handle = undefined;
|
|
@@ -38,6 +47,11 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
38
47
|
throw error;
|
|
39
48
|
}
|
|
40
49
|
}
|
|
50
|
+
function throwIfAborted(signal) {
|
|
51
|
+
if (signal?.aborted !== true)
|
|
52
|
+
return;
|
|
53
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
54
|
+
}
|
|
41
55
|
async function assertNamedFile(file, expected) {
|
|
42
56
|
const details = await lstat(file);
|
|
43
57
|
if (details.isSymbolicLink() || !sameFile(expected, fileIdentity(details))) {
|
package/dist/batch.js
CHANGED
|
@@ -13,7 +13,7 @@ import { handleCommand } from "./commands.js";
|
|
|
13
13
|
import { renderBatch } from "./batch-view.js";
|
|
14
14
|
import { columns } from "./ui/render.js";
|
|
15
15
|
import { terminalText } from "./ui/terminal-text.js";
|
|
16
|
-
import { recordAuxiliaryUsage, recordUsage } from "./usage.js";
|
|
16
|
+
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
|
|
17
17
|
export async function runBatch(session, environment = {}) {
|
|
18
18
|
const rl = environment.lines === undefined ? readline.createInterface({ input: stdin }) : undefined;
|
|
19
19
|
const lines = environment.lines ?? rl;
|
|
@@ -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
|
}
|
|
@@ -167,5 +167,8 @@ function events(emit, session) {
|
|
|
167
167
|
onUsage(usage) {
|
|
168
168
|
recordUsage(session.usage, usage);
|
|
169
169
|
},
|
|
170
|
+
onRequestInput(inputTokens) {
|
|
171
|
+
recordRequestInput(session.usage, inputTokens);
|
|
172
|
+
},
|
|
170
173
|
};
|
|
171
174
|
}
|
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/context/policy.js
CHANGED
|
@@ -42,16 +42,16 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
export function policyForContextWindow(context, compactionPercent) {
|
|
45
|
-
const windowTokens =
|
|
46
|
-
?
|
|
47
|
-
:
|
|
45
|
+
const windowTokens = context === undefined
|
|
46
|
+
? FALLBACK_CONTEXT_WINDOW_TOKENS
|
|
47
|
+
: requireWindow(context.tokens);
|
|
48
48
|
const percent = validPercent(compactionPercent)
|
|
49
49
|
? compactionPercent
|
|
50
50
|
: DEFAULT_COMPACTION_PERCENT;
|
|
51
51
|
const percentageLimit = Math.floor(windowTokens * percent / 100);
|
|
52
|
-
const providerLimit =
|
|
53
|
-
?
|
|
54
|
-
:
|
|
52
|
+
const providerLimit = context?.compactAtTokens === undefined
|
|
53
|
+
? windowTokens
|
|
54
|
+
: requireWindow(context.compactAtTokens);
|
|
55
55
|
const requestLimitTokens = Math.floor(Math.min(providerLimit, windowTokens) * (100 - REQUEST_ESTIMATE_HEADROOM_PERCENT) / 100);
|
|
56
56
|
const triggerTokens = Math.min(percentageLimit, requestLimitTokens - MIN_REQUEST_OUTPUT_TOKENS);
|
|
57
57
|
const targetTokens = Math.max(512, Math.min(Math.floor(windowTokens / 4), Math.floor(triggerTokens / 2)));
|
|
@@ -114,7 +114,14 @@ function validPolicy(policy) {
|
|
|
114
114
|
policy.targetTokens < policy.triggerTokens && policy.recentTokens < policy.triggerTokens;
|
|
115
115
|
}
|
|
116
116
|
function validWindow(value) {
|
|
117
|
-
|
|
117
|
+
// Adapters validate raw capacities at 4K before applying their own usable
|
|
118
|
+
// headroom. The resulting safe capacity can therefore be slightly smaller.
|
|
119
|
+
return value !== undefined && Number.isSafeInteger(value) && value >= 1_024 && value <= 10_000_000;
|
|
120
|
+
}
|
|
121
|
+
function requireWindow(value) {
|
|
122
|
+
if (validWindow(value))
|
|
123
|
+
return value;
|
|
124
|
+
throw new Error(`provider reported an invalid usable context window: ${value}`);
|
|
118
125
|
}
|
|
119
126
|
function validPercent(value) {
|
|
120
127
|
return Number.isSafeInteger(value) &&
|
|
@@ -24,7 +24,7 @@ export async function requestAssistant(history, current, specs, options, events,
|
|
|
24
24
|
onStream: (event) => events.onStream(event),
|
|
25
25
|
onStatus: (status) => events.onStatus?.(status),
|
|
26
26
|
});
|
|
27
|
-
return { message, context };
|
|
27
|
+
return { message, context, inputTokens: budget.inputTokens };
|
|
28
28
|
}
|
|
29
29
|
catch (error) {
|
|
30
30
|
if (recovered)
|
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,14 +57,22 @@ 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);
|
|
64
|
+
if (assistant.usage !== undefined)
|
|
65
|
+
events.onUsage?.(assistant.usage);
|
|
66
|
+
events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
|
|
67
|
+
? assistant.usage.inputTokens
|
|
68
|
+
: response.inputTokens);
|
|
48
69
|
append(assistant);
|
|
49
70
|
if (calls.length === 0) {
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
const steering = options.steering?.drainOrClose();
|
|
72
|
+
if (steering !== undefined && appendSteering(steering.messages)) {
|
|
73
|
+
await checkpoint("checkpointed");
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
52
76
|
await checkpoint("completed");
|
|
53
77
|
return; // the model is done — hand back to the user
|
|
54
78
|
}
|
|
@@ -58,8 +82,6 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
58
82
|
const results = [];
|
|
59
83
|
const announced = new Set();
|
|
60
84
|
try {
|
|
61
|
-
if (assistant.usage !== undefined)
|
|
62
|
-
events.onUsage?.(assistant.usage);
|
|
63
85
|
while (results.length < calls.length) {
|
|
64
86
|
const start = results.length;
|
|
65
87
|
const batch = nextBatch(calls, start, options.tools);
|
|
@@ -67,14 +89,15 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
67
89
|
for (let offset = 0; offset < batch.length; offset++) {
|
|
68
90
|
throwIfAborted(signal);
|
|
69
91
|
const call = batch[offset];
|
|
70
|
-
|
|
92
|
+
const current = start + offset + 1;
|
|
93
|
+
events.onToolPreparing?.(call, current, calls.length);
|
|
71
94
|
const preview = await look(call, options, signal);
|
|
72
95
|
throwIfAborted(signal);
|
|
73
96
|
announced.add(call.id);
|
|
74
97
|
events.onToolCall(call, preview);
|
|
75
|
-
prepared.push({ call, preview });
|
|
98
|
+
prepared.push({ call, current, preview });
|
|
76
99
|
}
|
|
77
|
-
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)));
|
|
78
101
|
for (let offset = 0; offset < runs.length; offset++) {
|
|
79
102
|
const call = prepared[offset]?.call;
|
|
80
103
|
const run = runs[offset];
|
|
@@ -112,9 +135,9 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
112
135
|
throw error;
|
|
113
136
|
}
|
|
114
137
|
append({ role: "user", content: results });
|
|
138
|
+
appendSteering(options.steering?.drain() ?? []);
|
|
115
139
|
await checkpoint("checkpointed");
|
|
116
140
|
}
|
|
117
|
-
throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
|
|
118
141
|
}
|
|
119
142
|
function nextBatch(calls, start, tools) {
|
|
120
143
|
const first = calls[start];
|
|
@@ -140,12 +163,12 @@ function assertToolCallIds(calls) {
|
|
|
140
163
|
if (call.id.trim() === "")
|
|
141
164
|
throw new Error("provider returned a tool call without an id");
|
|
142
165
|
if (seen.has(call.id)) {
|
|
143
|
-
throw new Error("provider returned duplicate tool call ids in one
|
|
166
|
+
throw new Error("provider returned duplicate tool call ids in one response");
|
|
144
167
|
}
|
|
145
168
|
seen.add(call.id);
|
|
146
169
|
}
|
|
147
170
|
}
|
|
148
|
-
async function settle(call, options, events, signal, preview) {
|
|
171
|
+
async function settle(call, current, total, options, events, signal, preview) {
|
|
149
172
|
const tool = findTool(options.tools, call.name);
|
|
150
173
|
if (tool === undefined) {
|
|
151
174
|
return refuse(call, `no such tool: ${call.name}`, "unknown tool");
|
|
@@ -157,6 +180,7 @@ async function settle(call, options, events, signal, preview) {
|
|
|
157
180
|
return refuse(call, "the user declined this call — ask them how to proceed", "declined");
|
|
158
181
|
}
|
|
159
182
|
throwIfAborted(signal);
|
|
183
|
+
events.onToolStart?.(call, current, total);
|
|
160
184
|
return runTool(tool, call, {
|
|
161
185
|
...options.toolContext,
|
|
162
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/oauth-http.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// These requests never redirect, never retry, and never retain an unbounded
|
|
4
4
|
// response. Authorization codes and refresh tokens must not leak into errors.
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const AUTH_ORIGIN = "https://auth.openai.com";
|
|
6
7
|
const TIMEOUT_MS = 15_000;
|
|
7
8
|
const MAX_BODY_CHARS = 64_000;
|
|
@@ -96,7 +97,7 @@ function errorDetail(value, secrets) {
|
|
|
96
97
|
let safe = detail.replace(/[\r\n]+/g, " ");
|
|
97
98
|
for (const secret of secrets)
|
|
98
99
|
safe = safe.replaceAll(secret, "[credential redacted]");
|
|
99
|
-
return ` · ${safe
|
|
100
|
+
return ` · ${leadingText(safe, 300)}`;
|
|
100
101
|
}
|
|
101
102
|
function bodySecrets(body) {
|
|
102
103
|
const values = body.contentType === "application/x-www-form-urlencoded"
|
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;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
+
import { leadingText } from "./text-boundary.js";
|
|
5
6
|
const CALLBACK_PORTS = [1455, 1457];
|
|
6
7
|
export const OPENAI_CALLBACK_PATH = "/auth/callback";
|
|
7
8
|
export async function openAICallback(state) {
|
|
@@ -42,7 +43,7 @@ export async function openAICallback(state) {
|
|
|
42
43
|
const authError = incoming.searchParams.get("error_description") ?? incoming.searchParams.get("error");
|
|
43
44
|
const authorizationCode = incoming.searchParams.get("code");
|
|
44
45
|
if (authError !== null) {
|
|
45
|
-
rejectCode(new Error(`ChatGPT sign-in was rejected · ${authError
|
|
46
|
+
rejectCode(new Error(`ChatGPT sign-in was rejected · ${leadingText(authError, 300)}`));
|
|
46
47
|
}
|
|
47
48
|
else if (authorizationCode === null || authorizationCode === "") {
|
|
48
49
|
rejectCode(new Error("ChatGPT sign-in returned no authorization code"));
|
|
@@ -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
|
}
|