/** * MOA (Mixture of Agents) extension for pi. * * Recreates Nous Research Hermes Agent's MOA workflow as a slash-command: * * 1. `/moa ` fan-out: call N reference models in parallel, each seeing * ONLY the single user message (single-turn context, no tools, no system * prompt, no history). * 2. The reference outputs are labeled and injected into a synthesized message. * 3. That message is sent through the normal agent loop with the configured * main model ("aggregator"), which sees the full conversation history and * can call tools normally. * 4. When the turn settles, the previous model is restored (FR-1.1 / U5). * * Path B (command-driven) per the requirements doc: no custom provider is * registered; models and auth are resolved entirely through pi's existing * mechanism (`ctx.modelRegistry`), so no API keys live in the config file. * * Config file: ~/.pi/agent/moa.json (global; see README for the schema) */ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { complete as compatComplete } from "@earendil-works/pi-ai/compat"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { buildSessionContext, DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; // --------------------------------------------------------------------------- // Debug log — appended to ~/.pi/agent/moa-debug.log so reference-model failures // are diagnosable even when pi's TUI swallows console output. Remove when stable. // --------------------------------------------------------------------------- function moaLog(...parts: unknown[]): void { if (!process.env.MOA_DEBUG) return; try { const line = parts .map((p) => { if (typeof p === "string") return p; if (p instanceof Error) return `${p.message}\n${p.stack ?? ""}`; return JSON.stringify(p); }) .join(" "); appendFileSync(join(getAgentDir(), "moa-debug.log"), `[${new Date().toISOString()}] ${line}\n`); } catch { /* ignore */ } } // --------------------------------------------------------------------------- // Config types (mirror Hermes' moa config section; identifiers only, no keys) // --------------------------------------------------------------------------- interface RefModel { provider: string; model: string; /** Optional per-slot reasoning effort (provider-dependent, best effort). */ reasoning_effort?: string; } interface Aggregator { provider: string; model: string; } interface Preset { reference_models: RefModel[]; /** Optional fixed main-model override. When omitted, MOA uses the current * UI-selected model (`ctx.model`) as the main output model. */ aggregator?: Aggregator; /** Cap for each reference output. Does NOT cap the aggregator output. */ reference_max_tokens?: number; /** Optional sampling temperature for reference calls. */ reference_temperature?: number; /** Reserved for Hermes parity. Not applied in v1 (pi governs main-model tokens). */ max_tokens?: number; } interface MoaConfig { default_preset?: string; presets: Record; /** Toggle for "auto" mode: every interactive message runs the MOA pipeline * (reference fan-out + main model) automatically. */ auto_mode?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CONFIG_PATH = join(getAgentDir(), "moa.json"); const DEFAULT_PRESET_NAME = "default"; const DEFAULT_REF_MAX_TOKENS = 4000; const REF_TIMEOUT_MS = 120_000; // hard cap per reference call (2 min) const MAX_REF_MODELS = 3; /** 上下文总结的输出上限(用户要求 100–200 字)。 */ const SUMMARY_MAX_TOKENS = 1000; const SUBCOMMANDS = ["list", "configure", "delete", "status", "set-main", "help"] as const; type Subcommand = (typeof SUBCOMMANDS)[number]; type RefResult = { ok: true; text: string } | { ok: false; error: string }; // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- export default function moaExtension(pi: ExtensionAPI) { // Module-level "MOA turn in flight" state. Rebuilt on session reload; reset // on session_start to avoid stale state (U7). let pendingRestore: Model | undefined; // ------------------------------------------------------------------ util function loadConfig(): MoaConfig | undefined { if (!existsSync(CONFIG_PATH)) return undefined; try { return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) as MoaConfig; } catch (err) { console.error(`[moa] failed to parse ${CONFIG_PATH}: ${err}`); return undefined; } } function saveConfig(cfg: MoaConfig): void { mkdirSync(dirname(CONFIG_PATH), { recursive: true }); writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n", "utf-8"); } function labelOf(p: { provider: string; model: string }): string { return `${p.provider}/${p.model}`; } /** Render a runtime Model object (`{ provider, id }`). */ function modelLabel(m: { provider: string; id: string }): string { return `${m.provider}/${m.id}`; } function findModel( ctx: ExtensionContext, provider: string, model: string, ): Model | undefined { return ctx.modelRegistry.find(provider, model) as Model | undefined; } /** Extract readable text from a completed assistant message, falling back to thinking content. */ function extractText(msg: AssistantMessage): string { const texts: string[] = []; const thinkings: string[] = []; for (const block of msg.content ?? []) { if (block.type === "text") texts.push(block.text); else if (block.type === "thinking") thinkings.push((block as { thinking?: string }).thinking ?? ""); } const text = texts.join("\n").trim(); if (text) return text; const thinking = thinkings.join("\n").trim(); return thinking ? `[思考] ${thinking}` : ""; } /** Models with configured auth, deduped by provider/model, sorted. */ function collectAvailableModels(ctx: ExtensionCommandContext): Model[] { const seen = new Set(); const out: Model[] = []; for (const m of ctx.modelRegistry.getAvailable()) { if (!ctx.modelRegistry.hasConfiguredAuth(m)) continue; const key = `${m.provider}/${m.id}`; if (seen.has(key)) continue; seen.add(key); out.push(m); } return out.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b))); } /** * 列表选择:TUI 下用可滚动的 SelectList(最多显示 10 项,超出滚动,带 (n/total) 指示), * 避免 ctx.ui.select 一次性渲染全部选项导致列表溢出窗口、光标跳到列表底部; * 非 TUI 模式(RPC 等)回退到 ctx.ui.select。 */ async function pickFromList( ctx: ExtensionCommandContext, title: string, items: SelectItem[], ): Promise { if (ctx.mode === "tui") { const chosen = await ctx.ui.custom((tui, theme, _kb, done) => { const container = new Container(); container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); container.addChild(new Text(theme.fg("accent", theme.bold(title)))); const selectList = new SelectList(items, Math.min(items.length, 10), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); selectList.onSelect = (item) => done(item.value); selectList.onCancel = () => done(null); container.addChild(selectList); container.addChild(new Text(theme.fg("dim", "↑↓ 选择 • enter 确认 • esc 取消"))); container.addChild(new DynamicBorder((s) => theme.fg("accent", s))); return { render: (w: number) => container.render(w), invalidate: () => container.invalidate(), handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); }, }; }); return chosen ?? undefined; } const labels = items.map((it) => it.label || it.value); const chosen = await ctx.ui.select(title, labels); if (!chosen) return undefined; return items.find((it) => (it.label || it.value) === chosen)?.value; } // -------------------------------------------------------------- wizard async function pickModel( ctx: ExtensionCommandContext, title: string, ): Promise { const models = collectAvailableModels(ctx); if (models.length === 0) { ctx.ui.notify( "未检测到已配置鉴权的模型。请先运行 /login 或配置 API Key。", "error", ); return undefined; } const items: SelectItem[] = models.map((m) => ({ value: modelLabel(m), label: modelLabel(m) })); const chosen = await pickFromList(ctx, title, items); if (!chosen) return undefined; const m = models.find((x) => modelLabel(x) === chosen); return m ? { provider: m.provider, model: m.id } : undefined; } async function pickReference( ctx: ExtensionCommandContext, index: number, canSkip: boolean, ): Promise { const models = collectAvailableModels(ctx); if (models.length === 0) { ctx.ui.notify( "未检测到已配置鉴权的模型。请先运行 /login 或配置 API Key。", "error", ); return undefined; } const items: SelectItem[] = models.map((m) => ({ value: modelLabel(m), label: modelLabel(m) })); const SKIP = "(跳过:不再添加参考模型)"; if (canSkip) items.push({ value: SKIP, label: SKIP }); const chosen = await pickFromList(ctx, `选择参考模型 ${index}`, items); if (!chosen) return undefined; if (chosen === SKIP) return "skip"; const m = models.find((x) => modelLabel(x) === chosen); return m ? { provider: m.provider, model: m.id } : undefined; } async function runWizard(ctx: ExtensionCommandContext): Promise { ctx.ui.notify("开始配置 MOA:选择参考模型(主输出模型默认跟随界面)", "info"); const refs: RefModel[] = []; const r1 = await pickReference(ctx, 1, false); if (r1 === undefined || r1 === "skip") return undefined; refs.push(r1); const r2 = await pickReference(ctx, 2, true); if (r2 === undefined) return undefined; if (r2 !== "skip") refs.push(r2); if (refs.length >= 2 && refs.length < MAX_REF_MODELS) { const add3 = await ctx.ui.confirm("参考模型", "是否再添加第 3 个参考模型?"); if (add3) { const r3 = await pickReference(ctx, 3, false); if (r3 === undefined) return undefined; if (r3 !== "skip") refs.push(r3); } } // 主输出模型默认跟随当前界面模型;可选指定固定主模型(override)。 const wantOverride = await ctx.ui.confirm( "主输出模型", "默认情况下,MOA 的主输出模型直接使用当前界面模型(推荐,避免与界面模型冲突)。\n是否要指定一个固定主输出模型?", ); let agg: Aggregator | undefined; if (wantOverride) { agg = await pickModel(ctx, "选择固定主输出模型(覆盖界面模型)"); if (!agg) return undefined; } const preset: Preset = { reference_models: refs, aggregator: agg, reference_max_tokens: DEFAULT_REF_MAX_TOKENS, }; const summary = [ `主输出模型:${agg ? labelOf(agg) : "跟随当前界面模型"}`, `参考模型:${refs.map(labelOf).join("、")}`, `参考输出上限:${preset.reference_max_tokens} tokens`, `配置将写入:${CONFIG_PATH}`, ].join("\n"); const ok = await ctx.ui.confirm("确认 MOA 配置", summary); if (!ok) return undefined; return preset; } // --------------------------------------------------------- MOA pipeline /** * 对指定模型做一次单轮、无工具、无 system prompt 的调用,返回正文文本。 * 双路径兼容:pi 0.84+ 走 registry.complete;pi 0.81.x 走 compat + getApiKeyAndHeaders。 */ async function callModelOnce( ctx: ExtensionContext, model: Model, prompt: string, opts: { maxTokens: number; temperature?: number; reasoningEffort?: string }, ): Promise { const context = { messages: [{ role: "user" as const, content: prompt, timestamp: Date.now() }], }; moaLog("callModelOnce", `${model.provider}/${model.id}`, `api=${(model as any).api}`, `promptLen=${prompt.length}`, `maxTokens=${opts.maxTokens}`); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REF_TIMEOUT_MS); try { const options: any = { maxTokens: opts.maxTokens, signal: controller.signal }; if (opts.temperature !== undefined) options.temperature = opts.temperature; if (opts.reasoningEffort) options.reasoningEffort = opts.reasoningEffort; // Version-compatible model call: // - pi >= 0.84: ModelRegistry.complete() resolves auth (incl. OAuth refresh) internally. // - pi 0.81.x: the facade has no complete(); resolve auth manually and use the // pi-ai compat dispatcher (same pattern as the official summarize example). const registry = ctx.modelRegistry as any; moaLog(`registry.complete 类型: ${typeof registry.complete}`); let result: AssistantMessage; if (typeof registry.complete === "function") { result = await registry.complete(model, context, options); } else { const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); moaLog("getApiKeyAndHeaders:", JSON.stringify({ ok: auth.ok, apiKeyLen: (auth as any).apiKey?.length ?? 0, hasHeaders: !!(auth as any).headers, hasEnv: !!(auth as any).env, error: (auth as any).error })); if (!auth.ok) return { ok: false, error: auth.error }; if (!auth.apiKey && !auth.headers) { return { ok: false, error: `模型 ${modelLabel(model)} 无可用鉴权(apiKey/headers 均为空)` }; } result = await compatComplete(model, context, { ...options, apiKey: auth.apiKey, headers: auth.headers, env: auth.env, }); } const text = extractText(result); moaLog("调用返回:", `stopReason=${result.stopReason}`, `textLen=${text.length}`, `errMsg=${(result as any).errorMessage ?? "无"}`); if (!text) return { ok: false, error: `模型返回空内容(stopReason=${result.stopReason})` }; return { ok: true, text }; } catch (err) { moaLog("调用异常:", `${model.provider}/${model.id}`, err); const reason = controller.signal.aborted ? `超时(${REF_TIMEOUT_MS}ms)` : err instanceof Error ? err.message : String(err); return { ok: false, error: reason }; } finally { clearTimeout(timer); } } /** * 参考模型提示词包装:声明其无联网/工具能力,要求对不确定知识点明确说明, * 避免参考模型凭空编造(例如写游戏 mod 时瞎编 API/版本号/路径)误导主模型。 */ function buildReferencePrompt(prompt: string): string { return [ "你是一个 Mixture of Agents(MOA)流程中的参考模型,正在为主模型提供参考意见。", "", "重要约束(务必遵守):", "1. 你没有网络搜索、联网查询或任何工具能力,只能基于已有训练知识回答。", "2. 凡涉及需要联网核实的事实(如游戏/mod 的当前版本、API 签名、文件路径、", " 参数名、配置项、第三方库用法等),若你没有把握,必须在回复中明确说明", " 「这一点我不确定,建议联网核实」,不要凭空编造。", "3. 不要为了显得确定而编造函数名、文件路径、版本号、参数或示例代码。", "4. 把「有把握的部分」与「需联网核实的部分」分开陈述,便于主模型判断取舍。", "", "用户的问题如下:", prompt, ].join("\n"); } async function callReference( ctx: ExtensionContext, ref: RefModel, prompt: string, maxTokens: number, temperature?: number, ): Promise { const model = findModel(ctx, ref.provider, ref.model); if (!model) { return { ok: false, error: `模型 ${labelOf(ref)} 在 registry 中不存在` }; } if (!ctx.modelRegistry.hasConfiguredAuth(model)) { return { ok: false, error: `模型 ${labelOf(ref)} 无已配置鉴权(请 /login ${ref.provider})` }; } const res = await callModelOnce(ctx, model, buildReferencePrompt(prompt), { maxTokens, temperature, reasoningEffort: ref.reasoning_effort, }); moaLog("callReference 结果:", labelOf(ref), res.ok ? `成功(${res.text.length}字)` : `失败: ${res.error}`); return res; } // ---------------------------------------------- context summary (FR-3) /** 提取 user 消息 content 中的纯文本(content 可能是 string 或内容块数组)。 */ function textOfUserContent(content: unknown): string { if (typeof content === "string") return content.trim(); if (Array.isArray(content)) { const parts: string[] = []; for (const b of content as Array<{ type?: string; text?: string }>) { if (b?.type === "text" && b.text) parts.push(b.text); } return parts.join("\n").trim(); } return ""; } /** 从 session 提取 user/assistant 逐条历史文本(经 buildSessionContext 处理 compaction)。 */ function extractHistoryText(ctx: ExtensionContext): string { try { const entries = ctx.sessionManager.getEntries(); const sessionCtx = buildSessionContext(entries, ctx.sessionManager.getLeafId()); const lines: string[] = []; for (const m of sessionCtx.messages as unknown as Array<{ role?: string; content?: unknown }>) { if (m.role === "user") { const t = textOfUserContent(m.content); if (t) lines.push(`用户:${t}`); } else if (m.role === "assistant") { const t = extractText(m as unknown as AssistantMessage); if (t) lines.push(`助手:${t}`); } } return lines.join("\n"); } catch (err) { console.error(`[moa] extractHistoryText failed: ${err}`); return ""; } } /** * 多轮对话时,用主输出模型生成「当前在聊什么」的 100–200 字总结。 * 无历史返回 undefined;总结失败返回 undefined(降级为仅用用户 prompt,不阻断 MOA)。 */ async function buildConversationSummary( ctx: ExtensionContext, model: Model, ): Promise { const history = extractHistoryText(ctx); if (!history) return undefined; const prompt = [ "请用 100–200 字概括下面这段对话「当前正在讨论或处理什么」。", "只输出总结本身:不要客套、不要提问、不要列举步骤。", "", "对话历史:", history, ].join("\n"); const res = await callModelOnce(ctx, model, prompt, { maxTokens: SUMMARY_MAX_TOKENS }); if (!res.ok) { ctx.ui.notify( `对话上下文总结失败(${res.error}),参考模型将仅使用用户 prompt。`, "warning", ); return undefined; } return res.text; } function assembleMessage( prompt: string, results: Array<{ ref: RefModel; res: RefResult }>, ): string { const lines: string[] = [prompt, ""]; lines.push("---"); lines.push("以下是由参考模型并行生成的参考意见(仅供主模型参考,不构成最终答案):"); lines.push(""); results.forEach(({ ref, res }, i) => { lines.push(`## 参考模型 ${i + 1}:${labelOf(ref)}`); lines.push(res.ok ? res.text : `⚠️ 调用失败:${res.error}`); lines.push(""); }); return lines.join("\n"); } async function runMoa( ctx: ExtensionCommandContext, prompt: string, preset: Preset, ): Promise { const refs = preset.reference_models.slice(0, MAX_REF_MODELS); const maxTokens = preset.reference_max_tokens ?? DEFAULT_REF_MAX_TOKENS; // 解析主输出模型:配置了 aggregator 用固定 override,否则用当前界面模型。 const aggregatorModel = preset.aggregator ? findModel(ctx, preset.aggregator.provider, preset.aggregator.model) : ctx.model; const aggregatorLabel = preset.aggregator ? labelOf(preset.aggregator) : aggregatorModel ? modelLabel(aggregatorModel) : "(当前界面模型)"; if (!aggregatorModel) { ctx.ui.setStatus("moa", undefined); ctx.ui.notify( preset.aggregator ? `主输出模型 ${labelOf(preset.aggregator)} 在 registry 中不存在,请 /moa configure 重新选择。` : "当前未选择界面模型。请先用 /model 选择模型,或 /moa configure 指定固定主输出模型。", "error", ); return; } if (!ctx.modelRegistry.hasConfiguredAuth(aggregatorModel)) { ctx.ui.setStatus("moa", undefined); ctx.ui.notify( `主输出模型 ${aggregatorLabel} 无已配置鉴权,请 /login ${aggregatorModel.provider}。`, "error", ); return; } // 需求 3:多轮对话时,先让主输出模型总结「当前在聊什么」,把总结提供给参考模型, // 而不是只给用户单句 prompt。总结失败降级为仅用用户 prompt,不阻断 MOA。 ctx.ui.setStatus("moa", "MOA:正在总结对话上下文…"); const summary = await buildConversationSummary(ctx, aggregatorModel); const refPrompt = summary ? `【对话背景总结】\n${summary}\n\n【用户当前问题】\n${prompt}` : prompt; ctx.ui.setStatus("moa", "MOA:参考模型并行生成中…"); const settled = await Promise.allSettled( refs.map((ref) => callReference(ctx, ref, refPrompt, maxTokens, preset.reference_temperature)), ); const results = settled.map((s, i) => ({ ref: refs[i], res: s.status === "fulfilled" ? s.value : { ok: false as const, error: s.reason instanceof Error ? s.reason.message : String(s.reason), }, })); const okCount = results.filter((r) => r.res.ok).length; if (okCount === 0) { ctx.ui.setStatus("moa", undefined); const details = results .map((r) => (r.res.ok ? `• ${labelOf(r.ref)}:未知错误` : `• ${labelOf(r.ref)}:${r.res.error}`)) .join("\n"); ctx.ui.notify(`全部参考模型调用失败,已中止(未运行主模型)。\n${details}`, "error"); return; } const originalModel = ctx.model; // 只有显式 override(配置了 aggregator)且与当前模型不同时才需要切换; // 未配置 aggregator 时主模型就是当前界面模型,无需切换、无需恢复。 const needSwitch = !!preset.aggregator && (!originalModel || originalModel.provider !== aggregatorModel.provider || originalModel.id !== aggregatorModel.id); if (needSwitch) { const switched = await pi.setModel(aggregatorModel); if (!switched) { ctx.ui.setStatus("moa", undefined); ctx.ui.notify(`主输出模型 ${aggregatorLabel} 无 API Key,已中止。`, "error"); return; } pendingRestore = originalModel; } const assembled = assembleMessage(prompt, results); ctx.ui.setStatus("moa", `MOA:${okCount}/${refs.length} 参考已就绪,主模型输出中…`); pi.sendUserMessage(assembled); } /** * 自动模式核心:复用总结 + 参考模型并行 fan-out,返回组装好的消息文本。 * 参考模型全部失败时返回 undefined(调用方降级为普通对话)。 */ async function runAutoFanout( ctx: ExtensionContext, prompt: string, preset: Preset, ): Promise { const refs = preset.reference_models.slice(0, MAX_REF_MODELS); const maxTokens = preset.reference_max_tokens ?? DEFAULT_REF_MAX_TOKENS; moaLog("runAutoFanout 开始:", `refs=${refs.map((r) => `${r.provider}/${r.model}`).join(",")}`, `主模型=${ctx.model?.provider}/${ctx.model?.id}`, `maxTokens=${maxTokens}`); // 自动模式主模型始终是当前界面模型;有鉴权时才做上下文总结。 const mainModel = ctx.model; let summary: string | undefined; if (mainModel && ctx.modelRegistry.hasConfiguredAuth(mainModel)) { summary = await buildConversationSummary(ctx, mainModel); } const refPrompt = summary ? `【对话背景总结】\n${summary}\n\n【用户当前问题】\n${prompt}` : prompt; const settled = await Promise.allSettled( refs.map((ref) => callReference(ctx, ref, refPrompt, maxTokens, preset.reference_temperature)), ); const results = settled.map((s, i) => ({ ref: refs[i], res: s.status === "fulfilled" ? s.value : { ok: false as const, error: s.reason instanceof Error ? s.reason.message : String(s.reason), }, })); const okCount = results.filter((r) => r.res.ok).length; moaLog("runAutoFanout 结果:", `ok=${okCount}/${results.length}`, results.map((r) => `${labelOf(r.ref)}=${r.res.ok ? "成功" : r.res.error}`).join(" | ")); if (okCount === 0) { const details = results .map((r) => (r.res.ok ? `• ${labelOf(r.ref)}:未知错误` : `• ${labelOf(r.ref)}:${r.res.error}`)) .join("\n"); ctx.ui.notify(`MOA 自动:全部参考模型调用失败,本次按普通对话处理。\n${details}`, "error"); return undefined; } return assembleMessage(prompt, results); } // ------------------------------------------------------- config bootstrap async function ensureConfig(ctx: ExtensionCommandContext): Promise { const cfg = loadConfig(); const name = cfg?.default_preset ?? DEFAULT_PRESET_NAME; const preset = cfg?.presets[name]; if (cfg && preset) return preset; if (!ctx.hasUI) { ctx.ui.notify( `未找到 MOA 配置。请在交互终端运行 /moa 完成引导,或手工编辑 ${CONFIG_PATH}。`, "error", ); return null; } ctx.ui.notify("未检测到 MOA 配置,进入首次配置引导…", "info"); const newPreset = await runWizard(ctx); if (!newPreset) return null; const newCfg: MoaConfig = cfg ?? { presets: {} }; newCfg.default_preset = name; newCfg.presets[name] = newPreset; saveConfig(newCfg); ctx.ui.notify(`配置已写入 ${CONFIG_PATH}`, "info"); return newPreset; } function showUsage(ctx: ExtensionCommandContext): void { const lines = [ "MOA(Mixture of Agents)用法:", "/moa 用默认预设跑一次 MOA", "/moa 打印用法与当前配置(未配置时进入引导)", "/moa list 列出所有预设", "/moa configure [name] 交互式创建/编辑预设(默认 default)", "/moa delete 删除预设", "/moa status 显示当前配置与模型", "/moa set-main

/ 设置固定主输出模型(默认跟随界面模型)", "/moa-auto 切换 MOA 自动模式(每条消息都走 MOA)", ]; ctx.ui.notify(lines.join("\n"), "info"); } // ---------------------------------------------------------- subcommands async function handleSubcommand( cmd: Subcommand, rest: string, ctx: ExtensionCommandContext, ): Promise { switch (cmd) { case "list": { const cfg = loadConfig(); if (!cfg || Object.keys(cfg.presets).length === 0) { ctx.ui.notify("尚无任何预设。运行 /moa configure 创建。", "info"); return; } const lines: string[] = []; for (const [name, p] of Object.entries(cfg.presets)) { const star = name === (cfg.default_preset ?? DEFAULT_PRESET_NAME) ? " [默认]" : ""; lines.push(`${name}${star}`); lines.push(` 主模型:${p.aggregator ? labelOf(p.aggregator) : "跟随当前界面模型"}`); lines.push(` 参考:${p.reference_models.map(labelOf).join("、")}`); } ctx.ui.notify(lines.join("\n"), "info"); return; } case "configure": { if (!ctx.hasUI) { ctx.ui.notify(`非交互模式无法配置。请手工编辑 ${CONFIG_PATH}。`, "error"); return; } const name = rest.trim() || DEFAULT_PRESET_NAME; if (!/^[a-z0-9-]{1,32}$/.test(name)) { ctx.ui.notify("预设名称仅支持小写字母、数字、连字符(≤32 字符)。", "error"); return; } const preset = await runWizard(ctx); if (!preset) return; const cfg: MoaConfig = loadConfig() ?? { presets: {} }; if (!cfg.default_preset) cfg.default_preset = DEFAULT_PRESET_NAME; cfg.presets[name] = preset; saveConfig(cfg); ctx.ui.notify(`预设 "${name}" 已保存。`, "info"); return; } case "delete": { const name = rest.trim(); if (!name) { ctx.ui.notify("用法:/moa delete ", "warning"); return; } const cfg = loadConfig(); if (!cfg?.presets[name]) { ctx.ui.notify(`预设 "${name}" 不存在。`, "error"); return; } const ok = await ctx.ui.confirm("删除预设", `确定删除预设 "${name}"?`); if (!ok) return; delete cfg.presets[name]; if (cfg.default_preset === name) { cfg.default_preset = Object.keys(cfg.presets)[0]; } saveConfig(cfg); ctx.ui.notify(`预设 "${name}" 已删除。`, "info"); return; } case "status": { const cfg = loadConfig(); const name = cfg?.default_preset ?? DEFAULT_PRESET_NAME; const preset = cfg?.presets[name]; const cur = ctx.model ? modelLabel(ctx.model) : "(未选择)"; const lines = [ `默认预设:${name}`, `主输出模型:${preset ? (preset.aggregator ? labelOf(preset.aggregator) : "跟随当前界面模型") : "(未配置)"}`, `参考模型:${preset ? preset.reference_models.map(labelOf).join("、") : "(未配置)"}`, `当前模型:${cur}`, `配置文件:${CONFIG_PATH}`, ]; ctx.ui.notify(lines.join("\n"), "info"); return; } case "set-main": { const target = rest.trim(); const slash = target.indexOf("/"); if (!target || slash <= 0 || slash === target.length - 1) { ctx.ui.notify("用法:/moa set-main /", "warning"); return; } const provider = target.slice(0, slash); const modelId = target.slice(slash + 1); const model = findModel(ctx, provider, modelId); if (!model) { ctx.ui.notify(`模型 ${target} 在 registry 中不存在。`, "error"); return; } const ok = await ctx.ui.confirm( "设置固定主输出模型", `将默认预设的固定主输出模型设为 ${target} 并立即切换?(设为固定后不再跟随界面模型)`, ); if (!ok) return; const cfg: MoaConfig = loadConfig() ?? { presets: {} }; const name = cfg.default_preset ?? DEFAULT_PRESET_NAME; if (!cfg.presets[name]) { cfg.presets[name] = { reference_models: [], aggregator: { provider, model: modelId } }; } else { cfg.presets[name].aggregator = { provider, model: modelId }; } if (!cfg.default_preset) cfg.default_preset = name; saveConfig(cfg); const switched = await pi.setModel(model); if (!switched) { ctx.ui.notify(`已保存配置,但 ${target} 无 API Key,未能切换。`, "warning"); } else { ctx.ui.notify(`主输出模型已设为 ${target}。`, "info"); } return; } case "help": showUsage(ctx); return; } } // ------------------------------------------------------- command handler async function handlePrompt(prompt: string, ctx: ExtensionCommandContext): Promise { if (!ctx.isIdle() || pendingRestore) { ctx.ui.notify("代理正在忙碌或已有 MOA 流程进行中,请稍后再试。", "warning"); return; } const preset = await ensureConfig(ctx); if (!preset) return; await runMoa(ctx, prompt, preset); } pi.registerCommand("moa", { description: "Mixture of Agents:多参考模型并行 + 主模型汇总", getArgumentCompletions: (prefix) => { const items = SUBCOMMANDS.filter((s) => s.startsWith(prefix)); return items.length > 0 ? items.map((s) => ({ value: s, label: s })) : null; }, handler: async (args, ctx) => { const trimmed = args.trim(); if (!trimmed) { showUsage(ctx); const cfg = loadConfig(); const name = cfg?.default_preset ?? DEFAULT_PRESET_NAME; if (!cfg?.presets[name]) { await ensureConfig(ctx); } return; } const space = trimmed.search(/\s/); const first = space === -1 ? trimmed : trimmed.slice(0, space); const rest = space === -1 ? "" : trimmed.slice(space + 1).trim(); if ((SUBCOMMANDS as readonly string[]).includes(first)) { await handleSubcommand(first as Subcommand, rest, ctx); return; } // Anything else is a prompt (open question 8: known subcommand words win). await handlePrompt(trimmed, ctx); }, }); // Toggle MOA "auto" mode: when on, every interactive message goes through the // MOA pipeline (reference fan-out + main model). Run again to turn it off. pi.registerCommand("moa-auto", { description: "切换 MOA 自动模式(开启后每条消息都走 MOA,再次输入关闭)", handler: async (_args, ctx) => { const cfg: MoaConfig = loadConfig() ?? { presets: {} }; const enabling = !cfg.auto_mode; cfg.auto_mode = enabling; saveConfig(cfg); if (enabling) { const preset = cfg.presets[cfg.default_preset ?? DEFAULT_PRESET_NAME]; ctx.ui.setStatus("moa-auto", "MOA 自动:开"); ctx.ui.notify( preset && preset.reference_models.length > 0 ? "MOA 自动模式已开启:之后每条消息都会先并行咨询参考模型。再次输入 /moa-auto 关闭。" : "MOA 自动模式已开启,但尚未配置参考模型,请先 /moa configure。再次输入 /moa-auto 关闭。", "info", ); } else { ctx.ui.setStatus("moa-auto", undefined); ctx.ui.notify("MOA 自动模式已关闭。", "info"); } }, }); // ------------------------------------------------------------- lifecycle // Restore the pre-MOA model once the MOA turn fully settles (U5). `agent_settled` // fires only when pi won't continue automatically (no retry/compaction/follow-up), // so queued follow-ups still run on the aggregator before the restore. pi.on("agent_settled", async (_event, ctx) => { if (!pendingRestore) return; const model = pendingRestore; pendingRestore = undefined; ctx.ui.setStatus("moa", undefined); try { await pi.setModel(model); ctx.ui.notify(`MOA 完成,已恢复模型为 ${modelLabel(model)}。`, "info"); } catch { // Restore is best-effort; leave the model as-is on failure. } }); // 自动模式:拦截用户输入,把普通消息改写为 MOA 组装消息(参考模型并行 + 主模型)。 // 处理 interactive(TUI)与 rpc(pi-web 等远端界面)两种来源;斜杠命令在到达 // 这里前已被命令处理器接管,而 /moa 命令内部用 sendUserMessage(source === // "extension")发送的组装消息不会再次触发 fan-out,从而避免双重 MOA。 pi.on("input", async (event, ctx) => { if (event.source === "extension") return; const cfg = loadConfig(); if (!cfg?.auto_mode) return; const prompt = event.text.trim(); if (!prompt) return; const preset = cfg.presets[cfg.default_preset ?? DEFAULT_PRESET_NAME]; if (!preset || preset.reference_models.length === 0) return; moaLog("auto 输入触发:", `source=${event.source}`, `textLen=${prompt.length}`); ctx.ui.setStatus("moa-auto", "MOA 自动:参考模型生成中…"); try { const assembled = await runAutoFanout(ctx, prompt, preset); if (!assembled) return; // runAutoFanout 已 notify 具体错误 return { action: "transform" as const, text: assembled, images: event.images }; } finally { ctx.ui.setStatus("moa-auto", "MOA 自动:开"); } }); // Reset in-memory state when a new session starts (U7), and restore the // auto-mode status indicator if it is enabled in config. pi.on("session_start", (_event, ctx) => { pendingRestore = undefined; const cfg = loadConfig(); if (cfg?.auto_mode) { ctx.ui.setStatus("moa-auto", "MOA 自动:开"); } }); }