@giovannijecha/jecode 0.5.0 → 0.7.0

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.
@@ -0,0 +1,34 @@
1
+ // One streamed provider request with a single safe context-overflow recovery.
2
+ export async function requestAssistant(history, current, specs, options, events, signal) {
3
+ const prepared = await events.onContext?.(history, current, "budget");
4
+ let context = prepared === undefined ? [...current] : clone(prepared);
5
+ let recovered = false;
6
+ for (;;) {
7
+ try {
8
+ const message = await options.provider.send({
9
+ model: options.model,
10
+ system: options.system,
11
+ messages: context,
12
+ tools: specs,
13
+ maxTokens: options.maxTokens,
14
+ effort: options.effort,
15
+ signal,
16
+ onStream: (event) => events.onStream(event),
17
+ onStatus: (status) => events.onStatus?.(status),
18
+ });
19
+ return { message, context };
20
+ }
21
+ catch (error) {
22
+ if (recovered)
23
+ throw error;
24
+ const projected = await events.onContext?.(history, context, "overflow", error);
25
+ if (projected === undefined)
26
+ throw error;
27
+ context = clone(projected);
28
+ recovered = true;
29
+ }
30
+ }
31
+ }
32
+ function clone(messages) {
33
+ return structuredClone([...messages]);
34
+ }
@@ -5,6 +5,7 @@
5
5
  // loop iterates. That constraint is implemented here, not left as a product claim.
6
6
  import { isToolCall } from "./types.js";
7
7
  import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
+ import { requestAssistant } from "./controller-request.js";
8
9
  export const MAX_TOOL_CALLS_PER_STEP = 32;
9
10
  /** Independent read calls share one bounded execution wave. */
10
11
  export const MAX_CONCURRENT_TOOL_CALLS = 4;
@@ -13,24 +14,27 @@ export const MAX_CONCURRENT_TOOL_CALLS = 4;
13
14
  * stops asking for tools. `history` is mutated in place, so an aborted turn
14
15
  * still leaves the conversation in a consistent state.
15
16
  */
