@jskit-ai/assistant-core 0.1.165 → 0.1.166

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 (63) hide show
  1. package/examples/conversation/App.vue +104 -0
  2. package/examples/conversation/README.md +42 -0
  3. package/examples/conversation/dist/assets/index-CSTXm6q3.css +1 -0
  4. package/examples/conversation/dist/assets/index-DGgOLzoo.js +30 -0
  5. package/examples/conversation/dist/index.html +4 -0
  6. package/examples/conversation/index.html +2 -0
  7. package/examples/conversation/main.js +9 -0
  8. package/examples/conversation/node_modules/.vite/deps/@mdi_js.js +7450 -0
  9. package/examples/conversation/node_modules/.vite/deps/@mdi_js.js.map +1 -0
  10. package/examples/conversation/node_modules/.vite/deps/VTooltip-jIxO6ULs.js +2311 -0
  11. package/examples/conversation/node_modules/.vite/deps/VTooltip-jIxO6ULs.js.map +1 -0
  12. package/examples/conversation/node_modules/.vite/deps/_metadata.json +70 -0
  13. package/examples/conversation/node_modules/.vite/deps/defineComponent-DK6LSjDW.js +710 -0
  14. package/examples/conversation/node_modules/.vite/deps/defineComponent-DK6LSjDW.js.map +1 -0
  15. package/examples/conversation/node_modules/.vite/deps/hotkey-parsing-DnoZxtbW.js +1214 -0
  16. package/examples/conversation/node_modules/.vite/deps/hotkey-parsing-DnoZxtbW.js.map +1 -0
  17. package/examples/conversation/node_modules/.vite/deps/icons-ze2hmg7j.js +98 -0
  18. package/examples/conversation/node_modules/.vite/deps/icons-ze2hmg7j.js.map +1 -0
  19. package/examples/conversation/node_modules/.vite/deps/package.json +3 -0
  20. package/examples/conversation/node_modules/.vite/deps/theme-D78LVcJg.js +1301 -0
  21. package/examples/conversation/node_modules/.vite/deps/theme-D78LVcJg.js.map +1 -0
  22. package/examples/conversation/node_modules/.vite/deps/vue.js +2 -0
  23. package/examples/conversation/node_modules/.vite/deps/vue.runtime.esm-bundler-YcakWsDn.js +8538 -0
  24. package/examples/conversation/node_modules/.vite/deps/vue.runtime.esm-bundler-YcakWsDn.js.map +1 -0
  25. package/examples/conversation/node_modules/.vite/deps/vuetify.js +413 -0
  26. package/examples/conversation/node_modules/.vite/deps/vuetify.js.map +1 -0
  27. package/examples/conversation/node_modules/.vite/deps/vuetify_components.js +25370 -0
  28. package/examples/conversation/node_modules/.vite/deps/vuetify_components.js.map +1 -0
  29. package/examples/conversation/node_modules/.vite/deps/vuetify_directives.js +132 -0
  30. package/examples/conversation/node_modules/.vite/deps/vuetify_directives.js.map +1 -0
  31. package/examples/conversation/node_modules/.vite/deps/vuetify_iconsets_mdi-svg.js +70 -0
  32. package/examples/conversation/node_modules/.vite/deps/vuetify_iconsets_mdi-svg.js.map +1 -0
  33. package/examples/conversation/package.json +22 -0
  34. package/examples/conversation/server.js +123 -0
  35. package/examples/conversation/vite.config.js +8 -0
  36. package/package.json +59 -7
  37. package/src/client/conversation/AssistantComposerActions.vue +43 -0
  38. package/src/client/conversation/AssistantConversationElement.vue +78 -0
  39. package/src/client/conversation/AssistantProgress.vue +88 -0
  40. package/src/client/conversation/AssistantPromptInput.vue +467 -0
  41. package/src/client/conversation/AssistantTranscript.vue +1361 -0
  42. package/src/client/conversation/LongTextInlineParts.vue +64 -0
  43. package/src/client/conversation/LongTextPreviewBlocks.vue +324 -0
  44. package/src/client/conversation/index.js +9 -0
  45. package/src/client/conversation/submitText.js +29 -0
  46. package/src/client/conversation/useScrollToBottom.js +151 -0
  47. package/src/server/conversation/codexClient.js +328 -0
  48. package/src/server/conversation/codexEvents.js +623 -0
  49. package/src/server/conversation/codexTurn.js +280 -0
  50. package/src/server/conversation/index.js +2 -0
  51. package/src/server/conversation/memoryStorage.js +76 -0
  52. package/src/server/conversation/normalize.js +7 -0
  53. package/src/server/conversation/openCodeClient.js +512 -0
  54. package/src/server/conversation/transcript.js +92 -0
  55. package/src/shared/conversation/answerChoiceSugar.js +191 -0
  56. package/src/shared/conversation/index.js +6 -0
  57. package/src/shared/conversation/longTextBlocks.js +403 -0
  58. package/src/shared/conversation/numberedQuestionSugar.js +295 -0
  59. package/src/shared/conversation/scrollFollowState.js +32 -0
  60. package/src/shared/conversation/thinkingText.js +13 -0
  61. package/src/shared/conversation/turns.js +30 -0
  62. package/src/testing/conversationStorageContract.js +35 -0
  63. package/test/conversation.test.js +57 -0
