@giovannijecha/jecode 0.7.2 → 0.7.3

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 CHANGED
@@ -253,7 +253,7 @@ settings, then built-in defaults.
253
253
  | `--ollama-host` | `OLLAMA_HOST` | Cloud with an Ollama key, local without one |
254
254
  | `--root` | - | Current directory |
255
255
  | `--effort` | `JECODE_EFFORT` | `high` |
256
- | `--max-tokens` | `JECODE_MAX_TOKENS` | `64000`; not sent by `openai-codex` |
256
+ | `--max-tokens` | `JECODE_MAX_TOKENS` | `64000` ceiling, clamped to the usable request budget; not sent by `openai-codex` |
257
257
  | `--max-steps` | `JECODE_MAX_STEPS` | `40` |
258
258
  | `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
259
259
  | `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
@@ -275,8 +275,8 @@ Jecode treats model output, workspace content, tool output, and terminal text as
275
275
  untrusted data.
276
276
 
277
277
  - Current filesystem tools are confined to the selected workspace. Writes
278
- reject symlink and junction components, revalidate boundaries, and use atomic
279
- replacement.
278
+ reject symlink and junction components, revalidate boundaries and the
279
+ approved file state immediately before atomic replacement.
280
280
  - Dangerous tools ask by default unless explicitly allowed for the session or
281
281
  the process starts with `--auto-approve`.
282
282
  - Credential fields are masked and excluded from transcripts. Recognized
