@rynx-ai/daemon 0.1.11-beta.25 → 0.1.11-beta.26

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rynx-ai/plugin-channel-lark",
3
- "version": "0.1.11-beta.25",
3
+ "version": "0.1.11-beta.26",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/rynx-ai/rynx.git",
@@ -303,7 +303,9 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
303
303
  "semantic.session.create.v1",
304
304
  "semantic.session.fork.v1",
305
305
  "semantic.session.message.v1",
306
- "semantic.session.resources.v1",
306
+ "semantic.session.resources.v2",
307
+ "semantic.session.files.v1",
308
+ "semantic.session.workspace-files.v1",
307
309
  "semantic.session.message-queue.v1",
308
310
  "semantic.session.delete.v1",
309
311
  "semantic.session.interaction.v1",
@@ -447,6 +449,7 @@ export async function startRynxDaemonServer({ config, warn = defaultWarn, onShut
447
449
  sessionContextProvider: browserRunnerSessionContexts,
448
450
  admissionOpen,
449
451
  admissionReserve: reserveAdmission,
452
+ materializeGeneratedImage: (input) => sessionResources.materializeGeneratedImage(input),
450
453
  onMirrorError: (error, sessionId) => warn(`Session mirror failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`),
451
454
  });
452
455
  const sessionBrowserHost = appBrowserHostSupervisor
package/dist/db.js CHANGED
@@ -109,8 +109,9 @@ function migrate(conn) {
109
109
  session_id TEXT NOT NULL,
110
110
  client_upload_id TEXT NOT NULL,
111
111
  upload_id TEXT NOT NULL UNIQUE,
112
+ kind TEXT NOT NULL DEFAULT 'image' CHECK(kind IN ('image', 'file')),
112
113
  filename TEXT NOT NULL,
113
- media_type TEXT NOT NULL CHECK(media_type IN ('image/png', 'image/jpeg', 'image/webp')),
114
+ media_type TEXT NOT NULL,
114
115
  byte_length INTEGER NOT NULL,
115
116
  uploaded_bytes INTEGER NOT NULL DEFAULT 0,
116
117
  sha256 TEXT,
@@ -141,6 +142,9 @@ function migrate(conn) {
141
142
  'failed_not_started'
142
143
  )),
143
144
  error TEXT,
145
+ response_id TEXT,
146
+ message_item_id TEXT,
147
+ execution_snapshot TEXT,
144
148
  created_at INTEGER NOT NULL,
145
149
  updated_at INTEGER NOT NULL,
146
150
  PRIMARY KEY(session_id, client_message_id)