@@ -0,0 +1,512 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { Buffer } from "node:buffer";
3
+ import path from "node:path";
4
+
5
+ const OPENCODE_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024;
6
+ const OPENCODE_CATALOG_LIMIT_BYTES = 32 * 1024 * 1024;
7
+ const OPENCODE_EVENT_LIMIT_BYTES = 2 * 1024 * 1024;
8
+
9
+ function text(value = "") {
10
+ return String(value ?? "").trim();
11
+ }
12
+
13
+ function record(value = null) {
14
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
15
+ }
16
+
17
+ function openCodeServerError(message, {
18
+ body = null,
19
+ code = "assistant_opencode_server_request_failed",
20
+ method = "",
21
+ path = "",
22
+ status = 0
23
+ } = {}) {
24
+ const error = new Error(message);
25
+ error.body = body;
26
+ error.code = code;
27
+ error.method = method;
28
+ error.path = path;
29
+ error.statusCode = status;
30
+ return error;
31
+ }
32
+
33
+ async function readBoundedResponse(response, limitBytes = OPENCODE_RESPONSE_LIMIT_BYTES) {
34
+ if (!response.body) {
35
+ return "";
36
+ }
37
+ const reader = response.body.getReader();
38
+ const chunks = [];
39
+ let length = 0;
40
+ try {
41
+ while (true) {
42
+ const next = await reader.read();
43
+ if (next.done) {
44
+ break;
45
+ }
46
+ length += next.value.byteLength;
47
+ if (length > limitBytes) {
48
+ throw openCodeServerError(
49
+ "OpenCode returned more data than the bridge accepts.",
50
+ { code: "assistant_opencode_response_too_large", status: response.status }
51
+ );
52
+ }
53
+ chunks.push(next.value);
54
+ }
55
+ } finally {
56
+ reader.releaseLock();
57
+ }
58
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), length).toString("utf8");
59
+ }
60
+
61
+ function parsedJson(value = "") {
62
+ const source = String(value || "").trim();
63
+ if (!source) {
64
+ return null;
65
+ }
66
+ try {
67
+ return JSON.parse(source);
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function responseErrorMessage(payload = null, fallback = "OpenCode rejected the request.") {
74
+ return text(
75
+ payload?.message ||
76
+ payload?.error?.message ||
77
+ payload?.error ||
78
+ payload?.data?.message
79
+ ) || fallback;
80
+ }
81
+
82
+ function queryString(values = {}) {
83
+ const params = new URLSearchParams();
84
+ for (const [name, value] of Object.entries(values)) {
85
+ if (value !== undefined && value !== null && String(value).length > 0) {
86
+ params.set(name, String(value));
87
+ }
88
+ }
89
+ const serialized = params.toString();
90
+ return serialized ? `?${serialized}` : "";
91
+ }
92
+
93
+ function sessionPath(sessionId = "", suffix = "") {
94
+ const id = encodeURIComponent(text(sessionId));
95
+ if (!id) {
96
+ throw new TypeError("OpenCode session requests require a session id.");
97
+ }
98
+ return `/api/session/${id}${suffix}`;
99
+ }
100
+
101
+ function stableSessionPath(sessionId = "", suffix = "") {
102
+ const id = encodeURIComponent(text(sessionId));
103
+ if (!id) {
104
+ throw new TypeError("OpenCode session requests require a session id.");
105
+ }
106
+ return `/session/${id}${suffix}`;
107
+ }
108
+
109
+ function normalizedMessageRows(value = null) {
110
+ const rows = Array.isArray(value) ? value : Array.isArray(value?.data) ? value.data : [];
111
+ return rows.map((message) => {
112
+ const info = message?.info;
113
+ if (!info || typeof info !== "object" || Array.isArray(info)) {
114
+ return message;
115
+ }
116
+ return {
117
+ ...info,
118
+ content: Array.isArray(message.parts) ? message.parts : [],
119
+ type: text(info.role)
120
+ };
121
+ });
122
+ }
123
+
124
+ function providerCapabilityMedia(value = null) {
125
+ const source = record(value);
126
+ return Object.fromEntries([
127
+ "audio",
128
+ "image",
129
+ "pdf",
130
+ "text",
131
+ "video"
132
+ ].filter((name) => typeof source[name] === "boolean").map((name) => [name, source[name]]));
133
+ }
134
+
135
+ function sanitizedOpenCodeModel(model = {}, fallbackId = "") {
136
+ const source = record(model);
137
+ const id = text(fallbackId);
138
+ const context = Number(source.limit?.context);
139
+ const output = Number(source.limit?.output);
140
+ return {
141
+ capabilities: {
142
+ attachment: source.capabilities?.attachment === true,
143
+ input: providerCapabilityMedia(source.capabilities?.input),
144
+ output: providerCapabilityMedia(source.capabilities?.output),
145
+ reasoning: source.capabilities?.reasoning === true,
146
+ toolcall: source.capabilities?.toolcall === true
147
+ },
148
+ family: text(source.family),
149
+ id,
150
+ limit: {
151
+ ...(Number.isSafeInteger(context) && context > 0 ? { context } : {}),
152
+ ...(Number.isSafeInteger(output) && output > 0 ? { output } : {})
153
+ },
154
+ name: text(source.name) || id,
155
+ release_date: text(source.release_date),
156
+ status: text(source.status),
157
+ variants: Object.fromEntries(Object.keys(record(source.variants))
158
+ .filter(Boolean)
159
+ .map((variantId) => [variantId, {}]))
160
+ };
161
+ }
162
+
163
+ function invalidOpenCodeCatalogError() {
164
+ return openCodeServerError("OpenCode returned an invalid provider catalogue.", {
165
+ code: "assistant_opencode_catalog_invalid",
166
+ method: "GET",
167
+ path: "/provider",
168
+ status: 502
169
+ });
170
+ }
171
+
172
+ function sanitizedOpenCodeProviderCatalog(value = null) {
173
+ const source = record(value);
174
+ if (!Array.isArray(source.all) || source.all.length === 0) {
175
+ throw invalidOpenCodeCatalogError();
176
+ }
177
+ const all = source.all.map((provider) => {
178
+ const candidate = record(provider);
179
+ const id = text(candidate.id);
180
+ const environmentNames = Array.isArray(candidate.env)
181
+ ? candidate.env.map(text).filter(Boolean)
182
+ : [];
183
+ const models = record(candidate.models);
184
+ return {
185
+ apiKeyCompatible: environmentNames.length === 1 || [
186
+ "amazon-bedrock",
187
+ "google"
188
+ ].includes(id),
189
+ id,
190
+ models: Object.fromEntries(Object.entries(models)
191
+ .map(([modelId, model]) => [text(modelId), sanitizedOpenCodeModel(model, modelId)])
192
+ .filter(([modelId, model]) => modelId && model.id)),
193
+ name: text(candidate.name) || id
194
+ };
195
+ }).filter((provider) => provider.id);
196
+ if (all.length === 0) {
197
+ throw invalidOpenCodeCatalogError();
198
+ }
199
+ const defaults = record(source.default);
200
+ return {
201
+ all,
202
+ default: Object.fromEntries(Object.entries(defaults)
203
+ .map(([providerId, modelId]) => [text(providerId), text(modelId)])
204
+ .filter(([providerId, modelId]) => providerId && modelId))
205
+ };
206
+ }
207
+
208
+ function stablePromptBody(input = {}) {
209
+ const model = input?.model && typeof input.model === "object" ? input.model : {};
210
+ const modelID = text(model.modelID || model.id);
211
+ const providerID = text(model.providerID);
212
+ const prompt = typeof input?.prompt?.text === "string" ? input.prompt.text : "";
213
+ if (!prompt.trim()) {
214
+ throw new TypeError("OpenCode prompt requests require text.");
215
+ }
216
+ return {
217
+ ...(text(input.agent) ? { agent: text(input.agent) } : {}),
218
+ ...(text(input.id) ? { messageID: text(input.id) } : {}),
219
+ ...(modelID && providerID ? { model: { modelID, providerID } } : {}),
220
+ parts: [{ text: prompt, type: "text" }, ...(input.attachments || [])
221
+ .filter((attachment) => attachment.contentType?.startsWith("image/"))
222
+ .map((attachment) => ({
223
+ type: "file", mime: attachment.contentType, filename: attachment.fileName,
224
+ url: pathToFileURL(attachment.path).href
225
+ }))],
226
+ ...(text(model.variant) ? { variant: text(model.variant) } : {})
227
+ };
228
+ }
229
+
230
+ function eventSessionId(value = null) {
231
+ const payload = value && typeof value === "object" && !Array.isArray(value) ? value : {};
232
+ const properties = payload.properties && typeof payload.properties === "object"
233
+ ? payload.properties
234
+ : {};
235
+ return text(
236
+ payload.sessionID ||
237
+ properties.sessionID ||
238
+ properties.info?.sessionID ||
239
+ properties.part?.sessionID
240
+ );
241
+ }
242
+
243
+ function decodeOpenCodeEventData(value = "") {
244
+ const first = parsedJson(value);
245
+ if (typeof first !== "string") {
246
+ return first;
247
+ }
248
+ return parsedJson(first) ?? first;
249
+ }
250
+
251
+ function createOpenCodeServerClient({
252
+ allowAttachmentDirectories = false,
253
+ baseUrl = "",
254
+ directory = "",
255
+ fetchImpl = globalThis.fetch,
256
+ password = "",
257
+ username = "opencode"
258
+ } = {}) {
259
+ const origin = new URL(baseUrl);
260
+ if (origin.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname)) {
261
+ throw new TypeError("OpenCode bridge clients require a loopback HTTP server.");
262
+ }
263
+ if (typeof fetchImpl !== "function") {
264
+ throw new TypeError("OpenCode bridge clients require fetch().");
265
+ }
266
+ const authorization = `Basic ${Buffer.from(`${text(username) || "opencode"}:${String(password)}`).toString("base64")}`;
267
+ const scopedDirectory = text(directory);
268
+
269
+ function requestHeaders(accept = "application/json", body = undefined) {
270
+ return {
271
+ accept,
272
+ authorization,
273
+ ...(scopedDirectory ? { "x-opencode-directory": scopedDirectory } : {}),
274
+ ...(body === undefined ? {} : { "content-type": "application/json" })
275
+ };
276
+ }
277
+
278
+ async function request(method = "GET", requestPath = "/", {
279
+ body,
280
+ limitBytes = OPENCODE_RESPONSE_LIMIT_BYTES,
281
+ signal
282
+ } = {}) {
283
+ const response = await fetchImpl(new URL(requestPath, origin), {
284
+ body: body === undefined ? undefined : JSON.stringify(body),
285
+ headers: requestHeaders("application/json", body),
286
+ method,
287
+ signal
288
+ });
289
+ const source = response.status === 204
290
+ ? ""
291
+ : await readBoundedResponse(response, limitBytes);
292
+ const payload = parsedJson(source);
293
+ if (!response.ok) {
294
+ throw openCodeServerError(responseErrorMessage(payload), {
295
+ body: payload,
296
+ method,
297
+ path: requestPath,
298
+ status: response.status
299
+ });
300
+ }
301
+ return payload;
302
+ }
303
+
304
+ async function *events(sessionId = "", { onReady = null, signal } = {}) {
305
+ const requestPath = "/event";
306
+ const response = await fetchImpl(new URL(requestPath, origin), {
307
+ headers: requestHeaders("text/event-stream"),
308
+ method: "GET",
309
+ signal
310
+ });
311
+ if (!response.ok || !response.body) {
312
+ const source = await readBoundedResponse(response);
313
+ const payload = parsedJson(source);
314
+ throw openCodeServerError(responseErrorMessage(payload), {
315
+ body: payload,
316
+ method: "GET",
317
+ path: requestPath,
318
+ status: response.status
319
+ });
320
+ }
321
+ onReady?.();
322
+ const decoder = new TextDecoder();
323
+ const reader = response.body.getReader();
324
+ let buffered = "";
325
+ let event = { data: [], event: "message", id: "" };
326
+ let eventBytes = 0;
327
+ const dispatch = () => {
328
+ if (!event.data.length) {
329
+ event = { data: [], event: "message", id: "" };
330
+ eventBytes = 0;
331
+ return null;
332
+ }
333
+ const value = {
334
+ data: decodeOpenCodeEventData(event.data.join("\n")),
335
+ event: event.event,
336
+ id: event.id
337
+ };
338
+ event = { data: [], event: "message", id: "" };
339
+ eventBytes = 0;
340
+ return value;
341
+ };
342
+ try {
343
+ while (true) {
344
+ const next = await reader.read();
345
+ if (next.done) {
346
+ const finalEvent = dispatch();
347
+ const finalSessionId = eventSessionId(finalEvent?.data);
348
+ if (finalEvent && (!finalSessionId || finalSessionId === text(sessionId))) {
349
+ yield finalEvent;
350
+ }
351
+ return;
352
+ }
353
+ buffered += decoder.decode(next.value, { stream: true });
354
+ let lineEnd = buffered.indexOf("\n");
355
+ while (lineEnd >= 0) {
356
+ const line = buffered.slice(0, lineEnd).replace(/\r$/u, "");
357
+ buffered = buffered.slice(lineEnd + 1);
358
+ if (!line) {
359
+ const value = dispatch();
360
+ const valueSessionId = eventSessionId(value?.data);
361
+ if (value && (!valueSessionId || valueSessionId === text(sessionId))) {
362
+ yield value;
363
+ }
364
+ } else if (!line.startsWith(":")) {
365
+ const separator = line.indexOf(":");
366
+ const field = separator < 0 ? line : line.slice(0, separator);
367
+ const value = separator < 0
368
+ ? ""
369
+ : line.slice(separator + 1).replace(/^ /u, "");
370
+ if (field === "data") {
371
+ eventBytes += Buffer.byteLength(value);
372
+ if (eventBytes > OPENCODE_EVENT_LIMIT_BYTES) {
373
+ throw openCodeServerError("An OpenCode event exceeded the bridge limit.", {
374
+ code: "assistant_opencode_event_too_large",
375
+ method: "GET",
376
+ path: requestPath,
377
+ status: response.status
378
+ });
379
+ }
380
+ event.data.push(value);
381
+ } else if (field === "event") {
382
+ event.event = value;
383
+ } else if (field === "id") {
384
+ event.id = value;
385
+ }
386
+ }
387
+ lineEnd = buffered.indexOf("\n");
388
+ }
389
+ }
390
+ } finally {
391
+ await reader.cancel().catch(() => null);
392
+ reader.releaseLock();
393
+ }
394
+ }
395
+
396
+ return Object.freeze({
397
+ async agents({ directory = "", signal } = {}) {
398
+ return request("GET", `/agent${queryString({ directory })}`, {
399
+ limitBytes: OPENCODE_CATALOG_LIMIT_BYTES,
400
+ signal
401
+ });
402
+ },
403
+ async authenticateApiKey(modelProviderId = "", apiKey = "", { signal } = {}) {
404
+ const providerId = encodeURIComponent(text(modelProviderId));
405
+ if (!providerId || !String(apiKey)) {
406
+ throw new TypeError("OpenCode API-key authentication requires a provider id and key.");
407
+ }
408
+ return request("PUT", `/auth/${providerId}`, {
409
+ body: { key: String(apiKey), type: "api" },
410
+ signal
411
+ });
412
+ },
413
+ async createSession(input = {}, { signal } = {}) {
414
+ return (await request("POST", "/api/session", { body: input, signal }))?.data || null;
415
+ },
416
+ async deleteSession(sessionId = "", { signal } = {}) {
417
+ return request("DELETE", `/session/${encodeURIComponent(text(sessionId))}`, { signal });
418
+ },
419
+ events,
420
+ forDirectory(nextDirectory = "") {
421
+ const normalizedDirectory = text(nextDirectory);
422
+ if (!normalizedDirectory) {
423
+ throw new TypeError("OpenCode project clients require a working directory.");
424
+ }
425
+ return createOpenCodeServerClient({
426
+ allowAttachmentDirectories,
427
+ baseUrl: origin.toString(),
428
+ directory: normalizedDirectory,
429
+ fetchImpl,
430
+ password,
431
+ username
432
+ });
433
+ },
434
+ async health({ signal } = {}) {
435
+ return request("GET", "/global/health", { signal });
436
+ },
437
+ async interrupt(sessionId = "", { signal } = {}) {
438
+ return request("POST", stableSessionPath(sessionId, "/abort"), { signal });
439
+ },
440
+ async sessionStatus(sessionId = "", { signal } = {}) {
441
+ const statuses = await request("GET", "/session/status", { signal });
442
+ return statuses?.[text(sessionId)] || { type: "idle" };
443
+ },
444
+ async messages(sessionId = "", input = {}, { signal } = {}) {
445
+ const result = await request(
446
+ "GET",
447
+ `${stableSessionPath(sessionId, "/message")}${queryString({ limit: input.limit })}`,
448
+ { signal }
449
+ );
450
+ return { data: normalizedMessageRows(result) };
451
+ },
452
+ async prompt(sessionId = "", input = {}, { signal } = {}) {
453
+ const body = stablePromptBody(input);
454
+ if (allowAttachmentDirectories && input.attachments?.length) {
455
+ // Native tools check the parent directory when reopening a supplied file.
456
+ // Keep these exceptions on this conversation, alongside its existing rules.
457
+ const session = await request("GET", stableSessionPath(sessionId), { signal });
458
+ const permission = [...(session.permission || [])];
459
+ const previousLength = permission.length;
460
+ for (const attachment of input.attachments) {
461
+ const pattern = path.join(path.dirname(attachment.path), "*").replaceAll("\\", "/");
462
+ const rule = permission.findLast((item) => item.permission === "external_directory" && item.pattern === pattern);
463
+ if (rule?.action !== "allow") {
464
+ permission.push({ permission: "external_directory", pattern, action: "allow" });
465
+ }
466
+ }
467
+ if (permission.length !== previousLength) {
468
+ await request("PATCH", stableSessionPath(sessionId), { body: { permission }, signal });
469
+ }
470
+ }
471
+ await request("POST", stableSessionPath(sessionId, "/prompt_async"), {
472
+ body,
473
+ signal
474
+ });
475
+ return {
476
+ delivery: text(input.delivery),
477
+ id: text(input.id),
478
+ sessionID: text(sessionId)
479
+ };
480
+ },
481
+ async providers({ directory = "", signal } = {}) {
482
+ return sanitizedOpenCodeProviderCatalog(await request("GET", `/provider${queryString({ directory })}`, {
483
+ limitBytes: OPENCODE_CATALOG_LIMIT_BYTES,
484
+ signal
485
+ }));
486
+ },
487
+ async readSession(sessionId = "", { signal } = {}) {
488
+ return (await request("GET", sessionPath(sessionId), { signal }))?.data || null;
489
+ },
490
+ async removeAuthentication(modelProviderId = "", { signal } = {}) {
491
+ return request("DELETE", `/auth/${encodeURIComponent(text(modelProviderId))}`, { signal });
492
+ },
493
+ async switchAgent(sessionId = "", agent = "", { signal } = {}) {
494
+ return request("POST", sessionPath(sessionId, "/agent"), {
495
+ body: { agent: text(agent) },
496
+ signal
497
+ });
498
+ },
499
+ async switchModel(sessionId = "", model = {}, { signal } = {}) {
500
+ return request("POST", sessionPath(sessionId, "/model"), {
501
+ body: { model },
502
+ signal
503
+ });
504
+ }
505
+ });
506
+ }
507
+
508
+ export {
509
+ OPENCODE_RESPONSE_LIMIT_BYTES,
510
+ createOpenCodeServerClient,
511
+ readBoundedResponse
512
+ };
@@ -0,0 +1,92 @@
1
+ import { normalizeText } from "./normalize.js";
2
+
3
+ /** Conversation policy, independent of database, filesystem, identity and HTTP. */
4
+ export function createConversationTranscript({ storage, clock = () => new Date() } = {}) {
5
+ if (typeof storage?.read !== "function" || typeof storage?.write !== "function") {
6
+ throw new TypeError("Conversation storage requires read(scope, callback) and write(scope, callback).");
7
+ }
8
+
9
+ async function readConversationLog(scope) {
10
+ return storage.read(scope, async (transaction) => {
11
+ const ids = await transaction.listTurnIds();
12
+ return (await Promise.all(ids.map((id) => transaction.readTurn(id)))).filter(hasMessages);
13
+ });
14
+ }
15
+
16
+ async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0 } = {}) {
17
+ return storage.read(scope, async (transaction) => {
18
+ const ids = await transaction.listTurnIds();
19
+ const before = normalizeText(beforeTurnId);
20
+ const requestedLimit = Number.parseInt(String(limit || ""), 10);
21
+ const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 0;
22
+ const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
23
+ const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
24
+ const selected = ids.slice(start, end);
25
+ const turns = (await Promise.all(selected.map((id) => transaction.readTurn(id)))).filter(hasMessages);
26
+ return {
27
+ conversationLog: turns,
28
+ pagination: {
29
+ beforeTurnId: before,
30
+ count: turns.length,
31
+ hasMoreBefore: start > 0,
32
+ limit: pageLimit,
33
+ newestTurnId: turns.at(-1)?.turnId || "",
34
+ nextBeforeTurnId: start > 0 ? turns[0]?.turnId || selected[0] || "" : "",
35
+ oldestTurnId: turns[0]?.turnId || "",
36
+ totalTurnCount: ids.length
37
+ }
38
+ };
39
+ });
40
+ }
41
+
42
+ async function tailTurn(transaction) {
43
+ const ids = await transaction.listTurnIds();
44
+ return ids.length ? transaction.readTurn(ids.at(-1)) : null;
45
+ }
46
+
47
+ async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], turnMetadata = null, requireOpenTurn = false } = {}) {
48
+ const messageText = normalizeText(text);
49
+ const id = normalizeText(messageId);
50
+ if (!messageText) return null;
51
+ return storage.write(scope, async (transaction) => {
52
+ if (id && await transaction.hasMessage(id)) return null;
53
+ const createdAt = new Date(at || clock());
54
+ const tail = ["assistant", "commentary", "thinking"].includes(role) ? await tailTurn(transaction) : null;
55
+ const open = tail?.user && !tail.assistant ? tail : null;
56
+ if (requireOpenTurn && !open) return null;
57
+ const thinkingOnly = role === "thinking" && !open && at && tail &&
58
+ !tail.system && !tail.user && !tail.assistant && !tail.commentary?.length &&
59
+ tail.thinking?.some((message) => message.at === createdAt.toISOString());
60
+ const turnId = open?.turnId || (thinkingOnly ? tail.turnId : await transaction.nextTurnId());
61
+ await transaction.appendMessage(turnId, {
62
+ role, text: messageText, messageId: id, at: createdAt.toISOString(),
63
+ ...(role === "user" ? { attachments, turnMetadata } : {})
64
+ });
65
+ return transaction.readTurn(turnId);
66
+ });
67
+ }
68
+
69
+ return Object.freeze({
70
+ readConversationLog,
71
+ readConversationLogPage,
72
+ conversationMessageIdExists: (scope, messageId) => storage.read(scope, (transaction) => transaction.hasMessage(normalizeText(messageId))),
73
+ writeConversationUserMessage: (scope, input) => append(scope, "user", input),
74
+ writeConversationAssistantMessage: (scope, input) => append(scope, "assistant", input),
75
+ writeConversationCommentaryMessage: (scope, input) => append(scope, "commentary", input),
76
+ writeConversationThinkingMessage: (scope, input) => append(scope, "thinking", input),
77
+ writeConversationSystemMessage: (scope, input) => append(scope, "system", input),
78
+ async upsertConversationAssistantMessage(scope, { turnId = "", text = "" } = {}) {
79
+ const messageText = normalizeText(text);
80
+ if (!messageText) return null;
81
+ return storage.write(scope, async (transaction) => {
82
+ const id = normalizeText(turnId);
83
+ await transaction.replaceAssistant(id, { role: "assistant", text: messageText, at: new Date(clock()).toISOString() });
84
+ return transaction.readTurn(id);
85
+ });
86
+ }
87
+ });
88
+ }
89
+
90
+ function hasMessages(turn) {
91
+ return Boolean(turn && (turn.system || turn.user || turn.assistant || turn.commentary?.length || turn.thinking?.length));
92
+ }