@giovannijecha/jecode 0.7.0 → 0.7.2

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/dist/config.js CHANGED
@@ -3,29 +3,24 @@ import * as path from "node:path";
3
3
  import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
4
4
  import { EFFORTS, readSettings } from "./settings.js";
5
5
  import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
6
- const FLAGS = [
6
+ const VALUE_FLAGS = [
7
7
  "provider",
8
8
  "model",
9
9
  "ollama-host",
10
- "reduced-motion",
11
10
  "effort",
12
11
  "max-tokens",
13
12
  "max-steps",
14
13
  "compaction-percent",
15
14
  "root",
15
+ ];
16
+ const BOOLEAN_FLAGS = [
17
+ "reduced-motion",
16
18
  "auto-approve",
17
19
  "ephemeral",
18
20
  ];
21
+ const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
19
22
  export function loadConfig(argv, saved = readSettings()) {
20
23
  const flags = parseFlags(argv);
21
- // A flag nobody declared is a typo. Swallowed in silence it becomes a
22
- // setting the user believes is on, and the run that ignores it looks like
23
- // the feature is broken rather than misspelled.
24
- for (const name of Object.keys(flags)) {
25
- if (!FLAGS.includes(name)) {
26
- throw new Error(`unknown flag --${name} (known: ${FLAGS.map((f) => `--${f}`).join(", ")})`);
27
- }
28
- }
29
24
  const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
30
25
  const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
31
26
  const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
@@ -45,7 +40,9 @@ export function loadConfig(argv, saved = readSettings()) {
45
40
  maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
46
41
  compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
47
42
  root: path.resolve(pick(flags.root, undefined, process.cwd())),
48
- autoApprove: flags["auto-approve"] === "true" || process.env.JECODE_AUTO_APPROVE === "1",
43
+ autoApprove: flags["auto-approve"] === "true" ||
44
+ flags["auto-approve"] === "1" ||
45
+ process.env.JECODE_AUTO_APPROVE === "1",
49
46
  ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
50
47
  };
51
48
  }
@@ -85,27 +82,55 @@ function toPercent(value) {
85
82
  }
86
83
  return percent;
87
84
  }
