@giovannijecha/jecode 0.3.2 → 0.5.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.
Files changed (54) hide show
  1. package/README.md +63 -23
  2. package/dist/batch.js +22 -2
  3. package/dist/cli-info.js +3 -0
  4. package/dist/command-settings.js +19 -0
  5. package/dist/commands.js +11 -14
  6. package/dist/config.js +2 -0
  7. package/dist/controller.js +3 -0
  8. package/dist/conversation.js +208 -0
  9. package/dist/credential-commands.js +63 -80
  10. package/dist/credentials.js +7 -1
  11. package/dist/launch.js +19 -0
  12. package/dist/model-command.js +171 -0
  13. package/dist/permission-command.js +52 -53
  14. package/dist/provider-commands.js +71 -228
  15. package/dist/provider-errors.js +4 -3
  16. package/dist/provider-label.js +2 -2
  17. package/dist/providers/ollama.js +8 -3
  18. package/dist/sessions/codec.js +344 -0
  19. package/dist/sessions/lease.js +76 -0
  20. package/dist/sessions/runtime.js +73 -0
  21. package/dist/sessions/store.js +368 -0
  22. package/dist/settings-command.js +43 -98
  23. package/dist/start.js +67 -4
  24. package/dist/transcript-types.js +6 -0
  25. package/dist/tui/activity.js +3 -0
  26. package/dist/tui/app-input.js +9 -6
  27. package/dist/tui/app-workflows.js +37 -8
  28. package/dist/tui/app.js +72 -20
  29. package/dist/tui/components/composer.js +1 -1
  30. package/dist/tui/components/footer.js +1 -1
  31. package/dist/tui/components/menu.js +27 -15
  32. package/dist/tui/components/messages.js +1 -15
  33. package/dist/tui/components/prompt.js +2 -2
  34. package/dist/tui/components/status.js +5 -2
  35. package/dist/tui/components/tool.js +22 -5
  36. package/dist/tui/editor.js +54 -7
  37. package/dist/tui/feedback.js +5 -2
  38. package/dist/tui/field.js +1 -1
  39. package/dist/tui/help.js +4 -1
  40. package/dist/tui/input.js +4 -0
  41. package/dist/tui/keys.js +14 -3
  42. package/dist/tui/overlay.js +6 -0
  43. package/dist/tui/picker.js +26 -10
  44. package/dist/tui/resume.js +24 -0
  45. package/dist/tui/session-view.js +2 -2
  46. package/dist/tui/turn.js +2 -3
  47. package/dist/tui/view.js +1 -1
  48. package/dist/ui/diff.js +2 -0
  49. package/dist/ui/inline.js +2 -2
  50. package/dist/ui/markdown.js +7 -7
  51. package/dist/ui/render.js +2 -0
  52. package/dist/ui/theme.js +4 -4
  53. package/dist/usage.js +9 -0
  54. package/package.json +1 -1
package/README.md CHANGED
@@ -25,7 +25,7 @@
25
25
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
26
  </p>
27
27
 
28
- > Jecode is an early 0.3.x release. The core loop is usable today; commands and
28
+ > Jecode is an early 0.5.x release. The core loop is usable today; commands and
29
29
  > terminal interactions may still evolve before 1.0.
30
30
 
31
31
  ## Why Jecode
@@ -37,6 +37,8 @@
37
37
  diffs, approvals, reasoning, and status all share one full-screen TUI.
38
38
  - **Permission-aware.** Reads stay transparent; dangerous actions ask first.
39
39
  Session approvals can be reviewed and revoked.
40
+ - **Durable by default.** Interactive conversations survive terminal exits and
41
+ can be resumed without replaying tools. Batch runs remain stateless.
40
42
  - **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
41
43
  a local/remote Ollama server without changing the workflow.
42
44
  - **Lean by construction.** Jecode installs as plain JavaScript, runs on
@@ -66,6 +68,16 @@ cd path/to/your/project
66
68
  jecode
67
69
  ~~~
68
70
 
