@giovannijecha/jecode 0.8.1 → 0.8.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.
Files changed (48) hide show
  1. package/README.md +19 -278
  2. package/dist/accounts.js +17 -13
  3. package/dist/batch.js +54 -6
  4. package/dist/context/budget.js +28 -4
  5. package/dist/context/compactor.js +5 -4
  6. package/dist/context/estimate.js +43 -1
  7. package/dist/context/manual.js +8 -4
  8. package/dist/context/policy.js +68 -18
  9. package/dist/controller-request.js +23 -15
  10. package/dist/controller.js +26 -5
  11. package/dist/conversation.js +94 -33
  12. package/dist/credential-safety.js +56 -9
  13. package/dist/credentials.js +32 -4
  14. package/dist/input-boundary.js +80 -0
  15. package/dist/main.js +4 -1
  16. package/dist/openai-oauth-callback.js +1 -1
  17. package/dist/openai-oauth.js +59 -15
  18. package/dist/process-shutdown.js +52 -0
  19. package/dist/providers/anthropic-stream.js +24 -20
  20. package/dist/providers/anthropic-wire.js +7 -2
  21. package/dist/providers/ollama-wire.js +7 -15
  22. package/dist/providers/ollama.js +27 -10
  23. package/dist/providers/openai-wire.js +2 -16
  24. package/dist/providers/tool-input.js +17 -0
  25. package/dist/sessions/codec.js +2 -1
  26. package/dist/sessions/lease.js +7 -0
  27. package/dist/sessions/runtime.js +11 -3
  28. package/dist/sessions/store.js +90 -31
  29. package/dist/settings.js +10 -5
  30. package/dist/start.js +12 -2
  31. package/dist/text-boundary.js +2 -0
  32. package/dist/tui/app-input.js +40 -5
  33. package/dist/tui/app-state.js +1 -0
  34. package/dist/tui/app-workflows.js +1 -0
  35. package/dist/tui/app.js +11 -3
  36. package/dist/tui/blocks.js +1 -3
  37. package/dist/tui/components/messages.js +9 -13
  38. package/dist/tui/components/tool.js +2 -4
  39. package/dist/tui/editor.js +2 -0
  40. package/dist/tui/keys.js +64 -5
  41. package/dist/tui/overlay.js +12 -4
  42. package/dist/tui/picker.js +2 -0
  43. package/dist/tui/screen.js +5 -17
  44. package/dist/tui/transcript-grammar.js +1 -1
  45. package/dist/ui/theme.js +18 -18
  46. package/dist/user-store.js +54 -0
  47. package/package.json +6 -3
  48. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -2,8 +2,25 @@
2
2
  import { constants, deflateRawSync } from "node:zlib";
3
3
  const BYTES_PER_TOKEN = 3;
4
4
  const COMPRESSED_BYTES_PER_TOKEN = 5 / 6;
