import { join } from "node:path"; import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { DEFAULTS, effectiveInstructions, loadOrCreateSettings, parseSettings, readJevKey, saveSettings, type Settings } from "../lib/settings.ts"; import { editorPrompt, eligibleDraft, judge, localDecision, parseEdited, textContent } from "../lib/review.ts"; type SettingsWeb = Awaited>; const PLUGIN = "pi-jev-reply"; type Badge = { text: string }; function status(ctx: ExtensionContext, text?: string) { try { ctx.ui.setStatus(PLUGIN, text); } catch { /* 页脚状态是提示,不是门禁 */ } } async function limited(milliseconds: number, signal: AbortSignal, run: (signal: AbortSignal) => Promise): Promise { const timeout = new AbortController(); const combined = AbortSignal.any([signal, timeout.signal]); const timer = setTimeout(() => timeout.abort(), milliseconds); timer.unref(); let onAbort: (() => void) | undefined; try { combined.throwIfAborted(); return await Promise.race([ run(combined), new Promise((_, reject) => { onAbort = () => reject(new Error("Reply processing cancelled or timed out")); combined.addEventListener("abort", onAbort, { once: true }); if (combined.aborted) onAbort(); }), ]); } finally { clearTimeout(timer); if (onAbort) combined.removeEventListener("abort", onAbort); } } export default function clearReply(pi: ExtensionAPI) { pi.registerEntryRenderer(PLUGIN, (entry, _, theme) => { const detail = entry.data?.text ? ` ${entry.data.text}` : ""; return new Text(theme.fg("accent", PLUGIN) + theme.fg("dim", detail), 0, 0); }); pi.registerEntryRenderer("clear-reply", (entry, _, theme) => { const data = entry.data as { text?: string; rewrite?: boolean; visual?: string; model?: string } | undefined; const text = data?.text || [data?.rewrite ? "rewrite" : "", data?.visual && data.visual !== "none" ? data.visual : "", data?.model].filter(Boolean).join(" · "); return new Text(theme.fg("accent", PLUGIN) + theme.fg("dim", text ? ` ${text}` : ""), 0, 0); }); const settingsPath = () => join(getAgentDir(), "clear-reply.json"); let settings: Settings = { ...DEFAULTS }; let key = ""; let context: ExtensionContext | undefined; let lifetime = new AbortController(); let web: SettingsWeb | undefined; let opening: Promise | undefined; let warned = false; let configError = false; const copy = (en: string, zh: string) => settings.language === "zh-CN" ? zh : en; const active = () => settings.enabled && !configError && (settings.rewrite || settings.visuals); const lookupModel = (ctx: ExtensionContext, value: string) => { if (!value) return undefined; const slash = value.indexOf("/"); return slash >= 0 ? ctx.modelRegistry.find(value.slice(0, slash), value.slice(slash + 1)) : undefined; }; const fallbackModel = (ctx: ExtensionContext) => { if (ctx.model) return ctx.model; const available = ctx.modelRegistry.getAvailable?.() ?? []; return available.find(model => model.id === "other") ?? available[0]; }; const closeSettings = () => { web?.close(); web = undefined; const pending = opening; opening = undefined; void pending?.then(server => server.close(), () => {}); }; const reset = () => { lifetime.abort(); lifetime = new AbortController(); closeSettings(); warned = false; }; const chooseModel = (ctx: ExtensionContext, value: string) => lookupModel(ctx, value) ?? fallbackModel(ctx); pi.registerMarkdownTransformer((markdown, info) => { // ponytail: native Pi TUI only; custom transcript renderers and RPC consumers must buffer their own stream. return context?.mode === "tui" && active() && settings.hideDraft && info.messageType === "assistant" && info.isStreaming ? "" : markdown; }); pi.on("session_start", async (_, ctx) => { reset(); context = ctx; try { const loaded = await loadOrCreateSettings(settingsPath()); settings = loaded.settings; configError = false; } catch { configError = true; ctx.ui.notify("pi-jev-reply: cannot read settings. Existing files were not changed.", "warning"); } try { key = await readJevKey(); } catch { key = ""; ctx.ui.notify("pi-jev-reply: Jev credentials could not be read. You can still open settings.", "warning"); } if (!configError && settings.onboardingPending && ctx.mode === "tui") { await openSettings(ctx, true); } }); pi.on("session_before_switch", () => { reset(); }); pi.on("session_before_fork", () => { reset(); }); pi.on("session_before_tree", () => { reset(); }); pi.on("model_select", (_, ctx) => { context = ctx; }); pi.on("session_shutdown", () => { lifetime.abort(); closeSettings(); }); pi.on("message_end", async (event, ctx) => { context = ctx; const message = event.message; if (ctx.mode !== "tui" || !active() || message.role !== "assistant" || message.stopReason !== "stop" || message.content.some(part => part.type === "toolCall") || ctx.hasPendingMessages()) return; const draft = textContent(message.content); const userEntry = [...ctx.sessionManager.getBranch()].reverse().find(entry => entry.type === "message" && entry.message.role === "user"); const request = userEntry?.type === "message" && userEntry.message.role === "user" ? textContent(userEntry.message.content).slice(0, 2000) : ""; if (!eligibleDraft(draft, `${request}\n${effectiveInstructions(settings)}`)) return; const revision = settings; const current = { ...revision }; const operation = lifetime; const signal = AbortSignal.any([operation.signal, ...(ctx.signal ? [ctx.signal] : [])]); status(ctx, PLUGIN); try { const decision = key ? await limited(current.reviewTimeoutMs, signal, child => judge(draft, request, current, key, child)) : localDecision(current, request); if (!decision.rewrite && decision.visual === "none") return; const model = chooseModel(ctx, current.rewriteModel); if (!model) throw new Error("Rewrite model is not available"); const result = await limited(current.rewriteTimeoutMs, signal, child => ctx.modelRegistry.complete(model, { systemPrompt: editorPrompt(current, decision), messages: [{ role: "user", content: [{ type: "text", text: JSON.stringify({ draft, user_request: request }) }], timestamp: Date.now() }], }, { signal: child, maxTokens: Math.min(model.maxTokens, 8192) })); signal.throwIfAborted(); if (result.stopReason !== "stop" || result.content.some(part => part.type === "toolCall")) throw new Error("Incomplete editor response"); const edited = `${parseEdited(textContent(result.content), draft, decision)}${polishNote(settings.language)}`; if (operation !== lifetime || settings !== revision || !settings.enabled || ctx.hasPendingMessages()) return; // No drafts, credentials or model reasoning in diagnostics. const record = settings.language === "zh-CN" ? [decision.rewrite ? "改写" : "", decision.visual !== "none" ? visualLabel(decision.visual) : "", modelLabel(model)] : [decision.rewrite ? "rewrite" : "", decision.visual !== "none" ? decision.visual : "", `${model.provider}/${model.id}`]; pi.appendEntry(PLUGIN, { text: record.filter(Boolean).join(" · ") }); return { message: { ...message, content: [ ...message.content.filter(part => part.type !== "text"), { type: "text" as const, text: edited }, ] } }; } catch { if (!signal.aborted && !warned) { warned = true; ctx.ui.notify(copy("pi-jev-reply could not finish checking this reply. The original was kept.", "pi-jev-reply 未完成检查,已保留原回复。"), "warning"); } } finally { status(ctx); } }); async function openSettings(ctx: ExtensionContext, welcome = false) { context = ctx; if (ctx.mode !== "tui") { ctx.ui.notify("pi-jev-reply HTML settings require Pi interactive mode.", "warning"); return; } const operation = lifetime; try { try { key = await readJevKey(); } catch { key = ""; } if (!web || web.closed) { opening ??= (async () => { const { startSettingsWeb } = await import("../lib/settings-web.ts"); return startSettingsWeb(() => { const current = context ?? ctx; const available = current.modelRegistry.getAvailable(); const modelId = (model: typeof available[number]) => `${model.provider}/${model.id}`; const byId = new Map(available.map(model => [modelId(model), model])); const preferred = new Set([ ...current.scopedModels.map(({ model }) => modelId(model)), ...(current.model ? [modelId(current.model)] : []), ].filter(id => byId.has(id))); const ids = new Set([...preferred, ...available.map(modelId)]); return { settings, defaults: DEFAULTS, keyAvailable: Boolean(key), settingsError: configError, currentModel: current.model ? `${current.model.provider}/${current.model.id}` : "", models: [...ids].map(id => ({ id, label: `${byId.get(id)!.name || id} (${id})`, preferred: preferred.has(id) })), }; }, async value => { if (configError) throw new TypeError("Repair clear-reply.json before saving"); const next = parseSettings(value); await saveSettings(settingsPath(), next); settings = next; configError = false; warned = false; }, () => settings.settingsIdleMinutes * 60000); })(); const pending = opening; try { const server = await pending; if (operation !== lifetime || operation.signal.aborted) { server.close(); return; } web = server; } finally { if (opening === pending) opening = undefined; } } web.touch(); const url = welcome ? web.welcomeUrl : web.url; const launch = () => process.platform === "darwin" ? pi.exec("open", [url]) : process.platform === "win32" ? pi.exec("rundll32.exe", ["url.dll,FileProtocolHandler", url]) : pi.exec("xdg-open", [url]); const result = await launch().catch(() => ({ code: 1 })); if (result.code !== 0) { ctx.ui.notify(copy(`Open locally: ${url} (or run /pi-jev-reply-setting welcome)`, `请在本机打开:${url}(或执行 /pi-jev-reply-setting welcome)`), "info"); } else if (welcome && settings.onboardingPending && operation === lifetime && !operation.signal.aborted) { const next = { ...settings, onboardingPending: false }; await saveSettings(settingsPath(), next); settings = next; } } catch { ctx.ui.notify(copy("Could not open the page. Run /pi-jev-reply-setting welcome to retry the guide, or /pi-jev-reply-setting for settings.", "无法打开设置,请检查文件权限后重试。"), "error"); } } const command = { description: "Reply settings and guide; usage: /pi-jev-reply-setting [settings|welcome|on|off|status]", handler: async (args: string, ctx: ExtensionContext) => { const action = args.trim().toLowerCase(); if (!action || action === "settings") { await openSettings(ctx); return; } if (action === "welcome") { await openSettings(ctx, true); return; } if (action === "status") { ctx.ui.notify(copy( `pi-jev-reply: ${active() ? "enabled" : "inactive"}; Jev key ${key ? "configured" : "not configured (Jev review skipped)"}; model ${settings.rewriteModel || "current main model"}.`, `pi-jev-reply:${active() ? "已启用" : "未启用"};Jev 密钥${key ? "已配置" : "未配置(跳过 Jev 审核)"};模型:${settings.rewriteModel || "当前主模型"}。`, ), "info"); return; } if (action !== "on" && action !== "off") { ctx.ui.notify("Usage: /pi-jev-reply-setting [settings|welcome|on|off|status]", "info"); return; } try { if (configError) throw new Error("Existing settings need repair"); const next = { ...settings, enabled: action === "on" }; await saveSettings(settingsPath(), next); settings = next; if (!next.enabled) { lifetime.abort(); lifetime = new AbortController(); } ctx.ui.notify(copy(`pi-jev-reply ${action}.`, `pi-jev-reply 已${action === "on" ? "开启" : "关闭"}。`), "info"); } catch { ctx.ui.notify(copy("Settings were not saved. Check the configuration file.", "设置未保存,请检查配置文件。"), "error"); } }, }; pi.registerCommand("pi-jev-reply-setting", command); pi.registerCommand("clear-reply", command); } function visualLabel(visual: string) { if (visual === "diagram") return "流程图"; if (visual === "chart") return "图表"; if (visual === "table") return "表格"; return visual; } function modelLabel(model: { provider: string; id: string; name?: string }) { return model.name || `${model.provider}/${model.id}`; } function polishNote(language: Settings["language"]) { return language === "zh-CN" ? "\n\n✦ 已经过 pi-jev-reply 魔法优化输出~" : "\n\n✦ pi-jev-reply gave this a little magic polish~"; }