@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.
- package/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
|
@@ -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
|
+
}
|