71
+ Resume a saved conversation for the current project with a searchable picker,
72
+ or open the most recent one directly:
73
+
74
+ ~~~console
75
+ jecode resume
76
+ jecode resume --latest
77
+ ~~~
78
+
79
+ Use `jecode --ephemeral` when a conversation must stay memory-only.
80
+
69
81
  You can point at another workspace explicitly:
70
82
 
71
83
  ~~~console
@@ -146,10 +158,11 @@ published package runs no compilation or installation scripts.
146
158
  ## First session
147
159
 
148
160
  Jecode opens on an empty composer instead of forcing a setup wizard. Type
149
- **/settings** when you are ready to choose a provider, select a model, and
150
- configure authentication. An API key can remain in memory for the current
151
- session or be saved explicitly under **~/.jecode**; it is never stored in the
152
- workspace.
161
+ **/providers** to connect the services you use, then **/models** to choose from
162
+ their combined live catalogues. **/settings** keeps the selected model and the
163
+ remaining non-secret defaults together. An API key can remain in memory for
164
+ the current session or be saved explicitly under **~/.jecode**; it is never
165
+ stored in the workspace.
153
166
 
154
167
  | Provider ID | Authentication | Notes |
155
168
  |---|---|---|
@@ -158,7 +171,8 @@ workspace.
158
171
  | openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
159
172
  | ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
160
173
 
161
- Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
174
+ Choose **ChatGPT** in **/providers** to sign in on OpenAI's website without
175
+ pasting a key.
162
176
  Jecode offers a local browser callback and a device-code flow; WSL and remote
163
177
  terminals default to the device code. The connection is saved only after the
164
178
  flow completes. Availability and usage limits are determined by the ChatGPT
@@ -168,10 +182,10 @@ and is not an endorsement of Jecode by OpenAI.
168
182
  Anthropic remains API-key only. Jecode does not reuse a Claude consumer
169
183
  subscription or copy credentials from another client.
170
184
 
171
- Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
172
- in **/settings**. Existing users with an Ollama API key automatically use
173
- **https://ollama.com**; without a key, Jecode uses the local daemon at
174
- **http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
185
+ Choose **Ollama** in **/providers** to manage its API key and select **cloud**,
186
+ **local**, or a custom endpoint. Existing users with an Ollama API key
187
+ automatically use **https://ollama.com**; without a key, Jecode uses the local
188
+ daemon at **http://127.0.0.1:11434**. Remote custom endpoints must use HTTPS.
175
189
 
176
190
  ## Use the TUI
177
191
 
@@ -179,13 +193,12 @@ Type **/** to open searchable command completion inside the composer.
179
193
 
180
194
  | Command | What it does |
181
195
  |---|---|
182
- | /settings | Manage provider, connection, model, limits, motion, and authentication |
196
+ | /settings | Manage the selected model, limits, effort, motion, and provider access |
183
197
  | /effort | Change and save reasoning effort directly |
184
- | /providers | Switch the provider for the next turn |
185
- | /models | Search the live model catalogue |
186
- | /credentials | Manage API keys and the connected ChatGPT account |
187
- | /permissions | Manage session tool access and remembered approvals |
188
- | /new | Start a clean conversation and reset session tool permissions |
198
+ | /providers | Manage API keys, ChatGPT sign-in, and Ollama connections |
199
+ | /models | Search models across every available provider and select one |
200
+ | /permissions | Change session tool access inline and review remembered approvals |
201
+ | /new | Close the current conversation, start clean, and reset tool permissions |
189
202
  | /export | Save a timestamped Markdown transcript in the launch directory |
190
203
  | /help | Open a temporary keyboard reference in the composer dock |
191
204
  | /exit | Restore the terminal and exit |
@@ -193,6 +206,10 @@ Type **/** to open searchable command completion inside the composer.
193
206
  Useful controls:
194
207
 
195
208
  - **Up/Down** moves through command suggestions, menus, and input history.
