moonpi 0.4.4 → 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
@@ -182,6 +182,16 @@ Example output:
182
182
 
183
183
  At startup, a notification shows which files are currently selected for injection.
184
184
 
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
+
185
195
  ## Custom Providers
186
196
 
187
197
  moonpi includes the support from some custom providers, and provides slash commands to manage custom providers in `~/.pi/agent/models.json`.
@@ -207,6 +217,23 @@ Use `/model` to select a `synthetic` model. Use `/synthetic:quotas` to show your
207
217
 
208
218
  ![Synthetic quotas output](assets/screenshots/moonpi-syn.png)
209
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
+ ```
210
237
 
211
238
  ### Managing Custom Providers
212
239
 
@@ -334,6 +361,11 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
334
361
  "defaultMode": "auto",
335
362
  "preserveExternalTools": false,
336
363
  "customEditor": true,
364
+ "synthetic": {
365
+ "search": {
366
+ "enabled": true
367
+ }
368
+ },
337
369
  "contextFiles": {
338
370
  "enabled": true,
339
371
  "fileNames": ["README.md", "SPECS.md", "SPRINT.md"],
@@ -373,6 +405,12 @@ Configure `.pi/moonpi.json` (project) or `~/.pi/agent/moonpi.json` (global):
373
405
  | `preserveExternalTools` | `false` | When `true`, tools registered by other extensions are kept alongside moonpi tools when applying mode tool restrictions |
374
406
  | `customEditor` | `true` | When `false`, moonpi skips installing its mode-colored editor, preserving editor customizations from other extensions |
375
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
+
376
414
  #### Keybindings
377
415
 
378
416
  | Field | Default | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moonpi",
3
- "version": "0.4.4",
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;
package/src/index.ts CHANGED
@@ -140,7 +140,7 @@ ${todoList}`);
140
140
 
141
141
  // Synthetic is optional; keep core Moonpi mode hooks installed even if provider setup fails.
142
142
  try {
143
- await installSynthetic(pi);
143
+ await installSynthetic(pi, controller);
144
144
  } catch {
145
145
  // Ignore optional provider setup failures.
146
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[];