diff options
Diffstat (limited to 'config/pi')
| -rw-r--r-- | config/pi/extensions/parallel-extract.ts | 277 | ||||
| -rw-r--r-- | config/pi/extensions/parallel-search.ts | 401 | ||||
| -rw-r--r-- | config/pi/extensions/provider-discovery.ts.disabled | 108 | ||||
| -rw-r--r-- | config/pi/models.json | 20 | ||||
| -rw-r--r-- | config/pi/settings.json | 11 |
5 files changed, 817 insertions, 0 deletions
diff --git a/config/pi/extensions/parallel-extract.ts b/config/pi/extensions/parallel-extract.ts new file mode 100644 index 0000000..2ab7a87 --- /dev/null +++ b/config/pi/extensions/parallel-extract.ts @@ -0,0 +1,277 @@ +/** + * Parallel Extract Extension - Content extraction via Parallel.ai + * + * Provides the extract tool for extracting content from URLs. + * + * Requires PARALLEL_API_KEY environment variable. + * + * https://docs.parallel.ai + */ + +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type ExtensionAPI, + formatSize, + getMarkdownTheme, + keyHint, + type Theme, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Type } from "typebox"; + +const API_BASE = "https://api.parallel.ai"; +const BETA_HEADER = "search-extract-2025-10-10"; + +function getApiKey(): string { + const key = process.env.PARALLEL_API_KEY; + if (!key) { + throw new Error( + "PARALLEL_API_KEY environment variable is not set. " + + "Get your API key at https://platform.parallel.ai", + ); + } + return key; +} + +// ── API types ────────────────────────────────────────────────────────────── + +interface ExtractResult { + url: string; + title?: string | null; + publish_date?: string | null; + excerpts?: string[] | null; + full_content?: string | null; +} + +interface ExtractError { + url: string; + error_type: string; + http_status_code?: number | null; + content?: string | null; +} + +interface ExtractResponse { + extract_id: string; + results: ExtractResult[]; + errors: ExtractError[]; +} + +// ── API helper ───────────────────────────────────────────────────────────── + +async function parallelExtract( + params: { + url: string; + objective?: string; + }, + signal?: AbortSignal, +): Promise<ExtractResponse> { + const body: Record<string, unknown> = { + urls: [params.url], + excerpts: false, + full_content: true, + }; + if (params.objective) body.objective = params.objective; + + const resp = await fetch(`${API_BASE}/v1beta/extract`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": getApiKey(), + "parallel-beta": BETA_HEADER, + }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Extract API error ${resp.status}: ${text}`); + } + + return (await resp.json()) as ExtractResponse; +} + +// ── Formatting helper ────────────────────────────────────────────────────── + +function formatExtractResult(result?: ExtractResult, error?: ExtractError): string { + if (error) { + return ( + `## Error: ${error.url}\nType: ${error.error_type}` + + (error.http_status_code ? ` (HTTP ${error.http_status_code})` : "") + + (error.content ? `\n${error.content}` : "") + ); + } + + if (!result) return "No content extracted."; + + return "\n" + (result.full_content ?? ""); +} + +// ── Tool details type ────────────────────────────────────────────────────── + +interface ExtractDetails { + url: string; + error: boolean; + extractId: string; + truncated?: boolean; + spinnerFrame?: number; +} + +// ── Spinner ───────────────────────────────────────────────────────────────── + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +type SpinnerUpdate = (partial: { + content: { type: "text"; text: string }[]; + details?: ExtractDetails; +}) => void; + +function startSpinner( + onUpdate: SpinnerUpdate | undefined, + baseDetails: ExtractDetails, + signal?: AbortSignal, +): () => void { + if (!onUpdate) return () => {}; + let frame = 0; + const interval = setInterval(() => { + if (signal?.aborted) { + clearInterval(interval); + return; + } + onUpdate({ + content: [{ type: "text", text: "Running..." }], + details: { ...baseDetails, spinnerFrame: frame }, + }); + frame = (frame + 1) % SPINNER_FRAMES.length; + }, 80); + return () => clearInterval(interval); +} + +function renderSpinner(details: ExtractDetails | undefined, theme: Theme, label: string): Text { + const frame = details?.spinnerFrame ?? 0; + const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; + return new Text(`\n${theme.fg("dim", `${spinner} ${label} (escape/ctrl+c to cancel)`)}`, 0, 0); +} + +// ── Extension entry point ────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + if (!process.env.PARALLEL_API_KEY) return; + + pi.registerTool({ + name: "extract", + label: "Extract", + description: + `Extract content from a URL. Returns page title and full content. ` + + `Optionally provide an objective to focus extraction on specific information. ` + + `Output truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`, + promptSnippet: "Extract full content from a URL via Parallel.ai", + parameters: Type.Object({ + url: Type.String({ + description: "URL to extract content from", + }), + objective: Type.Optional( + Type.String({ + description: "Focus extraction on this objective (e.g. 'find the pricing information')", + }), + ), + }), + + async execute(_toolCallId, params, signal, onUpdate) { + const { url, objective } = params; + + const baseDetails: ExtractDetails = { url, error: false, extractId: "" }; + const stopSpinner = startSpinner(onUpdate as SpinnerUpdate | undefined, baseDetails, signal); + + let response: ExtractResponse; + try { + response = await parallelExtract({ url, objective }, signal); + } finally { + stopSpinner(); + } + + const hasError = response.errors.length > 0; + let resultText = formatExtractResult(response.results[0], response.errors[0]); + + const truncation = truncateHead(resultText, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + resultText = truncation.content; + const truncated = truncation.truncated; + if (truncated) { + resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines `; + resultText += `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). `; + resultText += `Try a more specific objective to reduce content size.]`; + } + + const details: ExtractDetails = { + url, + error: hasError, + extractId: response.extract_id, + truncated, + }; + + return { + content: [{ type: "text", text: resultText }], + details, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("extract ")); + text += theme.fg("accent", args.url.replace(/^https?:\/\//, "")); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + const details = result.details as ExtractDetails | undefined; + + if (isPartial) { + return renderSpinner(details, theme, "Extracting..."); + } + + if (!details || !details.extractId) { + const content = result.content[0]; + const msg = content?.type === "text" ? content.text : "No content"; + return new Text(theme.fg("dim", msg), 0, 0); + } + + if (!expanded) { + let text = ""; + if (details.error) { + text += "\n" + theme.fg("error", "error"); + } + text += `\n(${keyHint("app.tools.expand", "to expand")})`; + return new Text(text, 0, 0); + } + + const content = result.content[0]; + if (content?.type === "text" && content.text) { + const container = new Container(); + if (details.error) { + container.addChild(new Text(theme.fg("error", "error"), 0, 0)); + } + const mdTheme = getMarkdownTheme(); + container.addChild( + new Markdown(content.text, 1, 0, mdTheme, { color: (t) => theme.fg("dim", t) }), + ); + if (details.truncated) { + container.addChild(new Text(theme.fg("warning", "[truncated]"), 1, 0)); + } + container.addChild(new Spacer(1)); + container.addChild(new Text(`(${keyHint("app.tools.expand", "to collapse")})`, 0, 0)); + return container; + } + + let text = ""; + if (details.error) { + text += theme.fg("error", "error"); + } + return new Text(text, 0, 0); + }, + }); +} diff --git a/config/pi/extensions/parallel-search.ts b/config/pi/extensions/parallel-search.ts new file mode 100644 index 0000000..3bca820 --- /dev/null +++ b/config/pi/extensions/parallel-search.ts @@ -0,0 +1,401 @@ +/** + * Parallel Search Extension - Web search via Parallel.ai + * + * Provides the search tool for searching the web using natural language objectives or keyword queries. + * + * Requires PARALLEL_API_KEY environment variable. + * + * https://docs.parallel.ai + */ + +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type ExtensionAPI, + formatSize, + getMarkdownTheme, + keyHint, + type Theme, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Type } from "typebox"; + +const API_BASE = "https://api.parallel.ai"; +const BETA_HEADER = "search-extract-2025-10-10"; + +function getApiKey(): string { + const key = process.env.PARALLEL_API_KEY; + if (!key) { + throw new Error( + "PARALLEL_API_KEY environment variable is not set. " + + "Get your API key at https://platform.parallel.ai", + ); + } + return key; +} + +// ── API types ────────────────────────────────────────────────────────────── + +interface SearchResult { + url: string; + title?: string | null; + publish_date?: string | null; + excerpts?: string[] | null; +} + +interface SearchResponse { + search_id: string; + results: SearchResult[]; +} + +// ── API helper ───────────────────────────────────────────────────────────── + +async function parallelSearch( + params: { + objective?: string; + search_queries?: string[]; + max_results?: number; + mode?: string; + include_domains?: string[]; + exclude_domains?: string[]; + after_date?: string; + max_chars_per_result?: number; + max_chars_total?: number; + }, + signal?: AbortSignal, +): Promise<SearchResponse> { + const body: Record<string, unknown> = { + mode: params.mode ?? "agentic", + }; + if (params.objective) body.objective = params.objective; + if (params.search_queries) body.search_queries = params.search_queries; + if (params.max_results) body.max_results = params.max_results; + + if (params.max_chars_per_result || params.max_chars_total) { + const excerpts: Record<string, unknown> = {}; + if (params.max_chars_per_result) excerpts.max_chars_per_result = params.max_chars_per_result; + if (params.max_chars_total) excerpts.max_chars_total = params.max_chars_total; + body.excerpts = excerpts; + } + + if (params.include_domains || params.exclude_domains || params.after_date) { + const sourcePolicy: Record<string, unknown> = {}; + if (params.include_domains) sourcePolicy.include_domains = params.include_domains; + if (params.exclude_domains) sourcePolicy.exclude_domains = params.exclude_domains; + if (params.after_date) sourcePolicy.after_date = params.after_date; + body.source_policy = sourcePolicy; + } + + const resp = await fetch(`${API_BASE}/v1beta/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": getApiKey(), + "parallel-beta": BETA_HEADER, + }, + body: JSON.stringify(body), + signal, + }); + + if (!resp.ok) { + const text = await resp.text().catch(() => ""); + throw new Error(`Parallel search API error ${resp.status}: ${text}`); + } + + return (await resp.json()) as SearchResponse; +} + +// ── Formatting helper ────────────────────────────────────────────────────── + +function formatSearchResults(results: SearchResult[]): string { + if (results.length === 0) return "No results found."; + + const parts: string[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + let entry = `[${i + 1}] ${r.title ?? "(no title)"}\n ${r.url}`; + if (r.publish_date) entry += `\n Published: ${r.publish_date}`; + if (r.excerpts?.length) { + entry += "\n" + r.excerpts.map((e) => ` ${e}`).join("\n"); + } + parts.push(entry); + } + return parts.join("\n\n"); +} + +// ── Tool details types ───────────────────────────────────────────────────── + +interface SearchResultDetail { + url: string; + title?: string | null; + publish_date?: string | null; + excerpts?: string[] | null; +} + +interface SearchDetails { + objective?: string; + search_queries?: string[]; + resultCount: number; + searchId: string; + results?: SearchResultDetail[]; + spinnerFrame?: number; +} + +// ── Spinner ───────────────────────────────────────────────────────────────── + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +type SpinnerUpdate = (partial: { + content: { type: "text"; text: string }[]; + details?: SearchDetails; +}) => void; + +function startSpinner( + onUpdate: SpinnerUpdate | undefined, + baseDetails: SearchDetails, + signal?: AbortSignal, +): () => void { + if (!onUpdate) return () => {}; + let frame = 0; + const interval = setInterval(() => { + if (signal?.aborted) { + clearInterval(interval); + return; + } + onUpdate({ + content: [{ type: "text", text: "Running..." }], + details: { ...baseDetails, spinnerFrame: frame }, + }); + frame = (frame + 1) % SPINNER_FRAMES.length; + }, 80); + return () => clearInterval(interval); +} + +function renderSpinner(details: SearchDetails | undefined, theme: Theme, label: string): Text { + const frame = details?.spinnerFrame ?? 0; + const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; + return new Text(`\n${theme.fg("dim", `${spinner} ${label} (escape/ctrl+c to cancel)`)}`, 0, 0); +} + +// ── Extension entry point ────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + if (!process.env.PARALLEL_API_KEY) return; + + const searchCountCache = new Map<string, number>(); + function searchCacheKey(objective?: string, search_queries?: string[]): string { + return JSON.stringify({ objective, search_queries }); + } + + pi.registerTool({ + name: "search", + label: "Search", + description: + `Search the web. Provide a natural language objective describing what you're looking for, ` + + `and/or keyword search queries. Returns relevant results with titles, URLs, and excerpts. ` + + `Output truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`, + promptSnippet: "Search the web via Parallel.ai (objective and/or keyword queries)", + parameters: Type.Object({ + objective: Type.Optional( + Type.String({ + description: + "Natural language description of what to find. At least one of objective or search_queries is required.", + }), + ), + search_queries: Type.Optional( + Type.Array(Type.String(), { + description: + "Keyword search queries. May contain search operators. At least one of objective or search_queries is required.", + }), + ), + max_results: Type.Optional( + Type.Number({ + description: "Max results to return (default: 5)", + }), + ), + mode: Type.Optional( + Type.String({ + description: + 'Search mode: "agentic" (default, concise), "one-shot" (comprehensive), or "fast" (lower latency)', + }), + ), + include_domains: Type.Optional( + Type.Array(Type.String(), { + description: 'Restrict results to these domains (e.g. ["wikipedia.org", ".gov"])', + }), + ), + exclude_domains: Type.Optional( + Type.Array(Type.String(), { + description: 'Exclude results from these domains (e.g. ["reddit.com"])', + }), + ), + after_date: Type.Optional( + Type.String({ + description: "Only return results published on or after this date (YYYY-MM-DD)", + }), + ), + max_chars_per_result: Type.Optional( + Type.Number({ + description: "Max characters of excerpts per result (min 1000)", + }), + ), + max_chars_total: Type.Optional( + Type.Number({ + description: "Max total characters of excerpts across all results (min 1000)", + }), + ), + }), + + async execute(_toolCallId, params, signal, onUpdate) { + const { + objective, + search_queries, + max_results, + mode, + include_domains, + exclude_domains, + after_date, + max_chars_per_result, + max_chars_total, + } = params; + + if (!objective && (!search_queries || search_queries.length === 0)) { + throw new Error("at least one of objective or search_queries must be provided."); + } + + const baseDetails: SearchDetails = { + objective, + search_queries, + resultCount: 0, + searchId: "", + }; + const stopSpinner = startSpinner(onUpdate as SpinnerUpdate | undefined, baseDetails, signal); + + let response: SearchResponse; + try { + response = await parallelSearch( + { + objective, + search_queries, + max_results: max_results ?? 5, + mode, + include_domains, + exclude_domains, + after_date, + max_chars_per_result: max_chars_per_result ?? 500, + max_chars_total: max_chars_total ?? 2500, + }, + signal, + ); + } finally { + stopSpinner(); + } + + let resultText = formatSearchResults(response.results); + + const truncation = truncateHead(resultText, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + resultText = truncation.content; + if (truncation.truncated) { + resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines `; + resultText += `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + searchCountCache.set(searchCacheKey(objective, search_queries), response.results.length); + + const details: SearchDetails = { + objective, + search_queries, + resultCount: response.results.length, + searchId: response.search_id, + results: response.results.map((r) => ({ + url: r.url, + title: r.title, + publish_date: r.publish_date, + excerpts: r.excerpts, + })), + }; + + return { + content: [{ type: "text", text: resultText }], + details, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("search ")); + if (args.objective) { + text += theme.fg("accent", `${args.objective}`); + } else if (args.search_queries?.length) { + text += theme.fg("accent", args.search_queries.join(", ")); + } + if (args.include_domains?.length) { + text += theme.fg("muted", ` site:${args.include_domains.join(",")}`); + } + const count = searchCountCache.get(searchCacheKey(args.objective, args.search_queries)); + if (count !== undefined) { + text += theme.fg("dim", ` [${count} results]`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded, isPartial }, theme) { + const details = result.details as SearchDetails | undefined; + + if (isPartial) { + return renderSpinner(details, theme, "Searching..."); + } + + if (!details || !details.resultCount) { + const content = result.content[0]; + const msg = content?.type === "text" ? content.text : "No results found"; + return new Text(theme.fg("warning", msg), 0, 0); + } + + if (!expanded) { + let text = "\n"; + if (details.results?.length) { + for (let i = 0; i < details.results.length; i++) { + const r = details.results[i]; + if (i > 0) text += "\n"; + const url = r.url.replace(/^https?:\/\//, ""); + text += `${theme.fg("dim", `${i + 1}.`)} ${theme.fg("accent", url)}`; + } + } + text += `\n\n(${keyHint("app.tools.expand", "to expand")})`; + return new Text(text, 0, 0); + } + + const container = new Container(); + container.addChild(new Spacer(1)); + + if (details.results?.length) { + const mdTheme = getMarkdownTheme(); + for (let i = 0; i < details.results.length; i++) { + if (i > 0) { + container.addChild(new Spacer(1)); + } + const r = details.results[i]; + const url = r.url.replace(/^https?:\/\//, ""); + const header = `${theme.fg("dim", `${i + 1}.`)} ${theme.fg("accent", url)}`; + container.addChild(new Text(header, 0, 0)); + + if (r.excerpts?.length) { + const excerptMd = r.excerpts.join("\n\n---\n\n"); + container.addChild( + new Markdown(excerptMd, 3, 0, mdTheme, { color: (t) => theme.fg("dim", t) }), + ); + } + } + } + + container.addChild(new Spacer(1)); + container.addChild(new Text(`(${keyHint("app.tools.expand", "to collapse")})`, 0, 0)); + return container; + }, + }); +} diff --git a/config/pi/extensions/provider-discovery.ts.disabled b/config/pi/extensions/provider-discovery.ts.disabled new file mode 100644 index 0000000..759a2fb --- /dev/null +++ b/config/pi/extensions/provider-discovery.ts.disabled @@ -0,0 +1,108 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +interface ProviderConfig { + id: string; + baseUrl: string; + envKey: string; + suffix: string; +} + +const PROVIDERS: ProviderConfig[] = [ + { + id: "siliconflow", + baseUrl: "https://api.siliconflow.com/v1", + envKey: "SILICONFLOW_API_KEY", + suffix: "SF" + }, + { + id: "deepinfra", + baseUrl: "https://api.deepinfra.com/v1/openai", + envKey: "DEEPINFRA_API_KEY", + suffix: "DI" + } +]; + +// Patterns matched in order; first match wins. +const CONTEXT_OVERRIDES: Array<[RegExp, number]> = [ + [/qwen3.*coder/i, 262144], + [/qwen3.*next/i, 262144], + [/qwen3-235b|qwen3-32b|qwen3-14b|qwen3-8b|qwen3-30b/i, 131072], + [/qwq-32b/i, 131072], + [/qwen2\.5-.*-128k/i, 131072], + [/qwen2\.5/i, 32768], + [/deepseek-v3|deepseek-r1/i, 131072], + [/kimi-k2/i, 131072], + [/glm-4\.[567]|glm-5/i, 131072], + [/gpt-oss/i, 131072], + [/minimax/i, 245760], + [/hunyuan/i, 32768], +]; + +function resolveContextWindow(modelId: string): number { + for (const [pattern, ctx] of CONTEXT_OVERRIDES) { + if (pattern.test(modelId)) return ctx; + } + return 32768; // conservative fallback +} + +export default async function (pi: ExtensionAPI) { + console.log("Provider discovery: Starting..."); + + await Promise.all(PROVIDERS.map(async (config) => { + const apiKey = process.env[config.envKey]; + if (!apiKey) { + console.warn(`Provider discovery: ${config.envKey} not found. Skipping ${config.id}.`); + return; + } + + try { + const response = await fetch(`${config.baseUrl}/models`, { + headers: { "Authorization": `Bearer ${apiKey}` } + }); + + if (!response.ok) { + throw new Error(`API error ${response.status}`); + } + + const payload = await response.json(); + const models = payload.data + .filter((model: any) => { + // Heuristic to filter for chat/text models + const id = model.id.toLowerCase(); + return id.includes("/") || model.type === "text-generation" || id.includes("gpt") || id.includes("llama") || id.includes("claude"); + }) + .map((model: any) => { + const id = model.id; + const isReasoning = id.toLowerCase().includes("r1") || + id.toLowerCase().includes("thinking") || + id.toLowerCase().includes("reasoning") || + id.toLowerCase().includes("qwq"); + + return { + id: id, + name: `${id.split('/').pop()} (${config.suffix})`, + reasoning: isReasoning, + input: ["text"], + contextWindow: model.context_window ?? resolveContextWindow(id), + maxTokens: 4096 + }; + }); + + pi.registerProvider(config.id, { + baseUrl: config.baseUrl, + apiKey: config.envKey, + api: "openai-completions", + models: models, + compat: { + supportsDeveloperRole: false, + supportsStrictMode: false, + requiresToolResultName: true + }, + }); + + console.log(`Provider discovery: Registered ${models.length} models for ${config.id}.`); + } catch (error) { + console.error(`Provider discovery: Failed to fetch models for ${config.id}:`, error); + } + })); +} diff --git a/config/pi/models.json b/config/pi/models.json new file mode 100644 index 0000000..9dd0617 --- /dev/null +++ b/config/pi/models.json @@ -0,0 +1,20 @@ +{ + "providers": { + "siliconflow-anthropic": { + "baseUrl": "https://api.siliconflow.com/anthropic", + "api": "anthropic-messages", + "apiKey": "SILICONFLOW_API_KEY", + "authHeader": true, + "models": [ + { + "id": "Qwen/Qwen3-Coder-480B-A35B-Instruct", + "name": "Qwen3 Coder 480B (SiliconFlow Anthropic)", + "reasoning": false, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 16384 + } + ] + } + } +} diff --git a/config/pi/settings.json b/config/pi/settings.json new file mode 100644 index 0000000..c1a47c4 --- /dev/null +++ b/config/pi/settings.json @@ -0,0 +1,11 @@ +{ + "defaultProvider": "openrouter", + "defaultModel": "z-ai/glm-5.3", + "defaultThinkingLevel": "high", + "hideThinkingBlock": true, + "theme": "dark", + "packages": [ + "npm:@narumitw/pi-goal", + "npm:pi-mcp-adapter@2.33.0" + ] +} |