209
+ - **Left/Right** moves the composer cursor or changes an inline menu value;
210
+ **Ctrl+Left/Right** moves by word.
211
+ - **Backspace/Delete** removes one character; **Ctrl+Backspace/Delete** removes
212
+ one word. **Home/End** moves to the start or end of the composer.
196
213
  - **Tab** completes a slash command without running it; **Enter** sends.
197
214
  - **Alt+Enter** inserts a newline.
198
215
  - **Esc** closes a menu or interrupts the foreground operation.
@@ -201,12 +218,12 @@ Useful controls:
201
218
  the place you are reading.
202
219
  - **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
203
220
 
204
- The one-line footer keeps model, effort, and workspace on the left. Live work
205
- stays visible on the reasoning and tool rail; the right edge carries the
206
- interrupt hint, readiness guidance, and temporary feedback without polluting
207
- the transcript. Slash commands never append content to the conversation or its
208
- Markdown export; **/help** closes with **Esc**, and token accounting remains
209
- internal to the active session.
221
+ The one-line footer keeps model, effort, and workspace on the left. While work
222
+ is active, the right edge shows its current state, elapsed time, and interrupt
223
+ hint; readiness guidance and temporary feedback use the same replaceable space
224
+ without polluting the transcript. Slash commands never append content to the
225
+ conversation or its Markdown export; **/help** closes with **Esc**, and token
226
+ accounting remains internal to the active session.
210
227
 
211
228
  ## Configuration
212
229
 
@@ -224,12 +241,30 @@ settings, built-in defaults.
224
241
  | --max-steps | JECODE_MAX_STEPS | 40 |
225
242
  | --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
226
243
  | --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
244
+ | --ephemeral | JECODE_EPHEMERAL=1 | Off |
227
245
 
228
246
  Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
229
247
  API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
230
248
  separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
231
249
  permissions where the operating system supports them. Environment API keys
232
- always win.
250
+ always win. Model selection saves the provider and model as one change; the
251
+ separate startup flags remain available for automation and override that saved
252
+ choice.
253
+
254
+ Interactive conversations are stored under **~/.jecode/sessions**, scoped to
255
+ the canonical workspace path. A checkpoint contains normalized messages and
256
+ the settled transcript needed to redraw the conversation. It excludes stored
257
+ provider credentials, OAuth tokens, provider-only opaque response data,
258
+ permission choices, draft composer text, transient footer notices, and pending
259
+ tool state. Session
260
+ files use owner-only modes on POSIX; Windows relies on the user-profile ACL.
261
+ `jecode resume` keeps the same durable session identity and advances that
262
+ session's conversation tree, so reopening and continuing a conversation does
263
+ not create duplicate picker entries. `/new` or a fresh launch starts another
264
+ logical session. Resume never executes an old tool call. If a crash left the
265
+ newest turn inside a tool loop, the same session resumes from its latest
266
+ completed ancestor and the next turn becomes a branch inside its tree because
267
+ provider-only continuation data is intentionally not stored.
233
268
 
234
269
  Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
235
270
  terminals and pipelines that disable colour.
@@ -242,6 +277,8 @@ When stdin or stdout is piped, Jecode switches to a plain line-oriented mode:
242
277
  printf "explain this project\n" | jecode --root .
243
278
  ~~~
244
279
 
280
+ Batch conversations are never written to the session store.
281
+
245
282
  Dangerous tools stay denied in batch mode unless **--auto-approve** is supplied
246
283
  explicitly. A terminal batch failure is written to stderr and exits non-zero,
247
284
  so shell pipelines can stop reliably.
@@ -270,6 +307,9 @@ untrusted data.
270
307
  idempotent catalogue reads retry; generation requests are never replayed.
271
308
  - Model and filesystem input are bounded before they reach the screen or
272
309
  provider.
310
+ - Durable session files are versioned, size-bounded, atomically checkpointed,
311
+ and treated as untrusted when loaded. A live lease prevents the same saved
312
+ session from being resumed by two Jecode processes at once.
273
313
 
274
314
  `run_command` is not an operating-system sandbox: an approved shell command can
