cursor-opencode-provider 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +33 -23
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +11 -1
  6. package/dist/context/build.js +76 -25
  7. package/dist/context/frozen.d.ts +29 -0
  8. package/dist/context/frozen.js +168 -0
  9. package/dist/context/index.d.ts +3 -2
  10. package/dist/context/index.js +3 -2
  11. package/dist/context/paths.d.ts +34 -53
  12. package/dist/context/paths.js +54 -118
  13. package/dist/context/rules.d.ts +1 -0
  14. package/dist/context/rules.js +2 -2
  15. package/dist/image-save-tool.d.ts +19 -0
  16. package/dist/image-save-tool.js +25 -0
  17. package/dist/image-save.d.ts +58 -0
  18. package/dist/image-save.js +189 -0
  19. package/dist/image-staging.d.ts +59 -0
  20. package/dist/image-staging.js +91 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/language-model.d.ts +13 -11
  23. package/dist/language-model.js +957 -150
  24. package/dist/plan-execution-kickoff.d.ts +55 -0
  25. package/dist/plan-execution-kickoff.js +162 -0
  26. package/dist/plugin-opencode2.js +52 -3
  27. package/dist/plugin-v2.d.ts +11 -9
  28. package/dist/plugin-v2.js +6 -13
  29. package/dist/plugin.d.ts +9 -0
  30. package/dist/plugin.js +61 -5
  31. package/dist/pricing-data.d.ts +16 -4
  32. package/dist/pricing-data.js +16 -4
  33. package/dist/protocol/ask-question.d.ts +133 -0
  34. package/dist/protocol/ask-question.js +320 -0
  35. package/dist/protocol/blob-reachability.d.ts +15 -0
  36. package/dist/protocol/blob-reachability.js +140 -0
  37. package/dist/protocol/blob-store.d.ts +37 -0
  38. package/dist/protocol/blob-store.js +115 -0
  39. package/dist/protocol/conversation-bind.d.ts +13 -0
  40. package/dist/protocol/conversation-bind.js +27 -1
  41. package/dist/protocol/conversation-persistence.d.ts +32 -0
  42. package/dist/protocol/conversation-persistence.js +504 -0
  43. package/dist/protocol/conversation-state.d.ts +16 -0
  44. package/dist/protocol/conversation-state.js +62 -0
  45. package/dist/protocol/create-plan.d.ts +141 -0
  46. package/dist/protocol/create-plan.js +373 -0
  47. package/dist/protocol/generate-image.d.ts +54 -0
  48. package/dist/protocol/generate-image.js +101 -0
  49. package/dist/protocol/interactions.d.ts +110 -8
  50. package/dist/protocol/interactions.js +295 -35
  51. package/dist/protocol/kv.d.ts +1 -0
  52. package/dist/protocol/kv.js +2 -0
  53. package/dist/protocol/messages.d.ts +2 -0
  54. package/dist/protocol/messages.js +148 -8
  55. package/dist/protocol/request.d.ts +2 -0
  56. package/dist/protocol/request.js +2 -0
  57. package/dist/protocol/switch-mode.d.ts +178 -0
  58. package/dist/protocol/switch-mode.js +417 -0
  59. package/dist/protocol/token-details.d.ts +33 -0
  60. package/dist/protocol/token-details.js +155 -0
  61. package/dist/protocol/tool-call-bridge.js +10 -5
  62. package/dist/protocol/tools.d.ts +44 -11
  63. package/dist/protocol/tools.js +171 -41
  64. package/dist/session.d.ts +39 -11
  65. package/dist/shared.d.ts +3 -0
  66. package/dist/shared.js +3 -0
  67. package/dist/usage.d.ts +42 -37
  68. package/dist/usage.js +201 -70
  69. package/dist/web-search-tool.d.ts +22 -26
  70. package/dist/web-search-tool.js +25 -15
  71. package/package.json +7 -4
