@giovannijecha/jecode 0.8.3 → 0.8.4

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
@@ -175,8 +175,9 @@ configuration, and safety boundaries.
175
175
 
176
176
  Public pull requests are not accepted at this stage. Code changes remain a
177
177
  maintainer and invited-collaborator workflow. See
178
- [CONTRIBUTING.md](CONTRIBUTING.md) and
179
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
178
+ [CONTRIBUTING.md](https://github.com/giovannijecha/jecode/blob/main/CONTRIBUTING.md)
179
+ and
180
+ [CODE_OF_CONDUCT.md](https://github.com/giovannijecha/jecode/blob/main/CODE_OF_CONDUCT.md).
180
181
 
181
182
  ## License
182
183
 
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 180" role="img" aria-label="jecode">
2
+ <text x="380" y="128" fill="#669BD2" font-family="Inter,Nunito Sans,system-ui,sans-serif" font-size="132" font-weight="650" letter-spacing="-7" text-anchor="middle">jecode</text>
3
+ </svg>
package/dist/batch.js CHANGED
@@ -14,6 +14,7 @@ import { columns } from "./ui/render.js";
14
14
  import { terminalText } from "./ui/terminal-text.js";
15
15
  import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
16
16
  import { assertPromptLength, boundedInputLines } from "./input-boundary.js";
17
+ import { providerFailure } from "./provider-errors.js";
17
18
  export async function runBatch(session, environment = {}) {
18
19
  const write = environment.write ?? ((text) => stdout.write(text));
19
20
  const width = environment.width ?? columns();
@@ -115,7 +116,18 @@ export async function runBatch(session, environment = {}) {
115
116
  commit(checkpoint, settlement);
116
117
  return compacted;
117
118
  };
118
- await runTurn(history, options(session, policy), turn, signal, modelHistory);
119
+ try {
120
+ await runTurn(history, options(session, policy), turn, signal, modelHistory);
121
+ }
122
+ catch (error) {
123
+ throwIfAborted(signal);
124
+ if (!(error instanceof Error))
125
+ throw error;
126
+ const message = providerFailure(session.provider, error);
127
+ if (message === error.message)
128
+ throw error;
129
+ throw new Error(message, { cause: error });
130
+ }
119
131
  turn.flush();
120
132
  }
121
133
  throwIfAborted(signal);
package/dist/config.js CHANGED
@@ -43,9 +43,7 @@ export function loadConfig(argv, saved = readSettings()) {
43
43
  : { maxModelRequests: toInt(maxModelRequests, "max-steps") }),
44
44
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
45
45
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
46
- autoApprove: flags["auto-approve"] === "true" ||
47
- flags["auto-approve"] === "1" ||
48
- process.env.JECODE_AUTO_APPROVE === "1",
46
+ autoApprove: autoApproval(flags["auto-approve"], process.env.JECODE_AUTO_APPROVE),
49
47
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
50
48
  };
51
49
  }
@@ -56,6 +54,11 @@ function bool(flag, env, fallback) {
56
54
  return env === "true" || env === "1";
57
55
  return fallback;
58
56
  }