275
315
  still access files and account resources available to the current user. Review
package/dist/batch.js CHANGED
@@ -30,9 +30,29 @@ export async function runBatch(session, environment = {}) {
30
30
  continue;
31
31
  }
32
32
  write(`> ${terminalText(line)}\n`);
33
- session.history.push({ role: "user", content: [{ kind: "text", text: line }] });
33
+ const parentId = session.conversation.activeNodeId;
34
+ const createdAt = new Date().toISOString();
35
+ const history = session.conversation.history;
36
+ const before = history.length;
37
+ let nodeId;
38
+ history.push({ role: "user", content: [{ kind: "text", text: line }] });
34
39
  const turn = events(emit, session);
35
- await runTurn(session.history, options(session), turn);
40
+ turn.onCheckpoint = async (checkpoint, settlement) => {
41
+ session.conversation = session.conversation.commit({
42
+ ...(nodeId === undefined ? {} : { nodeId }),
43
+ parentId,
44
+ createdAt,
45
+ identity: {
46
+ providerId: session.provider.id,
47
+ model: session.model,
48
+ effort: session.config.effort,
49
+ },
50
+ messages: checkpoint.slice(before),
51
+ blocks: [],
52
+ }, settlement);
53
+ nodeId = session.conversation.activeNodeId;
54
+ };
55
+ await runTurn(history, options(session), turn);
36
56
  turn.flush();
37
57
  }
38
58
  }