@@ -0,0 +1,504 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { chmod, link, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
4
+ import { constants as zlibConstants, gunzipSync, gzipSync } from "node:zlib";
5
+ import protobuf from "protobufjs";
6
+ import { CONVERSATION_CACHE_DIR, CONVERSATION_CACHE_SCHEMA_VERSION, CONVERSATION_CACHE_TTL_MS, } from "../shared.js";
7
+ import { decodeMessageSparse, encodeMessage } from "./messages.js";
8
+ import { trace } from "../debug.js";
9
+ const stores = new Map();
10
+ const initializedRoots = new Map();
11
+ const startupDiscardedStatuses = new Map();
12
+ const MAX_CONVERSATION_CACHE_BYTES = 256 * 1024 * 1024;
13
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
14
+ function recordStartupDiscard(cacheDir, fileName, status) {
15
+ const root = path.resolve(cacheDir);
16
+ let statuses = startupDiscardedStatuses.get(root);
17
+ if (!statuses) {
18
+ statuses = new Map();
19
+ startupDiscardedStatuses.set(root, statuses);
20
+ }
21
+ statuses.set(fileName, status);
22
+ }
23
+ export function conversationCacheDirectoryPath(cacheDir) {
24
+ return path.join(cacheDir, CONVERSATION_CACHE_DIR);
25
+ }
26
+ function sessionFileName(sessionKey) {
27
+ const label = sessionKey.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48);
28
+ const hash = createHash("sha256").update(sessionKey).digest("hex");
29
+ return `${label || "session"}-${hash}.pb.gz`;
30
+ }
31
+ export function conversationCacheFilePath(cacheDir, sessionKey) {
32
+ return path.join(conversationCacheDirectoryPath(cacheDir), sessionFileName(sessionKey));
33
+ }
34
+ function cloneConversation(value) {
35
+ return {
36
+ ...value,
37
+ checkpoint: value.checkpoint ? Uint8Array.from(value.checkpoint) : undefined,
38
+ blobs: value.blobs.map((blob) => ({ id: blob.id, data: Uint8Array.from(blob.data) })),
39
+ requestContext: structuredClone(value.requestContext),
40
+ toolCatalog: structuredClone(value.toolCatalog),
41
+ postCompactionRebase: value.postCompactionRebase,
42
+ };
43
+ }
44
+ /**
45
+ * Private proto3 layout, encoded inline because the published package contains
46
+ * compiled JS only:
47
+ *
48
+ * ConversationCache: schema_version=1, session_key=2, conversation_id=3,
49
+ * updated_at=4, checkpoint=5, blobs=6, request_context=7, tool_catalog=8,
50
+ * post_compaction_rebase=9.
51
+ * Blob: id=1, data=2. Tool: name=1, description=2,
52
+ * input_schema_json=3, source_name=4.
53
+ *
54
+ * Opaque protocol state stays as bytes. Only arbitrary host JSON Schemas remain
55
+ * JSON, nested as UTF-8 bytes inside each Tool message.
56
+ */
57
+ function fieldTag(field, wireType) {
58
+ return (field << 3) | wireType;
59
+ }
60
+ function bytesToHex(bytes) {
61
+ return Buffer.from(bytes).toString("hex");
62
+ }
63
+ function readUint64(reader) {
64
+ const raw = reader.uint64();
65
+ const value = typeof raw === "number" ? raw : Number(raw.toString());
66
+ if (!Number.isSafeInteger(value) || value < 0)
67
+ throw new Error("invalid uint64");
68
+ return value;
69
+ }
70
+ function readBlob(encoded) {
71
+ const reader = protobuf.Reader.create(encoded);
72
+ let id;
73
+ let blobData;
74
+ while (reader.pos < reader.len) {
75
+ const tag = reader.uint32();
76
+ switch (tag >>> 3) {
77
+ case 1:
78
+ if ((tag & 7) !== 2)
79
+ throw new Error("invalid blob id");
80
+ id = reader.bytes();
81
+ break;
82
+ case 2:
83
+ if ((tag & 7) !== 2)
84
+ throw new Error("invalid blob data");
85
+ blobData = reader.bytes();
86
+ break;
87
+ default:
88
+ reader.skipType(tag & 7);
89
+ }
90
+ }
91
+ if (!id || id.length === 0 || !blobData)
92
+ throw new Error("incomplete blob");
93
+ return { id: bytesToHex(id), data: Uint8Array.from(blobData) };
94
+ }
95
+ function readTool(data) {
96
+ const reader = protobuf.Reader.create(data);
97
+ let name;
98
+ let description;
99
+ let inputSchema;
100
+ let hasInputSchema = false;
101
+ let sourceName;
102
+ while (reader.pos < reader.len) {
103
+ const tag = reader.uint32();
104
+ const wireType = tag & 7;
105
+ switch (tag >>> 3) {
106
+ case 1:
107
+ if (wireType !== 2)
108
+ throw new Error("invalid tool name");
109
+ name = reader.string();
110
+ break;
111
+ case 2:
112
+ if (wireType !== 2)
113
+ throw new Error("invalid tool description");
114
+ description = reader.string();
115
+ break;
116
+ case 3:
117
+ if (wireType !== 2)
118
+ throw new Error("invalid tool schema");
119
+ inputSchema = JSON.parse(utf8Decoder.decode(reader.bytes()));
120
+ hasInputSchema = true;
121
+ break;
122
+ case 4:
123
+ if (wireType !== 2)
124
+ throw new Error("invalid tool source name");
125
+ sourceName = reader.string();
126
+ break;
127
+ default:
128
+ reader.skipType(wireType);
129
+ }
130
+ }
131
+ if (!name)
132
+ throw new Error("incomplete tool");
133
+ return {
134
+ name,
135
+ description,
136
+ ...(hasInputSchema ? { inputSchema } : {}),
137
+ sourceName,
138
+ };
139
+ }
140
+ function blobIdBytes(id) {
141
+ if (!/^(?:[0-9a-f]{2})+$/i.test(id))
142
+ throw new Error("invalid conversation blob id");
143
+ return Buffer.from(id, "hex");
144
+ }
145
+ function encodeCacheFile(value) {
146
+ const writer = protobuf.Writer.create();
147
+ const requestContext = encodeMessage("RequestContext", value.requestContext);
148
+ writer.uint32(fieldTag(1, 0)).uint32(CONVERSATION_CACHE_SCHEMA_VERSION);
149
+ writer.uint32(fieldTag(2, 2)).string(value.sessionKey);
150
+ writer.uint32(fieldTag(3, 2)).string(value.conversationId);
151
+ writer.uint32(fieldTag(4, 0)).uint64(value.updatedAt);
152
+ if (value.checkpoint?.length)
153
+ writer.uint32(fieldTag(5, 2)).bytes(value.checkpoint);
154
+ for (const blob of value.blobs) {
155
+ writer.uint32(fieldTag(6, 2)).fork();
156
+ writer.uint32(fieldTag(1, 2)).bytes(blobIdBytes(blob.id));
157
+ writer.uint32(fieldTag(2, 2)).bytes(blob.data);
158
+ writer.ldelim();
159
+ }
160
+ writer.uint32(fieldTag(7, 2)).bytes(requestContext);
161
+ for (const tool of value.toolCatalog) {
162
+ writer.uint32(fieldTag(8, 2)).fork();
163
+ writer.uint32(fieldTag(1, 2)).string(tool.name);
164
+ if (tool.description !== undefined)
165
+ writer.uint32(fieldTag(2, 2)).string(tool.description);
166
+ if (tool.inputSchema !== undefined) {
167
+ writer.uint32(fieldTag(3, 2)).bytes(Buffer.from(JSON.stringify(tool.inputSchema), "utf8"));
168
+ }
169
+ if (tool.sourceName !== undefined)
170
+ writer.uint32(fieldTag(4, 2)).string(tool.sourceName);
171
+ writer.ldelim();
172
+ }
173
+ if (value.postCompactionRebase)
174
+ writer.uint32(fieldTag(9, 0)).bool(true);
175
+ return { protobufBytes: writer.finish(), requestContextBytes: requestContext.length };
176
+ }
177
+ function decodeProtobuf(data) {
178
+ const reader = protobuf.Reader.create(data);
179
+ let schemaVersion;
180
+ let sessionKey;
181
+ let conversationId;
182
+ let updatedAt;
183
+ let checkpoint;
184
+ const blobs = [];
185
+ let requestContextBytes;
186
+ const toolCatalog = [];
187
+ let postCompactionRebase = false;
188
+ while (reader.pos < reader.len) {
189
+ const tag = reader.uint32();
190
+ const wireType = tag & 7;
191
+ switch (tag >>> 3) {
192
+ case 1:
193
+ if (wireType !== 0)
194
+ throw new Error("invalid schema version");
195
+ schemaVersion = reader.uint32();
196
+ break;
197
+ case 2:
198
+ if (wireType !== 2)
199
+ throw new Error("invalid session key");
200
+ sessionKey = reader.string();
201
+ break;
202
+ case 3:
203
+ if (wireType !== 2)
204
+ throw new Error("invalid conversation id");
205
+ conversationId = reader.string();
206
+ break;
207
+ case 4:
208
+ if (wireType !== 0)
209
+ throw new Error("invalid update timestamp");
210
+ updatedAt = readUint64(reader);
211
+ break;
212
+ case 5:
213
+ if (wireType !== 2)
214
+ throw new Error("invalid checkpoint");
215
+ checkpoint = Uint8Array.from(reader.bytes());
216
+ break;
217
+ case 6:
218
+ if (wireType !== 2)
219
+ throw new Error("invalid blob");
220
+ blobs.push(readBlob(reader.bytes()));
221
+ break;
222
+ case 7:
223
+ if (wireType !== 2)
224
+ throw new Error("invalid request context");
225
+ requestContextBytes = reader.bytes();
226
+ break;
227
+ case 8:
228
+ if (wireType !== 2)
229
+ throw new Error("invalid tool");
230
+ toolCatalog.push(readTool(reader.bytes()));
231
+ break;
232
+ case 9:
233
+ if (wireType !== 0)
234
+ throw new Error("invalid compaction marker");
235
+ postCompactionRebase = reader.bool();
236
+ break;
237
+ default:
238
+ reader.skipType(wireType);
239
+ }
240
+ }
241
+ if (schemaVersion !== CONVERSATION_CACHE_SCHEMA_VERSION ||
242
+ !sessionKey || !conversationId || updatedAt === undefined ||
243
+ !requestContextBytes || requestContextBytes.length === 0)
244
+ throw new Error("incomplete conversation cache");
245
+ const requestContext = decodeMessageSparse("RequestContext", requestContextBytes);
246
+ return {
247
+ sessionKey,
248
+ conversationId,
249
+ updatedAt,
250
+ checkpoint,
251
+ blobs,
252
+ requestContext,
253
+ toolCatalog,
254
+ postCompactionRebase,
255
+ };
256
+ }
257
+ function decodeCacheFile(compressed, expectedSessionKey) {
258
+ try {
259
+ if (compressed.length > MAX_CONVERSATION_CACHE_BYTES)
260
+ return undefined;
261
+ const protobufBytes = gunzipSync(compressed, { maxOutputLength: MAX_CONVERSATION_CACHE_BYTES });
262
+ const conversation = decodeProtobuf(protobufBytes);
263
+ if (expectedSessionKey && conversation.sessionKey !== expectedSessionKey)
264
+ return undefined;
265
+ return conversation;
266
+ }
267
+ catch {
268
+ return undefined;
269
+ }
270
+ }
271
+ function isFresh(value, now) {
272
+ return now - value.updatedAt <= CONVERSATION_CACHE_TTL_MS;
273
+ }
274
+ async function readConversationFile(filePath, expectedSessionKey) {
275
+ try {
276
+ return decodeCacheFile(await readFile(filePath), expectedSessionKey);
277
+ }
278
+ catch (error) {
279
+ const code = error && typeof error === "object" && "code" in error
280
+ ? error.code
281
+ : undefined;
282
+ if (code !== "ENOENT")
283
+ return undefined;
284
+ return undefined;
285
+ }
286
+ }
287
+ async function readConversationFileWithStatus(filePath, expectedSessionKey) {
288
+ try {
289
+ const value = decodeCacheFile(await readFile(filePath), expectedSessionKey);
290
+ return value ? { status: "restored", value } : { status: "invalid" };
291
+ }
292
+ catch (error) {
293
+ const code = error && typeof error === "object" && "code" in error
294
+ ? error.code
295
+ : undefined;
296
+ return { status: code === "ENOENT" ? "missing" : "invalid" };
297
+ }
298
+ }
299
+ /**
300
+ * Remove an invalid/stale candidate without deleting a fresh atomic replacement.
301
+ * The candidate is first moved aside. If it turns out another process replaced
302
+ * it between inspection and rename, hard-link it back only when the live path is
303
+ * still empty; otherwise the writer's newer path wins.
304
+ */
305
+ async function discardCacheCandidate(filePath, now) {
306
+ const quarantinePath = `${filePath}.${process.pid}.${randomUUID()}.stale`;
307
+ try {
308
+ await rename(filePath, quarantinePath);
309
+ }
310
+ catch (error) {
311
+ const code = error && typeof error === "object" && "code" in error
312
+ ? error.code
313
+ : undefined;
314
+ if (code === "ENOENT")
315
+ return undefined;
316
+ throw error;
317
+ }
318
+ try {
319
+ const moved = await readConversationFile(quarantinePath);
320
+ if (moved && isFresh(moved, now)) {
321
+ try {
322
+ await link(quarantinePath, filePath);
323
+ return moved;
324
+ }
325
+ catch (error) {
326
+ const code = error && typeof error === "object" && "code" in error
327
+ ? error.code
328
+ : undefined;
329
+ if (code !== "EEXIST")
330
+ throw error;
331
+ return await readConversationFile(filePath);
332
+ }
333
+ }
334
+ }
335
+ finally {
336
+ await unlink(quarantinePath).catch(() => { });
337
+ }
338
+ return await readConversationFile(filePath);
339
+ }
340
+ async function writeConversationFile(cacheDir, value) {
341
+ const directory = conversationCacheDirectoryPath(cacheDir);
342
+ const filePath = conversationCacheFilePath(cacheDir, value.sessionKey);
343
+ const tempPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
344
+ const encoded = encodeCacheFile(value);
345
+ const { protobufBytes } = encoded;
346
+ const compressed = gzipSync(protobufBytes, { level: zlibConstants.Z_BEST_COMPRESSION });
347
+ await ensureCacheDirectory(directory);
348
+ try {
349
+ await writeFile(tempPath, compressed, { mode: 0o600 });
350
+ await rename(tempPath, filePath);
351
+ trace(`conversation persistence: snapshot written sessionKey=${value.sessionKey} ` +
352
+ `fileBytes=${compressed.length} protobufBytes=${protobufBytes.length} ` +
353
+ `checkpointBytes=${value.checkpoint?.length ?? 0} ` +
354
+ `blobCount=${value.blobs.length} blobBytes=${value.blobs.reduce((sum, blob) => sum + blob.data.length, 0)} ` +
355
+ `requestContextBytes=${encoded.requestContextBytes} ` +
356
+ `toolCatalogBytes=${Buffer.byteLength(JSON.stringify(value.toolCatalog))}`);
357
+ }
358
+ finally {
359
+ await unlink(tempPath).catch(() => { });
360
+ }
361
+ }
362
+ async function ensureCacheDirectory(directory) {
363
+ await mkdir(directory, { recursive: true, mode: 0o700 });
364
+ if (process.platform !== "win32")
365
+ await chmod(directory, 0o700);
366
+ }
367
+ function queueWrite(store, value) {
368
+ const snapshot = cloneConversation(value);
369
+ const write = store.writeChain.catch(() => { }).then(() => writeConversationFile(store.cacheDir, snapshot));
370
+ store.writeChain = write;
371
+ return write;
372
+ }
373
+ function queueDelete(store) {
374
+ const write = store.writeChain.catch(() => { }).then(async () => {
375
+ await unlink(conversationCacheFilePath(store.cacheDir, store.sessionKey)).catch((error) => {
376
+ const code = error && typeof error === "object" && "code" in error
377
+ ? error.code
378
+ : undefined;
379
+ if (code !== "ENOENT")
380
+ throw error;
381
+ });
382
+ });
383
+ store.writeChain = write;
384
+ return write;
385
+ }
386
+ async function pruneConversationDirectory(cacheDir, now) {
387
+ const directory = conversationCacheDirectoryPath(cacheDir);
388
+ await ensureCacheDirectory(directory);
389
+ const entries = await readdir(directory, { withFileTypes: true });
390
+ await Promise.all(entries.map(async (entry) => {
391
+ if (!entry.isFile())
392
+ return;
393
+ const filePath = path.join(directory, entry.name);
394
+ if (entry.name.endsWith(".json")) {
395
+ trace(`conversation persistence: startup removed legacy JSON snapshot file=${entry.name}`);
396
+ await unlink(filePath).catch(() => { });
397
+ return;
398
+ }
399
+ if (!entry.name.endsWith(".pb.gz"))
400
+ return;
401
+ const loaded = await readConversationFileWithStatus(filePath);
402
+ if (loaded.status === "invalid") {
403
+ recordStartupDiscard(cacheDir, entry.name, "invalid");
404
+ trace(`conversation persistence: startup discarded invalid snapshot file=${entry.name}`);
405
+ await discardCacheCandidate(filePath, now);
406
+ }
407
+ else if (loaded.value && !isFresh(loaded.value, now)) {
408
+ recordStartupDiscard(cacheDir, entry.name, "expired");
409
+ trace(`conversation persistence: startup discarded expired snapshot ` +
410
+ `sessionKey=${loaded.value.sessionKey} ageMs=${Math.max(0, now - loaded.value.updatedAt)}`);
411
+ await discardCacheCandidate(filePath, now);
412
+ }
413
+ }));
414
+ }
415
+ /** Create the per-session cache directory and prune records older than 24 hours once per process. */
416
+ export async function initializeConversationPersistence(cacheDir, now = Date.now()) {
417
+ const key = path.resolve(cacheDir);
418
+ let pending = initializedRoots.get(key);
419
+ if (!pending) {
420
+ pending = pruneConversationDirectory(key, now);
421
+ initializedRoots.set(key, pending);
422
+ pending.catch(() => {
423
+ if (initializedRoots.get(key) === pending)
424
+ initializedRoots.delete(key);
425
+ });
426
+ }
427
+ await pending;
428
+ }
429
+ async function loadStore(cacheDir, sessionKey, now) {
430
+ await initializeConversationPersistence(cacheDir, now);
431
+ const filePath = conversationCacheFilePath(cacheDir, sessionKey);
432
+ const loaded = await readConversationFileWithStatus(filePath, sessionKey);
433
+ let value = loaded.value;
434
+ let loadStatus = loaded.status === "missing"
435
+ ? startupDiscardedStatuses.get(path.resolve(cacheDir))?.get(path.basename(filePath)) ?? "missing"
436
+ : loaded.status;
437
+ if (value && !isFresh(value, now)) {
438
+ loadStatus = "expired";
439
+ value = await discardCacheCandidate(filePath, now);
440
+ if (value && isFresh(value, now))
441
+ loadStatus = "restored";
442
+ }
443
+ return {
444
+ cacheDir,
445
+ sessionKey,
446
+ value: value && value.sessionKey === sessionKey && isFresh(value, now) ? value : undefined,
447
+ loadStatus,
448
+ writeChain: Promise.resolve(),
449
+ };
450
+ }
451
+ async function getStore(cacheDir, sessionKey, now = Date.now()) {
452
+ const root = path.resolve(cacheDir);
453
+ const key = `${root}\0${sessionKey}`;
454
+ let pending = stores.get(key);
455
+ if (!pending) {
456
+ pending = loadStore(root, sessionKey, now);
457
+ stores.set(key, pending);
458
+ pending.catch(() => {
459
+ if (stores.get(key) === pending)
460
+ stores.delete(key);
461
+ });
462
+ }
463
+ return pending;
464
+ }
465
+ export async function getPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
466
+ const store = await getStore(cacheDir, sessionKey, now);
467
+ return store.value ? cloneConversation(store.value) : undefined;
468
+ }
469
+ /** Read a snapshot together with the reason restart hydration did not restore it. */
470
+ export async function loadPersistedConversation(cacheDir, sessionKey, now = Date.now()) {
471
+ const store = await getStore(cacheDir, sessionKey, now);
472
+ return store.value
473
+ ? { status: "restored", value: cloneConversation(store.value) }
474
+ : { status: store.loadStatus };
475
+ }
476
+ /** Replace one session's durable snapshot and refresh its 24-hour lease. */
477
+ export async function persistConversation(cacheDir, value, now = Date.now()) {
478
+ const store = await getStore(cacheDir, value.sessionKey, now);
479
+ const snapshot = cloneConversation({
480
+ ...value,
481
+ updatedAt: now,
482
+ toolCatalog: structuredClone(value.toolCatalog ?? []),
483
+ postCompactionRebase: value.postCompactionRebase === true,
484
+ });
485
+ store.value = snapshot;
486
+ store.loadStatus = "restored";
487
+ startupDiscardedStatuses.get(path.resolve(cacheDir))?.delete(path.basename(conversationCacheFilePath(cacheDir, value.sessionKey)));
488
+ await queueWrite(store, snapshot);
489
+ }
490
+ /** Delete only the expected binding so an overlapping newer turn cannot be removed. */
491
+ export async function deletePersistedConversation(cacheDir, sessionKey, expectedConversationId) {
492
+ const store = await getStore(cacheDir, sessionKey);
493
+ const current = store.value;
494
+ if (!current || (expectedConversationId && current.conversationId !== expectedConversationId))
495
+ return;
496
+ store.value = undefined;
497
+ store.loadStatus = "missing";
498
+ await queueDelete(store);
499
+ }
500
+ export function resetConversationPersistenceForTests() {
501
+ stores.clear();
502
+ initializedRoots.clear();
503
+ startupDiscardedStatuses.clear();
504
+ }
@@ -0,0 +1,16 @@
1
+ import type { OpencodeToolDef } from "./tools.js";
2
+ /** Restore one OpenCode session before its conversation binding is resolved. */
3
+ export declare function hydrateConversationState(cacheDir: string, sessionKey: string): Promise<{
4
+ conversationId: string;
5
+ postCompactionRebase: boolean;
6
+ toolCatalog: OpencodeToolDef[];
7
+ } | undefined>;
8
+ /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
9
+ export declare function persistConversationState(cacheDir: string, input: {
10
+ sessionKey: string;
11
+ conversationId: string;
12
+ requestContext: Record<string, unknown>;
13
+ toolCatalog?: OpencodeToolDef[];
14
+ postCompactionRebase?: boolean;
15
+ }): Promise<void>;
16
+ export declare function clearPersistedConversationState(cacheDir: string, sessionKey: string, expectedConversationId?: string): Promise<void>;
@@ -0,0 +1,62 @@
1
+ import { trace } from "../debug.js";
2
+ import { getFrozenRequestContext, setFrozenRequestContext, } from "../context/frozen.js";
3
+ import { hasConversationBinding, isActiveConversationBinding, restoreConversationBinding, } from "./conversation-bind.js";
4
+ import { compactConversationBlobs, restoreConversationBlobs, } from "./blob-store.js";
5
+ import { getCheckpoint, setCheckpoint } from "./checkpoint.js";
6
+ import { deletePersistedConversation, loadPersistedConversation, persistConversation, } from "./conversation-persistence.js";
7
+ /** Restore one OpenCode session before its conversation binding is resolved. */
8
+ export async function hydrateConversationState(cacheDir, sessionKey) {
9
+ if (hasConversationBinding(sessionKey))
10
+ return undefined;
11
+ const loaded = await loadPersistedConversation(cacheDir, sessionKey);
12
+ const persisted = loaded.value;
13
+ if (!persisted) {
14
+ trace(`conversation persistence: hydration skipped sessionKey=${sessionKey} ` +
15
+ `status=${loaded.status}`);
16
+ return undefined;
17
+ }
18
+ restoreConversationBinding(sessionKey, persisted.conversationId);
19
+ if (persisted.checkpoint)
20
+ setCheckpoint(persisted.conversationId, persisted.checkpoint);
21
+ restoreConversationBlobs(persisted.conversationId, persisted.blobs);
22
+ setFrozenRequestContext(persisted.conversationId, persisted.requestContext);
23
+ trace(`conversation persistence: restored sessionKey=${sessionKey} ` +
24
+ `conversationId=${persisted.conversationId} checkpoint=${persisted.checkpoint?.length ?? 0}B ` +
25
+ `blobs=${persisted.blobs.length}`);
26
+ return {
27
+ conversationId: persisted.conversationId,
28
+ postCompactionRebase: persisted.postCompactionRebase,
29
+ toolCatalog: structuredClone(persisted.toolCatalog),
30
+ };
31
+ }
32
+ /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
33
+ export async function persistConversationState(cacheDir, input) {
34
+ // A newer Run may have reset/superseded this conversation while its final
35
+ // frame was still in flight. Never let that late TurnEnded resurrect it.
36
+ if (!isActiveConversationBinding(input.sessionKey, input.conversationId)) {
37
+ trace(`conversation persistence: skipped superseded TurnEnded ` +
38
+ `sessionKey=${input.sessionKey} conversationId=${input.conversationId}`);
39
+ return;
40
+ }
41
+ const checkpoint = getCheckpoint(input.conversationId);
42
+ const blobCompaction = compactConversationBlobs(input.conversationId, checkpoint);
43
+ const blobs = blobCompaction.blobs;
44
+ const requestContext = getFrozenRequestContext(input.conversationId) ?? input.requestContext;
45
+ await persistConversation(cacheDir, {
46
+ sessionKey: input.sessionKey,
47
+ conversationId: input.conversationId,
48
+ checkpoint,
49
+ blobs,
50
+ requestContext,
51
+ toolCatalog: structuredClone(input.toolCatalog ?? []),
52
+ postCompactionRebase: input.postCompactionRebase,
53
+ });
54
+ trace(`conversation persistence: saved sessionKey=${input.sessionKey} ` +
55
+ `conversationId=${input.conversationId} checkpoint=${checkpoint?.length ?? 0}B ` +
56
+ `blobs=${blobCompaction.beforeCount}->${blobCompaction.afterCount} ` +
57
+ `blobBytes=${blobCompaction.beforeBytes}->${blobCompaction.afterBytes}` +
58
+ (blobCompaction.fallbackReason ? ` compactionFallback=${blobCompaction.fallbackReason}` : ""));
59
+ }
60
+ export async function clearPersistedConversationState(cacheDir, sessionKey, expectedConversationId) {
61
+ await deletePersistedConversation(cacheDir, sessionKey, expectedConversationId);
62
+ }