pi-opencode-cli-bridge 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/json.ts ADDED
@@ -0,0 +1,13 @@
1
+ export function objectRecord(value: unknown): Record<string, unknown> | undefined {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value)
3
+ ? value as Record<string, unknown>
4
+ : undefined;
5
+ }
6
+
7
+ export function safeJson(value: unknown): string {
8
+ try {
9
+ return JSON.stringify(value, null, 2);
10
+ } catch {
11
+ return String(value);
12
+ }
13
+ }
package/src/models.ts ADDED
@@ -0,0 +1,351 @@
1
+ import type { ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
2
+ import { runCapture, usesV2Cli } from "./cli.js";
3
+ import { objectRecord } from "./json.js";
4
+ import type { ModelCost, OpenCodeModelInfo } from "./types/models.js";
5
+
6
+ const DEFAULT_CONTEXT_WINDOW = 128_000;
7
+ const DEFAULT_MAX_TOKENS = 16_384;
8
+
9
+ export const DEFAULT_FREE_MODELS = [
10
+ "opencode/mimo-v2.5-free",
11
+ "opencode/nemotron-3.5-lightning-free",
12
+ "opencode/ling-3.0-flash-fin-free",
13
+ "opencode/big-pickle",
14
+ ];
15
+
16
+ const ZERO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
17
+
18
+ const THINKING_LEVELS = [
19
+ "off",
20
+ "minimal",
21
+ "low",
22
+ "medium",
23
+ "high",
24
+ "xhigh",
25
+ ] as const satisfies readonly ModelThinkingLevel[];
26
+
27
+ export function configuredModels(): string[] | undefined {
28
+ const raw = process.env.OPENCODE_PI_MODELS?.trim();
29
+ if (!raw) return undefined;
30
+ return raw
31
+ .split(/[\s,]+/)
32
+ .map((part) => part.trim())
33
+ .filter(Boolean)
34
+ .map((model) => (model.includes("/") ? model : `opencode/${model}`));
35
+ }
36
+
37
+ function modelDisplayName(model: string): string {
38
+ const [, id = model] = model.split(/\/(.*)/s);
39
+ return `OpenCode ${id}`;
40
+ }
41
+
42
+ function contextWindowFor(model: string): number {
43
+ if (model.includes("big-pickle")) return 200_000;
44
+ return DEFAULT_CONTEXT_WINDOW;
45
+ }
46
+
47
+ function maxTokensFor(model: string): number {
48
+ if (model.includes("big-pickle")) return 32_000;
49
+ return DEFAULT_MAX_TOKENS;
50
+ }
51
+
52
+ function looksFree(model: string): boolean {
53
+ return /(^opencode\/.*-free$)|(^opencode\/big-pickle$)/.test(model);
54
+ }
55
+
56
+ /** Read a non-negative finite cost figure; anything else means "unknown". */
57
+ function costFigure(value: unknown): number | undefined {
58
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
59
+ ? value
60
+ : undefined;
61
+ }
62
+
63
+ /**
64
+ * Read the real per-model cost from verbose discovery metadata (#78).
65
+ * Missing or malformed figures fall back to zero so a model is never
66
+ * reported with an invented non-zero cost.
67
+ */
68
+ export function parseModelCost(metadata: unknown): ModelCost {
69
+ const value = metadata as {
70
+ cost?: {
71
+ input?: unknown;
72
+ output?: unknown;
73
+ cache?: { read?: unknown; write?: unknown };
74
+ };
75
+ } | null;
76
+ const cost = value?.cost;
77
+ return {
78
+ input: costFigure(cost?.input) ?? 0,
79
+ output: costFigure(cost?.output) ?? 0,
80
+ cacheRead: costFigure(cost?.cache?.read) ?? 0,
81
+ cacheWrite: costFigure(cost?.cache?.write) ?? 0,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Free models are identified by zero input and output cost in the discovery
87
+ * metadata (#77). When a record carries no usable cost figures (older CLI
88
+ * output), fall back to the legacy `-free` name pattern so paid models are
89
+ * never misregistered as free.
90
+ */
91
+ export function isFreeModel(model: {
92
+ id: string;
93
+ cost: ModelCost;
94
+ costFromMetadata?: boolean;
95
+ }): boolean {
96
+ if (model.cost.input !== 0 || model.cost.output !== 0) return false;
97
+ if (model.costFromMetadata === true) return true;
98
+ return looksFree(model.id);
99
+ }
100
+
101
+ /**
102
+ * Discovery metadata carries a per-model status field (#79). Only `active`
103
+ * models are registered; a missing status is treated as active so older CLI
104
+ * output without the field keeps working.
105
+ */
106
+ export function isActiveModel(status: unknown): boolean {
107
+ if (status === undefined || status === null) return true;
108
+ return String(status).trim().toLowerCase() === "active";
109
+ }
110
+
111
+ function dedupe(values: string[]): string[] {
112
+ return [...new Set(values)];
113
+ }
114
+
115
+ function fallbackModel(id: string): OpenCodeModelInfo {
116
+ return {
117
+ id,
118
+ name: modelDisplayName(id),
119
+ reasoning: false,
120
+ image: false,
121
+ contextWindow: contextWindowFor(id),
122
+ maxTokens: maxTokensFor(id),
123
+ cost: ZERO_COST,
124
+ };
125
+ }
126
+
127
+ function positiveNumber(value: unknown, fallback: number): number {
128
+ return typeof value === "number" && Number.isFinite(value) && value > 0
129
+ ? value
130
+ : fallback;
131
+ }
132
+
133
+ function thinkingLevelMap(variants: unknown): ThinkingLevelMap {
134
+ const map: ThinkingLevelMap = {};
135
+ const names =
136
+ typeof variants === "object" && variants !== null && !Array.isArray(variants)
137
+ ? new Set(Object.keys(variants))
138
+ : new Set<string>();
139
+
140
+ for (const level of THINKING_LEVELS) {
141
+ if (level === "off") {
142
+ if (names.has("off")) map.off = "off";
143
+ else if (names.has("none")) map.off = "none";
144
+ continue;
145
+ }
146
+ map[level] = names.has(level) ? level : null;
147
+ }
148
+ if (!names.has("xhigh") && names.has("max")) map.xhigh = "max";
149
+ return map;
150
+ }
151
+
152
+ export function reasoningCliArgs(
153
+ requestedReasoning: ModelThinkingLevel | undefined,
154
+ map?: ThinkingLevelMap,
155
+ ): string[] {
156
+ // Capture blocks for recovery; --thinking controls CLI output, not model effort.
157
+ const args = ["--thinking"];
158
+ if (!requestedReasoning || requestedReasoning === "off") return args;
159
+ const variant = map?.[requestedReasoning];
160
+ if (typeof variant === "string" && variant.trim()) {
161
+ args.push("--variant", variant);
162
+ }
163
+ return args;
164
+ }
165
+
166
+ function findJsonObjectEnd(text: string, start: number): number | undefined {
167
+ let depth = 0;
168
+ let inString = false;
169
+ let escaped = false;
170
+ for (let index = start; index < text.length; index += 1) {
171
+ const character = text[index];
172
+ if (inString) {
173
+ if (escaped) escaped = false;
174
+ else if (character === "\\") escaped = true;
175
+ else if (character === '"') inString = false;
176
+ continue;
177
+ }
178
+ if (character === '"') inString = true;
179
+ else if (character === "{") depth += 1;
180
+ else if (character === "}") {
181
+ depth -= 1;
182
+ if (depth === 0) return index + 1;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ /** Parse the `model-id` + pretty-printed JSON pairs from OpenCode verbose discovery. */
189
+ export function parseVerboseModels(output: string): OpenCodeModelInfo[] {
190
+ const records = new Map<string, OpenCodeModelInfo>();
191
+ const idPattern = /^opencode\/[^\s]+\s*$/gm;
192
+ for (const match of output.matchAll(idPattern)) {
193
+ const id = match[0].trim();
194
+ const metadataStart = (match.index ?? 0) + match[0].length;
195
+ const objectStart = output.indexOf("{", metadataStart);
196
+ if (objectStart < 0) continue;
197
+
198
+ const nextId = output.slice(metadataStart).search(/^opencode\/[^\s]+\s*$/m);
199
+ if (nextId >= 0 && objectStart >= metadataStart + nextId) continue;
200
+ const objectEnd = findJsonObjectEnd(output, objectStart);
201
+ if (objectEnd === undefined) continue;
202
+
203
+ let metadata: unknown;
204
+ try {
205
+ metadata = JSON.parse(output.slice(objectStart, objectEnd));
206
+ } catch {
207
+ continue;
208
+ }
209
+ if (typeof metadata !== "object" || metadata === null) continue;
210
+
211
+ records.set(id, modelInfo(id, metadata));
212
+ }
213
+ return [...records.values()];
214
+ }
215
+
216
+ function modelInfo(id: string, metadata: unknown): OpenCodeModelInfo {
217
+ const value = metadata as {
218
+ name?: unknown;
219
+ status?: unknown;
220
+ cost?: {
221
+ input?: unknown;
222
+ output?: unknown;
223
+ cache?: { read?: unknown; write?: unknown };
224
+ };
225
+ limit?: { context?: unknown; output?: unknown };
226
+ capabilities?: { reasoning?: unknown; input?: { image?: unknown } };
227
+ variants?: unknown;
228
+ };
229
+ const reasoning = value.capabilities?.reasoning === true;
230
+ const cost = parseModelCost(value);
231
+ const costFromMetadata =
232
+ costFigure(value.cost?.input) !== undefined &&
233
+ costFigure(value.cost?.output) !== undefined;
234
+ const status =
235
+ typeof value.status === "string" && value.status.trim()
236
+ ? value.status.trim()
237
+ : undefined;
238
+ return {
239
+ id,
240
+ name:
241
+ typeof value.name === "string" && value.name.trim()
242
+ ? value.name.trim()
243
+ : modelDisplayName(id),
244
+ reasoning,
245
+ image: value.capabilities?.input?.image === true,
246
+ contextWindow: positiveNumber(value.limit?.context, contextWindowFor(id)),
247
+ maxTokens: positiveNumber(value.limit?.output, maxTokensFor(id)),
248
+ cost,
249
+ ...(costFromMetadata ? { costFromMetadata: true as const } : {}),
250
+ ...(status !== undefined ? { status } : {}),
251
+ ...(reasoning ? { thinkingLevelMap: thinkingLevelMap(value.variants) } : {}),
252
+ };
253
+ }
254
+
255
+ /** V2 exposes model metadata through its local API, not `models --verbose`. */
256
+ function parseV2Models(output: string): OpenCodeModelInfo[] {
257
+ const data = objectRecord(JSON.parse(output))?.data;
258
+ if (!Array.isArray(data)) return [];
259
+ return data.flatMap((entry) => {
260
+ const value = objectRecord(entry);
261
+ if (value?.providerID !== "opencode" || typeof value.id !== "string" ||
262
+ !value.id.trim() || /[\s/#]/.test(value.id) || value.enabled === false) return [];
263
+ const capabilities = objectRecord(value.capabilities);
264
+ const variants = Array.isArray(value.variants)
265
+ ? value.variants.flatMap((v) => typeof objectRecord(v)?.id === "string" ? [String(v.id)] : [])
266
+ : [];
267
+ const costs = Array.isArray(value.cost) ? value.cost : [];
268
+ // Any paid tier makes automatic selection unsafe, even when the base tier is free.
269
+ if (costs.some((c) => costFigure(objectRecord(c)?.input) === undefined ||
270
+ costFigure(objectRecord(c)?.output) === undefined)) return [];
271
+ const cost = costs.length ? {
272
+ input: Math.max(...costs.map(c => c.input)),
273
+ output: Math.max(...costs.map(c => c.output)),
274
+ cache: {
275
+ read: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.read) ?? 0)),
276
+ write: Math.max(...costs.map(c => costFigure(objectRecord(c.cache)?.write) ?? 0)),
277
+ },
278
+ } : undefined;
279
+ return [modelInfo(`opencode/${value.id}`, {
280
+ ...value, cost,
281
+ capabilities: {
282
+ reasoning: variants.length > 0 || typeof objectRecord(value.compatibility)?.reasoningField === "string",
283
+ input: { image: Array.isArray(capabilities?.input) && capabilities.input.includes("image") },
284
+ },
285
+ variants: Object.fromEntries(variants.map(id => [id, {}])),
286
+ })];
287
+ });
288
+ }
289
+
290
+ export async function discoverModels(opts?: {
291
+ forceDiscovery?: boolean;
292
+ }): Promise<{
293
+ models: OpenCodeModelInfo[];
294
+ time: number;
295
+ error: string | undefined;
296
+ }> {
297
+ const configured = configuredModels();
298
+ // An explicit OPENCODE_PI_MODELS list already tells us exactly which
299
+ // models to register, so the fast (non-forced) path skips spawning
300
+ // opencode entirely rather than making explicitly configured users pay a
301
+ // discovery timeout on every startup when the binary is missing or slow.
302
+ // `forceDiscovery` (used by the user-initiated /opencode-pi update
303
+ // command) still runs discovery so configured models can be enriched with
304
+ // real capability metadata (reasoning/image/limits) on request.
305
+ if (configured?.length && !opts?.forceDiscovery) {
306
+ return {
307
+ models: dedupe(configured).map(fallbackModel),
308
+ time: Date.now(),
309
+ error: undefined,
310
+ };
311
+ }
312
+
313
+ try {
314
+ const v2 = await usesV2Cli();
315
+ const result = await runCapture(v2
316
+ ? ["api", "--standalone", "get", "/api/model"]
317
+ : ["models", "opencode", "--verbose"]);
318
+ if (result.code !== 0) {
319
+ throw new Error(
320
+ result.stderr.trim() ||
321
+ `opencode models exited with code ${result.code}`,
322
+ );
323
+ }
324
+
325
+ const metadata = v2 ? parseV2Models(result.stdout) : parseVerboseModels(result.stdout);
326
+ if (metadata.length === 0) {
327
+ throw new Error("opencode model discovery returned no metadata");
328
+ }
329
+ const byId = new Map(metadata.map((model) => [model.id, model]));
330
+ const selected = configured?.length
331
+ ? dedupe(configured).map((id) => byId.get(id) ?? fallbackModel(id))
332
+ : metadata.filter(
333
+ (model) => isActiveModel(model.status) && isFreeModel(model),
334
+ );
335
+ if (selected.length === 0) {
336
+ throw new Error("opencode model discovery returned no free models");
337
+ }
338
+
339
+ return { models: selected, time: Date.now(), error: undefined };
340
+ } catch (error) {
341
+ const message = error instanceof Error ? error.message : String(error);
342
+ const fallbackIds = configured?.length
343
+ ? dedupe(configured)
344
+ : DEFAULT_FREE_MODELS;
345
+ return {
346
+ models: fallbackIds.map(fallbackModel),
347
+ time: Date.now(),
348
+ error: message,
349
+ };
350
+ }
351
+ }
package/src/process.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { once } from "node:events";
3
3
 
4
- export type ProcessOptions = {
4
+ type ProcessOptions = {
5
5
  timeoutMs: number;
6
6
  input?: string | (() => string | Promise<string>);
7
7
  env?: NodeJS.ProcessEnv;
package/src/prompt.ts ADDED
@@ -0,0 +1,181 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Context, ImageContent, Message, TextContent, Tool, ToolCall } from "@earendil-works/pi-ai";
4
+ import { safeJson } from "./json.js";
5
+
6
+ function contentToText(
7
+ content: string | (TextContent | ImageContent)[],
8
+ ): string {
9
+ if (typeof content === "string") return content;
10
+ return content
11
+ .map((item) => {
12
+ if (item.type === "text") return item.text;
13
+ return `[image in conversation: ${item.mimeType}, ${item.data.length} base64 chars]`;
14
+ })
15
+ .join("\n");
16
+ }
17
+
18
+ export function imageContentsForModel(
19
+ messages: Message[],
20
+ supportsImages: boolean,
21
+ ): ImageContent[] {
22
+ if (!supportsImages) return [];
23
+
24
+ const images: ImageContent[] = [];
25
+ for (const message of messages) {
26
+ if (message.role !== "user" && message.role !== "toolResult") continue;
27
+ if (typeof message.content === "string") continue;
28
+ for (const content of message.content) {
29
+ if (content.type === "image") images.push(content);
30
+ }
31
+ }
32
+ return images;
33
+ }
34
+
35
+ function safeImageExtension(mimeType: string): string {
36
+ const extensions: Record<string, string> = {
37
+ "image/avif": ".avif",
38
+ "image/bmp": ".bmp",
39
+ "image/gif": ".gif",
40
+ "image/jpeg": ".jpg",
41
+ "image/png": ".png",
42
+ "image/webp": ".webp",
43
+ };
44
+ return extensions[mimeType.toLowerCase()] ?? ".bin";
45
+ }
46
+
47
+ export async function writeImageFiles(
48
+ images: ImageContent[],
49
+ directory: string,
50
+ ): Promise<string[]> {
51
+ const imageDir = join(directory, "pi-images");
52
+ if (images.length > 0) await mkdir(imageDir, { recursive: true });
53
+ return Promise.all(
54
+ images.map(async (image, index) => {
55
+ if (!image.mimeType.toLowerCase().startsWith("image/")) {
56
+ throw new Error(`Unsupported image MIME type: ${image.mimeType}`);
57
+ }
58
+ const base64 = image.data.replace(/\s/g, "");
59
+ if (
60
+ !base64 ||
61
+ base64.length % 4 === 1 ||
62
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)
63
+ ) {
64
+ throw new Error(`Invalid base64 data for image ${index + 1}`);
65
+ }
66
+ const data = Buffer.from(base64, "base64");
67
+ if (data.length === 0) {
68
+ throw new Error(`Empty image data for image ${index + 1}`);
69
+ }
70
+ const path = join(
71
+ imageDir,
72
+ `image-${String(index + 1).padStart(3, "0")}${safeImageExtension(image.mimeType)}`,
73
+ );
74
+ await writeFile(path, data);
75
+ return path;
76
+ }),
77
+ );
78
+ }
79
+
80
+ function serializeMessage(message: Message): string {
81
+ if (message.role === "user" || message.role === "system") {
82
+ return `${message.role.toUpperCase()}:\n${contentToText(message.content)}`;
83
+ }
84
+
85
+ if (message.role === "toolResult") {
86
+ return [
87
+ `PI TOOL RESULT (${message.toolName}, id=${message.toolCallId}, isError=${message.isError}):`,
88
+ contentToText(message.content),
89
+ ].join("\n");
90
+ }
91
+
92
+ const parts = message.content.map(
93
+ (part: TextContent | ToolCall | { type: "thinking"; thinking: string }) => {
94
+ if (part.type === "text") return part.text;
95
+ if (part.type === "thinking")
96
+ return `<thinking>${part.thinking}</thinking>`;
97
+ return `<pi_tool_call>${safeJson({ id: part.id, name: part.name, arguments: part.arguments })}</pi_tool_call>`;
98
+ },
99
+ );
100
+ return `ASSISTANT:\n${parts.join("\n")}`;
101
+ }
102
+
103
+ export function serializeTools(tools?: Tool[]): string {
104
+ if (!tools || tools.length === 0)
105
+ return "No Pi tools are available for this turn.";
106
+ return safeJson(
107
+ tools.map((tool) => ({
108
+ name: tool.name,
109
+ description: tool.description,
110
+ parameters: tool.parameters,
111
+ })),
112
+ );
113
+ }
114
+
115
+ export function buildPrompt(context: Context): string {
116
+ const sections: string[] = [];
117
+ sections.push(`# Pi/OpenCode bridge instructions
118
+
119
+ You are being used as the model backend for Pi Coding Agent through the OpenCode CLI.
120
+ OpenCode's own tools are disabled. Do not try to use OpenCode tools.
121
+
122
+ If you need Pi to run a tool, your entire response must contain only one or more tool-call blocks separated by whitespace:
123
+ <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call>
124
+
125
+ Rules for Pi tool calls:
126
+ - Use only exact tool names listed in the "Available Pi tools" section.
127
+ - The JSON inside every marker must be valid JSON with a string "name" and an object "arguments".
128
+ - Escape every quote inside a JSON string value as \\\", especially quotes inside shell commands.
129
+ - Markers are bridge control syntax. Never quote them, explain them, put them in prose, or wrap them in Markdown fences.
130
+ - Never output a marker as an example. A marker means you are requesting immediate execution.
131
+ - Do not put any text before or after tool-call markers. Mixed prose and markers will not execute.
132
+ - NEVER use XML-style tool-use syntax (e.g., <bash>, <read>, <glob>, <grep>, <edit>, <task>, <arg_key>, <arg_value>). These are your native tool-use format and are completely disabled.
133
+ - NEVER use Claude Code XML-style markup (e.g., <bash command="...">, <read path="...">, <arg_key>, <arg_value>). This is not a tool-call and will be treated as plain text.
134
+ - If you can answer without a tool, answer normally in plain text and do not emit any marker.
135
+ - After Pi returns tool results, match them to prior tool-call IDs in the transcript, then either answer or request another Pi tool call.`);
136
+
137
+ if (context.systemPrompt?.trim()) {
138
+ sections.push(`# Pi system prompt
139
+
140
+ ${context.systemPrompt}`);
141
+ }
142
+
143
+ sections.push(`# Available Pi tools
144
+
145
+ ${serializeTools(context.tools)}`);
146
+
147
+ if (context.messages.length > 0) {
148
+ sections.push(`# Conversation transcript
149
+
150
+ ${context.messages.map(serializeMessage).join("\n\n---\n\n")}`);
151
+ } else {
152
+ sections.push("# Conversation transcript\n\n(no prior messages)");
153
+ }
154
+
155
+ sections.push("Now produce the next assistant message for Pi.");
156
+ return sections.join("\n\n---\n\n");
157
+ }
158
+
159
+ export function serializedTranscriptParts(messages: Message[]): string[] {
160
+ return messages.map(serializeMessage);
161
+ }
162
+
163
+ export function buildContinuationPrompt(deltaParts: string[]): string {
164
+ return [
165
+ "# Continued Pi/OpenCode bridge turn",
166
+ "",
167
+ "This message continues your existing OpenCode session: the Pi system prompt,",
168
+ "tool list, and earlier transcript are already in context. Do not ask for them again.",
169
+ "If you need Pi to run a tool, your entire response must contain only one or more",
170
+ "tool-call blocks separated by whitespace:",
171
+ '<pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call>',
172
+ "Use only exact tool names from the earlier tool list. Never quote markers, explain",
173
+ "them, or wrap them in Markdown fences. If you can answer without a tool, answer normally.",
174
+ "",
175
+ "# New transcript since your last response",
176
+ "",
177
+ deltaParts.join("\n\n---\n\n"),
178
+ "",
179
+ "Now produce the next assistant message for Pi.",
180
+ ].join("\n");
181
+ }