@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 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.4.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
@@ -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 | Start a clean conversation and reset session tool permissions |
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. Live work
210
- stays visible on the reasoning and tool rail; the right edge carries the
211
- interrupt hint, readiness guidance, and temporary feedback without polluting
212
- the transcript. Slash commands never append content to the conversation or its
213
- Markdown export; **/help** closes with **Esc**, and token accounting remains
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.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
 
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
- session.history.length = 0;
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) {
@@ -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
+ }
@@ -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
- // `effort` has no equivalent in this shape and is dropped rather than
62
- // guessed at depth on these models is a property of the model chosen.
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);