@omercnet/paseo-omp 0.3.0-next.100.1 → 0.3.0-next.101.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.3.0-next.100.1",
3
+ "version": "0.3.0-next.101.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -1303,6 +1303,7 @@ export interface OmpPersistedSessionMessages {
1303
1303
  nativeSessionId: string;
1304
1304
  byteLength: number;
1305
1305
  messages: OmpMessage[];
1306
+ imageReplayWarning?: true;
1306
1307
  }
1307
1308
 
1308
1309
  export interface OmpStartOptions {
@@ -4,6 +4,7 @@ import { type FileHandle, lstat, open, opendir, realpath } from "node:fs/promise
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
6
6
  import { ompSessionDir } from "../paths";
7
+ import { isValidImagePayload } from "./image";
7
8
 
8
9
  const MAX_DESCRIPTOR_PREFIX_BYTES = 64 * 1024;
9
10
  const MAX_DESCRIPTOR_SUFFIX_BYTES = 64 * 1024;
@@ -48,6 +49,7 @@ export interface OmpPersistedSessionTranscript {
48
49
  nativeSessionId: string;
49
50
  byteLength: number;
50
51
  messages: unknown[];
52
+ imageReplayWarning?: true;
51
53
  }
52
54
  export interface OmpSessionListOptions {
53
55
  cwd?: string;
@@ -66,6 +68,11 @@ interface ScanBudget {
66
68
  exhausted: boolean;
67
69
  }
68
70
 
71
+ interface BlobReplayBudget {
72
+ bytes: number;
73
+ imageReplayWarning?: true;
74
+ }
75
+
69
76
  export function validateNativeSessionId(value: unknown): string {
70
77
  if (typeof value !== "string" || !NATIVE_SESSION_ID.test(value)) {
71
78
  throw new Error("Invalid OMP session identifier");
@@ -156,6 +163,7 @@ async function yieldToEventLoop(): Promise<void> {
156
163
  setImmediate(result.resolve);
157
164
  await result.promise;
158
165
  }
166
+ const UNAVAILABLE_IMAGE_MARKER = "[Image unavailable during session replay]";
159
167
  async function readStableFile(
160
168
  handle: FileHandle,
161
169
  byteLength: number,
@@ -213,6 +221,7 @@ async function hydrateBlobImageData(
213
221
  ) {
214
222
  throw new Error("OMP transcript image blob failed ownership or size validation");
215
223
  }
224
+ budget.bytes += stat.size;
216
225
  const bytes = await readStableFile(
217
226
  handle,
218
227
  stat.size,
@@ -222,7 +231,6 @@ async function hydrateBlobImageData(
222
231
  if (createHash("sha256").update(bytes).digest("hex") !== hash) {
223
232
  throw new Error("OMP transcript image blob failed integrity validation");
224
233
  }
225
- budget.bytes += bytes.byteLength;
226
234
  return bytes.toString("base64");
227
235
  } finally {
228
236
  await handle.close().catch(() => undefined);
@@ -232,11 +240,13 @@ async function hydrateBlobImageData(
232
240
  async function hydrateImageParts(
233
241
  value: unknown,
234
242
  blobDirectory: string,
235
- budget: { bytes: number },
243
+ budget: BlobReplayBudget,
244
+ failureMode: "marker" | "omit",
236
245
  signal?: AbortSignal,
237
246
  ): Promise<unknown> {
238
247
  if (!Array.isArray(value)) return value;
239
248
  let hydrated: unknown[] | undefined;
249
+ let omitted: boolean[] | undefined;
240
250
  for (let index = 0; index < value.length; index += 1) {
241
251
  const part = value[index];
242
252
  if (
@@ -251,30 +261,53 @@ async function hydrateImageParts(
251
261
  ) {
252
262
  continue;
253
263
  }
254
- const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
255
- hydrated ??= [...value];
256
- hydrated[index] = { ...part, data };
264
+ try {
265
+ const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
266
+ const mimeType = "mimeType" in part ? part.mimeType : undefined;
267
+ if (typeof mimeType !== "string" || !isValidImagePayload(data, mimeType, data.length)) {
268
+ throw new Error("OMP transcript image blob failed MIME validation");
269
+ }
270
+ hydrated ??= [...value];
271
+ hydrated[index] = { ...part, data };
272
+ } catch {
273
+ signal?.throwIfAborted();
274
+ budget.imageReplayWarning = true;
275
+ hydrated ??= [...value];
276
+ if (failureMode === "marker") {
277
+ hydrated[index] = { type: "text", text: UNAVAILABLE_IMAGE_MARKER };
278
+ } else {
279
+ omitted ??= [];
280
+ omitted[index] = true;
281
+ }
282
+ }
257
283
  }
258
- return hydrated ?? value;
284
+ if (!hydrated) return value;
285
+ return omitted ? hydrated.filter((_, index) => !omitted[index]) : hydrated;
259
286
  }
260
287
 
261
288
  async function hydratePersistedMessageImages(
262
289
  message: unknown,
263
290
  blobDirectory: string | undefined,
264
- budget: { bytes: number },
291
+ budget: BlobReplayBudget,
265
292
  signal?: AbortSignal,
266
293
  ): Promise<unknown> {
267
294
  if (!blobDirectory || !message || typeof message !== "object" || Array.isArray(message)) {
268
295
  return message;
269
296
  }
270
297
  const record = message as Record<string, unknown>;
271
- let content = await hydrateImageParts(record.content, blobDirectory, budget, signal);
298
+ let content = await hydrateImageParts(record.content, blobDirectory, budget, "marker", signal);
272
299
  if (content && typeof content === "object" && !Array.isArray(content)) {
273
300
  const contentRecord = content as Record<string, unknown>;
274
- const nested = await hydrateImageParts(contentRecord.content, blobDirectory, budget, signal);
301
+ const nested = await hydrateImageParts(
302
+ contentRecord.content,
303
+ blobDirectory,
304
+ budget,
305
+ "marker",
306
+ signal,
307
+ );
275
308
  if (nested !== contentRecord.content) content = { ...contentRecord, content: nested };
276
309
  }
277
- const images = await hydrateImageParts(record.images, blobDirectory, budget, signal);
310
+ const images = await hydrateImageParts(record.images, blobDirectory, budget, "omit", signal);
278
311
  if (content === record.content && images === record.images) return message;
279
312
  return { ...record, content, images };
280
313
  }
@@ -620,7 +653,7 @@ export async function readOmpPersistedSessionTranscript(
620
653
  throw new Error("OMP session transcript exceeds message limits");
621
654
  }
622
655
  const hydratedMessages: unknown[] = [];
623
- const blobBudget = { bytes: 0 };
656
+ const blobBudget: BlobReplayBudget = { bytes: 0 };
624
657
  for (const message of messages) {
625
658
  signal?.throwIfAborted();
626
659
  hydratedMessages.push(
@@ -632,6 +665,7 @@ export async function readOmpPersistedSessionTranscript(
632
665
  nativeSessionId,
633
666
  byteLength: bytes.byteLength,
634
667
  messages: hydratedMessages,
668
+ ...(blobBudget.imageReplayWarning ? { imageReplayWarning: true as const } : {}),
635
669
  };
636
670
  } catch (error) {
637
671
  if (error instanceof Error) throw error;
@@ -1592,6 +1592,18 @@ export class OmpProviderSession {
1592
1592
  replay.signal,
1593
1593
  );
1594
1594
  messages = transcript.messages;
1595
+ if (transcript.imageReplayWarning) {
1596
+ this.emit({
1597
+ type: "timeline.item",
1598
+ sessionId: this.id,
1599
+ item: {
1600
+ id: "omp:replay-image-unavailable",
1601
+ type: "notification",
1602
+ level: "warning",
1603
+ message: "OMP skipped one or more unavailable images while replaying this session.",
1604
+ },
1605
+ });
1606
+ }
1595
1607
  } catch (error) {
1596
1608
  if (replay.signal.aborted) throw error;
1597
1609
  this.emit({