@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.
Files changed (43) hide show
  1. package/README.md +87 -23
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +26 -19
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +3 -2
  6. package/dist/context/budget.js +37 -0
  7. package/dist/context/compactor.js +9 -2
  8. package/dist/context/estimate.js +31 -0
  9. package/dist/context/policy.js +24 -14
  10. package/dist/controller-request.js +27 -4
  11. package/dist/controller.js +6 -4
  12. package/dist/conversation.js +48 -31
  13. package/dist/oauth-http.js +2 -1
  14. package/dist/openai-oauth-callback.js +2 -1
  15. package/dist/providers/anthropic.js +1 -1
  16. package/dist/providers/http.js +8 -5
  17. package/dist/providers/ollama.js +1 -1
  18. package/dist/providers/openai-codex.js +1 -3
  19. package/dist/providers/openai.js +1 -1
  20. package/dist/providers/sse.js +117 -40
  21. package/dist/providers/stream-limits.js +14 -2
  22. package/dist/sessions/store.js +2 -1
  23. package/dist/text-boundary.js +47 -0
  24. package/dist/timeline.js +2 -1
  25. package/dist/tools/fs.js +85 -38
  26. package/dist/tools/search.js +106 -44
  27. package/dist/tools/shell.js +14 -6
  28. package/dist/tools/text-boundary.js +7 -25
  29. package/dist/tui/app-state.js +0 -1
  30. package/dist/tui/app-workflows.js +40 -30
  31. package/dist/tui/app.js +12 -14
  32. package/dist/tui/blocks.js +0 -2
  33. package/dist/tui/components/messages.js +2 -5
  34. package/dist/tui/components/tool.js +11 -10
  35. package/dist/tui/session-view.js +2 -1
  36. package/dist/tui/transcript-view.js +178 -106
  37. package/dist/tui/turn.js +29 -8
  38. package/dist/tui/view.js +8 -3
  39. package/dist/ui/diff.js +51 -16
  40. package/dist/ui/render.js +16 -24
  41. package/dist/ui/width.js +31 -22
  42. package/dist/usage.js +5 -1
  43. 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 saved conversation for the current workspace with a searchable picker:
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
- Run `jecode --help` for every startup option. Windows, Ubuntu, and macOS are
82
- covered by the project test matrix.
104
+ Resume the most recently updated conversation directly:
83
105
 
84
- ### Prereleases
106
+ ```console
107
+ jecode resume --latest
108
+ ```
85
109
 
86
- The stable npm package is the supported installation artifact. To try an
87
- opt-in prerelease:
110
+ List every startup option:
88
111
 
89
112
  ```console
90
- npm install --global @giovannijecha/jecode@next
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
- ### Update and uninstall
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
- If an older GitHub installation still owns the `jecode` executable, remove the
109
- legacy unscoped package before installing the scoped package:
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
- npm install --global @giovannijecha/jecode
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
- npm install --global @giovannijecha/jecode
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`; not sent by `openai-codex` |
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, and use atomic
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, and `npm run bench:transcript` for a manual long-session rendering
347
- probe. Architecture and security boundaries are documented in
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, shouldResolveContextPolicy } from "./context/policy.js";
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
- let contextPolicy;
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, reason, error) => {
70
- if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
71
- return undefined;
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
- contextPolicy ??= resolveContextPolicy({
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: await contextPolicy,
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, "budget");
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 disable animated terminal states
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.isInteger(n) || n <= 0)
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: normalized(plan.prefix),
30
+ messages,
24
31
  tools: [],
25
- maxTokens: policy.summaryMaxTokens,
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
+ }
@@ -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 = validWindow(context?.tokens)
47
- ? context.tokens
48
- : FALLBACK_CONTEXT_WINDOW_TOKENS;
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 = validWindow(context?.compactAtTokens)
54
- ? context.compactAtTokens
55
- : percentageLimit;
56
- const triggerTokens = Math.min(percentageLimit, providerLimit, windowTokens);
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
- const bytes = Buffer.byteLength(JSON.stringify(messages), "utf8");
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
- return value !== undefined && Number.isSafeInteger(value) && value >= 4_096 && value <= 10_000_000;
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
- const prepared = await events.onContext?.(history, current, "budget");
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: options.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
- const projected = await events.onContext?.(history, context, "overflow", error);
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
  }
@@ -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);