@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 +6 -5
- package/dist/batch.js +22 -18
- 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 +11 -8
- package/dist/controller-request.js +26 -3
- package/dist/controller.js +1 -0
- package/dist/conversation.js +48 -31
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +4 -2
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +3 -3
- package/dist/providers/stream-limits.js +14 -2
- package/dist/tools/fs.js +80 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +37 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +38 -29
- package/dist/tui/app.js +12 -14
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/tool.js +4 -8
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/view.js +8 -3
- package/package.json +2 -1
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
|
|
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
|
|
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
|
|
347
|
-
|
|
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
|
|
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
|
-
|
|
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 },
|
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,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
|
-
:
|
|
56
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -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
|
-
|
|
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)
|
package/dist/conversation.js
CHANGED
|
@@ -25,32 +25,25 @@ export class ConversationTree {
|
|
|
25
25
|
return new ConversationTree([], 0);
|
|
26
26
|
}
|
|
27
27
|
static restore(nodes, activeNodeId) {
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
package/dist/providers/http.js
CHANGED
|
@@ -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;
|
package/dist/providers/ollama.js
CHANGED
|
@@ -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)
|
package/dist/providers/openai.js
CHANGED
|
@@ -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)
|
package/dist/providers/sse.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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 =
|
|
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`);
|