@giovannijecha/jecode 0.8.2 → 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.
Files changed (53) hide show
  1. package/README.md +22 -280
  2. package/assets/wordmark-steel.svg +3 -0
  3. package/dist/accounts.js +17 -13
  4. package/dist/batch.js +66 -6
  5. package/dist/config.js +6 -3
  6. package/dist/context/budget.js +13 -1
  7. package/dist/context/compactor.js +5 -4
  8. package/dist/context/estimate.js +43 -1
  9. package/dist/context/manual.js +8 -4
  10. package/dist/context/policy.js +68 -18
  11. package/dist/controller-request.js +8 -8
  12. package/dist/controller.js +6 -1
  13. package/dist/conversation.js +94 -33
  14. package/dist/credential-safety.js +56 -9
  15. package/dist/credentials.js +32 -4
  16. package/dist/input-boundary.js +80 -0
  17. package/dist/main.js +4 -1
  18. package/dist/openai-oauth-callback.js +1 -1
  19. package/dist/process-shutdown.js +52 -0
  20. package/dist/provider-commands.js +43 -6
  21. package/dist/provider-errors.js +59 -1
  22. package/dist/providers/anthropic-stream.js +24 -20
  23. package/dist/providers/anthropic-wire.js +7 -2
  24. package/dist/providers/http.js +4 -34
  25. package/dist/providers/ollama-wire.js +7 -15
  26. package/dist/providers/ollama.js +1 -0
  27. package/dist/providers/openai-codex.js +1 -1
  28. package/dist/providers/openai-stream.js +43 -7
  29. package/dist/providers/openai-wire.js +2 -16
  30. package/dist/providers/openai.js +1 -1
  31. package/dist/providers/sse.js +45 -17
  32. package/dist/providers/tool-input.js +17 -0
  33. package/dist/sessions/catalog.js +199 -0
  34. package/dist/sessions/codec.js +2 -1
  35. package/dist/sessions/lease.js +7 -0
  36. package/dist/sessions/runtime.js +11 -3
  37. package/dist/sessions/store.js +171 -78
  38. package/dist/settings.js +10 -5
  39. package/dist/start.js +12 -2
  40. package/dist/text-boundary.js +2 -0
  41. package/dist/tools/search.js +27 -18
  42. package/dist/tui/app-input.js +40 -5
  43. package/dist/tui/app-state.js +1 -0
  44. package/dist/tui/app-workflows.js +1 -0
  45. package/dist/tui/app.js +11 -3
  46. package/dist/tui/editor.js +2 -0
  47. package/dist/tui/keys.js +64 -5
  48. package/dist/tui/overlay.js +12 -4
  49. package/dist/tui/picker.js +2 -0
  50. package/dist/tui/screen.js +5 -17
  51. package/dist/user-store.js +54 -0
  52. package/package.json +7 -3
  53. /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
@@ -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) {
@@ -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 { toolInputFromJson } from "./tool-input.js";
4
5
  import { wireTokenCount } from "./wire-usage.js";
5
6
  export function toWireTool(tool) {
6
7
  return {
@@ -73,7 +74,7 @@ export function fromWireResponse(data, providerId = "openai") {
73
74
  kind: "tool_call",
74
75
  id: item.call_id,
75
76
  name: item.name,
76
- input: parseArguments(item.arguments),
77
+ ...toolInputFromJson(item.arguments),
77
78
  });
78
79
  }
79
80
  else {
@@ -106,18 +107,3 @@ function normalizeUsage(data) {
106
107
  reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
107
108
  };
108
109
  }
109
- // Arguments arrive as a JSON string and models vary in how they escape it, so
110
- // this always goes through a real parse — never string matching.
111
- function parseArguments(text) {
112
- if (text === undefined || text === "")
113
- return {};
114
- try {
115
- const parsed = JSON.parse(text);
116
- return typeof parsed === "object" && parsed !== null
117
- ? parsed
118
- : {};
119
- }
120
- catch {
121
- return {};
122
- }
123
- }
@@ -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,17 @@
1
+ // Provider-neutral validation for tool arguments crossing a wire boundary.
2
+ export function toolInputFromJson(text) {
3
+ if (text === undefined || text.trim() === "")
4
+ return { input: {} };
5
+ try {
6
+ return toolInputFromValue(JSON.parse(text));
7
+ }
8
+ catch {
9
+ return { input: {}, inputError: "tool arguments were not valid JSON" };
10
+ }
11
+ }
12
+ export function toolInputFromValue(value) {
13
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
14
+ return { input: value };
15
+ }
16
+ return { input: {}, inputError: "tool arguments must be a JSON object" };
17
+ }
@@ -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
+ }
@@ -3,9 +3,10 @@
3
3
  // can become conversation or provider input.
