xo-harness 0.2.0 → 0.3.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 (115) hide show
  1. package/README.md +92 -5
  2. package/dist/browser/worklets/capture-processor.js +34 -0
  3. package/dist/browser/worklets/playback-processor.js +188 -0
  4. package/dist/browser.d.ts +1 -0
  5. package/dist/browser.js +1 -0
  6. package/dist/internal/browser/browser-voice-client.d.ts +33 -0
  7. package/dist/internal/browser/browser-voice-client.js +316 -0
  8. package/dist/internal/browser/index.d.ts +2 -0
  9. package/dist/internal/browser/index.js +1 -0
  10. package/dist/internal/harness/conversation-context.d.ts +45 -0
  11. package/dist/internal/harness/conversation-context.js +79 -0
  12. package/dist/internal/harness/conversation-projection.d.ts +55 -0
  13. package/dist/internal/harness/conversation-projection.js +145 -0
  14. package/dist/internal/harness/event-stream.d.ts +23 -2
  15. package/dist/internal/harness/event-stream.js +148 -18
  16. package/dist/internal/harness/index.d.ts +7 -1
  17. package/dist/internal/harness/index.js +7 -1
  18. package/dist/internal/harness/message.d.ts +24 -11
  19. package/dist/internal/harness/message.js +271 -77
  20. package/dist/internal/harness/report-diff.d.ts +3 -0
  21. package/dist/internal/harness/report-diff.js +10 -0
  22. package/dist/internal/harness/report.d.ts +17 -14
  23. package/dist/internal/harness/report.js +41 -24
  24. package/dist/internal/harness/runtime-limits.d.ts +18 -0
  25. package/dist/internal/harness/runtime-limits.js +19 -0
  26. package/dist/internal/harness/session-persistence.d.ts +16 -0
  27. package/dist/internal/harness/session-persistence.js +106 -0
  28. package/dist/internal/harness/shadow.d.ts +5 -5
  29. package/dist/internal/harness/shadow.js +214 -52
  30. package/dist/internal/harness/socket-bridge.d.ts +45 -4
  31. package/dist/internal/harness/socket-bridge.js +215 -46
  32. package/dist/internal/harness/task-supervisor.d.ts +6 -2
  33. package/dist/internal/harness/task-supervisor.js +97 -15
  34. package/dist/internal/harness/tool-calls.d.ts +40 -0
  35. package/dist/internal/harness/tool-calls.js +132 -0
  36. package/dist/internal/harness/tool-policy.d.ts +2 -2
  37. package/dist/internal/harness/tool-runtime.d.ts +2 -2
  38. package/dist/internal/harness/tool-runtime.js +37 -40
  39. package/dist/internal/harness/tools.d.ts +21 -0
  40. package/dist/internal/harness/tools.js +7 -1
  41. package/dist/internal/harness/usage-tracker.d.ts +39 -0
  42. package/dist/internal/harness/usage-tracker.js +104 -0
  43. package/dist/internal/harness/voice-session.d.ts +39 -8
  44. package/dist/internal/harness/voice-session.js +353 -58
  45. package/dist/internal/harness/xo.d.ts +7 -12
  46. package/dist/internal/harness/xo.js +25 -13
  47. package/dist/internal/protocol/async-queue.d.ts +22 -1
  48. package/dist/internal/protocol/async-queue.js +86 -12
  49. package/dist/internal/protocol/audio.d.ts +16 -2
  50. package/dist/internal/protocol/audio.js +23 -5
  51. package/dist/internal/protocol/backend-output.d.ts +70 -0
  52. package/dist/internal/protocol/backend-output.js +39 -0
  53. package/dist/internal/protocol/event-json.d.ts +3 -0
  54. package/dist/internal/protocol/event-json.js +15 -0
  55. package/dist/internal/protocol/events.d.ts +342 -4
  56. package/dist/internal/protocol/events.js +53 -25
  57. package/dist/internal/protocol/index.d.ts +5 -0
  58. package/dist/internal/protocol/index.js +5 -0
  59. package/dist/internal/protocol/output-source.d.ts +18 -0
  60. package/dist/internal/protocol/output-source.js +15 -0
  61. package/dist/internal/protocol/paced-audio.d.ts +27 -0
  62. package/dist/internal/protocol/paced-audio.js +117 -0
  63. package/dist/internal/protocol/parts.d.ts +154 -0
  64. package/dist/internal/protocol/parts.js +32 -6
  65. package/dist/internal/protocol/provider.d.ts +308 -1
  66. package/dist/internal/protocol/provider.js +102 -3
  67. package/dist/internal/protocol/records.d.ts +773 -0
  68. package/dist/internal/protocol/records.js +46 -0
  69. package/dist/internal/protocol/tools.d.ts +40 -0
  70. package/dist/internal/protocol/tools.js +12 -0
  71. package/dist/internal/protocol/transcript.d.ts +19 -0
  72. package/dist/internal/protocol/transcript.js +53 -0
  73. package/dist/internal/provider/contract.d.ts +25 -2
  74. package/dist/internal/provider/event-queue.d.ts +17 -0
  75. package/dist/internal/provider/event-queue.js +78 -0
  76. package/dist/internal/provider/grok-voice.d.ts +12 -9
  77. package/dist/internal/provider/grok-voice.js +26 -15
  78. package/dist/internal/provider/index.d.ts +1 -0
  79. package/dist/internal/provider/index.js +1 -0
  80. package/dist/internal/provider/live-session.d.ts +29 -0
  81. package/dist/internal/provider/live-session.js +840 -0
  82. package/dist/internal/provider/node-socket.js +6 -0
  83. package/dist/internal/provider/openai-live.d.ts +62 -0
  84. package/dist/internal/provider/openai-live.js +160 -0
  85. package/dist/internal/provider/openai-realtime.d.ts +9 -5
  86. package/dist/internal/provider/openai-realtime.js +24 -10
  87. package/dist/internal/provider/realtime-session.d.ts +12 -1
  88. package/dist/internal/provider/realtime-session.js +373 -70
  89. package/dist/internal/provider/realtime-socket.d.ts +3 -1
  90. package/dist/internal/provider/tool-status-context.d.ts +10 -0
  91. package/dist/internal/provider/tool-status-context.js +30 -0
  92. package/dist/internal/provider/workers-socket.js +63 -15
  93. package/dist/internal/provider/workers.d.ts +1 -0
  94. package/dist/internal/provider/workers.js +1 -0
  95. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
  96. package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
  97. package/dist/internal/storage/event-store.d.ts +22 -4
  98. package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
  99. package/dist/internal/storage/jsonl-event-store.js +21 -16
  100. package/dist/internal/storage/memory-event-store.d.ts +4 -3
  101. package/dist/internal/storage/memory-event-store.js +8 -2
  102. package/dist/internal/storage/memory.d.ts +1 -1
  103. package/dist/internal/testkit/events.d.ts +14 -0
  104. package/dist/internal/testkit/events.js +33 -0
  105. package/dist/internal/testkit/runtime.d.ts +3 -0
  106. package/dist/internal/testkit/runtime.js +3 -0
  107. package/dist/internal/testkit/trajectory.d.ts +23 -0
  108. package/dist/internal/testkit/trajectory.js +58 -0
  109. package/dist/internal/tools-openai/index.d.ts +2 -0
  110. package/dist/internal/tools-openai/index.js +79 -72
  111. package/dist/internal/tools-openai/responses.d.ts +5 -1
  112. package/dist/internal/tools-openai/responses.js +53 -5
  113. package/dist/testing.d.ts +1 -0
  114. package/dist/testing.js +1 -0
  115. package/package.json +7 -1