@@ -341,6 +345,7 @@ function migrate(conn) {
341
345
  CREATE INDEX IF NOT EXISTS idx_runtime_pending_enrollments_expires
342
346
  ON runtime_pending_enrollments(expires_at);
343
347
  `);
348
+ migrateSessionResourceFiles(conn);
344
349
  migratePluginCapabilityDeclarations(conn);
345
350
  migrateSessionSnapshots(conn);
346
351
  // Breaking plugin-runtime cutover: remove obsolete core-owned state.
@@ -351,6 +356,80 @@ function migrate(conn) {
351
356
  addColumnIfMissing(conn, "plugin_marketplaces", "built_in", "INTEGER NOT NULL DEFAULT 0");
352
357
  addColumnIfMissing(conn, "sessions", "forked_from_session_id", "TEXT");
353
358
  addColumnIfMissing(conn, "plugin_session_launch_operations", "raw_request_hash", "TEXT");
359
+ addColumnIfMissing(conn, "session_message_operations", "response_id", "TEXT");
360
+ addColumnIfMissing(conn, "session_message_operations", "message_item_id", "TEXT");
361
+ addColumnIfMissing(conn, "session_message_operations", "execution_snapshot", "TEXT");
362
+ }
363
+ /** Rebuild the original image-only table without losing committed resources. */
364
+ function migrateSessionResourceFiles(conn) {
365
+ const columns = conn
366
+ .prepare("PRAGMA table_info(session_resources)")
367
+ .all();
368
+ if (columns.some((column) => column.name === "kind"))
369
+ return;
370
+ conn.exec("PRAGMA foreign_keys = OFF");
371
+ try {
372
+ transaction(conn, () => {
373
+ conn.exec(`
374
+ ALTER TABLE session_message_operation_resources
375
+ RENAME TO session_message_operation_resources_image_v1;
376
+ ALTER TABLE session_resources RENAME TO session_resources_image_v1;
377
+
378
+ CREATE TABLE session_resources (
379
+ id TEXT PRIMARY KEY,
380
+ session_id TEXT NOT NULL,
381
+ client_upload_id TEXT NOT NULL,
382
+ upload_id TEXT NOT NULL UNIQUE,
383
+ kind TEXT NOT NULL DEFAULT 'image' CHECK(kind IN ('image', 'file')),
384
+ filename TEXT NOT NULL,
385
+ media_type TEXT NOT NULL,
386
+ byte_length INTEGER NOT NULL,
387
+ uploaded_bytes INTEGER NOT NULL DEFAULT 0,
388
+ sha256 TEXT,
389
+ width INTEGER,
390
+ height INTEGER,
391
+ state TEXT NOT NULL DEFAULT 'uploading'
392
+ CHECK(state IN ('uploading', 'staged', 'committed')),
393
+ message_item_id TEXT,
394
+ created_at INTEGER NOT NULL,
395
+ committed_at INTEGER,
396
+ UNIQUE(session_id, client_upload_id)
397
+ );
398
+ INSERT INTO session_resources (
399
+ id, session_id, client_upload_id, upload_id, kind, filename, media_type,
400
+ byte_length, uploaded_bytes, sha256, width, height, state,
401
+ message_item_id, created_at, committed_at
402
+ )
403
+ SELECT
404
+ id, session_id, client_upload_id, upload_id, 'image', filename, media_type,
405
+ byte_length, uploaded_bytes, sha256, width, height, state,
406
+ message_item_id, created_at, committed_at
407
+ FROM session_resources_image_v1;
408
+
409
+ CREATE TABLE session_message_operation_resources (
410
+ session_id TEXT NOT NULL,
411
+ client_message_id TEXT NOT NULL,
412
+ resource_id TEXT NOT NULL UNIQUE REFERENCES session_resources(id) ON DELETE CASCADE,
413
+ PRIMARY KEY(session_id, client_message_id, resource_id),
414
+ FOREIGN KEY(session_id, client_message_id)
415
+ REFERENCES session_message_operations(session_id, client_message_id)
416
+ ON DELETE CASCADE
417
+ );
418
+ INSERT INTO session_message_operation_resources
419
+ SELECT * FROM session_message_operation_resources_image_v1;
420
+
421
+ DROP TABLE session_message_operation_resources_image_v1;
422
+ DROP TABLE session_resources_image_v1;
423
+ CREATE INDEX idx_session_resources_session
424
+ ON session_resources(session_id, created_at);
425
+ CREATE INDEX idx_session_resources_message
426
+ ON session_resources(message_item_id);
427
+ `);
428
+ })();
429
+ }
430
+ finally {
431
+ conn.exec("PRAGMA foreign_keys = ON");
432
+ }
354
433
  }
355
434
  /** Remove the inert permission-declaration columns left by older installations. */
356
435
  function migratePluginCapabilityDeclarations(conn) {
@@ -1,6 +1,7 @@
1
- import { type AdmissionReservation, type AgentSpec, type AgentCapabilities, type AgentRuntimeId, type AgentSessionStore, type ConversationRuntime, type ResolvedExecutionSnapshot, type SessionRegistry, type SessionWorkspaceSnapshot } from "@rynx-ai/core";
1
+ import { type AdmissionReservation, type AgentSpec, type AgentCapabilities, type AgentRuntimeId, type AgentSessionStore, type ResolvedExecutionSnapshot, type RuntimeCollaborationMode, type SessionRegistry, type SessionWorkspaceSnapshot, type SteerSignal } from "@rynx-ai/core";
2
2
  import { type PluginAgentSummary, type PluginHostEvent, type PluginSessionActivitySnapshot, type PluginResolvedSessionExecution, type PluginSessionExecutionSnapshot, type PluginSessionInteractionResolveDisposition } from "@rynx-ai/plugin-sdk";
3
3
  import type { SessionInteractionResolution } from "@rynx-ai/protocol";
4
+ import type { MachineSessionMessageInput, StartedMachineSessionRun } from "@rynx-ai/server";
4
5
  import { PluginRunnerError } from "@rynx-ai/plugin-runner";
5
6
  /** Leaves ample room for the RPC envelope below the runner's 1 MiB line cap. */
6
7
  export declare const MAX_PLUGIN_HOST_EVENT_BYTES: number;
@@ -37,7 +38,16 @@ export interface PluginHostServices {
37
38
  publicKey: string;
38
39
  sign(pluginId: string, challenge: string): string;
39
40
  };
40
- conversationRuntime: ConversationRuntime;
41
+ sessionRuns: {
42
+ startRun: (sessionId: string, input: string | MachineSessionMessageInput, options?: {
43
+ signal?: AbortSignal;
44
+ steer?: SteerSignal;
45
+ collaborationMode?: RuntimeCollaborationMode;
46
+ }) => Promise<StartedMachineSessionRun>;
47
+ };
48
+ sessionFiles: {
49
+ resolveResourcePath(sessionId: string, resourceId: string): Promise<string>;
50
+ };
41
51
  capabilities: AgentCapabilities;
42
52
  sessionStore: AgentSessionStore;
43
53
  sessionRegistry: SessionRegistry;
@@ -124,6 +134,7 @@ export declare class PluginHostRpc {
124
134
  private runSession;
125
135
  private runSessionAdmitted;
126
136
  private pumpRun;
137
+ private projectSessionFileEvent;
127
138
  private releaseRun;
128
139
  private interruptRun;
129
140
  private sessionActivity;
@@ -116,6 +116,10 @@ export class PluginHostRpc {
116
116
  publicKey: this.services.installationProof.publicKey,
117
117
  },
118
118
  protocolVersion: 1,
119
+ capabilities: {
120
+ sessionRunContent: true,
121
+ sessionRunFiles: true,
122
+ },
119
123
  ...(this.services.sessionPortal
120
124
  ? { sessionPortal: { ...this.services.sessionPortal } }
121
125
  : {}),
@@ -352,7 +356,14 @@ export class PluginHostRpc {
352
356
  const input = record(params);
353
357
  const sessionId = requiredSessionId(input);
354
358
  const operationId = optionalString(input.operationId, "operationId", 512);
355
- const message = requiredText(input.message, "message", MAX_MESSAGE_LENGTH);
359
+ const hasMessage = input.message !== undefined;
360
+ const hasContent = input.content !== undefined;
361
+ if (hasMessage === hasContent) {
362
+ throw new PluginHostCallError("INVALID_PARAMS", "provide exactly one of message or content");
363
+ }
364
+ const message = hasMessage
365
+ ? requiredText(input.message, "message", MAX_MESSAGE_LENGTH)
366
+ : undefined;
356
367
  const collaborationMode = optionalCollaborationMode(input.collaborationMode);
357
368
  let session = this.ownedSession(sessionId);
358
369
  if (operationId) {
@@ -366,6 +377,13 @@ export class PluginHostRpc {
366
377
  }
367
378
  const active = this.activeSessionRuns.get(sessionId);
368
379
  if (active) {
380
+ if (hasContent) {
381
+ // Native steering currently accepts text only. Preserve the attachment
382
+ // as a new Turn after the active response reaches its terminal edge.
383
+ await waitForHostPromise(active.settled, signal);
384
+ throwIfHostCallAborted(signal);
385
+ return await this.runSessionAdmitted(params, signal);
386
+ }
369
387
  const queued = active.input.enqueue(message);
370
388
  if (!queued.accepted) {
371
389
  // The response reached its terminal boundary before Host cleanup won
@@ -417,6 +435,7 @@ export class PluginHostRpc {
417
435
  });
418
436
  session = this.ownedSession(sessionId);
419
437
  }
438
+ const content = hasContent ? await pluginRunContent(input.content) : undefined;
420
439
  const abort = new AbortController();
421
440
  const cancel = () => abort.abort(signal?.reason);
422
441
  if (signal?.aborted) {
@@ -458,16 +477,20 @@ export class PluginHostRpc {
458
477
  this.activeSessionRuns.set(sessionId, run);
459
478
  if (operationId)
460
479
  this.activeRunsByOperation.set(operationId, run);
461
- let startedExecution;
480
+ let started;
462
481
  try {
463
- startedExecution = await this.services.conversationRuntime.runLive({
464
- message,
465
- threadId: sessionId,
466
- workspace: session.workspace,
467
- execution: session.execution,
482
+ started = await this.services.sessionRuns.startRun(sessionId, message !== undefined
483
+ ? {
484
+ message,
485
+ clientMessageId: operationId ?? `plugin_run_${runId}`,
486
+ }
487
+ : {
488
+ content: content,
489
+ clientMessageId: operationId ?? `plugin_run_${runId}`,
490
+ }, {
468
491
  signal: abort.signal,
469
- ...(collaborationMode ? { collaborationMode } : {}),
470
492
  steer: runInput,
493
+ ...(collaborationMode ? { collaborationMode } : {}),
471
494
  });
472
495
  }
473
496
  catch (error) {
@@ -490,18 +513,20 @@ export class PluginHostRpc {
490
513
  throwIfHostCallAborted(signal);
491
514
  throw error;
492
515
  }
516
+ run.responseId = started.responseId;
493
517
  const result = {
494
518
  runId,
519
+ responseId: started.responseId,
495
520
  sessionId,
496
- provider: startedExecution.provider,
497
- model: startedExecution.model,
498
- authSource: startedExecution.authSource,
521
+ provider: started.provider,
522
+ model: started.model,
523
+ authSource: started.authSource,
499
524
  };
500
525
  run.startupSettled = true;
501
526
  run.resolveStarted(result);
502
527
  // Let the RPC transport write the request response before stream events are
503
528
  // emitted. `runLive` has already subscribed, so no agent event is lost.
504
- setImmediate(() => void this.pumpRun(run, startedExecution.generator));
529
+ setImmediate(() => void this.pumpRun(run, started.generator));
505
530
  return result;
506
531
  }
507
532
  async pumpRun(run, generator) {
@@ -521,6 +546,16 @@ export class PluginHostRpc {
521
546
  for (const projected of projectSessionEvent(run, event)) {
522
547
  await this.emit(projected);
523
548
  }
549
+ try {
550
+ const fileEvent = await this.projectSessionFileEvent(run, event);
551
+ if (fileEvent)
552
+ await this.emit(fileEvent);
553
+ }
554
+ catch {
555
+ // File delivery is an optional projection of a canonical event. A
556
+ // missing borrowed path must not turn an otherwise successful native
557
+ // Turn into session.run.failed or stop consuming its event stream.
558
+ }
524
559
  terminalFailure = sessionRunFailure(event);
525
560
  if (terminalFailure)
526
561
  break;
@@ -583,6 +618,44 @@ export class PluginHostRpc {
583
618
  this.releaseRun(run);
584
619
  }
585
620
  }
621
+ async projectSessionFileEvent(run, event) {
622
+ const input = jsonRecord(event);
623
+ if (input?.type !== "response.output_item.done")
624
+ return undefined;
625
+ const item = jsonRecord(input.item);
626
+ if (item?.type !== "function_call_output")
627
+ return undefined;
628
+ const data = jsonRecord(item.data);
629
+ const image = jsonRecord(data?.generatedImage);
630
+ if (!image || typeof image.mediaType !== "string")
631
+ return undefined;
632
+ let filePath;
633
+ if (typeof image.path === "string" && path.isAbsolute(image.path)) {
634
+ filePath = image.path;
635
+ }
636
+ else if (typeof image.resourceId === "string") {
637
+ filePath = await this.services.sessionFiles.resolveResourcePath(run.sessionId, image.resourceId);
638
+ }
639
+ else {
640
+ return undefined;
641
+ }
642
+ return {
643
+ event: "session.run.file",
644
+ payload: {
645
+ runId: run.runId,
646
+ responseId: typeof item.responseId === "string"
647
+ ? item.responseId
648
+ : run.responseId,
649
+ sessionId: run.sessionId,
650
+ itemId: typeof item.id === "string" ? item.id : "unknown",
651
+ filename: typeof image.filename === "string" && image.filename
652
+ ? image.filename
653
+ : path.basename(filePath),
654
+ mediaType: image.mediaType,
655
+ path: filePath,
656
+ },
657
+ };
658
+ }
586
659
  releaseRun(run) {
587
660
  run.input.close();
588
661
  if (!run.startupSettled) {
@@ -943,6 +1016,93 @@ function sessionExecutionSnapshot(value) {
943
1016
  function requiredSessionId(params) {
944
1017
  return requiredString(record(params).sessionId, "sessionId", 512);
945
1018
  }
1019
+ async function pluginRunContent(value) {
1020
+ if (!Array.isArray(value) || value.length === 0 || value.length > 256) {
1021
+ throw new PluginHostCallError("INVALID_PARAMS", "content must be a non-empty array with at most 256 parts");
1022
+ }
1023
+ return Promise.all(value.map(async (entry, index) => {
1024
+ const input = record(entry);
1025
+ if (input.type === "input_text") {
1026
+ return {
1027
+ type: "input_text",
1028
+ text: requiredText(input.text, `content[${index}].text`, MAX_MESSAGE_LENGTH),
1029
+ };
1030
+ }
1031
+ if (input.type !== "input_image" && input.type !== "input_file") {
1032
+ throw new PluginHostCallError("INVALID_PARAMS", `content[${index}].type is invalid`);
1033
+ }
1034
+ const suppliedPath = requiredPathString(input.path, `content[${index}].path`, 16_384);
1035
+ if (!path.isAbsolute(suppliedPath)) {
1036
+ throw new PluginHostCallError("INVALID_PARAMS", `content[${index}].path must be absolute`);
1037
+ }
1038
+ let resolvedPath;
1039
+ let fileSize;
1040
+ try {
1041
+ resolvedPath = await realpath(suppliedPath);
1042
+ const metadata = await stat(resolvedPath);
1043
+ if (!metadata.isFile())
1044
+ throw new Error("not a regular file");
1045
+ fileSize = metadata.size;
1046
+ }
1047
+ catch (error) {
1048
+ throw new PluginHostCallError("INVALID_PARAMS", `content[${index}].path is not a readable regular file: ${error instanceof Error ? error.message : String(error)}`);
1049
+ }
1050
+ const filename = input.type === "input_file"
1051
+ ? requiredString(input.filename, `content[${index}].filename`, 255)
1052
+ : optionalString(input.filename, `content[${index}].filename`, 255) ??
1053
+ path.basename(resolvedPath);
1054
+ if (input.type === "input_image") {
1055
+ return {
1056
+ type: "input_image",
1057
+ path: resolvedPath,
1058
+ filename,
1059
+ mediaType: pluginImageMediaType(input.mediaType, resolvedPath, `content[${index}].mediaType`),
1060
+ byteLength: fileSize,
1061
+ };
1062
+ }
1063
+ return {
1064
+ type: "input_file",
1065
+ path: resolvedPath,
1066
+ filename,
1067
+ mediaType: pluginMediaType(input.mediaType ?? "application/octet-stream", `content[${index}].mediaType`),
1068
+ byteLength: fileSize,
1069
+ };
1070
+ }));
1071
+ }
1072
+ function pluginImageMediaType(value, filePath, label) {
1073
+ const inferred = (() => {
1074
+ switch (path.extname(filePath).toLowerCase()) {
1075
+ case ".png": return "image/png";
1076
+ case ".jpg":
1077
+ case ".jpeg": return "image/jpeg";
1078
+ case ".webp": return "image/webp";
1079
+ case ".gif": return "image/gif";
1080
+ case ".svg": return "image/svg+xml";
1081
+ default: return undefined;
1082
+ }
1083
+ })();
1084
+ const mediaType = value === undefined ? inferred : pluginMediaType(value, label);
1085
+ if (mediaType !== "image/png" &&
1086
+ mediaType !== "image/jpeg" &&
1087
+ mediaType !== "image/webp" &&
1088
+ mediaType !== "image/gif" &&
1089
+ mediaType !== "image/svg+xml") {
1090
+ throw new PluginHostCallError("INVALID_PARAMS", `${label} is not a supported image type`);
1091
+ }
1092
+ return mediaType;
1093
+ }
1094
+ function pluginMediaType(value, label) {
1095
+ if (typeof value !== "string") {
1096
+ throw new PluginHostCallError("INVALID_PARAMS", `${label} must be a media type`);
1097
+ }
1098
+ const mediaType = value.trim().toLowerCase();
1099
+ if (mediaType.length === 0 ||
1100
+ mediaType.length > 255 ||
1101
+ !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mediaType)) {
1102
+ throw new PluginHostCallError("INVALID_PARAMS", `${label} is invalid`);
1103
+ }
1104
+ return mediaType;
1105
+ }
946
1106
  function requiredSessionPortalAccess(params) {
947
1107
  const access = record(params).access;
948
1108
  if (access === "read_only" || access === "read_write")
@@ -30,6 +30,7 @@ export declare class SqliteSessionForkStore implements MachineSessionForkStorePo
30
30
  items: SessionItem[];
31
31
  }): void;
32
32
  private publishTarget;
33
+ private publishTargetUnchecked;
33
34
  markError(operationId: string, error: string): void;
34
35
  listReserved(): MachineSessionForkOperationRecord[];
35
36
  hasReservedSource(sessionId: string): boolean;
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { chmodSync, copyFileSync, existsSync, mkdirSync, } from "node:fs";
3
- import { join } from "node:path";
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, } from "node:fs";
3
+ import { extname, join } from "node:path";
4
4
  import { newSessionId, newSessionItemId, normalizeSessionTitle, resolvedExecutionSnapshotSchema, rynxHome, sessionWorkspaceSnapshotSchema, } from "@rynx-ai/core";
5
5
  import { MachineSessionServiceFailure, } from "@rynx-ai/server";
6
6
  import { db } from "./db.js";
@@ -94,6 +94,19 @@ export class SqliteSessionForkStore {
94
94
  this.publishTarget(input);
95
95
  }
96
96
  publishTarget(input) {
97
+ try {
98
+ this.publishTargetUnchecked(input);
99
+ }
100
+ catch (error) {
101
+ const published = scalar(db().prepare("SELECT 1 FROM sessions WHERE id = ?"))
102
+ .get(input.target.id);
103
+ if (!published) {
104
+ rmSync(this.sessionDirectory(input.target.id), { recursive: true, force: true });
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+ publishTargetUnchecked(input) {
97
110
  const itemIds = new Map();
98
111
  for (const item of input.items)
99
112
  itemIds.set(item.id, newSessionItemId(item.type));
@@ -152,11 +165,11 @@ export class SqliteSessionForkStore {
152
165
  source, created_at, updated_at
153
166
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(input.target.id, JSON.stringify(sessionWorkspaceSnapshotSchema.parse(input.target.workspace)), JSON.stringify(resolvedExecutionSnapshotSchema.parse(input.target.execution)), normalizeSessionTitle(input.target.title) ?? null, input.target.forkedFromSessionId ?? null, input.target.source, input.target.createdAt, input.target.updatedAt ?? input.target.createdAt);
154
167
  const insertResource = conn.prepare(`INSERT INTO session_resources (
155
- id, session_id, client_upload_id, upload_id, filename, media_type,
168
+ id, session_id, client_upload_id, upload_id, kind, filename, media_type,
156
169
  byte_length, uploaded_bytes, sha256, width, height, state,
157
170
  message_item_id, created_at, committed_at
158
171
  ) VALUES (
159
- @id, @session_id, @client_upload_id, @upload_id, @filename, @media_type,
172
+ @id, @session_id, @client_upload_id, @upload_id, @kind, @filename, @media_type,
160
173
  @byte_length, @uploaded_bytes, @sha256, @width, @height, 'committed',
161
174
  @message_item_id, @created_at, @committed_at
162
175
  )`);
@@ -166,6 +179,7 @@ export class SqliteSessionForkStore {
166
179
  session_id: resource.session_id,
167
180
  client_upload_id: resource.client_upload_id,
168
181
  upload_id: resource.upload_id,
182
+ kind: resource.kind,
169
183
  filename: resource.filename,
170
184
  media_type: resource.media_type,
171
185
  byte_length: resource.byte_length,
@@ -250,7 +264,7 @@ export class SqliteSessionForkStore {
250
264
  return join(this.resourceRoot, key);
251
265
  }
252
266
  resourcePath(row) {
253
- return join(this.sessionDirectory(row.session_id), `${row.id}.${extension(row.media_type)}`);
267
+ return join(this.sessionDirectory(row.session_id), `${row.id}.${extension(row)}`);
254
268
  }
255
269
  }
256
270
  function toOperation(row) {
@@ -268,22 +282,40 @@ function toOperation(row) {
268
282
  function referencedResourceIds(items) {
269
283
  const ids = new Set();
270
284
  for (const item of items) {
271
- if (item.type !== "message" || item.data.role !== "user")
272
- continue;
273
- for (const part of item.data.content) {
274
- if (part.type === "input_image")
275
- ids.add(part.resourceId);
285
+ if (item.type === "message" && item.data.role === "user") {
286
+ for (const part of item.data.content) {
287
+ if ((part.type === "input_image" || part.type === "input_file") &&
288
+ typeof part.resourceId === "string")
289
+ ids.add(part.resourceId);
290
+ }
276
291
  }
292
+ if (item.type === "function_call_output" &&
293
+ item.data.generatedImage &&
294
+ typeof item.data.generatedImage.resourceId === "string")
295
+ ids.add(item.data.generatedImage.resourceId);
277
296
  }
278
297
  return [...ids];
279
298
  }
280
299
  function remapItemResources(item, resources) {
281
300
  const cloned = structuredClone(item);
282
- if (cloned.type !== "message" || cloned.data.role !== "user")
283
- return cloned;
284
- cloned.data.content = cloned.data.content.map((part) => part.type === "input_image"
285
- ? { ...part, resourceId: resources.get(part.resourceId) ?? part.resourceId }
286
- : part);
301
+ if (cloned.type === "message" && cloned.data.role === "user") {
302
+ cloned.data.content = cloned.data.content.map((part) => {
303
+ if ((part.type === "input_image" || part.type === "input_file") &&
304
+ typeof part.resourceId === "string") {
305
+ return { ...part, resourceId: resources.get(part.resourceId) ?? part.resourceId };
306
+ }
307
+ return part;
308
+ });
309
+ }
310
+ if (cloned.type === "function_call_output" &&
311
+ cloned.data.generatedImage &&
312
+ typeof cloned.data.generatedImage.resourceId === "string") {
313
+ const resourceId = cloned.data.generatedImage.resourceId;
314
+ cloned.data.generatedImage = {
315
+ ...cloned.data.generatedImage,
316
+ resourceId: resources.get(resourceId) ?? resourceId,
317
+ };
318
+ }
287
319
  return cloned;
288
320
  }
289
321
  function deterministicId(prefix, targetSessionId, sourceId) {
@@ -292,13 +324,19 @@ function deterministicId(prefix, targetSessionId, sourceId) {
292
324
  .digest("hex");
293
325
  return `${prefix}_${digest.slice(0, 40)}`;
294
326
  }
295
- function extension(mediaType) {
296
- switch (mediaType) {
297
- case "image/png":
327
+ function extension(row) {
328
+ if (row.kind === "image") {
329
+ if (row.media_type === "image/png")
298
330
  return "png";
299
- case "image/jpeg":
331
+ if (row.media_type === "image/jpeg")
300
332
  return "jpg";
301
- case "image/webp":
333
+ if (row.media_type === "image/webp")
302
334
  return "webp";
335
+ if (row.media_type === "image/gif")
336
+ return "gif";
337
+ if (row.media_type === "image/svg+xml")
338
+ return "svg";
303
339
  }
340
+ const value = extname(row.filename).slice(1).toLowerCase();
341
+ return /^[a-z0-9][a-z0-9._-]{0,15}$/.test(value) ? value : "bin";
304
342
  }
@@ -14,6 +14,7 @@ export declare class SqliteSessionLogStore implements SessionLogStore {
14
14
  afterId?: string;
15
15
  limit?: number;
16
16
  }): Promise<SessionItem[]>;
17
+ get(sessionId: string, itemId: string): Promise<SessionItem | undefined>;
17
18
  snapshot(sessionId: string): Promise<SessionItem[]>;
18
19
  listSessions(opts?: {
19
20
  limit?: number;
@@ -38,9 +38,18 @@ export class SqliteSessionLogStore {
38
38
  created_by: stored.createdBy ?? null,
39
39
  created_at: stored.createdAt,
40
40
  });
41
- if (stored.type === "message" && stored.data.role === "user") {
42
- const resourceIds = stored.data.content.flatMap((part) => part.type === "input_image" ? [part.resourceId] : []);
43
- const operationIds = new Set();
41
+ const resourceIds = stored.type === "message" && stored.data.role === "user"
42
+ ? stored.data.content.flatMap((part) => (part.type === "input_image" || part.type === "input_file") &&
43
+ "resourceId" in part && part.resourceId
44
+ ? [part.resourceId]
45
+ : [])
46
+ : stored.type === "function_call_output" &&
47
+ stored.data.generatedImage &&
48
+ "resourceId" in stored.data.generatedImage &&
49
+ stored.data.generatedImage.resourceId
50
+ ? [stored.data.generatedImage.resourceId]
51
+ : [];
52
+ if (resourceIds.length > 0) {
44
53
  for (const resourceId of resourceIds) {
45
54
  const resource = conn.prepare(`SELECT state, message_item_id
46
55
  FROM session_resources
@@ -48,23 +57,12 @@ export class SqliteSessionLogStore {
48
57
  if (!resource ||
49
58
  resource.state === "uploading" ||
50
59
  (resource.message_item_id && resource.message_item_id !== stored.id)) {
51
- throw new Error(`Session image resource ${resourceId} is not committable`);
60
+ throw new Error(`Session resource ${resourceId} is not committable`);
52
61
  }
53
62
  conn.prepare(`UPDATE session_resources
54
63
  SET state = 'committed', message_item_id = ?,
55
64
  committed_at = COALESCE(committed_at, ?)
56
65
  WHERE id = ? AND session_id = ?`).run(stored.id, stored.createdAt, resourceId, sessionId);
57
- const operation = conn.prepare(`SELECT client_message_id
58
- FROM session_message_operation_resources
59
- WHERE session_id = ? AND resource_id = ?`).get(sessionId, resourceId);
60
- if (operation)
61
- operationIds.add(operation.client_message_id);
62
- }
63
- for (const clientMessageId of operationIds) {
64
- conn.prepare(`UPDATE session_message_operations
65
- SET state = 'mirrored', error = NULL, updated_at = ?
66
- WHERE session_id = ? AND client_message_id = ?
67
- AND state IN ('injecting', 'injected', 'outcome_unknown')`).run(stored.createdAt, sessionId, clientMessageId);
68
66
  }
69
67
  }
70
68
  return stored;
@@ -93,6 +91,10 @@ export class SqliteSessionLogStore {
93
91
  .all(sessionId, afterPosition, limit);
94
92
  return rows.map(toItem);
95
93
  }
94
+ async get(sessionId, itemId) {
95
+ const row = db().prepare("SELECT * FROM session_items WHERE session_id = ? AND id = ?").get(sessionId, itemId);
96
+ return row ? toItem(row) : undefined;
97
+ }
96
98
  async snapshot(sessionId) {
97
99
  const rows = db()
98
100
  .prepare("SELECT * FROM session_items WHERE session_id = ? ORDER BY position ASC")