@frockbot/kernel-contracts 0.0.0 → 0.1.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/src/types.ts ADDED
@@ -0,0 +1,1791 @@
1
+ import {
2
+ decodeSendToUserPayloadV1,
3
+ type SendToUserPayloadV1,
4
+ } from "./send-to-user.js";
5
+ import { decodeWorkspacePathV1, type WorkspacePathV1 } from "./workspace.js";
6
+ import {
7
+ decodeSkillRefV1,
8
+ decodeSkillRefsV1,
9
+ type SkillRefV1,
10
+ } from "./skills.js";
11
+
12
+ export interface ToolCall {
13
+ id: string;
14
+ name: string;
15
+ input: unknown;
16
+ }
17
+
18
+ export interface ToolCallOccurrence {
19
+ occurrenceId: string;
20
+ turn: number;
21
+ step: number;
22
+ ordinal: number;
23
+ call: ToolCall;
24
+ }
25
+
26
+ export function toolOccurrenceId(
27
+ turn: number,
28
+ step: number,
29
+ ordinal: number,
30
+ ): string {
31
+ if (
32
+ !Number.isSafeInteger(turn) ||
33
+ turn <= 0 ||
34
+ !Number.isSafeInteger(step) ||
35
+ step <= 0 ||
36
+ !Number.isSafeInteger(ordinal) ||
37
+ ordinal < 0
38
+ ) {
39
+ throw new Error("tool occurrence coordinates are invalid");
40
+ }
41
+ return `tool:${turn}:${step}:${ordinal}`;
42
+ }
43
+
44
+ export function toolCallOccurrences(
45
+ turn: number,
46
+ step: number,
47
+ calls: readonly ToolCall[],
48
+ ): ToolCallOccurrence[] {
49
+ return calls.map((call, ordinal) => ({
50
+ occurrenceId: toolOccurrenceId(turn, step, ordinal),
51
+ turn,
52
+ step,
53
+ ordinal,
54
+ call,
55
+ }));
56
+ }
57
+
58
+ export function toolIntentMatches(
59
+ call: ToolCall,
60
+ intent: { name: string; input: unknown },
61
+ ): boolean {
62
+ if (call.name !== intent.name) return false;
63
+ try {
64
+ return JSON.stringify(call.input) === JSON.stringify(intent.input);
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ export interface ToolSchema {
71
+ name: string;
72
+ description: string;
73
+ inputSchema: Record<string, unknown>;
74
+ }
75
+
76
+ /** The media types a tool result attachment may carry. */
77
+ export const TOOL_ATTACHMENT_MEDIA_TYPES_V1 = [
78
+ "image/png",
79
+ "image/jpeg",
80
+ "image/webp",
81
+ ] as const;
82
+
83
+ export type ToolAttachmentMediaTypeV1 =
84
+ (typeof TOOL_ATTACHMENT_MEDIA_TYPES_V1)[number];
85
+
86
+ /** Most attachments one tool result may carry. */
87
+ export const TOOL_ATTACHMENT_LIMIT_V1 = 8;
88
+
89
+ /**
90
+ * A binary a tool produced, named by where it lives durably rather than by its
91
+ * bytes.
92
+ *
93
+ * The bytes are deliberately absent. An attachment is recorded in the session
94
+ * event log, and that log is one Durable Object value: a base64 screenshot in
95
+ * it would be a durable record that grows past what the object can hold. The
96
+ * Workspace holds the bytes, the content hash names exactly which bytes, and a
97
+ * model-invocation adapter that can show an image resolves them at request
98
+ * time. `dataBase64` is that resolution and is never durable — the session
99
+ * event decoder refuses it.
100
+ */
101
+ export interface ToolAttachmentV1 {
102
+ kind: "image";
103
+ mediaType: ToolAttachmentMediaTypeV1;
104
+ /** The durable root and relative path the bytes were written to. */
105
+ workspacePath: WorkspacePathV1;
106
+ contentHash: string;
107
+ bytes: number;
108
+ /** Resolved bytes, in memory only, for one model request. */
109
+ dataBase64?: string;
110
+ }
111
+
112
+ export type LlmMessage =
113
+ | { role: "user"; content: string }
114
+ | { role: "assistant"; content: string; toolCalls: ToolCall[] }
115
+ | {
116
+ role: "tool";
117
+ callId: string;
118
+ name: string;
119
+ content: string;
120
+ isError: boolean;
121
+ attachments?: ToolAttachmentV1[];
122
+ };
123
+
124
+ export interface ModelBindingSnapshot {
125
+ connectionId: string;
126
+ connectionGeneration?: string;
127
+ catalogGeneration?: string;
128
+ }
129
+
130
+ export interface NormalizedModelRequest {
131
+ requestId: string;
132
+ provider: string;
133
+ model: string;
134
+ system: string;
135
+ messages: LlmMessage[];
136
+ tools: ToolSchema[];
137
+ modelBinding?: ModelBindingSnapshot;
138
+ }
139
+
140
+ export type LlmStreamEvent =
141
+ | { type: "text-delta"; text: string }
142
+ | { type: "tool-call"; call: ToolCall }
143
+ | { type: "finish"; reason: "completed" | "tool-calls" | "max-tokens" };
144
+
145
+ export type StepOutcome =
146
+ | "completed"
147
+ | "blocked"
148
+ | "cancelled"
149
+ | "interrupted"
150
+ | "model-error"
151
+ | "tool-error";
152
+
153
+ export type TurnOutcome = StepOutcome;
154
+
155
+ /** Longest `reason` a `turn/end` event may carry. */
156
+ export const TURN_END_REASON_MAX_LENGTH = 500;
157
+
158
+ /**
159
+ * Truncates a failure description to what a `turn/end` `reason` accepts.
160
+ * Returns `undefined` when nothing describable remains.
161
+ */
162
+ export function turnEndReason(value: unknown): string | undefined {
163
+ const text =
164
+ value instanceof Error && value.message
165
+ ? value.message
166
+ : typeof value === "string"
167
+ ? value
168
+ : "";
169
+ const bounded = text.slice(0, TURN_END_REASON_MAX_LENGTH);
170
+ return bounded.length > 0 ? bounded : undefined;
171
+ }
172
+
173
+ /** The failure text a User sees for a Turn that did not complete. */
174
+ export function turnFailureMessage(
175
+ outcome: TurnOutcome,
176
+ reason?: string,
177
+ ): string {
178
+ return reason
179
+ ? `Bot turn ended with outcome ${outcome}: ${reason}`
180
+ : `Bot turn ended with outcome ${outcome}`;
181
+ }
182
+
183
+ /**
184
+ * The kind of Turn an Agent run was admitted as. GrokBot trims the tool
185
+ * catalog per turn type — the parity register's row 57 — so the kernel has to
186
+ * carry the value; which tools a turn type admits stays Package policy.
187
+ *
188
+ * All names are declared together because the value crosses the manifest, the
189
+ * isolate contract, and the durable run record: adding one later is a wire
190
+ * change in three places.
191
+ */
192
+ export type TurnTypeV1 = "chat" | "automation" | "subagent";
193
+
194
+ /** The declared turn types, in their canonical order. */
195
+ export const TURN_TYPES_V1: readonly TurnTypeV1[] = [
196
+ "chat",
197
+ "automation",
198
+ "subagent",
199
+ ];
200
+
201
+ /** The strict decoder for a turn type crossing any seam. */
202
+ export function decodeTurnTypeV1(
203
+ value: unknown,
204
+ label = "turn type",
205
+ ): TurnTypeV1 {
206
+ const turnType = TURN_TYPES_V1.find((candidate) => candidate === value);
207
+ if (!turnType) throw new Error(`${label} is invalid`);
208
+ return turnType;
209
+ }
210
+
211
+ /** The Composition generation an admitted Turn is pinned to. */
212
+ export interface CompositionPinV1 {
213
+ generationId: string;
214
+ artifactSetHash: string;
215
+ }
216
+
217
+ /**
218
+ * The three Memory tiers a fact can be written to or injected from, named as
219
+ * the session log records them. Bot Memory is the Bot's own; the other two are
220
+ * shared roots sharded per writing Bot.
221
+ */
222
+ export type MemoryScopeNameV1 = "bot" | "user" | "project";
223
+
224
+ export interface SessionEventMap {
225
+ "session/created": { createdAt: string };
226
+ /**
227
+ * `skills` is the Skills this input invoked with `/` or `@`. Optional
228
+ * because an input that invokes none carries no field at all, so every
229
+ * `input/queued` recorded before invocation existed still decodes.
230
+ */
231
+ "input/queued": {
232
+ messageId: string;
233
+ text: string;
234
+ skills?: SkillRefV1[];
235
+ };
236
+ "input/admitted": { messageId: string; turn: number };
237
+ "input/cancelled": { messageId: string; reason: "user" | "shutdown" };
238
+ "turn/start": { turn: number };
239
+ "composition/pinned": {
240
+ turn: number;
241
+ generationId: string;
242
+ artifactSetHash: string;
243
+ };
244
+ /**
245
+ * The turn type this Turn was admitted as, recorded beside the Composition
246
+ * it pinned so the trimmed tool catalog the Turn ran on is auditable in
247
+ * durable state. Absent on Turns recorded before turn admission existed;
248
+ * they replay as `chat`.
249
+ */
250
+ "turn/admission": { turn: number; turnType: TurnTypeV1 };
251
+ /**
252
+ * A user-facing send, recorded on the step whose tool call produced it.
253
+ * Row 57b: one send tool carries every typed payload, so the log holds the
254
+ * payload rather than a per-payload event, and the client projection reads
255
+ * it back. `occurrenceId` is the tool occurrence the send belongs to, which
256
+ * is what makes a replayed Turn produce exactly one of these per call.
257
+ */
258
+ "send/to-user": {
259
+ turn: number;
260
+ step: number;
261
+ occurrenceId: string;
262
+ payload: SendToUserPayloadV1;
263
+ };
264
+ /**
265
+ * A child Turn's hand-off to its parent — the same Bot's user-visible
266
+ * conversation (row 40, §2.13). Recorded here so the hand-off is durable on
267
+ * the child's own log; delivering it to the parent is a later slice, and
268
+ * nothing reads this event yet.
269
+ */
270
+ "wake/parent": {
271
+ turn: number;
272
+ step: number;
273
+ occurrenceId: string;
274
+ message: string;
275
+ };
276
+ "step/start": { turn: number; step: number };
277
+ "user/message": {
278
+ turn: number;
279
+ step: number;
280
+ messageId: string;
281
+ text: string;
282
+ };
283
+ "model/request": {
284
+ turn: number;
285
+ step: number;
286
+ request: NormalizedModelRequest;
287
+ };
288
+ "model/effect-not-started": {
289
+ turn: number;
290
+ step: number;
291
+ requestId: string;
292
+ reason: string;
293
+ };
294
+ "model/reconciliation-required": {
295
+ turn: number;
296
+ step: number;
297
+ requestId: string;
298
+ reason: string;
299
+ };
300
+ "assistant/chunk": {
301
+ turn: number;
302
+ step: number;
303
+ requestId: string;
304
+ text: string;
305
+ };
306
+ "assistant/message": {
307
+ turn: number;
308
+ step: number;
309
+ requestId: string;
310
+ text: string;
311
+ toolCalls: ToolCall[];
312
+ };
313
+ "tool/call": {
314
+ turn: number;
315
+ step: number;
316
+ occurrenceId: string;
317
+ name: string;
318
+ input: unknown;
319
+ };
320
+ "tool/result": {
321
+ turn: number;
322
+ step: number;
323
+ occurrenceId: string;
324
+ name: string;
325
+ content: string;
326
+ isError: boolean;
327
+ status: "completed" | "interrupted";
328
+ /** Durable references to binaries the tool produced. Never their bytes. */
329
+ attachments?: ToolAttachmentV1[];
330
+ };
331
+ /**
332
+ * The Bot recorded the intent to author a Package, before the bundler ran.
333
+ * Constitution, Durable effects: intent is recorded before the effect.
334
+ */
335
+ "package/author-intent": {
336
+ turn: number;
337
+ step: number;
338
+ effectId: string;
339
+ packageId: string;
340
+ sourceHash: string;
341
+ };
342
+ /** The authored artifact and the pending Composition generation it produced. */
343
+ "package/authored": {
344
+ turn: number;
345
+ step: number;
346
+ effectId: string;
347
+ packageId: string;
348
+ version: string;
349
+ contentHash: string;
350
+ generationId: string;
351
+ };
352
+ /**
353
+ * The Skills this Turn loaded as instructions, and the candidates it
354
+ * refused. Constitution, Memory: "the session event log records exactly what
355
+ * was injected, so an injection gap is visible in durable state rather than
356
+ * silently changing the Bot's behavior." A Skill is an instruction, so its
357
+ * injection is recorded on the Turn that used it, with the exact generation
358
+ * — "the exact Skill generation each Turn used is reconstructable".
359
+ */
360
+ "skill/injected": {
361
+ turn: number;
362
+ skills: Array<{
363
+ path: string;
364
+ name: string;
365
+ generationId: string;
366
+ contentHash: string;
367
+ /**
368
+ * Who the Skill is attributed to, when the reading Bot did not author
369
+ * it: its User, or another Bot of that User writing the User-global
370
+ * instruction root. Absent for a Skill the Bot wrote itself, which is
371
+ * the common case and has nothing to disclose. It is rendered in the
372
+ * catalog block, so the durable record and the prompt agree on whose
373
+ * instruction the Turn ran under.
374
+ */
375
+ by?: string;
376
+ }>;
377
+ refusals: Array<{ path: string; reason: string }>;
378
+ };
379
+ /**
380
+ * A Skill the User invoked from the composer, resolved to the exact
381
+ * generation this Turn expanded. Invocation is not disclosure-on-demand: the
382
+ * body is expanded into the Turn's first step, so what the model was told is
383
+ * reconstructable from `model/request` and *which* Skill the User asked for
384
+ * is reconstructable from here. One event per invoked ref.
385
+ */
386
+ "skill/invoked": {
387
+ turn: number;
388
+ ref: SkillRefV1;
389
+ generationId: string;
390
+ contentHash: string;
391
+ };
392
+ /** The Bot recorded the intent to write a Skill, before the write ran. */
393
+ "skill/write-intent": {
394
+ turn: number;
395
+ step: number;
396
+ effectId: string;
397
+ path: string;
398
+ contentHash: string;
399
+ };
400
+ /** The generation the Skill write produced. */
401
+ "skill/written": {
402
+ turn: number;
403
+ step: number;
404
+ effectId: string;
405
+ path: string;
406
+ generationId: string;
407
+ contentHash: string;
408
+ };
409
+ /**
410
+ * The Memory this Turn injected, and what it left out. Constitution,
411
+ * Memory: "What Memory enters a model request, and when, is Package policy,
412
+ * and the session event log records exactly what was injected, so an
413
+ * injection gap is visible in durable state rather than silently changing
414
+ * the Bot's behavior." `sources` names every Memory file generation the
415
+ * render read; `facts` is every line that reached the prompt; `omissions`
416
+ * names each tier a cap or a failure cut short.
417
+ *
418
+ * `projectId` is `""` for the tiers that have none, so every entry has the
419
+ * same shape and the decoder needs no optional field.
420
+ */
421
+ "memory/injected": {
422
+ turn: number;
423
+ sources: Array<{
424
+ scope: MemoryScopeNameV1;
425
+ projectId: string;
426
+ path: string;
427
+ generationId: string;
428
+ contentHash: string;
429
+ }>;
430
+ facts: Array<{
431
+ scope: MemoryScopeNameV1;
432
+ projectId: string;
433
+ tier: "profile" | "log";
434
+ via: string;
435
+ learnedAt: string;
436
+ text: string;
437
+ }>;
438
+ omissions: Array<{ scope: MemoryScopeNameV1; reason: string }>;
439
+ /**
440
+ * Marked facts (`[note] `/`[episode] `) the note fade dropped before the
441
+ * caps were applied, per scope. Deliberately distinct from `omissions`: an
442
+ * omission is a gap to repair, a fade is the note tier working.
443
+ */
444
+ faded?: Array<{
445
+ scope: MemoryScopeNameV1;
446
+ projectId: string;
447
+ count: number;
448
+ }>;
449
+ /**
450
+ * `YYYY-MM-DD`: the oldest day a marked fact was still injected on, and
451
+ * the TTL it was derived from. Recorded because the fade is read-time, so
452
+ * the model request only reconstructs exactly if the day it used is on the
453
+ * log. Absent on an event written before the fade existed, which is the
454
+ * honest reading: no fade was applied.
455
+ */
456
+ noteCutoff?: string;
457
+ noteTtlDays?: number;
458
+ };
459
+ /** The Bot recorded the intent to change Memory, before the write ran. */
460
+ "memory/write-intent": {
461
+ turn: number;
462
+ step: number;
463
+ effectId: string;
464
+ action: "write" | "forget";
465
+ scope: MemoryScopeNameV1;
466
+ projectId: string;
467
+ tier: "profile" | "log" | "note";
468
+ path: string;
469
+ contentHash: string;
470
+ };
471
+ /** The generation the Memory write produced. */
472
+ "memory/written": {
473
+ turn: number;
474
+ step: number;
475
+ effectId: string;
476
+ action: "write" | "forget";
477
+ scope: MemoryScopeNameV1;
478
+ projectId: string;
479
+ tier: "profile" | "log" | "note";
480
+ path: string;
481
+ generationId: string;
482
+ contentHash: string;
483
+ };
484
+ /** The Bot recorded the intent to change Project membership, before it ran. */
485
+ "memory/project-intent": {
486
+ turn: number;
487
+ step: number;
488
+ effectId: string;
489
+ action: "create" | "join" | "leave";
490
+ projectId: string;
491
+ };
492
+ /** The Project membership the durable authority holds after the change. */
493
+ "memory/project-changed": {
494
+ turn: number;
495
+ step: number;
496
+ effectId: string;
497
+ action: "create" | "join" | "leave";
498
+ projectId: string;
499
+ projects: string[];
500
+ };
501
+ /**
502
+ * The Bot recorded the intent to generate an image, before the model ran.
503
+ *
504
+ * "Record durable execution intent before invoking an external side effect.
505
+ * Only effects an interface declares read-only are exempt." Image generation
506
+ * is billed and durable, so the intent is recorded first and keyed by the
507
+ * effect, which is also the object's name under the Package's Workspace
508
+ * root. `promptHash` rather than the prompt: the prompt reaches the log once
509
+ * already, in `tool/call`, and this event exists to fence the effect, not to
510
+ * copy its input.
511
+ */
512
+ "image/generate-intent": {
513
+ turn: number;
514
+ step: number;
515
+ effectId: string;
516
+ model: string;
517
+ promptHash: string;
518
+ width: number;
519
+ height: number;
520
+ };
521
+ /**
522
+ * The generation the image write produced. Recorded after the Workspace
523
+ * write settles, so recovery can tell an effect that reached storage from
524
+ * one that did not, and never bills a second time for one that did.
525
+ */
526
+ "image/generated": {
527
+ turn: number;
528
+ step: number;
529
+ effectId: string;
530
+ model: string;
531
+ path: string;
532
+ generationId: string;
533
+ contentHash: string;
534
+ mimeType: string;
535
+ width: number;
536
+ height: number;
537
+ };
538
+ /**
539
+ * One run of the durable-root sync between the Computer's Workspace and
540
+ * object storage (ADR 0013), on a Turn that had the Computer open.
541
+ *
542
+ * "Connections to the Computer are expected to drop on every pause; every
543
+ * Computer client reconnects and resumes rather than treating a dropped
544
+ * connection as failure." A sync that could not run is therefore an
545
+ * `unavailable` outcome recorded here, never a thrown error and never a
546
+ * failed Turn — and a sync that did run leaves what it moved in durable
547
+ * state, so a missing pull is visible rather than silent.
548
+ *
549
+ * `reason` is why the sync ran: `open` before the Turn's first Computer tool
550
+ * call, `signal` when the on-Computer watcher reported a change mid-Turn,
551
+ * `turn-end` after a Turn that used the Computer.
552
+ */
553
+ /**
554
+ * A background process on the Computer changed hands: it was launched,
555
+ * looked at, read, or ended. Recorded so a Turn's durable history says what
556
+ * became of a process that outlived it — including `unknown`, which is a
557
+ * first-class outcome and not an error.
558
+ */
559
+ "computer/process": {
560
+ turn: number;
561
+ processId: string;
562
+ action: "launch" | "check" | "logs" | "stop";
563
+ status: "starting" | "running" | "exited" | "unknown";
564
+ exitCode?: number;
565
+ };
566
+ /**
567
+ * The dynamic Computer line this Turn added to its system prompt. An empty
568
+ * `text` is an explicit wake-free read that found no fresh human lease; a
569
+ * non-empty value records the exact line plus the durable lease generation
570
+ * fields that selected it.
571
+ */
572
+ "computer/injected": {
573
+ turn: number;
574
+ text: string;
575
+ ownerId?: string;
576
+ expiresAt?: string;
577
+ };
578
+ "computer/sync": {
579
+ turn: number;
580
+ reason: "open" | "signal" | "turn-end";
581
+ status: "ok" | "unavailable" | "refused" | "skipped";
582
+ detail: string;
583
+ pulled: number;
584
+ pushed: number;
585
+ restored: number;
586
+ removed: number;
587
+ adopted: number;
588
+ conflicts: number;
589
+ failures: number;
590
+ };
591
+ /**
592
+ * The Bot's name changed, and who changed it. A rename is a durable write
593
+ * that happens outside any Turn — a User edits the Bot's settings, or (from
594
+ * the slice that gives a Bot its own profile tool) the Bot renames itself —
595
+ * so the event carries no `turn` or `step`. `namedBy` is the writer the
596
+ * durable profile now records, so the announcement and the provenance in
597
+ * `BotProfile.namedBy` can never disagree.
598
+ */
599
+ "bot/renamed": {
600
+ from: string;
601
+ to: string;
602
+ namedBy: "user" | "bot";
603
+ /**
604
+ * The Bot and admitted Turn that wrote the name, when a Bot wrote it.
605
+ * `namedBy` says which kind of writer; this names the exact one, so a
606
+ * self-rename is attributable from the log alone. Absent on a User rename
607
+ * and on every announcement recorded before it existed.
608
+ */
609
+ writer?: {
610
+ kind: "bot";
611
+ botId: string;
612
+ sessionId: string;
613
+ turnId: string;
614
+ };
615
+ };
616
+ /**
617
+ * A subagent Task this Turn dispatched (ADR 0017). Recorded on the *parent*
618
+ * Session, because the child's Session is its own durable state and never
619
+ * enters the visible transcript: this event is the only thing the
620
+ * conversation says about a task, and the client draws it as a chip.
621
+ *
622
+ * `taskType` is an opaque string here for the same reason `turnType` is a
623
+ * kernel value and a role catalog is not: which roles exist is Package
624
+ * policy, and the kernel only records the one that was used.
625
+ */
626
+ "task/dispatched": {
627
+ turn: number;
628
+ step: number;
629
+ occurrenceId: string;
630
+ taskId: string;
631
+ taskType: string;
632
+ description: string;
633
+ model: string;
634
+ background: boolean;
635
+ };
636
+ /** A message the parent appended to a running task's bounded queue. */
637
+ "task/message": {
638
+ turn: number;
639
+ step: number;
640
+ occurrenceId: string;
641
+ taskId: string;
642
+ message: string;
643
+ };
644
+ /**
645
+ * A task reached its one terminal state. It carries no `turn`: a background
646
+ * task settles after the Turn that dispatched it is over, so this is durable
647
+ * Bot history rather than a step of any Turn — the `bot/renamed` shape.
648
+ */
649
+ "task/settled": {
650
+ taskId: string;
651
+ status: "completed" | "failed" | "stopped";
652
+ summary?: string;
653
+ };
654
+ /**
655
+ * Explicit authenticated cancellation of a task, recorded before the child
656
+ * is asked to stop. `requestedBy` says which door it came through: the Bot's
657
+ * own `task_stop`, or the User's `POST /tasks/:taskId/stop`.
658
+ */
659
+ "task/stopped": {
660
+ taskId: string;
661
+ requestedBy: "bot" | "user";
662
+ };
663
+ "step/end": { turn: number; step: number; outcome: StepOutcome };
664
+ /**
665
+ * `reason` states why a Turn ended in a non-`completed` outcome, so the
666
+ * failure a User sees names its cause instead of only its outcome. It is
667
+ * absent on a `completed` Turn and bounded to
668
+ * {@link TURN_END_REASON_MAX_LENGTH} characters.
669
+ */
670
+ "turn/end": { turn: number; outcome: TurnOutcome; reason?: string };
671
+ "session/disposed": { disposedAt: string };
672
+ }
673
+
674
+ function eventRecord(value: unknown, label: string): Record<string, unknown> {
675
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
676
+ throw new Error(`${label} must be an object`);
677
+ }
678
+ return value as Record<string, unknown>;
679
+ }
680
+
681
+ function requireEventKeys(
682
+ value: Record<string, unknown>,
683
+ keys: readonly string[],
684
+ label: string,
685
+ ): void {
686
+ if (
687
+ Object.keys(value).length !== keys.length ||
688
+ !keys.every((key) => Object.hasOwn(value, key))
689
+ ) {
690
+ throw new Error(`${label} has invalid fields`);
691
+ }
692
+ }
693
+
694
+ function eventString(
695
+ value: unknown,
696
+ label: string,
697
+ allowEmpty = false,
698
+ ): string {
699
+ if (typeof value !== "string" || (!allowEmpty && value.length === 0)) {
700
+ throw new Error(`${label} must be a string`);
701
+ }
702
+ return value;
703
+ }
704
+
705
+ function memoryScope(value: unknown, label: string): void {
706
+ if (value !== "bot" && value !== "user" && value !== "project") {
707
+ throw new Error(`${label} is invalid`);
708
+ }
709
+ }
710
+
711
+ function memoryTier(value: unknown, label: string): void {
712
+ if (value !== "profile" && value !== "log" && value !== "note") {
713
+ throw new Error(`${label} is invalid`);
714
+ }
715
+ }
716
+
717
+ function memoryAction(value: unknown, label: string): void {
718
+ if (value !== "write" && value !== "forget") {
719
+ throw new Error(`${label} is invalid`);
720
+ }
721
+ }
722
+
723
+ function memoryProjectAction(value: unknown, label: string): void {
724
+ if (value !== "create" && value !== "join" && value !== "leave") {
725
+ throw new Error(`${label} is invalid`);
726
+ }
727
+ }
728
+
729
+ function eventTimestamp(value: unknown, label: string): string {
730
+ const timestamp = eventString(value, label);
731
+ if (!Number.isFinite(Date.parse(timestamp))) {
732
+ throw new Error(`${label} must be a timestamp`);
733
+ }
734
+ return timestamp;
735
+ }
736
+
737
+ function eventInteger(value: unknown, label: string, minimum: number): number {
738
+ if (!Number.isSafeInteger(value) || (value as number) < minimum) {
739
+ throw new Error(`${label} must be an integer`);
740
+ }
741
+ return value as number;
742
+ }
743
+
744
+ function requireJsonValue(value: unknown, label: string, depth = 0): void {
745
+ if (depth > 32) throw new Error(`${label} is too deeply nested`);
746
+ if (
747
+ value === null ||
748
+ typeof value === "string" ||
749
+ typeof value === "boolean" ||
750
+ (typeof value === "number" && Number.isFinite(value))
751
+ ) {
752
+ return;
753
+ }
754
+ if (Array.isArray(value)) {
755
+ for (const entry of value) requireJsonValue(entry, label, depth + 1);
756
+ return;
757
+ }
758
+ const record = eventRecord(value, label);
759
+ for (const entry of Object.values(record)) {
760
+ requireJsonValue(entry, label, depth + 1);
761
+ }
762
+ }
763
+
764
+ function requireToolCall(value: unknown, label: string): void {
765
+ const call = eventRecord(value, label);
766
+ requireEventKeys(call, ["id", "name", "input"], label);
767
+ eventString(call.id, `${label}.id`);
768
+ eventString(call.name, `${label}.name`);
769
+ requireJsonValue(call.input, `${label}.input`);
770
+ }
771
+
772
+ function requireLlmMessage(value: unknown, label: string): void {
773
+ const message = eventRecord(value, label);
774
+ const role = eventString(message.role, `${label}.role`);
775
+ if (role === "user") {
776
+ requireEventKeys(message, ["role", "content"], label);
777
+ eventString(message.content, `${label}.content`, true);
778
+ return;
779
+ }
780
+ if (role === "assistant") {
781
+ requireEventKeys(message, ["role", "content", "toolCalls"], label);
782
+ eventString(message.content, `${label}.content`, true);
783
+ if (!Array.isArray(message.toolCalls)) {
784
+ throw new Error(`${label}.toolCalls must be an array`);
785
+ }
786
+ message.toolCalls.forEach((call, index) =>
787
+ requireToolCall(call, `${label}.toolCalls[${index}]`),
788
+ );
789
+ return;
790
+ }
791
+ if (role === "tool") {
792
+ requireEventKeys(
793
+ message,
794
+ [
795
+ "role",
796
+ "callId",
797
+ "name",
798
+ "content",
799
+ "isError",
800
+ ...(Object.hasOwn(message, "attachments") ? ["attachments"] : []),
801
+ ],
802
+ label,
803
+ );
804
+ if (message.attachments !== undefined) {
805
+ decodeToolAttachmentsV1(
806
+ message.attachments,
807
+ `${label}.attachments`,
808
+ false,
809
+ );
810
+ }
811
+ eventString(message.callId, `${label}.callId`);
812
+ eventString(message.name, `${label}.name`);
813
+ eventString(message.content, `${label}.content`, true);
814
+ if (typeof message.isError !== "boolean") {
815
+ throw new Error(`${label}.isError must be a boolean`);
816
+ }
817
+ return;
818
+ }
819
+ throw new Error(`${label}.role is invalid`);
820
+ }
821
+
822
+ /**
823
+ * The exact v1 decoder for the attachments a tool result carries.
824
+ *
825
+ * `durable` refuses `dataBase64`: resolved bytes belong to one model request
826
+ * and never to the event log, so a record that carries them is a record that
827
+ * would grow without bound and is rejected at the seam rather than trimmed.
828
+ */
829
+ export function decodeToolAttachmentsV1(
830
+ value: unknown,
831
+ label: string,
832
+ durable: boolean,
833
+ ): ToolAttachmentV1[] {
834
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
835
+ if (value.length > TOOL_ATTACHMENT_LIMIT_V1) {
836
+ throw new Error(
837
+ `${label} must hold at most ${TOOL_ATTACHMENT_LIMIT_V1} attachments`,
838
+ );
839
+ }
840
+ return value.map((entry, index) => {
841
+ const item = `${label}[${index}]`;
842
+ const attachment = eventRecord(entry, item);
843
+ requireEventKeys(
844
+ attachment,
845
+ [
846
+ "kind",
847
+ "mediaType",
848
+ "workspacePath",
849
+ "contentHash",
850
+ "bytes",
851
+ ...(Object.hasOwn(attachment, "dataBase64") ? ["dataBase64"] : []),
852
+ ],
853
+ item,
854
+ );
855
+ if (attachment.kind !== "image") {
856
+ throw new Error(`${item}.kind is invalid`);
857
+ }
858
+ const mediaType = TOOL_ATTACHMENT_MEDIA_TYPES_V1.find(
859
+ (known) => known === attachment.mediaType,
860
+ );
861
+ if (!mediaType) throw new Error(`${item}.mediaType is invalid`);
862
+ if (
863
+ typeof attachment.bytes !== "number" ||
864
+ !Number.isSafeInteger(attachment.bytes) ||
865
+ attachment.bytes < 0
866
+ ) {
867
+ throw new Error(`${item}.bytes must be a non-negative integer`);
868
+ }
869
+ const contentHash = eventString(
870
+ attachment.contentHash,
871
+ `${item}.contentHash`,
872
+ );
873
+ if (!/^[0-9a-f]{64}$/.test(contentHash)) {
874
+ throw new Error(`${item}.contentHash must be a sha-256 digest`);
875
+ }
876
+ if (durable && attachment.dataBase64 !== undefined) {
877
+ throw new Error(
878
+ `${item}.dataBase64 is never durable; the Workspace holds the bytes`,
879
+ );
880
+ }
881
+ return {
882
+ kind: "image",
883
+ mediaType,
884
+ workspacePath: decodeWorkspacePathV1(
885
+ attachment.workspacePath,
886
+ `${item}.workspacePath`,
887
+ ),
888
+ contentHash,
889
+ bytes: attachment.bytes,
890
+ ...(attachment.dataBase64 === undefined
891
+ ? {}
892
+ : {
893
+ dataBase64: eventString(
894
+ attachment.dataBase64,
895
+ `${item}.dataBase64`,
896
+ ),
897
+ }),
898
+ } satisfies ToolAttachmentV1;
899
+ });
900
+ }
901
+
902
+ function requireToolSchema(value: unknown, label: string): void {
903
+ const tool = eventRecord(value, label);
904
+ requireEventKeys(tool, ["name", "description", "inputSchema"], label);
905
+ eventString(tool.name, `${label}.name`);
906
+ eventString(tool.description, `${label}.description`, true);
907
+ const schema = eventRecord(tool.inputSchema, `${label}.inputSchema`);
908
+ requireJsonValue(schema, `${label}.inputSchema`);
909
+ }
910
+
911
+ /**
912
+ * The exact v1 decoder for a normalized model request. Exported because the
913
+ * request crosses the Bot isolate boundary inbound — a Bot-authored model
914
+ * adapter composes it — and every inbound value is decoded at its seam.
915
+ */
916
+ export function decodeNormalizedModelRequestV1(
917
+ value: unknown,
918
+ label = "normalized model request",
919
+ ): NormalizedModelRequest {
920
+ requireNormalizedModelRequest(value, label);
921
+ // SAFETY: requireNormalizedModelRequest validated every field exactly.
922
+ return value as NormalizedModelRequest;
923
+ }
924
+
925
+ function requireNormalizedModelRequest(value: unknown, label: string): void {
926
+ const request = eventRecord(value, label);
927
+ requireEventKeys(
928
+ request,
929
+ [
930
+ "requestId",
931
+ "provider",
932
+ "model",
933
+ "system",
934
+ "messages",
935
+ "tools",
936
+ ...(Object.hasOwn(request, "modelBinding") ? ["modelBinding"] : []),
937
+ ],
938
+ label,
939
+ );
940
+ eventString(request.requestId, `${label}.requestId`);
941
+ eventString(request.provider, `${label}.provider`);
942
+ eventString(request.model, `${label}.model`);
943
+ eventString(request.system, `${label}.system`, true);
944
+ if (!Array.isArray(request.messages) || !Array.isArray(request.tools)) {
945
+ throw new Error(`${label} messages and tools must be arrays`);
946
+ }
947
+ request.messages.forEach((message, index) =>
948
+ requireLlmMessage(message, `${label}.messages[${index}]`),
949
+ );
950
+ request.tools.forEach((tool, index) =>
951
+ requireToolSchema(tool, `${label}.tools[${index}]`),
952
+ );
953
+ if (request.modelBinding !== undefined) {
954
+ const binding = eventRecord(request.modelBinding, `${label}.modelBinding`);
955
+ requireEventKeys(
956
+ binding,
957
+ [
958
+ "connectionId",
959
+ ...(Object.hasOwn(binding, "connectionGeneration")
960
+ ? ["connectionGeneration"]
961
+ : []),
962
+ ...(Object.hasOwn(binding, "catalogGeneration")
963
+ ? ["catalogGeneration"]
964
+ : []),
965
+ ],
966
+ `${label}.modelBinding`,
967
+ );
968
+ eventString(binding.connectionId, `${label}.modelBinding.connectionId`);
969
+ if (binding.connectionGeneration !== undefined) {
970
+ eventString(
971
+ binding.connectionGeneration,
972
+ `${label}.modelBinding.connectionGeneration`,
973
+ );
974
+ }
975
+ if (binding.catalogGeneration !== undefined) {
976
+ eventString(
977
+ binding.catalogGeneration,
978
+ `${label}.modelBinding.catalogGeneration`,
979
+ );
980
+ }
981
+ }
982
+ }
983
+
984
+ const SESSION_EVENT_COMMON_KEYS = ["type", "seq", "timestamp"] as const;
985
+
986
+ export function decodeSessionEvent(input: unknown): SessionEvent {
987
+ const event = eventRecord(input, "session event");
988
+ const type = eventString(event.type, "session event.type");
989
+ eventInteger(event.seq, "session event.seq", 0);
990
+ eventTimestamp(event.timestamp, "session event.timestamp");
991
+ const keys = (...specific: string[]) => [
992
+ ...SESSION_EVENT_COMMON_KEYS,
993
+ ...specific,
994
+ ];
995
+ const turn = () => eventInteger(event.turn, "session event.turn", 1);
996
+ const step = () => eventInteger(event.step, "session event.step", 1);
997
+ const text = () => eventString(event.text, "session event.text", true);
998
+ const requestId = () =>
999
+ eventString(event.requestId, "session event.requestId");
1000
+ switch (type) {
1001
+ case "session/created":
1002
+ requireEventKeys(event, keys("createdAt"), "session event");
1003
+ eventTimestamp(event.createdAt, "session event.createdAt");
1004
+ break;
1005
+ case "input/queued":
1006
+ // Exact keys either way: an input that invoked no Skill carries no
1007
+ // `skills` field, and one that did carries a bounded, decoded list.
1008
+ requireEventKeys(
1009
+ event,
1010
+ event.skills === undefined
1011
+ ? keys("messageId", "text")
1012
+ : keys("messageId", "text", "skills"),
1013
+ "session event",
1014
+ );
1015
+ eventString(event.messageId, "session event.messageId");
1016
+ text();
1017
+ if (event.skills !== undefined) {
1018
+ decodeSkillRefsV1(event.skills, "session event.skills");
1019
+ }
1020
+ break;
1021
+ case "input/admitted":
1022
+ requireEventKeys(event, keys("messageId", "turn"), "session event");
1023
+ eventString(event.messageId, "session event.messageId");
1024
+ turn();
1025
+ break;
1026
+ case "input/cancelled":
1027
+ requireEventKeys(event, keys("messageId", "reason"), "session event");
1028
+ eventString(event.messageId, "session event.messageId");
1029
+ if (event.reason !== "user" && event.reason !== "shutdown") {
1030
+ throw new Error("session event.reason is invalid");
1031
+ }
1032
+ break;
1033
+ case "turn/start":
1034
+ requireEventKeys(event, keys("turn"), "session event");
1035
+ turn();
1036
+ break;
1037
+ case "composition/pinned":
1038
+ requireEventKeys(
1039
+ event,
1040
+ keys("turn", "generationId", "artifactSetHash"),
1041
+ "session event",
1042
+ );
1043
+ turn();
1044
+ eventString(event.generationId, "session event.generationId");
1045
+ eventString(event.artifactSetHash, "session event.artifactSetHash");
1046
+ break;
1047
+ case "turn/admission":
1048
+ requireEventKeys(event, keys("turn", "turnType"), "session event");
1049
+ turn();
1050
+ decodeTurnTypeV1(event.turnType, "session event.turnType");
1051
+ break;
1052
+ case "send/to-user":
1053
+ requireEventKeys(
1054
+ event,
1055
+ keys("turn", "step", "occurrenceId", "payload"),
1056
+ "session event",
1057
+ );
1058
+ turn();
1059
+ step();
1060
+ eventString(event.occurrenceId, "session event.occurrenceId");
1061
+ decodeSendToUserPayloadV1(event.payload, "session event.payload");
1062
+ break;
1063
+ case "wake/parent":
1064
+ requireEventKeys(
1065
+ event,
1066
+ keys("turn", "step", "occurrenceId", "message"),
1067
+ "session event",
1068
+ );
1069
+ turn();
1070
+ step();
1071
+ eventString(event.occurrenceId, "session event.occurrenceId");
1072
+ eventString(event.message, "session event.message");
1073
+ break;
1074
+ case "step/start":
1075
+ requireEventKeys(event, keys("turn", "step"), "session event");
1076
+ turn();
1077
+ step();
1078
+ break;
1079
+ case "user/message":
1080
+ requireEventKeys(
1081
+ event,
1082
+ keys("turn", "step", "messageId", "text"),
1083
+ "session event",
1084
+ );
1085
+ turn();
1086
+ step();
1087
+ eventString(event.messageId, "session event.messageId");
1088
+ text();
1089
+ break;
1090
+ case "model/request":
1091
+ requireEventKeys(event, keys("turn", "step", "request"), "session event");
1092
+ turn();
1093
+ step();
1094
+ requireNormalizedModelRequest(event.request, "session event.request");
1095
+ break;
1096
+ case "model/effect-not-started":
1097
+ case "model/reconciliation-required":
1098
+ requireEventKeys(
1099
+ event,
1100
+ keys("turn", "step", "requestId", "reason"),
1101
+ "session event",
1102
+ );
1103
+ turn();
1104
+ step();
1105
+ requestId();
1106
+ eventString(event.reason, "session event.reason");
1107
+ break;
1108
+ case "assistant/chunk":
1109
+ requireEventKeys(
1110
+ event,
1111
+ keys("turn", "step", "requestId", "text"),
1112
+ "session event",
1113
+ );
1114
+ turn();
1115
+ step();
1116
+ requestId();
1117
+ text();
1118
+ break;
1119
+ case "assistant/message":
1120
+ requireEventKeys(
1121
+ event,
1122
+ keys("turn", "step", "requestId", "text", "toolCalls"),
1123
+ "session event",
1124
+ );
1125
+ turn();
1126
+ step();
1127
+ requestId();
1128
+ text();
1129
+ if (!Array.isArray(event.toolCalls)) {
1130
+ throw new Error("session event.toolCalls must be an array");
1131
+ }
1132
+ event.toolCalls.forEach((call, index) =>
1133
+ requireToolCall(call, `session event.toolCalls[${index}]`),
1134
+ );
1135
+ break;
1136
+ case "tool/call":
1137
+ requireEventKeys(
1138
+ event,
1139
+ keys("turn", "step", "occurrenceId", "name", "input"),
1140
+ "session event",
1141
+ );
1142
+ turn();
1143
+ step();
1144
+ eventString(event.occurrenceId, "session event.occurrenceId");
1145
+ eventString(event.name, "session event.name");
1146
+ requireJsonValue(event.input, "session event.input");
1147
+ break;
1148
+ case "tool/result":
1149
+ requireEventKeys(
1150
+ event,
1151
+ keys(
1152
+ "turn",
1153
+ "step",
1154
+ "occurrenceId",
1155
+ "name",
1156
+ "content",
1157
+ "isError",
1158
+ "status",
1159
+ ...(Object.hasOwn(event, "attachments") ? ["attachments"] : []),
1160
+ ),
1161
+ "session event",
1162
+ );
1163
+ turn();
1164
+ step();
1165
+ eventString(event.occurrenceId, "session event.occurrenceId");
1166
+ eventString(event.name, "session event.name");
1167
+ eventString(event.content, "session event.content", true);
1168
+ if (typeof event.isError !== "boolean") {
1169
+ throw new Error("session event.isError must be a boolean");
1170
+ }
1171
+ if (event.status !== "completed" && event.status !== "interrupted") {
1172
+ throw new Error("session event.status is invalid");
1173
+ }
1174
+ if (event.attachments !== undefined) {
1175
+ decodeToolAttachmentsV1(
1176
+ event.attachments,
1177
+ "session event.attachments",
1178
+ true,
1179
+ );
1180
+ }
1181
+ break;
1182
+ case "package/author-intent":
1183
+ requireEventKeys(
1184
+ event,
1185
+ keys("turn", "step", "effectId", "packageId", "sourceHash"),
1186
+ "session event",
1187
+ );
1188
+ turn();
1189
+ step();
1190
+ eventString(event.effectId, "session event.effectId");
1191
+ eventString(event.packageId, "session event.packageId");
1192
+ eventString(event.sourceHash, "session event.sourceHash");
1193
+ break;
1194
+ case "package/authored":
1195
+ requireEventKeys(
1196
+ event,
1197
+ keys(
1198
+ "turn",
1199
+ "step",
1200
+ "effectId",
1201
+ "packageId",
1202
+ "version",
1203
+ "contentHash",
1204
+ "generationId",
1205
+ ),
1206
+ "session event",
1207
+ );
1208
+ turn();
1209
+ step();
1210
+ eventString(event.effectId, "session event.effectId");
1211
+ eventString(event.packageId, "session event.packageId");
1212
+ eventString(event.version, "session event.version");
1213
+ eventString(event.contentHash, "session event.contentHash");
1214
+ eventString(event.generationId, "session event.generationId");
1215
+ break;
1216
+ case "skill/injected": {
1217
+ requireEventKeys(
1218
+ event,
1219
+ keys("turn", "skills", "refusals"),
1220
+ "session event",
1221
+ );
1222
+ turn();
1223
+ if (!Array.isArray(event.skills) || !Array.isArray(event.refusals)) {
1224
+ throw new Error("session event skills and refusals must be arrays");
1225
+ }
1226
+ event.skills.forEach((skill, index) => {
1227
+ const label = `session event.skills[${index}]`;
1228
+ const entry = eventRecord(skill, label);
1229
+ // Exact keys either way: a Skill the Bot wrote itself carries no `by`,
1230
+ // and one written by its User or another of its User's Bots carries
1231
+ // the attribution the catalog block renders.
1232
+ requireEventKeys(
1233
+ entry,
1234
+ entry.by === undefined
1235
+ ? ["path", "name", "generationId", "contentHash"]
1236
+ : ["path", "name", "generationId", "contentHash", "by"],
1237
+ label,
1238
+ );
1239
+ eventString(entry.path, `${label}.path`);
1240
+ eventString(entry.name, `${label}.name`);
1241
+ eventString(entry.generationId, `${label}.generationId`);
1242
+ eventString(entry.contentHash, `${label}.contentHash`);
1243
+ if (entry.by !== undefined) eventString(entry.by, `${label}.by`);
1244
+ });
1245
+ event.refusals.forEach((refusal, index) => {
1246
+ const label = `session event.refusals[${index}]`;
1247
+ const entry = eventRecord(refusal, label);
1248
+ requireEventKeys(entry, ["path", "reason"], label);
1249
+ eventString(entry.path, `${label}.path`);
1250
+ eventString(entry.reason, `${label}.reason`);
1251
+ });
1252
+ break;
1253
+ }
1254
+ case "skill/invoked":
1255
+ requireEventKeys(
1256
+ event,
1257
+ keys("turn", "ref", "generationId", "contentHash"),
1258
+ "session event",
1259
+ );
1260
+ turn();
1261
+ decodeSkillRefV1(event.ref, "session event.ref");
1262
+ eventString(event.generationId, "session event.generationId");
1263
+ eventString(event.contentHash, "session event.contentHash");
1264
+ break;
1265
+ case "skill/write-intent":
1266
+ requireEventKeys(
1267
+ event,
1268
+ keys("turn", "step", "effectId", "path", "contentHash"),
1269
+ "session event",
1270
+ );
1271
+ turn();
1272
+ step();
1273
+ eventString(event.effectId, "session event.effectId");
1274
+ eventString(event.path, "session event.path");
1275
+ eventString(event.contentHash, "session event.contentHash");
1276
+ break;
1277
+ case "skill/written":
1278
+ requireEventKeys(
1279
+ event,
1280
+ keys("turn", "step", "effectId", "path", "generationId", "contentHash"),
1281
+ "session event",
1282
+ );
1283
+ turn();
1284
+ step();
1285
+ eventString(event.effectId, "session event.effectId");
1286
+ eventString(event.path, "session event.path");
1287
+ eventString(event.generationId, "session event.generationId");
1288
+ eventString(event.contentHash, "session event.contentHash");
1289
+ break;
1290
+ case "memory/injected": {
1291
+ requireEventKeys(
1292
+ event,
1293
+ keys(
1294
+ "turn",
1295
+ "sources",
1296
+ "facts",
1297
+ "omissions",
1298
+ // The fade's bookkeeping arrived after the event did, so a session
1299
+ // logged before it still decodes: absent means no fade ran.
1300
+ ...(Object.hasOwn(event, "faded") ? ["faded"] : []),
1301
+ ...(Object.hasOwn(event, "noteCutoff") ? ["noteCutoff"] : []),
1302
+ ...(Object.hasOwn(event, "noteTtlDays") ? ["noteTtlDays"] : []),
1303
+ ),
1304
+ "session event",
1305
+ );
1306
+ turn();
1307
+ if (
1308
+ !Array.isArray(event.sources) ||
1309
+ !Array.isArray(event.facts) ||
1310
+ !Array.isArray(event.omissions)
1311
+ ) {
1312
+ throw new Error(
1313
+ "session event sources, facts and omissions must be arrays",
1314
+ );
1315
+ }
1316
+ event.sources.forEach((source, index) => {
1317
+ const label = `session event.sources[${index}]`;
1318
+ const entry = eventRecord(source, label);
1319
+ requireEventKeys(
1320
+ entry,
1321
+ ["scope", "projectId", "path", "generationId", "contentHash"],
1322
+ label,
1323
+ );
1324
+ memoryScope(entry.scope, `${label}.scope`);
1325
+ eventString(entry.projectId, `${label}.projectId`, true);
1326
+ eventString(entry.path, `${label}.path`);
1327
+ eventString(entry.generationId, `${label}.generationId`);
1328
+ eventString(entry.contentHash, `${label}.contentHash`);
1329
+ });
1330
+ event.facts.forEach((fact, index) => {
1331
+ const label = `session event.facts[${index}]`;
1332
+ const entry = eventRecord(fact, label);
1333
+ requireEventKeys(
1334
+ entry,
1335
+ ["scope", "projectId", "tier", "via", "learnedAt", "text"],
1336
+ label,
1337
+ );
1338
+ memoryScope(entry.scope, `${label}.scope`);
1339
+ eventString(entry.projectId, `${label}.projectId`, true);
1340
+ if (entry.tier !== "profile" && entry.tier !== "log") {
1341
+ throw new Error(`${label}.tier is invalid`);
1342
+ }
1343
+ eventString(entry.via, `${label}.via`, true);
1344
+ eventString(entry.learnedAt, `${label}.learnedAt`);
1345
+ eventString(entry.text, `${label}.text`);
1346
+ });
1347
+ event.omissions.forEach((omission, index) => {
1348
+ const label = `session event.omissions[${index}]`;
1349
+ const entry = eventRecord(omission, label);
1350
+ requireEventKeys(entry, ["scope", "reason"], label);
1351
+ memoryScope(entry.scope, `${label}.scope`);
1352
+ eventString(entry.reason, `${label}.reason`);
1353
+ });
1354
+ if (Object.hasOwn(event, "faded")) {
1355
+ if (!Array.isArray(event.faded)) {
1356
+ throw new Error("session event faded must be an array");
1357
+ }
1358
+ event.faded.forEach((fade, index) => {
1359
+ const label = `session event.faded[${index}]`;
1360
+ const entry = eventRecord(fade, label);
1361
+ requireEventKeys(entry, ["scope", "projectId", "count"], label);
1362
+ memoryScope(entry.scope, `${label}.scope`);
1363
+ eventString(entry.projectId, `${label}.projectId`, true);
1364
+ eventInteger(entry.count, `${label}.count`, 1);
1365
+ });
1366
+ }
1367
+ if (Object.hasOwn(event, "noteCutoff")) {
1368
+ eventString(event.noteCutoff, "session event.noteCutoff");
1369
+ }
1370
+ if (Object.hasOwn(event, "noteTtlDays")) {
1371
+ eventInteger(event.noteTtlDays, "session event.noteTtlDays", 1);
1372
+ }
1373
+ break;
1374
+ }
1375
+ case "memory/write-intent":
1376
+ requireEventKeys(
1377
+ event,
1378
+ keys(
1379
+ "turn",
1380
+ "step",
1381
+ "effectId",
1382
+ "action",
1383
+ "scope",
1384
+ "projectId",
1385
+ "tier",
1386
+ "path",
1387
+ "contentHash",
1388
+ ),
1389
+ "session event",
1390
+ );
1391
+ turn();
1392
+ step();
1393
+ eventString(event.effectId, "session event.effectId");
1394
+ memoryAction(event.action, "session event.action");
1395
+ memoryScope(event.scope, "session event.scope");
1396
+ eventString(event.projectId, "session event.projectId", true);
1397
+ memoryTier(event.tier, "session event.tier");
1398
+ eventString(event.path, "session event.path");
1399
+ eventString(event.contentHash, "session event.contentHash");
1400
+ break;
1401
+ case "memory/written":
1402
+ requireEventKeys(
1403
+ event,
1404
+ keys(
1405
+ "turn",
1406
+ "step",
1407
+ "effectId",
1408
+ "action",
1409
+ "scope",
1410
+ "projectId",
1411
+ "tier",
1412
+ "path",
1413
+ "generationId",
1414
+ "contentHash",
1415
+ ),
1416
+ "session event",
1417
+ );
1418
+ turn();
1419
+ step();
1420
+ eventString(event.effectId, "session event.effectId");
1421
+ memoryAction(event.action, "session event.action");
1422
+ memoryScope(event.scope, "session event.scope");
1423
+ eventString(event.projectId, "session event.projectId", true);
1424
+ memoryTier(event.tier, "session event.tier");
1425
+ eventString(event.path, "session event.path");
1426
+ eventString(event.generationId, "session event.generationId");
1427
+ eventString(event.contentHash, "session event.contentHash");
1428
+ break;
1429
+ case "memory/project-intent":
1430
+ requireEventKeys(
1431
+ event,
1432
+ keys("turn", "step", "effectId", "action", "projectId"),
1433
+ "session event",
1434
+ );
1435
+ turn();
1436
+ step();
1437
+ eventString(event.effectId, "session event.effectId");
1438
+ memoryProjectAction(event.action, "session event.action");
1439
+ eventString(event.projectId, "session event.projectId");
1440
+ break;
1441
+ case "memory/project-changed":
1442
+ requireEventKeys(
1443
+ event,
1444
+ keys("turn", "step", "effectId", "action", "projectId", "projects"),
1445
+ "session event",
1446
+ );
1447
+ turn();
1448
+ step();
1449
+ eventString(event.effectId, "session event.effectId");
1450
+ memoryProjectAction(event.action, "session event.action");
1451
+ eventString(event.projectId, "session event.projectId");
1452
+ if (!Array.isArray(event.projects)) {
1453
+ throw new Error("session event.projects must be an array");
1454
+ }
1455
+ event.projects.forEach((project, index) =>
1456
+ eventString(project, `session event.projects[${index}]`),
1457
+ );
1458
+ break;
1459
+ case "image/generate-intent":
1460
+ requireEventKeys(
1461
+ event,
1462
+ keys(
1463
+ "turn",
1464
+ "step",
1465
+ "effectId",
1466
+ "model",
1467
+ "promptHash",
1468
+ "width",
1469
+ "height",
1470
+ ),
1471
+ "session event",
1472
+ );
1473
+ turn();
1474
+ step();
1475
+ eventString(event.effectId, "session event.effectId");
1476
+ eventString(event.model, "session event.model");
1477
+ eventString(event.promptHash, "session event.promptHash");
1478
+ eventInteger(event.width, "session event.width", 1);
1479
+ eventInteger(event.height, "session event.height", 1);
1480
+ break;
1481
+ case "image/generated":
1482
+ requireEventKeys(
1483
+ event,
1484
+ keys(
1485
+ "turn",
1486
+ "step",
1487
+ "effectId",
1488
+ "model",
1489
+ "path",
1490
+ "generationId",
1491
+ "contentHash",
1492
+ "mimeType",
1493
+ "width",
1494
+ "height",
1495
+ ),
1496
+ "session event",
1497
+ );
1498
+ turn();
1499
+ step();
1500
+ eventString(event.effectId, "session event.effectId");
1501
+ eventString(event.model, "session event.model");
1502
+ eventString(event.path, "session event.path");
1503
+ eventString(event.generationId, "session event.generationId");
1504
+ eventString(event.contentHash, "session event.contentHash");
1505
+ eventString(event.mimeType, "session event.mimeType");
1506
+ eventInteger(event.width, "session event.width", 1);
1507
+ eventInteger(event.height, "session event.height", 1);
1508
+ break;
1509
+ case "task/dispatched":
1510
+ requireEventKeys(
1511
+ event,
1512
+ keys(
1513
+ "turn",
1514
+ "step",
1515
+ "occurrenceId",
1516
+ "taskId",
1517
+ "taskType",
1518
+ "description",
1519
+ "model",
1520
+ "background",
1521
+ ),
1522
+ "session event",
1523
+ );
1524
+ turn();
1525
+ step();
1526
+ eventString(event.occurrenceId, "session event.occurrenceId");
1527
+ eventString(event.taskId, "session event.taskId");
1528
+ eventString(event.taskType, "session event.taskType");
1529
+ eventString(event.description, "session event.description");
1530
+ eventString(event.model, "session event.model");
1531
+ if (typeof event.background !== "boolean") {
1532
+ throw new Error("session event.background must be a boolean");
1533
+ }
1534
+ break;
1535
+ case "task/message":
1536
+ requireEventKeys(
1537
+ event,
1538
+ keys("turn", "step", "occurrenceId", "taskId", "message"),
1539
+ "session event",
1540
+ );
1541
+ turn();
1542
+ step();
1543
+ eventString(event.occurrenceId, "session event.occurrenceId");
1544
+ eventString(event.taskId, "session event.taskId");
1545
+ eventString(event.message, "session event.message");
1546
+ break;
1547
+ case "task/settled":
1548
+ requireEventKeys(
1549
+ event,
1550
+ keys(
1551
+ "taskId",
1552
+ "status",
1553
+ ...(Object.hasOwn(event, "summary") ? ["summary"] : []),
1554
+ ),
1555
+ "session event",
1556
+ );
1557
+ eventString(event.taskId, "session event.taskId");
1558
+ if (
1559
+ event.status !== "completed" &&
1560
+ event.status !== "failed" &&
1561
+ event.status !== "stopped"
1562
+ ) {
1563
+ throw new Error("session event.status is invalid");
1564
+ }
1565
+ if (event.summary !== undefined) {
1566
+ eventString(event.summary, "session event.summary");
1567
+ }
1568
+ break;
1569
+ case "task/stopped":
1570
+ requireEventKeys(event, keys("taskId", "requestedBy"), "session event");
1571
+ eventString(event.taskId, "session event.taskId");
1572
+ if (event.requestedBy !== "bot" && event.requestedBy !== "user") {
1573
+ throw new Error("session event.requestedBy is invalid");
1574
+ }
1575
+ break;
1576
+ case "computer/process": {
1577
+ requireEventKeys(
1578
+ event,
1579
+ keys(
1580
+ "turn",
1581
+ "processId",
1582
+ "action",
1583
+ "status",
1584
+ ...(Object.hasOwn(event, "exitCode") ? ["exitCode"] : []),
1585
+ ),
1586
+ "session event",
1587
+ );
1588
+ turn();
1589
+ eventString(event.processId, "session event.processId");
1590
+ if (
1591
+ event.action !== "launch" &&
1592
+ event.action !== "check" &&
1593
+ event.action !== "logs" &&
1594
+ event.action !== "stop"
1595
+ ) {
1596
+ throw new Error("session event.action is invalid");
1597
+ }
1598
+ if (
1599
+ event.status !== "starting" &&
1600
+ event.status !== "running" &&
1601
+ event.status !== "exited" &&
1602
+ event.status !== "unknown"
1603
+ ) {
1604
+ throw new Error("session event.status is invalid");
1605
+ }
1606
+ if (
1607
+ event.exitCode !== undefined &&
1608
+ (typeof event.exitCode !== "number" ||
1609
+ !Number.isSafeInteger(event.exitCode))
1610
+ ) {
1611
+ throw new Error("session event.exitCode must be an integer");
1612
+ }
1613
+ break;
1614
+ }
1615
+ case "computer/injected": {
1616
+ const active = event.text !== "";
1617
+ requireEventKeys(
1618
+ event,
1619
+ keys("turn", "text", ...(active ? ["ownerId", "expiresAt"] : [])),
1620
+ "session event",
1621
+ );
1622
+ turn();
1623
+ eventString(event.text, "session event.text", true);
1624
+ if (active) {
1625
+ eventString(event.ownerId, "session event.ownerId");
1626
+ eventTimestamp(event.expiresAt, "session event.expiresAt");
1627
+ }
1628
+ break;
1629
+ }
1630
+ case "computer/sync": {
1631
+ requireEventKeys(
1632
+ event,
1633
+ keys(
1634
+ "turn",
1635
+ "reason",
1636
+ "status",
1637
+ "detail",
1638
+ "pulled",
1639
+ "pushed",
1640
+ "restored",
1641
+ "removed",
1642
+ "adopted",
1643
+ "conflicts",
1644
+ "failures",
1645
+ ),
1646
+ "session event",
1647
+ );
1648
+ turn();
1649
+ if (!["open", "signal", "turn-end"].includes(event.reason as string)) {
1650
+ throw new Error("session event.reason is invalid");
1651
+ }
1652
+ if (
1653
+ !["ok", "unavailable", "refused", "skipped"].includes(
1654
+ event.status as string,
1655
+ )
1656
+ ) {
1657
+ throw new Error("session event.status is invalid");
1658
+ }
1659
+ eventString(event.detail, "session event.detail", true);
1660
+ for (const field of [
1661
+ "pulled",
1662
+ "pushed",
1663
+ "restored",
1664
+ "removed",
1665
+ "adopted",
1666
+ "conflicts",
1667
+ "failures",
1668
+ ] as const) {
1669
+ eventInteger(event[field], `session event.${field}`, 0);
1670
+ }
1671
+ break;
1672
+ }
1673
+ case "bot/renamed": {
1674
+ requireEventKeys(
1675
+ event,
1676
+ keys(
1677
+ "from",
1678
+ "to",
1679
+ "namedBy",
1680
+ ...(Object.hasOwn(event, "writer") ? ["writer"] : []),
1681
+ ),
1682
+ "session event",
1683
+ );
1684
+ eventString(event.from, "session event.from");
1685
+ eventString(event.to, "session event.to");
1686
+ if (event.namedBy !== "user" && event.namedBy !== "bot") {
1687
+ throw new Error("session event.namedBy is invalid");
1688
+ }
1689
+ if (event.writer !== undefined) {
1690
+ const writer = event.writer;
1691
+ if (
1692
+ typeof writer !== "object" ||
1693
+ writer === null ||
1694
+ Array.isArray(writer)
1695
+ ) {
1696
+ throw new Error("session event.writer is invalid");
1697
+ }
1698
+ const fields = writer as Record<string, unknown>;
1699
+ requireEventKeys(
1700
+ fields,
1701
+ ["kind", "botId", "sessionId", "turnId"],
1702
+ "session event.writer",
1703
+ );
1704
+ if (fields.kind !== "bot") {
1705
+ throw new Error("session event.writer.kind is invalid");
1706
+ }
1707
+ eventString(fields.botId, "session event.writer.botId");
1708
+ eventString(fields.sessionId, "session event.writer.sessionId");
1709
+ eventString(fields.turnId, "session event.writer.turnId");
1710
+ // Only a Bot writer exists, so a `user` provenance can never carry one.
1711
+ if (event.namedBy !== "bot") {
1712
+ throw new Error("session event.writer is invalid");
1713
+ }
1714
+ }
1715
+ break;
1716
+ }
1717
+ case "step/end":
1718
+ requireEventKeys(event, keys("turn", "step", "outcome"), "session event");
1719
+ turn();
1720
+ step();
1721
+ if (
1722
+ ![
1723
+ "completed",
1724
+ "blocked",
1725
+ "cancelled",
1726
+ "interrupted",
1727
+ "model-error",
1728
+ "tool-error",
1729
+ ].includes(event.outcome as string)
1730
+ ) {
1731
+ throw new Error("session event.outcome is invalid");
1732
+ }
1733
+ break;
1734
+ case "turn/end":
1735
+ requireEventKeys(
1736
+ event,
1737
+ keys(
1738
+ "turn",
1739
+ "outcome",
1740
+ ...(Object.hasOwn(event, "reason") ? ["reason"] : []),
1741
+ ),
1742
+ "session event",
1743
+ );
1744
+ turn();
1745
+ if (event.reason !== undefined) {
1746
+ const reason = eventString(event.reason, "session event.reason");
1747
+ if (reason.length > TURN_END_REASON_MAX_LENGTH) {
1748
+ throw new Error("session event.reason is too long");
1749
+ }
1750
+ }
1751
+ if (
1752
+ ![
1753
+ "completed",
1754
+ "blocked",
1755
+ "cancelled",
1756
+ "interrupted",
1757
+ "model-error",
1758
+ "tool-error",
1759
+ ].includes(event.outcome as string)
1760
+ ) {
1761
+ throw new Error("session event.outcome is invalid");
1762
+ }
1763
+ break;
1764
+ case "session/disposed":
1765
+ requireEventKeys(event, keys("disposedAt"), "session event");
1766
+ eventTimestamp(event.disposedAt, "session event.disposedAt");
1767
+ break;
1768
+ default:
1769
+ throw new Error("session event.type is invalid");
1770
+ }
1771
+ // SAFETY: the exhaustive variant switch validates every SessionEvent field.
1772
+ return event as unknown as SessionEvent;
1773
+ }
1774
+
1775
+ export type SessionEventInput<
1776
+ T extends keyof SessionEventMap = keyof SessionEventMap,
1777
+ > = {
1778
+ [K in T]: { type: K } & SessionEventMap[K];
1779
+ }[T];
1780
+
1781
+ export type SessionEvent<
1782
+ T extends keyof SessionEventMap = keyof SessionEventMap,
1783
+ > = SessionEventInput<T> & {
1784
+ seq: number;
1785
+ timestamp: string;
1786
+ };
1787
+
1788
+ export interface SessionEventEnvelope {
1789
+ sessionId: string;
1790
+ event: SessionEvent;
1791
+ }