@aexol/spectral 0.0.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.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Per-connection pi SDK lifecycle.
3
+ *
4
+ * One `PiBridge` instance per active WebSocket connection. Wraps:
5
+ * - `createAgentSession` (in-memory session manager — we own persistence in
6
+ * SQLite; pi doesn't need to write its own JSONL files).
7
+ * - `subscribe` listener that translates pi `AgentSessionEvent`s into our
8
+ * own `ServerEvent` wire format and pushes them through a caller-supplied
9
+ * sink.
10
+ * - `prompt(text)` to send user input.
11
+ * - `dispose()` for clean teardown on WS close.
12
+ *
13
+ * Event mapping (pi → wire):
14
+ * - `message_start` (assistant) → emit our own `message_start` with a
15
+ * fresh UUID `messageId`. Pi's AssistantMessage has no stable id field, so
16
+ * we mint one per turn and use it for all subsequent deltas/tool events
17
+ * until `message_end`.
18
+ * - `message_update` w/ inner text_delta / thinking_delta → wire `text_delta`
19
+ * or `thinking_delta` carrying the `delta` and our `messageId`.
20
+ * - `tool_execution_start` → wire `tool_call`.
21
+ * - `tool_execution_end` → wire `tool_result`.
22
+ * - `message_end` (assistant) → wire `message_end` and persist the
23
+ * final assembled text + JSONL of every WIRE event we emitted for this
24
+ * message into SQLite. Content is taken from the final
25
+ * `AssistantMessage.content` (concatenating `text` blocks); we fall back
26
+ * to the `text_delta` accumulator when the provider didn't populate
27
+ * final blocks (e.g. deepseek via openai-compatible).
28
+ * - `agent_end` → wire `agent_end`.
29
+ *
30
+ * Persistence shape:
31
+ * `events_jsonl` is the newline-delimited JSON of the wire-format
32
+ * `ServerEvent`s we emitted for this message — NOT raw pi
33
+ * `AgentSessionEvent`s. This guarantees the client's `parseWireEvents`
34
+ * reducer can rehydrate the turn after a refresh using the exact same
35
+ * reducer it uses for the live broadcast.
36
+ *
37
+ * Errors thrown synchronously from `session.prompt()` (e.g. no model
38
+ * configured) are caught by the caller in `routes.ts` and re-emitted as
39
+ * `{type:"error"}`.
40
+ *
41
+ * History rehydration limitation:
42
+ * Pi's SDK exposes `messages` and `sendUserMessage` but reconstructing a
43
+ * fresh AgentSession from a transcript of `WireMessage`s is non-trivial —
44
+ * the SDK's internal state (tool registry, system prompt, model context)
45
+ * expects to own message creation. For the MVP we accept that pi sees a
46
+ * fresh context on reconnect; the user still sees the full transcript in
47
+ * the UI because we send `session_ready.history` from SQLite. Multi-turn
48
+ * conversations within a single WS connection work normally.
49
+ */
50
+ import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, } from "@mariozechner/pi-coding-agent";
51
+ import { randomUUID } from "node:crypto";
52
+ import aexolMcpExtension from "../extensions/aexol-mcp.js";
53
+ import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/models-fetch.js";
54
+ /**
55
+ * Synthetic provider names registered with pi's `ModelRegistry`. They route
56
+ * 100% of inference traffic through the backend (`${backendUrl}/v1`), which
57
+ * authenticates with the machine JWT and forwards to the upstream provider
58
+ * with centrally-managed API keys.
59
+ *
60
+ * Two providers (rather than one) because pi's `ProviderConfigInput.api`
61
+ * picks the request shape per registration. Backend supports both, but a
62
+ * single bag would force all models onto one shape; instead we group by
63
+ * upstream provider type.
64
+ */
65
+ const SPECTRAL_PROXY_ANTHROPIC = "spectral-proxy-anthropic";
66
+ const SPECTRAL_PROXY_OPENAI = "spectral-proxy-openai";
67
+ const SPECTRAL_PROXY_USER_MODEL = "spectral-proxy-user-model";
68
+ const SPECTRAL_PROXY_AEXOL_REFACTOR = "spectral-proxy-aexol-refactor";
69
+ /**
70
+ * Concatenate text from an `AssistantMessage.content` array. Returns the
71
+ * empty string when no text blocks are present (tool-only turns) or when
72
+ * the input is missing/non-array (defensive — pi's `message_end` always
73
+ * carries an array, but we don't want to crash on a future SDK change).
74
+ */
75
+ function extractTextFromContent(content) {
76
+ if (!Array.isArray(content))
77
+ return "";
78
+ let out = "";
79
+ for (const block of content) {
80
+ if (block &&
81
+ typeof block === "object" &&
82
+ block.type === "text" &&
83
+ typeof block.text === "string") {
84
+ out += block.text;
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+ export class PiBridge {
90
+ session;
91
+ unsubscribe;
92
+ pending;
93
+ disposed = false;
94
+ opts;
95
+ /**
96
+ * Pi's model registry. Built lazily in `start()` so we can resolve a
97
+ * `modelId` (envelope-supplied or SQLite-persisted) to a concrete `Model`
98
+ * via `registry.getAll().find(m => m.id === modelId)` before invoking
99
+ * `session.setModel()`. Phase 3 (Available Models whitelist).
100
+ */
101
+ modelRegistry;
102
+ /**
103
+ * Last `modelId` we successfully applied via `session.setModel()`, or
104
+ * `undefined` if we never applied one (pi falls back to its own settings
105
+ * file in that case, matching pre-Phase-3 behaviour). Tracked so repeated
106
+ * envelopes carrying the same modelId don't churn pi's internal state.
107
+ */
108
+ lastAppliedModelId;
109
+ constructor(opts) {
110
+ this.opts = opts;
111
+ }
112
+ /**
113
+ * Create the pi session, wire up subscription, and return.
114
+ * Throws on creation failure (caller should surface to client).
115
+ */
116
+ async start() {
117
+ if (this.disposed)
118
+ throw new Error("PiBridge already disposed");
119
+ // ResourceLoader with the Aexol MCP extension wired in via factory.
120
+ // The extension's signature `(pi: ExtensionAPI) => Promise<void>` matches
121
+ // the ExtensionFactory type exactly, so we can pass it directly.
122
+ const resourceLoader = new DefaultResourceLoader({
123
+ cwd: this.opts.cwd,
124
+ agentDir: this.opts.agentDir ?? `${process.env.HOME ?? ""}/.pi/agent`,
125
+ extensionFactories: [aexolMcpExtension],
126
+ // Skip on-disk extension/skill discovery so the server is self-contained.
127
+ noExtensions: false,
128
+ noSkills: true,
129
+ noPromptTemplates: true,
130
+ noThemes: true,
131
+ });
132
+ await resourceLoader.reload();
133
+ // In-memory session: SQLite is our source of truth.
134
+ const sessionManager = SessionManager.inMemory(this.opts.cwd);
135
+ // Build a model registry that does NOT touch ~/.pi/agent/auth.json or
136
+ // ~/.pi/agent/models.json — the backend is now the only source of
137
+ // provider credentials and the only allowed inference target. We then
138
+ // register synthetic providers (`spectral-proxy-anthropic` /
139
+ // `spectral-proxy-openai`) whose `baseUrl` points at the backend's
140
+ // `/v1` proxy and whose `apiKey` is the machine JWT. Pi will then
141
+ // POST `${baseUrl}/messages` (or `/chat/completions`) with
142
+ // `Authorization: Bearer <machineJwt>` for every turn — the backend
143
+ // verifies the JWT, looks up the requested `model` in the BaseModel
144
+ // whitelist, and forwards to the upstream provider with its own
145
+ // centrally-managed API keys. There is intentionally NO fallback to
146
+ // disk-based auth: this is the single path for `spectral serve`.
147
+ const authStorage = AuthStorage.inMemory();
148
+ this.modelRegistry = ModelRegistry.inMemory(authStorage);
149
+ const fetchModels = this.opts.fetchAllowedModels ?? defaultFetchAllowedModels;
150
+ let allowedModels;
151
+ try {
152
+ allowedModels = await fetchModels({
153
+ backendUrl: this.opts.backendUrl,
154
+ machineJwt: this.opts.machineJwt,
155
+ });
156
+ }
157
+ catch (err) {
158
+ const e = err instanceof Error ? err : new Error(String(err));
159
+ throw new Error(`Failed to fetch allowed models from backend; check SPECTRAL_BACKEND_URL ` +
160
+ `and machine JWT. Underlying error: ${e.message}`);
161
+ }
162
+ this.registerSyntheticProviders(allowedModels);
163
+ console.info(`✓ Inference routed via backend proxy (${allowedModels.length} model(s) available)`);
164
+ const result = await createAgentSession({
165
+ cwd: this.opts.cwd,
166
+ resourceLoader,
167
+ sessionManager,
168
+ authStorage,
169
+ modelRegistry: this.modelRegistry,
170
+ });
171
+ this.session = result.session;
172
+ // Subscribe BEFORE any prompt fires.
173
+ this.unsubscribe = this.session.subscribe((ev) => this.handleEvent(ev));
174
+ }
175
+ /**
176
+ * Register one synthetic provider per upstream API shape. Anthropic models
177
+ * go to `${backendUrl}/v1/messages` (Messages API); everything else (OpenAI,
178
+ * Google, Cerebras, etc.) goes to `${backendUrl}/v1/chat/completions`
179
+ * (OpenAI-compatible API). The backend supports both endpoints natively
180
+ * (verified in F1).
181
+ *
182
+ * Pi will send `Authorization: Bearer ${apiKey}` (because `authHeader: true`)
183
+ * which carries the machine JWT — the only credential the backend trusts.
184
+ *
185
+ * The `id` we register is the raw `modelId` (e.g. `claude-3-5-haiku-latest`),
186
+ * which is exactly what the backend expects in `body.model`.
187
+ */
188
+ registerSyntheticProviders(allowedModels) {
189
+ if (!this.modelRegistry)
190
+ return;
191
+ const baseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/v1`;
192
+ const anthropicModels = allowedModels.filter((m) => m.provider === "anthropic");
193
+ const openaiCompatModels = allowedModels.filter((m) => m.provider !== "anthropic" && m.provider !== "built-in");
194
+ if (anthropicModels.length > 0) {
195
+ this.modelRegistry.registerProvider(SPECTRAL_PROXY_ANTHROPIC, {
196
+ baseUrl,
197
+ apiKey: this.opts.machineJwt,
198
+ authHeader: true,
199
+ api: "anthropic-messages",
200
+ models: anthropicModels.map((m) => ({
201
+ id: m.modelId,
202
+ name: m.displayName,
203
+ api: "anthropic-messages",
204
+ // Pin provider/baseUrl explicitly so pi's ModelRegistry doesn't
205
+ // auto-derive `provider` from a slash-prefixed id (e.g. treating
206
+ // `deepseek/deepseek-v4-pro` as provider `"deepseek"`), which would
207
+ // make `hasConfiguredAuth(model)` look up the wrong provider key
208
+ // and surface "No API key for deepseek/...". Both must point back
209
+ // at our synthetic proxy provider so auth resolves to the machine JWT.
210
+ provider: SPECTRAL_PROXY_ANTHROPIC,
211
+ baseUrl,
212
+ reasoning: false,
213
+ input: ["text", "image"],
214
+ // The cost block is required by pi's typing but unused for routing;
215
+ // the backend enforces real billing/limits server-side, not pi.
216
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
217
+ contextWindow: 0,
218
+ maxTokens: 0,
219
+ })),
220
+ });
221
+ }
222
+ if (openaiCompatModels.length > 0) {
223
+ this.modelRegistry.registerProvider(SPECTRAL_PROXY_OPENAI, {
224
+ baseUrl,
225
+ apiKey: this.opts.machineJwt,
226
+ authHeader: true,
227
+ api: "openai-completions",
228
+ models: openaiCompatModels.map((m) => ({
229
+ id: m.modelId,
230
+ name: m.displayName,
231
+ api: "openai-completions",
232
+ // See anthropic batch above for rationale — without these, pi
233
+ // auto-derives `provider` from slash-prefixed ids like
234
+ // `deepseek/deepseek-v4-pro` or `meta-llama/llama-3.3-70b-instruct`,
235
+ // breaking auth lookup against our synthetic proxy provider.
236
+ provider: SPECTRAL_PROXY_OPENAI,
237
+ baseUrl,
238
+ reasoning: false,
239
+ input: ["text", "image"],
240
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
241
+ contextWindow: 0,
242
+ maxTokens: 0,
243
+ })),
244
+ });
245
+ }
246
+ // Built-in UserModel entries — custom models registered by the team.
247
+ // These route through `${backendUrl}/v1` (OpenAI-compatible) and carry
248
+ // the `name` as the model id, which the backend resolves to the actual
249
+ // provider/configuration via the UserModel record.
250
+ const userModelEntries = allowedModels.filter((m) => m.provider === "built-in");
251
+ if (userModelEntries.length > 0) {
252
+ this.modelRegistry.registerProvider(SPECTRAL_PROXY_USER_MODEL, {
253
+ baseUrl,
254
+ apiKey: this.opts.machineJwt,
255
+ authHeader: true,
256
+ api: "openai-completions",
257
+ models: userModelEntries.map((m) => ({
258
+ id: m.modelId,
259
+ name: m.displayName,
260
+ api: "openai-completions",
261
+ provider: SPECTRAL_PROXY_USER_MODEL,
262
+ baseUrl,
263
+ reasoning: false,
264
+ input: ["text", "image"],
265
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
266
+ contextWindow: 0,
267
+ maxTokens: 0,
268
+ })),
269
+ });
270
+ }
271
+ // Refactor-loop model — dedicated endpoint for Aexol agent chat toggle.
272
+ // Routes to the team user model at /models/team/aexol/refactor-loop/v1
273
+ // using machine JWT auth, same as other synthetic providers.
274
+ {
275
+ const refactorBaseUrl = `${this.opts.backendUrl.replace(/\/$/, "")}/models/built-in/refactor-loop/v1`;
276
+ this.modelRegistry.registerProvider(SPECTRAL_PROXY_AEXOL_REFACTOR, {
277
+ baseUrl: refactorBaseUrl,
278
+ apiKey: this.opts.machineJwt,
279
+ authHeader: true,
280
+ api: "openai-completions",
281
+ models: [
282
+ {
283
+ id: "__aexol_refactor_loop__",
284
+ name: "Aexol Refactor Loop",
285
+ api: "openai-completions",
286
+ baseUrl: refactorBaseUrl,
287
+ reasoning: false,
288
+ input: ["text", "image"],
289
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
290
+ contextWindow: 0,
291
+ maxTokens: 0,
292
+ },
293
+ ],
294
+ });
295
+ }
296
+ }
297
+ /**
298
+ * Apply a sticky model selection to the underlying pi session, if it
299
+ * differs from what was last applied. No-ops when:
300
+ * - `modelId` is null/undefined (caller passed nothing to apply)
301
+ * - the same modelId was already applied to this session
302
+ * - the registry can't resolve the modelId (emits an `error` wire event
303
+ * so the client surfaces the failure, but does NOT throw — the caller
304
+ * is expected to drop the prompt)
305
+ *
306
+ * Returns true when the requested model is now in effect (either because
307
+ * we just applied it or because it was already applied). Returns false
308
+ * on resolution failure so the caller can skip `prompt()` and avoid
309
+ * driving pi against the wrong model.
310
+ *
311
+ * Phase 3 (Available Models whitelist).
312
+ */
313
+ async setModel(modelId) {
314
+ if (!modelId)
315
+ return true; // nothing to apply — pi keeps its current model
316
+ if (!this.session)
317
+ throw new Error("PiBridge.start() not called");
318
+ if (this.lastAppliedModelId === modelId)
319
+ return true; // idempotent: same model already in effect
320
+ if (!this.modelRegistry) {
321
+ // Defensive: start() always populates this; if it didn't we can't
322
+ // resolve and must surface to the client rather than silently using
323
+ // pi's default.
324
+ this.opts.emit({
325
+ type: "error",
326
+ message: `Cannot apply modelId "${modelId}": model registry unavailable`,
327
+ });
328
+ return false;
329
+ }
330
+ const model = this.modelRegistry
331
+ .getAvailable()
332
+ .find((m) => m.id === modelId);
333
+ if (!model) {
334
+ this.opts.emit({
335
+ type: "error",
336
+ message: `Unknown modelId "${modelId}" — not found in pi model registry`,
337
+ });
338
+ return false;
339
+ }
340
+ try {
341
+ await this.session.setModel(model);
342
+ this.lastAppliedModelId = modelId;
343
+ return true;
344
+ }
345
+ catch (err) {
346
+ const e = err instanceof Error ? err : new Error(String(err));
347
+ this.opts.onError?.(e);
348
+ this.opts.emit({
349
+ type: "error",
350
+ message: `Failed to switch to modelId "${modelId}": ${e.message}`,
351
+ });
352
+ return false;
353
+ }
354
+ }
355
+ /**
356
+ * Forward a user message to pi. Resolves when the full turn ends.
357
+ * The caller is responsible for persisting the user message to SQLite
358
+ * BEFORE invoking this — we don't do it here because pi's `prompt` may
359
+ * fail and we still want the user message recorded.
360
+ */
361
+ async prompt(text) {
362
+ if (!this.session)
363
+ throw new Error("PiBridge.start() not called");
364
+ try {
365
+ await this.session.prompt(text);
366
+ }
367
+ catch (err) {
368
+ const e = err instanceof Error ? err : new Error(String(err));
369
+ this.opts.onError?.(e);
370
+ this.opts.emit({ type: "error", message: e.message });
371
+ }
372
+ }
373
+ dispose() {
374
+ if (this.disposed)
375
+ return;
376
+ this.disposed = true;
377
+ try {
378
+ this.unsubscribe?.();
379
+ }
380
+ catch {
381
+ // ignore
382
+ }
383
+ try {
384
+ this.session?.dispose();
385
+ }
386
+ catch {
387
+ // ignore
388
+ }
389
+ this.session = undefined;
390
+ this.unsubscribe = undefined;
391
+ this.pending = undefined;
392
+ }
393
+ // --- internals -----------------------------------------------------------
394
+ /**
395
+ * Emit a wire event AND record it in the pending message's audit log so
396
+ * the persisted JSONL exactly matches the live broadcast. Use this in
397
+ * place of `this.opts.emit` for any event that should appear in
398
+ * `events_jsonl` for the current assistant message.
399
+ */
400
+ emitAndBuffer(event) {
401
+ if (this.pending)
402
+ this.pending.wireEvents.push(event);
403
+ this.opts.emit(event);
404
+ }
405
+ /**
406
+ * Finalize the current `pending` assistant message: persist its assembled
407
+ * content + wire events (including any tool_call / tool_result events that
408
+ * arrived after `message_end`) to SQLite via `onAssistantMessageComplete`.
409
+ *
410
+ * Idempotent: marks the pending as `finalized` on first call; subsequent
411
+ * calls are no-ops (guards against double-persisting when both
412
+ * `message_start` and `agent_end` would otherwise finalize the same pending).
413
+ *
414
+ * Skips empty framing-only messages (messages with no text, thinking, or
415
+ * tool events that contribute nothing the client can render).
416
+ */
417
+ finalizePendingMessage() {
418
+ if (!this.pending || this.pending.finalized)
419
+ return;
420
+ this.pending.finalized = true;
421
+ const { messageId, text, wireEvents, content } = this.pending;
422
+ // Skip persistence for pure-framing intermediate "messages". Pi
423
+ // emits a `message_end` for each internal step (e.g. between two
424
+ // tool-calling rounds) and many of those carry no visible content
425
+ // at all — only `message_start` + `message_end` framing. Persisting
426
+ // them creates orphan rows that are dropped on hydration anyway,
427
+ // and they pollute the sidebar message counts. The decision is:
428
+ // - persist if `content` is non-empty (text the user saw), OR
429
+ // - persist if any "meaningful" wire event was buffered
430
+ // (text_delta / thinking_delta / tool_call / tool_result).
431
+ // Otherwise the message contributes nothing the client can render
432
+ // — drop it. Live broadcast is unchanged; subscribers already saw
433
+ // the framing on the wire.
434
+ const finalContent = content ?? text;
435
+ const hasMeaningfulEvent = wireEvents.some((e) => e.type === "text_delta" ||
436
+ e.type === "thinking_delta" ||
437
+ e.type === "tool_call" ||
438
+ e.type === "tool_result");
439
+ if (!finalContent && !hasMeaningfulEvent) {
440
+ console.debug("[pi-bridge] skipping empty intermediate message");
441
+ return;
442
+ }
443
+ const eventsJsonl = wireEvents.map((e) => JSON.stringify(e)).join("\n");
444
+ try {
445
+ this.opts.onAssistantMessageComplete({
446
+ messageId,
447
+ content: finalContent,
448
+ eventsJsonl,
449
+ });
450
+ }
451
+ catch (err) {
452
+ const e = err instanceof Error ? err : new Error(String(err));
453
+ this.opts.onError?.(e);
454
+ }
455
+ }
456
+ /**
457
+ * Subscriber callback. Public so tests can drive event flow without
458
+ * spinning up a real pi session — production code never calls this
459
+ * directly; pi's `subscribe()` does, via the closure registered in
460
+ * `start()`.
461
+ */
462
+ handleEvent(ev) {
463
+ if (this.disposed)
464
+ return;
465
+ switch (ev.type) {
466
+ case "message_start": {
467
+ // Only assistant messages get our message_start frame on the wire.
468
+ // User messages are persisted separately by the routes layer.
469
+ if (ev.message.role !== "assistant")
470
+ return;
471
+ // Finalize the previous pending message (if any) before starting a
472
+ // new one. This captures tool events that arrived after the previous
473
+ // `message_end` but before this `message_start`. Deferred persistence
474
+ // is necessary because pi fires tool_execution_* events BETWEEN
475
+ // messages — after the previous `message_end` nulled the pending in
476
+ // the old code, those tool events were lost from history.
477
+ this.finalizePendingMessage();
478
+ const messageId = randomUUID();
479
+ const wireEvent = {
480
+ type: "message_start",
481
+ messageId,
482
+ role: "assistant",
483
+ };
484
+ this.pending = { messageId, text: "", wireEvents: [wireEvent] };
485
+ this.opts.emit(wireEvent);
486
+ return;
487
+ }
488
+ case "message_update": {
489
+ if (!this.pending)
490
+ return;
491
+ const inner = ev.assistantMessageEvent;
492
+ if (inner.type === "text_delta") {
493
+ this.pending.text += inner.delta;
494
+ this.emitAndBuffer({
495
+ type: "text_delta",
496
+ messageId: this.pending.messageId,
497
+ delta: inner.delta,
498
+ });
499
+ }
500
+ else if (inner.type === "thinking_delta") {
501
+ this.emitAndBuffer({
502
+ type: "thinking_delta",
503
+ messageId: this.pending.messageId,
504
+ delta: inner.delta,
505
+ });
506
+ }
507
+ // Other inner types (text_start/end, toolcall_*, done, error) we
508
+ // don't surface as their own wire frames — tool calls come through
509
+ // the top-level tool_execution_* events instead.
510
+ return;
511
+ }
512
+ case "tool_execution_start": {
513
+ this.emitAndBuffer({
514
+ type: "tool_call",
515
+ messageId: this.pending?.messageId ?? "",
516
+ id: ev.toolCallId,
517
+ name: ev.toolName,
518
+ args: ev.args,
519
+ });
520
+ return;
521
+ }
522
+ case "tool_execution_end": {
523
+ this.emitAndBuffer({
524
+ type: "tool_result",
525
+ messageId: this.pending?.messageId ?? "",
526
+ id: ev.toolCallId,
527
+ result: ev.result,
528
+ isError: ev.isError,
529
+ });
530
+ return;
531
+ }
532
+ case "message_end": {
533
+ if (ev.message.role !== "assistant" || !this.pending)
534
+ return;
535
+ const { messageId, text } = this.pending;
536
+ // Prefer the assembled text from pi's final AssistantMessage
537
+ // (authoritative — providers like deepseek only populate this and
538
+ // skip per-token `text_delta`s entirely). Fall back to the
539
+ // accumulator for providers that stream deltas without re-asserting
540
+ // the final content, and finally to the empty string for tool-only
541
+ // turns that legitimately have no text.
542
+ const assembled = extractTextFromContent(ev.message.content);
543
+ const content = assembled || text;
544
+ const endEvent = { type: "message_end", messageId };
545
+ this.pending.wireEvents.push(endEvent);
546
+ this.opts.emit(endEvent);
547
+ // Defer persistence: keep `this.pending` alive so tool events that
548
+ // arrive after `message_end` (pi fires tool_execution_* events
549
+ // BETWEEN messages) are buffered into `pending.wireEvents`. We store
550
+ // the final content and persist later — when the next `message_start`
551
+ // signals a new step, or when `agent_end` closes the turn.
552
+ this.pending.content = content;
553
+ return;
554
+ }
555
+ case "agent_end": {
556
+ // Finalize the last pending message before closing the turn. After
557
+ // `agent_end`, no more tool events will arrive, so this is the final
558
+ // persistence opportunity for the current message.
559
+ this.finalizePendingMessage();
560
+ this.pending = undefined;
561
+ this.opts.emit({ type: "agent_end" });
562
+ return;
563
+ }
564
+ default:
565
+ // Other pi-internal events (turn_start, queue_update, compaction_*,
566
+ // auto_retry_*, tool_execution_update) are intentionally not on the
567
+ // wire surface for MVP and are NOT persisted — the wire format is
568
+ // the source of truth for replay.
569
+ return;
570
+ }
571
+ }
572
+ }