88
- // Accepts --key value, --key=value, and bare --flag (which reads as "true").
85
+ // Value flags accept --key value and --key=value. Boolean flags are bare or
86
+ // take a real boolean value, so an accidental positional argument is never
87
+ // swallowed as configuration.
89
88
  function parseFlags(argv) {
90
89
  const flags = {};
91
90
  for (let i = 0; i < argv.length; i++) {
92
91
  const arg = argv[i];
93
- if (arg === undefined || !arg.startsWith("--"))
92
+ if (arg === undefined)
94
93
  continue;
94
+ if (!arg.startsWith("--"))
95
+ throw new Error(`unexpected argument "${arg}"`);
95
96
  const body = arg.slice(2);
96
97
  const eq = body.indexOf("=");
97
- if (eq !== -1) {
98
- flags[body.slice(0, eq)] = body.slice(eq + 1);
98
+ const name = eq === -1 ? body : body.slice(0, eq);
99
+ const inline = eq === -1 ? undefined : body.slice(eq + 1);
100
+ if (!FLAGS.includes(name)) {
101
+ throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
102
+ }
103
+ if (BOOLEAN_FLAGS.includes(name)) {
104
+ if (inline === undefined) {
105
+ const next = argv[i + 1];
106
+ if (next !== undefined && ["true", "false", "1", "0"].includes(next)) {
107
+ flags[name] = next;
108
+ i++;
109
+ }
110
+ else {
111
+ flags[name] = "true";
112
+ }
113
+ }
114
+ else if (["true", "false", "1", "0"].includes(inline)) {
115
+ flags[name] = inline;
116
+ }
117
+ else {
118
+ throw new Error(`--${name} must be true or false`);
119
+ }
99
120
  continue;
100
121
  }
101
- const next = argv[i + 1];
102
- if (next !== undefined && !next.startsWith("--")) {
103
- flags[body] = next;
104
- i++;
122
+ if (inline !== undefined) {
123
+ if (inline === "")
124
+ throw new Error(`--${name} requires a value`);
125
+ flags[name] = inline;
126
+ continue;
105
127
  }
106
- else {
107
- flags[body] = "true";
128
+ const next = argv[i + 1];
129
+ if (next === undefined || next.startsWith("--")) {
130
+ throw new Error(`--${name} requires a value`);
108
131
  }
132
+ flags[name] = next;
133
+ i++;
109
134
  }
110
135
  return flags;
111
136
  }
@@ -62,6 +62,7 @@ export async function compactSession(session, options = {}) {
62
62
  messages: active.messages,
63
63
  blocks: active.blocks,
64
64
  context: result.anchor,
65
+ ...(active.failure === undefined ? {} : { failure: active.failure }),
65
66
  }, active.settlement);
66
67
  await session.persistence?.checkpoint(next);
67
68
  session.conversation = next;
@@ -5,6 +5,7 @@
5
5
  // project an older prefix before it is sent to a provider. Provider traffic
6
6
  // and live screen blocks remain prospective until a consistent checkpoint.
7
7
  import { projectContext, validContextAnchor } from "./context/projection.js";
8
+ import { assertPersistableNode } from "./sessions/codec.js";
8
9
  export const CONVERSATION_LIMITS = Object.freeze({
9
10
  nodes: 1_024,
10
11
  messageCodeUnits: 8_388_608,
@@ -37,6 +38,7 @@ export class ConversationTree {
37
38
  messages: node.messages,
38
39
  blocks: node.blocks,
39
40
  ...(node.context === undefined ? {} : { context: node.context }),
41
+ ...(node.failure === undefined ? {} : { failure: node.failure }),
40
42
  }, node.settlement);
41
43
  const restored = tree.activeNode;
42
44
  if (restored === undefined || restored.id !== node.id) {
@@ -92,6 +94,19 @@ export class ConversationTree {
92
94
  }
93
95
  return undefined;
94
96
  }
97
+ /** Select the newest turn that is safe to continue after a restart. */
98
+ latestResumable() {
99
+ let id = this.#activeNodeId;
100
+ while (id !== 0) {
101
+ const node = this.node(id);
102
+ if (node === undefined)
103
+ throw new Error("conversation path is incomplete");
104
+ if (node.settlement !== "checkpointed")
105
+ return this.select(id);
106
+ id = node.parentId;
107
+ }
108
+ return undefined;
109
+ }
95
110
  get history() {
96
111
  return this.#path().flatMap((node) => clone(node.messages));
97
112
  }
@@ -99,7 +114,12 @@ export class ConversationTree {
99
114
  return projectContext(this.#path());
100
115
  }
101
116
  get transcript() {
102
- return this.#path().flatMap((node) => clone(node.blocks));
117
+ return this.#path().flatMap((node) => [
118
+ ...clone(node.blocks),
119
+ ...(node.failure === undefined
120
+ ? []
121
+ : [{ kind: "notice", text: node.failure.text, tone: node.failure.tone }]),
122
+ ]);
103
123
  }
104
124
  #append(draft, settlement) {
105
125
  if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
@@ -115,8 +135,10 @@ export class ConversationTree {
115
135
  messages: draft.messages,
116
136
  blocks: settledBlocks(draft.blocks),
117
137
  ...(draft.context === undefined ? {} : { context: draft.context }),
138
+ ...(draft.failure === undefined ? {} : { failure: draft.failure }),
118
139
  });
119
140
  assertTurn(node);
141
+ assertPersistableNode(node);
120
142
  const nodes = [...this.#nodes, node];
121
143
  assertBounds(nodes);
122
144
  return new ConversationTree(nodes, node.id);
@@ -135,8 +157,10 @@ export class ConversationTree {
135
157
  messages: draft.messages,
136
158
  blocks: settledBlocks(draft.blocks),
137
159
  context: draft.context ?? current.context,
160
+ failure: draft.failure,
138
161
  });
139
162
  assertTurn(node);
163
+ assertPersistableNode(node);
140
164
  const nodes = [...this.#nodes];
141
165
  nodes[id - 1] = node;
142
166
  assertBounds(nodes);
@@ -163,6 +187,7 @@ function ownedNode(node) {
163
187
  messages: Object.freeze(clone(node.messages)),
164
188
  blocks: Object.freeze(clone(node.blocks)),
165
189
  ...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
190
+ ...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
166
191
  });
167
192
  }