package/dist/cli-info.js CHANGED
@@ -5,6 +5,7 @@ const HELP = `jecode — an owned coding agent for the terminal
5
5
 
6
6
  Usage:
7
7
  jecode [options]
8
+ jecode resume [--latest] [options]
8
9
 
9
10
  Options:
10
11
  --root <path> workspace root (default: current directory)
@@ -16,6 +17,8 @@ Options:
16
17
  --max-steps <number> tool-loop ceiling
17
18
  --reduced-motion disable animated terminal states
18
19
  --auto-approve allow dangerous tools for this process
20
+ --ephemeral do not save this conversation
21
+ --latest resume the newest session without a picker
19
22
  -h, --help show this help
20
23
  -v, --version show the installed version
21
24
 
@@ -0,0 +1,19 @@
1
+ // Shared persistence boundary for commands that change non-secret defaults.
2
+ import { updateSettings } from "./settings.js";
3
+ export async function saveCommandSettings(host, patch) {
4
+ try {
5
+ if (host.saveSettings === undefined)
6
+ await updateSettings(patch);
7
+ else
8
+ await host.saveSettings(patch);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ host.emit({
13
+ kind: "notice",
14
+ text: `could not save settings · ${error.message}`,
15
+ tone: "error",
16
+ });
17
+ return false;
18
+ }
19
+ }
package/dist/commands.js CHANGED
@@ -2,10 +2,11 @@
2
2
  //
3
3
  // One of them does reach the network — a menu of models cannot be built
4
4
  // without asking the provider what it has — but none of them ever sends a
5
- // message. Provider and credential interaction lives in provider-commands.ts;
6
- // this file keeps command discovery, dispatch, and local session operations.
7
- import { modelsCommand, providersCommand } from "./provider-commands.js";
8
- import { credentialsCommand } from "./credential-commands.js";
5
+ // message. Provider access and model selection stay in their own commands;
6
+ // this file keeps discovery, dispatch, and local session operations.
7
+ import { ConversationTree } from "./conversation.js";
8
+ import { modelsCommand } from "./model-command.js";
9
+ import { providersCommand } from "./provider-commands.js";
9
10
  import { permissionsCommand } from "./permission-command.js";
10
11
  import { effortCommand, settingsCommand } from "./settings-command.js";
11
12
  import { emptyUsage } from "./usage.js";
@@ -13,8 +14,8 @@ import { emptyUsage } from "./usage.js";
13
14
  * The commands, declared once.
14
15
  *
15
16
  * Deliberately short. `/settings` owns persistent defaults; the narrower
16
- * provider, model, effort, and credential commands remain useful direct paths
17
- * into the same interactions.
17
+ * model, provider-access, and effort commands remain useful direct paths into
18
+ * the same interactions.
18
19
  */
19
20
  export const COMMANDS = [
20
21
  { name: "help", blurb: "show keyboard controls" },
@@ -24,9 +25,8 @@ export const COMMANDS = [
24
25
  { name: "permissions", blurb: "manage session tool access" },
25
26
  { name: "settings", blurb: "change and save jecode defaults" },
26
27
  { name: "effort", blurb: "set the reasoning effort" },
27
- { name: "credentials", blurb: "manage API keys and connected accounts" },
28
- { name: "models", blurb: "pick a model, from what the provider offers" },
29
- { name: "providers", blurb: "pick a provider" },
28
+ { name: "models", blurb: "choose from every available provider" },
29
+ { name: "providers", blurb: "manage provider access and connections" },
30
30
  ];
31
31
  export async function handleCommand(line, session, host) {
32
32
  const [name] = line.slice(1).trim().split(/\s+/);
@@ -46,9 +46,9 @@ export async function handleCommand(line, session, host) {
46
46
  case "exit":
47
47
  return "exit";
48
48
  case "new":
49
- session.history.length = 0;
49
+ await host.reset?.();
50
+ session.conversation = ConversationTree.empty();
50
51
  session.usage = emptyUsage();
51
- host.reset?.();
52
52
  host.emit({ kind: "notice", text: "new session", tone: "info" });
53
53
  return "handled";
54
54
  case "export":
@@ -69,9 +69,6 @@ export async function handleCommand(line, session, host) {
69
69
  case "effort":
70
70
  await effortCommand(session, host);
71
71
  return "handled";
72
- case "credentials":
73
- await credentialsCommand(session, host);
74
- return "handled";
75
72
  case "models":
76
73
  await modelsCommand(session, host);
77
74
  return "handled";
package/dist/config.js CHANGED
@@ -12,6 +12,7 @@ const FLAGS = [
12
12
  "max-steps",
13
13
  "root",
14
14
  "auto-approve",
15
+ "ephemeral",
15
16
  ];
16
17
  export function loadConfig(argv, saved = readSettings()) {
17
18
  const flags = parseFlags(argv);
@@ -42,6 +43,7 @@ export function loadConfig(argv, saved = readSettings()) {
42
43
  maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
43
44
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
44
45
  autoApprove: flags["auto-approve"] === "true" || process.env.JECODE_AUTO_APPROVE === "1",
46
+ ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
45
47
  };
46
48
  }
47
49
  function bool(flag, env, fallback) {
@@ -44,6 +44,7 @@ export async function runTurn(history, options, events, signal) {
44
44
  if (calls.length === 0) {
45
45
  if (assistant.usage !== undefined)
46
46
  events.onUsage?.(assistant.usage);
47
+ await events.onCheckpoint?.(history, "completed");
47
48
  return; // the model is done — hand back to the user
48
49
  }
49
50
  // Consecutive shared reads run together. An exclusive call is an ordered
@@ -100,11 +101,13 @@ export async function runTurn(history, options, events, signal) {
100
101
  // The surface is already failing; the next turn can still proceed.
101
102
  }
102
103
  }
104
+ await events.onCheckpoint?.(history, "checkpointed");
103
105
  if (interrupted)
104
106
  throw abortReason(signal);
105
107
  throw error;
106
108
  }
107
109
  history.push({ role: "user", content: results });
110
+ await events.onCheckpoint?.(history, "checkpointed");
108
111
  }
109
112
  throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
110
113
  }
@@ -0,0 +1,208 @@
1
+ // Canonical settled conversation state.
2
+ //
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.
7
+ export const CONVERSATION_LIMITS = Object.freeze({
8
+ nodes: 1_024,
9
+ messageCodeUnits: 8_388_608,
10
+ transcriptCodeUnits: 8_388_608,
11
+ });
12
+ /** Immutable tree with one selected model/transcript path. */
13
+ export class ConversationTree {
14
+ #nodes;
15
+ #activeNodeId;
16
+ constructor(nodes, activeNodeId) {
17
+ this.#nodes = Object.freeze([...nodes]);
18
+ this.#activeNodeId = activeNodeId;
19
+ Object.freeze(this);
20
+ }
21
+ static empty() {
22
+ return new ConversationTree([], 0);
23
+ }
24
+ static restore(nodes, activeNodeId) {
25
+ let tree = ConversationTree.empty();
26
+ for (let index = 0; index < nodes.length; index++) {
27
+ const node = nodes[index];
28
+ if (node === undefined || node.id !== index + 1) {
29
+ throw new Error("session contains a non-sequential conversation node");
30
+ }
31
+ tree = tree.select(node.parentId).commit({
32
+ parentId: node.parentId,
33
+ createdAt: node.createdAt,
34
+ identity: node.identity,
35
+ messages: node.messages,
36
+ blocks: node.blocks,
37
+ }, node.settlement);
38
+ const restored = tree.activeNode;
39
+ if (restored === undefined || restored.id !== node.id) {
40
+ throw new Error("session conversation could not be restored");
41
+ }
42
+ if (node.revision > 1) {
43
+ const copy = [...tree.#nodes];
44
+ copy[node.id - 1] = ownedNode({ ...restored, revision: node.revision });
45
+ tree = new ConversationTree(copy, node.id);
46
+ }
47
+ }
48
+ return tree.select(activeNodeId);
49
+ }
50
+ /** Commit or extend the one prospective leaf turn. */
51
+ commit(draft, settlement) {
52
+ if (draft.parentId !== (draft.nodeId === undefined
53
+ ? this.#activeNodeId
54
+ : this.node(draft.nodeId)?.parentId)) {
55
+ throw new Error("turn parent no longer matches the selected conversation");
56
+ }
57
+ if (draft.nodeId === undefined)
58
+ return this.#append(draft, settlement);
59
+ return this.#replace(draft, settlement);
60
+ }
61
+ select(nodeId) {
62
+ if (!validNodeId(nodeId) || (nodeId !== 0 && this.#nodes[nodeId - 1]?.id !== nodeId)) {
63
+ throw new Error("conversation node does not exist");
64
+ }
65
+ return new ConversationTree(this.#nodes, nodeId);
66
+ }
67
+ node(nodeId) {
68
+ return nodeId === 0 ? undefined : this.#nodes[nodeId - 1];
69
+ }
70
+ get nodes() {
71
+ return this.#nodes;
72
+ }
73
+ get activeNodeId() {
74
+ return this.#activeNodeId;
75
+ }
76
+ get activeNode() {
77
+ return this.node(this.#activeNodeId);
78
+ }
79
+ /** Select the newest completed turn on the active path, if one exists. */
80
+ latestCompleted() {
81
+ let id = this.#activeNodeId;
82
+ while (id !== 0) {
83
+ const node = this.node(id);
84
+ if (node === undefined)
85
+ throw new Error("conversation path is incomplete");
86
+ if (node.settlement === "completed")
87
+ return this.select(id);
88
+ id = node.parentId;
89
+ }
90
+ return undefined;
91
+ }
92
+ get history() {
93
+ return this.#path().flatMap((node) => clone(node.messages));
94
+ }
95
+ get transcript() {
96
+ return this.#path().flatMap((node) => clone(node.blocks));
97
+ }
98
+ #append(draft, settlement) {
99
+ if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
100
+ throw new Error("conversation reached its session limit — start /new");
101
+ }
102
+ const node = ownedNode({
103
+ id: this.#nodes.length + 1,
104
+ parentId: draft.parentId,
105
+ revision: 1,
106
+ createdAt: draft.createdAt,
107
+ settlement,
108
+ identity: draft.identity,
109
+ messages: draft.messages,
110
+ blocks: settledBlocks(draft.blocks),
111
+ });
112
+ assertTurn(node);
113
+ const nodes = [...this.#nodes, node];
114
+ assertBounds(nodes);
115
+ return new ConversationTree(nodes, node.id);
116
+ }
117
+ #replace(draft, settlement) {
118
+ const id = draft.nodeId;
119
+ const current = this.node(id);
120
+ if (current === undefined || id !== this.#activeNodeId || this.#nodes.some((node) => node.parentId === id)) {
121
+ throw new Error("only the active leaf turn can be checkpointed");
122
+ }
123
+ const node = ownedNode({
124
+ ...current,
125
+ revision: current.revision + 1,
126
+ settlement,
127
+ identity: draft.identity,
128
+ messages: draft.messages,
129
+ blocks: settledBlocks(draft.blocks),
130
+ });
131
+ assertTurn(node);
132
+ const nodes = [...this.#nodes];
133
+ nodes[id - 1] = node;
134
+ assertBounds(nodes);
135
+ return new ConversationTree(nodes, id);
136
+ }
137
+ #path() {
138
+ const path = [];
139
+ let id = this.#activeNodeId;
140
+ while (id !== 0) {
141
+ const node = this.node(id);
142
+ if (node === undefined)
143
+ throw new Error("conversation path is incomplete");
144
+ path.push(node);
145
+ id = node.parentId;
146
+ }
147
+ path.reverse();
148
+ return path;
149
+ }
150
+ }
151
+ function ownedNode(node) {
152
+ return Object.freeze({
153
+ ...node,
154
+ identity: Object.freeze({ ...node.identity }),
155
+ messages: Object.freeze(clone(node.messages)),
156
+ blocks: Object.freeze(clone(node.blocks)),
157
+ });
158
+ }
159
+ function settledBlocks(blocks) {
160
+ return clone(blocks).flatMap((block) => {
161
+ if (block.kind === "notice")
162
+ return [];
163
+ if (block.kind === "reasoning") {
164
+ const { live: _live, expanded: _expanded, ...settled } = block;
165
+ return [settled];
166
+ }
167
+ if (block.kind === "tool") {
168
+ const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
169
+ return [settled];
170
+ }
171
+ return [block];
172
+ });
173
+ }
174
+ function assertTurn(node) {
175
+ if (!validNodeId(node.id) || node.id === 0 ||
176
+ !validNodeId(node.parentId) || node.parentId >= node.id ||
177
+ !Number.isSafeInteger(node.revision) || node.revision < 1 ||
178
+ node.createdAt.length === 0 || node.createdAt.length > 64 ||
179
+ (node.settlement !== "checkpointed" && node.settlement !== "completed") ||
180
+ node.messages.length < 2 || node.messages[0]?.role !== "user" ||
181
+ node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
182
+ node.identity.model.length === 0 || node.identity.model.length > 512 ||
183
+ node.identity.effort.length === 0 || node.identity.effort.length > 32)
184
+ throw new Error("turn checkpoint is invalid");
185
+ if (node.settlement === "completed" && node.messages.at(-1)?.role !== "assistant") {
186
+ throw new Error("a completed turn must end with an assistant message");
187
+ }
188
+ }
189
+ function assertBounds(nodes) {
190
+ let messageCodeUnits = 0;
191
+ let transcriptCodeUnits = 0;
192
+ for (const node of nodes) {
193
+ messageCodeUnits += JSON.stringify(node.messages).length;
194
+ transcriptCodeUnits += JSON.stringify(node.blocks).length;
195
+ }
196
+ if (messageCodeUnits > CONVERSATION_LIMITS.messageCodeUnits) {
197
+ throw new Error("conversation model history reached its session limit — start /new");
198
+ }
199
+ if (transcriptCodeUnits > CONVERSATION_LIMITS.transcriptCodeUnits) {
200
+ throw new Error("conversation transcript reached its session limit — start /new");
201
+ }
202
+ }
203
+ function validNodeId(value) {
204
+ return Number.isSafeInteger(value) && value >= 0;
205
+ }
206
+ function clone(value) {
207
+ return structuredClone(value);
208
+ }