@giovannijecha/jecode 0.5.0 → 0.6.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.
@@ -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
  }
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
11
  import { userDataPath } from "../user-data.js";
12
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, } from "./codec.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
13
13
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
14
  const DIRECTORY_MODE = 0o700;
15
15
  const FILE_MODE = 0o600;
@@ -104,7 +104,7 @@ export class DurableSessionStore {
104
104
  throw new Error("session checkpoint cannot be recovered safely");
105
105
  }
106
106
  head = {
107
- version: 1,
107
+ version: SESSION_SCHEMA,
108
108
  sequence: candidate.sequence,
109
109
  nodeId: candidate.node.id,
110
110
  parentId: candidate.node.parentId,
@@ -128,14 +128,14 @@ export class DurableSessionStore {
128
128
  const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
129
129
  const target = this.#sessionDirectory(id);
130
130
  const meta = {
131
- version: 1,
131
+ version: SESSION_SCHEMA,
132
132
  id,
133
133
  workspaceRoot: this.workspaceRoot,
134
134
  workspaceDigest: this.workspaceDigest,
135
135
  createdAt: now,
136
136
  };
137
137
  const head = {
138
- version: 1,
138
+ version: SESSION_SCHEMA,
139
139
  sequence: conversation.nodes.length,
140
140
  nodeId: active.id,
141
141
  parentId: active.parentId,
@@ -187,7 +187,7 @@ export class DurableSessionStore {
187
187
  assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
188
188
  const now = new Date().toISOString();
189
189
  const head = {
190
- version: 1,
190
+ version: SESSION_SCHEMA,
191
191
  sequence: previous.head.sequence + 1,
192
192
  nodeId: active.id,
193
193
  parentId: active.parentId,
@@ -1,6 +1,7 @@
1
1
  // The persistent settings hub. Provider access and model selection reuse the
2
2
  // same command flows exposed directly through /providers and /models.
3
3
  import { saveCommandSettings } from "./command-settings.js";
4
+ import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
4
5
  import { modelsCommand } from "./model-command.js";
5
6
  import { providerFailure } from "./provider-errors.js";
6
7
  import { providerLabel } from "./provider-label.js";
@@ -43,6 +44,9 @@ export async function settingsCommand(session, host) {
43
44
  case "maxSteps":
44
45
  await numberSetting(session, host, "maxSteps", "max tool steps");
45
46
  break;
47
+ case "compactionPercent":
48
+ await compactionSetting(session, host);
49
+ break;
46
50
  case "reducedMotion":
47
51
  await motionSetting(session, host);
48
52
  break;
@@ -53,9 +57,11 @@ export async function settingsCommand(session, host) {
53
57
  }
54
58
  }
55
59
  export function settingsPicker(values, index = 0) {
60
+ const items = settingsItems(values);
56
61
  return {
57
62
  title: [],
58
- options: settingsItems(values).map((item) => item.option),
63
+ options: items.map((item) => item.option),
64
+ visible: items.length,
59
65
  index,
60
66
  };
61
67
  }
@@ -76,13 +82,17 @@ function settingsItems(values) {
76
82
  option: { label: "max output tokens", value: String(values.maxTokens) },
77
83
  }]),
78
84
  { action: "maxSteps", option: { label: "max tool steps", value: String(values.maxSteps) } },
85
+ {
86
+ action: "compactionPercent",
87
+ option: { label: "context compaction", value: `${values.compactionPercent}%` },
88
+ },
79
89
  {
80
90
  action: "reducedMotion",
81
91
  option: { label: "reduced motion", value: values.reducedMotion ? "on" : "off" },
82
92
  },
83
93
  {
84
94
  action: "providers",
85
- option: { label: "providers", hint: "manage access and connections" },
95
+ option: { label: "providers", hint: "manage connections" },
86
96
  },
87
97
  ];
88
98
  }
@@ -93,6 +103,7 @@ function settingsValues(session) {
93
103
  effort: session.config.effort,
94
104
  ...(session.provider.id === "openai-codex" ? {} : { maxTokens: session.config.maxTokens }),
95
105
  maxSteps: session.config.maxSteps,
106
+ compactionPercent: session.config.compactionPercent,
96
107
  reducedMotion: session.config.reducedMotion,
97
108
  };
98
109
  }
@@ -185,6 +196,35 @@ async function numberSetting(session, host, name, label) {
185
196
  return;
186
197
  session.config[name] = value;
187
198
  }
199
+ async function compactionSetting(session, host) {
200
+ if (host.type === undefined)
201
+ return;
202
+ const label = "context compaction";
203
+ const field = {
204
+ title: heading(label, `${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT} percent`, session.palette),
205
+ right: "enter save · esc back",
206
+ editor: of(String(session.config.compactionPercent)),
207
+ secret: false,
208
+ note: "Compacts when model context reaches this percentage.",
209
+ };
210
+ const text = await host.type(field);
211
+ if (text === undefined)
212
+ return;
213
+ const value = Number(text);
214
+ if (!Number.isSafeInteger(value) ||
215
+ value < MIN_COMPACTION_PERCENT ||
216
+ value > MAX_COMPACTION_PERCENT) {
217
+ host.emit({
218
+ kind: "notice",
219
+ text: `${label} must be from ${MIN_COMPACTION_PERCENT} to ${MAX_COMPACTION_PERCENT}`,
220
+ tone: "error",
221
+ });
222
+ return;
223
+ }
224
+ if (!(await saveCommandSettings(host, { compactionPercent: value })))
225
+ return;
226
+ session.config.compactionPercent = value;
227
+ }
188
228
  function chooser(host) {
189
229
  if (host.choose === undefined) {
190
230
  host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });