moonpi 0.4.3 → 0.4.5

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/README.md CHANGED
@@ -54,7 +54,7 @@ npm install -g @mariozechner/pi-coding-agent
54
54
  Then, install the `moonpi` extension set directly via `pi`:
55
55
 
56
56
  ```bash
57
- pi install git:github.com/galatolofederico/moonpi@v0.4.1
57
+ pi install npm:moonpi
58
58
  ```
59
59
 
60
60
 
@@ -182,7 +182,177 @@ Example output:
182
182
 
183
183
  At startup, a notification shows which files are currently selected for injection.
184
184
 
185
- ### Configuration
185
+ ### `/context:clear`
186
+
187
+ Deselects all currently active context files (both `/pick`-selected and auto-discovered). After clearing, no files are injected into the prompt until you run `/pick` again.
188
+
189
+ Example output:
190
+
191
+ ```
192
+ Cleared context file selection (3 file(s) deselected). Use /pick to select files.
193
+ ```
194
+
195
+ ## Custom Providers
196
+
197
+ moonpi includes the support from some custom providers, and provides slash commands to manage custom providers in `~/.pi/agent/models.json`.
198
+
199
+
200
+ ### Synthetic Provider
201
+
202
+ Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
203
+
204
+ Configure credentials with either:
205
+
206
+ ```bash
207
+ export SYNTHETIC_API_KEY=...
208
+ ```
209
+
210
+ or run:
211
+
212
+ ```text
213
+ /login
214
+ ```
215
+
216
+ Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
217
+
218
+ ![Synthetic quotas output](assets/screenshots/moonpi-syn.png)
219
+
220
+ #### Web Search
221
+
222
+ When authenticated with Synthetic, moonpi makes a `web_search` tool available to the agent. This tool uses the [Synthetic search API](https://docs.synthetic.new) to perform zero-data-retention web searches and return results with title, URL, published date, and text excerpt.
223
+
224
+ The tool is **only visible to the LLM when logged in with Synthetic** — it is not registered at all when no API key is configured, so the model never sees it or knows it exists.
225
+
226
+ To disable the search tool even when logged in, set `synthetic.search.enabled` to `false` in your config:
227
+
228
+ ```json
229
+ {
230
+ "synthetic": {
231
+ "search": {
232
+ "enabled": false
233
+ }
234
+ }
235
+ }
236
+ ```
237
+
238
+ ### Managing Custom Providers
239
+
240
+ moonpi provides five slash commands to manage custom providers in `~/.pi/agent/models.json`:
241
+
242
+ #### `/custom-provider:add-provider`
243
+
244
+ Interactive wizard that adds a new custom provider. Prompts for:
245
+
246
+ 1. **Provider name** — a unique identifier (e.g. `my-vllm`)
247
+ 2. **API type** — select from all supported APIs (`openai-completions`, `anthropic-messages`, `google-generative-ai`, etc.)
248
+ 3. **Base URL** — the API endpoint (sensible defaults per API type)
249
+ 4. **API key** — your API key or an environment variable name
250
+
251
+ Example `models.json` result:
252
+
253
+ ```json
254
+ {
255
+ "providers": {
256
+ "my-vllm": {
257
+ "baseUrl": "http://127.0.0.1:8000/v1",
258
+ "api": "openai-completions",
259
+ "apiKey": "none",
260
+ "models": []
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ #### `/custom-provider:add-model`
267
+
268
+ Adds a model to an existing custom provider. Prompts for:
269
+
270
+ 1. **Provider** — select from existing custom providers
271
+ 2. **Model ID** — the model identifier (e.g. `Qwen/Qwen3-27B`)
272
+ 3. **Display name** — optional human-readable name
273
+ 4. **Advanced options** — optional configuration for reasoning, context window, max tokens, image input, and API type override
274
+
275
+ #### `/custom-provider:scan-models`
276
+
277
+ Auto-detects models from an OpenAI-compatible provider endpoint. Works with providers using `openai-completions` or `openai-responses` API.
278
+
279
+ 1. **Select provider** — choose from OpenAI-compatible custom providers
280
+ 2. **Scan** — fetches `/v1/models` from the provider's base URL
281
+ 3. **Select models** — checkbox UI shows all discovered models; already-added models are greyed out
282
+ - `Space` to toggle individual models
283
+ - `a`/`A` to select/deselect all
284
+ - `Enter` to confirm (adds all new models if none selected)
285
+ 4. Auto-fills `contextWindow` from `max_model_len` when available
286
+
287
+ #### `/custom-provider:remove-provider`
288
+
289
+ Removes a custom provider and all its models from `models.json`. Asks for confirmation before deleting.
290
+
291
+ #### `/custom-provider:remove-model`
292
+
293
+ Removes a single model from a custom provider. Prompts for the provider, then the model to remove, with confirmation.
294
+
295
+ After any change, run `/reload` to refresh pi's model registry and make new models available in `/model`.
296
+
297
+ ## Moonpi loop
298
+
299
+ moonpi includes sprint-oriented loop for larger projects.
300
+
301
+ ### `/sprint:init`
302
+
303
+ Creates a new sprint for a larger project.
304
+
305
+ This command asks **one question**: the sprint objective.
306
+
307
+ It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
308
+
309
+ ```txt
310
+ ./sprints/<sprint_number>/SPRINT.md
311
+ ./sprints/<sprint_number>/TASKS.md
312
+ ```
313
+
314
+ The sprint is divided into phases.
315
+
316
+ Each phase includes tasks and verification steps that define when the phase is complete.
317
+
318
+ The goal is to turn a vague big project into a concrete, phased execution plan.
319
+
320
+ ### `/sprint:loop`
321
+
322
+ Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
323
+
324
+ The loop works like this:
325
+
326
+ 1. complete one phase
327
+ 2. mark completed tasks in:
328
+
329
+ ```txt
330
+ ./sprints/<sprint_number>/TASKS.md
331
+ ```
332
+
333
+ 3. compact the conversation/context
334
+ 4. proceed to the next phase
335
+ 5. repeat until the sprint is complete
336
+
337
+ The model signals the end of a phase by calling a special `end_phase` tool.
338
+
339
+ This keeps long-running projects simple, resumable, and grounded in actual files.
340
+
341
+ ### Does it work?
342
+
343
+ Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
344
+
345
+ ```text
346
+ create WebOS a fully functional web-based operating system with apps, games and everything
347
+ ```
348
+
349
+ https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
350
+
351
+
352
+ And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
353
+
354
+
355
+ ## Configuration
186
356
 
187
357
  Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
188
358
 
@@ -191,6 +361,11 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
191
361
  "defaultMode": "auto",
192
362
  "preserveExternalTools": false,
193
363
  "customEditor": true,
364
+ "synthetic": {
365
+ "search": {
366
+ "enabled": true
367
+ }
368
+ },
194
369
  "contextFiles": {
195
370
  "enabled": true,
196
371
  "fileNames": ["README.md", "SPECS.md", "SPRINT.md"],
@@ -230,6 +405,12 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
230
405
  | `preserveExternalTools` | `false` | When `true`, tools registered by other extensions are kept alongside moonpi tools when applying mode tool restrictions |
231
406
  | `customEditor` | `true` | When `false`, moonpi skips installing its mode-colored editor, preserving editor customizations from other extensions |
232
407
 
408
+ #### Synthetic
409
+
410
+ | Field | Default | Description |
411
+ | --- | --- | --- |
412
+ | `synthetic.search.enabled` | `true` | When `false`, the `web_search` tool is not registered even if logged in with Synthetic |
413
+
233
414
  #### Keybindings
234
415
 
235
416
  | Field | Default | Description |
@@ -316,89 +497,6 @@ If the model tries to write to a file without reading it first, the write tool r
316
497
 
317
498
  This prevents careless overwrites and forces the agent to inspect the current state of a file before modifying it.
318
499
 
319
- ## Moonpi loop
320
-
321
- moonpi includes sprint-oriented loop for larger projects.
322
-
323
- ### `/sprint:init`
324
-
325
- Creates a new sprint for a larger project.
326
-
327
- This command asks **one question**: the sprint objective.
328
-
329
- It then delegates SPRINT.md and TASKS.md creation to the agent, which writes:
330
-
331
- ```txt
332
- ./sprints/<sprint_number>/SPRINT.md
333
- ./sprints/<sprint_number>/TASKS.md
334
- ```
335
-
336
- The sprint is divided into phases.
337
-
338
- Each phase includes tasks and verification steps that define when the phase is complete.
339
-
340
- The goal is to turn a vague big project into a concrete, phased execution plan.
341
-
342
- ### `/sprint:loop`
343
-
344
- Runs the latest sprint phase-by-phase. Automatically picks the most recent sprint.
345
-
346
- The loop works like this:
347
-
348
- 1. complete one phase
349
- 2. mark completed tasks in:
350
-
351
- ```txt
352
- ./sprints/<sprint_number>/TASKS.md
353
- ```
354
-
355
- 3. compact the conversation/context
356
- 4. proceed to the next phase
357
- 5. repeat until the sprint is complete
358
-
359
- The model signals the end of a phase by calling a special `end_phase` tool.
360
-
361
- This keeps long-running projects simple, resumable, and grounded in actual files.
362
-
363
- ### Does it work?
364
-
365
- Watch a drastically sped-up video of `Qwen/Qwen3.6-27B` working unattended for over an hour on this sprint prompt:
366
-
367
- ```text
368
- create WebOS a fully functional web-based operating system with apps, games and everything
369
- ```
370
-
371
- https://github.com/user-attachments/assets/92670a55-a3c4-4c31-a4a2-0afc449f0137
372
-
373
-
374
- And judge the result yourself [here](https://qwen36-27b-moonpi-webos.netlify.app/).
375
-
376
- ## Custom Providers
377
-
378
- moonpi includes the support from some custom providers.
379
-
380
-
381
- ### Synthetic Provider
382
-
383
- Moonpi registers Synthetic as the `synthetic` provider using the OpenAI-compatible endpoint.
384
-
385
- Configure credentials with either:
386
-
387
- ```bash
388
- export SYNTHETIC_API_KEY=...
389
- ```
390
-
391
- or run:
392
-
393
- ```text
394
- /login
395
- ```
396
-
397
- Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your weekly token and rolling 5h usage quotas:
398
-
399
- ![Synthetic quotas output](assets/screenshots/moonpi-syn.png)
400
-
401
-
402
500
  ## Why moonpi?
403
501
 
404
502
  moonpi is not trying to be a giant agent framework.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moonpi",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Opinionated set of extensions for pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/config.ts CHANGED
@@ -42,6 +42,11 @@ export const DEFAULT_CONFIG: MoonpiConfig = {
42
42
  defaultMode: "auto",
43
43
  preserveExternalTools: true,
44
44
  customEditor: true,
45
+ synthetic: {
46
+ search: {
47
+ enabled: true,
48
+ },
49
+ },
45
50
  contextFiles: {
46
51
  enabled: true,
47
52
  fileNames: ["README.md", "SPECS.md", "SPRINT.md"],
@@ -125,6 +130,7 @@ function mergeConfig(base: MoonpiConfig, raw: Record<string, unknown> | undefine
125
130
  defaultMode: base.defaultMode,
126
131
  preserveExternalTools: base.preserveExternalTools,
127
132
  customEditor: base.customEditor,
133
+ synthetic: { ...base.synthetic },
128
134
  contextFiles: { ...base.contextFiles },
129
135
  guards: { ...base.guards },
130
136
  keybindings: { ...base.keybindings },
@@ -134,6 +140,14 @@ function mergeConfig(base: MoonpiConfig, raw: Record<string, unknown> | undefine
134
140
  if (typeof raw.preserveExternalTools === "boolean") next.preserveExternalTools = raw.preserveExternalTools;
135
141
  if (typeof raw.customEditor === "boolean") next.customEditor = raw.customEditor;
136
142
 
143
+ if (isRecord(raw.synthetic)) {
144
+ if (isRecord(raw.synthetic.search)) {
145
+ if (typeof raw.synthetic.search.enabled === "boolean") {
146
+ next.synthetic.search.enabled = raw.synthetic.search.enabled;
147
+ }
148
+ }
149
+ }
150
+
137
151
  if (isRecord(raw.contextFiles)) {
138
152
  const context = raw.contextFiles;
139
153
  if (typeof context.enabled === "boolean") next.contextFiles.enabled = context.enabled;
@@ -484,6 +484,21 @@ export function installContextFiles(pi: ExtensionAPI, controller: MoonpiControll
484
484
  },
485
485
  });
486
486
 
487
+ pi.registerCommand("context:clear", {
488
+ description: "Deselect all context files (clear /pick and auto-discovered files)",
489
+ handler: async (_args, ctx) => {
490
+ if (!controller.config.contextFiles.enabled) {
491
+ ctx.ui.notify("moonpi context file injection is disabled in /moonpi:settings.", "warning");
492
+ return;
493
+ }
494
+
495
+ const previousCount = getEffectiveSelectedContextFilePaths(ctx.cwd, controller).length;
496
+ controller.state.selectedContextFilePaths = [];
497
+ controller.persist();
498
+ ctx.ui.notify(`Cleared context file selection (${previousCount} file(s) deselected). Use /pick to select files.`, "info");
499
+ },
500
+ });
501
+
487
502
  pi.on("session_start", async (_event, ctx) => {
488
503
  controller.restoreFromSession(ctx);
489
504
  const discovery = controller.state.selectedContextFilePaths === undefined ? findDefaultContextFilePaths(ctx.cwd, controller) : undefined;
@@ -0,0 +1,673 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
5
+ import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
6
+
7
+ // =========================================================================
8
+ // models.json helpers
9
+ // =========================================================================
10
+
11
+ /** The API types that custom providers can use. */
12
+ const KNOWN_APIS = [
13
+ "openai-completions",
14
+ "openai-responses",
15
+ "anthropic-messages",
16
+ "google-generative-ai",
17
+ "google-vertex",
18
+ "mistral-conversations",
19
+ "bedrock-converse-stream",
20
+ "azure-openai-responses",
21
+ "openai-codex-responses",
22
+ ] as const;
23
+
24
+ /** APIs that are OpenAI-compatible and support the /v1/models endpoint. */
25
+ const OPENAI_COMPAT_APIS = new Set([
26
+ "openai-completions",
27
+ "openai-responses",
28
+ ]);
29
+
30
+ interface ModelDefinition {
31
+ id: string;
32
+ name?: string;
33
+ api?: string;
34
+ baseUrl?: string;
35
+ reasoning?: boolean;
36
+ input?: string[];
37
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
38
+ contextWindow?: number;
39
+ maxTokens?: number;
40
+ headers?: Record<string, string>;
41
+ compat?: Record<string, unknown>;
42
+ }
43
+
44
+ interface ProviderConfig {
45
+ name?: string;
46
+ baseUrl?: string;
47
+ apiKey?: string;
48
+ api?: string;
49
+ headers?: Record<string, string>;
50
+ compat?: Record<string, unknown>;
51
+ authHeader?: boolean;
52
+ models?: ModelDefinition[];
53
+ modelOverrides?: Record<string, unknown>;
54
+ }
55
+
56
+ interface ModelsConfig {
57
+ providers: Record<string, ProviderConfig>;
58
+ }
59
+
60
+ function getModelsJsonPath(): string {
61
+ return join(getAgentDir(), "models.json");
62
+ }
63
+
64
+ function readModelsJson(): ModelsConfig {
65
+ const filePath = getModelsJsonPath();
66
+ if (!existsSync(filePath)) {
67
+ return { providers: {} };
68
+ }
69
+ try {
70
+ const raw = readFileSync(filePath, "utf-8");
71
+ // Strip // line comments (same as pi's model-registry.ts)
72
+ const stripped = raw
73
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
74
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
75
+ return JSON.parse(stripped) as ModelsConfig;
76
+ } catch {
77
+ return { providers: {} };
78
+ }
79
+ }
80
+
81
+ function writeModelsJson(config: ModelsConfig): void {
82
+ const filePath = getModelsJsonPath();
83
+ const dir = join(filePath, "..");
84
+ if (!existsSync(dir)) {
85
+ mkdirSync(dir, { recursive: true });
86
+ }
87
+ writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
88
+ }
89
+
90
+ // =========================================================================
91
+ // Command: /custom-provider:add-provider
92
+ // =========================================================================
93
+
94
+ async function addProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
95
+ const config = readModelsJson();
96
+
97
+ // 1. Provider name
98
+ const providerName = await ctx.ui.input("Provider name (e.g. my-vllm)", "my-provider");
99
+ if (!providerName?.trim()) {
100
+ ctx.ui.notify("Cancelled: no provider name given.", "warning");
101
+ return;
102
+ }
103
+ const name = providerName.trim();
104
+
105
+ if (config.providers[name]) {
106
+ const overwrite = await ctx.ui.confirm(
107
+ `Provider "${name}" already exists. Overwrite?`,
108
+ `Existing provider has baseUrl: ${config.providers[name]!.baseUrl ?? "none"}, api: ${config.providers[name]!.api ?? "none"}`,
109
+ );
110
+ if (!overwrite) {
111
+ ctx.ui.notify("Cancelled.", "info");
112
+ return;
113
+ }
114
+ }
115
+
116
+ // 2. API type
117
+ const selectedApi = await ctx.ui.select("Select API type", [...KNOWN_APIS]);
118
+ if (!selectedApi) {
119
+ ctx.ui.notify("Cancelled: no API type selected.", "warning");
120
+ return;
121
+ }
122
+
123
+ // 3. Base URL
124
+ const defaultBaseUrl = selectedApi === "anthropic-messages"
125
+ ? "https://api.anthropic.com"
126
+ : selectedApi === "google-generative-ai"
127
+ ? "https://generativelanguage.googleapis.com"
128
+ : "";
129
+ const baseUrl = await ctx.ui.input("Base URL", defaultBaseUrl || "http://localhost:8000/v1");
130
+ if (!baseUrl?.trim()) {
131
+ ctx.ui.notify("Cancelled: no base URL given.", "warning");
132
+ return;
133
+ }
134
+
135
+ // 4. API key
136
+ const apiKey = await ctx.ui.input("API key (or env var name like MY_API_KEY)", "none");
137
+ if (!apiKey?.trim()) {
138
+ ctx.ui.notify("Cancelled: no API key given.", "warning");
139
+ return;
140
+ }
141
+
142
+ // Build the provider config
143
+ const providerConfig: ProviderConfig = {
144
+ baseUrl: baseUrl.trim(),
145
+ api: selectedApi,
146
+ apiKey: apiKey.trim(),
147
+ models: [],
148
+ };
149
+
150
+ config.providers[name] = providerConfig;
151
+ writeModelsJson(config);
152
+
153
+ ctx.ui.notify(
154
+ `Provider "${name}" added to models.json.\n` +
155
+ ` API: ${selectedApi}\n` +
156
+ ` Base URL: ${baseUrl.trim()}\n` +
157
+ ` API Key: ${apiKey.trim()}\n\n` +
158
+ `Use /custom-provider:add-model to add models, or /custom-provider:scan-models to auto-detect them.\n` +
159
+ `Run /reload to refresh pi's model registry.`,
160
+ "info",
161
+ );
162
+ }
163
+
164
+ // =========================================================================
165
+ // Command: /custom-provider:add-model
166
+ // =========================================================================
167
+
168
+ async function addModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
169
+ const config = readModelsJson();
170
+ const providerNames = Object.keys(config.providers);
171
+
172
+ if (providerNames.length === 0) {
173
+ ctx.ui.notify(
174
+ "No custom providers found. Use /custom-provider:add-provider first.",
175
+ "warning",
176
+ );
177
+ return;
178
+ }
179
+
180
+ // 1. Select provider
181
+ const selectedProvider = await ctx.ui.select("Select provider to add model to", providerNames);
182
+ if (!selectedProvider) {
183
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
184
+ return;
185
+ }
186
+
187
+ const provider = config.providers[selectedProvider]!;
188
+
189
+ // 2. Model ID
190
+ const modelId = await ctx.ui.input("Model ID (e.g. Qwen/Qwen3-27B)", "");
191
+ if (!modelId?.trim()) {
192
+ ctx.ui.notify("Cancelled: no model ID given.", "warning");
193
+ return;
194
+ }
195
+
196
+ // Check for duplicates
197
+ const existingModels = provider.models ?? [];
198
+ if (existingModels.some((m) => m.id === modelId.trim())) {
199
+ ctx.ui.notify(`Model "${modelId.trim()}" already exists in provider "${selectedProvider}".`, "warning");
200
+ return;
201
+ }
202
+
203
+ // 3. Optional fields
204
+ const modelName = await ctx.ui.input("Model display name (optional, press Enter to skip)", modelId.trim());
205
+
206
+ const wantsAdvanced = await ctx.ui.confirm("Configure advanced options?", "Context window, max tokens, reasoning, input types");
207
+ const modelDef: ModelDefinition = {
208
+ id: modelId.trim(),
209
+ ...(modelName?.trim() && modelName.trim() !== modelId.trim() ? { name: modelName.trim() } : {}),
210
+ };
211
+
212
+ if (wantsAdvanced) {
213
+ // API override
214
+ const overrideApi = await ctx.ui.confirm("Override API type for this model?", `Provider default: ${provider.api ?? "unknown"}`);
215
+ if (overrideApi) {
216
+ const modelApi = await ctx.ui.select("Select model API type", [...KNOWN_APIS]);
217
+ if (modelApi) modelDef.api = modelApi;
218
+ }
219
+
220
+ // Reasoning
221
+ const reasoning = await ctx.ui.confirm("Does this model support reasoning/thinking?", "");
222
+ modelDef.reasoning = reasoning;
223
+
224
+ // Context window
225
+ const ctxWindow = await ctx.ui.input("Context window (tokens)", "128000");
226
+ if (ctxWindow?.trim()) {
227
+ const val = parseInt(ctxWindow.trim(), 10);
228
+ if (!Number.isNaN(val) && val > 0) modelDef.contextWindow = val;
229
+ }
230
+
231
+ // Max tokens
232
+ const maxTokens = await ctx.ui.input("Max output tokens", "16384");
233
+ if (maxTokens?.trim()) {
234
+ const val = parseInt(maxTokens.trim(), 10);
235
+ if (!Number.isNaN(val) && val > 0) modelDef.maxTokens = val;
236
+ }
237
+
238
+ // Input types
239
+ const hasImage = await ctx.ui.confirm("Supports image input?", "");
240
+ modelDef.input = hasImage ? ["text", "image"] : ["text"];
241
+ }
242
+
243
+ // Add model to provider
244
+ if (!provider.models) provider.models = [];
245
+ provider.models.push(modelDef);
246
+ writeModelsJson(config);
247
+
248
+ const summary = [
249
+ `Model "${modelDef.id}" added to provider "${selectedProvider}" in models.json.`,
250
+ ];
251
+ if (modelDef.name) summary.push(` Display name: ${modelDef.name}`);
252
+ if (modelDef.reasoning !== undefined) summary.push(` Reasoning: ${modelDef.reasoning}`);
253
+ if (modelDef.contextWindow) summary.push(` Context window: ${modelDef.contextWindow}`);
254
+ if (modelDef.maxTokens) summary.push(` Max tokens: ${modelDef.maxTokens}`);
255
+ summary.push("");
256
+ summary.push("Run /reload to refresh pi's model registry.");
257
+
258
+ ctx.ui.notify(summary.join("\n"), "info");
259
+ }
260
+
261
+ // =========================================================================
262
+ // Command: /custom-provider:scan-models
263
+ // =========================================================================
264
+
265
+ interface RemoteModel {
266
+ id: string;
267
+ object?: string;
268
+ created?: number;
269
+ owned_by?: string;
270
+ max_model_len?: number;
271
+ }
272
+
273
+ interface ModelsListResponse {
274
+ object: string;
275
+ data: RemoteModel[];
276
+ }
277
+
278
+ async function scanModelsCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
279
+ const config = readModelsJson();
280
+ const providerNames = Object.keys(config.providers);
281
+
282
+ if (providerNames.length === 0) {
283
+ ctx.ui.notify(
284
+ "No custom providers found. Use /custom-provider:add-provider first.",
285
+ "warning",
286
+ );
287
+ return;
288
+ }
289
+
290
+ // Filter to providers that are OpenAI-compatible
291
+ const compatProviders = providerNames.filter((name) => {
292
+ const api = config.providers[name]!.api;
293
+ return !api || OPENAI_COMPAT_APIS.has(api);
294
+ });
295
+
296
+ if (compatProviders.length === 0) {
297
+ ctx.ui.notify(
298
+ "No OpenAI-compatible providers found. Scan requires providers using openai-completions or openai-responses API.",
299
+ "warning",
300
+ );
301
+ return;
302
+ }
303
+
304
+ // 1. Select provider
305
+ const selectedProvider = await ctx.ui.select(
306
+ "Select provider to scan for models",
307
+ compatProviders,
308
+ );
309
+ if (!selectedProvider) {
310
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
311
+ return;
312
+ }
313
+
314
+ const provider = config.providers[selectedProvider]!;
315
+ const baseUrl = provider.baseUrl?.replace(/\/+$/, "");
316
+
317
+ if (!baseUrl) {
318
+ ctx.ui.notify(`Provider "${selectedProvider}" has no base URL configured.`, "error");
319
+ return;
320
+ }
321
+
322
+ // 2. Fetch models from /v1/models or /models
323
+ const modelsEndpoint = baseUrl.endsWith("/v1") || baseUrl.endsWith("/v1/")
324
+ ? `${baseUrl}/models`
325
+ : `${baseUrl}/v1/models`;
326
+
327
+ ctx.ui.notify(`Scanning ${modelsEndpoint}...`, "info");
328
+
329
+ let response: ModelsListResponse;
330
+ try {
331
+ const headers: Record<string, string> = {
332
+ "Accept": "application/json",
333
+ };
334
+ if (provider.apiKey && provider.apiKey !== "none") {
335
+ headers["Authorization"] = `Bearer ${provider.apiKey}`;
336
+ }
337
+
338
+ const res = await fetch(modelsEndpoint, { headers, signal: AbortSignal.timeout(15_000) });
339
+ if (!res.ok) {
340
+ ctx.ui.notify(
341
+ `Failed to fetch models: HTTP ${res.status} ${res.statusText}\n` +
342
+ `Endpoint: ${modelsEndpoint}`,
343
+ "error",
344
+ );
345
+ return;
346
+ }
347
+ const body = await res.json() as ModelsListResponse;
348
+ if (!body.data || !Array.isArray(body.data)) {
349
+ ctx.ui.notify(
350
+ `Unexpected response format. Expected { "data": [...] }.\n` +
351
+ `Got: ${JSON.stringify(body).slice(0, 500)}`,
352
+ "error",
353
+ );
354
+ return;
355
+ }
356
+ response = body;
357
+ } catch (err) {
358
+ ctx.ui.notify(
359
+ `Failed to connect to ${modelsEndpoint}: ${err instanceof Error ? err.message : String(err)}`,
360
+ "error",
361
+ );
362
+ return;
363
+ }
364
+
365
+ if (response.data.length === 0) {
366
+ ctx.ui.notify("No models found at the endpoint.", "info");
367
+ return;
368
+ }
369
+
370
+ // 3. Show discovered models and let user select which to add
371
+ const existingIds = new Set((provider.models ?? []).map((m) => m.id));
372
+ const modelOptions = response.data.map((m) => {
373
+ const exists = existingIds.has(m.id) ? " [already added]" : "";
374
+ const maxLen = m.max_model_len ? ` (ctx: ${m.max_model_len})` : "";
375
+ const owner = m.owned_by ? ` - ${m.owned_by}` : "";
376
+ return `${m.id}${maxLen}${owner}${exists}`;
377
+ });
378
+
379
+ // Select which models to add
380
+ const selectedModels = await ctx.ui.custom<string[]>((tui, theme, _kb, done) => {
381
+ let cursorIndex = 0;
382
+ const selected = new Set<number>();
383
+ let cachedLines: string[] | undefined;
384
+ // Only allow selecting models that aren't already added
385
+ const addableIndices = response.data
386
+ .map((m, i) => ({ i, alreadyAdded: existingIds.has(m.id) }))
387
+ .filter((x) => !x.alreadyAdded)
388
+ .map((x) => x.i);
389
+
390
+ function refresh() {
391
+ cachedLines = undefined;
392
+ tui.requestRender();
393
+ }
394
+
395
+ function handleInput(data: string) {
396
+ if (matchesKey(data, Key.up)) {
397
+ cursorIndex = Math.max(0, cursorIndex - 1);
398
+ refresh();
399
+ return;
400
+ }
401
+ if (matchesKey(data, Key.down)) {
402
+ cursorIndex = Math.min(modelOptions.length - 1, cursorIndex + 1);
403
+ refresh();
404
+ return;
405
+ }
406
+ if (matchesKey(data, Key.space)) {
407
+ if (addableIndices.includes(cursorIndex)) {
408
+ if (selected.has(cursorIndex)) {
409
+ selected.delete(cursorIndex);
410
+ } else {
411
+ selected.add(cursorIndex);
412
+ }
413
+ }
414
+ refresh();
415
+ return;
416
+ }
417
+ if (matchesKey(data, Key.enter)) {
418
+ // If nothing selected, select all addable models
419
+ const toAdd = selected.size > 0
420
+ ? [...selected]
421
+ : addableIndices;
422
+ const ids = toAdd
423
+ .sort((a, b) => a - b)
424
+ .map((i) => response.data[i]!.id);
425
+ done(ids);
426
+ return;
427
+ }
428
+ if (matchesKey(data, Key.escape)) {
429
+ done([]);
430
+ }
431
+ // a/A to select/deselect all
432
+ if (data === "a") {
433
+ for (const i of addableIndices) selected.add(i);
434
+ refresh();
435
+ return;
436
+ }
437
+ if (data === "A") {
438
+ selected.clear();
439
+ refresh();
440
+ return;
441
+ }
442
+ }
443
+
444
+ function render(width: number): string[] {
445
+ if (cachedLines) return cachedLines;
446
+ const lines: string[] = [];
447
+ const add = (s: string) => lines.push(truncateToWidth(s, width));
448
+
449
+ add(theme.fg("accent", "─".repeat(width)));
450
+ add(theme.fg("text", ` Found ${response.data.length} models at ${selectedProvider}`));
451
+ lines.push("");
452
+
453
+ for (let i = 0; i < modelOptions.length; i++) {
454
+ const isCursor = i === cursorIndex;
455
+ const isAddable = addableIndices.includes(i);
456
+ const isChecked = selected.has(i);
457
+ const prefix = isCursor ? theme.fg("accent", "> ") : " ";
458
+ const box = isAddable
459
+ ? (isChecked ? theme.fg("success", "☑") : theme.fg("dim", "☐"))
460
+ : theme.fg("dim", "■");
461
+ const label = isAddable
462
+ ? theme.fg("text", modelOptions[i]!)
463
+ : theme.fg("dim", modelOptions[i]!);
464
+ add(`${prefix}${box} ${label}`);
465
+ }
466
+
467
+ lines.push("");
468
+ const selectedCount = selected.size;
469
+ if (selectedCount > 0) {
470
+ add(theme.fg("dim", ` ${selectedCount} selected • Space toggle • Enter confirm • a/A select/deselect all • Esc cancel`));
471
+ } else {
472
+ add(theme.fg("dim", ` Enter to add all new models • Space toggle • a/A select/deselect all • Esc cancel`));
473
+ }
474
+
475
+ add(theme.fg("accent", "─".repeat(width)));
476
+ cachedLines = lines;
477
+ return lines;
478
+ }
479
+
480
+ return {
481
+ render,
482
+ invalidate: () => { cachedLines = undefined; },
483
+ handleInput,
484
+ };
485
+ });
486
+
487
+ if (selectedModels.length === 0) {
488
+ ctx.ui.notify("No models selected. Cancelled.", "info");
489
+ return;
490
+ }
491
+
492
+ // 4. Add selected models to provider
493
+ if (!provider.models) provider.models = [];
494
+
495
+ let addedCount = 0;
496
+ let skippedCount = 0;
497
+ for (const modelId of selectedModels) {
498
+ if (existingIds.has(modelId)) {
499
+ skippedCount++;
500
+ continue;
501
+ }
502
+ const remoteModel = response.data.find((m) => m.id === modelId);
503
+ const modelDef: ModelDefinition = {
504
+ id: modelId,
505
+ ...(remoteModel?.max_model_len ? { contextWindow: remoteModel.max_model_len } : {}),
506
+ };
507
+ provider.models.push(modelDef);
508
+ existingIds.add(modelId);
509
+ addedCount++;
510
+ }
511
+
512
+ writeModelsJson(config);
513
+
514
+ ctx.ui.notify(
515
+ `Scan complete for "${selectedProvider}".\n` +
516
+ ` Added: ${addedCount} model(s)\n` +
517
+ (skippedCount > 0 ? ` Skipped (already existing): ${skippedCount}\n` : "") +
518
+ `\nRun /reload to refresh pi's model registry.`,
519
+ "info",
520
+ );
521
+ }
522
+
523
+ // =========================================================================
524
+ // Command: /custom-provider:remove-provider
525
+ // =========================================================================
526
+
527
+ async function removeProviderCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
528
+ const config = readModelsJson();
529
+ const providerNames = Object.keys(config.providers);
530
+
531
+ if (providerNames.length === 0) {
532
+ ctx.ui.notify(
533
+ "No custom providers found in models.json.",
534
+ "warning",
535
+ );
536
+ return;
537
+ }
538
+
539
+ // 1. Select provider to remove
540
+ const selectedProvider = await ctx.ui.select("Select provider to remove", providerNames);
541
+ if (!selectedProvider) {
542
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
543
+ return;
544
+ }
545
+
546
+ // 2. Confirm deletion
547
+ const provider = config.providers[selectedProvider]!;
548
+ const modelCount = provider.models?.length ?? 0;
549
+ const confirmMessage = modelCount > 0
550
+ ? `Remove provider "${selectedProvider}" and its ${modelCount} model(s)?`
551
+ : `Remove provider "${selectedProvider}"?`;
552
+
553
+ const confirmed = await ctx.ui.confirm(
554
+ confirmMessage,
555
+ `This will update models.json. Run /reload after to refresh pi's model registry.`,
556
+ );
557
+ if (!confirmed) {
558
+ ctx.ui.notify("Cancelled.", "info");
559
+ return;
560
+ }
561
+
562
+ // 3. Remove provider
563
+ delete config.providers[selectedProvider];
564
+ writeModelsJson(config);
565
+
566
+ ctx.ui.notify(
567
+ `Provider "${selectedProvider}" removed from models.json.\n` +
568
+ `Run /reload to refresh pi's model registry.`,
569
+ "info",
570
+ );
571
+ }
572
+
573
+ // =========================================================================
574
+ // Command: /custom-provider:remove-model
575
+ // =========================================================================
576
+
577
+ async function removeModelCommand(_args: string, ctx: ExtensionCommandContext): Promise<void> {
578
+ const config = readModelsJson();
579
+ const providerNames = Object.keys(config.providers);
580
+
581
+ if (providerNames.length === 0) {
582
+ ctx.ui.notify(
583
+ "No custom providers found in models.json.",
584
+ "warning",
585
+ );
586
+ return;
587
+ }
588
+
589
+ // 1. Select provider
590
+ const selectedProvider = await ctx.ui.select("Select provider to remove a model from", providerNames);
591
+ if (!selectedProvider) {
592
+ ctx.ui.notify("Cancelled: no provider selected.", "warning");
593
+ return;
594
+ }
595
+
596
+ const provider = config.providers[selectedProvider]!;
597
+ const models = provider.models ?? [];
598
+
599
+ if (models.length === 0) {
600
+ ctx.ui.notify(
601
+ `Provider "${selectedProvider}" has no models.`,
602
+ "warning",
603
+ );
604
+ return;
605
+ }
606
+
607
+ // 2. Select model to remove
608
+ const modelLabels = models.map((m) => m.name ? `${m.id} (${m.name})` : m.id);
609
+ const selectedModelLabel = await ctx.ui.select("Select model to remove", modelLabels);
610
+ if (!selectedModelLabel) {
611
+ ctx.ui.notify("Cancelled: no model selected.", "warning");
612
+ return;
613
+ }
614
+
615
+ const selectedIndex = modelLabels.indexOf(selectedModelLabel);
616
+ if (selectedIndex === -1) {
617
+ ctx.ui.notify("Cancelled: could not resolve model.", "warning");
618
+ return;
619
+ }
620
+
621
+ const modelToRemove = models[selectedIndex]!;
622
+
623
+ // 3. Confirm
624
+ const confirmed = await ctx.ui.confirm(
625
+ `Remove model "${modelToRemove.id}" from provider "${selectedProvider}"?`,
626
+ "This will update models.json. Run /reload after to refresh pi's model registry.",
627
+ );
628
+ if (!confirmed) {
629
+ ctx.ui.notify("Cancelled.", "info");
630
+ return;
631
+ }
632
+
633
+ // 4. Remove model
634
+ provider.models = models.filter((_, i) => i !== selectedIndex);
635
+ writeModelsJson(config);
636
+
637
+ ctx.ui.notify(
638
+ `Model "${modelToRemove.id}" removed from provider "${selectedProvider}" in models.json.\n` +
639
+ `Run /reload to refresh pi's model registry.`,
640
+ "info",
641
+ );
642
+ }
643
+
644
+ // =========================================================================
645
+ // Install all custom-provider commands
646
+ // =========================================================================
647
+
648
+ export function installCustomProviderCommands(pi: ExtensionAPI): void {
649
+ pi.registerCommand("custom-provider:add-provider", {
650
+ description: "Add a custom provider to models.json (interactive wizard)",
651
+ handler: addProviderCommand,
652
+ });
653
+
654
+ pi.registerCommand("custom-provider:add-model", {
655
+ description: "Add a model to an existing custom provider in models.json",
656
+ handler: addModelCommand,
657
+ });
658
+
659
+ pi.registerCommand("custom-provider:scan-models", {
660
+ description: "Scan an OpenAI-compatible provider endpoint for available models and add them to models.json",
661
+ handler: scanModelsCommand,
662
+ });
663
+
664
+ pi.registerCommand("custom-provider:remove-provider", {
665
+ description: "Remove a custom provider and all its models from models.json",
666
+ handler: removeProviderCommand,
667
+ });
668
+
669
+ pi.registerCommand("custom-provider:remove-model", {
670
+ description: "Remove a model from an existing custom provider in models.json",
671
+ handler: removeModelCommand,
672
+ });
673
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { formatConfig } from "./config.js";
3
3
  import { installContextFiles } from "./context-files.js";
4
+ import { installCustomProviderCommands } from "./custom-providers.js";
4
5
  import { installGuards } from "./guards.js";
5
6
  import { MoonpiController } from "./modes.js";
6
7
  import { formatTodoList } from "./state.js";
@@ -22,6 +23,7 @@ export default async function moonpi(pi: ExtensionAPI): Promise<void> {
22
23
  installGuards(pi, controller);
23
24
  installContextFiles(pi, controller);
24
25
  installSprintWorkflow(pi, controller);
26
+ installCustomProviderCommands(pi);
25
27
 
26
28
  pi.registerCommand("moonpi:mode", {
27
29
  description: "Switch moonpi mode: plan, act, auto, fast",
@@ -138,7 +140,7 @@ ${todoList}`);
138
140
 
139
141
  // Synthetic is optional; keep core Moonpi mode hooks installed even if provider setup fails.
140
142
  try {
141
- await installSynthetic(pi);
143
+ await installSynthetic(pi, controller);
142
144
  } catch {
143
145
  // Ignore optional provider setup failures.
144
146
  }
package/src/modes.ts CHANGED
@@ -40,6 +40,7 @@ function latestSnapshot(entries: SessionEntry[]): MoonpiSnapshot | undefined {
40
40
  export class MoonpiController {
41
41
  readonly state = new MoonpiState();
42
42
  config: MoonpiConfig = loadMoonpiConfig(process.cwd());
43
+ syntheticAuthenticated = false;
43
44
  private terminalInputUnsubscribe: (() => void) | undefined;
44
45
 
45
46
  constructor(private readonly pi: ExtensionAPI) {}
@@ -144,9 +145,14 @@ export class MoonpiController {
144
145
  }
145
146
 
146
147
  getToolsForCurrentMode(): string[] {
147
- if (!this.config.preserveExternalTools) return STABLE_MOONPI_TOOLS;
148
- const externalTools = this.pi.getActiveTools().filter((toolName) => !MOONPI_TOOL_NAMES.has(toolName));
149
- return [...new Set([...STABLE_MOONPI_TOOLS, ...externalTools])];
148
+ let tools = this.config.preserveExternalTools
149
+ ? [...new Set([...STABLE_MOONPI_TOOLS, ...this.pi.getActiveTools().filter((toolName) => !MOONPI_TOOL_NAMES.has(toolName))])]
150
+ : [...STABLE_MOONPI_TOOLS];
151
+
152
+ if (this.syntheticAuthenticated && this.config.synthetic.search.enabled) {
153
+ tools.push("web_search");
154
+ }
155
+ return tools;
150
156
  }
151
157
 
152
158
  buildModePrompt(): string {
package/src/synthetic.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ProviderModelConfig } from "@mariozechner/pi-coding-agent";
2
+ import { Text } from "@mariozechner/pi-tui";
3
+ import { Type, type Static } from "typebox";
4
+ import type { MoonpiController } from "./modes.js";
2
5
  import { SYNTHETIC_MODELS_FALLBACK, mergeWithFallback, parseSyntheticModels, readCachedModels, writeCachedModels } from "./synthetic-models.js";
3
6
 
4
7
  const SYNTHETIC_PROVIDER = "synthetic";
@@ -6,6 +9,7 @@ const SYNTHETIC_API_KEY_ENV = "SYNTHETIC_API_KEY";
6
9
  const SYNTHETIC_OPENAI_BASE_URL = "https://api.synthetic.new/openai/v1";
7
10
  const SYNTHETIC_MODELS_URL = "https://api.synthetic.new/openai/v1/models";
8
11
  const SYNTHETIC_QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
12
+ const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
9
13
  const FETCH_TIMEOUT_MS = 15_000;
10
14
 
11
15
  type QuotasErrorKind = "cancelled" | "timeout" | "config" | "http" | "network";
@@ -238,6 +242,155 @@ async function getSyntheticApiKey(ctx: ExtensionCommandContext | ExtensionContex
238
242
  return storedKey ?? process.env[SYNTHETIC_API_KEY_ENV] ?? "";
239
243
  }
240
244
 
245
+ // =========================================================================
246
+ // Search API
247
+ // =========================================================================
248
+
249
+ interface SearchResult {
250
+ url: string;
251
+ title: string;
252
+ text: string;
253
+ published?: string;
254
+ }
255
+
256
+ interface SearchResponse {
257
+ results: SearchResult[];
258
+ }
259
+
260
+ const SearchParamsSchema = Type.Object({
261
+ query: Type.String({ description: "Search query" }),
262
+ });
263
+
264
+ type SearchParams = Static<typeof SearchParamsSchema>;
265
+
266
+ interface SearchDetails {
267
+ query: string;
268
+ resultCount: number;
269
+ }
270
+
271
+ function formatSearchResults(query: string, results: SearchResult[]): string {
272
+ if (results.length === 0) {
273
+ return `No results found for "${query}".`;
274
+ }
275
+
276
+ const lines: string[] = [`Found ${results.length} result${results.length === 1 ? "" : "s"} for "${query}":\n`];
277
+
278
+ for (let i = 0; i < results.length; i++) {
279
+ const result = results[i]!;
280
+ const num = i + 1;
281
+ lines.push(`${num}. **${result.title}**`);
282
+ lines.push(` ${result.url}`);
283
+ if (result.published) {
284
+ try {
285
+ const date = new Date(result.published);
286
+ if (!Number.isNaN(date.getTime())) {
287
+ lines.push(` Published: ${date.toISOString().split("T")[0]}`);
288
+ }
289
+ } catch {
290
+ // Skip unparseable dates
291
+ }
292
+ }
293
+ lines.push(` ${result.text}`);
294
+ lines.push("");
295
+ }
296
+
297
+ return lines.join("\n");
298
+ }
299
+
300
+ function registerSearchTool(pi: ExtensionAPI): void {
301
+ pi.registerTool<typeof SearchParamsSchema, SearchDetails>({
302
+ name: "web_search",
303
+ label: "web search",
304
+ description:
305
+ "Search the web using the Synthetic search API. Returns a list of results with title, URL, and text excerpt.",
306
+ promptSnippet: "Search the web for information",
307
+ promptGuidelines: [
308
+ "Use web_search when you need to find information on the web that you don't already know.",
309
+ "Prefer web_search over guessing URLs or making assumptions about external documentation.",
310
+ ],
311
+ parameters: SearchParamsSchema,
312
+ async execute(_toolCallId, params: SearchParams, signal, _onUpdate, ctx) {
313
+ const apiKey = await getSyntheticApiKey(ctx);
314
+ if (!apiKey) {
315
+ return {
316
+ content: [{ type: "text", text: "Error: Synthetic API key not available. Please re-authenticate with /login synthetic." }],
317
+ details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
318
+ };
319
+ }
320
+
321
+ const signals = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
322
+ if (signal) signals.push(signal);
323
+ const combinedSignal = AbortSignal.any(signals);
324
+
325
+ try {
326
+ const response = await fetch(SYNTHETIC_SEARCH_URL, {
327
+ method: "POST",
328
+ headers: {
329
+ Authorization: `Bearer ${apiKey}`,
330
+ "Content-Type": "application/json",
331
+ "X-Title": "moonpi",
332
+ },
333
+ body: JSON.stringify({ query: params.query }),
334
+ signal: combinedSignal,
335
+ });
336
+
337
+ if (!response.ok) {
338
+ let message = response.statusText;
339
+ const body = await response.text();
340
+ if (body.length > 0) {
341
+ try {
342
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
343
+ if (typeof parsed.error === "string") message = parsed.error;
344
+ else if (typeof parsed.message === "string") message = parsed.message;
345
+ else message = body;
346
+ } catch {
347
+ message = body;
348
+ }
349
+ }
350
+ return {
351
+ content: [{ type: "text", text: `Error: Synthetic search API returned ${response.status}: ${message}` }],
352
+ details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
353
+ };
354
+ }
355
+
356
+ const data = (await response.json()) as SearchResponse;
357
+ const results = data.results ?? [];
358
+ return {
359
+ content: [{ type: "text", text: formatSearchResults(params.query, results) }],
360
+ details: { query: params.query, resultCount: results.length } satisfies SearchDetails,
361
+ };
362
+ } catch (error: unknown) {
363
+ const aborted = combinedSignal.aborted || (error instanceof DOMException && error.name === "AbortError");
364
+ if (aborted) {
365
+ if (isTimeoutReason(combinedSignal.reason)) {
366
+ return {
367
+ content: [{ type: "text", text: "Error: Synthetic search request timed out." }],
368
+ details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
369
+ };
370
+ }
371
+ return {
372
+ content: [{ type: "text", text: "Error: Synthetic search request was cancelled." }],
373
+ details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
374
+ };
375
+ }
376
+
377
+ const message = error instanceof Error ? error.message : "Unknown error";
378
+ return {
379
+ content: [{ type: "text", text: `Error: Synthetic search failed: ${message}` }],
380
+ details: { query: params.query, resultCount: 0 } satisfies SearchDetails,
381
+ };
382
+ }
383
+ },
384
+ renderResult(result, _options, theme) {
385
+ const text = result.content
386
+ .filter((item) => item.type === "text")
387
+ .map((item) => item.text)
388
+ .join("\n");
389
+ return new Text(theme.fg("toolOutput", text), 0, 0);
390
+ },
391
+ });
392
+ }
393
+
241
394
  async function fetchSyntheticModels(apiKey: string, signal?: AbortSignal): Promise<ProviderModelConfig[] | null> {
242
395
  if (!apiKey) return null;
243
396
 
@@ -309,7 +462,16 @@ async function refreshLiveModels(pi: ExtensionAPI, apiKey: string, signal?: Abor
309
462
  return fetchedModels;
310
463
  }
311
464
 
312
- export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
465
+ export async function installSynthetic(pi: ExtensionAPI, controller: MoonpiController): Promise<void> {
466
+ let searchToolRegistered = false;
467
+ const searchEnabled = () => controller.config.synthetic.search.enabled;
468
+
469
+ function ensureSearchToolRegistered(): void {
470
+ if (searchToolRegistered || !searchEnabled()) return;
471
+ searchToolRegistered = true;
472
+ registerSearchTool(pi);
473
+ }
474
+
313
475
  // Fast path: read cached models from disk (synchronous, no network).
314
476
  // This ensures models are available immediately at init time, which is
315
477
  // critical for model restoration on session resume. Without this, models
@@ -327,6 +489,8 @@ export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
327
489
  // resolved later in the session_start handler.
328
490
  const apiKey = process.env[SYNTHETIC_API_KEY_ENV] ?? "";
329
491
  if (apiKey) {
492
+ controller.syntheticAuthenticated = true;
493
+ ensureSearchToolRegistered();
330
494
  // Fire and forget – we already registered with cached/fallback models,
331
495
  // so startup isn't blocked on the network request.
332
496
  refreshLiveModels(pi, apiKey).catch(() => {
@@ -338,6 +502,14 @@ export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
338
502
  // and refresh the model list from the live API.
339
503
  pi.on("session_start", async (_event, ctx) => {
340
504
  const apiKey = await getSyntheticApiKey(ctx);
505
+ const wasAuthenticated = controller.syntheticAuthenticated;
506
+ controller.syntheticAuthenticated = !!apiKey;
507
+ if (controller.syntheticAuthenticated && !searchToolRegistered) {
508
+ ensureSearchToolRegistered();
509
+ }
510
+ if (controller.syntheticAuthenticated !== wasAuthenticated) {
511
+ controller.applyMode(ctx);
512
+ }
341
513
  // Fire-and-forget: don't block session startup on the network request.
342
514
  // Cached/fallback models are already registered, so the provider is usable immediately.
343
515
  refreshLiveModels(pi, apiKey, ctx.signal).catch(() => {});
package/src/types.ts CHANGED
@@ -35,6 +35,12 @@ export interface MoonpiConfig {
35
35
  /** Whether moonpi installs its custom mode-colored editor. Set to false to preserve
36
36
  * editor customizations from other extensions (e.g. pi-wierd-statusline). */
37
37
  customEditor: boolean;
38
+ synthetic: {
39
+ search: {
40
+ /** Whether the web_search tool is available when logged in with Synthetic. */
41
+ enabled: boolean;
42
+ };
43
+ };
38
44
  contextFiles: {
39
45
  enabled: boolean;
40
46
  fileNames: string[];