@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
- import { type UserContentPart } from "@rynx-ai/core";
1
+ import { type SessionImageFileReference, type UserContentPart } from "@rynx-ai/core";
2
2
  import { type RemoteRuntimeSessionResourceDeleteParams, type RemoteRuntimeSessionResourceDeleteResult, type RemoteRuntimeSessionResourceGetParams, type RemoteRuntimeSessionResourceGetResult, type RemoteRuntimeSessionResourcePolicyResult, type RemoteRuntimeSessionResourceReadParams, type RemoteRuntimeSessionResourceReadResult, type RemoteRuntimeSessionResourceUploadBeginParams, type RemoteRuntimeSessionResourceUploadBeginResult, type RemoteRuntimeSessionResourceUploadChunkParams, type RemoteRuntimeSessionResourceUploadChunkResult, type RemoteRuntimeSessionResourceUploadCommitParams, type RemoteRuntimeSessionResourceUploadCommitResult } from "@rynx-ai/protocol/remote-runtime-rpc";
3
- import { type MachineSessionPreparedInput, type MachineSessionResourcePort } from "@rynx-ai/server";
3
+ import { type MachineSessionPreparedInput, type MachineSessionResourcePort, type MachineSessionRunExecutionSelection, type MachineSessionRunExecutionSnapshot } from "@rynx-ai/server";
4
4
  export declare class SqliteSessionResourceStore implements MachineSessionResourcePort {
5
5
  private readonly root;
6
6
  constructor(root?: string);
@@ -9,18 +9,32 @@ export declare class SqliteSessionResourceStore implements MachineSessionResourc
9
9
  writeUploadChunk(input: RemoteRuntimeSessionResourceUploadChunkParams): Promise<RemoteRuntimeSessionResourceUploadChunkResult>;
10
10
  commitUpload(input: RemoteRuntimeSessionResourceUploadCommitParams): Promise<RemoteRuntimeSessionResourceUploadCommitResult>;
11
11
  getResource(input: RemoteRuntimeSessionResourceGetParams): Promise<RemoteRuntimeSessionResourceGetResult>;
12
+ resolveCommittedLocalPath(sessionId: string, resourceId: string): string;
12
13
  readResource(input: RemoteRuntimeSessionResourceReadParams): Promise<RemoteRuntimeSessionResourceReadResult>;
13
14
  deleteResource(input: RemoteRuntimeSessionResourceDeleteParams): Promise<RemoteRuntimeSessionResourceDeleteResult>;
14
- prepareInput(sessionId: string, content: UserContentPart[], clientMessageId?: string): Promise<MachineSessionPreparedInput>;
15
- markMessageInjecting(sessionId: string, clientMessageId: string): void;
16
- markMessageInjected(sessionId: string, clientMessageId: string): void;
15
+ materializeGeneratedImage(input: {
16
+ sessionId: string;
17
+ sourceId: string;
18
+ result: string;
19
+ }): Promise<SessionImageFileReference>;
20
+ prepareInput(sessionId: string, content: UserContentPart[], clientMessageId?: string, operationExecution?: {
21
+ selection: MachineSessionRunExecutionSelection;
22
+ snapshot: MachineSessionRunExecutionSnapshot;
23
+ }): Promise<MachineSessionPreparedInput>;
24
+ markMessageInjecting(sessionId: string, clientMessageId: string, identity?: {
25
+ responseId: string;
26
+ messageItemId: string;
27
+ }): void;
28
+ markMessageInjected(sessionId: string, clientMessageId: string, identity?: {
29
+ responseId: string;
30
+ }): void;
17
31
  markMessageOutcomeUnknown(sessionId: string, clientMessageId: string, reason: string): void;
18
32
  markMessageFailedNotStarted(sessionId: string, clientMessageId: string, reason: string): void;
19
33
  deleteSession(sessionId: string): void;
20
34
  private prepareMessageOperation;
21
35
  private bindOperationResources;
22
36
  private transitionOperation;
23
- private finalizeUpload;
37
+ private finalizeStoredFile;
24
38
  private reconcileUploadOffset;
25
39
  private rowByUpload;
26
40
  private requireUpload;
@@ -1,8 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
3
- import { basename, join } from "node:path";
2
+ import { chmodSync, closeSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
3
+ import { basename, extname, isAbsolute, join } from "node:path";
4
4
  import { rynxHome, } from "@rynx-ai/core";
5
- import { defaultSessionResourcePolicy, SESSION_RESOURCE_MAX_IMAGE_PIXELS, SESSION_RESOURCE_MAX_IMAGE_SIDE, SESSION_RESOURCE_MAX_MESSAGE_BYTES, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, } from "@rynx-ai/protocol/remote-runtime-rpc";
5
+ import { defaultSessionResourcePolicy, SESSION_RESOURCE_MAX_IMAGE_BYTES, SESSION_RESOURCE_MAX_IMAGE_PIXELS, SESSION_RESOURCE_MAX_IMAGE_SIDE, SESSION_RESOURCE_MAX_MESSAGE_BYTES, SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, } from "@rynx-ai/protocol/remote-runtime-rpc";
6
6
  import { MachineSessionServiceFailure, } from "@rynx-ai/server";
7
7
  import { db } from "./db.js";
8
8
  import { transaction } from "./sqlite.js";
@@ -26,7 +26,8 @@ export class SqliteSessionResourceStore {
26
26
  WHERE session_id = ? AND client_upload_id = ?`)
27
27
  .get(input.sessionId, input.clientUploadId);
28
28
  if (existing) {
29
- if (existing.filename !== filename ||
29
+ if (existing.kind !== resourceKind(input.type) ||
30
+ existing.filename !== filename ||
30
31
  existing.media_type !== input.mediaType ||
31
32
  existing.byte_length !== input.byteLength) {
32
33
  throw conflict("clientUploadId was already used for different content");
@@ -44,23 +45,27 @@ export class SqliteSessionResourceStore {
44
45
  : { resource: toMetadata(refreshed) }),
45
46
  };
46
47
  }
48
+ if (input.type === "input_image" && input.byteLength > SESSION_RESOURCE_MAX_IMAGE_BYTES) {
49
+ throw invalid("Image exceeds the Session resource policy");
50
+ }
47
51
  const staged = db()
48
- .prepare(`SELECT COUNT(*) AS count, COALESCE(SUM(byte_length), 0) AS bytes
52
+ .prepare(`SELECT COUNT(*) AS count,
53
+ COALESCE(SUM(CASE WHEN kind = 'image' THEN byte_length ELSE 0 END), 0) AS bytes
49
54
  FROM session_resources
50
55
  WHERE session_id = ? AND state IN ('uploading', 'staged')`)
51
56
  .get(input.sessionId);
52
57
  if (staged.count >= MAX_STAGED_RESOURCES_PER_SESSION ||
53
- staged.bytes + input.byteLength > SESSION_RESOURCE_MAX_MESSAGE_BYTES) {
54
- throw unavailable("Session staged image quota exceeded");
58
+ (input.type === "input_image" && staged.bytes + input.byteLength > SESSION_RESOURCE_MAX_MESSAGE_BYTES)) {
59
+ throw unavailable("Session staged resource quota exceeded");
55
60
  }
56
61
  const resourceId = `res_${randomUUID()}`;
57
62
  const uploadId = `upl_${randomUUID()}`;
58
63
  const createdAt = Date.now();
59
64
  ensurePrivateDirectory(this.sessionDirectory(input.sessionId));
60
65
  db().prepare(`INSERT INTO session_resources
61
- (id, session_id, client_upload_id, upload_id, filename, media_type,
66
+ (id, session_id, client_upload_id, upload_id, kind, filename, media_type,
62
67
  byte_length, uploaded_bytes, state, created_at)
63
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, 'uploading', ?)`).run(resourceId, input.sessionId, input.clientUploadId, uploadId, filename, input.mediaType, input.byteLength, createdAt);
68
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 'uploading', ?)`).run(resourceId, input.sessionId, input.clientUploadId, uploadId, resourceKind(input.type), filename, input.mediaType, input.byteLength, createdAt);
64
69
  return { uploadId, resourceId, offset: 0, complete: false };
65
70
  }
66
71
  async writeUploadChunk(input) {
@@ -123,23 +128,25 @@ export class SqliteSessionResourceStore {
123
128
  const partial = this.partialPath(row);
124
129
  const final = this.finalPath(row);
125
130
  if (!existsSync(partial) && existsSync(final)) {
126
- const bytes = readFileSync(final);
127
- return { resource: this.finalizeUpload(row, bytes) };
128
- }
129
- const bytes = readFileSync(partial);
130
- const image = inspectImage(bytes, row.media_type);
131
- if (image.width > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
132
- image.height > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
133
- image.width * image.height > SESSION_RESOURCE_MAX_IMAGE_PIXELS) {
134
- throw invalid("Image dimensions exceed the Session resource policy");
131
+ return { resource: await this.finalizeStoredFile(row, final) };
135
132
  }
133
+ const inspected = row.kind === "image"
134
+ ? inspectImage(readFileSync(partial), imageMediaType(row.media_type))
135
+ : undefined;
136
+ validateImageDimensions(inspected);
136
137
  renameSync(partial, final);
137
138
  chmodSync(final, 0o600);
138
- return { resource: this.finalizeUpload(row, bytes, image) };
139
+ return { resource: await this.finalizeStoredFile(row, final, inspected) };
139
140
  }
140
141
  async getResource(input) {
141
142
  return { resource: toMetadata(this.requireResource(input.sessionId, input.resourceId)) };
142
143
  }
144
+ resolveCommittedLocalPath(sessionId, resourceId) {
145
+ const row = this.requireResource(sessionId, resourceId);
146
+ if (row.state !== "committed")
147
+ throw unavailable("Resource is not committed");
148
+ return this.finalPath(row);
149
+ }
143
150
  async readResource(input) {
144
151
  const row = this.requireResource(input.sessionId, input.resourceId);
145
152
  if (row.state === "uploading")
@@ -174,7 +181,56 @@ export class SqliteSessionResourceStore {
174
181
  db().prepare("DELETE FROM session_resources WHERE id = ?").run(row.id);
175
182
  return { deleted: true };
176
183
  }
177
- async prepareInput(sessionId, content, clientMessageId) {
184
+ async materializeGeneratedImage(input) {
185
+ const parsed = decodeGeneratedImage(input.result);
186
+ const clientUploadId = `gen_${createHash("sha256")
187
+ .update(`${input.sessionId}\0${input.sourceId}`)
188
+ .digest("hex")}`;
189
+ const begun = await this.beginUpload({
190
+ sessionId: input.sessionId,
191
+ clientUploadId,
192
+ type: "input_image",
193
+ filename: `generated-image.${imageExtension(parsed.mediaType)}`,
194
+ mediaType: parsed.mediaType,
195
+ byteLength: parsed.bytes.byteLength,
196
+ });
197
+ const expectedSha256 = createHash("sha256").update(parsed.bytes).digest("hex");
198
+ if (begun.complete && begun.resource?.sha256 !== expectedSha256) {
199
+ throw conflict("Generated image source changed after it was materialized");
200
+ }
201
+ if (!begun.complete) {
202
+ if (begun.offset > 0) {
203
+ const row = this.requireUpload(input.sessionId, begun.uploadId);
204
+ const existing = readFileSync(this.partialPath(row));
205
+ if (existing.byteLength !== begun.offset ||
206
+ !existing.equals(parsed.bytes.subarray(0, begun.offset))) {
207
+ throw conflict("Generated image source changed while it was being materialized");
208
+ }
209
+ }
210
+ let offset = begun.offset;
211
+ while (offset < parsed.bytes.byteLength) {
212
+ const chunk = parsed.bytes.subarray(offset, Math.min(offset + SESSION_RESOURCE_TRANSFER_CHUNK_BYTES, parsed.bytes.byteLength));
213
+ const written = await this.writeUploadChunk({
214
+ sessionId: input.sessionId,
215
+ uploadId: begun.uploadId,
216
+ offset,
217
+ data: chunk.toString("base64"),
218
+ });
219
+ offset = written.offset;
220
+ }
221
+ }
222
+ const metadata = begun.complete && begun.resource
223
+ ? begun.resource
224
+ : (await this.commitUpload({
225
+ sessionId: input.sessionId,
226
+ uploadId: begun.uploadId,
227
+ })).resource;
228
+ if (metadata.type !== "input_image")
229
+ throw conflict("Generated image resource changed type");
230
+ const { type: _type, state: _state, sha256: _sha256, createdAt: _createdAt, ...reference } = metadata;
231
+ return reference;
232
+ }
233
+ async prepareInput(sessionId, content, clientMessageId, operationExecution) {
178
234
  const runtimeContent = [];
179
235
  const canonicalContent = [];
180
236
  const resourceIds = [];
@@ -187,55 +243,118 @@ export class SqliteSessionResourceStore {
187
243
  title ||= part.text.trim();
188
244
  continue;
189
245
  }
246
+ if ("path" in part && part.path !== undefined) {
247
+ if (!isAbsolute(part.path))
248
+ throw invalid("File path must be absolute");
249
+ const stats = statSync(part.path);
250
+ if (!stats.isFile())
251
+ throw invalid("File path must reference a regular file");
252
+ const canonical = {
253
+ ...part,
254
+ byteLength: part.byteLength ?? stats.size,
255
+ };
256
+ canonicalContent.push(canonical);
257
+ runtimeContent.push({
258
+ type: part.type === "input_image" ? "local_image" : "local_file",
259
+ path: part.path,
260
+ resource: canonical,
261
+ });
262
+ title ||= part.filename ?? basename(part.path);
263
+ continue;
264
+ }
190
265
  const row = this.requireResource(sessionId, part.resourceId);
191
266
  if (row.state === "uploading")
192
- throw unavailable("Image upload is incomplete");
193
- const resource = toImagePart(row);
194
- if (resource.mediaType !== part.mediaType ||
267
+ throw unavailable("Resource upload is incomplete");
268
+ const resource = toResourcePart(row);
269
+ if (resource.type !== part.type ||
270
+ resource.mediaType !== part.mediaType ||
195
271
  (part.byteLength !== undefined && part.byteLength !== resource.byteLength)) {
196
- throw conflict("Image reference metadata does not match the stored resource");
272
+ throw conflict("Resource reference metadata does not match the stored resource");
197
273
  }
198
- totalImageBytes += row.byte_length;
274
+ if (row.kind === "image")
275
+ totalImageBytes += row.byte_length;
199
276
  resourceIds.push(row.id);
200
277
  canonicalContent.push(resource);
201
278
  runtimeContent.push({
202
- type: "local_image",
279
+ type: resource.type === "input_image" ? "local_image" : "local_file",
203
280
  path: this.finalPath(row),
204
- resource,
281
+ resource: resource,
205
282
  });
206
283
  title ||= row.filename;
207
284
  }
208
285
  if (runtimeContent.length === 0 || totalImageBytes > SESSION_RESOURCE_MAX_MESSAGE_BYTES) {
209
286
  throw invalid("Message content is empty or exceeds the image byte limit");
210
287
  }
211
- if (!runtimeContent.some((part) => part.type === "local_image" || part.text.trim())) {
212
- throw invalid("Message content must contain text or an image");
288
+ if (!runtimeContent.some((part) => part.type !== "text" || part.text.trim())) {
289
+ throw invalid("Message content must contain text or a file");
213
290
  }
214
291
  let operationState;
215
292
  if (clientMessageId) {
216
- const requestHash = hashMessageContent(canonicalContent);
217
- operationState = this.prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds);
293
+ const requestHash = hashMessageContent(canonicalContent, operationExecution?.selection);
294
+ const operation = this.prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds, operationExecution?.snapshot);
295
+ operationState = operation.state;
296
+ return {
297
+ input: {
298
+ content: runtimeContent,
299
+ clientMessageId,
300
+ },
301
+ title: title || "File",
302
+ operationState,
303
+ ...(operation.responseId ? { responseId: operation.responseId } : {}),
304
+ ...(operation.messageItemId ? { messageItemId: operation.messageItemId } : {}),
305
+ ...(operation.execution ? { operationExecution: operation.execution } : {}),
306
+ };
218
307
  }
219
308
  return {
220
309
  input: {
221
310
  content: runtimeContent,
222
311
  ...(clientMessageId ? { clientMessageId } : {}),
223
312
  },
224
- title: title || "Image",
313
+ title: title || "File",
225
314
  ...(operationState ? { operationState } : {}),
226
315
  };
227
316
  }
228
- markMessageInjecting(sessionId, clientMessageId) {
229
- this.transitionOperation(sessionId, clientMessageId, ["prepared", "failed_not_started"], "injecting");
317
+ markMessageInjecting(sessionId, clientMessageId, identity) {
318
+ const current = db().prepare(`SELECT state, response_id, message_item_id
319
+ FROM session_message_operations
320
+ WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
321
+ if (!current ||
322
+ (identity && current.response_id && current.response_id !== identity.responseId) ||
323
+ (identity && current.message_item_id && current.message_item_id !== identity.messageItemId)) {
324
+ throw conflict("Message operation identity changed");
325
+ }
326
+ const result = db().prepare(`UPDATE session_message_operations
327
+ SET state = 'injecting', error = NULL,
328
+ response_id = COALESCE(response_id, ?),
329
+ message_item_id = COALESCE(message_item_id, ?),
330
+ updated_at = ?
331
+ WHERE session_id = ? AND client_message_id = ?
332
+ AND state IN ('prepared', 'failed_not_started')`).run(identity?.responseId ?? null, identity?.messageItemId ?? null, Date.now(), sessionId, clientMessageId);
333
+ if (result.changes === 0 && current.state !== "injecting" && current.state !== "mirrored") {
334
+ throw conflict("Message operation state changed");
335
+ }
230
336
  }
231
- markMessageInjected(sessionId, clientMessageId) {
232
- this.transitionOperation(sessionId, clientMessageId, ["injecting"], "injected");
337
+ markMessageInjected(sessionId, clientMessageId, identity) {
338
+ const current = db().prepare(`SELECT state, response_id
339
+ FROM session_message_operations
340
+ WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
341
+ if (!current ||
342
+ (identity && current.response_id && current.response_id !== identity.responseId)) {
343
+ throw conflict("Message operation identity changed");
344
+ }
345
+ const result = db().prepare(`UPDATE session_message_operations
346
+ SET state = 'injected', error = NULL,
347
+ response_id = COALESCE(response_id, ?),
348
+ updated_at = ?
349
+ WHERE session_id = ? AND client_message_id = ? AND state = 'injecting'`).run(identity?.responseId ?? null, Date.now(), sessionId, clientMessageId);
350
+ if (result.changes !== 1)
351
+ throw conflict("Message operation state changed");
233
352
  }
234
353
  markMessageOutcomeUnknown(sessionId, clientMessageId, reason) {
235
354
  this.transitionOperation(sessionId, clientMessageId, ["injecting"], "outcome_unknown", reason);
236
355
  }
237
356
  markMessageFailedNotStarted(sessionId, clientMessageId, reason) {
238
- this.transitionOperation(sessionId, clientMessageId, ["prepared", "injecting"], "failed_not_started", reason);
357
+ this.transitionOperation(sessionId, clientMessageId, ["prepared", "injecting", "mirrored"], "failed_not_started", reason);
239
358
  }
240
359
  deleteSession(sessionId) {
241
360
  const rows = db()
@@ -251,31 +370,45 @@ export class SqliteSessionResourceStore {
251
370
  })();
252
371
  rmSync(this.sessionDirectory(sessionId), { recursive: true, force: true });
253
372
  }
254
- prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds) {
373
+ prepareMessageOperation(sessionId, clientMessageId, requestHash, resourceIds, execution) {
255
374
  const conn = db();
256
375
  return transaction(conn, () => {
257
- const existing = conn.prepare(`SELECT request_hash, state
376
+ const existing = conn.prepare(`SELECT request_hash, state, response_id, message_item_id, execution_snapshot
258
377
  FROM session_message_operations
259
378
  WHERE session_id = ? AND client_message_id = ?`).get(sessionId, clientMessageId);
260
379
  if (existing) {
380
+ const storedExecution = parseOperationExecution(existing.execution_snapshot);
261
381
  if (existing.request_hash !== requestHash) {
262
382
  throw conflict("clientMessageId was already used for different content");
263
383
  }
264
384
  if (existing.state === "failed_not_started") {
385
+ const retryExecution = storedExecution ?? execution;
265
386
  this.bindOperationResources(conn, sessionId, clientMessageId, resourceIds);
266
387
  conn.prepare(`UPDATE session_message_operations
267
- SET state = 'prepared', error = NULL, updated_at = ?
268
- WHERE session_id = ? AND client_message_id = ?`).run(Date.now(), sessionId, clientMessageId);
269
- return "prepared";
388
+ SET state = 'prepared', error = NULL,
389
+ execution_snapshot = COALESCE(execution_snapshot, ?), updated_at = ?
390
+ WHERE session_id = ? AND client_message_id = ?`).run(execution ? JSON.stringify(execution) : null, Date.now(), sessionId, clientMessageId);
391
+ return {
392
+ state: "prepared",
393
+ ...(existing.response_id ? { responseId: existing.response_id } : {}),
394
+ ...(existing.message_item_id ? { messageItemId: existing.message_item_id } : {}),
395
+ ...(retryExecution ? { execution: retryExecution } : {}),
396
+ };
270
397
  }
271
- return existing.state;
398
+ return {
399
+ state: existing.state,
400
+ ...(existing.response_id ? { responseId: existing.response_id } : {}),
401
+ ...(existing.message_item_id ? { messageItemId: existing.message_item_id } : {}),
402
+ ...(storedExecution ? { execution: storedExecution } : {}),
403
+ };
272
404
  }
273
405
  const now = Date.now();
274
406
  conn.prepare(`INSERT INTO session_message_operations
275
- (session_id, client_message_id, request_hash, state, created_at, updated_at)
276
- VALUES (?, ?, ?, 'prepared', ?, ?)`).run(sessionId, clientMessageId, requestHash, now, now);
407
+ (session_id, client_message_id, request_hash, state, execution_snapshot,
408
+ created_at, updated_at)
409
+ VALUES (?, ?, ?, 'prepared', ?, ?, ?)`).run(sessionId, clientMessageId, requestHash, execution ? JSON.stringify(execution) : null, now, now);
277
410
  this.bindOperationResources(conn, sessionId, clientMessageId, resourceIds);
278
- return "prepared";
411
+ return { state: "prepared", ...(execution ? { execution } : {}) };
279
412
  })();
280
413
  }
281
414
  bindOperationResources(conn, sessionId, clientMessageId, resourceIds) {
@@ -318,12 +451,12 @@ export class SqliteSessionResourceStore {
318
451
  throw conflict("Message operation state changed");
319
452
  }
320
453
  }
321
- finalizeUpload(row, bytes, inspected = inspectImage(bytes, row.media_type)) {
322
- const digest = createHash("sha256").update(bytes).digest("hex");
454
+ async finalizeStoredFile(row, file, inspected) {
455
+ const digest = await hashFile(file);
323
456
  db().prepare(`UPDATE session_resources
324
457
  SET state = 'staged', uploaded_bytes = byte_length, sha256 = ?,
325
458
  width = ?, height = ?
326
- WHERE id = ? AND state = 'uploading'`).run(digest, inspected.width, inspected.height, row.id);
459
+ WHERE id = ? AND state = 'uploading'`).run(digest, inspected?.width ?? null, inspected?.height ?? null, row.id);
327
460
  return toMetadata(this.requireResource(row.session_id, row.id));
328
461
  }
329
462
  reconcileUploadOffset(row) {
@@ -369,7 +502,7 @@ export class SqliteSessionResourceStore {
369
502
  return join(this.sessionDirectory(row.session_id), `${row.id}.part`);
370
503
  }
371
504
  finalPath(row) {
372
- return join(this.sessionDirectory(row.session_id), `${row.id}.${extension(row.media_type)}`);
505
+ return join(this.sessionDirectory(row.session_id), `${row.id}.${resourceExtension(row)}`);
373
506
  }
374
507
  sweepExpiredStagedResources(now = Date.now()) {
375
508
  const rows = db()
@@ -383,17 +516,25 @@ export class SqliteSessionResourceStore {
383
516
  }
384
517
  }
385
518
  }
386
- function toImagePart(row) {
387
- if (row.state === "uploading" ||
388
- !row.sha256 ||
389
- !row.width ||
390
- !row.height) {
519
+ function toResourcePart(row) {
520
+ if (row.state === "uploading" || !row.sha256) {
391
521
  throw unavailable("Resource upload is incomplete");
392
522
  }
523
+ if (row.kind === "file") {
524
+ return {
525
+ type: "input_file",
526
+ resourceId: row.id,
527
+ mediaType: row.media_type,
528
+ filename: row.filename,
529
+ byteLength: row.byte_length,
530
+ };
531
+ }
532
+ if (!row.width || !row.height)
533
+ throw unavailable("Image upload is incomplete");
393
534
  return {
394
535
  type: "input_image",
395
536
  resourceId: row.id,
396
- mediaType: row.media_type,
537
+ mediaType: imageMediaType(row.media_type),
397
538
  filename: row.filename,
398
539
  byteLength: row.byte_length,
399
540
  width: row.width,
@@ -401,7 +542,7 @@ function toImagePart(row) {
401
542
  };
402
543
  }
403
544
  function toMetadata(row) {
404
- const part = toImagePart(row);
545
+ const part = toResourcePart(row);
405
546
  if (row.state === "uploading")
406
547
  throw unavailable("Resource upload is incomplete");
407
548
  return {
@@ -411,13 +552,38 @@ function toMetadata(row) {
411
552
  createdAt: row.created_at,
412
553
  };
413
554
  }
414
- function hashMessageContent(content) {
415
- return createHash("sha256")
416
- .update(JSON.stringify(content.map((part) => part.type === "input_text"
555
+ function hashMessageContent(content, execution) {
556
+ const canonicalContent = content.map((part) => part.type === "input_text"
417
557
  ? { type: part.type, text: part.text }
418
- : { type: part.type, resourceId: part.resourceId })))
558
+ : "resourceId" in part
559
+ ? { type: part.type, resourceId: part.resourceId }
560
+ : { type: part.type, path: part.path });
561
+ const hasExecutionSelection = execution && (Object.prototype.hasOwnProperty.call(execution, "model") ||
562
+ Object.prototype.hasOwnProperty.call(execution, "reasoningEffort"));
563
+ return createHash("sha256")
564
+ .update(JSON.stringify(hasExecutionSelection
565
+ ? { content: canonicalContent, execution }
566
+ : canonicalContent))
419
567
  .digest("hex");
420
568
  }
569
+ function parseOperationExecution(value) {
570
+ if (!value)
571
+ return undefined;
572
+ try {
573
+ const parsed = JSON.parse(value);
574
+ if ((parsed.model !== null && typeof parsed.model !== "string") ||
575
+ (parsed.reasoningEffort !== null && typeof parsed.reasoningEffort !== "string")) {
576
+ return undefined;
577
+ }
578
+ return {
579
+ model: parsed.model,
580
+ reasoningEffort: parsed.reasoningEffort,
581
+ };
582
+ }
583
+ catch {
584
+ return undefined;
585
+ }
586
+ }
421
587
  function inspectImage(bytes, declared) {
422
588
  const sniffed = sniffMediaType(bytes);
423
589
  if (sniffed !== declared)
@@ -426,14 +592,22 @@ function inspectImage(bytes, declared) {
426
592
  ? validatePngContainer(bytes)
427
593
  : sniffed === "image/jpeg"
428
594
  ? validateJpegContainer(bytes)
429
- : validateWebpContainer(bytes);
595
+ : sniffed === "image/webp"
596
+ ? validateWebpContainer(bytes)
597
+ : sniffed === "image/gif"
598
+ ? validateGifContainer(bytes)
599
+ : validateSvgContainer(bytes);
430
600
  if (!structurallyValid)
431
601
  throw invalid("Image file is incomplete or malformed");
432
602
  const dimensions = sniffed === "image/png"
433
603
  ? pngDimensions(bytes)
434
604
  : sniffed === "image/jpeg"
435
605
  ? jpegDimensions(bytes)
436
- : webpDimensions(bytes);
606
+ : sniffed === "image/webp"
607
+ ? webpDimensions(bytes)
608
+ : sniffed === "image/gif"
609
+ ? gifDimensions(bytes)
610
+ : svgDimensions(bytes);
437
611
  if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) {
438
612
  throw invalid("Image dimensions could not be decoded");
439
613
  }
@@ -591,8 +765,67 @@ function sniffMediaType(bytes) {
591
765
  bytes.toString("ascii", 0, 4) === "RIFF" &&
592
766
  bytes.toString("ascii", 8, 12) === "WEBP")
593
767
  return "image/webp";
768
+ if (bytes.length >= 10 &&
769
+ (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a"))
770
+ return "image/gif";
771
+ try {
772
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).trimStart();
773
+ if (/^(?:<\?xml[^>]*>\s*)?(?:<!--[^]*?-->\s*)*<svg\b/i.test(text)) {
774
+ return "image/svg+xml";
775
+ }
776
+ }
777
+ catch {
778
+ // Not UTF-8 SVG.
779
+ }
594
780
  return undefined;
595
781
  }
782
+ function validateGifContainer(bytes) {
783
+ return bytes.length >= 14 && bytes.at(-1) === 0x3b;
784
+ }
785
+ function gifDimensions(bytes) {
786
+ if (bytes.length < 10)
787
+ return undefined;
788
+ return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
789
+ }
790
+ function svgText(bytes) {
791
+ try {
792
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
793
+ }
794
+ catch {
795
+ return undefined;
796
+ }
797
+ }
798
+ function validateSvgContainer(bytes) {
799
+ const text = svgText(bytes)?.trim();
800
+ if (!text)
801
+ return false;
802
+ const withoutProlog = text
803
+ .replace(/^<\?xml[^>]*>\s*/i, "")
804
+ .replace(/^(?:<!--[^]*?-->\s*)*/i, "");
805
+ return /^<svg\b/i.test(withoutProlog) && (/<\/svg>\s*$/i.test(text) || /\/\>\s*$/.test(text));
806
+ }
807
+ function svgDimensions(bytes) {
808
+ const open = svgText(bytes)?.match(/<svg\b([^>]*)>/i)?.[1];
809
+ if (open === undefined)
810
+ return undefined;
811
+ const width = numericSvgLength(open.match(/\bwidth\s*=\s*["']([^"']+)["']/i)?.[1]);
812
+ const height = numericSvgLength(open.match(/\bheight\s*=\s*["']([^"']+)["']/i)?.[1]);
813
+ if (width && height)
814
+ return { width, height };
815
+ const viewBox = open.match(/\bviewBox\s*=\s*["']([^"']+)["']/i)?.[1]
816
+ ?.trim().split(/[\s,]+/).map(Number);
817
+ if (viewBox?.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
818
+ return { width: Math.ceil(viewBox[2]), height: Math.ceil(viewBox[3]) };
819
+ }
820
+ return { width: width ?? 300, height: height ?? 150 };
821
+ }
822
+ function numericSvgLength(value) {
823
+ const match = value?.trim().match(/^(\d+(?:\.\d+)?)(?:px)?$/i);
824
+ if (!match)
825
+ return undefined;
826
+ const number = Number(match[1]);
827
+ return Number.isFinite(number) && number > 0 ? Math.ceil(number) : undefined;
828
+ }
596
829
  function pngDimensions(bytes) {
597
830
  if (bytes.toString("ascii", 12, 16) !== "IHDR")
598
831
  return undefined;
@@ -666,13 +899,75 @@ function decodeBase64(value) {
666
899
  }
667
900
  return Buffer.from(value, "base64");
668
901
  }
669
- function extension(mediaType) {
670
- return mediaType === "image/png" ? "png" : mediaType === "image/jpeg" ? "jpg" : "webp";
902
+ function resourceKind(type) {
903
+ return type === "input_image" ? "image" : "file";
904
+ }
905
+ function imageMediaType(mediaType) {
906
+ switch (mediaType) {
907
+ case "image/png":
908
+ case "image/jpeg":
909
+ case "image/webp":
910
+ case "image/gif":
911
+ case "image/svg+xml":
912
+ return mediaType;
913
+ default:
914
+ throw invalid("Image media type is invalid");
915
+ }
916
+ }
917
+ function resourceExtension(row) {
918
+ if (row.kind === "image") {
919
+ switch (imageMediaType(row.media_type)) {
920
+ case "image/png": return "png";
921
+ case "image/jpeg": return "jpg";
922
+ case "image/webp": return "webp";
923
+ case "image/gif": return "gif";
924
+ case "image/svg+xml": return "svg";
925
+ }
926
+ }
927
+ const extension = extname(row.filename).slice(1).toLowerCase();
928
+ return /^[a-z0-9][a-z0-9._-]{0,15}$/.test(extension) ? extension : "bin";
929
+ }
930
+ function decodeGeneratedImage(result) {
931
+ const dataUrl = /^data:(image\/[a-z0-9+.-]+);base64,([A-Za-z0-9+/=]+)$/i.exec(result);
932
+ const encoded = dataUrl?.[2] ?? result;
933
+ const bytes = decodeBase64(encoded);
934
+ if (bytes.byteLength === 0 || bytes.byteLength > SESSION_RESOURCE_MAX_IMAGE_BYTES) {
935
+ throw invalid("Generated image exceeds the Session image resource policy");
936
+ }
937
+ const mediaType = sniffMediaType(bytes);
938
+ if (!mediaType || (dataUrl && imageMediaType(dataUrl[1].toLowerCase()) !== mediaType)) {
939
+ throw invalid("Generated image bytes are invalid");
940
+ }
941
+ return { bytes, mediaType };
942
+ }
943
+ function imageExtension(mediaType) {
944
+ switch (mediaType) {
945
+ case "image/png": return "png";
946
+ case "image/jpeg": return "jpg";
947
+ case "image/webp": return "webp";
948
+ case "image/gif": return "gif";
949
+ case "image/svg+xml": return "svg";
950
+ }
951
+ }
952
+ function validateImageDimensions(image) {
953
+ if (!image)
954
+ return;
955
+ if (image.width > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
956
+ image.height > SESSION_RESOURCE_MAX_IMAGE_SIDE ||
957
+ image.width * image.height > SESSION_RESOURCE_MAX_IMAGE_PIXELS) {
958
+ throw invalid("Image dimensions exceed the Session resource policy");
959
+ }
960
+ }
961
+ async function hashFile(path) {
962
+ const hash = createHash("sha256");
963
+ for await (const chunk of createReadStream(path))
964
+ hash.update(chunk);
965
+ return hash.digest("hex");
671
966
  }
672
967
  function safeFilename(value) {
673
968
  const name = basename(value).replace(/[\u0000-\u001f\u007f]/g, "").trim();
674
969
  if (!name || name === "." || name === "..")
675
- throw invalid("Image filename is invalid");
970
+ throw invalid("Resource filename is invalid");
676
971
  return name;
677
972
  }
678
973
  function ensurePrivateDirectory(path) {