@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 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 completed turns and select where the next branch should begin |
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 tree. Selecting an older
207
- turn changes only the in-memory path: it creates and saves a branch only after
208
- the next real user message. Cancelling the picker or exiting before that message
209
- leaves the durable head unchanged. Historical tools are displayed but never
210
- executed. If a crash interrupted a tool loop, Jecode resumes from the latest
211
- completed ancestor and lets the next user turn create a branch. `/export`
212
- writes only the currently selected path.
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`; not sent by `openai-codex` |
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, and use atomic
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, atomically checkpointed, and
293
- treated as untrusted when loaded. A live lease prevents concurrent resume.
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, and `npm run bench:transcript` for a manual long-session rendering
343
- 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
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, 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
+ }
@@ -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;
@@ -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)
@@ -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
- 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 = [];
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
- tree = tree.select(node.parentId).commit({
34
- parentId: node.parentId,
35
- createdAt: node.createdAt,
36
- identity: node.identity,
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
- 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);
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) => clone(node.blocks));
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 !== "checkpointed" && node.settlement !== "completed") ||
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 === "completed" && node.messages.at(-1)?.role !== "assistant") {
195
- throw new Error("a completed turn must end with an assistant message");
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
- assertContextPath(nodes, node);
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 assertContextPath(nodes, owner) {
220
- const context = owner.context;
221
- const boundary = nodes[context.throughNodeId - 1];
222
- if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
223
- throw new Error("turn context checkpoint is invalid");
224
- }
225
- let id = owner.id;
226
- while (id !== 0 && id !== boundary.id)
227
- id = nodes[id - 1]?.parentId ?? 0;
228
- if (id !== boundary.id)
229
- throw new Error("turn context checkpoint is outside its branch");
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.