@@ -0,0 +1,316 @@
1
+ import { MessageInputTextSchema } from "../protocol/index.js";
2
+ const INPUT_SAMPLE_RATE = 24_000;
3
+ /** One live voice conversation: microphone up over the socket, model audio down to the speakers. */
4
+ export class BrowserVoiceClient {
5
+ #socket;
6
+ #context;
7
+ #media;
8
+ #player;
9
+ #running = true;
10
+ #started = false;
11
+ #stopping = false;
12
+ #closed = Promise.withResolvers();
13
+ #teardownPromise;
14
+ constructor(socket, context, media, player) {
15
+ this.#socket = socket;
16
+ this.#context = context;
17
+ this.#media = media;
18
+ this.#player = player;
19
+ }
20
+ /** Resolves after submitting start locally; onStarted reports the server's readiness frame. */
21
+ static async start(options, callbacks) {
22
+ const { signal } = options;
23
+ signal?.throwIfAborted();
24
+ const context = new AudioContext({ sampleRate: INPUT_SAMPLE_RATE });
25
+ if (context.sampleRate !== INPUT_SAMPLE_RATE) {
26
+ await context.close();
27
+ throw new Error(`AudioContext runs at ${context.sampleRate} Hz; this browser cannot resample to 24 kHz`);
28
+ }
29
+ let acquiredMedia;
30
+ let openedSocket;
31
+ try {
32
+ await duringStartup(Promise.all([
33
+ context.audioWorklet.addModule(String(options.workletUrls.capture)),
34
+ context.audioWorklet.addModule(String(options.workletUrls.playback)),
35
+ ]), signal);
36
+ signal?.throwIfAborted();
37
+ const media = await duringStartup(navigator.mediaDevices.getUserMedia({
38
+ audio: {
39
+ channelCount: 1,
40
+ echoCancellation: { exact: true },
41
+ noiseSuppression: true,
42
+ autoGainControl: true,
43
+ },
44
+ }), signal, (lateMedia) => {
45
+ for (const track of lateMedia.getTracks())
46
+ track.stop();
47
+ });
48
+ acquiredMedia = media;
49
+ signal?.throwIfAborted();
50
+ if (!media.getAudioTracks()[0]?.getSettings().echoCancellation) {
51
+ throw new Error("This browser could not enable microphone echo cancellation. Use a browser that supports echo-cancelled capture.");
52
+ }
53
+ const socket = new WebSocket(options.socketUrl);
54
+ openedSocket = socket;
55
+ socket.binaryType = "arraybuffer";
56
+ await duringStartup(new Promise((resolve, reject) => {
57
+ socket.onopen = () => resolve();
58
+ socket.onerror = () => reject(new Error("Could not open the voice socket"));
59
+ socket.onclose = () => reject(new Error("Voice socket closed before connecting"));
60
+ }), signal);
61
+ signal?.throwIfAborted();
62
+ const source = context.createMediaStreamSource(media);
63
+ const capture = new AudioWorkletNode(context, "xo-capture", {
64
+ numberOfInputs: 1,
65
+ numberOfOutputs: 0,
66
+ });
67
+ source.connect(capture);
68
+ const player = new AudioWorkletNode(context, "xo-playback", {
69
+ numberOfInputs: 0,
70
+ numberOfOutputs: 1,
71
+ outputChannelCount: [1],
72
+ });
73
+ player.connect(context.destination);
74
+ const session = new BrowserVoiceClient(socket, context, media, player);
75
+ const fail = (error, reason = "audio_error") => {
76
+ if (!session.#running)
77
+ return;
78
+ void session.#teardown();
79
+ callbacks.onError(error instanceof Error ? error.message : String(error));
80
+ callbacks.onClosed(reason);
81
+ };
82
+ const send = (data) => {
83
+ try {
84
+ socket.send(data);
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ fail(error, "socket_error");
89
+ return false;
90
+ }
91
+ };
92
+ capture.onprocessorerror = () => fail(new Error("Microphone audio processing failed"));
93
+ player.onprocessorerror = () => fail(new Error("Playback audio processing failed"));
94
+ capture.port.onmessage = (event) => {
95
+ if (!session.#running || !session.#started || socket.readyState !== WebSocket.OPEN)
96
+ return;
97
+ if (socket.bufferedAmount + event.data.byteLength > INPUT_SAMPLE_RATE * 2 * 30) {
98
+ fail(new Error("Microphone upload is more than 30 seconds behind"));
99
+ return;
100
+ }
101
+ if (send(event.data))
102
+ callbacks.onStats({ sentBytes: event.data.byteLength });
103
+ };
104
+ player.port.onmessage = (event) => {
105
+ if (!session.#running)
106
+ return;
107
+ if (event.data.type === "playout.error") {
108
+ fail(new Error(event.data.message ?? "Playback audio processing failed"));
109
+ return;
110
+ }
111
+ if (event.data.type === "playout.underrun" || event.data.type === "playout.rebuffered") {
112
+ callbacks.onEvent(event.data);
113
+ return;
114
+ }
115
+ if (socket.readyState !== WebSocket.OPEN)
116
+ return;
117
+ if (event.data.type === "playout.range") {
118
+ send(JSON.stringify(event.data));
119
+ return;
120
+ }
121
+ if (event.data.type !== "playout")
122
+ return;
123
+ send(JSON.stringify({
124
+ type: "playout",
125
+ streamId: event.data.streamId,
126
+ playedThroughSample: event.data.playedThroughSample,
127
+ }));
128
+ };
129
+ socket.onmessage = (event) => {
130
+ if (!session.#running)
131
+ return;
132
+ try {
133
+ if (typeof event.data === "string") {
134
+ session.#handleControl(JSON.parse(event.data), callbacks);
135
+ }
136
+ else if (!session.#stopping) {
137
+ session.#handleAudioFrame(event.data, callbacks);
138
+ }
139
+ }
140
+ catch (error) {
141
+ fail(error);
142
+ }
143
+ };
144
+ socket.onclose = () => {
145
+ const notify = session.#running;
146
+ void session.#teardown();
147
+ if (notify)
148
+ callbacks.onClosed("socket_closed");
149
+ };
150
+ socket.send(JSON.stringify({ ...options.start, type: "start" }));
151
+ return session;
152
+ }
153
+ catch (error) {
154
+ for (const track of acquiredMedia?.getTracks() ?? [])
155
+ track.stop();
156
+ openedSocket?.close();
157
+ await context.close().catch(() => undefined);
158
+ throw error;
159
+ }
160
+ }
161
+ async stop() {
162
+ if (!this.#running)
163
+ return this.#teardown();
164
+ this.#stopping = true;
165
+ this.#started = false;
166
+ this.setMicrophoneMuted(true);
167
+ // The server can take time to settle work; end local speech immediately.
168
+ this.discardPlayback();
169
+ try {
170
+ if (this.#socket.readyState === WebSocket.OPEN) {
171
+ this.#socket.send(JSON.stringify({ type: "stop", reason: "user_stopped" }));
172
+ let timer;
173
+ await Promise.race([
174
+ this.#closed.promise,
175
+ new Promise((resolve) => {
176
+ timer = setTimeout(resolve, 5000);
177
+ }),
178
+ ]);
179
+ clearTimeout(timer);
180
+ }
181
+ }
182
+ finally {
183
+ await this.#teardown();
184
+ }
185
+ }
186
+ /** Submits locally after onStarted; throws if the session cannot accept the message. */
187
+ sendText(text) {
188
+ if (!this.#running || this.#stopping)
189
+ throw new Error("Voice session is closed");
190
+ if (!this.#started)
191
+ throw new Error("Typed input requires a started voice session; wait for onStarted");
192
+ if (this.#socket.readyState !== WebSocket.OPEN)
193
+ throw new Error("Voice socket is not open");
194
+ this.#socket.send(JSON.stringify({ type: "text", text: MessageInputTextSchema.parse(text) }));
195
+ }
196
+ setMicrophoneMuted(muted) {
197
+ // Disabled tracks still produce zero PCM; the capture worklet keeps the continuous input clock alive.
198
+ for (const track of this.#media.getAudioTracks())
199
+ track.enabled = !muted;
200
+ }
201
+ discardPlayback() {
202
+ this.#player.port.postMessage({ type: "discard" });
203
+ }
204
+ #handleControl(message, callbacks) {
205
+ switch (message.type) {
206
+ case "started":
207
+ if (this.#stopping)
208
+ break;
209
+ // Media configuration is available even when the host hides journal events.
210
+ // Older bridges still configure playback through session.started below.
211
+ if (message.outputLifecycle === "continuous" || message.outputLifecycle === "response")
212
+ this.#player.port.postMessage({
213
+ type: "configure",
214
+ ranges: message.outputLifecycle === "continuous",
215
+ });
216
+ this.#started = true;
217
+ callbacks.onStarted(String(message.sessionId ?? ""));
218
+ break;
219
+ case "interrupt":
220
+ if (typeof message.streamId === "string" && message.streamId)
221
+ this.#player.port.postMessage({ type: "interrupt", streamId: message.streamId });
222
+ break;
223
+ case "event":
224
+ {
225
+ const event = (message.event ?? {});
226
+ if (event.type === "session.started")
227
+ this.#player.port.postMessage({
228
+ type: "configure",
229
+ ranges: event.capabilities?.outputLifecycle ===
230
+ "continuous",
231
+ });
232
+ if (event.type === "audio.interrupted")
233
+ this.#player.port.postMessage({ type: "interrupt", streamId: event.streamId });
234
+ callbacks.onEvent(event);
235
+ }
236
+ break;
237
+ case "error":
238
+ callbacks.onError(String(message.message ?? "unknown error"));
239
+ break;
240
+ case "closed": {
241
+ const notify = this.#running;
242
+ void this.#teardown();
243
+ if (notify)
244
+ callbacks.onClosed(String(message.reason ?? "closed"));
245
+ break;
246
+ }
247
+ default:
248
+ break;
249
+ }
250
+ }
251
+ #handleAudioFrame(frame, callbacks) {
252
+ const view = new DataView(frame);
253
+ if (frame.byteLength < 4)
254
+ throw new Error("Invalid audio frame header");
255
+ const headerLength = view.getUint32(0, true);
256
+ if (frame.byteLength < 4 + headerLength)
257
+ throw new Error("Invalid audio frame header");
258
+ const header = JSON.parse(new TextDecoder().decode(new Uint8Array(frame, 4, headerLength)));
259
+ if (header?.sampleRate !== INPUT_SAMPLE_RATE || header.channels !== 1)
260
+ throw new Error("Browser playback requires 24 kHz mono PCM16");
261
+ if (typeof header.streamId !== "string" ||
262
+ !header.streamId ||
263
+ !Number.isSafeInteger(header.startSample) ||
264
+ header.startSample < 0 ||
265
+ (frame.byteLength - 4 - headerLength) % 2 !== 0)
266
+ throw new Error("Invalid PCM16 playback frame");
267
+ if (frame.byteLength === 4 + headerLength)
268
+ return;
269
+ const pcm = frame.slice(4 + headerLength);
270
+ callbacks.onStats({ receivedBytes: pcm.byteLength });
271
+ this.#player.port.postMessage({ type: "chunk", streamId: header.streamId, startSample: header.startSample, pcm }, [pcm]);
272
+ }
273
+ #teardown() {
274
+ if (this.#teardownPromise)
275
+ return this.#teardownPromise;
276
+ const settled = Promise.withResolvers();
277
+ // Cache before releasing resources: socket.close may synchronously notify a host adapter.
278
+ this.#teardownPromise = settled.promise;
279
+ this.#closed.resolve();
280
+ this.#running = false;
281
+ void this.#releaseResources().then(settled.resolve, settled.reject);
282
+ return settled.promise;
283
+ }
284
+ async #releaseResources() {
285
+ for (const track of this.#media.getTracks())
286
+ track.stop();
287
+ this.#player.port.postMessage({ type: "reset" });
288
+ if (this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING) {
289
+ this.#socket.close();
290
+ }
291
+ await this.#context.close().catch(() => undefined);
292
+ }
293
+ }
294
+ /** Browser permission requests cannot be revoked; release their resources if they resolve after abort. */
295
+ function duringStartup(work, signal, releaseLate) {
296
+ if (!signal)
297
+ return work;
298
+ return new Promise((resolve, reject) => {
299
+ const abort = () => reject(signal.reason);
300
+ signal.addEventListener("abort", abort, { once: true });
301
+ if (signal.aborted)
302
+ abort();
303
+ work.then((value) => {
304
+ signal.removeEventListener("abort", abort);
305
+ if (signal.aborted) {
306
+ releaseLate?.(value);
307
+ reject(signal.reason);
308
+ }
309
+ else
310
+ resolve(value);
311
+ }, (error) => {
312
+ signal.removeEventListener("abort", abort);
313
+ reject(error);
314
+ });
315
+ });
316
+ }
@@ -0,0 +1,2 @@
1
+ export type { VoiceStartRequest } from "../harness/index.js";
2
+ export * from "./browser-voice-client.js";
@@ -0,0 +1 @@
1
+ export * from "./browser-voice-client.js";
@@ -0,0 +1,45 @@
1
+ import type { Message, ProviderOutputSource } from "../protocol/index.js";
2
+ import type { ConversationSnapshot, ConversationTextCompletion } from "./conversation-projection.js";
3
+ export interface ConversationContextOptions {
4
+ /** Each selected text part becomes one context message. Zero selects no messages. */
5
+ maxMessages: number;
6
+ /** Combined UTF-8 text bytes, not tokens or encoded request size. */
7
+ maxTextBytes: number;
8
+ /**
9
+ * Live captions have no authoritative turn-final event, even after session end.
10
+ * Including unconfirmed text is a deliberate application choice; it may be partial.
11
+ */
12
+ unconfirmedText: "include" | "omit";
13
+ /** Include successful backend text/refusals; these are not evidence of spoken delivery. */
14
+ includeBackendText?: boolean;
15
+ }
16
+ export interface ConversationContextMessage {
17
+ role: Message["role"];
18
+ text: string;
19
+ messageId: string;
20
+ partId: string;
21
+ completion: ConversationTextCompletion;
22
+ source?: ProviderOutputSource;
23
+ }
24
+ export type ConversationContextOmissionReason = "non-text" | "empty" | "backend-excluded" | "unconfirmed" | "incomplete" | "cancelled" | "failed" | "interrupted" | "message-limit" | "text-byte-limit";
25
+ export interface ConversationContextSelection {
26
+ sessionId: string;
27
+ throughSequence: number;
28
+ messages: ConversationContextMessage[];
29
+ omitted: {
30
+ messageId: string;
31
+ partId: string;
32
+ reason: ConversationContextOmissionReason;
33
+ }[];
34
+ }
35
+ /**
36
+ * Selects a newest suffix of eligible whole text parts, preserving projection order,
37
+ * roles and exact text. At the first budget boundary all older eligible text is
38
+ * omitted; an oversized recent request is never silently replaced with older ones.
39
+ *
40
+ * This detached selection seeds a new model session. It does not resume the provider,
41
+ * replay tools, summarize outcomes, or prove that assistant text was heard. Tools,
42
+ * reasoning and media are reported as omissions without copying their payloads.
43
+ * Provider input conversion and token limits remain the caller's responsibility.
44
+ */
45
+ export declare function selectConversationContext(snapshot: ConversationSnapshot, options: ConversationContextOptions): ConversationContextSelection;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Selects a newest suffix of eligible whole text parts, preserving projection order,
3
+ * roles and exact text. At the first budget boundary all older eligible text is
4
+ * omitted; an oversized recent request is never silently replaced with older ones.
5
+ *
6
+ * This detached selection seeds a new model session. It does not resume the provider,
7
+ * replay tools, summarize outcomes, or prove that assistant text was heard. Tools,
8
+ * reasoning and media are reported as omissions without copying their payloads.
9
+ * Provider input conversion and token limits remain the caller's responsibility.
10
+ */
11
+ export function selectConversationContext(snapshot, options) {
12
+ for (const [name, value] of [
13
+ ["maxMessages", options.maxMessages],
14
+ ["maxTextBytes", options.maxTextBytes],
15
+ ]) {
16
+ if (!Number.isSafeInteger(value) || value < 0)
17
+ throw new Error(`${name} must be a nonnegative safe integer`);
18
+ }
19
+ if (options.unconfirmedText !== "include" && options.unconfirmedText !== "omit")
20
+ throw new Error("unconfirmedText must be include or omit");
21
+ const messages = [];
22
+ const omitted = [];
23
+ const encoder = new TextEncoder();
24
+ let textBytes = 0;
25
+ let boundary;
26
+ for (let messageIndex = snapshot.messages.length - 1; messageIndex >= 0; messageIndex--) {
27
+ const message = snapshot.messages[messageIndex];
28
+ if (!message)
29
+ continue;
30
+ for (let partIndex = message.parts.length - 1; partIndex >= 0; partIndex--) {
31
+ const part = message.parts[partIndex];
32
+ if (!part)
33
+ continue;
34
+ const completion = snapshot.textCompletion[part.id] ?? "unconfirmed";
35
+ let reason;
36
+ if (part.type !== "text")
37
+ reason = "non-text";
38
+ else if (!part.text.trim())
39
+ reason = "empty";
40
+ else if (part.source?.scope === "backend" && !options.includeBackendText)
41
+ reason = "backend-excluded";
42
+ else if (message.role === "assistant" && part.playback === "interrupted")
43
+ reason = "interrupted";
44
+ else if (part.state === "incomplete" || part.state === "cancelled" || part.state === "failed")
45
+ reason = part.state;
46
+ else if (completion === "unconfirmed" &&
47
+ (options.unconfirmedText === "omit" || part.source?.scope === "backend"))
48
+ reason = "unconfirmed";
49
+ else if (boundary)
50
+ reason = boundary;
51
+ else if (messages.length >= options.maxMessages)
52
+ reason = boundary = "message-limit";
53
+ else {
54
+ const bytes = encoder.encode(part.text).byteLength;
55
+ if (bytes > options.maxTextBytes - textBytes)
56
+ reason = boundary = "text-byte-limit";
57
+ else {
58
+ messages.push({
59
+ role: message.role,
60
+ text: part.text,
61
+ messageId: message.id,
62
+ partId: part.id,
63
+ completion,
64
+ ...(part.source === undefined ? {} : { source: { ...part.source } }),
65
+ });
66
+ textBytes += bytes;
67
+ }
68
+ }
69
+ if (reason)
70
+ omitted.push({ messageId: message.id, partId: part.id, reason });
71
+ }
72
+ }
73
+ return {
74
+ sessionId: snapshot.sessionId,
75
+ throughSequence: snapshot.throughSequence,
76
+ messages: messages.reverse(),
77
+ omitted: omitted.reverse(),
78
+ };
79
+ }
@@ -0,0 +1,55 @@
1
+ import { type HarnessRecord, type Message } from "../protocol/index.js";
2
+ import { type ConversationTextCompletion } from "./message.js";
3
+ export type { ConversationTextCompletion } from "./message.js";
4
+ /** Bump when replay changes persisted membership or evidence. Version 4 retains response playback interruptions. */
5
+ export declare const CONVERSATION_PROJECTION_VERSION = 4;
6
+ export interface ConversationSnapshot {
7
+ sessionId: string;
8
+ throughSequence: number;
9
+ /** The source log contains session.ended; this does not finalize unfinished utterances. */
10
+ sealed: boolean;
11
+ messages: readonly Message[];
12
+ /** Complete current map keyed by text part ID. Timed display groups remain unconfirmed after sealing. */
13
+ textCompletion: Readonly<Record<string, ConversationTextCompletion>>;
14
+ }
15
+ export interface ConversationChange {
16
+ sessionId: string;
17
+ throughSequence: number;
18
+ sealed: boolean;
19
+ /** Complete current message order; timestamps may tie and IDs are not sortable. */
20
+ messageIds: readonly string[];
21
+ upsert: readonly Message[];
22
+ removeIds: readonly string[];
23
+ /** Complete replacement map, including when there are no changed messages. */
24
+ textCompletion: Readonly<Record<string, ConversationTextCompletion>>;
25
+ }
26
+ /**
27
+ * One session's persistent conversation view, reusing the batch message projector.
28
+ * Feed the complete contiguous log from session.started at sequence 1. This is not
29
+ * a checkpoint restore API: hosts replay source events to reconstruct its state.
30
+ *
31
+ * apply only captures detached metadata; changes/snapshot fold lazily. PCM content
32
+ * is never read or retained, and only the first output marker per legacy response
33
+ * stream is retained. Duplicate/conflict checks compare format, sample ranges and
34
+ * other metadata, not audio bytes; this is not a full event-log integrity verifier.
35
+ *
36
+ * Persist identities as (sessionId, message.id). Each emitted value is deeply
37
+ * detached, safe to queue or mutate without changing the projector or older results.
38
+ * Host transactions must commit message writes/removals and throughSequence together.
39
+ */
40
+ export declare class ConversationProjector {
41
+ #private;
42
+ constructor(options: {
43
+ sessionId: string;
44
+ });
45
+ /** Returns false for a repeated metadata-identical event, even after the session is sealed. */
46
+ apply(event: HarnessRecord): boolean;
47
+ /** Observes the complete view without consuming pending changes. */
48
+ snapshot(): ConversationSnapshot;
49
+ /**
50
+ * Changes since the previous changes() call. Empty diffs still advance the source
51
+ * checkpoint. Queue batches in order; if persistence fails, retry the returned batch
52
+ * rather than calling changes() again to reconstruct it. snapshot() does not drain it.
53
+ */
54
+ changes(): ConversationChange;
55
+ }
@@ -0,0 +1,145 @@
1
+ import { HarnessRecordSchema, toHarnessRecord } from "../protocol/index.js";
2
+ import { foldMessages } from "./message.js";
3
+ /** Bump when replay changes persisted membership or evidence. Version 4 retains response playback interruptions. */
4
+ export const CONVERSATION_PROJECTION_VERSION = 4;
5
+ /**
6
+ * One session's persistent conversation view, reusing the batch message projector.
7
+ * Feed the complete contiguous log from session.started at sequence 1. This is not
8
+ * a checkpoint restore API: hosts replay source events to reconstruct its state.
9
+ *
10
+ * apply only captures detached metadata; changes/snapshot fold lazily. PCM content
11
+ * is never read or retained, and only the first output marker per legacy response
12
+ * stream is retained. Duplicate/conflict checks compare format, sample ranges and
13
+ * other metadata, not audio bytes; this is not a full event-log integrity verifier.
14
+ *
15
+ * Persist identities as (sessionId, message.id). Each emitted value is deeply
16
+ * detached, safe to queue or mutate without changing the projector or older results.
17
+ * Host transactions must commit message writes/removals and throughSequence together.
18
+ */
19
+ export class ConversationProjector {
20
+ #sessionId;
21
+ #events = [];
22
+ #signatures = [];
23
+ #eventIds = new Set();
24
+ #audioStreams = new Set();
25
+ #continuousOutput = false;
26
+ #sealed = false;
27
+ #dirty = false;
28
+ #messages = [];
29
+ #textCompletion = {};
30
+ #previousMessages = new Map();
31
+ constructor(options) {
32
+ if (!options.sessionId)
33
+ throw new Error("Conversation projection requires a sessionId");
34
+ this.#sessionId = options.sessionId;
35
+ }
36
+ /** Returns false for a repeated metadata-identical event, even after the session is sealed. */
37
+ apply(event) {
38
+ if (event.sessionId !== this.#sessionId)
39
+ throw new Error("Conversation projection cannot mix sessions");
40
+ if (!Number.isSafeInteger(event.sequence) || event.sequence < 1)
41
+ throw new Error("Conversation event sequence must be a positive safe integer");
42
+ const metadata = structuredClone(HarnessRecordSchema.parse(toHarnessRecord(event, "metadata")));
43
+ const signature = fingerprint(metadata);
44
+ const existing = this.#signatures[event.sequence - 1];
45
+ if (existing !== undefined) {
46
+ if (existing !== signature)
47
+ throw new Error(`Conflicting event at sequence ${event.sequence}`);
48
+ return false;
49
+ }
50
+ if (event.sequence !== this.#signatures.length + 1)
51
+ throw new Error(`Expected sequence ${this.#signatures.length + 1}; received ${event.sequence}`);
52
+ if (this.#sealed)
53
+ throw new Error("Conversation projection is sealed");
54
+ if (this.#signatures.length === 0 && event.type !== "session.started")
55
+ throw new Error("Conversation projection must begin with session.started");
56
+ if (this.#signatures.length > 0 && event.type === "session.started")
57
+ throw new Error("Conversation projection already has a session.started event");
58
+ if (this.#eventIds.has(event.id))
59
+ throw new Error(`Event ID ${event.id} already belongs to another sequence`);
60
+ this.#signatures.push(signature);
61
+ this.#eventIds.add(event.id);
62
+ if (this.#retain(metadata)) {
63
+ this.#events.push(metadata);
64
+ this.#dirty = true;
65
+ }
66
+ return true;
67
+ }
68
+ /** Observes the complete view without consuming pending changes. */
69
+ snapshot() {
70
+ this.#reconcile();
71
+ return structuredClone({
72
+ sessionId: this.#sessionId,
73
+ throughSequence: this.#signatures.length,
74
+ sealed: this.#sealed,
75
+ messages: this.#messages,
76
+ textCompletion: this.#textCompletion,
77
+ });
78
+ }
79
+ /**
80
+ * Changes since the previous changes() call. Empty diffs still advance the source
81
+ * checkpoint. Queue batches in order; if persistence fails, retry the returned batch
82
+ * rather than calling changes() again to reconstruct it. snapshot() does not drain it.
83
+ */
84
+ changes() {
85
+ this.#reconcile();
86
+ const current = new Map();
87
+ const upsert = this.#messages.filter((message) => {
88
+ const signature = fingerprint(message);
89
+ current.set(message.id, signature);
90
+ return this.#previousMessages.get(message.id) !== signature;
91
+ });
92
+ const removeIds = [...this.#previousMessages.keys()].filter((id) => !current.has(id));
93
+ this.#previousMessages = current;
94
+ return structuredClone({
95
+ sessionId: this.#sessionId,
96
+ throughSequence: this.#signatures.length,
97
+ sealed: this.#sealed,
98
+ messageIds: this.#messages.map((message) => message.id),
99
+ upsert,
100
+ removeIds,
101
+ textCompletion: this.#textCompletion,
102
+ });
103
+ }
104
+ #retain(event) {
105
+ switch (event.type) {
106
+ case "session.started":
107
+ this.#continuousOutput = event.capabilities.outputLifecycle === "continuous";
108
+ return true;
109
+ case "session.ended":
110
+ this.#sealed = true;
111
+ return true;
112
+ case "audio.output":
113
+ if (this.#continuousOutput || this.#audioStreams.has(event.chunk.streamId))
114
+ return false;
115
+ this.#audioStreams.add(event.chunk.streamId);
116
+ return true;
117
+ case "message.input":
118
+ case "audio.interrupted":
119
+ case "transcript":
120
+ case "transcript.delta":
121
+ case "transcript.fragment":
122
+ case "backend.output.delta":
123
+ case "backend.output":
124
+ case "backend.response":
125
+ return true;
126
+ default:
127
+ // The shared fold owns tool/task interpretation, including future lifecycle events.
128
+ return event.type.startsWith("tool.") || event.type.startsWith("task.");
129
+ }
130
+ }
131
+ #reconcile() {
132
+ if (!this.#dirty)
133
+ return;
134
+ const projection = foldMessages(this.#events);
135
+ this.#messages = projection.messages;
136
+ this.#textCompletion = projection.textCompletion;
137
+ this.#dirty = false;
138
+ }
139
+ }
140
+ /** Canonical object-key order makes replay insensitive to JSON property insertion order. */
141
+ function fingerprint(value) {
142
+ return JSON.stringify(value, (_key, field) => field !== null && typeof field === "object" && !Array.isArray(field)
143
+ ? Object.fromEntries(Object.entries(field).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)))
144
+ : field);
145
+ }
@@ -1,7 +1,28 @@
1
- import { type HarnessEvent } from "../protocol/index.js";
1
+ import type { HarnessEvent } from "../protocol/index.js";
2
+ export declare class EventReplayUnavailableError extends Error {
3
+ readonly afterSequence: number;
4
+ readonly oldestAvailableSequence: number | undefined;
5
+ readonly latestSequence: number;
6
+ constructor(afterSequence: number, oldestAvailableSequence: number | undefined, latestSequence: number);
7
+ }
8
+ /** One shared replay buffer; iterators retain cursors, never private event queues. */
2
9
  export declare class ReplayEventStream {
3
10
  #private;
11
+ constructor(options?: {
12
+ maxEvents?: number | undefined;
13
+ maxAudioBytes?: number | undefined;
14
+ });
4
15
  publish(event: HarnessEvent): void;
5
- subscribe(afterSequence?: number): AsyncIterable<HarnessEvent>;
16
+ /** At most one next() may be pending per iterator. Evicted cursors fail rather than skip events. */
17
+ subscribe(afterSequence?: number): AsyncIterableIterator<HarnessEvent>;
6
18
  close(): void;
19
+ /** Drain retained history, then reject reads with the failure instead of reporting a clean end. */
20
+ fail(error: unknown): void;
21
+ stats(): {
22
+ retainedEvents: number;
23
+ retainedAudioBytes: number;
24
+ oldestSequence: number | undefined;
25
+ latestSequence: number;
26
+ subscribers: number;
27
+ };
7
28
  }