@giovannijecha/jecode 0.7.2 → 0.7.4
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 +87 -23
- package/dist/atomic.js +14 -0
- package/dist/batch.js +26 -19
- package/dist/cli-info.js +1 -1
- package/dist/config.js +3 -2
- package/dist/context/budget.js +37 -0
- package/dist/context/compactor.js +9 -2
- package/dist/context/estimate.js +31 -0
- package/dist/context/policy.js +24 -14
- package/dist/controller-request.js +27 -4
- package/dist/controller.js +6 -4
- package/dist/conversation.js +48 -31
- package/dist/oauth-http.js +2 -1
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +8 -5
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -3
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +117 -40
- package/dist/providers/stream-limits.js +14 -2
- package/dist/sessions/store.js +2 -1
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/fs.js +85 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +7 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +40 -30
- package/dist/tui/app.js +12 -14
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/messages.js +2 -5
- package/dist/tui/components/tool.js +11 -10
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/turn.js +29 -8
- package/dist/tui/view.js +8 -3
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +16 -24
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -53,10 +53,32 @@
|
|
|
53
53
|
|
|
54
54
|
Jecode requires **Node.js 22.18+ on the 22.x line, or Node.js 24+**, and npm.
|
|
55
55
|
|
|
56
|
+
### Install or update
|
|
57
|
+
|
|
58
|
+
Install the current stable release. Running the same command again updates an
|
|
59
|
+
existing installation:
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
npm install --global @giovannijecha/jecode@latest
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Confirm the installed version:
|
|
66
|
+
|
|
56
67
|
```console
|
|
57
|
-
npm install --global @giovannijecha/jecode
|
|
58
68
|
jecode --version
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Start Jecode
|
|
72
|
+
|
|
73
|
+
Open the project you want Jecode to work on:
|
|
74
|
+
|
|
75
|
+
```console
|
|
59
76
|
cd path/to/your/project
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then start Jecode:
|
|
80
|
+
|
|
81
|
+
```console
|
|
60
82
|
jecode
|
|
61
83
|
```
|
|
62
84
|
|
|
@@ -71,33 +93,41 @@ change to improve startup performance.
|
|
|
71
93
|
Use `jecode --root path/to/project` to select another workspace, or
|
|
72
94
|
`jecode --ephemeral` when the conversation must stay memory-only.
|
|
73
95
|
|
|
74
|
-
Resume a
|
|
96
|
+
### Resume a conversation
|
|
97
|
+
|
|
98
|
+
Open the searchable resume picker for the current workspace:
|
|
75
99
|
|
|
76
100
|
```console
|
|
77
101
|
jecode resume
|
|
78
|
-
jecode resume --latest
|
|
79
102
|
```
|
|
80
103
|
|
|
81
|
-
|
|
82
|
-
covered by the project test matrix.
|
|
104
|
+
Resume the most recently updated conversation directly:
|
|
83
105
|
|
|
84
|
-
|
|
106
|
+
```console
|
|
107
|
+
jecode resume --latest
|
|
108
|
+
```
|
|
85
109
|
|
|
86
|
-
|
|
87
|
-
opt-in prerelease:
|
|
110
|
+
List every startup option:
|
|
88
111
|
|
|
89
112
|
```console
|
|
90
|
-
|
|
113
|
+
jecode --help
|
|
91
114
|
```
|
|
92
115
|
|
|
116
|
+
Windows, Ubuntu, and macOS are covered by the project test matrix.
|
|
117
|
+
|
|
118
|
+
### Prereleases
|
|
119
|
+
|
|
120
|
+
Prereleases exist only during an announced release-candidate cycle. When one is
|
|
121
|
+
active, its GitHub release provides the exact installation command. Outside an
|
|
122
|
+
active cycle, the stable npm package is the only supported installation
|
|
123
|
+
artifact.
|
|
124
|
+
|
|
93
125
|
Git URL installs are intentionally unsupported: the source tree contains no
|
|
94
126
|
generated runtime and defines no install-time build hook.
|
|
95
127
|
|
|
96
|
-
###
|
|
128
|
+
### Uninstall
|
|
97
129
|
|
|
98
130
|
```console
|
|
99
|
-
npm install --global @giovannijecha/jecode
|
|
100
|
-
jecode --version
|
|
101
131
|
npm uninstall --global @giovannijecha/jecode
|
|
102
132
|
```
|
|
103
133
|
|
|
@@ -105,12 +135,19 @@ Uninstalling the command preserves `~/.jecode`. Remove that directory only when
|
|
|
105
135
|
you intentionally want to erase saved settings, credentials, accounts, and
|
|
106
136
|
sessions.
|
|
107
137
|
|
|
108
|
-
|
|
109
|
-
|
|
138
|
+
### Replace a legacy installation
|
|
139
|
+
|
|
140
|
+
If an older GitHub installation still owns the `jecode` executable, first
|
|
141
|
+
remove the legacy unscoped package:
|
|
110
142
|
|
|
111
143
|
```console
|
|
112
144
|
npm uninstall --global jecode
|
|
113
|
-
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Then install the current scoped package:
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
npm install --global @giovannijecha/jecode@latest
|
|
114
151
|
```
|
|
115
152
|
|
|
116
153
|
Do not work around the resulting `EEXIST` error with `--force`.
|
|
@@ -119,14 +156,39 @@ Do not work around the resulting `EEXIST` error with `--force`.
|
|
|
119
156
|
|
|
120
157
|
WSL uses its own Node.js installation and `PATH`; the Node.js version installed
|
|
121
158
|
on Windows does not apply inside it. Keep user-installed npm commands in the
|
|
122
|
-
Linux user path:
|
|
159
|
+
Linux user path. Set the user-level npm prefix:
|
|
123
160
|
|
|
124
161
|
```console
|
|
125
162
|
npm config set prefix "$HOME/.local"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Add it to the current shell's `PATH`:
|
|
166
|
+
|
|
167
|
+
```console
|
|
126
168
|
export PATH="$HOME/.local/bin:$PATH"
|
|
127
|
-
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Install or update Jecode:
|
|
172
|
+
|
|
173
|
+
```console
|
|
174
|
+
npm install --global @giovannijecha/jecode@latest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Refresh the command cache:
|
|
178
|
+
|
|
179
|
+
```console
|
|
128
180
|
hash -r
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Confirm which executable will run:
|
|
184
|
+
|
|
185
|
+
```console
|
|
129
186
|
command -v jecode
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Verify the installed version:
|
|
190
|
+
|
|
191
|
+
```console
|
|
130
192
|
jecode --version
|
|
131
193
|
```
|
|
132
194
|
|
|
@@ -253,7 +315,7 @@ settings, then built-in defaults.
|
|
|
253
315
|
| `--ollama-host` | `OLLAMA_HOST` | Cloud with an Ollama key, local without one |
|
|
254
316
|
| `--root` | - | Current directory |
|
|
255
317
|
| `--effort` | `JECODE_EFFORT` | `high` |
|
|
256
|
-
| `--max-tokens` | `JECODE_MAX_TOKENS` | `64000
|
|
318
|
+
| `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
|
|
257
319
|
| `--max-steps` | `JECODE_MAX_STEPS` | `40` |
|
|
258
320
|
| `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
|
|
259
321
|
| `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
|
|
@@ -275,8 +337,8 @@ Jecode treats model output, workspace content, tool output, and terminal text as
|
|
|
275
337
|
untrusted data.
|
|
276
338
|
|
|
277
339
|
- Current filesystem tools are confined to the selected workspace. Writes
|
|
278
|
-
reject symlink and junction components, revalidate boundaries
|
|
279
|
-
replacement.
|
|
340
|
+
reject symlink and junction components, revalidate boundaries and the
|
|
341
|
+
approved file state immediately before atomic replacement.
|
|
280
342
|
- Dangerous tools ask by default unless explicitly allowed for the session or
|
|
281
343
|
the process starts with `--auto-approve`.
|
|
282
344
|
- Credential fields are masked and excluded from transcripts. Recognized
|
|
@@ -343,10 +405,12 @@ npm run check
|
|
|
343
405
|
```
|
|
344
406
|
|
|
345
407
|
Use `npm run tui:lab` to inspect production TUI components with inert local
|
|
346
|
-
fixtures
|
|
347
|
-
|
|
408
|
+
fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
|
|
409
|
+
probes for long-session rendering and workspace search. Architecture and
|
|
410
|
+
security boundaries are documented in
|
|
348
411
|
[docs/architecture.md](docs/architecture.md); brand assets and usage rules live
|
|
349
|
-
in [docs/brand.md](docs/brand.md).
|
|
412
|
+
in [docs/brand.md](docs/brand.md). The maintainer release procedure lives in
|
|
413
|
+
[docs/releasing.md](docs/releasing.md).
|
|
350
414
|
|
|
351
415
|
## Community
|
|
352
416
|
|
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
|
@@ -8,12 +8,12 @@ import { runTurn } from "./controller.js";
|
|
|
8
8
|
import { resolveContextPolicy } from "./context/capacity.js";
|
|
9
9
|
import { compactContext } from "./context/compactor.js";
|
|
10
10
|
import { compactSession } from "./context/manual.js";
|
|
11
|
-
import { isContextOverflow
|
|
11
|
+
import { isContextOverflow } from "./context/policy.js";
|
|
12
12
|
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;
|
|
@@ -45,7 +45,13 @@ export async function runBatch(session, environment = {}) {
|
|
|
45
45
|
const prospectiveNodeId = session.conversation.nodes.length + 1;
|
|
46
46
|
let nodeId;
|
|
47
47
|
let context;
|
|
48
|
-
|
|
48
|
+
const policy = () => {
|
|
49
|
+
return resolveContextPolicy({
|
|
50
|
+
provider: session.provider,
|
|
51
|
+
model: session.model,
|
|
52
|
+
compactionPercent: session.config.compactionPercent,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
49
55
|
const user = { role: "user", content: [{ kind: "text", text: line }] };
|
|
50
56
|
history.push(user);
|
|
51
57
|
modelHistory.push(structuredClone(user));
|
|
@@ -66,19 +72,12 @@ export async function runBatch(session, environment = {}) {
|
|
|
66
72
|
}, settlement);
|
|
67
73
|
nodeId = session.conversation.activeNodeId;
|
|
68
74
|
};
|
|
69
|
-
const compact = async (checkpoint, projected,
|
|
70
|
-
if (reason === "overflow" &&
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
const force = reason === "overflow";
|
|
74
|
-
if (!shouldResolveContextPolicy(projected, session.usage.lastInputTokens, force)) {
|
|
75
|
+
const compact = async (checkpoint, projected, request) => {
|
|
76
|
+
if (request.reason === "overflow" &&
|
|
77
|
+
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
75
78
|
return undefined;
|
|
76
79
|
}
|
|
77
|
-
|
|
78
|
-
provider: session.provider,
|
|
79
|
-
model: session.model,
|
|
80
|
-
compactionPercent: session.config.compactionPercent,
|
|
81
|
-
});
|
|
80
|
+
const force = request.reason === "overflow";
|
|
82
81
|
const result = await compactContext({
|
|
83
82
|
provider: session.provider,
|
|
84
83
|
model: session.model,
|
|
@@ -87,9 +86,9 @@ export async function runBatch(session, environment = {}) {
|
|
|
87
86
|
turn: checkpoint.slice(before),
|
|
88
87
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
89
88
|
coveredMessages: context?.messageCount ?? 0,
|
|
90
|
-
lastInputTokens: session.usage.lastInputTokens,
|
|
89
|
+
lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
|
|
91
90
|
force,
|
|
92
|
-
policy:
|
|
91
|
+
policy: request.policy,
|
|
93
92
|
});
|
|
94
93
|
if (result === undefined)
|
|
95
94
|
return undefined;
|
|
@@ -101,12 +100,16 @@ export async function runBatch(session, environment = {}) {
|
|
|
101
100
|
turn.onContext = compact;
|
|
102
101
|
turn.onCheckpoint = async (checkpoint, settlement, projected) => {
|
|
103
102
|
commit(checkpoint, settlement);
|
|
104
|
-
const compacted = await compact(checkpoint, projected,
|
|
103
|
+
const compacted = await compact(checkpoint, projected, {
|
|
104
|
+
reason: "budget",
|
|
105
|
+
policy: await policy(),
|
|
106
|
+
inputTokens: session.usage.lastInputTokens,
|
|
107
|
+
});
|
|
105
108
|
if (compacted !== undefined)
|
|
106
109
|
commit(checkpoint, settlement);
|
|
107
110
|
return compacted;
|
|
108
111
|
};
|
|
109
|
-
await runTurn(history, options(session), turn, undefined, modelHistory);
|
|
112
|
+
await runTurn(history, options(session, policy), turn, undefined, modelHistory);
|
|
110
113
|
turn.flush();
|
|
111
114
|
}
|
|
112
115
|
}
|
|
@@ -114,13 +117,14 @@ export async function runBatch(session, environment = {}) {
|
|
|
114
117
|
rl?.close();
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
|
-
function options(session) {
|
|
120
|
+
function options(session, contextPolicy) {
|
|
118
121
|
return {
|
|
119
122
|
provider: session.provider,
|
|
120
123
|
tools: session.tools,
|
|
121
124
|
model: session.model,
|
|
122
125
|
system: session.system,
|
|
123
126
|
maxTokens: session.config.maxTokens,
|
|
127
|
+
contextPolicy,
|
|
124
128
|
effort: session.config.effort,
|
|
125
129
|
maxSteps: session.config.maxSteps,
|
|
126
130
|
toolContext: { root: session.config.root },
|
|
@@ -163,5 +167,8 @@ function events(emit, session) {
|
|
|
163
167
|
onUsage(usage) {
|
|
164
168
|
recordUsage(session.usage, usage);
|
|
165
169
|
},
|
|
170
|
+
onRequestInput(inputTokens) {
|
|
171
|
+
recordRequestInput(session.usage, inputTokens);
|
|
172
|
+
},
|
|
166
173
|
};
|
|
167
174
|
}
|
package/dist/cli-info.js
CHANGED
|
@@ -18,7 +18,7 @@ Options:
|
|
|
18
18
|
--max-steps <number> tool-loop ceiling
|
|
19
19
|
--compaction-percent <${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT}>
|
|
20
20
|
context usage that triggers compaction (default: ${DEFAULT_COMPACTION_PERCENT})
|
|
21
|
-
--reduced-motion
|
|
21
|
+
--reduced-motion use a steady terminal cursor
|
|
22
22
|
--auto-approve allow dangerous tools for this process
|
|
23
23
|
--ephemeral do not save this conversation
|
|
24
24
|
--latest resume the newest session without a picker
|
package/dist/config.js
CHANGED
|
@@ -69,8 +69,9 @@ function optional(flag, env, fallback) {
|
|
|
69
69
|
}
|
|
70
70
|
function toInt(value, name) {
|
|
71
71
|
const n = Number(value);
|
|
72
|
-
if (!Number.
|
|
73
|
-
throw new Error(`--${name} must be a positive integer`);
|
|
72
|
+
if (!Number.isSafeInteger(n) || n <= 0) {
|
|
73
|
+
throw new Error(`--${name} must be a positive safe integer`);
|
|
74
|
+
}
|
|
74
75
|
return n;
|
|
75
76
|
}
|
|
76
77
|
function toPercent(value) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Conservative provider-neutral budgeting for one complete model request.
|
|
2
|
+
import { estimateSerializedTokens } from "./estimate.js";
|
|
3
|
+
import { MIN_REQUEST_OUTPUT_TOKENS } from "./policy.js";
|
|
4
|
+
const ENVELOPE_OVERHEAD_TOKENS = 64;
|
|
5
|
+
const MESSAGE_OVERHEAD_TOKENS = 8;
|
|
6
|
+
const TOOL_OVERHEAD_TOKENS = 16;
|
|
7
|
+
/** Include system text and tool schemas instead of measuring conversation alone. */
|
|
8
|
+
export function estimateRequestInputTokens(envelope) {
|
|
9
|
+
const contentTokens = estimateSerializedTokens({
|
|
10
|
+
system: envelope.system,
|
|
11
|
+
messages: envelope.messages,
|
|
12
|
+
tools: envelope.tools,
|
|
13
|
+
});
|
|
14
|
+
return contentTokens +
|
|
15
|
+
ENVELOPE_OVERHEAD_TOKENS +
|
|
16
|
+
envelope.messages.length * MESSAGE_OVERHEAD_TOKENS +
|
|
17
|
+
envelope.tools.length * TOOL_OVERHEAD_TOKENS;
|
|
18
|
+
}
|
|
19
|
+
/** Clamp the configured output ceiling so the complete request remains usable. */
|
|
20
|
+
export function budgetRequest(envelope, configuredMaxOutputTokens, policy) {
|
|
21
|
+
if (!Number.isSafeInteger(configuredMaxOutputTokens) || configuredMaxOutputTokens <= 0) {
|
|
22
|
+
throw new Error("max output tokens must be a positive safe integer");
|
|
23
|
+
}
|
|
24
|
+
const inputTokens = estimateRequestInputTokens(envelope);
|
|
25
|
+
const available = policy.requestLimitTokens - inputTokens;
|
|
26
|
+
const minimum = Math.min(configuredMaxOutputTokens, MIN_REQUEST_OUTPUT_TOKENS);
|
|
27
|
+
if (available < minimum) {
|
|
28
|
+
throw new Error(`request input needs approximately ${inputTokens} tokens, leaving ` +
|
|
29
|
+
`${Math.max(0, available)} of the ${policy.requestLimitTokens}-token safe request budget; ` +
|
|
30
|
+
`at least ${minimum} output tokens are required`);
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
inputTokens,
|
|
34
|
+
maxOutputTokens: Math.min(configuredMaxOutputTokens, available),
|
|
35
|
+
limitTokens: policy.requestLimitTokens,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// One bounded provider request that condenses an older context prefix.
|
|
2
|
+
import { budgetRequest } from "./budget.js";
|
|
2
3
|
import { CONTEXT_LIMITS, summaryMessage } from "./projection.js";
|
|
3
4
|
import { planCompaction } from "./policy.js";
|
|
4
5
|
const SUMMARY_SYSTEM = [
|
|
@@ -17,12 +18,18 @@ export async function compactContext(options) {
|
|
|
17
18
|
return undefined;
|
|
18
19
|
options.onBegin?.();
|
|
19
20
|
try {
|
|
21
|
+
const messages = normalized(plan.prefix);
|
|
22
|
+
const budget = budgetRequest({
|
|
23
|
+
system: SUMMARY_SYSTEM,
|
|
24
|
+
messages,
|
|
25
|
+
tools: [],
|
|
26
|
+
}, policy.summaryMaxTokens, policy);
|
|
20
27
|
const response = await options.provider.send({
|
|
21
28
|
model: options.model,
|
|
22
29
|
system: SUMMARY_SYSTEM,
|
|
23
|
-
messages
|
|
30
|
+
messages,
|
|
24
31
|
tools: [],
|
|
25
|
-
maxTokens:
|
|
32
|
+
maxTokens: budget.maxOutputTokens,
|
|
26
33
|
effort: options.effort,
|
|
27
34
|
signal: options.signal,
|
|
28
35
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One provider-neutral estimate shared by compaction and request budgeting.
|
|
2
|
+
import { constants, deflateRawSync } from "node:zlib";
|
|
3
|
+
const BYTES_PER_TOKEN = 3;
|
|
4
|
+
const COMPRESSED_BYTES_PER_TOKEN = 5 / 6;
|
|
5
|
+
export function estimateSerializedTokens(value) {
|
|
6
|
+
const serialized = Buffer.from(JSON.stringify(value), "utf8");
|
|
7
|
+
const compressedBytes = deflateRawSync(serialized, {
|
|
8
|
+
level: constants.Z_BEST_SPEED,
|
|
9
|
+
}).byteLength;
|
|
10
|
+
const literalTokens = literalTokenFloor(serialized);
|
|
11
|
+
// Normal prose and source code retain the established byte floor. Data that
|
|
12
|
+
// compresses poorly approaches the byte-fallback ceiling used by modern
|
|
13
|
+
// tokenizers. Non-ASCII bytes and punctuation retain a separate literal
|
|
14
|
+
// floor, including when repeated input compresses unusually well.
|
|
15
|
+
return Math.min(serialized.byteLength, Math.max(Math.ceil(serialized.byteLength / BYTES_PER_TOKEN), Math.ceil(compressedBytes / COMPRESSED_BYTES_PER_TOKEN), literalTokens));
|
|
16
|
+
}
|
|
17
|
+
function literalTokenFloor(serialized) {
|
|
18
|
+
let compactableAscii = 0;
|
|
19
|
+
let literalBytes = 0;
|
|
20
|
+
for (const byte of serialized) {
|
|
21
|
+
if ((byte >= 48 && byte <= 57) ||
|
|
22
|
+
(byte >= 65 && byte <= 90) ||
|
|
23
|
+
(byte >= 97 && byte <= 122)) {
|
|
24
|
+
compactableAscii++;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
literalBytes++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return literalBytes + Math.ceil(compactableAscii / BYTES_PER_TOKEN);
|
|
31
|
+
}
|
package/dist/context/policy.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Provider-neutral context pressure and safe compaction boundaries.
|
|
2
|
+
import { estimateSerializedTokens } from "./estimate.js";
|
|
2
3
|
export const DEFAULT_COMPACTION_PERCENT = 85;
|
|
3
4
|
export const MIN_COMPACTION_PERCENT = 50;
|
|
4
5
|
export const MAX_COMPACTION_PERCENT = 95;
|
|
5
|
-
export const CONTEXT_CAPACITY_PROBE_TOKENS = 16_000;
|
|
6
6
|
export const FALLBACK_CONTEXT_WINDOW_TOKENS = 200_000;
|
|
7
|
+
export const REQUEST_ESTIMATE_HEADROOM_PERCENT = 5;
|
|
8
|
+
export const MIN_REQUEST_OUTPUT_TOKENS = 256;
|
|
7
9
|
export function planCompaction(context, turn, coveredMessages, lastInputTokens, force, policy) {
|
|
8
10
|
if (!validPolicy(policy) || coveredMessages < 0 || coveredMessages > turn.length)
|
|
9
11
|
return undefined;
|
|
@@ -39,25 +41,25 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
|
|
|
39
41
|
messageCount: boundary,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
|
-
export function shouldResolveContextPolicy(context, lastInputTokens, force = false) {
|
|
43
|
-
return force || Math.max(estimateTokens(context), lastInputTokens) >= CONTEXT_CAPACITY_PROBE_TOKENS;
|
|
44
|
-
}
|
|
45
44
|
export function policyForContextWindow(context, compactionPercent) {
|
|
46
|
-
const windowTokens =
|
|
47
|
-
?
|
|
48
|
-
:
|
|
45
|
+
const windowTokens = context === undefined
|
|
46
|
+
? FALLBACK_CONTEXT_WINDOW_TOKENS
|
|
47
|
+
: requireWindow(context.tokens);
|
|
49
48
|
const percent = validPercent(compactionPercent)
|
|
50
49
|
? compactionPercent
|
|
51
50
|
: DEFAULT_COMPACTION_PERCENT;
|
|
52
51
|
const percentageLimit = Math.floor(windowTokens * percent / 100);
|
|
53
|
-
const providerLimit =
|
|
54
|
-
?
|
|
55
|
-
:
|
|
56
|
-
const
|
|
52
|
+
const providerLimit = context?.compactAtTokens === undefined
|
|
53
|
+
? windowTokens
|
|
54
|
+
: requireWindow(context.compactAtTokens);
|
|
55
|
+
const requestLimitTokens = Math.floor(Math.min(providerLimit, windowTokens) * (100 - REQUEST_ESTIMATE_HEADROOM_PERCENT) / 100);
|
|
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)));
|
|
58
58
|
const recentTokens = Math.max(256, Math.min(Math.floor(windowTokens / 8), Math.floor(targetTokens / 2)));
|
|
59
59
|
const minimumPrefixTokens = Math.max(256, Math.min(Math.floor(windowTokens / 20), Math.floor(targetTokens / 2)));
|
|
60
60
|
return Object.freeze({
|
|
61
|
+
windowTokens,
|
|
62
|
+
requestLimitTokens,
|
|
61
63
|
triggerTokens,
|
|
62
64
|
targetTokens,
|
|
63
65
|
recentTokens,
|
|
@@ -66,8 +68,7 @@ export function policyForContextWindow(context, compactionPercent) {
|
|
|
66
68
|
});
|
|
67
69
|
}
|
|
68
70
|
export function estimateTokens(messages) {
|
|
69
|
-
|
|
70
|
-
return Math.ceil(bytes / 3) + messages.length * 8;
|
|
71
|
+
return estimateSerializedTokens(messages) + messages.length * 8;
|
|
71
72
|
}
|
|
72
73
|
export function isContextOverflow(error) {
|
|
73
74
|
const candidate = error;
|
|
@@ -108,10 +109,19 @@ function safeBoundary(turn, index) {
|
|
|
108
109
|
}
|
|
109
110
|
function validPolicy(policy) {
|
|
110
111
|
return Object.values(policy).every((value) => Number.isSafeInteger(value) && value > 0) &&
|
|
112
|
+
policy.requestLimitTokens <= policy.windowTokens &&
|
|
113
|
+
policy.triggerTokens <= policy.requestLimitTokens &&
|
|
111
114
|
policy.targetTokens < policy.triggerTokens && policy.recentTokens < policy.triggerTokens;
|
|
112
115
|
}
|
|
113
116
|
function validWindow(value) {
|
|
114
|
-
|
|
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}`);
|
|
115
125
|
}
|
|
116
126
|
function validPercent(value) {
|
|
117
127
|
return Number.isSafeInteger(value) &&
|
|
@@ -1,27 +1,37 @@
|
|
|
1
1
|
// One streamed provider request with a single safe context-overflow recovery.
|
|
2
|
+
import { budgetRequest, estimateRequestInputTokens } from "./context/budget.js";
|
|
3
|
+
import { isContextOverflow } from "./context/policy.js";
|
|
2
4
|
export async function requestAssistant(history, current, specs, options, events, signal) {
|
|
3
|
-
|
|
5
|
+
let policy = await options.contextPolicy();
|
|
6
|
+
const prepared = await prepareContext(history, current, specs, options, events, policy, "budget");
|
|
4
7
|
let context = prepared === undefined ? [...current] : clone(prepared);
|
|
5
8
|
let recovered = false;
|
|
6
9
|
for (;;) {
|
|
10
|
+
const budget = budgetRequest({
|
|
11
|
+
system: options.system,
|
|
12
|
+
messages: context,
|
|
13
|
+
tools: specs,
|
|
14
|
+
}, options.maxTokens, policy);
|
|
7
15
|
try {
|
|
8
16
|
const message = await options.provider.send({
|
|
9
17
|
model: options.model,
|
|
10
18
|
system: options.system,
|
|
11
19
|
messages: context,
|
|
12
20
|
tools: specs,
|
|
13
|
-
maxTokens:
|
|
21
|
+
maxTokens: budget.maxOutputTokens,
|
|
14
22
|
effort: options.effort,
|
|
15
23
|
signal,
|
|
16
24
|
onStream: (event) => events.onStream(event),
|
|
17
25
|
onStatus: (status) => events.onStatus?.(status),
|
|
18
26
|
});
|
|
19
|
-
return { message, context };
|
|
27
|
+
return { message, context, inputTokens: budget.inputTokens };
|
|
20
28
|
}
|
|
21
29
|
catch (error) {
|
|
22
30
|
if (recovered)
|
|
23
31
|
throw error;
|
|
24
|
-
|
|
32
|
+
if (isContextOverflow(error))
|
|
33
|
+
policy = await options.contextPolicy();
|
|
34
|
+
const projected = await prepareContext(history, context, specs, options, events, policy, "overflow", error);
|
|
25
35
|
if (projected === undefined)
|
|
26
36
|
throw error;
|
|
27
37
|
context = clone(projected);
|
|
@@ -29,6 +39,19 @@ export async function requestAssistant(history, current, specs, options, events,
|
|
|
29
39
|
}
|
|
30
40
|
}
|
|
31
41
|
}
|
|
42
|
+
async function prepareContext(history, context, specs, options, events, policy, reason, error) {
|
|
43
|
+
const inputTokens = estimateRequestInputTokens({
|
|
44
|
+
system: options.system,
|
|
45
|
+
messages: context,
|
|
46
|
+
tools: specs,
|
|
47
|
+
});
|
|
48
|
+
return events.onContext?.(history, context, {
|
|
49
|
+
reason,
|
|
50
|
+
policy,
|
|
51
|
+
inputTokens,
|
|
52
|
+
...(error === undefined ? {} : { error }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
32
55
|
function clone(messages) {
|
|
33
56
|
return structuredClone([...messages]);
|
|
34
57
|
}
|
package/dist/controller.js
CHANGED
|
@@ -17,6 +17,7 @@ export const MAX_CONCURRENT_TOOL_CALLS = 4;
|
|
|
17
17
|
export async function runTurn(history, options, events, signal, modelHistory = history) {
|
|
18
18
|
const specs = toolSpecs(options.tools);
|
|
19
19
|
let context = modelHistory;
|
|
20
|
+
throwIfAborted(signal);
|
|
20
21
|
const append = (message) => {
|
|
21
22
|
history.push(message);
|
|
22
23
|
if (context !== history)
|
|
@@ -44,10 +45,13 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
44
45
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
45
46
|
}
|
|
46
47
|
assertToolCallIds(calls);
|
|
48
|
+
if (assistant.usage !== undefined)
|
|
49
|
+
events.onUsage?.(assistant.usage);
|
|
50
|
+
events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
|
|
51
|
+
? assistant.usage.inputTokens
|
|
52
|
+
: response.inputTokens);
|
|
47
53
|
append(assistant);
|
|
48
54
|
if (calls.length === 0) {
|
|
49
|
-
if (assistant.usage !== undefined)
|
|
50
|
-
events.onUsage?.(assistant.usage);
|
|
51
55
|
await checkpoint("completed");
|
|
52
56
|
return; // the model is done — hand back to the user
|
|
53
57
|
}
|
|
@@ -57,8 +61,6 @@ export async function runTurn(history, options, events, signal, modelHistory = h
|
|
|
57
61
|
const results = [];
|
|
58
62
|
const announced = new Set();
|
|
59
63
|
try {
|
|
60
|
-
if (assistant.usage !== undefined)
|
|
61
|
-
events.onUsage?.(assistant.usage);
|
|
62
64
|
while (results.length < calls.length) {
|
|
63
65
|
const start = results.length;
|
|
64
66
|
const batch = nextBatch(calls, start, options.tools);
|