@tt-a1i/openpi 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -0,0 +1,856 @@
1
+ import type { BigIntStats } from "node:fs";
2
+ import { open, stat, type FileHandle } from "node:fs/promises";
3
+ import { scheduler } from "node:timers/promises";
4
+ import {
5
+ sessionEntryToContextMessages,
6
+ type SessionEntry,
7
+ type SessionHeader,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ export const PREVIEW_MAX_MESSAGES = 80;
11
+ export const PREVIEW_MAX_RETAINED_BYTES = 1024 * 1024;
12
+ export const PREVIEW_MAX_LINE_BYTES = 8 * 1024 * 1024;
13
+ export const PREVIEW_READ_CHUNK_BYTES = 256 * 1024;
14
+ export const PREVIEW_MAX_HEADER_SCAN_BYTES = 1024 * 1024;
15
+
16
+ export type PreviewContextMessage = ReturnType<
17
+ typeof sessionEntryToContextMessages
18
+ >[number];
19
+
20
+ export interface PreviewFileIdentity {
21
+ path: string;
22
+ device: string;
23
+ inode: string;
24
+ size: number;
25
+ mtimeNs: string;
26
+ }
27
+
28
+ export interface SessionPreviewData {
29
+ messages: PreviewContextMessage[];
30
+ totalMessages: number;
31
+ bytesRead: number;
32
+ retainedBytes: number;
33
+ truncatedBytes: number;
34
+ identity: PreviewFileIdentity;
35
+ }
36
+
37
+ interface LoadSessionPreviewOptions {
38
+ signal?: AbortSignal;
39
+ onRead?: (bytesRead: number) => void;
40
+ }
41
+
42
+ interface ParsedLine {
43
+ offset: number;
44
+ value: Record<string, unknown>;
45
+ }
46
+
47
+ interface MessageSegment {
48
+ messagesNewestFirst: MessageEnvelope[];
49
+ projectedCount: number;
50
+ retainedBytes: number;
51
+ retentionClosed: boolean;
52
+ }
53
+
54
+ interface MessageEnvelope {
55
+ message: PreviewContextMessage;
56
+ originalBytes: number;
57
+ retainedBytes: number;
58
+ }
59
+
60
+ const decoder = new TextDecoder("utf-8", { fatal: true });
61
+
62
+ function previewFormatError(path: string, offset: number, reason: string) {
63
+ return new Error(
64
+ `Session preview ${reason} at byte offset ${offset}: ${path}`,
65
+ );
66
+ }
67
+
68
+ function abortError(signal: AbortSignal) {
69
+ if (signal.reason instanceof Error) return signal.reason;
70
+ const error = new Error("Session preview loading was cancelled.");
71
+ error.name = "AbortError";
72
+ return error;
73
+ }
74
+
75
+ function throwIfAborted(signal: AbortSignal | undefined) {
76
+ if (signal?.aborted) throw abortError(signal);
77
+ }
78
+
79
+ function decodeLine(path: string, bytes: Buffer, offset: number) {
80
+ const withoutCarriageReturn =
81
+ bytes.at(-1) === 0x0d ? bytes.subarray(0, -1) : bytes;
82
+ if (withoutCarriageReturn.length === 0) return undefined;
83
+ if (withoutCarriageReturn.length > PREVIEW_MAX_LINE_BYTES) {
84
+ throw previewFormatError(path, offset, "line exceeds 8388608 bytes");
85
+ }
86
+
87
+ let text: string;
88
+ try {
89
+ text = decoder.decode(withoutCarriageReturn);
90
+ } catch {
91
+ throw previewFormatError(path, offset, "contains invalid UTF-8");
92
+ }
93
+ if (!text.trim()) return undefined;
94
+
95
+ let value: unknown;
96
+ try {
97
+ value = JSON.parse(text);
98
+ } catch {
99
+ throw previewFormatError(path, offset, "contains malformed JSONL");
100
+ }
101
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
102
+ throw previewFormatError(path, offset, "entry is not a JSON object");
103
+ }
104
+ return value as Record<string, unknown>;
105
+ }
106
+
107
+ async function* readLinesReverse(
108
+ handle: FileHandle,
109
+ path: string,
110
+ size: number,
111
+ signal: AbortSignal | undefined,
112
+ onRead: (bytes: number) => void,
113
+ ): AsyncGenerator<ParsedLine> {
114
+ let position = size;
115
+ let pending = Buffer.alloc(0);
116
+
117
+ while (position > 0) {
118
+ throwIfAborted(signal);
119
+ const start = Math.max(0, position - PREVIEW_READ_CHUNK_BYTES);
120
+ const chunk = Buffer.allocUnsafe(position - start);
121
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, start);
122
+ if (bytesRead !== chunk.length) {
123
+ throw new Error(
124
+ `Session file changed while preview was loading: ${path}`,
125
+ );
126
+ }
127
+ onRead(bytesRead);
128
+ const combined = Buffer.concat([chunk, pending]);
129
+ let lineEnd = combined.length;
130
+
131
+ for (let index = combined.length - 1; index >= 0; index--) {
132
+ if (combined[index] !== 0x0a) continue;
133
+ const offset = start + index + 1;
134
+ const value = decodeLine(
135
+ path,
136
+ combined.subarray(index + 1, lineEnd),
137
+ offset,
138
+ );
139
+ if (value) yield { offset, value };
140
+ lineEnd = index;
141
+ }
142
+
143
+ pending = Buffer.from(combined.subarray(0, lineEnd));
144
+ if (pending.length > PREVIEW_MAX_LINE_BYTES) {
145
+ throw previewFormatError(path, start, "line exceeds 8388608 bytes");
146
+ }
147
+ position = start;
148
+ await scheduler.yield();
149
+ }
150
+
151
+ const value = decodeLine(path, pending, 0);
152
+ if (value) yield { offset: 0, value };
153
+ }
154
+
155
+ async function* readLinesForward(
156
+ handle: FileHandle,
157
+ path: string,
158
+ size: number,
159
+ signal: AbortSignal | undefined,
160
+ onRead: (bytes: number) => void,
161
+ ): AsyncGenerator<ParsedLine> {
162
+ let position = 0;
163
+ let pending = Buffer.alloc(0);
164
+ let pendingOffset = 0;
165
+
166
+ while (position < size) {
167
+ throwIfAborted(signal);
168
+ const readLength = Math.min(PREVIEW_READ_CHUNK_BYTES, size - position);
169
+ const chunk = Buffer.allocUnsafe(readLength);
170
+ const { bytesRead } = await handle.read(chunk, 0, readLength, position);
171
+ if (bytesRead !== readLength) {
172
+ throw new Error(
173
+ `Session file changed while preview was loading: ${path}`,
174
+ );
175
+ }
176
+ onRead(bytesRead);
177
+ const combined = Buffer.concat([pending, chunk]);
178
+ let lineStart = 0;
179
+
180
+ for (let index = 0; index < combined.length; index++) {
181
+ if (combined[index] !== 0x0a) continue;
182
+ const offset = pendingOffset + lineStart;
183
+ const value = decodeLine(
184
+ path,
185
+ combined.subarray(lineStart, index),
186
+ offset,
187
+ );
188
+ if (value) yield { offset, value };
189
+ lineStart = index + 1;
190
+ }
191
+
192
+ pending = Buffer.from(combined.subarray(lineStart));
193
+ pendingOffset = position + readLength - pending.length;
194
+ if (pending.length > PREVIEW_MAX_LINE_BYTES) {
195
+ throw previewFormatError(
196
+ path,
197
+ pendingOffset,
198
+ "line exceeds 8388608 bytes",
199
+ );
200
+ }
201
+ position += readLength;
202
+ await scheduler.yield();
203
+ }
204
+
205
+ const value = decodeLine(path, pending, pendingOffset);
206
+ if (value) yield { offset: pendingOffset, value };
207
+ }
208
+
209
+ function fileIdentity(path: string, stats: BigIntStats): PreviewFileIdentity {
210
+ return {
211
+ path,
212
+ device: stats.dev.toString(),
213
+ inode: stats.ino.toString(),
214
+ size: Number(stats.size),
215
+ mtimeNs: stats.mtimeNs.toString(),
216
+ };
217
+ }
218
+
219
+ function previewFileChangedError(path: string) {
220
+ return new Error(`Session file changed while preview was loading: ${path}`);
221
+ }
222
+
223
+ function samePreviewFileIdentity(left: BigIntStats, right: BigIntStats) {
224
+ return (
225
+ left.dev === right.dev &&
226
+ left.ino === right.ino &&
227
+ left.size === right.size &&
228
+ left.mtimeNs === right.mtimeNs
229
+ );
230
+ }
231
+
232
+ async function finalizePreviewFileIdentity(
233
+ path: string,
234
+ handle: FileHandle,
235
+ before: BigIntStats,
236
+ ) {
237
+ let after: BigIntStats;
238
+ let currentPath: BigIntStats;
239
+ try {
240
+ [after, currentPath] = await Promise.all([
241
+ handle.stat({ bigint: true }),
242
+ stat(path, { bigint: true }),
243
+ ]);
244
+ } catch {
245
+ throw previewFileChangedError(path);
246
+ }
247
+ if (
248
+ !samePreviewFileIdentity(before, after) ||
249
+ !samePreviewFileIdentity(after, currentPath)
250
+ ) {
251
+ throw previewFileChangedError(path);
252
+ }
253
+ return fileIdentity(path, currentPath);
254
+ }
255
+
256
+ export async function readPreviewFileIdentity(path: string) {
257
+ return fileIdentity(path, await stat(path, { bigint: true }));
258
+ }
259
+
260
+ function validateIndexedEntry(
261
+ path: string,
262
+ line: ParsedLine,
263
+ ): SessionEntry & Record<string, unknown> {
264
+ const { value } = line;
265
+ if (typeof value.id !== "string") {
266
+ throw previewFormatError(path, line.offset, "entry has no string id");
267
+ }
268
+ if (value.parentId !== null && typeof value.parentId !== "string") {
269
+ throw previewFormatError(path, line.offset, "entry has invalid parentId");
270
+ }
271
+ return value as SessionEntry & Record<string, unknown>;
272
+ }
273
+
274
+ function validateHeader(path: string, line: ParsedLine) {
275
+ const value = line.value;
276
+ if (value.type !== "session" || typeof value.id !== "string") {
277
+ throw previewFormatError(path, line.offset, "has no valid session header");
278
+ }
279
+ const version = value.version ?? 1;
280
+ if (
281
+ !Number.isInteger(version) ||
282
+ Number(version) < 1 ||
283
+ Number(version) > 3
284
+ ) {
285
+ throw previewFormatError(
286
+ path,
287
+ line.offset,
288
+ "uses an unsupported session version",
289
+ );
290
+ }
291
+ return {
292
+ header: value as unknown as SessionHeader,
293
+ version: Number(version),
294
+ };
295
+ }
296
+
297
+ async function readSessionHeader(
298
+ handle: FileHandle,
299
+ path: string,
300
+ size: number,
301
+ signal: AbortSignal | undefined,
302
+ onRead: (bytes: number) => void,
303
+ ) {
304
+ const scanBytes = Math.min(size, PREVIEW_MAX_HEADER_SCAN_BYTES);
305
+ for await (const line of readLinesForward(
306
+ handle,
307
+ path,
308
+ scanBytes,
309
+ signal,
310
+ onRead,
311
+ )) {
312
+ return { line, ...validateHeader(path, line) };
313
+ }
314
+ if (size > scanBytes) {
315
+ throw previewFormatError(path, scanBytes, "header exceeds 1048576 bytes");
316
+ }
317
+ throw previewFormatError(path, 0, "has no valid session header");
318
+ }
319
+
320
+ function normalizeEntryForVersion(
321
+ entry: SessionEntry & Record<string, unknown>,
322
+ version: number,
323
+ ) {
324
+ if (version > 2 || entry.type !== "message") return entry;
325
+ const rawMessage = entry.message as unknown;
326
+ if (
327
+ typeof rawMessage !== "object" ||
328
+ rawMessage === null ||
329
+ !("role" in rawMessage) ||
330
+ rawMessage.role !== "hookMessage"
331
+ ) {
332
+ return entry;
333
+ }
334
+ return {
335
+ ...entry,
336
+ message: { ...rawMessage, role: "custom" },
337
+ } as SessionEntry & Record<string, unknown>;
338
+ }
339
+
340
+ function createMessageSegment(): MessageSegment {
341
+ return {
342
+ messagesNewestFirst: [],
343
+ projectedCount: 0,
344
+ retainedBytes: 0,
345
+ retentionClosed: false,
346
+ };
347
+ }
348
+
349
+ function measurePreviewPayload(value: unknown): number {
350
+ if (typeof value === "string") return Buffer.byteLength(value, "utf8");
351
+ if (Array.isArray(value)) {
352
+ return (
353
+ 8 + value.reduce((total, item) => total + measurePreviewPayload(item), 0)
354
+ );
355
+ }
356
+ if (typeof value !== "object" || value === null) return 8;
357
+ return (
358
+ 8 +
359
+ Object.entries(value).reduce(
360
+ (total, [key, nested]) =>
361
+ total + Buffer.byteLength(key, "utf8") + measurePreviewPayload(nested),
362
+ 0,
363
+ )
364
+ );
365
+ }
366
+
367
+ function truncateUtf8(value: string, maxBytes: number) {
368
+ if (maxBytes <= 0) return "";
369
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
370
+ let low = 0;
371
+ let high = value.length;
372
+ while (low < high) {
373
+ const midpoint = Math.ceil((low + high) / 2);
374
+ if (Buffer.byteLength(value.slice(0, midpoint), "utf8") <= maxBytes) {
375
+ low = midpoint;
376
+ } else {
377
+ high = midpoint - 1;
378
+ }
379
+ }
380
+ let end = low;
381
+ const code = value.charCodeAt(end - 1);
382
+ if (code >= 0xd800 && code <= 0xdbff) end--;
383
+ return value.slice(0, Math.max(0, end));
384
+ }
385
+
386
+ interface ProjectionBudget {
387
+ remaining: number;
388
+ retained: number;
389
+ omitted: number;
390
+ }
391
+
392
+ function boundPreviewValue(value: unknown, budget: ProjectionBudget): unknown {
393
+ if (typeof value === "string") {
394
+ const originalBytes = Buffer.byteLength(value, "utf8");
395
+ if (originalBytes <= budget.remaining) {
396
+ budget.remaining -= originalBytes;
397
+ budget.retained += originalBytes;
398
+ return value;
399
+ }
400
+ if (budget.remaining <= 0) {
401
+ budget.omitted += originalBytes;
402
+ return "";
403
+ }
404
+
405
+ let marker = `… ${originalBytes} bytes omitted from preview`;
406
+ let kept = "";
407
+ for (let attempt = 0; attempt < 3; attempt++) {
408
+ const markerBytes = Buffer.byteLength(marker, "utf8");
409
+ kept = truncateUtf8(value, Math.max(0, budget.remaining - markerBytes));
410
+ const keptBytes = Buffer.byteLength(kept, "utf8");
411
+ marker = `… ${originalBytes - keptBytes} bytes omitted from preview`;
412
+ }
413
+ const boundedMarker = truncateUtf8(
414
+ marker,
415
+ Math.max(0, budget.remaining - Buffer.byteLength(kept, "utf8")),
416
+ );
417
+ const projected = `${kept}${boundedMarker}`;
418
+ const retained = Buffer.byteLength(projected, "utf8");
419
+ budget.remaining -= retained;
420
+ budget.retained += retained;
421
+ budget.omitted += originalBytes - Buffer.byteLength(kept, "utf8");
422
+ return projected;
423
+ }
424
+
425
+ if (Array.isArray(value)) {
426
+ if (budget.remaining < 8) {
427
+ budget.omitted += measurePreviewPayload(value);
428
+ return [];
429
+ }
430
+ budget.remaining -= 8;
431
+ budget.retained += 8;
432
+ const bounded: unknown[] = [];
433
+ for (let index = 0; index < value.length; index++) {
434
+ if (budget.remaining <= 0) {
435
+ for (let omitted = index; omitted < value.length; omitted++) {
436
+ budget.omitted += measurePreviewPayload(value[omitted]);
437
+ }
438
+ break;
439
+ }
440
+ const remainingBeforeItem = budget.remaining;
441
+ const projected = boundPreviewValue(value[index], budget);
442
+ if (projected === undefined && budget.remaining === remainingBeforeItem) {
443
+ for (let omitted = index + 1; omitted < value.length; omitted++) {
444
+ budget.omitted += measurePreviewPayload(value[omitted]);
445
+ }
446
+ break;
447
+ }
448
+ bounded.push(projected);
449
+ }
450
+ return bounded;
451
+ }
452
+ if (typeof value !== "object" || value === null) {
453
+ if (budget.remaining < 8) {
454
+ budget.omitted += 8;
455
+ return undefined;
456
+ }
457
+ budget.remaining -= 8;
458
+ budget.retained += 8;
459
+ return value;
460
+ }
461
+
462
+ if (budget.remaining < 8) {
463
+ budget.omitted += measurePreviewPayload(value);
464
+ return {};
465
+ }
466
+ budget.remaining -= 8;
467
+ budget.retained += 8;
468
+
469
+ const bounded: Record<string, unknown> = {};
470
+ for (const [key, nested] of Object.entries(value)) {
471
+ const keyBytes = Buffer.byteLength(key, "utf8");
472
+ if (keyBytes > budget.remaining) {
473
+ budget.omitted += keyBytes + measurePreviewPayload(nested);
474
+ continue;
475
+ }
476
+ budget.remaining -= keyBytes;
477
+ budget.retained += keyBytes;
478
+ bounded[key] = boundPreviewValue(nested, budget);
479
+ }
480
+ return bounded;
481
+ }
482
+
483
+ function boundPreviewMessage(message: PreviewContextMessage, maxBytes: number) {
484
+ const budget: ProjectionBudget = {
485
+ remaining: Math.max(0, maxBytes),
486
+ retained: 0,
487
+ omitted: 0,
488
+ };
489
+ const bounded = boundPreviewValue(message, budget) as PreviewContextMessage;
490
+ return {
491
+ message: bounded,
492
+ retainedBytes: budget.retained,
493
+ omittedBytes: budget.omitted,
494
+ };
495
+ }
496
+
497
+ function appendProjectedNewestFirst(
498
+ segment: MessageSegment,
499
+ projected: PreviewContextMessage[],
500
+ ) {
501
+ segment.projectedCount += projected.length;
502
+ for (let index = projected.length - 1; index >= 0; index--) {
503
+ if (segment.retentionClosed) continue;
504
+ const message = projected[index]!;
505
+ if (segment.messagesNewestFirst.length >= PREVIEW_MAX_MESSAGES) {
506
+ segment.retentionClosed = true;
507
+ continue;
508
+ }
509
+ const originalBytes = measurePreviewPayload(message);
510
+ const bounded = boundPreviewMessage(
511
+ message,
512
+ PREVIEW_MAX_RETAINED_BYTES - segment.retainedBytes,
513
+ );
514
+ segment.messagesNewestFirst.push({
515
+ message: bounded.message,
516
+ originalBytes,
517
+ retainedBytes: bounded.retainedBytes,
518
+ });
519
+ segment.retainedBytes += bounded.retainedBytes;
520
+ if (bounded.omittedBytes > 0) segment.retentionClosed = true;
521
+ }
522
+ }
523
+
524
+ function appendProjectedForward(
525
+ segment: MessageSegment,
526
+ projected: PreviewContextMessage[],
527
+ ) {
528
+ segment.projectedCount += projected.length;
529
+ for (const message of projected) {
530
+ const originalBytes = measurePreviewPayload(message);
531
+ const bounded = boundPreviewMessage(message, PREVIEW_MAX_RETAINED_BYTES);
532
+ segment.messagesNewestFirst.unshift({
533
+ message: bounded.message,
534
+ originalBytes,
535
+ retainedBytes: bounded.retainedBytes,
536
+ });
537
+ segment.retainedBytes += bounded.retainedBytes;
538
+ while (
539
+ segment.messagesNewestFirst.length > PREVIEW_MAX_MESSAGES ||
540
+ segment.retainedBytes > PREVIEW_MAX_RETAINED_BYTES
541
+ ) {
542
+ const evicted = segment.messagesNewestFirst.pop();
543
+ if (!evicted) break;
544
+ segment.retainedBytes -= evicted.retainedBytes;
545
+ }
546
+ }
547
+ }
548
+
549
+ function retainNewestMessages(messages: MessageEnvelope[]) {
550
+ const retainedNewestFirst: PreviewContextMessage[] = [];
551
+ let retainedBytes = 0;
552
+ let truncatedBytes = 0;
553
+ for (let index = messages.length - 1; index >= 0; index--) {
554
+ if (retainedNewestFirst.length >= PREVIEW_MAX_MESSAGES) break;
555
+ const envelope = messages[index]!;
556
+ const bounded = boundPreviewMessage(
557
+ envelope.message,
558
+ PREVIEW_MAX_RETAINED_BYTES - retainedBytes,
559
+ );
560
+ retainedNewestFirst.push(bounded.message);
561
+ retainedBytes += bounded.retainedBytes;
562
+ truncatedBytes += Math.max(
563
+ 0,
564
+ envelope.originalBytes - bounded.retainedBytes,
565
+ );
566
+ if (
567
+ bounded.omittedBytes > 0 ||
568
+ retainedBytes >= PREVIEW_MAX_RETAINED_BYTES
569
+ ) {
570
+ break;
571
+ }
572
+ }
573
+ return {
574
+ messages: retainedNewestFirst.reverse(),
575
+ retainedBytes,
576
+ truncatedBytes,
577
+ };
578
+ }
579
+
580
+ interface LegacyCompaction {
581
+ entryIndex: number;
582
+ firstKeptEntryIndex: number | undefined;
583
+ }
584
+
585
+ function projectLegacyEntry(value: Record<string, unknown>) {
586
+ const entry = normalizeEntryForVersion(
587
+ value as SessionEntry & Record<string, unknown>,
588
+ 1,
589
+ );
590
+ return sessionEntryToContextMessages(entry);
591
+ }
592
+
593
+ async function loadLegacyPreview(
594
+ handle: FileHandle,
595
+ path: string,
596
+ size: number,
597
+ signal: AbortSignal | undefined,
598
+ onRead: (bytes: number) => void,
599
+ ) {
600
+ const allMessages = createMessageSegment();
601
+ let latestCompaction: LegacyCompaction | undefined;
602
+ let entryIndex = -1;
603
+ let sawHeader = false;
604
+
605
+ for await (const line of readLinesForward(
606
+ handle,
607
+ path,
608
+ size,
609
+ signal,
610
+ onRead,
611
+ )) {
612
+ entryIndex++;
613
+ if (line.value.type === "session") {
614
+ if (sawHeader || entryIndex !== 0) {
615
+ throw previewFormatError(
616
+ path,
617
+ line.offset,
618
+ "has multiple session headers",
619
+ );
620
+ }
621
+ sawHeader = true;
622
+ validateHeader(path, line);
623
+ continue;
624
+ }
625
+ if (!sawHeader) {
626
+ throw previewFormatError(
627
+ path,
628
+ line.offset,
629
+ "has no valid session header",
630
+ );
631
+ }
632
+
633
+ if (line.value.type === "compaction") {
634
+ const firstKept = line.value.firstKeptEntryIndex;
635
+ if (firstKept !== undefined && !Number.isInteger(firstKept)) {
636
+ throw previewFormatError(
637
+ path,
638
+ line.offset,
639
+ "compaction has invalid firstKeptEntryIndex",
640
+ );
641
+ }
642
+ latestCompaction = {
643
+ entryIndex,
644
+ firstKeptEntryIndex:
645
+ typeof firstKept === "number" && firstKept >= 1
646
+ ? firstKept
647
+ : undefined,
648
+ };
649
+ }
650
+ appendProjectedForward(allMessages, projectLegacyEntry(line.value));
651
+ }
652
+
653
+ if (!sawHeader) {
654
+ throw previewFormatError(path, 0, "has no valid session header");
655
+ }
656
+ if (!latestCompaction) {
657
+ const retained = retainNewestMessages(
658
+ [...allMessages.messagesNewestFirst].reverse(),
659
+ );
660
+ return {
661
+ messages: retained.messages,
662
+ totalMessages: allMessages.projectedCount,
663
+ retainedBytes: retained.retainedBytes,
664
+ truncatedBytes: retained.truncatedBytes,
665
+ };
666
+ }
667
+
668
+ const compactionMessages = createMessageSegment();
669
+ const preCompaction = createMessageSegment();
670
+ const postCompaction = createMessageSegment();
671
+ const firstKept = latestCompaction.firstKeptEntryIndex;
672
+ const hasKeptRange =
673
+ firstKept !== undefined && firstKept < latestCompaction.entryIndex;
674
+ entryIndex = -1;
675
+
676
+ for await (const line of readLinesForward(
677
+ handle,
678
+ path,
679
+ size,
680
+ signal,
681
+ onRead,
682
+ )) {
683
+ entryIndex++;
684
+ if (entryIndex === 0) continue;
685
+ const projected = projectLegacyEntry(line.value);
686
+ if (entryIndex === latestCompaction.entryIndex) {
687
+ appendProjectedNewestFirst(compactionMessages, projected);
688
+ } else if (
689
+ hasKeptRange &&
690
+ entryIndex >= firstKept &&
691
+ entryIndex < latestCompaction.entryIndex
692
+ ) {
693
+ appendProjectedForward(preCompaction, projected);
694
+ } else if (entryIndex > latestCompaction.entryIndex) {
695
+ appendProjectedForward(postCompaction, projected);
696
+ }
697
+ }
698
+
699
+ const ordered = [
700
+ ...[...compactionMessages.messagesNewestFirst].reverse(),
701
+ ...[...preCompaction.messagesNewestFirst].reverse(),
702
+ ...[...postCompaction.messagesNewestFirst].reverse(),
703
+ ];
704
+ const retained = retainNewestMessages(ordered);
705
+ return {
706
+ messages: retained.messages,
707
+ totalMessages:
708
+ compactionMessages.projectedCount +
709
+ preCompaction.projectedCount +
710
+ postCompaction.projectedCount,
711
+ retainedBytes: retained.retainedBytes,
712
+ truncatedBytes: retained.truncatedBytes,
713
+ };
714
+ }
715
+
716
+ export async function loadSessionPreviewData(
717
+ path: string,
718
+ options: LoadSessionPreviewOptions = {},
719
+ ): Promise<SessionPreviewData> {
720
+ throwIfAborted(options.signal);
721
+ const handle = await open(path, "r");
722
+ let bytesRead = 0;
723
+ const recordRead = (count: number) => {
724
+ bytesRead += count;
725
+ options.onRead?.(count);
726
+ };
727
+
728
+ try {
729
+ const before = await handle.stat({ bigint: true });
730
+ const discoveredHeader = await readSessionHeader(
731
+ handle,
732
+ path,
733
+ Number(before.size),
734
+ options.signal,
735
+ recordRead,
736
+ );
737
+ if (discoveredHeader.version === 1) {
738
+ const legacy = await loadLegacyPreview(
739
+ handle,
740
+ path,
741
+ Number(before.size),
742
+ options.signal,
743
+ recordRead,
744
+ );
745
+ throwIfAborted(options.signal);
746
+ const identity = await finalizePreviewFileIdentity(path, handle, before);
747
+ return {
748
+ ...legacy,
749
+ bytesRead,
750
+ identity,
751
+ };
752
+ }
753
+ const postCompaction = createMessageSegment();
754
+ const preCompaction = createMessageSegment();
755
+ let compactionMessages: MessageSegment | undefined;
756
+ let compactionFirstKeptId: string | undefined;
757
+ let foundFirstKept = false;
758
+ let expectedId: string | null | undefined;
759
+ let headerLine: ParsedLine | undefined;
760
+ let earliestLine: ParsedLine | undefined;
761
+
762
+ for await (const line of readLinesReverse(
763
+ handle,
764
+ path,
765
+ Number(before.size),
766
+ options.signal,
767
+ recordRead,
768
+ )) {
769
+ earliestLine = line;
770
+ if (line.value.type === "session") {
771
+ if (headerLine) {
772
+ throw previewFormatError(
773
+ path,
774
+ line.offset,
775
+ "has multiple session headers",
776
+ );
777
+ }
778
+ headerLine = line;
779
+ continue;
780
+ }
781
+
782
+ const entry = normalizeEntryForVersion(
783
+ validateIndexedEntry(path, line),
784
+ discoveredHeader.version,
785
+ );
786
+ const isActive =
787
+ expectedId === undefined ||
788
+ (expectedId !== null && entry.id === expectedId);
789
+ if (!isActive) continue;
790
+ expectedId = entry.parentId;
791
+
792
+ const projected = sessionEntryToContextMessages(entry);
793
+ if (!compactionMessages && entry.type === "compaction") {
794
+ compactionMessages = createMessageSegment();
795
+ appendProjectedNewestFirst(compactionMessages, projected);
796
+ compactionFirstKeptId = entry.firstKeptEntryId;
797
+ continue;
798
+ }
799
+ if (!compactionMessages) {
800
+ appendProjectedNewestFirst(postCompaction, projected);
801
+ continue;
802
+ }
803
+ if (!foundFirstKept) {
804
+ appendProjectedNewestFirst(preCompaction, projected);
805
+ if (entry.id === compactionFirstKeptId) foundFirstKept = true;
806
+ }
807
+ }
808
+
809
+ if (!headerLine || earliestLine !== headerLine) {
810
+ throw previewFormatError(path, 0, "has no valid session header");
811
+ }
812
+ if (expectedId !== undefined && expectedId !== null) {
813
+ throw previewFormatError(
814
+ path,
815
+ headerLine.offset,
816
+ "active lineage references a missing parent",
817
+ );
818
+ }
819
+ const reverseHeader = validateHeader(path, headerLine);
820
+ if (reverseHeader.version !== discoveredHeader.version) {
821
+ throw new Error(
822
+ `Session file changed while preview was loading: ${path}`,
823
+ );
824
+ }
825
+
826
+ throwIfAborted(options.signal);
827
+ const identity = await finalizePreviewFileIdentity(path, handle, before);
828
+
829
+ const orderedMessages = compactionMessages
830
+ ? [
831
+ ...[...compactionMessages.messagesNewestFirst].reverse(),
832
+ ...(foundFirstKept
833
+ ? [...preCompaction.messagesNewestFirst].reverse()
834
+ : []),
835
+ ...[...postCompaction.messagesNewestFirst].reverse(),
836
+ ]
837
+ : [...postCompaction.messagesNewestFirst].reverse();
838
+ const retained = retainNewestMessages(orderedMessages);
839
+ const totalMessages = compactionMessages
840
+ ? compactionMessages.projectedCount +
841
+ postCompaction.projectedCount +
842
+ (foundFirstKept ? preCompaction.projectedCount : 0)
843
+ : postCompaction.projectedCount;
844
+
845
+ return {
846
+ messages: retained.messages,
847
+ totalMessages,
848
+ bytesRead,
849
+ retainedBytes: retained.retainedBytes,
850
+ truncatedBytes: retained.truncatedBytes,
851
+ identity,
852
+ };
853
+ } finally {
854
+ await handle.close();
855
+ }
856
+ }