@giovannijecha/jecode 0.7.1 → 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 +20 -15
- 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/manual.js +1 -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 +89 -35
- 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/sessions/codec.js +54 -8
- package/dist/sessions/runtime.js +2 -2
- package/dist/sessions/store.js +54 -32
- package/dist/timeline.js +12 -6
- 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 +92 -34
- package/dist/tui/app.js +43 -20
- 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
|
@@ -168,7 +168,7 @@ Type `/` to open searchable command completion inside the composer.
|
|
|
168
168
|
| `/providers` | Manage provider connections, API keys, ChatGPT sign-in, and Ollama endpoints |
|
|
169
169
|
| `/models` | Search all currently usable provider catalogues and select a model |
|
|
170
170
|
| `/permissions` | Change session tool access and review remembered approvals |
|
|
171
|
-
| `/timeline` | Browse
|
|
171
|
+
| `/timeline` | Browse resumable turns and select where the next branch should begin |
|
|
172
172
|
| `/compact` | Compact the current branch context without deleting saved conversation history |
|
|
173
173
|
| `/new` | Start a new conversation and reset session tool permissions |
|
|
174
174
|
| `/export` | Save a timestamped Markdown transcript in the launch directory |
|
|
@@ -203,13 +203,16 @@ the resume picker until it has a settled turn. Resuming and continuing a
|
|
|
203
203
|
conversation keeps its durable session identity and updates one picker entry
|
|
204
204
|
instead of creating duplicates.
|
|
205
205
|
|
|
206
|
-
`/timeline` shows completed turns in the conversation
|
|
207
|
-
turn changes only the in-memory path: it creates and
|
|
208
|
-
the next real user message. Cancelling the picker or
|
|
209
|
-
leaves the durable head unchanged.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
206
|
+
`/timeline` shows completed, failed, and interrupted turns in the conversation
|
|
207
|
+
tree. Selecting an older turn changes only the in-memory path: it creates and
|
|
208
|
+
saves a branch only after the next real user message. Cancelling the picker or
|
|
209
|
+
exiting before that message leaves the durable head unchanged. A failed turn
|
|
210
|
+
keeps the same partial evidence and outcome in the live transcript, export, and
|
|
211
|
+
resume, while the next model receives a neutral failure boundary instead of
|
|
212
|
+
incomplete streamed text. Historical tools are displayed but never executed.
|
|
213
|
+
If a process stops abruptly inside a tool loop, Jecode resumes from the latest
|
|
214
|
+
safe ancestor and lets the next user turn create a branch. `/export` writes
|
|
215
|
+
only the currently selected path.
|
|
213
216
|
|
|
214
217
|
When model-facing context approaches the selected model's usable capacity,
|
|
215
218
|
Jecode asks the provider for a bounded summary of the older prefix and keeps
|
|
@@ -250,7 +253,7 @@ settings, then built-in defaults.
|
|
|
250
253
|
| `--ollama-host` | `OLLAMA_HOST` | Cloud with an Ollama key, local without one |
|
|
251
254
|
| `--root` | - | Current directory |
|
|
252
255
|
| `--effort` | `JECODE_EFFORT` | `high` |
|
|
253
|
-
| `--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` |
|
|
254
257
|
| `--max-steps` | `JECODE_MAX_STEPS` | `40` |
|
|
255
258
|
| `--compaction-percent` | `JECODE_COMPACTION_PERCENT` | `85`; accepts `50` through `95` |
|
|
256
259
|
| `--reduced-motion` | `JECODE_REDUCED_MOTION=1` | Off |
|
|
@@ -272,8 +275,8 @@ Jecode treats model output, workspace content, tool output, and terminal text as
|
|
|
272
275
|
untrusted data.
|
|
273
276
|
|
|
274
277
|
- Current filesystem tools are confined to the selected workspace. Writes
|
|
275
|
-
reject symlink and junction components, revalidate boundaries
|
|
276
|
-
replacement.
|
|
278
|
+
reject symlink and junction components, revalidate boundaries and the
|
|
279
|
+
approved file state immediately before atomic replacement.
|
|
277
280
|
- Dangerous tools ask by default unless explicitly allowed for the session or
|
|
278
281
|
the process starts with `--auto-approve`.
|
|
279
282
|
- Credential fields are masked and excluded from transcripts. Recognized
|
|
@@ -289,8 +292,9 @@ untrusted data.
|
|
|
289
292
|
- Provider handshakes and idle response bodies have finite deadlines. Only
|
|
290
293
|
idempotent catalogue reads retry; generation requests are never replayed.
|
|
291
294
|
- Model, terminal, and filesystem input are bounded before use.
|
|
292
|
-
- Session files are versioned, size-bounded
|
|
293
|
-
treated as untrusted when loaded. A
|
|
295
|
+
- Session files are versioned, symmetrically size-bounded before write and
|
|
296
|
+
after read, atomically checkpointed, and treated as untrusted when loaded. A
|
|
297
|
+
live lease prevents concurrent resume.
|
|
294
298
|
|
|
295
299
|
`run_command` is not an operating-system sandbox. An approved command can still
|
|
296
300
|
access files and account resources available to the current user. Review
|
|
@@ -339,8 +343,9 @@ npm run check
|
|
|
339
343
|
```
|
|
340
344
|
|
|
341
345
|
Use `npm run tui:lab` to inspect production TUI components with inert local
|
|
342
|
-
fixtures
|
|
343
|
-
|
|
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
|
|
344
349
|
[docs/architecture.md](docs/architecture.md); brand assets and usage rules live
|
|
345
350
|
in [docs/brand.md](docs/brand.md).
|
|
346
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/manual.js
CHANGED
|
@@ -62,6 +62,7 @@ export async function compactSession(session, options = {}) {
|
|
|
62
62
|
messages: active.messages,
|
|
63
63
|
blocks: active.blocks,
|
|
64
64
|
context: result.anchor,
|
|
65
|
+
...(active.failure === undefined ? {} : { failure: active.failure }),
|
|
65
66
|
}, active.settlement);
|
|
66
67
|
await session.persistence?.checkpoint(next);
|
|
67
68
|
session.conversation = next;
|
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
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// project an older prefix before it is sent to a provider. Provider traffic
|
|
6
6
|
// and live screen blocks remain prospective until a consistent checkpoint.
|
|
7
7
|
import { projectContext, validContextAnchor } from "./context/projection.js";
|
|
8
|
+
import { assertPersistableNode } from "./sessions/codec.js";
|
|
8
9
|
export const CONVERSATION_LIMITS = Object.freeze({
|
|
9
10
|
nodes: 1_024,
|
|
10
11
|
messageCodeUnits: 8_388_608,
|
|
@@ -24,31 +25,25 @@ export class ConversationTree {
|
|
|
24
25
|
return new ConversationTree([], 0);
|
|
25
26
|
}
|
|
26
27
|
static restore(nodes, activeNodeId) {
|
|
27
|
-
|
|
28
|
+
if (nodes.length > CONVERSATION_LIMITS.nodes) {
|
|
29
|
+
throw new Error("conversation reached its session limit — start /new");
|
|
30
|
+
}
|
|
31
|
+
const restored = [];
|
|
28
32
|
for (let index = 0; index < nodes.length; index++) {
|
|
29
33
|
const node = nodes[index];
|
|
30
34
|
if (node === undefined || node.id !== index + 1) {
|
|
31
35
|
throw new Error("session contains a non-sequential conversation node");
|
|
32
36
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
messages: node.messages,
|
|
38
|
-
blocks: node.blocks,
|
|
39
|
-
...(node.context === undefined ? {} : { context: node.context }),
|
|
40
|
-
}, node.settlement);
|
|
41
|
-
const restored = tree.activeNode;
|
|
42
|
-
if (restored === undefined || restored.id !== node.id) {
|
|
43
|
-
throw new Error("session conversation could not be restored");
|
|
44
|
-
}
|
|
45
|
-
if (node.revision > 1) {
|
|
46
|
-
const copy = [...tree.#nodes];
|
|
47
|
-
copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
|
|
48
|
-
tree = new ConversationTree(copy, node.id);
|
|
49
|
-
}
|
|
37
|
+
const owned = ownedNode({ ...node, blocks: settledBlocks(node.blocks) });
|
|
38
|
+
assertTurn(owned);
|
|
39
|
+
assertPersistableNode(owned);
|
|
40
|
+
restored.push(owned);
|
|
50
41
|
}
|
|
51
|
-
|
|
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);
|
|
52
47
|
}
|
|
53
48
|
/** Commit or extend the one prospective leaf turn. */
|
|
54
49
|
commit(draft, settlement) {
|
|
@@ -92,6 +87,19 @@ export class ConversationTree {
|
|
|
92
87
|
}
|
|
93
88
|
return undefined;
|
|
94
89
|
}
|
|
90
|
+
/** Select the newest turn that is safe to continue after a restart. */
|
|
91
|
+
latestResumable() {
|
|
92
|
+
let id = this.#activeNodeId;
|
|
93
|
+
while (id !== 0) {
|
|
94
|
+
const node = this.node(id);
|
|
95
|
+
if (node === undefined)
|
|
96
|
+
throw new Error("conversation path is incomplete");
|
|
97
|
+
if (node.settlement !== "checkpointed")
|
|
98
|
+
return this.select(id);
|
|
99
|
+
id = node.parentId;
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
95
103
|
get history() {
|
|
96
104
|
return this.#path().flatMap((node) => clone(node.messages));
|
|
97
105
|
}
|
|
@@ -99,7 +107,12 @@ export class ConversationTree {
|
|
|
99
107
|
return projectContext(this.#path());
|
|
100
108
|
}
|
|
101
109
|
get transcript() {
|
|
102
|
-
return this.#path().flatMap((node) =>
|
|
110
|
+
return this.#path().flatMap((node) => [
|
|
111
|
+
...clone(node.blocks),
|
|
112
|
+
...(node.failure === undefined
|
|
113
|
+
? []
|
|
114
|
+
: [{ kind: "notice", text: node.failure.text, tone: node.failure.tone }]),
|
|
115
|
+
]);
|
|
103
116
|
}
|
|
104
117
|
#append(draft, settlement) {
|
|
105
118
|
if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
|
|
@@ -115,8 +128,10 @@ export class ConversationTree {
|
|
|
115
128
|
messages: draft.messages,
|
|
116
129
|
blocks: settledBlocks(draft.blocks),
|
|
117
130
|
...(draft.context === undefined ? {} : { context: draft.context }),
|
|
131
|
+
...(draft.failure === undefined ? {} : { failure: draft.failure }),
|
|
118
132
|
});
|
|
119
133
|
assertTurn(node);
|
|
134
|
+
assertPersistableNode(node);
|
|
120
135
|
const nodes = [...this.#nodes, node];
|
|
121
136
|
assertBounds(nodes);
|
|
122
137
|
return new ConversationTree(nodes, node.id);
|
|
@@ -135,8 +150,10 @@ export class ConversationTree {
|
|
|
135
150
|
messages: draft.messages,
|
|
136
151
|
blocks: settledBlocks(draft.blocks),
|
|
137
152
|
context: draft.context ?? current.context,
|
|
153
|
+
failure: draft.failure,
|
|
138
154
|
});
|
|
139
155
|
assertTurn(node);
|
|
156
|
+
assertPersistableNode(node);
|
|
140
157
|
const nodes = [...this.#nodes];
|
|
141
158
|
nodes[id - 1] = node;
|
|
142
159
|
assertBounds(nodes);
|
|
@@ -163,6 +180,7 @@ function ownedNode(node) {
|
|
|
163
180
|
messages: Object.freeze(clone(node.messages)),
|
|
164
181
|
blocks: Object.freeze(clone(node.blocks)),
|
|
165
182
|
...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
|
|
183
|
+
...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
|
|
166
184
|
});
|
|
167
185
|
}
|
|
168
186
|
function settledBlocks(blocks) {
|
|
@@ -174,6 +192,8 @@ function settledBlocks(blocks) {
|
|
|
174
192
|
return [settled];
|
|
175
193
|
}
|
|
176
194
|
if (block.kind === "tool") {
|
|
195
|
+
if (block.tone === "pending")
|
|
196
|
+
return [];
|
|
177
197
|
const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
|
|
178
198
|
return [settled];
|
|
179
199
|
}
|
|
@@ -185,26 +205,37 @@ function assertTurn(node) {
|
|
|
185
205
|
!validNodeId(node.parentId) || node.parentId >= node.id ||
|
|
186
206
|
!Number.isSafeInteger(node.revision) || node.revision < 1 ||
|
|
187
207
|
node.createdAt.length === 0 || node.createdAt.length > 64 ||
|
|
188
|
-
(node.settlement
|
|
208
|
+
!validSettlement(node.settlement) ||
|
|
189
209
|
node.messages.length < 2 || node.messages[0]?.role !== "user" ||
|
|
190
210
|
node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
|
|
191
211
|
node.identity.model.length === 0 || node.identity.model.length > 512 ||
|
|
192
212
|
node.identity.effort.length === 0 || node.identity.effort.length > 32)
|
|
193
213
|
throw new Error("turn checkpoint is invalid");
|
|
194
|
-
if (node.settlement
|
|
195
|
-
throw new Error("a
|
|
214
|
+
if (node.settlement !== "checkpointed" && node.messages.at(-1)?.role !== "assistant") {
|
|
215
|
+
throw new Error("a resumable turn must end with an assistant message");
|
|
216
|
+
}
|
|
217
|
+
const failed = node.settlement === "failed" || node.settlement === "interrupted";
|
|
218
|
+
if (failed !== (node.failure !== undefined) ||
|
|
219
|
+
(node.settlement === "failed" && node.failure?.tone !== "error") ||
|
|
220
|
+
(node.settlement === "interrupted" && node.failure?.tone !== "warn")) {
|
|
221
|
+
throw new Error("turn failure state is invalid");
|
|
196
222
|
}
|
|
197
223
|
}
|
|
224
|
+
function validSettlement(value) {
|
|
225
|
+
return value === "checkpointed" || value === "completed" ||
|
|
226
|
+
value === "failed" || value === "interrupted";
|
|
227
|
+
}
|
|
198
228
|
function assertBounds(nodes) {
|
|
199
229
|
let messageCodeUnits = 0;
|
|
200
230
|
let transcriptCodeUnits = 0;
|
|
201
231
|
let contextCodeUnits = 0;
|
|
232
|
+
const contextOwners = [];
|
|
202
233
|
for (const node of nodes) {
|
|
203
234
|
messageCodeUnits += JSON.stringify(node.messages).length;
|
|
204
235
|
transcriptCodeUnits += JSON.stringify(node.blocks).length;
|
|
205
236
|
contextCodeUnits += node.context?.summary.length ?? 0;
|
|
206
237
|
if (node.context !== undefined)
|
|
207
|
-
|
|
238
|
+
contextOwners.push(node);
|
|
208
239
|
}
|
|
209
240
|
if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
|
|
210
241
|
throw new Error("conversation model history reached its session limit — start /new");
|
|
@@ -215,18 +246,41 @@ function assertBounds(nodes) {
|
|
|
215
246
|
if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
|
|
216
247
|
throw new Error("conversation context summaries reached their session limit — start /new");
|
|
217
248
|
}
|
|
249
|
+
assertContextPaths(nodes, contextOwners);
|
|
218
250
|
}
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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");
|
|
283
|
+
}
|
|
230
284
|
}
|
|
231
285
|
function validNodeId(value) {
|
|
232
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.
|