168
193
  function settledBlocks(blocks) {
@@ -174,6 +199,8 @@ function settledBlocks(blocks) {
174
199
  return [settled];
175
200
  }
176
201
  if (block.kind === "tool") {
202
+ if (block.tone === "pending")
203
+ return [];
177
204
  const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
178
205
  return [settled];
179
206
  }
@@ -185,16 +212,26 @@ function assertTurn(node) {
185
212
  !validNodeId(node.parentId) || node.parentId >= node.id ||
186
213
  !Number.isSafeInteger(node.revision) || node.revision < 1 ||
187
214
  node.createdAt.length === 0 || node.createdAt.length > 64 ||
188
- (node.settlement !== "checkpointed" && node.settlement !== "completed") ||
215
+ !validSettlement(node.settlement) ||
189
216
  node.messages.length < 2 || node.messages[0]?.role !== "user" ||
190
217
  node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
191
218
  node.identity.model.length === 0 || node.identity.model.length > 512 ||
192
219
  node.identity.effort.length === 0 || node.identity.effort.length > 32)
193
220
  throw new Error("turn checkpoint is invalid");
194
- if (node.settlement === "completed" && node.messages.at(-1)?.role !== "assistant") {
195
- throw new Error("a completed turn must end with an assistant message");
221
+ if (node.settlement !== "checkpointed" && node.messages.at(-1)?.role !== "assistant") {
222
+ throw new Error("a resumable turn must end with an assistant message");
223
+ }
224
+ const failed = node.settlement === "failed" || node.settlement === "interrupted";
225
+ if (failed !== (node.failure !== undefined) ||
226
+ (node.settlement === "failed" && node.failure?.tone !== "error") ||
227
+ (node.settlement === "interrupted" && node.failure?.tone !== "warn")) {
228
+ throw new Error("turn failure state is invalid");
196
229
  }
197
230
  }
231
+ function validSettlement(value) {
232
+ return value === "checkpointed" || value === "completed" ||
233
+ value === "failed" || value === "interrupted";
234
+ }
198
235
  function assertBounds(nodes) {
199
236
  let messageCodeUnits = 0;
200
237
  let transcriptCodeUnits = 0;
@@ -40,17 +40,21 @@ export function credentialRedactor(source = process.env) {
40
40
  const ready = [];
41
41
  let at = 0;
42
42
  while (at < combined.length) {
43
+ const rest = combined.slice(at);
44
+ // A complete shorter credential can also be the prefix of a longer
45
+ // one. Hold that ambiguous suffix until the next chunk proves which
46
+ // value arrived, otherwise the longer credential leaks its tail.
47
+ if (rest.length < longest &&
48
+ values.some((value) => value.length > rest.length && value.startsWith(rest))) {
49
+ pending = rest;
50
+ return ready.join("");
51
+ }
43
52
  const complete = values.find((value) => combined.startsWith(value, at));
44
53
  if (complete !== undefined) {
45
54
  ready.push(REDACTED);
46
55
  at += complete.length;
47
56
  continue;
48
57
  }
49
- const rest = combined.slice(at);
50
- if (rest.length < longest && values.some((value) => value.startsWith(rest))) {
51
- pending = rest;
52
- return ready.join("");
53
- }
54
58
  ready.push(combined[at]);
55
59
  at++;
56
60
  }
@@ -1,5 +1,6 @@
1
1
  // Translation between the normalized vocabulary and the Anthropic wire shape.
2
2
  // Pure functions, no I/O — which is what makes them testable without a key.
3
+ import { wireTokenCount } from "./wire-usage.js";
3
4
  export function toWireTool(tool) {
4
5
  return { name: tool.name, description: tool.description, input_schema: tool.input };
5
6
  }
@@ -70,10 +71,10 @@ function normalizeUsage(data) {
70
71
  if (usage === undefined)
71
72
  return undefined;
72
73
  return {
73
- inputTokens: usage.input_tokens ?? 0,
74
- outputTokens: usage.output_tokens ?? 0,
75
- cachedInputTokens: usage.cache_read_input_tokens ?? 0,
76
- cacheWriteInputTokens: usage.cache_creation_input_tokens ?? 0,
74
+ inputTokens: wireTokenCount(usage.input_tokens),
75
+ outputTokens: wireTokenCount(usage.output_tokens),
76
+ cachedInputTokens: wireTokenCount(usage.cache_read_input_tokens),
77
+ cacheWriteInputTokens: wireTokenCount(usage.cache_creation_input_tokens),
77
78
  reasoningTokens: 0,
78
79
  };
79
80
  }
@@ -6,6 +6,7 @@
6
6
  // Chat Completions differs on two points that matter here — a tool result is a
7
7
  // message of its own with role "tool", not a block inside a user turn, and tool
8
8
  // arguments travel as a JSON string rather than an object.
9
+ import { wireTokenCount } from "./wire-usage.js";
9
10
  export function toWireTool(tool) {
10
11
  return {
11
12
  type: "function",
@@ -83,8 +84,8 @@ function normalizeUsage(reply) {
83
84
  if (reply.usage === undefined)
84
85
  return undefined;
85
86
  return {
86
- inputTokens: reply.usage.prompt_tokens ?? 0,
87
- outputTokens: reply.usage.completion_tokens ?? 0,
87
+ inputTokens: wireTokenCount(reply.usage.prompt_tokens),
88
+ outputTokens: wireTokenCount(reply.usage.completion_tokens),
88
89
  cachedInputTokens: 0,
89
90
  cacheWriteInputTokens: 0,
90
91
  reasoningTokens: 0,
@@ -1,6 +1,7 @@
1
1
  // Translation between the normalized vocabulary and the OpenAI Responses wire
2
2
  // shape: a flat `input` list where tool calls and their outputs are top-level
3
3
  // items keyed by `call_id`, rather than blocks nested inside a message.
4
+ import { wireTokenCount } from "./wire-usage.js";
4
5
  export function toWireTool(tool) {
5
6
  return {
6
7
  type: "function",
@@ -83,11 +84,11 @@ function normalizeUsage(data) {
83
84
  if (usage === undefined || usage === null)
84
85
  return undefined;
85
86
  return {
86
- inputTokens: usage.input_tokens ?? 0,
87
- outputTokens: usage.output_tokens ?? 0,
88
- cachedInputTokens: usage.input_tokens_details?.cached_tokens ?? 0,
89
- cacheWriteInputTokens: usage.input_tokens_details?.cache_write_tokens ?? 0,
90
- reasoningTokens: usage.output_tokens_details?.reasoning_tokens ?? 0,
87
+ inputTokens: wireTokenCount(usage.input_tokens),
88
+ outputTokens: wireTokenCount(usage.output_tokens),
89
+ cachedInputTokens: wireTokenCount(usage.input_tokens_details?.cached_tokens),
90
+ cacheWriteInputTokens: wireTokenCount(usage.input_tokens_details?.cache_write_tokens),
91
+ reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
91
92
  };
92
93
  }
93
94
  // Arguments arrive as a JSON string and models vary in how they escape it, so
@@ -0,0 +1,6 @@
1
+ // Usage comes from remote JSON and eventually reaches the strict session
2
+ // codec. Normalize it at the wire boundary so one malformed counter cannot
3
+ // make an otherwise valid saved session unreadable.
4
+ export function wireTokenCount(value) {
5
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
6
+ }
@@ -1,17 +1,21 @@
1
1
  // Strict codecs for session files. Disk is an untrusted boundary even when
2
2
  // the directory is owner-only: every value is bounded and re-owned before it
3
3
  // can become conversation or provider input.
4
+ import { Buffer } from "node:buffer";
4
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
5
- export const SESSION_SCHEMA = 2;
6
+ export const SESSION_SCHEMA = 3;
6
7
  export const SESSION_FILE_LIMITS = Object.freeze({
7
8
  text: 1_048_576,
9
+ metadataBytes: 64 * 1_024,
10
+ nodeBytes: 20 * 1_024 * 1_024,
8
11
  jsonDepth: 24,
9
12
  jsonNodes: 32_768,
10
13
  blocks: 8_192,
11
14
  details: 8_192,
12
15
  });
13
16
  export function encodeMeta(meta) {
14
- return line(meta);
17
+ decodeMeta(meta);
18
+ return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
15
19
  }
16
20
  export function decodeMeta(value) {
17
21
  if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
@@ -32,7 +36,8 @@ export function decodeMeta(value) {
32
36
  });
33
37
  }
34
38
  export function encodeHead(head) {
35
- return line(head);
39
+ decodeHead(head);
40
+ return boundedLine(head, SESSION_FILE_LIMITS.metadataBytes);
36
41
  }
37
42
  export function decodeHead(value) {
38
43
  if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
@@ -55,7 +60,16 @@ export function decodeHead(value) {
55
60
  });
56
61
  }
57
62
  export function encodeNode(node, sequence, updatedAt) {
58
- return line({
63
+ const envelope = nodeEnvelope(node, sequence, updatedAt);
64
+ decodeNode(envelope);
65
+ return boundedLine(envelope, SESSION_FILE_LIMITS.nodeBytes);
66
+ }
67
+ /** Enforce the exact current-disk boundary before a turn enters the tree. */
68
+ export function assertPersistableNode(node) {
69
+ encodeNode(node, 1, node.createdAt);
70
+ }
71
+ function nodeEnvelope(node, sequence, updatedAt) {
72
+ return {
59
73
  version: SESSION_SCHEMA,
60
74
  sequence,
61
75
  updatedAt,
@@ -69,8 +83,9 @@ export function encodeNode(node, sequence, updatedAt) {
69
83
  messages: node.messages.map(messageRecord),
70
84
  blocks: node.blocks.flatMap(blockRecord),
71
85
  context: node.context ?? null,
86
+ failure: node.failure ?? null,
72
87
  },
73
- });
88
+ };
74
89
  }
75
90
  export function decodeNode(value) {
76
91
  if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
@@ -82,7 +97,9 @@ export function decodeNode(value) {
82
97
  const raw = value["node"];
83
98
  const nodeKeys = version === 1
84
99
  ? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
85
- : "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement";
100
+ : version === 2
101
+ ? "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement"
102
+ : "blocks,context,createdAt,failure,id,identity,messages,parentId,revision,settlement";
86
103
  if (!record(raw) || !keys(raw, nodeKeys)) {
87
104
  throw invalid();
88
105
  }
@@ -93,7 +110,7 @@ export function decodeNode(value) {
93
110
  !integer(raw["parentId"], 0) ||
94
111
  !integer(raw["revision"], 1) ||
95
112
  !timestamp(raw["createdAt"]) ||
96
- (raw["settlement"] !== "checkpointed" && raw["settlement"] !== "completed") ||
113
+ !settlement(raw["settlement"], version) ||
97
114
  !record(identity) || !keys(identity, "effort,model,providerId") ||
98
115
  !bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
99
116
  !bounded(identity["effort"], 32) ||
@@ -103,6 +120,9 @@ export function decodeNode(value) {
103
120
  const context = version === 1
104
121
  ? undefined
105
122
  : contextFromRecord(raw["context"], raw["id"], messages.length);
123
+ const failure = version < 3
124
+ ? undefined
125
+ : failureFromRecord(raw["failure"], raw["settlement"]);
106
126
  const node = Object.freeze({
107
127
  id: raw["id"],
108
128
  parentId: raw["parentId"],
@@ -117,9 +137,25 @@ export function decodeNode(value) {
117
137
  messages: Object.freeze(messages.map(messageFromRecord)),
118
138
  blocks: Object.freeze(blocks.map(blockFromRecord)),
119
139
  ...(context === undefined ? {} : { context: Object.freeze(context) }),
140
+ ...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
120
141
  });
121
142
  return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
122
143
  }
144
+ function failureFromRecord(value, settlement) {
145
+ const failed = settlement === "failed" || settlement === "interrupted";
146
+ if (value === null) {
147
+ if (failed)
148
+ throw invalid();
149
+ return undefined;
150
+ }
151
+ if (!failed || !record(value) || !keys(value, "text,tone"))
152
+ throw invalid();
153
+ if (!bounded(value["text"]) ||
154
+ (settlement === "failed" && value["tone"] !== "error") ||
155
+ (settlement === "interrupted" && value["tone"] !== "warn"))
156
+ throw invalid();
157
+ return { text: value["text"], tone: value["tone"] };
158
+ }
123
159
  function contextFromRecord(value, ownerId, ownerMessages) {
124
160
  if (value === null)
125
161
  return undefined;
@@ -340,6 +376,12 @@ function jsonValue(value, budget, depth) {
340
376
  function line(value) {
341
377
  return `${JSON.stringify(value, null, 2)}\n`;
342
378
  }
379
+ function boundedLine(value, maxBytes) {
380
+ const encoded = line(value);
381
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes)
382
+ throw invalid();
383
+ return encoded;
384
+ }
343
385
  function keys(value, expected) {
344
386
  return Object.keys(value).sort().join(",") === expected;
345
387
  }
@@ -356,7 +398,11 @@ function integer(value, minimum) {
356
398
  return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
357
399
  }
358
400
  function schema(value) {
359
- return value === 1 || value === SESSION_SCHEMA;
401
+ return value === 1 || value === 2 || value === SESSION_SCHEMA;
402
+ }
403
+ function settlement(value, version) {
404
+ return value === "checkpointed" || value === "completed" ||
405
+ (version >= 3 && (value === "failed" || value === "interrupted"));
360
406
  }
361
407
  function nullableInteger(value, minimum) {
362
408
  return value === null || integer(value, minimum);
@@ -21,9 +21,9 @@ export class SessionPersistence {
21
21
  const lease = await store.claim(id);
22
22
  try {
23
23
  const snapshot = await store.load(id);
24
- const conversation = snapshot.conversation.latestCompleted();
24
+ const conversation = snapshot.conversation.latestResumable();
25
25
  if (conversation === undefined)
26
- throw new Error("session has no completed turn to resume");
26
+ throw new Error("session has no resumable turn");
27
27
  return Object.freeze({
28
28
  conversation,
29
29
  persistence: new SessionPersistence(store, id, lease),
@@ -4,17 +4,17 @@
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, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } 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
11
  import { userDataPath } from "../user-data.js";
12
- import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
12
+ import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
13
13
  import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
14
14
  const DIRECTORY_MODE = 0o700;
15
15
  const FILE_MODE = 0o600;
16
- const MAX_CATALOG_SCAN = 128;
17
- const MAX_JSON_BYTES = 20 * 1024 * 1024;
16
+ const MAX_CATALOG_ENTRIES = 4_096;
17
+ const CATALOG_READ_CONCURRENCY = 8;
18
18
  const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
19
19
  const NODE_NAME = /^(\d{6})\.json$/;
20
20
  const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
@@ -37,45 +37,45 @@ export class DurableSessionStore {
37
37
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
38
38
  throw new Error("session catalogue limit is invalid");
39
39
  }
40
- const entries = await directoryEntries(this.#bucket);
41
- const names = entries
42
- .filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
43
- .map((entry) => entry.name)
44
- .sort((left, right) => right.localeCompare(left))
45
- .slice(0, MAX_CATALOG_SCAN);
40
+ const names = await catalogNames(this.#bucket);
46
41
  const catalog = [];
47
- for (const id of names) {
48
- try {
49
- const snapshot = await this.load(id);
50
- const conversation = snapshot.conversation.latestCompleted();
51
- if (conversation === undefined)
52
- continue;
53
- catalog.push({
54
- id,
55
- createdAt: snapshot.meta.createdAt,
56
- updatedAt: snapshot.head.updatedAt,
57
- turns: selectedTurnCount(conversation),
58
- preview: firstUserText(conversation),
59
- active: await this.#leaseIsActive(id),
60
- });
61
- }
62
- catch {
63
- // Corrupt or foreign data never becomes a resume candidate.
64
- }
42
+ for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
43
+ const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
44
+ .map(async (id) => {
45
+ try {
46
+ const snapshot = await this.load(id);
47
+ const conversation = snapshot.conversation.latestResumable();
48
+ if (conversation === undefined)
49
+ return undefined;
50
+ return {
51
+ id,
52
+ createdAt: snapshot.meta.createdAt,
53
+ updatedAt: snapshot.head.updatedAt,
54
+ turns: selectedTurnCount(conversation),
55
+ preview: firstUserText(conversation),
56
+ active: await this.#leaseIsActive(id),
57
+ };
58
+ }
59
+ catch {
60
+ // Corrupt or foreign data never becomes a resume candidate.
61
+ return undefined;
62
+ }
63
+ }));
64
+ catalog.push(...batch.filter((entry) => entry !== undefined));
65
65
  }
66
66
  return catalog
67
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
67
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
68
68
  .slice(0, limit);
69
69
  }
70
70
  async load(id) {
71
71
  assertSessionId(id);
72
72
  const directory = this.#sessionDirectory(id);
73
73
  await assertDirectory(directory);
74
- const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), 64 * 1024));
74
+ const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
75
75
  if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
76
76
  workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
77
77
  throw new Error("session belongs to a different workspace");
78
- let head = decodeHead(await readJson(path.join(directory, "head.json"), 64 * 1024));
78
+ let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
79
79
  const stored = await readNodes(path.join(directory, "nodes"));
80
80
  const ahead = stored.filter((entry) => entry.sequence > head.sequence);
81
81
  if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
@@ -242,6 +242,28 @@ export class DurableSessionStore {
242
242
  return owner !== undefined && pidIsAlive(owner.pid);
243
243
  }
244
244
  }
245
+ async function catalogNames(directory) {
246
+ try {
247
+ await assertDirectory(directory);
248
+ }
249
+ catch (error) {
250
+ if (error.code === "ENOENT")
251
+ return [];
252
+ throw error;
253
+ }
254
+ const names = [];
255
+ let entries = 0;
256
+ const handle = await opendir(directory);
257
+ for await (const entry of handle) {
258
+ entries++;
259
+ if (entries > MAX_CATALOG_ENTRIES) {
260
+ throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
261
+ }
262
+ if (entry.isDirectory() && SESSION_NAME.test(entry.name))
263
+ names.push(entry.name);
264
+ }
265
+ return names.sort((left, right) => right.localeCompare(left));
266
+ }
245
267
  function assertSharedNodes(previous, next, replacedId) {
246
268
  for (const node of previous.nodes) {
247
269
  if (node.id === replacedId)
@@ -278,7 +300,7 @@ async function readNodes(directory) {
278
300
  const id = Number(NODE_NAME.exec(name)?.[1]);
279
301
  if (id !== index + 1)
280
302
  throw new Error("session conversation nodes are not contiguous");
281
- const decoded = decodeNode(await readJson(path.join(directory, name), MAX_JSON_BYTES));
303
+ const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
282
304
  if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
283
305
  throw new Error("session conversation node identity is invalid");
284
306
  }