4
4
  import { Buffer } from "node:buffer";
5
5
  import { CONTEXT_LIMITS } from "../context/projection.js";
6
+ import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
6
7
  export const SESSION_SCHEMA = 4;
7
8
  export const SESSION_FILE_LIMITS = Object.freeze({
8
- text: 1_048_576,
9
+ text: MAX_TEXT_CODE_UNITS,
9
10
  metadataBytes: 64 * 1_024,
10
11
  nodeBytes: 20 * 1_024 * 1_024,
11
12
  jsonDepth: 24,
@@ -9,6 +9,13 @@ export function sessionLease(id, file, token) {
9
9
  let closed = false;
10
10
  return Object.freeze({
11
11
  id,
12
+ assertOwned: async () => {
13
+ if (closed)
14
+ throw new Error("session lease is closed");
15
+ const current = await readLease(file);
16
+ if (current !== token)
17
+ throw new Error("session lease is no longer owned by this process");
18
+ },
12
19
  close: async () => {
13
20
  if (closed)
14
21
  return;
@@ -8,11 +8,13 @@ export class SessionPersistence {
8
8
  #store;
9
9
  #sessionId;
10
10
  #lease;
11
+ #snapshot;
11
12
  #failure;
12
- constructor(store, sessionId, lease) {
13
+ constructor(store, sessionId, lease, snapshot) {
13
14
  this.#store = store;
14
15
  this.#sessionId = sessionId;
15
16
  this.#lease = lease;
17
+ this.#snapshot = snapshot;
16
18
  }
17
19
  static fresh(store) {
18
20
  return new SessionPersistence(store, null);
@@ -26,7 +28,7 @@ export class SessionPersistence {
26
28
  throw new Error("session has no resumable turn");
27
29
  return Object.freeze({
28
30
  conversation,
29
- persistence: new SessionPersistence(store, id, lease),
31
+ persistence: new SessionPersistence(store, id, lease, snapshot),
30
32
  });
31
33
  }
32
34
  catch (error) {
@@ -51,9 +53,14 @@ export class SessionPersistence {
51
53
  const published = await this.#store.publish(conversation, true);
52
54
  this.#lease = published.lease;
53
55
  this.#sessionId = published.meta.id;
56
+ this.#snapshot = published;
54
57
  return;
55
58
  }
56
- await this.#store.checkpoint(this.#sessionId, conversation);
59
+ if (this.#lease === undefined || this.#snapshot === undefined) {
60
+ throw new Error("session persistence has no verified owner snapshot");
61
+ }
62
+ await this.#lease.assertOwned();
63
+ this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
57
64
  }
58
65
  catch (error) {
59
66
  this.#failure = error;
@@ -64,6 +71,7 @@ export class SessionPersistence {
64
71
  await this.#lease?.close();
65
72
  this.#lease = undefined;
66
73
  this.#sessionId = null;
74
+ this.#snapshot = undefined;
67
75
  this.#failure = undefined;
68
76
  }
69
77
  async close() {