16
- export async function runTurn(history, options, events, signal) {
17
+ export async function runTurn(history, options, events, signal, modelHistory = history) {
17
18
  const specs = toolSpecs(options.tools);
19
+ let context = modelHistory;
20
+ const append = (message) => {
21
+ history.push(message);
22
+ if (context !== history)
23
+ context.push(message);
24
+ };
25
+ const checkpoint = async (settlement) => {
26
+ const projected = await events.onCheckpoint?.(history, settlement, context);
27
+ if (projected !== undefined)
28
+ context = clone([...projected]);
29
+ };
18
30
  for (let step = 0; step < options.maxSteps; step++) {
19
31
  throwIfAborted(signal);
20
32
  events.onStep?.(step + 1, options.maxSteps);
21
33
  // The message is displayed as it streams; what comes back here is the
22
34
  // assembled version, which exists to be appended to the history.
23
- const assistant = await options.provider.send({
24
- model: options.model,
25
- system: options.system,
26
- messages: history,
27
- tools: specs,
28
- maxTokens: options.maxTokens,
29
- effort: options.effort,
30
- signal,
31
- onStream: (event) => events.onStream(event),
32
- onStatus: (status) => events.onStatus?.(status),
33
- });
35
+ const response = await requestAssistant(history, context, specs, options, events, signal);
36
+ const assistant = response.message;
37
+ context = response.context;
34
38
  throwIfAborted(signal);
35
39
  const calls = assistant.content.filter(isToolCall);
36
40
  if (assistant.content.length === 0) {
@@ -40,11 +44,11 @@ export async function runTurn(history, options, events, signal) {
40
44
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
41
45
  }
42
46
  assertToolCallIds(calls);
43
- history.push(assistant);
47
+ append(assistant);
44
48
  if (calls.length === 0) {
45
49
  if (assistant.usage !== undefined)
46
50
  events.onUsage?.(assistant.usage);
47
- await events.onCheckpoint?.(history, "completed");
51
+ await checkpoint("completed");
48
52
  return; // the model is done — hand back to the user
49
53
  }
50
54
  // Consecutive shared reads run together. An exclusive call is an ordered
@@ -88,7 +92,7 @@ export async function runTurn(history, options, events, signal) {
88
92
  repairs.push({ call, run });
89
93
  results.push(run.result);
90
94
  }
91
- history.push({ role: "user", content: results });
95
+ append({ role: "user", content: results });
92
96
  // History repair is the invariant. UI recovery is best-effort and must
93
97
  // never replace the original exception or leave the conversation open.
94
98
  for (const { call, run } of repairs) {
@@ -101,13 +105,13 @@ export async function runTurn(history, options, events, signal) {
101
105
  // The surface is already failing; the next turn can still proceed.
102
106
  }
103
107
  }
104
- await events.onCheckpoint?.(history, "checkpointed");
108
+ await checkpoint("checkpointed");
105
109
  if (interrupted)
106
110
  throw abortReason(signal);
107
111
  throw error;
108
112
  }
109
- history.push({ role: "user", content: results });
110
- await events.onCheckpoint?.(history, "checkpointed");
113
+ append({ role: "user", content: results });
114
+ await checkpoint("checkpointed");
111
115
  }
112
116
  throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
113
117
  }
@@ -190,3 +194,6 @@ function throwIfAborted(signal) {
190
194
  function abortReason(signal) {
191
195
  return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
192
196
  }
197
+ function clone(value) {
198
+ return structuredClone(value);
199
+ }
@@ -1,13 +1,15 @@
1
1
  // Canonical settled conversation state.
2
2
  //
3
3
  // A node owns one complete user-turn delta. The selected root-to-node path is
4
- // the only history sent to a provider and the only transcript shown by the
5
- // TUI. Provider traffic and live screen blocks remain prospective until the
6
- // controller reaches a consistent completion or tool checkpoint.
4
+ // the durable source for the transcript and full history; context anchors can
5
+ // project an older prefix before it is sent to a provider. Provider traffic
6
+ // and live screen blocks remain prospective until a consistent checkpoint.
7
+ import { projectContext, validContextAnchor } from "./context/projection.js";
7
8
  export const CONVERSATION_LIMITS = Object.freeze({
8
9
  nodes: 1_024,
9
10
  messageCodeUnits: 8_388_608,
10
11
  transcriptCodeUnits: 8_388_608,
12
+ contextCodeUnits: 8_388_608,
11
13
  });
12
14
  /** Immutable tree with one selected model/transcript path. */
13
15
  export class ConversationTree {
@@ -34,6 +36,7 @@ export class ConversationTree {
34
36
  identity: node.identity,
35
37
  messages: node.messages,
36
38
  blocks: node.blocks,
39
+ ...(node.context === undefined ? {} : { context: node.context }),
37
40
  }, node.settlement);
38
41
  const restored = tree.activeNode;
39
42
  if (restored === undefined || restored.id !== node.id) {
@@ -92,6 +95,9 @@ export class ConversationTree {
92
95
  get history() {
93
96
  return this.#path().flatMap((node) => clone(node.messages));
94
97
  }
98
+ get contextHistory() {
99
+ return projectContext(this.#path());
100
+ }
95
101
  get transcript() {
96
102
  return this.#path().flatMap((node) => clone(node.blocks));
97
103
  }
@@ -108,6 +114,7 @@ export class ConversationTree {
108
114
  identity: draft.identity,
109
115
  messages: draft.messages,
110
116
  blocks: settledBlocks(draft.blocks),
117
+ ...(draft.context === undefined ? {} : { context: draft.context }),
111
118
  });
112
119
  assertTurn(node);
113
120
  const nodes = [...this.#nodes, node];
@@ -127,6 +134,7 @@ export class ConversationTree {
127
134
  identity: draft.identity,
128
135
  messages: draft.messages,
129
136
  blocks: settledBlocks(draft.blocks),
137
+ context: draft.context ?? current.context,
130
138
  });
131
139
  assertTurn(node);
132
140
  const nodes = [...this.#nodes];
@@ -154,6 +162,7 @@ function ownedNode(node) {
154
162
  identity: Object.freeze({ ...node.identity }),
155
163
  messages: Object.freeze(clone(node.messages)),
156
164
  blocks: Object.freeze(clone(node.blocks)),
165
+ ...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
157
166
  });
158
167
  }
159
168
  function settledBlocks(blocks) {
@@ -189,9 +198,13 @@ function assertTurn(node) {
189
198
  function assertBounds(nodes) {
190
199
  let messageCodeUnits = 0;
191
200
  let transcriptCodeUnits = 0;
201
+ let contextCodeUnits = 0;
192
202
  for (const node of nodes) {
193
203
  messageCodeUnits += JSON.stringify(node.messages).length;
194
204
  transcriptCodeUnits += JSON.stringify(node.blocks).length;
205
+ contextCodeUnits += node.context?.summary.length ?? 0;
206
+ if (node.context !== undefined)
207
+ assertContextPath(nodes, node);
195
208
  }
196
209
  if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
197
210
  throw new Error("conversation model history reached its session limit — start /new");
@@ -199,6 +212,21 @@ function assertBounds(nodes) {
199
212
  if (transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits) {
200
213
  throw new Error("conversation transcript reached its session limit — start /new");
201
214
  }
215
+ if (contextCodeUnits > CONVERSATION_LIMITS.contextCodeUnits) {
216
+ throw new Error("conversation context summaries reached their session limit — start /new");
217
+ }
218
+ }
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");
202
230
  }
203
231
  function validNodeId(value) {
204
232
  return Number.isSafeInteger(value) && value >= 0;
@@ -7,7 +7,7 @@
7
7
  // purpose: the default omits the reasoning text, which on a screen reads as a
8
8
  // long silence before anything appears.
9
9
  import { postSse } from "./http.js";
10
- import { listModels } from "./catalog.js";
10
+ import { modelCatalog } from "./catalog.js";
11
11
  import { keyFor } from "../credentials.js";
12
12
  import { EFFORTS, requireSupportedEffort } from "../effort.js";
13
13
  import { assembleAnthropic } from "./anthropic-stream.js";
@@ -19,6 +19,7 @@ const KEY = "ANTHROPIC_API_KEY";
19
19
  const ADAPTIVE = /^claude-(?:fable-5|mythos-(?:5|preview)|opus-(?:5|4-[678])|sonnet-(?:5|4-6))(?:-|$)/;
20
20
  const MAX_WITHOUT_XHIGH = ["low", "medium", "high", "max"];
21
21
  const ANTHROPIC_45_EFFORTS = ["low", "medium", "high"];
22
+ let contextByModel = new Map();
22
23
  export function supportsAdaptiveThinking(model) {
23
24
  return ADAPTIVE.test(model);
24
25
  }
@@ -41,12 +42,24 @@ export const anthropic = {
41
42
  },
42
43
  // Newest first is how the endpoint already answers, so the order is left
43
44
  // exactly as it arrives rather than re-sorted into something less useful.
44
- models(signal, onStatus) {
45
- return listModels(MODELS, headers(requireKey()), signal, onStatus);
45
+ async models(signal, onStatus) {
46
+ const catalog = await loadModels(signal, onStatus);
47
+ contextByModel = catalog.contexts;
48
+ return catalog.ids;
46
49
  },
47
50
  async efforts(model) {
48
51
  return anthropicEfforts(model);
49
52
  },
53
+ async contextWindow(model, signal, onStatus) {
54
+ if (contextByModel.has(model))
55
+ return contextByModel.get(model);
56
+ const catalog = await loadModels(signal, onStatus);
57
+ contextByModel = catalog.contexts;
58
+ const context = contextByModel.get(model);
59
+ if (!contextByModel.has(model))
60
+ contextByModel.set(model, undefined);
61
+ return context;
62
+ },
50
63
  location: () => "cloud",
51
64
  async send(req) {
52
65
  const key = requireKey();
@@ -77,6 +90,23 @@ export const anthropic = {
77
90
  return fromWireResponse(data);
78
91
  },
79
92
  };
93
+ async function loadModels(signal, onStatus) {
94
+ const entries = await modelCatalog(MODELS, headers(requireKey()), signal, onStatus);
95
+ return {
96
+ ids: entries.map((entry) => entry.id),
97
+ contexts: new Map(entries.map((entry) => [
98
+ entry.id,
99
+ contextWindow(entry.metadata["max_input_tokens"]),
100
+ ])),
101
+ };
102
+ }
103
+ function contextWindow(value) {
104
+ return validTokenCount(value) ? Object.freeze({ tokens: value }) : undefined;
105
+ }
106
+ function validTokenCount(value) {
107
+ return typeof value === "number" && Number.isSafeInteger(value) &&
108
+ value >= 4_096 && value <= 10_000_000;
109
+ }
80
110
  // Read at the moment it is used, never captured at import: a key typed into
81
111
  // the running window has to count, and so does one exported after startup.
82
112
  function apiKey() {
@@ -13,6 +13,9 @@ export const MAX_MODEL_CATALOG_ENTRIES = 1_000;
13
13
  export const MAX_MODEL_CATALOG_ITEMS = 4_000;
14
14
  export const MAX_MODEL_ID_CHARS = 256;
15
15
  export async function listModels(url, headers, signal, onStatus) {
16
+ return (await modelCatalog(url, headers, signal, onStatus)).map((entry) => entry.id);
17
+ }
18
+ export async function modelCatalog(url, headers, signal, onStatus) {
16
19
  const body = await getJson(url, headers, signal, onStatus);
17
20
  const data = body.data;
18
21
  if (!Array.isArray(data))
@@ -22,16 +25,20 @@ export async function listModels(url, headers, signal, onStatus) {
22
25
  const inspected = Math.min(data.length, MAX_MODEL_CATALOG_ITEMS);
23
26
  for (let index = 0; index < inspected && models.length < MAX_MODEL_CATALOG_ENTRIES; index++) {
24
27
  const entry = data[index];
25
- const id = typeof entry === "object" && entry !== null
26
- ? entry.id
27
- : undefined;
28
+ const metadata = record(entry) ? entry : undefined;
29
+ if (metadata === undefined)
30
+ continue;
31
+ const id = metadata["id"];
28
32
  if (typeof id !== "string" ||
29
33
  id === "" ||
30
34
  id.length > MAX_MODEL_ID_CHARS ||
31
35
  unique.has(id))
32
36
  continue;
33
37
  unique.add(id);
34
- models.push(id);
38
+ models.push({ id, metadata });
35
39
  }
36
40
  return models;
37
41
  }
42
+ function record(value) {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
@@ -6,7 +6,7 @@
6
6
  // pulled or the subscription grants — so the model has to be named with
7
7
  // --model.
8
8
  import { requireSupportedEffort } from "../effort.js";
9
- import { postSse } from "./http.js";
9
+ import { getJson, postJson, postSse } from "./http.js";
10
10
  import { listModels } from "./catalog.js";
11
11
  import { keyFor } from "../credentials.js";
12
12
  import { assembleOllama } from "./ollama-stream.js";
@@ -16,6 +16,8 @@ const KEY = "OLLAMA_API_KEY";
16
16
  // Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
17
17
  const OLLAMA_EFFORTS = ["low", "medium", "high"];
18
18
  let configuredHost;
19
+ const CONTEXT_CACHE_MS = 30_000;
20
+ const contextByEndpoint = new Map();
19
21
  /** Set the endpoint selected for this process. Undefined restores key-aware inference. */
20
22
  export function configureOllama(host) {
21
23
  configuredHost = host === undefined ? undefined : parseOllamaEndpoint(host).baseUrl;
@@ -51,6 +53,21 @@ export const ollama = {
51
53
  async efforts() {
52
54
  return OLLAMA_EFFORTS;
53
55
  },
56
+ async contextWindow(model, signal, onStatus) {
57
+ const at = endpoint();
58
+ const cacheKey = `${at.baseUrl}\u0000${model}`;
59
+ const cached = contextByEndpoint.get(cacheKey);
60
+ if (cached !== undefined && cached.expiresAt > Date.now())
61
+ return cached.value;
62
+ const observed = await nativeContextWindow(at, model, signal, onStatus);
63
+ if (observed?.runtime === true) {
64
+ contextByEndpoint.set(cacheKey, {
65
+ value: observed.value,
66
+ expiresAt: Date.now() + CONTEXT_CACHE_MS,
67
+ });
68
+ }
69
+ return observed?.value;
70
+ },
54
71
  location: () => {
55
72
  try {
56
73
  return endpoint().loopback ? "local" : "cloud";
@@ -79,6 +96,63 @@ export const ollama = {
79
96
  return fromWireReply(reply);
80
97
  },
81
98
  };
99
+ async function nativeContextWindow(at, model, signal, onStatus) {
100
+ try {
101
+ const running = await getJson(`${at.baseUrl}/api/ps`, headers(at), signal, onStatus);
102
+ const allocated = runningContext(running, model);
103
+ if (allocated !== undefined)
104
+ return { value: usableContext(allocated), runtime: true };
105
+ }
106
+ catch (error) {
107
+ throwIfAborted(signal, error);
108
+ }
109
+ try {
110
+ const details = await postJson(`${at.baseUrl}/api/show`, headers(at), { model }, signal, onStatus);
111
+ const capacity = modelCapacity(details);
112
+ return capacity === undefined
113
+ ? undefined
114
+ : { value: usableContext(capacity), runtime: false };
115
+ }
116
+ catch (error) {
117
+ throwIfAborted(signal, error);
118
+ return undefined;
119
+ }
120
+ }
121
+ function runningContext(value, model) {
122
+ if (!record(value) || !Array.isArray(value["models"]))
123
+ return undefined;
124
+ for (const entry of value["models"]) {
125
+ if (!record(entry))
126
+ continue;
127
+ if (entry["name"] !== model && entry["model"] !== model)
128
+ continue;
129
+ if (validTokenCount(entry["context_length"]))
130
+ return entry["context_length"];
131
+ }
132
+ return undefined;
133
+ }
134
+ function modelCapacity(value) {
135
+ if (!record(value) || !record(value["model_info"]))
136
+ return undefined;
137
+ const capacities = Object.entries(value["model_info"])
138
+ .filter(([name, count]) => name.endsWith(".context_length") && validTokenCount(count))
139
+ .map(([, count]) => count);
140
+ return capacities.length === 0 ? undefined : Math.max(...capacities);
141
+ }
142
+ function validTokenCount(value) {
143
+ return typeof value === "number" && Number.isSafeInteger(value) &&
144
+ value >= 4_096 && value <= 10_000_000;
145
+ }
146
+ function usableContext(tokens) {
147
+ return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
148
+ }
149
+ function record(value) {
150
+ return typeof value === "object" && value !== null && !Array.isArray(value);
151
+ }
152
+ function throwIfAborted(signal, error) {
153
+ if (signal?.aborted === true)
154
+ throw error;
155
+ }
82
156
  function endpoint() {
83
157
  return ollamaConnection();
84
158
  }
@@ -19,6 +19,7 @@ const MAX_MODELS = 1_000;
19
19
  const MAX_MODEL_CHARS = 256;
20
20
  const XHIGH_EFFORTS = ["low", "medium", "high", "xhigh"];
21
21
  let effortByModel = new Map();
22
+ let contextByModel = new Map();
22
23
  export const openaiCodex = {
23
24
  id: ID,
24
25
  defaultModel: "",
@@ -29,7 +30,7 @@ export const openaiCodex = {
29
30
  location: () => "cloud",
30
31
  async models(signal, onStatus) {
31
32
  const catalog = await loadCatalog(signal, onStatus);
32
- effortByModel = catalog.efforts;
33
+ rememberCatalog(catalog);
33
34
  return catalog.ids;
34
35
  },
35
36
  async efforts(model, signal, onStatus) {
@@ -37,9 +38,19 @@ export const openaiCodex = {
37
38
  if (cached !== undefined)
38
39
  return cached;
39
40
  const catalog = await loadCatalog(signal, onStatus);
40
- effortByModel = catalog.efforts;
41
+ rememberCatalog(catalog);
41
42
  return effortByModel.get(model) ?? fallbackEfforts(model);
42
43
  },
44
+ async contextWindow(model, signal, onStatus) {
45
+ if (contextByModel.has(model))
46
+ return contextByModel.get(model);
47
+ const catalog = await loadCatalog(signal, onStatus);
48
+ rememberCatalog(catalog);
49
+ const context = contextByModel.get(model);
50
+ if (!contextByModel.has(model))
51
+ contextByModel.set(model, undefined);
52
+ return context;
53
+ },
43
54
  async send(req) {
44
55
  const efforts = effortByModel.get(req.model) ?? fallbackEfforts(req.model);
45
56
  const effort = requireSupportedEffort(req.model, req.effort, efforts);
@@ -75,6 +86,10 @@ async function loadCatalog(signal, onStatus) {
75
86
  return modelCatalog(body);
76
87
  }, signal, onStatus);
77
88
  }
89
+ function rememberCatalog(catalog) {
90
+ effortByModel = catalog.efforts;
91
+ contextByModel = catalog.contexts;
92
+ }
78
93
  async function withAuthorization(operation, signal, onStatus) {
79
94
  let authorization = await openAIAuthorization(undefined, signal, onStatus);
80
95
  try {
@@ -120,6 +135,7 @@ function modelCatalog(value) {
120
135
  id,
121
136
  priority: typeof entry["priority"] === "number" ? entry["priority"] : 0,
122
137
  efforts: reasoningLevels(entry, id),
138
+ context: modelContextWindow(entry),
123
139
  }];
124
140
  })
125
141
  .sort((left, right) => left.priority - right.priority)
@@ -127,8 +143,36 @@ function modelCatalog(value) {
127
143
  return {
128
144
  ids: models.map((entry) => entry.id),
129
145
  efforts: new Map(models.map((entry) => [entry.id, entry.efforts])),
146
+ contexts: new Map(models.map((entry) => [entry.id, entry.context])),
130
147
  };
131
148
  }
149
+ function modelContextWindow(entry) {
150
+ const resolved = tokenCount(entry["context_window"]) ?? tokenCount(entry["max_context_window"]);
151
+ if (resolved === undefined)
152
+ return undefined;
153
+ const percent = percentage(entry["effective_context_window_percent"]) ?? 95;
154
+ const tokens = Math.floor(resolved * percent / 100);
155
+ if (!validTokenCount(tokens))
156
+ return undefined;
157
+ const automatic = Math.floor(resolved * 9 / 10);
158
+ const advertised = tokenCount(entry["auto_compact_token_limit"]);
159
+ return Object.freeze({
160
+ tokens,
161
+ compactAtTokens: Math.min(advertised ?? automatic, automatic, tokens),
162
+ });
163
+ }
164
+ function tokenCount(value) {
165
+ return validTokenCount(value) ? value : undefined;
166
+ }
167
+ function validTokenCount(value) {
168
+ return typeof value === "number" && Number.isSafeInteger(value) &&
169
+ value >= 4_096 && value <= 10_000_000;
170
+ }
171
+ function percentage(value) {
172
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 && value <= 100
173
+ ? value
174
+ : undefined;
175
+ }
132
176
  function reasoningLevels(entry, model) {
133
177
  const source = entry["supported_reasoning_levels"];
134
178
  if (!Array.isArray(source))
@@ -37,6 +37,20 @@ export function openAIEfforts(model) {
37
37
  return STANDARD_EFFORTS;
38
38
  return STANDARD_EFFORTS;
39
39
  }
40
+ /** Conservative capacities for the reasoning families accepted by this transport. */
41
+ export function openAIContextWindow(model) {
42
+ if (/^gpt-5\.6(?:[.-]|$)/.test(model))
43
+ return usableContext(1_050_000);
44
+ if (/^gpt-5(?:[.-]|$)/.test(model))
45
+ return usableContext(400_000);
46
+ if (/^(?:o(?:1|3|4)|codex-mini)(?:[.-]|$)/.test(model)) {
47
+ return usableContext(200_000);
48
+ }
49
+ return undefined;
50
+ }
51
+ function usableContext(tokens) {
52
+ return Object.freeze({ tokens: Math.floor(tokens * 95 / 100) });
53
+ }
40
54
  export const openai = {
41
55
  id: "openai",
42
56
  defaultModel: "gpt-5",
@@ -55,6 +69,9 @@ export const openai = {
55
69
  async efforts(model) {
56
70
  return openAIEfforts(model);
57
71
  },
72
+ async contextWindow(model) {
73
+ return openAIContextWindow(model);
74
+ },
58
75
  location: () => "cloud",
59
76
  async send(req) {
60
77
  const key = requireKey();
@@ -1,7 +1,8 @@
1
1
  // Strict codecs for session files. Disk is an untrusted boundary even when
2
2
  // the directory is owner-only: every value is bounded and re-owned before it
3
3
  // can become conversation or provider input.
4
- export const SESSION_SCHEMA = 1;
4
+ import { CONTEXT_LIMITS } from "../context/projection.js";
5
+ export const SESSION_SCHEMA = 2;
5
6
  export const SESSION_FILE_LIMITS = Object.freeze({
6
7
  text: 1_048_576,
7
8
  jsonDepth: 24,
@@ -16,14 +17,14 @@ export function decodeMeta(value) {
16
17
  if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
17
18
  throw invalid();
18
19
  }
19
- if (value["version"] !== SESSION_SCHEMA ||
20
+ if (!schema(value["version"]) ||
20
21
  !identifier(value["id"]) ||
21
22
  !bounded(value["workspaceRoot"], 32_768) ||
22
23
  !digest(value["workspaceDigest"]) ||
23
24
  !timestamp(value["createdAt"]))
24
25
  throw invalid();
25
26
  return Object.freeze({
26
- version: 1,
27
+ version: value["version"],
27
28
  id: value["id"],
28
29
  workspaceRoot: value["workspaceRoot"],
29
30
  workspaceDigest: value["workspaceDigest"],
@@ -37,7 +38,7 @@ export function decodeHead(value) {
37
38
  if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
38
39
  throw invalid();
39
40
  }
40
- if (value["version"] !== SESSION_SCHEMA ||
41
+ if (!schema(value["version"]) ||
41
42
  !integer(value["sequence"], 0) ||
42
43
  !integer(value["nodeId"], 1) ||
43
44
  !integer(value["parentId"], 0) || value["parentId"] >= value["nodeId"] ||
@@ -45,7 +46,7 @@ export function decodeHead(value) {
45
46
  !timestamp(value["updatedAt"]))
46
47
  throw invalid();
47
48
  return Object.freeze({
48
- version: 1,
49
+ version: value["version"],
49
50
  sequence: value["sequence"],
50
51
  nodeId: value["nodeId"],
51
52
  parentId: value["parentId"],
@@ -67,17 +68,22 @@ export function encodeNode(node, sequence, updatedAt) {
67
68
  identity: node.identity,
68
69
  messages: node.messages.map(messageRecord),
69
70
  blocks: node.blocks.flatMap(blockRecord),
71
+ context: node.context ?? null,
70
72
  },
71
73
  });
72
74
  }
73
75
  export function decodeNode(value) {
74
76
  if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
75
77
  throw invalid();
76
- if (value["version"] !== SESSION_SCHEMA || !integer(value["sequence"], 1) ||
78
+ const version = value["version"];
79
+ if (!schema(version) || !integer(value["sequence"], 1) ||
77
80
  !timestamp(value["updatedAt"]))
78
81
  throw invalid();
79
82
  const raw = value["node"];
80
- if (!record(raw) || !keys(raw, "blocks,createdAt,id,identity,messages,parentId,revision,settlement")) {
83
+ const nodeKeys = version === 1
84
+ ? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
85
+ : "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement";
86
+ if (!record(raw) || !keys(raw, nodeKeys)) {
81
87
  throw invalid();
82
88
  }
83
89
  const identity = raw["identity"];
@@ -94,6 +100,9 @@ export function decodeNode(value) {
94
100
  !Array.isArray(messages) || messages.length > SESSION_FILE_LIMITS.blocks ||
95
101
  !Array.isArray(blocks) || blocks.length > SESSION_FILE_LIMITS.blocks)
96
102
  throw invalid();
103
+ const context = version === 1
104
+ ? undefined
105
+ : contextFromRecord(raw["context"], raw["id"], messages.length);
97
106
  const node = Object.freeze({
98
107
  id: raw["id"],
99
108
  parentId: raw["parentId"],
@@ -107,9 +116,29 @@ export function decodeNode(value) {
107
116
  }),
108
117
  messages: Object.freeze(messages.map(messageFromRecord)),
109
118
  blocks: Object.freeze(blocks.map(blockFromRecord)),
119
+ ...(context === undefined ? {} : { context: Object.freeze(context) }),
110
120
  });
111
121
  return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
112
122
  }
123
+ function contextFromRecord(value, ownerId, ownerMessages) {
124
+ if (value === null)
125
+ return undefined;
126
+ if (!record(value) || !keys(value, "createdAt,messageCount,summary,throughNodeId")) {
127
+ throw invalid();
128
+ }
129
+ if (!integer(value["throughNodeId"], 1) || value["throughNodeId"] > ownerId ||
130
+ !integer(value["messageCount"], 0) ||
131
+ (value["throughNodeId"] === ownerId && value["messageCount"] > ownerMessages) ||
132
+ !timestamp(value["createdAt"]) ||
133
+ !bounded(value["summary"], CONTEXT_LIMITS.summaryCodeUnits))
134
+ throw invalid();
135
+ return {
136
+ throughNodeId: value["throughNodeId"],
137
+ messageCount: value["messageCount"],
138
+ createdAt: value["createdAt"],
139
+ summary: value["summary"],
140
+ };
141
+ }
113
142
  function messageRecord(message) {
114
143
  return {
115
144
  role: message.role,
@@ -326,6 +355,9 @@ function boundedText(value, limit = SESSION_FILE_LIMITS.text) {
326
355
  function integer(value, minimum) {
327
356
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
328
357
  }
358
+ function schema(value) {
359
+ return value === 1 || value === SESSION_SCHEMA;
360
+ }
329
361
  function nullableInteger(value, minimum) {
330
362
  return value === null || integer(value, minimum);
331
363
  }