5
+ const SERIALIZED_CHUNK_CODE_UNITS = 64 * 1_024;
5
6
  export function estimateSerializedTokens(value) {
6
- const serialized = Buffer.from(JSON.stringify(value), "utf8");
7
+ let tokens = 0;
8
+ for (const serialized of serializedChunks(value))
9
+ tokens += estimateChunk(serialized);
10
+ return tokens;
11
+ }
12
+ /** Estimate the same conservative value while yielding between bounded chunks. */
13
+ export async function estimateSerializedTokensResponsive(value, signal) {
14
+ let tokens = 0;
15
+ for (const serialized of serializedChunks(value)) {
16
+ throwIfAborted(signal);
17
+ tokens += estimateChunk(serialized);
18
+ await new Promise((resolve) => setImmediate(resolve));
19
+ }
20
+ throwIfAborted(signal);
21
+ return tokens;
22
+ }
23
+ function estimateChunk(serialized) {
7
24
  const compressedBytes = deflateRawSync(serialized, {
8
25
  level: constants.Z_BEST_SPEED,
9
26
  }).byteLength;
@@ -14,6 +31,20 @@ export function estimateSerializedTokens(value) {
14
31
  // floor, including when repeated input compresses unusually well.
15
32
  return Math.min(serialized.byteLength, Math.max(Math.ceil(serialized.byteLength / BYTES_PER_TOKEN), Math.ceil(compressedBytes / COMPRESSED_BYTES_PER_TOKEN), literalTokens));
16
33
  }
34
+ function* serializedChunks(value) {
35
+ const serialized = JSON.stringify(value);
36
+ if (serialized === undefined)
37
+ throw new TypeError("value is not JSON-serializable");
38
+ for (let start = 0; start < serialized.length;) {
39
+ let end = Math.min(serialized.length, start + SERIALIZED_CHUNK_CODE_UNITS);
40
+ if (end < serialized.length &&
41
+ highSurrogate(serialized.charCodeAt(end - 1)) &&
42
+ lowSurrogate(serialized.charCodeAt(end)))
43
+ end--;
44
+ yield Buffer.from(serialized.slice(start, end), "utf8");
45
+ start = end;
46
+ }
47
+ }
17
48
  function literalTokenFloor(serialized) {
18
49
  let compactableAscii = 0;
19
50
  let literalBytes = 0;
@@ -29,3 +60,14 @@ function literalTokenFloor(serialized) {
29
60
  }
30
61
  return literalBytes + Math.ceil(compactableAscii / BYTES_PER_TOKEN);
31
62
  }
63
+ function highSurrogate(value) {
64
+ return value >= 0xd800 && value <= 0xdbff;
65
+ }
66
+ function lowSurrogate(value) {
67
+ return value >= 0xdc00 && value <= 0xdfff;
68
+ }
69
+ function throwIfAborted(signal) {
70
+ if (signal?.aborted !== true)
71
+ return;
72
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
73
+ }
@@ -6,14 +6,15 @@
6
6
  import { recordAuxiliaryUsage } from "../usage.js";
7
7
  import { resolveContextPolicy } from "./capacity.js";
8
8
  import { compactContext } from "./compactor.js";
9
- import { estimateTokens, planCompaction } from "./policy.js";
9
+ import { estimateTokensResponsive, planCompaction } from "./policy.js";
10
10
  const MIN_PREFIX_TOKENS = 512;
11
11
  export async function compactSession(session, options = {}) {
12
12
  const active = session.conversation.activeNode;
13
13
  if (active === undefined)
14
14
  return "unchanged";
15
15
  const context = session.conversation.contextHistory;
16
- if (estimateTokens(context) < MIN_PREFIX_TOKENS)
16
+ const estimatedInputTokens = await estimateTokensResponsive(context, options.signal);
17
+ if (estimatedInputTokens < MIN_PREFIX_TOKENS)
17
18
  return "unchanged";
18
19
  if (session.conversation.nodes.some((node) => node.parentId === active.id)) {
19
20
  throw new Error("continue this branch before compacting");
@@ -29,8 +30,9 @@ export async function compactSession(session, options = {}) {
29
30
  const coveredMessages = active.context?.throughNodeId === active.id
30
31
  ? active.context.messageCount
31
32
  : 0;
32
- const plan = planCompaction(context, active.messages, coveredMessages, session.usage.lastInputTokens, true, policy);
33
- if (plan === undefined || estimateTokens(plan.prefix) < MIN_PREFIX_TOKENS) {
33
+ const plan = await planCompaction(context, active.messages, coveredMessages, session.usage.lastInputTokens, true, policy, estimatedInputTokens, options.signal);
34
+ if (plan === undefined ||
35
+ await estimateTokensResponsive(plan.prefix, options.signal) < MIN_PREFIX_TOKENS) {
34
36
  options.onStatus?.();
35
37
  return "unchanged";
36
38
  }
@@ -43,6 +45,8 @@ export async function compactSession(session, options = {}) {
43
45
  nodeId: active.id,
44
46
  coveredMessages,
45
47
  lastInputTokens: session.usage.lastInputTokens,
48
+ estimatedInputTokens,
49
+ precomputedPlan: plan,
46
50
  signal: options.signal,
47
51
  force: true,
48
52
  failLoudly: true,
@@ -1,15 +1,18 @@
1
1
  // Provider-neutral context pressure and safe compaction boundaries.
2
- import { estimateSerializedTokens } from "./estimate.js";
2
+ import { estimateSerializedTokens, estimateSerializedTokensResponsive, } from "./estimate.js";
3
3
  export const DEFAULT_COMPACTION_PERCENT = 85;
4
4
  export const MIN_COMPACTION_PERCENT = 50;
5
5
  export const MAX_COMPACTION_PERCENT = 95;
6
6
  export const FALLBACK_CONTEXT_WINDOW_TOKENS = 200_000;
7
7
  export const REQUEST_ESTIMATE_HEADROOM_PERCENT = 5;
8
8
  export const MIN_REQUEST_OUTPUT_TOKENS = 256;
9
- export function planCompaction(context, turn, coveredMessages, lastInputTokens, force, policy) {
9
+ export async function planCompaction(context, turn, coveredMessages, lastInputTokens, force, policy, estimatedInputTokens, signal) {
10
10
  if (!validPolicy(policy) || coveredMessages < 0 || coveredMessages > turn.length)
11
11
  return undefined;
12
- const estimated = estimateTokens(context);
12
+ if (estimatedInputTokens !== undefined &&
13
+ (!Number.isSafeInteger(estimatedInputTokens) || estimatedInputTokens <= 0))
14
+ return undefined;
15
+ const estimated = estimatedInputTokens ?? await estimateTokensResponsive(context, signal);
13
16
  if (!force &&
14
17
  (estimated < policy.targetTokens || Math.max(estimated, lastInputTokens) < policy.triggerTokens))
15
18
  return undefined;
@@ -17,15 +20,16 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
17
20
  const contextPrefix = context.length - currentSuffix;
18
21
  if (contextPrefix < 0)
19
22
  return undefined;
20
- if (!sameMessages(context.slice(contextPrefix), turn.slice(coveredMessages)))
23
+ if (!await sameMessages(context.slice(contextPrefix), turn.slice(coveredMessages), signal))
21
24
  return undefined;
22
25
  const targetTokens = force
23
26
  ? Math.min(policy.targetTokens, Math.max(512, Math.floor(estimated / 4)))
24
27
  : policy.targetTokens;
25
28
  const recentTokens = Math.min(policy.recentTokens, Math.max(256, Math.floor(targetTokens / 2)));
26
- let boundary = recentBoundary(turn, coveredMessages, recentTokens);
29
+ const recent = await recentBoundary(turn, coveredMessages, recentTokens, signal);
30
+ let boundary = recent.boundary;
27
31
  let tail = turn.slice(boundary);
28
- if (turn.length > 1 && estimateTokens(tail) > targetTokens) {
32
+ if (turn.length > 1 && recent.tokens > targetTokens) {
29
33
  boundary = turn.length;
30
34
  tail = [];
31
35
  }
@@ -33,8 +37,9 @@ export function planCompaction(context, turn, coveredMessages, lastInputTokens,
33
37
  if (prefixEnd <= 0 || prefixEnd > context.length)
34
38
  return undefined;
35
39
  const prefix = context.slice(0, prefixEnd);
36
- if (!force && estimateTokens(prefix) < policy.minimumPrefixTokens)
40
+ if (!force && await estimateTokensResponsive(prefix, signal) < policy.minimumPrefixTokens) {
37
41
  return undefined;
42
+ }
38
43
  return {
39
44
  prefix: clone(prefix),
40
45
  tail: clone(tail),
@@ -70,6 +75,9 @@ export function policyForContextWindow(context, compactionPercent) {
70
75
  export function estimateTokens(messages) {
71
76
  return estimateSerializedTokens(messages) + messages.length * 8;
72
77
  }
78
+ export async function estimateTokensResponsive(messages, signal) {
79
+ return await estimateSerializedTokensResponsive(messages, signal) + messages.length * 8;
80
+ }
73
81
  export function isContextOverflow(error) {
74
82
  const candidate = error;
75
83
  if (candidate.status !== 400 && candidate.status !== 413)
@@ -77,16 +85,45 @@ export function isContextOverflow(error) {
77
85
  const detail = `${candidate.message}\n${candidate.body ?? ""}`;
78
86
  return /context_length_exceeded|maximum context length|context window|prompt is too long|input (?:is )?too (?:long|large)|(?:input|prompt|context).{0,80}(?:exceed|maximum|max tokens)/i.test(detail);
79
87
  }
80
- function recentBoundary(turn, coveredMessages, recentTokens) {
81
- let boundary = minimumRecentBoundary(turn);
82
- for (let candidate = boundary - 1; candidate >= coveredMessages; candidate--) {
83
- if (!safeBoundary(turn, candidate))
84
- continue;
85
- if (estimateTokens(turn.slice(candidate)) > recentTokens)
86
- break;
87
- boundary = candidate;
88
+ async function recentBoundary(turn, coveredMessages, recentTokens, signal) {
89
+ const minimum = Math.max(coveredMessages, minimumRecentBoundary(turn));
90
+ const candidates = [];
91
+ for (let candidate = coveredMessages; candidate <= minimum; candidate++) {
92
+ if (candidate === minimum || safeBoundary(turn, candidate))
93
+ candidates.push(candidate);
94
+ }
95
+ const cache = new Map();
96
+ const estimateAt = (candidate) => {
97
+ let estimate = cache.get(candidate);
98
+ if (estimate === undefined) {
99
+ estimate = estimateTokensResponsive(turn.slice(candidate), signal);
100
+ cache.set(candidate, estimate);
101
+ }
102
+ return estimate;
103
+ };
104
+ const last = candidates.length - 1;
105
+ const minimumTokens = await estimateAt(candidates[last]);
106
+ if (minimumTokens > recentTokens) {
107
+ return { boundary: candidates[last], tokens: minimumTokens };
88
108
  }
89
- return Math.max(coveredMessages, boundary);
109
+ // Adding older messages raises the byte and literal floors. A lower-bound
110
+ // search therefore replaces the former serialization of every suffix.
111
+ let low = 0;
112
+ let high = last;
113
+ let selected = last;
114
+ while (low <= high) {
115
+ const middle = Math.floor((low + high) / 2);
116
+ const tokens = await estimateAt(candidates[middle]);
117
+ if (tokens <= recentTokens) {
118
+ selected = middle;
119
+ high = middle - 1;
120
+ }
121
+ else {
122
+ low = middle + 1;
123
+ }
124
+ }
125
+ const boundary = candidates[selected];
126
+ return { boundary, tokens: await estimateAt(boundary) };
90
127
  }
91
128
  function minimumRecentBoundary(turn) {
92
129
  const last = turn.at(-1);
@@ -127,8 +164,21 @@ function validPercent(value) {
127
164
  return Number.isSafeInteger(value) &&
128
165
  value >= MIN_COMPACTION_PERCENT && value <= MAX_COMPACTION_PERCENT;
129
166
  }
130
- function sameMessages(left, right) {
131
- return JSON.stringify(left) === JSON.stringify(right);
167
+ async function sameMessages(left, right, signal) {
168
+ if (left.length !== right.length)
169
+ return false;
170
+ for (let index = 0; index < left.length; index++) {
171
+ throwIfAborted(signal);
172
+ if (JSON.stringify(left[index]) !== JSON.stringify(right[index]))
173
+ return false;
174
+ await new Promise((resolve) => setImmediate(resolve));
175
+ }
176
+ return true;
177
+ }
178
+ function throwIfAborted(signal) {
179
+ if (signal?.aborted !== true)
180
+ return;
181
+ throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
132
182
  }
133
183
  function clone(value) {
134
184
  return structuredClone(value);
@@ -1,17 +1,14 @@
1
1
  // One streamed provider request with a single safe context-overflow recovery.
2
- import { budgetRequest, estimateRequestInputTokens } from "./context/budget.js";
2
+ import { budgetRequestFromInputTokens, estimateRequestInputTokensResponsive, } from "./context/budget.js";
3
3
  import { isContextOverflow } from "./context/policy.js";
4
4
  export async function requestAssistant(history, current, specs, options, events, signal) {
5
5
  let policy = await options.contextPolicy();
6
- const prepared = await prepareContext(history, current, specs, options, events, policy, "budget");
7
- let context = prepared === undefined ? [...current] : clone(prepared);
6
+ const prepared = await prepareContext(history, current, specs, options, events, policy, "budget", signal);
7
+ let context = prepared.projected === undefined ? [...current] : clone(prepared.projected);
8
+ let inputTokens = prepared.inputTokens;
8
9
  let recovered = false;
9
10
  for (;;) {
10
- const budget = budgetRequest({
11
- system: options.system,
12
- messages: context,
13
- tools: specs,
14
- }, options.maxTokens, policy);
11
+ const budget = budgetRequestFromInputTokens(inputTokens, options.maxTokens, policy);
15
12
  try {
16
13
  const message = await options.provider.send({
17
14
  model: options.model,
@@ -31,26 +28,37 @@ export async function requestAssistant(history, current, specs, options, events,
31
28
  throw error;
32
29
  if (isContextOverflow(error))
33
30
  policy = await options.contextPolicy();
34
- const projected = await prepareContext(history, context, specs, options, events, policy, "overflow", error);
35
- if (projected === undefined)
31
+ const next = await prepareContext(history, context, specs, options, events, policy, "overflow", signal, error, inputTokens);
32
+ if (next.projected === undefined)
36
33
  throw error;
37
- context = clone(projected);
34
+ context = clone(next.projected);
35
+ inputTokens = next.inputTokens;
38
36
  recovered = true;
39
37
  }
40
38
  }
41
39
  }
42
- async function prepareContext(history, context, specs, options, events, policy, reason, error) {
43
- const inputTokens = estimateRequestInputTokens({
40
+ async function prepareContext(history, context, specs, options, events, policy, reason, signal, error, knownInputTokens) {
41
+ const inputTokens = knownInputTokens ?? await estimateRequestInputTokensResponsive({
44
42
  system: options.system,
45
43
  messages: context,
46
44
  tools: specs,
47
- });
48
- return events.onContext?.(history, context, {
45
+ }, signal);
46
+ const projected = await events.onContext?.(history, context, {
49
47
  reason,
50
48
  policy,
51
49
  inputTokens,
52
50
  ...(error === undefined ? {} : { error }),
53
51
  });
52
+ return {
53
+ projected,
54
+ inputTokens: projected === undefined
55
+ ? inputTokens
56
+ : await estimateRequestInputTokensResponsive({
57
+ system: options.system,
58
+ messages: projected,
59
+ tools: specs,
60
+ }, signal),
61
+ };
54
62
  }
55
63
  function clone(messages) {
56
64
  return structuredClone([...messages]);
@@ -97,13 +97,29 @@ export async function runTurn(history, options, events, signal, modelHistory = h
97
97
  events.onToolCall(call, preview);
98
98
  prepared.push({ call, current, preview });
99
99
  }
100
- const runs = await Promise.all(prepared.map(({ call, current, preview }) => settle(call, current, calls.length, options, events, signal, preview)));
101
- for (let offset = 0; offset < runs.length; offset++) {
100
+ const settlements = await Promise.allSettled(prepared.map(({ call, current, preview }) => settle(call, current, calls.length, options, events, signal, preview)));
101
+ let batchFailure;
102
+ for (let offset = 0; offset < settlements.length; offset++) {
102
103
  const call = prepared[offset]?.call;
103
- const run = runs[offset];
104
+ const settlement = settlements[offset];
105
+ const run = settlement.status === "fulfilled"
106
+ ? settlement.value
107
+ : refuse(call, signal?.aborted === true
108
+ ? "interrupted before completion"
109
+ : "tool processing stopped before completion", signal?.aborted === true ? "interrupted" : "failed");
110
+ if (settlement.status === "rejected" && batchFailure === undefined) {
111
+ batchFailure = { error: settlement.reason };
112
+ }
104
113
  results.push(run.result);
105
- events.onToolResult(call, run.result, run.summary);
114
+ try {
115
+ events.onToolResult(call, run.result, run.summary);
116
+ }
117
+ catch (error) {
118
+ batchFailure ??= { error };
119
+ }
106
120
  }
121
+ if (batchFailure !== undefined)
122
+ throw batchFailure.error;
107
123
  }
108
124
  }
109
125
  catch (error) {
@@ -169,11 +185,14 @@ function assertToolCallIds(calls) {
169
185
  }
170
186
  }
171
187
  async function settle(call, current, total, options, events, signal, preview) {
188
+ throwIfAborted(signal);
189
+ if (call.inputError !== undefined) {
190
+ return refuse(call, call.inputError, "invalid arguments");
191
+ }
172
192
  const tool = findTool(options.tools, call.name);
173
193
  if (tool === undefined) {
174
194
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
175
195
  }
176
- throwIfAborted(signal);
177
196
  const approved = !tool.dangerous || await events.approve(call);
178
197
  throwIfAborted(signal);
179
198
  if (!approved) {
@@ -202,6 +221,8 @@ function refuse(call, reason, summary) {
202
221
  * turn depends on it, because it exists for the user, not for the model.
203
222
  */
204
223
  async function look(call, options, signal) {
224
+ if (call.inputError !== undefined)
225
+ return undefined;
205
226
  const tool = findTool(options.tools, call.name);
206
227
  if (tool?.preview === undefined)
207
228
  return undefined;
@@ -16,13 +16,17 @@ export const CONVERSATION_LIMITS = Object.freeze({
16
16
  export class ConversationTree {
17
17
  #nodes;
18
18
  #activeNodeId;
19
- constructor(nodes, activeNodeId) {
20
- this.#nodes = Object.freeze([...nodes]);
19
+ #nodeBounds;
20
+ #bounds;
21
+ constructor(nodes, activeNodeId, nodeBounds, bounds) {
22
+ this.#nodes = nodes;
21
23
  this.#activeNodeId = activeNodeId;
24
+ this.#nodeBounds = nodeBounds;
25
+ this.#bounds = bounds;
22
26
  Object.freeze(this);
23
27
  }
24
28
  static empty() {
25
- return new ConversationTree([], 0);
29
+ return new ConversationTree(Object.freeze([]), 0, Object.freeze([]), emptyBounds());
26
30
  }
27
31
  static restore(nodes, activeNodeId) {
28
32
  if (nodes.length > CONVERSATION_LIMITS.nodes) {
@@ -39,11 +43,13 @@ export class ConversationTree {
39
43
  assertPersistableNode(owned);
40
44
  restored.push(owned);
41
45
  }
42
- assertBounds(restored);
46
+ const measured = measureBounds(restored);
47
+ assertBounds(measured.total);
48
+ assertContextPaths(restored, restored.filter((node) => node.context !== undefined));
43
49
  if (!validNodeId(activeNodeId) ||
44
50
  (activeNodeId !== 0 && restored[activeNodeId - 1]?.id !== activeNodeId))
45
51
  throw new Error("conversation node does not exist");
46
- return new ConversationTree(restored, activeNodeId);
52
+ return new ConversationTree(Object.freeze(restored), activeNodeId, Object.freeze(measured.nodes), measured.total);
47
53
  }
48
54
  /** Commit or extend the one prospective leaf turn. */
49
55
  commit(draft, settlement) {
@@ -60,7 +66,7 @@ export class ConversationTree {
60
66
  if (!validNodeId(nodeId) || (nodeId !== 0 && this.#nodes[nodeId - 1]?.id !== nodeId)) {
61
67
  throw new Error("conversation node does not exist");
62
68
  }
63
- return new ConversationTree(this.#nodes, nodeId);
69
+ return new ConversationTree(this.#nodes, nodeId, this.#nodeBounds, this.#bounds);
64
70
  }
65
71
  node(nodeId) {
66
72
  return nodeId === 0 ? undefined : this.#nodes[nodeId - 1];
@@ -132,9 +138,11 @@ export class ConversationTree {
132
138
  });
133
139
  assertTurn(node);
134
140
  assertPersistableNode(node);
135
- const nodes = [...this.#nodes, node];
136
- assertBounds(nodes);
137
- return new ConversationTree(nodes, node.id);
141
+ assertContextPath(this.#nodes, node);
142
+ const nodeBounds = measureNode(node);
143
+ const bounds = addBounds(this.#bounds, nodeBounds);
144
+ assertBounds(bounds);
145
+ return new ConversationTree(Object.freeze([...this.#nodes, node]), node.id, Object.freeze([...this.#nodeBounds, nodeBounds]), bounds);
138
146
  }
139
147
  #replace(draft, settlement) {
140
148
  const id = draft.nodeId;
@@ -154,10 +162,16 @@ export class ConversationTree {
154
162
  });
155
163
  assertTurn(node);
156
164
  assertPersistableNode(node);
165
+ assertContextPath(this.#nodes, node);
157
166
  const nodes = [...this.#nodes];
158
167
  nodes[id - 1] = node;
159
- assertBounds(nodes);
160
- return new ConversationTree(nodes, id);
168
+ const priorBounds = this.#nodeBounds[id - 1];
169
+ const nextNodeBounds = measureNode(node);
170
+ const nodeBounds = [...this.#nodeBounds];
171
+ nodeBounds[id - 1] = nextNodeBounds;
172
+ const bounds = replaceBounds(this.#bounds, priorBounds, nextNodeBounds);
173
+ assertBounds(bounds);
174
+ return new ConversationTree(Object.freeze(nodes), id, Object.freeze(nodeBounds), bounds);
161
175
  }
162
176
  #path() {
163
177
  const path = [];
@@ -174,13 +188,13 @@ export class ConversationTree {
174
188
  }
175
189
  }
176
190
  function ownedNode(node) {
177
- return Object.freeze({
191
+ return deepFreeze({
178
192
  ...node,
179
- identity: Object.freeze({ ...node.identity }),
180
- messages: Object.freeze(clone(node.messages)),
181
- blocks: Object.freeze(clone(node.blocks)),
182
- ...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
183
- ...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
193
+ identity: { ...node.identity },
194
+ messages: clone(node.messages),
195
+ blocks: clone(node.blocks),
196
+ ...(node.context === undefined ? {} : { context: { ...node.context } }),
197
+ ...(node.failure === undefined ? {} : { failure: { ...node.failure } }),
184
198
  });
185
199
  }
186
200
  function settledBlocks(blocks) {
@@ -225,28 +239,64 @@ function validSettlement(value) {
225
239
  return value === "checkpointed" || value === "completed" ||
226
240
  value === "failed" || value === "interrupted";
227
241
  }
228
- function assertBounds(nodes) {
229
- let messageCodeUnits = 0;
230
- let transcriptCodeUnits = 0;
231
- let contextCodeUnits = 0;
232
- const contextOwners = [];
233
- for (const node of nodes) {
234
- messageCodeUnits += JSON.stringify(node.messages).length;
235
- transcriptCodeUnits += JSON.stringify(node.blocks).length;
236
- contextCodeUnits += node.context?.summary.length ?? 0;
237
- if (node.context !== undefined)
238
- contextOwners.push(node);
239
- }
240
- if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
242
+ function assertBounds(bounds) {
243
+ if (bounds.messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
241
244
  throw new Error("conversation model history reached its session limit — start /new");
242
245
  }
243
- if (transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits) {
246
+ if (bounds.transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits) {
244
247
  throw new Error("conversation transcript reached its session limit — start /new");
245
248
  }
246
- if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
249
+ if (bounds.contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
247
250
  throw new Error("conversation context summaries reached their session limit — start /new");
248
251
  }
249
- assertContextPaths(nodes, contextOwners);
252
+ }
253
+ function measureBounds(nodes) {
254
+ const measured = nodes.map(measureNode);
255
+ return {
256
+ nodes: measured,
257
+ total: measured.reduce(addBounds, emptyBounds()),
258
+ };
259
+ }
260
+ function measureNode(node) {
261
+ return Object.freeze({
262
+ messageCodeUnits: JSON.stringify(node.messages).length,
263
+ transcriptCodeUnits: JSON.stringify(node.blocks).length,
264
+ contextCodeUnits: node.context?.summary.length ?? 0,
265
+ });
266
+ }
267
+ function emptyBounds() {
268
+ return Object.freeze({ messageCodeUnits: 0, transcriptCodeUnits: 0, contextCodeUnits: 0 });
269
+ }
270
+ function addBounds(left, right) {
271
+ return Object.freeze({
272
+ messageCodeUnits: left.messageCodeUnits + right.messageCodeUnits,
273
+ transcriptCodeUnits: left.transcriptCodeUnits + right.transcriptCodeUnits,
274
+ contextCodeUnits: left.contextCodeUnits + right.contextCodeUnits,
275
+ });
276
+ }
277
+ function replaceBounds(total, before, after) {
278
+ return Object.freeze({
279
+ messageCodeUnits: total.messageCodeUnits - before.messageCodeUnits + after.messageCodeUnits,
280
+ transcriptCodeUnits: total.transcriptCodeUnits - before.transcriptCodeUnits + after.transcriptCodeUnits,
281
+ contextCodeUnits: total.contextCodeUnits - before.contextCodeUnits + after.contextCodeUnits,
282
+ });
283
+ }
284
+ function assertContextPath(nodes, owner) {
285
+ const context = owner.context;
286
+ if (context === undefined)
287
+ return;
288
+ const boundary = context.throughNodeId === owner.id
289
+ ? owner
290
+ : nodes[context.throughNodeId - 1];
291
+ if (boundary === undefined || !validContextAnchor(context, boundary.messages.length)) {
292
+ throw new Error("turn context checkpoint is invalid");
293
+ }
294
+ let current = owner;
295
+ while (current !== undefined && current.id !== boundary.id) {
296
+ current = nodes[current.parentId - 1];
297
+ }
298
+ if (current === undefined)
299
+ throw new Error("turn context checkpoint is outside its branch");
250
300
  }
251
301
  function assertContextPaths(nodes, owners) {
252
302
  if (owners.length === 0)
@@ -288,3 +338,14 @@ function validNodeId(value) {
288
338
  function clone(value) {
289
339
  return structuredClone(value);
290
340
  }
341
+ function deepFreeze(value, seen = new WeakSet()) {
342
+ if (typeof value !== "object" || value === null || seen.has(value))
343
+ return value;
344
+ seen.add(value);
345
+ if (!ArrayBuffer.isView(value)) {
346
+ for (const child of Object.values(value))
347
+ deepFreeze(child, seen);
348
+ Object.freeze(value);
349
+ }
350
+ return value;
351
+ }