@giovannijecha/jecode 0.4.0 → 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 +41 -8
- package/dist/batch.js +22 -2
- package/dist/cli-info.js +3 -0
- package/dist/commands.js +3 -2
- package/dist/config.js +2 -0
- package/dist/controller.js +3 -0
- package/dist/conversation.js +208 -0
- package/dist/launch.js +19 -0
- 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/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 +27 -3
- package/dist/tui/app.js +71 -19
- package/dist/tui/components/messages.js +0 -14
- package/dist/tui/components/status.js +4 -1
- package/dist/tui/components/tool.js +19 -2
- package/dist/tui/feedback.js +3 -0
- package/dist/tui/resume.js +24 -0
- package/dist/tui/turn.js +2 -3
- package/dist/ui/diff.js +2 -0
- package/dist/ui/render.js +2 -0
- 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
|
|
@@ -186,7 +198,7 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
186
198
|
| /providers | Manage API keys, ChatGPT sign-in, and Ollama connections |
|
|
187
199
|
| /models | Search models across every available provider and select one |
|
|
188
200
|
| /permissions | Change session tool access inline and review remembered approvals |
|
|
189
|
-
| /new |
|
|
201
|
+
| /new | Close the current conversation, start clean, and reset tool permissions |
|
|
190
202
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
191
203
|
| /help | Open a temporary keyboard reference in the composer dock |
|
|
192
204
|
| /exit | Restore the terminal and exit |
|
|
@@ -206,12 +218,12 @@ Useful controls:
|
|
|
206
218
|
the place you are reading.
|
|
207
219
|
- **Ctrl+O** expands or compacts the latest reasoning or tool-detail block.
|
|
208
220
|
|
|
209
|
-
The one-line footer keeps model, effort, and workspace on the left.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
the transcript. Slash commands never append content to the
|
|
213
|
-
Markdown export; **/help** closes with **Esc**, and token
|
|
214
|
-
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.
|
|
215
227
|
|
|
216
228
|
## Configuration
|
|
217
229
|
|
|
@@ -229,6 +241,7 @@ settings, built-in defaults.
|
|
|
229
241
|
| --max-steps | JECODE_MAX_STEPS | 40 |
|
|
230
242
|
| --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
|
|
231
243
|
| --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
|
|
244
|
+
| --ephemeral | JECODE_EPHEMERAL=1 | Off |
|
|
232
245
|
|
|
233
246
|
Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
|
|
234
247
|
API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
|
|
@@ -238,6 +251,21 @@ always win. Model selection saves the provider and model as one change; the
|
|
|
238
251
|
separate startup flags remain available for automation and override that saved
|
|
239
252
|
choice.
|
|
240
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.
|
|
268
|
+
|
|
241
269
|
Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
|
|
242
270
|
terminals and pipelines that disable colour.
|
|
243
271
|
|
|
@@ -249,6 +277,8 @@ When stdin or stdout is piped, Jecode switches to a plain line-oriented mode:
|
|
|
249
277
|
printf "explain this project\n" | jecode --root .
|
|
250
278
|
~~~
|
|
251
279
|
|
|
280
|
+
Batch conversations are never written to the session store.
|
|
281
|
+
|
|
252
282
|
Dangerous tools stay denied in batch mode unless **--auto-approve** is supplied
|
|
253
283
|
explicitly. A terminal batch failure is written to stderr and exits non-zero,
|
|
254
284
|
so shell pipelines can stop reliably.
|
|
@@ -277,6 +307,9 @@ untrusted data.
|
|
|
277
307
|
idempotent catalogue reads retry; generation requests are never replayed.
|
|
278
308
|
- Model and filesystem input are bounded before they reach the screen or
|
|
279
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.
|
|
280
313
|
|
|
281
314
|
`run_command` is not an operating-system sandbox: an approved shell command can
|
|
282
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
|
|
package/dist/commands.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// without asking the provider what it has — but none of them ever sends a
|
|
5
5
|
// message. Provider access and model selection stay in their own commands;
|
|
6
6
|
// this file keeps discovery, dispatch, and local session operations.
|
|
7
|
+
import { ConversationTree } from "./conversation.js";
|
|
7
8
|
import { modelsCommand } from "./model-command.js";
|
|
8
9
|
import { providersCommand } from "./provider-commands.js";
|
|
9
10
|
import { permissionsCommand } from "./permission-command.js";
|
|
@@ -45,9 +46,9 @@ export async function handleCommand(line, session, host) {
|
|
|
45
46
|
case "exit":
|
|
46
47
|
return "exit";
|
|
47
48
|
case "new":
|
|
48
|
-
|
|
49
|
+
await host.reset?.();
|
|
50
|
+
session.conversation = ConversationTree.empty();
|
|
49
51
|
session.usage = emptyUsage();
|
|
50
|
-
host.reset?.();
|
|
51
52
|
host.emit({ kind: "notice", text: "new session", tone: "info" });
|
|
52
53
|
return "handled";
|
|
53
54
|
case "export":
|
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
|
+
}
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function parseLaunch(argv) {
|
|
2
|
+
const first = argv[0];
|
|
3
|
+
if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
|
|
4
|
+
if (first !== "resume")
|
|
5
|
+
throw new Error(`unknown command ${first}`);
|
|
6
|
+
const rest = argv.slice(1);
|
|
7
|
+
const latest = rest.filter((value) => value === "--latest").length;
|
|
8
|
+
if (latest > 1)
|
|
9
|
+
throw new Error("--latest may be passed only once");
|
|
10
|
+
return {
|
|
11
|
+
kind: "resume",
|
|
12
|
+
latest: latest === 1,
|
|
13
|
+
configArgs: rest.filter((value) => value !== "--latest"),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (argv.includes("--latest"))
|
|
17
|
+
throw new Error("--latest requires `jecode resume`");
|
|
18
|
+
return { kind: "new", latest: false, configArgs: [...argv] };
|
|
19
|
+
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// daemon. There is no default model — the catalogue is whatever the host has
|
|
6
6
|
// pulled or the subscription grants — so the model has to be named with
|
|
7
7
|
// --model.
|
|
8
|
+
import { requireSupportedEffort } from "../effort.js";
|
|
8
9
|
import { postSse } from "./http.js";
|
|
9
10
|
import { listModels } from "./catalog.js";
|
|
10
11
|
import { keyFor } from "../credentials.js";
|
|
@@ -12,6 +13,8 @@ import { assembleOllama } from "./ollama-stream.js";
|
|
|
12
13
|
import { OLLAMA_CLOUD_HOST, OLLAMA_LOCAL_HOST, ollamaConnectionKind, parseOllamaEndpoint, } from "./ollama-endpoint.js";
|
|
13
14
|
import { fromWireReply, stopNotice, toWireMessages, toWireTool } from "./ollama-wire.js";
|
|
14
15
|
const KEY = "OLLAMA_API_KEY";
|
|
16
|
+
// Ollama also accepts `none`; Jecode's product-wide reasoning floor is `low`.
|
|
17
|
+
const OLLAMA_EFFORTS = ["low", "medium", "high"];
|
|
15
18
|
let configuredHost;
|
|
16
19
|
/** Set the endpoint selected for this process. Undefined restores key-aware inference. */
|
|
17
20
|
export function configureOllama(host) {
|
|
@@ -46,7 +49,7 @@ export const ollama = {
|
|
|
46
49
|
return listModels(`${at.baseUrl}/v1/models`, headers(at), signal, onStatus);
|
|
47
50
|
},
|
|
48
51
|
async efforts() {
|
|
49
|
-
return
|
|
52
|
+
return OLLAMA_EFFORTS;
|
|
50
53
|
},
|
|
51
54
|
location: () => {
|
|
52
55
|
try {
|
|
@@ -58,13 +61,15 @@ export const ollama = {
|
|
|
58
61
|
},
|
|
59
62
|
async send(req) {
|
|
60
63
|
const at = endpoint();
|
|
61
|
-
|
|
62
|
-
//
|
|
64
|
+
const effort = requireSupportedEffort(req.model, req.effort, OLLAMA_EFFORTS);
|
|
65
|
+
// The OpenAI-compatible endpoint accepts this vocabulary for thinking
|
|
66
|
+
// models. Invalid levels are rejected locally instead of being rewritten.
|
|
63
67
|
const events = await postSse(`${at.baseUrl}/v1/chat/completions`, headers(at), {
|
|
64
68
|
model: req.model,
|
|
65
69
|
messages: toWireMessages(req.system, req.messages),
|
|
66
70
|
tools: req.tools.map(toWireTool),
|
|
67
71
|
max_tokens: req.maxTokens,
|
|
72
|
+
reasoning_effort: effort,
|
|
68
73
|
stream: true,
|
|
69
74
|
}, req.signal, req.onStatus);
|
|
70
75
|
const reply = await assembleOllama(events, req.onStream);
|