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 +38 -0
- package/package.json +1 -1
- package/src/config.ts +14 -0
- package/src/context-files.ts +15 -0
- package/src/index.ts +1 -1
- package/src/modes.ts +9 -3
- package/src/synthetic.ts +173 -1
- package/src/types.ts +6 -0
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
|

|
|
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
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;
|
package/src/context-files.ts
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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[];
|