@@ -343,8 +343,9 @@ npm run check
343
343
  ```
344
344
 
345
345
  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
346
+ fixtures. `npm run bench:transcript` and `npm run bench:search` provide manual
347
+ probes for long-session rendering and workspace search. Architecture and
348
+ security boundaries are documented in
348
349
  [docs/architecture.md](docs/architecture.md); brand assets and usage rules live
349
350
  in [docs/brand.md](docs/brand.md).
350
351
 
package/dist/batch.js CHANGED
@@ -8,7 +8,7 @@ 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";
@@ -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 },
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,9 +41,6 @@ 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
45
  const windowTokens = validWindow(context?.tokens)
47
46
  ? context.tokens
@@ -52,12 +51,15 @@ export function policyForContextWindow(context, compactionPercent) {
52
51
  const percentageLimit = Math.floor(windowTokens * percent / 100);
53
52
  const providerLimit = validWindow(context?.compactAtTokens)
54
53
  ? context.compactAtTokens
55
- : percentageLimit;
56
- const triggerTokens = Math.min(percentageLimit, providerLimit, windowTokens);
54
+ : windowTokens;
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,6 +109,8 @@ 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) {
@@ -1,16 +1,24 @@
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),
@@ -21,7 +29,9 @@ export async function requestAssistant(history, current, specs, options, events,
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)
@@ -25,32 +25,25 @@ export class ConversationTree {
25
25
  return new ConversationTree([], 0);
26
26
  }
27
27
  static restore(nodes, activeNodeId) {
28
- let tree = ConversationTree.empty();
28
+ if (nodes.length > CONVERSATION_LIMITS.nodes) {
29
+ throw new Error("conversation reached its session limit — start /new");
30
+ }
31
+ const restored = [];
29
32
  for (let index = 0; index < nodes.length; index++) {
30
33
  const node = nodes[index];
31
34
  if (node === undefined || node.id !== index + 1) {
32
35
  throw new Error("session contains a non-sequential conversation node");
33
36
  }
34
- tree = tree.select(node.parentId).commit({
35
- parentId: node.parentId,
36
- createdAt: node.createdAt,
37
- identity: node.identity,
38
- messages: node.messages,
39
- blocks: node.blocks,
40
- ...(node.context === undefined ? {} : { context: node.context }),
41
- ...(node.failure === undefined ? {} : { failure: node.failure }),
42
- }, node.settlement);
43
- const restored = tree.activeNode;
44
- if (restored === undefined || restored.id !== node.id) {
45
- throw new Error("session conversation could not be restored");
46
- }
47
- if (node.revision > 1) {
48
- const copy = [...tree.#nodes];
49
- copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
50
- tree = new ConversationTree(copy, node.id);
51
- }
37
+ const owned = ownedNode({ ...node, blocks: settledBlocks(node.blocks) });
38
+ assertTurn(owned);
39
+ assertPersistableNode(owned);
40
+ restored.push(owned);
52
41
  }
53
- return tree.select(activeNodeId);
42
+ assertBounds(restored);
43
+ if (!validNodeId(activeNodeId) ||
44
+ (activeNodeId !== 0 && restored[activeNodeId - 1]?.id !== activeNodeId))
45
+ throw new Error("conversation node does not exist");
46
+ return new ConversationTree(restored, activeNodeId);
54
47
  }
55
48
  /** Commit or extend the one prospective leaf turn. */
56
49
  commit(draft, settlement) {
@@ -236,12 +229,13 @@ function assertBounds(nodes) {
236
229
  let messageCodeUnits = 0;
237
230
  let transcriptCodeUnits = 0;
238
231
  let contextCodeUnits = 0;
232
+ const contextOwners = [];
239
233
  for (const node of nodes) {
240
234
  messageCodeUnits += JSON.stringify(node.messages).length;
241
235
  transcriptCodeUnits += JSON.stringify(node.blocks).length;
242
236
  contextCodeUnits += node.context?.summary.length ?? 0;
243
237
  if (node.context !== undefined)
244
- assertContextPath(nodes, node);
238
+ contextOwners.push(node);
245
239
  }
246
240
  if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
247
241
  throw new Error("conversation model history reached its session limit — start /new");
@@ -252,18 +246,41 @@ function assertBounds(nodes) {
252
246
  if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
253
247
  throw new Error("conversation context summaries reached their session limit — start /new");
254
248
  }
249
+ assertContextPaths(nodes, contextOwners);
255
250
  }
256
- function assertContextPath(nodes, owner) {
257
- const context = owner.context;
258
- const boundary = nodes[context.throughNodeId - 1];
259
- if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
260
- throw new Error("turn context checkpoint is invalid");
251
+ function assertContextPaths(nodes, owners) {
252
+ if (owners.length === 0)
253
+ return;
254
+ const children = Array.from({ length: nodes.length + 1 }, () => []);
255
+ for (const node of nodes)
256
+ children[node.parentId]?.push(node.id);
257
+ const entered = new Uint32Array(nodes.length + 1);
258
+ const exited = new Uint32Array(nodes.length + 1);
259
+ const stack = [{ id: 0, exit: false }];
260
+ let clock = 0;
261
+ while (stack.length > 0) {
262
+ const current = stack.pop();
263
+ if (current.exit) {
264
+ exited[current.id] = clock++;
265
+ continue;
266
+ }
267
+ entered[current.id] = clock++;
268
+ stack.push({ id: current.id, exit: true });
269
+ const descendants = children[current.id];
270
+ for (let index = descendants.length - 1; index >= 0; index--) {
271
+ stack.push({ id: descendants[index], exit: false });
272
+ }
273
+ }
274
+ for (const owner of owners) {
275
+ const context = owner.context;
276
+ const boundary = nodes[context.throughNodeId - 1];
277
+ if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
278
+ throw new Error("turn context checkpoint is invalid");
279
+ }
280
+ if (entered[boundary.id] > entered[owner.id] ||
281
+ exited[owner.id] > exited[boundary.id])
282
+ throw new Error("turn context checkpoint is outside its branch");
261
283
  }
262
- let id = owner.id;
263
- while (id !== 0 && id !== boundary.id)
264
- id = nodes[id - 1]?.parentId ?? 0;
265
- if (id !== boundary.id)
266
- throw new Error("turn context checkpoint is outside its branch");
267
284
  }
268
285
  function validNodeId(value) {
269
286
  return Number.isSafeInteger(value) && value >= 0;
@@ -80,7 +80,7 @@ export const anthropic = {
80
80
  effort: requireSupportedEffort(req.model, req.effort, efforts),
81
81
  };
82
82
  }
83
- const events = await postSse(ENDPOINT, headers(key), body, req.signal, req.onStatus);
83
+ const events = await postSse(ENDPOINT, headers(key), body, req.maxTokens, req.signal, req.onStatus);
84
84
  const data = await assembleAnthropic(events, req.onStream);
85
85
  // A refusal or a truncation never arrives as streamed text, so it has to
86
86
  // be announced separately or the user watches the turn end in silence.
@@ -2,6 +2,7 @@
2
2
  // event stream. Only idempotent reads retry. Once a POST starts or response
3
3
  // bytes flow, a failure is surfaced rather than silently replayed.
4
4
  import { readSseJson } from "./sse.js";
5
+ import { sseStreamCharacterLimit } from "./stream-limits.js";
5
6
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504]);
6
7
  const MAX_JSON_CHARS = 5_000_000;
7
8
  const MAX_ERROR_CHARS = 2_000;
@@ -33,11 +34,12 @@ async function asJson(url, res) {
33
34
  throw httpError(`${url} returned non-JSON`, res.status, text.slice(0, 500));
34
35
  }
35
36
  }
36
- export async function postSse(url, headers, body, signal, onStatus) {
37
+ export async function postSse(url, headers, body, maxOutputTokens, signal, onStatus) {
38
+ const maximumChars = sseStreamCharacterLimit(maxOutputTokens);
37
39
  const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
38
40
  if (res.body === null)
39
41
  throw httpError(`${url} returned no body`, res.status);
40
- return readSseJson(withIdleTimeout(url, res.body));
42
+ return readSseJson(withIdleTimeout(url, res.body), maximumChars);
41
43
  }
42
44
  async function request(url, headers, body, signal, onStatus) {
43
45
  const maxRetries = body === undefined ? GET_RETRIES : 0;
@@ -88,7 +88,7 @@ export const ollama = {
88
88
  max_tokens: req.maxTokens,
89
89
  reasoning_effort: effort,
90
90
  stream: true,
91
- }, req.signal, req.onStatus);
91
+ }, req.maxTokens, req.signal, req.onStatus);
92
92
  const reply = await assembleOllama(events, req.onStream);
93
93
  const notice = stopNotice(reply);
94
94
  if (notice !== undefined)
@@ -71,7 +71,7 @@ export const openaiCodex = {
71
71
  text: { verbosity: "low" },
72
72
  include: ["reasoning.encrypted_content"],
73
73
  prompt_cache_key: SESSION_ID,
74
- }, req.signal, req.onStatus);
74
+ }, req.maxTokens, req.signal, req.onStatus);
75
75
  const data = await assembleOpenAI(events, req.onStream);
76
76
  const notice = stopNotice(data);
77
77
  if (notice !== undefined)
@@ -86,7 +86,7 @@ export const openai = {
86
86
  store: false,
87
87
  include: ["reasoning.encrypted_content"],
88
88
  stream: true,
89
- }, req.signal, req.onStatus);
89
+ }, req.maxTokens, req.signal, req.onStatus);
90
90
  const data = await assembleOpenAI(events, req.onStream);
91
91
  const notice = stopNotice(data);
92
92
  if (notice !== undefined)
@@ -3,15 +3,15 @@
3
3
  // The format is small: `field: value` lines, a blank line ends an event. Only
4
4
  // `data` matters here — both providers put the event discriminator inside the
5
5
  // JSON payload, so the `event:` line is redundant and skipped.
6
- import { addBounded, MAX_SSE_EVENT_CHARS, MAX_SSE_STREAM_CHARS, } from "./stream-limits.js";
7
- export async function* readSseJson(body) {
6
+ import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
+ export async function* readSseJson(body, maximumChars) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
10
  let buffer = "";
11
11
  let finished = false;
12
12
  let total = 0;
13
13
  const append = (text) => {
14
- total = addBounded(total, text.length, MAX_SSE_STREAM_CHARS, "SSE stream");
14
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
15
15
  buffer += text;
16
16
  };
17
17
  try {
@@ -1,8 +1,20 @@
1
1
  // Response streams are remote input. Bound the pieces that otherwise grow
2
- // independently of the request's output-token setting.
2
+ // independently, while letting a larger requested output carry its necessarily
3
+ // larger framing, terminal envelope, and opaque reasoning payloads.
3
4
  export const MAX_SSE_EVENT_CHARS = 1_000_000;
4
- export const MAX_SSE_STREAM_CHARS = 4_000_000;
5
+ export const MAX_SSE_STREAM_CHARS = 256_000_000;
5
6
  export const MAX_TOOL_ARGUMENT_CHARS = 1_000_000;
7
+ const MIN_SSE_STREAM_CHARS = 4_000_000;
8
+ const SSE_CHARS_PER_OUTPUT_TOKEN = 512;
9
+ export function sseStreamCharacterLimit(maxOutputTokens) {
10
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
11
+ throw new Error("max output tokens must be a positive safe integer");
12
+ }
13
+ const scaled = maxOutputTokens > Math.floor(MAX_SSE_STREAM_CHARS / SSE_CHARS_PER_OUTPUT_TOKEN)
14
+ ? MAX_SSE_STREAM_CHARS
15
+ : maxOutputTokens * SSE_CHARS_PER_OUTPUT_TOKEN;
16
+ return Math.max(MIN_SSE_STREAM_CHARS, scaled);
17
+ }
6
18
  export function addBounded(total, added, maximum, label) {
7
19
  if (added > maximum - total) {
8
20
  throw new Error(`${label} exceeded ${maximum} characters`);