@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.
- package/README.md +63 -23
- package/dist/batch.js +22 -2
- package/dist/cli-info.js +3 -0
- package/dist/command-settings.js +19 -0
- package/dist/commands.js +11 -14
- package/dist/config.js +2 -0
- package/dist/controller.js +3 -0
- package/dist/conversation.js +208 -0
- package/dist/credential-commands.js +63 -80
- package/dist/credentials.js +7 -1
- package/dist/launch.js +19 -0
- package/dist/model-command.js +171 -0
- package/dist/permission-command.js +52 -53
- package/dist/provider-commands.js +71 -228
- package/dist/provider-errors.js +4 -3
- package/dist/provider-label.js +2 -2
- package/dist/providers/ollama.js +8 -3
- package/dist/sessions/codec.js +344 -0
- package/dist/sessions/lease.js +76 -0
- package/dist/sessions/runtime.js +73 -0
- package/dist/sessions/store.js +368 -0
- package/dist/settings-command.js +43 -98
- package/dist/start.js +67 -4
- package/dist/transcript-types.js +6 -0
- package/dist/tui/activity.js +3 -0
- package/dist/tui/app-input.js +9 -6
- package/dist/tui/app-workflows.js +37 -8
- package/dist/tui/app.js +72 -20
- package/dist/tui/components/composer.js +1 -1
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +27 -15
- package/dist/tui/components/messages.js +1 -15
- package/dist/tui/components/prompt.js +2 -2
- package/dist/tui/components/status.js +5 -2
- package/dist/tui/components/tool.js +22 -5
- package/dist/tui/editor.js +54 -7
- package/dist/tui/feedback.js +5 -2
- package/dist/tui/field.js +1 -1
- package/dist/tui/help.js +4 -1
- package/dist/tui/input.js +4 -0
- package/dist/tui/keys.js +14 -3
- package/dist/tui/overlay.js +6 -0
- package/dist/tui/picker.js +26 -10
- package/dist/tui/resume.js +24 -0
- package/dist/tui/session-view.js +2 -2
- package/dist/tui/turn.js +2 -3
- package/dist/tui/view.js +1 -1
- package/dist/ui/diff.js +2 -0
- package/dist/ui/inline.js +2 -2
- package/dist/ui/markdown.js +7 -7
- package/dist/ui/render.js +2 -0
- package/dist/ui/theme.js +4 -4
- package/dist/usage.js +9 -0
- 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.
|
|
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
|
-
**/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 **
|
|
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 **
|
|
172
|
-
|
|
173
|
-
**https://ollama.com**; without a key, Jecode uses the local
|
|
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
|
|
196
|
+
| /settings | Manage the selected model, limits, effort, motion, and provider access |
|
|
183
197
|
| /effort | Change and save reasoning effort directly |
|
|
184
|
-
| /providers |
|
|
185
|
-
| /models | Search
|
|
186
|
-
| /
|
|
187
|
-
| /
|
|
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.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
the transcript. Slash commands never append content to the
|
|
208
|
-
Markdown export; **/help** closes with **Esc**, and token
|
|
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.
|
|
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
|
-
|
|
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
|
|
6
|
-
// this file keeps
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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: "
|
|
28
|
-
{ name: "
|
|
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
|
-
|
|
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) {
|
package/dist/controller.js
CHANGED
|
@@ -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
|
+
}
|