@plannotator/pi-extension 0.15.0 → 0.15.2

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,491 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/providers/opencode-sdk.ts
2
+ /**
3
+ * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
4
+ *
5
+ * Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE.
6
+ * One server per provider, shared across all sessions. The user must have the
7
+ * `opencode` CLI installed and authenticated.
8
+ */
9
+
10
+ import { BaseSession } from "../base-session.ts";
11
+ import { buildSystemPrompt } from "../context.ts";
12
+ import type {
13
+ AIMessage,
14
+ AIProvider,
15
+ AIProviderCapabilities,
16
+ AISession,
17
+ CreateSessionOptions,
18
+ OpenCodeConfig,
19
+ } from "../types.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Constants
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const PROVIDER_NAME = "opencode-sdk";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // SDK import cache — resolve once, reuse across all sessions
29
+ // ---------------------------------------------------------------------------
30
+
31
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
32
+ let sdk: any = null;
33
+
34
+ async function getSDK() {
35
+ if (!sdk) {
36
+ sdk = await import("@opencode-ai/sdk");
37
+ }
38
+ return sdk;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Provider
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export class OpenCodeProvider implements AIProvider {
46
+ readonly name = PROVIDER_NAME;
47
+ readonly capabilities: AIProviderCapabilities = {
48
+ fork: true,
49
+ resume: true,
50
+ streaming: true,
51
+ tools: true,
52
+ };
53
+ models?: Array<{ id: string; label: string; default?: boolean }>;
54
+
55
+ private config: OpenCodeConfig;
56
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
57
+ private server: { url: string; close: () => void } | null = null;
58
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
59
+ private client: any = null;
60
+ private startPromise: Promise<void> | null = null;
61
+
62
+ constructor(config: OpenCodeConfig) {
63
+ this.config = config;
64
+ }
65
+
66
+ /** Lazy-spawn the OpenCode server and create the HTTP client. */
67
+ async ensureServer(): Promise<void> {
68
+ if (this.server && this.client) return;
69
+ this.startPromise ??= this.doStart().catch((err) => {
70
+ this.startPromise = null;
71
+ throw err;
72
+ });
73
+ return this.startPromise;
74
+ }
75
+
76
+ private async doStart(): Promise<void> {
77
+ const { createOpencodeServer, createOpencodeClient } = await getSDK();
78
+
79
+ this.server = await createOpencodeServer({
80
+ hostname: this.config.hostname ?? "127.0.0.1",
81
+ ...(this.config.port != null && { port: this.config.port }),
82
+ timeout: 15_000,
83
+ });
84
+
85
+ this.client = createOpencodeClient({
86
+ baseUrl: this.server!.url,
87
+ directory: this.config.cwd ?? process.cwd(),
88
+ });
89
+ }
90
+
91
+ async createSession(options: CreateSessionOptions): Promise<AISession> {
92
+ await this.ensureServer();
93
+
94
+ const result = await this.client.session.create({
95
+ query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() },
96
+ });
97
+ const sessionData = result.data;
98
+
99
+ const session = new OpenCodeSession({
100
+ sessionId: sessionData.id,
101
+ systemPrompt: buildSystemPrompt(options.context),
102
+ client: this.client,
103
+ model: options.model,
104
+ parentSessionId: null,
105
+ });
106
+ return session;
107
+ }
108
+
109
+ async forkSession(options: CreateSessionOptions): Promise<AISession> {
110
+ await this.ensureServer();
111
+
112
+ const parentId = options.context.parent?.sessionId;
113
+ if (!parentId) {
114
+ throw new Error("Fork requires a parent session ID.");
115
+ }
116
+
117
+ const result = await this.client.session.fork({
118
+ path: { id: parentId },
119
+ });
120
+ const sessionData = result.data;
121
+
122
+ return new OpenCodeSession({
123
+ sessionId: sessionData.id,
124
+ systemPrompt: buildSystemPrompt(options.context),
125
+ client: this.client,
126
+ model: options.model,
127
+ parentSessionId: parentId,
128
+ });
129
+ }
130
+
131
+ async resumeSession(sessionId: string): Promise<AISession> {
132
+ await this.ensureServer();
133
+
134
+ // Verify session exists
135
+ await this.client.session.get({ path: { id: sessionId } });
136
+
137
+ return new OpenCodeSession({
138
+ sessionId,
139
+ systemPrompt: null,
140
+ client: this.client,
141
+ model: undefined,
142
+ parentSessionId: null,
143
+ });
144
+ }
145
+
146
+ dispose(): void {
147
+ if (this.server) {
148
+ this.server.close();
149
+ this.server = null;
150
+ this.client = null;
151
+ this.startPromise = null;
152
+ }
153
+ }
154
+
155
+ /** Fetch available models from OpenCode. Call before registering the provider. */
156
+ async fetchModels(): Promise<void> {
157
+ try {
158
+ await this.ensureServer();
159
+
160
+ const result = await this.client.provider.list({
161
+ query: { directory: this.config.cwd ?? process.cwd() },
162
+ });
163
+ const data = result.data;
164
+ const connected = new Set(data.connected as string[]);
165
+ const allProviders = data.all as Array<{
166
+ id: string;
167
+ models: Record<string, { id: string; providerID: string; name: string }>;
168
+ }>;
169
+
170
+ const models: Array<{ id: string; label: string; default?: boolean }> = [];
171
+ for (const provider of allProviders) {
172
+ if (!connected.has(provider.id)) continue;
173
+ for (const model of Object.values(provider.models)) {
174
+ models.push({
175
+ id: `${model.providerID}/${model.id}`,
176
+ label: model.name ?? model.id,
177
+ });
178
+ }
179
+ }
180
+
181
+ if (models.length > 0) {
182
+ // Mark first model as default
183
+ models[0].default = true;
184
+ this.models = models;
185
+ }
186
+ } catch {
187
+ // OpenCode not configured or no models available
188
+ }
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Session
194
+ // ---------------------------------------------------------------------------
195
+
196
+ interface SessionConfig {
197
+ sessionId: string;
198
+ systemPrompt: string | null;
199
+ // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
200
+ client: any;
201
+ /** Model in "providerID/modelID" format. */
202
+ model?: string;
203
+ parentSessionId: string | null;
204
+ }
205
+
206
+ class OpenCodeSession extends BaseSession {
207
+ private config: SessionConfig;
208
+
209
+ constructor(config: SessionConfig) {
210
+ super({
211
+ parentSessionId: config.parentSessionId,
212
+ initialId: config.sessionId,
213
+ });
214
+ this.config = config;
215
+ this._resolvedId = config.sessionId;
216
+ }
217
+
218
+ async *query(prompt: string): AsyncIterable<AIMessage> {
219
+ const started = this.startQuery();
220
+ if (!started) {
221
+ yield BaseSession.BUSY_ERROR;
222
+ return;
223
+ }
224
+ const { gen } = started;
225
+
226
+ try {
227
+ // Build model param if specified
228
+ let modelParam: { providerID: string; modelID: string } | undefined;
229
+ if (this.config.model) {
230
+ const [providerID, ...rest] = this.config.model.split("/");
231
+ const modelID = rest.join("/");
232
+ if (providerID && modelID) {
233
+ modelParam = { providerID, modelID };
234
+ }
235
+ }
236
+
237
+ // Subscribe to SSE events
238
+ const { stream } = await this.config.client.event.subscribe();
239
+
240
+ try {
241
+ // Send prompt asynchronously
242
+ try {
243
+ await this.config.client.session.promptAsync({
244
+ path: { id: this.config.sessionId },
245
+ body: {
246
+ ...(!this._firstQuerySent &&
247
+ this.config.systemPrompt && {
248
+ system: this.config.systemPrompt,
249
+ }),
250
+ ...(modelParam && { model: modelParam }),
251
+ parts: [{ type: "text", text: prompt }],
252
+ },
253
+ });
254
+ } catch (err) {
255
+ yield {
256
+ type: "error",
257
+ error: `OpenCode rejected prompt: ${err instanceof Error ? err.message : String(err)}`,
258
+ code: "opencode_prompt_rejected",
259
+ };
260
+ return;
261
+ }
262
+ this._firstQuerySent = true;
263
+
264
+ // Drain SSE events filtered by session ID
265
+ for await (const event of stream) {
266
+ const eventType = event.type as string;
267
+ const props = event.properties as Record<string, unknown> | undefined;
268
+ if (!props) continue;
269
+
270
+ // Filter: only events for our session
271
+ const eventSessionId =
272
+ (props.sessionID as string) ??
273
+ ((props.info as Record<string, unknown>)?.sessionID as string) ??
274
+ ((props.part as Record<string, unknown>)?.sessionID as string);
275
+ if (eventSessionId && eventSessionId !== this.config.sessionId) continue;
276
+
277
+ const mapped = mapOpenCodeEvent(eventType, props, this.id);
278
+ for (const msg of mapped) {
279
+ yield msg;
280
+ if (msg.type === "result" || (msg.type === "error" && isTerminalEvent(eventType))) {
281
+ return;
282
+ }
283
+ }
284
+ }
285
+ } finally {
286
+ stream.return?.();
287
+ }
288
+ } catch (err) {
289
+ yield {
290
+ type: "error",
291
+ error: err instanceof Error ? err.message : String(err),
292
+ code: "provider_error",
293
+ };
294
+ } finally {
295
+ this.endQuery(gen);
296
+ }
297
+ }
298
+
299
+ abort(): void {
300
+ this.config.client.session
301
+ .abort({ path: { id: this.config.sessionId } })
302
+ .catch(() => {});
303
+ super.abort();
304
+ }
305
+
306
+ respondToPermission(
307
+ requestId: string,
308
+ allow: boolean,
309
+ _message?: string,
310
+ ): void {
311
+ this.config.client
312
+ .postSessionIdPermissionsPermissionId({
313
+ path: { id: this.config.sessionId, permissionID: requestId },
314
+ body: { response: allow ? "once" : "reject" },
315
+ })
316
+ .catch(() => {});
317
+ }
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Event mapping
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /** Returns true for events that should terminate the query when mapped to an error. */
325
+ function isTerminalEvent(eventType: string): boolean {
326
+ return eventType === "session.error" || eventType === "session.status";
327
+ }
328
+
329
+ /**
330
+ * Map an OpenCode SSE event to AIMessage[].
331
+ *
332
+ * Key events:
333
+ * message.part.delta → text_delta (streaming text)
334
+ * message.part.updated → tool_use / tool_result (tool lifecycle)
335
+ * permission.updated → permission_request
336
+ * session.status → result (when idle)
337
+ * message.updated → error (when message has error)
338
+ */
339
+ export function mapOpenCodeEvent(
340
+ eventType: string,
341
+ props: Record<string, unknown>,
342
+ sessionId: string,
343
+ ): AIMessage[] {
344
+ switch (eventType) {
345
+ case "message.part.delta": {
346
+ const field = props.field as string;
347
+ const delta = props.delta as string;
348
+ if (field === "text" && delta) {
349
+ return [{ type: "text_delta", delta }];
350
+ }
351
+ return [];
352
+ }
353
+
354
+ case "message.part.updated": {
355
+ const part = props.part as Record<string, unknown>;
356
+ if (!part) return [];
357
+
358
+ const partType = part.type as string;
359
+
360
+ if (partType === "tool") {
361
+ const state = part.state as Record<string, unknown>;
362
+ if (!state) return [];
363
+
364
+ const status = state.status as string;
365
+ const callID = (part.callID as string) ?? (part.id as string);
366
+ const toolName = part.tool as string;
367
+
368
+ switch (status) {
369
+ case "running":
370
+ return [
371
+ {
372
+ type: "tool_use",
373
+ toolName: toolName ?? "unknown",
374
+ toolInput: (state.input as Record<string, unknown>) ?? {},
375
+ toolUseId: callID,
376
+ },
377
+ ];
378
+
379
+ case "completed": {
380
+ const output = (state.output as string) ?? "";
381
+ return [
382
+ {
383
+ type: "tool_result",
384
+ toolUseId: callID,
385
+ result: output,
386
+ },
387
+ ];
388
+ }
389
+
390
+ case "error": {
391
+ const error = (state.error as string) ?? "Tool execution failed";
392
+ return [
393
+ {
394
+ type: "tool_result",
395
+ toolUseId: callID,
396
+ result: `[Error] ${error}`,
397
+ },
398
+ ];
399
+ }
400
+
401
+ default:
402
+ return [];
403
+ }
404
+ }
405
+
406
+ return [];
407
+ }
408
+
409
+ case "permission.updated": {
410
+ const id = props.id as string;
411
+ const permType = props.type as string;
412
+ const title = props.title as string;
413
+ const callID = props.callID as string;
414
+ const metadata = (props.metadata as Record<string, unknown>) ?? {};
415
+
416
+ return [
417
+ {
418
+ type: "permission_request",
419
+ requestId: id,
420
+ toolName: permType ?? "unknown",
421
+ toolInput: metadata,
422
+ title: title ?? permType,
423
+ toolUseId: callID ?? id,
424
+ },
425
+ ];
426
+ }
427
+
428
+ case "session.status": {
429
+ const status = props.status as Record<string, unknown>;
430
+ if (status?.type === "idle") {
431
+ return [
432
+ {
433
+ type: "result",
434
+ sessionId,
435
+ success: true,
436
+ },
437
+ ];
438
+ }
439
+ return [];
440
+ }
441
+
442
+ case "session.error": {
443
+ const error = props.error as Record<string, unknown>;
444
+ const message =
445
+ (error?.message as string) ?? (props.message as string) ?? "Session error";
446
+ return [
447
+ {
448
+ type: "error",
449
+ error: message,
450
+ code: "opencode_session_error",
451
+ },
452
+ ];
453
+ }
454
+
455
+ case "message.updated": {
456
+ const info = props.info as Record<string, unknown>;
457
+ if (!info) return [];
458
+
459
+ const msgError = info.error as Record<string, unknown>;
460
+ if (msgError) {
461
+ const errorData = msgError.data as Record<string, unknown>;
462
+ const message =
463
+ (errorData?.message as string) ??
464
+ (msgError.name as string) ??
465
+ "Message error";
466
+ return [
467
+ {
468
+ type: "error",
469
+ error: message,
470
+ code: "opencode_message_error",
471
+ },
472
+ ];
473
+ }
474
+ return [];
475
+ }
476
+
477
+ default:
478
+ return [];
479
+ }
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Factory registration
484
+ // ---------------------------------------------------------------------------
485
+
486
+ import { registerProviderFactory } from "../provider.ts";
487
+
488
+ registerProviderFactory(
489
+ PROVIDER_NAME,
490
+ async (config) => new OpenCodeProvider(config as OpenCodeConfig),
491
+ );
@@ -0,0 +1,111 @@
1
+ // @generated — DO NOT EDIT. Source: packages/ai/providers/pi-events.ts
2
+ /**
3
+ * Pi event mapping — shared between Bun and Node.js Pi providers.
4
+ *
5
+ * Pure function, no runtime-specific dependencies.
6
+ */
7
+
8
+ import type { AIMessage } from "../types.ts";
9
+
10
+ /**
11
+ * Map a Pi AgentEvent (received as JSONL) to AIMessage[].
12
+ *
13
+ * Pi event hierarchy:
14
+ * agent_start > turn_start > message_start > message_update* > message_end
15
+ * > tool_execution_start > tool_execution_end > turn_end > agent_end
16
+ *
17
+ * We extract:
18
+ * - text_delta from message_update.assistantMessageEvent
19
+ * - tool_use from toolcall_end
20
+ * - tool_result from tool_execution_end
21
+ * - result from agent_end
22
+ */
23
+ export function mapPiEvent(
24
+ event: Record<string, unknown>,
25
+ sessionId: string,
26
+ ): AIMessage[] {
27
+ const eventType = event.type as string;
28
+
29
+ switch (eventType) {
30
+ case "message_update": {
31
+ const ame = event.assistantMessageEvent as
32
+ | Record<string, unknown>
33
+ | undefined;
34
+ if (!ame) return [];
35
+
36
+ const subType = ame.type as string;
37
+
38
+ switch (subType) {
39
+ case "text_delta":
40
+ return [{ type: "text_delta", delta: ame.delta as string }];
41
+
42
+ case "toolcall_end": {
43
+ const tc = ame.toolCall as Record<string, unknown>;
44
+ if (!tc) return [];
45
+ return [
46
+ {
47
+ type: "tool_use",
48
+ toolName: tc.name as string,
49
+ toolInput: (tc.arguments as Record<string, unknown>) ?? {},
50
+ toolUseId: tc.id as string,
51
+ },
52
+ ];
53
+ }
54
+
55
+ case "error": {
56
+ const partial = ame.error as Record<string, unknown> | undefined;
57
+ const errorMessage =
58
+ (partial?.errorMessage as string) ?? "Stream error";
59
+ return [
60
+ { type: "error", error: errorMessage, code: "pi_stream_error" },
61
+ ];
62
+ }
63
+
64
+ default:
65
+ return [];
66
+ }
67
+ }
68
+
69
+ case "tool_execution_end": {
70
+ const result = event.result;
71
+ const isError = event.isError as boolean;
72
+ const resultStr =
73
+ result == null
74
+ ? ""
75
+ : typeof result === "string"
76
+ ? result
77
+ : JSON.stringify(result);
78
+
79
+ return [
80
+ {
81
+ type: "tool_result",
82
+ toolUseId: event.toolCallId as string,
83
+ result: isError
84
+ ? `[Error] ${resultStr || "Tool execution failed"}`
85
+ : resultStr,
86
+ },
87
+ ];
88
+ }
89
+
90
+ case "agent_end":
91
+ return [
92
+ {
93
+ type: "result",
94
+ sessionId,
95
+ success: true,
96
+ },
97
+ ];
98
+
99
+ case "process_exited":
100
+ return [
101
+ {
102
+ type: "error",
103
+ error: "Pi process exited unexpectedly.",
104
+ code: "pi_process_exit",
105
+ },
106
+ ];
107
+
108
+ default:
109
+ return [];
110
+ }
111
+ }