57
+ function autoApproval(flag, env) {
58
+ if (flag !== undefined)
59
+ return flag === "true" || flag === "1";
60
+ return env === "1";
61
+ }
59
62
  function pick(flag, env, fallback) {
60
63
  if (flag !== undefined && flag !== "")
61
64
  return flag;
@@ -15,28 +15,65 @@ export async function providersCommand(session, host) {
15
15
  const choose = chooser(host);
16
16
  if (choose === undefined)
17
17
  return;
18
- let selected = Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id));
18
+ const groups = providerGroups();
19
+ let selected = Math.max(0, groups.findIndex((group) => group.providers.some((provider) => provider.id === session.provider.id)));
19
20
  while (true) {
20
21
  const index = await choose({
21
22
  title: [],
22
- options: PROVIDERS.map((provider) => ({
23
+ options: groups.map((group) => ({
24
+ label: group.label,
25
+ value: providerCount(group.providers.length),
26
+ })),
27
+ index: selected,
28
+ });
29
+ if (index === undefined) {
30
+ throwIfAborted(host.signal);
31
+ return;
32
+ }
33
+ const group = groups[index];
34
+ if (group === undefined)
35
+ return;
36
+ selected = index;
37
+ await providerGroupCommand(group, session, host);
38
+ throwIfAborted(host.signal);
39
+ }
40
+ }
41
+ function providerGroups() {
42
+ return [
43
+ { label: "Account", providers: PROVIDERS.filter((provider) => provider.auth.kind === "oauth") },
44
+ { label: "API", providers: PROVIDERS.filter((provider) => provider.auth.kind !== "oauth") },
45
+ ];
46
+ }
47
+ async function providerGroupCommand(group, session, host) {
48
+ if (host.choose === undefined)
49
+ return;
50
+ let selected = Math.max(0, group.providers.findIndex((provider) => provider.id === session.provider.id));
51
+ while (true) {
52
+ const index = await host.choose({
53
+ title: heading(group.label, "provider access", session.palette),
54
+ options: group.providers.map((provider) => ({
23
55
  label: providerLabel(provider.id),
24
56
  value: providerAccessHint(provider),
25
57
  })),
26
58
  index: selected,
27
59
  });
28
- if (index === undefined)
60
+ if (index === undefined) {
61
+ throwIfAborted(host.signal);
29
62
  return;
30
- const provider = PROVIDERS[index];
63
+ }
64
+ const provider = group.providers[index];
31
65
  if (provider === undefined)
32
66
  return;
33
67
  selected = index;
34
68
  await manageProvider(provider, session, host);
35
- // Esc closes only the nested provider flow. Ctrl+C also settles that
36
- // picker, but aborts the command signal and must not reopen the parent.
69
+ // Esc closes only the provider-specific flow. Ctrl+C also settles that
70
+ // interaction, but aborts the command signal and must not reopen a menu.
37
71
  throwIfAborted(host.signal);
38
72
  }
39
73
  }
74
+ function providerCount(count) {
75
+ return `${count} ${count === 1 ? "provider" : "providers"}`;
76
+ }
40
77
  export function providerAccessHint(provider) {
41
78
  if (provider.id === "ollama") {
42
79
  const connection = ollamaConnection();
@@ -1,11 +1,69 @@
1
1
  // Actionable provider failures for user-facing command and turn surfaces.
2
+ import { redactCredentials } from "./credential-safety.js";
2
3
  import { providerLabel } from "./provider-label.js";
4
+ import { leadingText } from "./text-boundary.js";
5
+ import { terminalText } from "./ui/terminal-text.js";
3
6
  const CONNECTION_FAILURE = /network error calling|timed out waiting for response headers|response body was idle/i;
7
+ const MAX_MESSAGE_CHARS = 1_000;
8
+ const MAX_REASON_CHARS = 500;
9
+ const HTML = /<!doctype\s|<\/?[a-z][^>]*>/i;
4
10
  export function providerFailure(provider, error, labelProvider = false) {
5
11
  if (provider.id === "ollama" && CONNECTION_FAILURE.test(error.message)) {
6
12
  return provider.location?.() === "local"
7
13
  ? "Ollama is not reachable on this computer · start Ollama or choose cloud in /providers"
8
14
  : "Ollama is not reachable · check its connection in /providers";
9
15
  }
10
- return labelProvider ? `${providerLabel(provider.id)}: ${error.message}` : error.message;
16
+ const message = safeText(error.message, MAX_MESSAGE_CHARS) || "provider request failed";
17
+ const reason = providerReason(error);
18
+ const detail = reason !== undefined && !message.toLocaleLowerCase().includes(reason.toLocaleLowerCase())
19
+ ? ` · ${reason}`
20
+ : "";
21
+ const failure = `${message}${detail}`;
22
+ return labelProvider ? `${providerLabel(provider.id)}: ${failure}` : failure;
23
+ }
24
+ function providerReason(error) {
25
+ const body = error.body;
26
+ if (typeof body !== "string" || body.trim() === "")
27
+ return undefined;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(body);
31
+ }
32
+ catch {
33
+ const raw = body.trim();
34
+ if (raw.startsWith("{") || raw.startsWith("[") || HTML.test(raw))
35
+ return undefined;
36
+ return safeReason(raw);
37
+ }
38
+ for (const candidate of reasonCandidates(parsed)) {
39
+ const reason = safeReason(candidate);
40
+ if (reason !== undefined)
41
+ return reason;
42
+ }
43
+ return undefined;
44
+ }
45
+ function reasonCandidates(value) {
46
+ if (typeof value === "string")
47
+ return [value];
48
+ if (!record(value))
49
+ return [];
50
+ const error = value["error"];
51
+ return [
52
+ record(error) ? error["message"] : undefined,
53
+ typeof error === "string" ? error : undefined,
54
+ value["message"],
55
+ value["detail"],
56
+ ].filter((candidate) => typeof candidate === "string");
57
+ }
58
+ function safeReason(text) {
59
+ if (HTML.test(text))
60
+ return undefined;
61
+ return safeText(text, MAX_REASON_CHARS) || undefined;
62
+ }
63
+ function safeText(text, max) {
64
+ const redacted = redactCredentials(text).trim().replace(/\s+/gu, " ");
65
+ return leadingText(terminalText(redacted), max);
66
+ }
67
+ function record(value) {
68
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
69
  }
@@ -40,7 +40,10 @@ export async function postSse(url, headers, body, maxOutputTokens, signal, onSta
40
40
  const res = await request(url, { accept: "text/event-stream", ...headers }, body, signal, onStatus);
41
41
  if (res.body === null)
42
42
  throw httpError(`${url} returned no body`, res.status);
43
- return readSseJson(withIdleTimeout(url, res.body), maximumChars);
43
+ return readSseJson(res.body, maximumChars, {
44
+ milliseconds: BODY_IDLE_TIMEOUT_MS,
45
+ error: () => httpError(`${url} SSE stream was idle for ${BODY_IDLE_TIMEOUT_MS}ms without an event`, res.status),
46
+ });
44
47
  }
45
48
  async function request(url, headers, body, signal, onStatus) {
46
49
  const maxRetries = body === undefined ? GET_RETRIES : 0;
@@ -154,39 +157,6 @@ async function timedRead(url, reader) {
154
157
  clearTimeout(timer);
155
158
  }
156
159
  }
157
- function withIdleTimeout(url, body) {
158
- const reader = body.getReader();
159
- let released = false;
160
- const release = () => {
161
- if (released)
162
- return;
163
- released = true;
164
- reader.releaseLock();
165
- };
166
- return new ReadableStream({
167
- async pull(controller) {
168
- try {
169
- const { done, value } = await timedRead(url, reader);
170
- if (done) {
171
- release();
172
- controller.close();
173
- }
174
- else {
175
- controller.enqueue(value);
176
- }
177
- }
178
- catch (error) {
179
- await reader.cancel(error).catch(() => undefined);
180
- release();
181
- controller.error(error);
182
- }
183
- },
184
- async cancel(reason) {
185
- await reader.cancel(reason).catch(() => undefined);
186
- release();
187
- },
188
- });
189
- }
190
160
  function waitLabel(ms) {
191
161
  return ms < 1_000 ? `${ms}ms` : `${Math.ceil(ms / 1_000)}s`;
192
162
  }
@@ -72,7 +72,7 @@ export const openaiCodex = {
72
72
  include: ["reasoning.encrypted_content"],
73
73
  prompt_cache_key: SESSION_ID,
74
74
  }, req.maxTokens, req.signal, req.onStatus);
75
- const data = await assembleOpenAI(events, req.onStream);
75
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
76
76
  const notice = stopNotice(data);
77
77
  if (notice !== undefined)
78
78
  req.onStream?.({ kind: "text", text: `\n${notice}` });
@@ -4,40 +4,70 @@
4
4
  // response in `response.completed`. The ChatGPT Codex backend can instead send
5
5
  // an empty final `output` after complete `response.output_item.done` events, so
6
6
  // those streamed items remain the fallback when the final envelope is empty.
7
- export async function assembleOpenAI(events, onStream) {
7
+ export async function assembleOpenAI(events, onStream, onStatus) {
8
8
  const items = [];
9
9
  const announcedTools = { identities: new Set(), anonymous: false };
10
10
  let refusal = false;
11
+ let activity;
12
+ const status = (next) => {
13
+ if (activity === next)
14
+ return;
15
+ activity = next;
16
+ onStatus?.(next);
17
+ };
11
18
  for await (const raw of events) {
12
19
  const event = raw;
13
20
  switch (event.type) {
21
+ case "response.created":
22
+ case "response.in_progress":
23
+ if (activity === undefined)
24
+ status("Working");
25
+ break;
14
26
  case "response.output_text.delta":
15
- if (typeof event.delta === "string")
27
+ if (typeof event.delta === "string") {
28
+ status("Responding");
16
29
  onStream?.({ kind: "text", text: event.delta });
30
+ }
17
31
  break;
18
32
  case "response.refusal.delta":
19
33
  if (typeof event.delta === "string") {
34
+ status("Responding");
20
35
  onStream?.({ kind: "text", text: `${refusal ? "" : "[refused] "}${event.delta}` });
21
36
  refusal = true;
22
37
  }
23
38
  break;
24
39
  case "response.reasoning_summary_text.delta":
25
- if (typeof event.delta === "string")
40
+ if (typeof event.delta === "string") {
41
+ status("Thinking");
26
42
  onStream?.({ kind: "thinking", text: event.delta });
43
+ }
44
+ break;
45
+ case "response.reasoning_summary_part.added":
46
+ status("Thinking");
47
+ break;
48
+ case "response.reasoning_summary_text.done":
49
+ case "response.reasoning_summary_part.done":
50
+ status("Working");
27
51
  break;
28
52
  case "response.output_item.added":
29
53
  if (isFunctionCall(event.item)) {
30
- announceTool(event, event.item, announcedTools, onStream);
54
+ announceTool(event, event.item, announcedTools, onStream, status);
55
+ }
56
+ else if (itemType(event.item) === "reasoning") {
57
+ status("Thinking");
58
+ }
59
+ else if (itemType(event.item) === "message") {
60
+ status("Responding");
31
61
  }
32
62
  break;
33
63
  case "response.function_call_arguments.delta":
34
64
  case "response.function_call_arguments.done":
35
- announceTool(event, undefined, announcedTools, onStream);
65
+ announceTool(event, undefined, announcedTools, onStream, status);
36
66
  break;
37
67
  case "response.output_item.done":
38
68
  if (event.item !== undefined) {
39
69
  if (isFunctionCall(event.item)) {
40
- announceTool(event, event.item, announcedTools, onStream);
70
+ announceTool(event, event.item, announcedTools, onStream, status);
41
71
  }
42
72
  items.push(event.item);
43
73
  }
@@ -66,7 +96,12 @@ function isFunctionCall(item) {
66
96
  return typeof item === "object" && item !== null &&
67
97
  item["type"] === "function_call";
68
98
  }
69
- function announceTool(event, item, announced, onStream) {
99
+ function itemType(item) {
100
+ return typeof item === "object" && item !== null
101
+ ? item["type"]
102
+ : undefined;
103
+ }
104
+ function announceTool(event, item, announced, onStream, onStatus) {
70
105
  const identities = toolIdentities(event, item);
71
106
  if (identities.length === 0) {
72
107
  if (announced.anonymous)
@@ -82,6 +117,7 @@ function announceTool(event, item, announced, onStream) {
82
117
  }
83
118
  const rawName = item?.name ?? event.name;
84
119
  const name = typeof rawName === "string" && rawName !== "" ? rawName : undefined;
120
+ onStatus?.(`Preparing ${name ?? "tool"}`);
85
121
  onStream?.({ kind: "tool", ...(name === undefined ? {} : { name }) });
86
122
  }
87
123
  function toolIdentities(event, item) {
@@ -87,7 +87,7 @@ export const openai = {
87
87
  include: ["reasoning.encrypted_content"],
88
88
  stream: true,
89
89
  }, req.maxTokens, req.signal, req.onStatus);
90
- const data = await assembleOpenAI(events, req.onStream);
90
+ const data = await assembleOpenAI(events, req.onStream, req.onStatus);
91
91
  const notice = stopNotice(data);
92
92
  if (notice !== undefined)
93
93
  req.onStream?.({ kind: "text", text: `\n${notice}` });
@@ -4,31 +4,44 @@
4
4
  // `data` matters here — both providers put the event discriminator inside the
5
5
  // JSON payload, so the `event:` line is redundant and skipped.
6
6
  import { addBounded, MAX_SSE_EVENT_CHARS, } from "./stream-limits.js";
7
- export async function* readSseJson(body, maximumChars) {
7
+ export async function* readSseJson(body, maximumChars, idle) {
8
8
  const reader = body.getReader();
9
9
  const decoder = new TextDecoder();
10
10
  const parser = new SseEventParser();
11
11
  let finished = false;
12
12
  let total = 0;
13
13
  try {
14
- for (;;) {
15
- const { done, value } = await reader.read();
16
- if (done)
17
- break;
18
- const text = decoder.decode(value, { stream: true });
19
- total = addBounded(total, text.length, maximumChars, "SSE stream");
20
- for (const payload of parser.push(text))
14
+ let ended = false;
15
+ while (!ended) {
16
+ // One deadline spans every raw read until a complete JSON event lands.
17
+ // SSE comments and partial framing prove only that the socket is alive;
18
+ // they must not keep a model request pending forever.
19
+ const deadline = eventDeadline(idle);
20
+ const payloads = [];
21
+ try {
22
+ while (payloads.length === 0 && !ended) {
23
+ const { done, value } = await deadline.wait(reader.read());
24
+ if (done) {
25
+ ended = true;
26
+ const text = decoder.decode();
27
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
28
+ payloads.push(...parser.push(text));
29
+ const payload = parser.finish();
30
+ if (payload !== undefined)
31
+ payloads.push(payload);
32
+ continue;
33
+ }
34
+ const text = decoder.decode(value, { stream: true });
35
+ total = addBounded(total, text.length, maximumChars, "SSE stream");
36
+ payloads.push(...parser.push(text));
37
+ }
38
+ }
39
+ finally {
40
+ deadline.clear();
41
+ }
42
+ for (const payload of payloads)
21
43
  yield payload;
22
44
  }
23
- // A stream that ends without a trailing blank line still owes us its last
24
- // event.
25
- const text = decoder.decode();
26
- total = addBounded(total, text.length, maximumChars, "SSE stream");
27
- for (const payload of parser.push(text))
28
- yield payload;
29
- const payload = parser.finish();
30
- if (payload !== undefined)
31
- yield payload;
32
45
  finished = true;
33
46
  }
34
47
  finally {
@@ -37,6 +50,21 @@ export async function* readSseJson(body, maximumChars) {
37
50
  reader.releaseLock();
38
51
  }
39
52
  }
53
+ function eventDeadline(idle) {
54
+ if (idle === undefined)
55
+ return { wait: (pending) => pending, clear: () => undefined };
56
+ let timer;
57
+ const expired = new Promise((_resolve, reject) => {
58
+ timer = setTimeout(() => reject(idle.error()), idle.milliseconds);
59
+ });
60
+ return {
61
+ wait: (pending) => Promise.race([pending, expired]),
62
+ clear: () => {
63
+ if (timer !== undefined)
64
+ clearTimeout(timer);
65
+ },
66
+ };
67
+ }
40
68
  // Keep fragments in bounded groups. A provider may split one SSE line into
41
69
  // hundreds of thousands of tiny chunks; repeatedly flattening the growing line
42
70
  // would make parsing quadratic even if boundary scanning itself were linear.
@@ -0,0 +1,199 @@
1
+ // Small, head-tied projections for the resume catalogue.
2
+ //
3
+ // Conversation nodes remain authoritative and are fully decoded when a
4
+ // session is selected. This auxiliary record lets listing stay independent of
5
+ // every unselected tree's depth; a missing or suspect record falls back to the
6
+ // strict loader and can be rebuilt without changing the session schema.
7
+ import { Buffer } from "node:buffer";
8
+ import { CONVERSATION_LIMITS } from "../conversation.js";
9
+ import { leadingText } from "../text-boundary.js";
10
+ import { decodeHead, encodeHead } from "./codec.js";
11
+ export const SESSION_CATALOG_FILE = "catalog.json";
12
+ export const SESSION_CHECKPOINT_FILE = ".checkpoint";
13
+ export const SESSION_CATALOG_BYTES = 4 * 1_024;
14
+ const CATALOG_SCHEMA = 1;
15
+ const PREVIEW_CODE_UNITS = 160;
16
+ export function sessionCatalog(meta, head, conversation) {
17
+ assertActiveHead(head, conversation);
18
+ const resumable = conversation.latestResumable();
19
+ return own({
20
+ version: CATALOG_SCHEMA,
21
+ id: meta.id,
22
+ workspaceDigest: meta.workspaceDigest,
23
+ createdAt: meta.createdAt,
24
+ head,
25
+ resumeNodeId: resumable?.activeNodeId ?? 0,
26
+ turns: resumable === undefined ? 0 : selectedTurnCount(resumable),
27
+ preview: resumable === undefined ? "" : firstUserText(resumable),
28
+ });
29
+ }
30
+ /** Advance the common linear checkpoint path without walking prior turns. */
31
+ export function advanceSessionCatalog(previous, meta, head, conversation) {
32
+ assertActiveHead(head, conversation);
33
+ if (previous.id !== meta.id ||
34
+ previous.workspaceDigest !== meta.workspaceDigest ||
35
+ previous.createdAt !== meta.createdAt)
36
+ throw new Error("session catalogue does not match its metadata");
37
+ const active = conversation.activeNode;
38
+ const revisesHead = active.id === previous.head.nodeId &&
39
+ active.parentId === previous.head.parentId &&
40
+ active.revision === previous.head.revision + 1;
41
+ const extendsHead = active.parentId === previous.head.nodeId &&
42
+ active.id > previous.head.nodeId && active.revision === 1;
43
+ if (!revisesHead && !extendsHead)
44
+ return sessionCatalog(meta, head, conversation);
45
+ // A resumable child of an unfinished head makes that intermediate node part
46
+ // of the selected path. Rebuild in that uncommon case so the turn count is
47
+ // exact instead of assuming one visible turn was appended.
48
+ if (extendsHead && active.settlement !== "checkpointed" &&
49
+ previous.resumeNodeId !== previous.head.nodeId)
50
+ return sessionCatalog(meta, head, conversation);
51
+ if (active.settlement === "checkpointed" && previous.resumeNodeId === active.id) {
52
+ return sessionCatalog(meta, head, conversation);
53
+ }
54
+ const resumable = active.settlement !== "checkpointed";
55
+ const addsTurn = resumable && previous.resumeNodeId !== active.id;
56
+ const turns = previous.turns + (addsTurn ? 1 : 0);
57
+ const preview = turns === 0
58
+ ? ""
59
+ : previous.turns === 0
60
+ ? firstUserTextInNode(active)
61
+ : previous.preview;
62
+ return own({
63
+ version: CATALOG_SCHEMA,
64
+ id: meta.id,
65
+ workspaceDigest: meta.workspaceDigest,
66
+ createdAt: meta.createdAt,
67
+ head,
68
+ resumeNodeId: resumable ? active.id : previous.resumeNodeId,
69
+ turns,
70
+ preview,
71
+ });
72
+ }
73
+ export function encodeSessionCatalog(catalog) {
74
+ const encoded = `${JSON.stringify(catalog, null, 2)}\n`;
75
+ decodeSessionCatalog(JSON.parse(encoded));
76
+ if (Buffer.byteLength(encoded, "utf8") > SESSION_CATALOG_BYTES) {
77
+ throw new Error("session catalogue data is invalid or unsupported");
78
+ }
79
+ return encoded;
80
+ }
81
+ export function decodeSessionCatalog(value) {
82
+ if (!record(value) || !keys(value, "createdAt,head,id,preview,resumeNodeId,turns,version,workspaceDigest"))
83
+ throw invalid();
84
+ const head = decodeHead(value["head"]);
85
+ const resumeNodeId = value["resumeNodeId"];
86
+ const turns = value["turns"];
87
+ const preview = value["preview"];
88
+ if (value["version"] !== CATALOG_SCHEMA ||
89
+ !identifier(value["id"]) ||
90
+ !digest(value["workspaceDigest"]) ||
91
+ !timestamp(value["createdAt"]) ||
92
+ !integer(resumeNodeId, 0) || resumeNodeId > head.nodeId ||
93
+ !integer(turns, 0) || turns > CONVERSATION_LIMITS.nodes || turns > resumeNodeId ||
94
+ typeof preview !== "string" || preview.length > PREVIEW_CODE_UNITS ||
95
+ (resumeNodeId === 0) !== (turns === 0) ||
96
+ (turns === 0 ? preview !== "" : preview === ""))
97
+ throw invalid();
98
+ return Object.freeze({
99
+ version: CATALOG_SCHEMA,
100
+ id: value["id"],
101
+ workspaceDigest: value["workspaceDigest"],
102
+ createdAt: value["createdAt"],
103
+ head,
104
+ resumeNodeId,
105
+ turns,
106
+ preview,
107
+ });
108
+ }
109
+ export function catalogMatches(catalog, meta, head) {
110
+ return catalog.id === meta.id &&
111
+ catalog.workspaceDigest === meta.workspaceDigest &&
112
+ catalog.createdAt === meta.createdAt &&
113
+ sameSessionHead(catalog.head, head);
114
+ }
115
+ export function sameSessionHead(left, right) {
116
+ return left.version === right.version &&
117
+ left.sequence === right.sequence &&
118
+ left.nodeId === right.nodeId &&
119
+ left.parentId === right.parentId &&
120
+ left.revision === right.revision &&
121
+ left.updatedAt === right.updatedAt;
122
+ }
123
+ function own(value) {
124
+ encodeHead(value.head);
125
+ return decodeSessionCatalog(structuredClone(value));
126
+ }
127
+ function assertActiveHead(head, conversation) {
128
+ const active = conversation.activeNode;
129
+ if (active === undefined || active.id !== head.nodeId ||
130
+ active.parentId !== head.parentId || active.revision !== head.revision)
131
+ throw new Error("session catalogue does not match its conversation head");
132
+ }
133
+ function selectedTurnCount(conversation) {
134
+ let count = 0;
135
+ let id = conversation.activeNodeId;
136
+ while (id !== 0) {
137
+ count++;
138
+ id = conversation.node(id)?.parentId ?? 0;
139
+ }
140
+ return count;
141
+ }
142
+ function firstUserText(conversation) {
143
+ const path = [];
144
+ let id = conversation.activeNodeId;
145
+ while (id !== 0) {
146
+ const node = conversation.node(id);
147
+ if (node === undefined)
148
+ throw new Error("session catalogue path is incomplete");
149
+ path.push(node);
150
+ id = node.parentId;
151
+ }
152
+ for (let index = path.length - 1; index >= 0; index--) {
153
+ const preview = userTextInNode(path[index]);
154
+ if (preview !== undefined)
155
+ return preview;
156
+ }
157
+ return "Untitled session";
158
+ }
159
+ function firstUserTextInNode(node) {
160
+ return userTextInNode(node) ?? "Untitled session";
161
+ }
162
+ function userTextInNode(node) {
163
+ for (const message of node.messages) {
164
+ if (message.role !== "user")
165
+ continue;
166
+ const preview = textPreview(message.content.find((block) => block.kind === "text")?.text);
167
+ if (preview !== undefined)
168
+ return preview;
169
+ }
170
+ return undefined;
171
+ }
172
+ function textPreview(text) {
173
+ const normalized = text?.replace(/\s+/gu, " ").trim();
174
+ return normalized === undefined || normalized === ""
175
+ ? undefined
176
+ : leadingText(normalized, PREVIEW_CODE_UNITS);
177
+ }
178
+ function keys(value, expected) {
179
+ return Object.keys(value).sort().join(",") === expected;
180
+ }
181
+ function record(value) {
182
+ return typeof value === "object" && value !== null && !Array.isArray(value);
183
+ }
184
+ function integer(value, minimum) {
185
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
186
+ }
187
+ function identifier(value) {
188
+ return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
189
+ }
190
+ function digest(value) {
191
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
192
+ }
193
+ function timestamp(value) {
194
+ return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
195
+ Number.isFinite(Date.parse(value));
196
+ }
197
+ function invalid() {
198
+ return new Error("session catalogue data is invalid or unsupported");
199
+ }
@@ -4,12 +4,12 @@
4
4
  // after that node is durable, so a crash leaves either the prior checkpoint or
5
5
  // one strictly recoverable mutation -- never an ambiguous partial history.
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
8
8
  import * as path from "node:path";
9
9
  import { atomicWrite } from "../atomic.js";
10
10
  import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
11
- import { leadingText } from "../text-boundary.js";
12
11
  import { userDataPath } from "../user-data.js";
12
+ import { advanceSessionCatalog, catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, sessionCatalog, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
13
13
  import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
14
14
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
15
15
  const DIRECTORY_MODE = 0o700;
@@ -44,24 +44,7 @@ export class DurableSessionStore {
44
44
  for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
45
45
  const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
46
46
  .map(async (id) => {
47
- try {
48
- const snapshot = await this.load(id);
49
- const conversation = snapshot.conversation.latestResumable();
50
- if (conversation === undefined)
51
- return undefined;
52
- return {
53
- id,
54
- createdAt: snapshot.meta.createdAt,
55
- updatedAt: snapshot.head.updatedAt,
56
- turns: selectedTurnCount(conversation),
57
- preview: firstUserText(conversation),
58
- active: await this.#leaseIsActive(id),
59
- };
60
- }
61
- catch {
62
- // Corrupt or foreign data never becomes a resume candidate.
63
- return undefined;
64
- }
47
+ return await this.#catalogEntry(id);
65
48
  }));
66
49
  catalog.push(...batch.filter((entry) => entry !== undefined));
67
50
  }
@@ -74,9 +57,7 @@ export class DurableSessionStore {
74
57
  const directory = this.#sessionDirectory(id);
75
58
  await assertDirectory(directory);
76
59
  const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
77
- if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
78
- workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
79
- throw new Error("session belongs to a different workspace");
60
+ assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
80
61
  let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
81
62
  const stored = await readNodes(path.join(directory, "nodes"));
82
63
  const ahead = stored.filter((entry) => entry.sequence > head.sequence);
@@ -120,7 +101,8 @@ export class DurableSessionStore {
120
101
  }
121
102
  const nodes = stored.map((entry) => entry.node);
122
103
  const conversation = ConversationTree.restore(nodes, head.nodeId);
123
- return Object.freeze({ meta, head, conversation });
104
+ const catalog = sessionCatalog(meta, head, conversation);
105
+ return Object.freeze({ meta, head, conversation, catalog });
124
106
  }
125
107
  async publish(conversation, claim) {
126
108
  const active = conversation.activeNode;
@@ -147,6 +129,7 @@ export class DurableSessionStore {
147
129
  revision: active.revision,
148
130
  updatedAt: now,
149
131
  });
132
+ const catalog = sessionCatalog(meta, head, conversation);
150
133
  try {
151
134
  await makePrivateDirectory(temporary);
152
135
  const nodes = path.join(temporary, "nodes");
@@ -157,6 +140,7 @@ export class DurableSessionStore {
157
140
  }
158
141
  await atomicWrite(path.join(temporary, "meta.json"), encodeMeta(meta), { mode: FILE_MODE });
159
142
  await atomicWrite(path.join(temporary, "head.json"), encodeHead(head), { mode: FILE_MODE });
143
+ await atomicWrite(path.join(temporary, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE });
160
144
  if (token !== undefined) {
161
145
  await atomicWrite(path.join(temporary, "active"), token, { mode: FILE_MODE });
162
146
  }
@@ -166,7 +150,7 @@ export class DurableSessionStore {
166
150
  await removeTemporaryDirectory(temporary, this.#bucket);
167
151
  throw error;
168
152
  }
169
- const snapshot = Object.freeze({ meta, head, conversation });
153
+ const snapshot = Object.freeze({ meta, head, conversation, catalog });
170
154
  if (token === undefined)
171
155
  return snapshot;
172
156
  const lease = sessionLease(id, path.join(target, "active"), token);
@@ -184,7 +168,7 @@ export class DurableSessionStore {
184
168
  };
185
169
  await validateNodesDirectory();
186
170
  const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
187
- if (!sameHead(currentHead, previous.head)) {
171
+ if (!sameSessionHead(currentHead, previous.head)) {
188
172
  throw new Error("session head changed after its verified snapshot");
189
173
  }
190
174
  const active = conversation.activeNode;
@@ -216,12 +200,19 @@ export class DurableSessionStore {
216
200
  revision: active.revision,
217
201
  updatedAt: now,
218
202
  });
203
+ const catalog = advanceSessionCatalog(previous.catalog, previous.meta, head, conversation);
204
+ const checkpointToken = leaseToken();
205
+ await atomicWrite(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken, { mode: FILE_MODE, validate: async () => assertDirectory(directory) });
219
206
  await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
220
207
  await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
221
208
  mode: FILE_MODE,
222
209
  validate: async () => assertDirectory(directory),
223
210
  });
224
- return Object.freeze({ meta: previous.meta, head, conversation });
211
+ await this.#writeCatalog(previous.meta.id, catalog, checkpointToken).catch(() => undefined);
212
+ // Once the canonical head is durable, a missing summary is detectable by
213
+ // its head mismatch and can be rebuilt without retaining a live marker.
214
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
215
+ return Object.freeze({ meta: previous.meta, head, conversation, catalog });
225
216
  }
226
217
  async claim(id) {
227
218
  assertSessionId(id);
@@ -265,6 +256,74 @@ export class DurableSessionStore {
265
256
  const owner = await leaseOwner(path.join(this.#sessionDirectory(id), "active"));
266
257
  return owner !== undefined && pidIsAlive(owner.pid);
267
258
  }
259
+ async #catalogEntry(id) {
260
+ try {
261
+ const directory = this.#sessionDirectory(id);
262
+ await assertDirectory(directory);
263
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
264
+ // A second head read closes the only useful race: a checkpoint landing
265
+ // between the small record reads. A changing marker gets one retry.
266
+ for (let attempt = 0; attempt < 2; attempt++) {
267
+ const checkpointBefore = await leaseOwner(checkpointFile);
268
+ try {
269
+ const [metaValue, headValue, catalogValue] = await Promise.all([
270
+ readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes),
271
+ readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes),
272
+ readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES),
273
+ ]);
274
+ const meta = decodeMeta(metaValue);
275
+ const head = decodeHead(headValue);
276
+ const storedCatalog = decodeSessionCatalog(catalogValue);
277
+ assertSessionWorkspace(meta, id, this.workspaceRoot, this.workspaceDigest);
278
+ const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
279
+ const checkpointAfter = await leaseOwner(checkpointFile);
280
+ if (!sameLease(checkpointBefore, checkpointAfter))
281
+ continue;
282
+ if (!sameSessionHead(head, confirmedHead) ||
283
+ !catalogMatches(storedCatalog, meta, head))
284
+ break;
285
+ if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
286
+ break;
287
+ const active = await this.#leaseIsActive(id) || checkpointAfter !== undefined;
288
+ return catalogEntry(storedCatalog, active);
289
+ }
290
+ catch {
291
+ break;
292
+ }
293
+ }
294
+ // Missing, stale, or malformed summaries are rebuilt only while the
295
+ // session is idle. Selecting a session still performs this strict load.
296
+ const checkpoint = await leaseOwner(checkpointFile);
297
+ if (await this.#leaseIsActive(id) ||
298
+ (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
299
+ return undefined;
300
+ const snapshot = await this.load(id);
301
+ await this.#writeCatalog(id, snapshot.catalog, checkpoint?.token).catch(() => undefined);
302
+ const currentCheckpoint = await leaseOwner(checkpointFile);
303
+ const active = await this.#leaseIsActive(id) ||
304
+ (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
305
+ return catalogEntry(snapshot.catalog, active);
306
+ }
307
+ catch {
308
+ // Corrupt, unsafe, active-without-a-summary, or foreign data never
309
+ // becomes a resume candidate.
310
+ return undefined;
311
+ }
312
+ }
313
+ async #writeCatalog(id, catalog, checkpointToken) {
314
+ const directory = this.#sessionDirectory(id);
315
+ const validate = async () => {
316
+ await assertDirectory(directory);
317
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
318
+ if (!sameSessionHead(currentHead, catalog.head)) {
319
+ throw new Error("session head changed while updating its catalogue");
320
+ }
321
+ };
322
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
323
+ if (checkpointToken !== undefined) {
324
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
325
+ }
326
+ }
268
327
  }
269
328
  async function catalogNames(directory) {
270
329
  try {
@@ -288,6 +347,26 @@ async function catalogNames(directory) {
288
347
  }
289
348
  return names.sort((left, right) => right.localeCompare(left));
290
349
  }
350
+ function catalogEntry(catalog, active) {
351
+ if (catalog.resumeNodeId === 0)
352
+ return undefined;
353
+ return Object.freeze({
354
+ id: catalog.id,
355
+ createdAt: catalog.createdAt,
356
+ updatedAt: catalog.head.updatedAt,
357
+ turns: catalog.turns,
358
+ preview: catalog.preview,
359
+ active,
360
+ });
361
+ }
362
+ function sameLease(left, right) {
363
+ return left?.token === right?.token;
364
+ }
365
+ function assertSessionWorkspace(meta, id, workspaceRoot, workspaceDigest) {
366
+ if (meta.id !== id || meta.workspaceDigest !== workspaceDigest ||
367
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(workspaceRoot))
368
+ throw new Error("session belongs to a different workspace");
369
+ }
291
370
  function assertSharedNodes(previous, next, replacedId) {
292
371
  for (const node of previous.nodes) {
293
372
  if (node.id === replacedId)
@@ -301,6 +380,7 @@ function assertSharedNodes(previous, next, replacedId) {
301
380
  function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
302
381
  encodeMeta(snapshot.meta);
303
382
  encodeHead(snapshot.head);
383
+ encodeSessionCatalog(snapshot.catalog);
304
384
  assertSessionId(snapshot.meta.id);
305
385
  if (snapshot.meta.workspaceDigest !== workspaceDigest ||
306
386
  workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
@@ -312,14 +392,9 @@ function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
312
392
  active.revision !== snapshot.head.revision ||
313
393
  snapshot.head.sequence < snapshot.conversation.nodes.length)
314
394
  throw new Error("session snapshot does not match its verified head");
315
- }
316
- function sameHead(left, right) {
317
- return left.version === right.version &&
318
- left.sequence === right.sequence &&
319
- left.nodeId === right.nodeId &&
320
- left.parentId === right.parentId &&
321
- left.revision === right.revision &&
322
- left.updatedAt === right.updatedAt;
395
+ if (!catalogMatches(snapshot.catalog, snapshot.meta, snapshot.head)) {
396
+ throw new Error("session snapshot does not match its verified catalogue");
397
+ }
323
398
  }
324
399
  async function assertMissingNode(file) {
325
400
  try {
@@ -393,17 +468,6 @@ async function makePrivateDirectory(directory) {
393
468
  if (process.platform !== "win32")
394
469
  await chmod(directory, DIRECTORY_MODE);
395
470
  }
396
- async function directoryEntries(directory) {
397
- try {
398
- await assertDirectory(directory);
399
- return await readdir(directory, { withFileTypes: true });
400
- }
401
- catch (error) {
402
- if (error.code === "ENOENT")
403
- return [];
404
- throw error;
405
- }
406
- }
407
471
  async function removeTemporaryDirectory(directory, bucket) {
408
472
  const relative = path.relative(bucket, directory);
409
473
  if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative) ||
@@ -411,26 +475,6 @@ async function removeTemporaryDirectory(directory, bucket) {
411
475
  throw new Error("refusing to remove an unverified session directory");
412
476
  await rm(directory, { recursive: true, force: true });
413
477
  }
414
- function selectedTurnCount(conversation) {
415
- let count = 0;
416
- let id = conversation.activeNodeId;
417
- while (id !== 0) {
418
- count++;
419
- id = conversation.node(id)?.parentId ?? 0;
420
- }
421
- return count;
422
- }
423
- function firstUserText(conversation) {
424
- for (const message of conversation.history) {
425
- if (message.role !== "user")
426
- continue;
427
- const text = message.content.find((block) => block.kind === "text")?.text
428
- .replace(/\s+/gu, " ").trim();
429
- if (text !== undefined && text !== "")
430
- return leadingText(text, 160);
431
- }
432
- return "Untitled session";
433
- }
434
478
  function nodeName(id) {
435
479
  return `${String(id).padStart(6, "0")}.json`;
436
480
  }
@@ -232,11 +232,10 @@ function preferRipgrep(files, bytes) {
232
232
  return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
233
233
  }
234
234
  async function walk(start, ctx, visit) {
235
- const pending = [start];
235
+ const pending = [];
236
236
  let seen = 0;
237
- while (pending.length > 0) {
237
+ const enter = async (lexical) => {
238
238
  checkAbort(ctx.signal);
239
- const lexical = pending.pop();
240
239
  const directory = await resolveExistingInRoot(ctx.root, lexical);
241
240
  let entries;
242
241
  try {
@@ -244,24 +243,34 @@ async function walk(start, ctx, visit) {
244
243
  }
245
244
  catch (error) {
246
245
  if (skippable(error))
247
- continue;
246
+ return;
248
247
  throw error;
249
248
  }
250
249
  entries.sort((a, b) => a.name.localeCompare(b.name));
251
- for (const entry of entries) {
252
- checkAbort(ctx.signal);
253
- if (++seen > MAX_VISITED)
254
- return { capped: true };
255
- if (entry.isSymbolicLink())
256
- continue;
257
- const target = path.join(directory, entry.name);
258
- if (entry.isDirectory()) {
259
- if (!SKIP.has(entry.name))
260
- pending.push(target);
261
- }
262
- else if (entry.isFile() && (await visit(target))) {
263
- return { capped: false };
264
- }
250
+ pending.push({ directory, entries, next: 0 });
251
+ };
252
+ await enter(start);
253
+ while (pending.length > 0) {
254
+ checkAbort(ctx.signal);
255
+ const frame = pending[pending.length - 1];
256
+ if (frame === undefined)
257
+ break;
258
+ const entry = frame.entries[frame.next++];
259
+ if (entry === undefined) {
260
+ pending.pop();
261
+ continue;
262
+ }
263
+ if (++seen > MAX_VISITED)
264
+ return { capped: true };
265
+ if (entry.isSymbolicLink())
266
+ continue;
267
+ const target = path.join(frame.directory, entry.name);
268
+ if (entry.isDirectory()) {
269
+ if (!SKIP.has(entry.name))
270
+ await enter(target);
271
+ }
272
+ else if (entry.isFile() && (await visit(target))) {
273
+ return { capped: false };
265
274
  }
266
275
  }
267
276
  return { capped: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,7 @@
26
26
  "bin/",
27
27
  "dist/",
28
28
  "assets/jeco-256.png",
29
+ "assets/wordmark-steel.svg",
29
30
  "LICENSE",
30
31
  "README.md"
31
32
  ],