import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, TextContent, ToolCall } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { getAgentDir, type ContextEvent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; const CONFIG_ENTRY_TYPE = "pi-prompt-translate-config"; const STATE_ENTRY_TYPE = "pi-prompt-translate-state"; const FINAL_TRANSLATION_ENTRY_TYPE = "pi-prompt-translate-final-translation"; type TranslateModelSetting = "current" | "default" | `${string}/${string}`; type TranslateConfig = { enabled: boolean; translateResponses: boolean; targetLanguage: string; translateModel: TranslateModelSetting; debug: boolean; }; type PendingTranslation = { turnIndex?: number; targetLanguage: string; translateResponses: boolean; }; type TranslationUsage = AssistantMessage["usage"]; type TranslationResult = { text: string; usage: TranslationUsage; }; type ProtectedSegment = { placeholder: string; value: string; }; type ProtectedText = { text: string; segments: ProtectedSegment[]; }; type FinalTranslationRecord = { at: string; targetLanguage: string; english: string; translated: string; translateModel: TranslateModelSetting; usage?: TranslationUsage; }; type ModelWithAuth = { model: Model; apiKey?: string; headers?: Record; env?: Record; }; type ExtensionGenerationTraceOptions = { name?: string; extension?: string; purpose?: string; metadata?: Record; }; type InstrumentedCompleteSimpleOptions = SimpleStreamOptions & { trace?: ExtensionGenerationTraceOptions; }; type InstrumentedCompleteSimple = ( model: Model, context: Context, options?: InstrumentedCompleteSimpleOptions ) => Promise; type ExtensionContextWithCompleteSimple = ExtensionContext & { completeSimple?: InstrumentedCompleteSimple; }; const DEFAULT_CONFIG: TranslateConfig = { enabled: true, translateResponses: false, targetLanguage: "Korean", translateModel: "current", debug: false, }; const ENGLISH_ONLY_AGENT_INSTRUCTION = [ "pi-prompt-translate is active for this turn.", "The user's original prompt may have been translated into English before this request reached you.", "Do all actual work, tool-use planning, tool call arguments, intermediate assistant messages, and final assistant answer in English.", "Do not answer in the user's original language or the configured target language during the agent run.", ].join("\n"); const LEGACY_RESPONSE_TRANSLATION_INSTRUCTION = "If response translation is enabled, the extension will translate only the final English work briefing/final answer after the turn is complete."; const RESPONSE_TRANSLATION_ENABLED_INSTRUCTION = "Response translation is enabled; the extension will translate only the final English work briefing/final answer after the turn is complete."; const RESPONSE_TRANSLATION_DISABLED_INSTRUCTION = "Response translation is disabled; the extension will not translate the final answer, so keep the final answer in English."; let config: TranslateConfig = { ...DEFAULT_CONFIG }; let pending: PendingTranslation | undefined; let finalTranslationByDisplayedText = new Map(); function getText(message: AssistantMessage): string { return message.content .filter((part): part is TextContent => part.type === "text") .map((part) => part.text) .join(""); } function hasToolCall(message: AssistantMessage): boolean { return message.content.some((part): part is ToolCall => part.type === "toolCall"); } function withSingleText(message: AssistantMessage, text: string): AssistantMessage { const nextContent = message.content.filter((part) => part.type !== "text"); return { ...message, content: [...nextContent, { type: "text", text }], }; } function estimateTranslationMaxTokens(model: Model, text: string): number { const modelLimit = Math.max(1, Math.min(model.maxTokens ?? 4096, 8192)); const estimatedOutput = Math.ceil(text.length / 2) + 96; return Math.max(64, Math.min(modelLimit, estimatedOutput)); } function shouldProtectTagName(tagName: string): boolean { const normalized = tagName.toLowerCase(); return normalized.includes("action") || normalized === "pi-autoprompt-next"; } function protectFinalAnswerSegments(text: string): ProtectedText { const segments: ProtectedSegment[] = []; let protectedText = text; const addSegment = (value: string) => { const placeholder = `__PI_PROMPT_TRANSLATE_PROTECTED_${segments.length}__`; segments.push({ placeholder, value }); return placeholder; }; protectedText = protectedText.replace(/<([A-Za-z][\w:-]*)\b[^>]*>[\s\S]*?<\/\1>/g, (match, tagName: string) => shouldProtectTagName(tagName) ? addSegment(match) : match ); protectedText = protectedText.replace(/<([A-Za-z][\w:-]*)\b[^>]*\/>/g, (match, tagName: string) => shouldProtectTagName(tagName) ? addSegment(match) : match ); return { text: protectedText, segments }; } function restoreProtectedSegments(text: string, segments: ProtectedSegment[]): string { let restored = text; for (const segment of segments) { restored = restored.split(segment.placeholder).join(segment.value); } return restored; } function createTranslationContext(systemPrompt: string, text: string): Context { return { systemPrompt, // Omit volatile timestamps from translation-only requests to improve provider prompt-cache hits. messages: [{ role: "user", content: text } as never], tools: undefined, }; } function normalizeLanguage(input: string): string { const value = input.trim().toLowerCase(); const aliases: Record = { ar: "Arabic", arabic: "Arabic", 아랍어: "Arabic", العربية: "Arabic", cn: "Chinese", chinese: "Chinese", zh: "Chinese", zhcn: "Chinese", "zh-cn": "Chinese", 중국어: "Chinese", 中文: "Chinese", de: "German", deu: "German", german: "German", 독일어: "German", deutsch: "German", en: "English", eng: "English", english: "English", 영어: "English", es: "Spanish", esp: "Spanish", spanish: "Spanish", 스페인어: "Spanish", español: "Spanish", fr: "French", fra: "French", fre: "French", french: "French", 프랑스어: "French", français: "French", hi: "Hindi", hin: "Hindi", hindi: "Hindi", 힌디어: "Hindi", हिन्दी: "Hindi", id: "Indonesian", ind: "Indonesian", indonesian: "Indonesian", 인도네시아어: "Indonesian", "bahasa indonesia": "Indonesian", it: "Italian", ita: "Italian", italian: "Italian", 이탈리아어: "Italian", italiano: "Italian", ja: "Japanese", jp: "Japanese", japanese: "Japanese", 일본어: "Japanese", 日本語: "Japanese", ko: "Korean", kor: "Korean", korean: "Korean", 한국어: "Korean", 한글: "Korean", nl: "Dutch", dut: "Dutch", nld: "Dutch", dutch: "Dutch", 네덜란드어: "Dutch", nederlands: "Dutch", pl: "Polish", pol: "Polish", polish: "Polish", 폴란드어: "Polish", polski: "Polish", pt: "Portuguese", por: "Portuguese", portuguese: "Portuguese", 포르투갈어: "Portuguese", português: "Portuguese", ru: "Russian", rus: "Russian", russian: "Russian", 러시아어: "Russian", русский: "Russian", th: "Thai", tha: "Thai", thai: "Thai", 태국어: "Thai", ไทย: "Thai", tr: "Turkish", tur: "Turkish", turkish: "Turkish", 터키어: "Turkish", türkçe: "Turkish", vi: "Vietnamese", vie: "Vietnamese", vietnamese: "Vietnamese", 베트남어: "Vietnamese", "tiếng việt": "Vietnamese", }; return aliases[value] ?? input.trim(); } function normalizeConfig(value: Partial): TranslateConfig { return { ...DEFAULT_CONFIG, ...value, translateResponses: value.translateResponses ?? DEFAULT_CONFIG.translateResponses, translateModel: value.translateModel ?? DEFAULT_CONFIG.translateModel, debug: value.debug ?? DEFAULT_CONFIG.debug, }; } function extractLatestConfig(ctx: ExtensionContext): TranslateConfig { let latest = { ...DEFAULT_CONFIG }; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === CONFIG_ENTRY_TYPE && entry.data && typeof entry.data === "object") { latest = normalizeConfig({ ...latest, ...(entry.data as Partial) }); } } return latest; } function rebuildFinalTranslationMap(ctx: ExtensionContext) { const next = new Map(); for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom" || entry.customType !== FINAL_TRANSLATION_ENTRY_TYPE || !entry.data || typeof entry.data !== "object") { continue; } const record = entry.data as Partial; if (typeof record.translated === "string" && typeof record.english === "string") { next.set(record.translated, record.english); } } finalTranslationByDisplayedText = next; } function persistConfig(pi: ExtensionAPI) { pi.appendEntry(CONFIG_ENTRY_TYPE, config); } function rememberFinalTranslation(pi: ExtensionAPI, record: FinalTranslationRecord) { finalTranslationByDisplayedText.set(record.translated, record.english); pi.appendEntry(FINAL_TRANSLATION_ENTRY_TYPE, record); } function replaceDisplayedAssistantTextWithEnglish(message: ContextEvent["messages"][number]): ContextEvent["messages"][number] { if (message.role !== "assistant") return message; let changed = false; const content = message.content.map((part) => { if (part.type !== "text") return part; const english = finalTranslationByDisplayedText.get(part.text.trim()); if (!english) return part; changed = true; return { ...part, text: english }; }); return changed ? { ...message, content } : message; } function buildEnglishOnlyInstruction(translateResponses: boolean): string { return [ ENGLISH_ONLY_AGENT_INSTRUCTION, translateResponses ? RESPONSE_TRANSLATION_ENABLED_INSTRUCTION : RESPONSE_TRANSLATION_DISABLED_INSTRUCTION, ].join("\n"); } function stripResponseTranslationInstructions(systemPrompt: string): string { return systemPrompt .split("\n") .filter( (line) => line !== LEGACY_RESPONSE_TRANSLATION_INSTRUCTION && line !== RESPONSE_TRANSLATION_ENABLED_INSTRUCTION && line !== RESPONSE_TRANSLATION_DISABLED_INSTRUCTION ) .join("\n"); } function appendEnglishOnlyInstruction(systemPrompt: string, translateResponses = config.translateResponses): string { const responseInstruction = translateResponses ? RESPONSE_TRANSLATION_ENABLED_INSTRUCTION : RESPONSE_TRANSLATION_DISABLED_INSTRUCTION; if (systemPrompt.includes(ENGLISH_ONLY_AGENT_INSTRUCTION)) { const stripped = stripResponseTranslationInstructions(systemPrompt); return `${stripped}\n${responseInstruction}`; } return `${systemPrompt}\n\n${buildEnglishOnlyInstruction(translateResponses)}`; } function readDefaultModel(): { provider?: string; model?: string } { try { const settings = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8")) as { defaultProvider?: string; defaultModel?: string; }; return { provider: settings.defaultProvider, model: settings.defaultModel }; } catch { return {}; } } function modelLabel(model: Model | undefined): string { return model ? `${model.provider}/${model.id}` : "none"; } async function resolveConfiguredModel(ctx: ExtensionContext): Promise> { if (config.translateModel === "current") { const model = ctx.model as Model | undefined; if (!model) throw new Error("No active model is selected."); return model; } if (config.translateModel === "default") { const { provider, model } = readDefaultModel(); if (!provider || !model) throw new Error("defaultProvider/defaultModel is not configured in pi settings."); const found = ctx.modelRegistry.find(provider, model) as Model | undefined; if (!found) throw new Error(`Default model not found: ${provider}/${model}`); return found; } const slash = config.translateModel.indexOf("/"); const provider = config.translateModel.slice(0, slash); const modelId = config.translateModel.slice(slash + 1); const found = ctx.modelRegistry.find(provider, modelId) as Model | undefined; if (!found) throw new Error(`Translation model not found: ${config.translateModel}`); return found; } async function getModelAndAuth(ctx: ExtensionContext): Promise { const model = await resolveConfiguredModel(ctx); const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok) throw new Error(auth.error); return { model, apiKey: auth.apiKey, headers: auth.headers, env: auth.env }; } function debug(ctx: ExtensionContext, message: string) { if (config.debug && ctx.hasUI) ctx.ui.notify(`[prompt-translate] ${message}`, "info"); } function formatUsage(usage: TranslationUsage): string { const cost = usage.cost?.total; const costText = typeof cost === "number" ? `, cost=$${cost.toFixed(6)}` : ""; return `input=${usage.input}, output=${usage.output}, cacheRead=${usage.cacheRead}, cacheWrite=${usage.cacheWrite}, total=${usage.totalTokens}${costText}`; } async function translate(ctx: ExtensionContext, text: string, targetLanguage: string, purpose: "prompt" | "answer"): Promise { const { model, apiKey, headers, env } = await getModelAndAuth(ctx); debug(ctx, `${purpose} translation with ${modelLabel(model)}`); const protectedInput = purpose === "answer" ? protectFinalAnswerSegments(text) : { text, segments: [] }; const systemPrompt = purpose === "prompt" ? [ "Translate to English. Output only the translation.", "Keep code, paths, commands, markdown, placeholders, JSON, XML-like tags, and technical terms unchanged when appropriate.", ].join("\n") : [ `Translate to ${targetLanguage}. Output only the translation.`, "Keep code, paths, commands, markdown, JSON, placeholders, XML-like tags, machine-readable sections, and protected tokens unchanged.", "Never alter, translate, remove, or add content inside placeholders like __PI_PROMPT_TRANSLATE_PROTECTED_0__.", ].join("\n"); const llmContext = createTranslationContext(systemPrompt, protectedInput.text); const options: SimpleStreamOptions = { apiKey, headers, env, maxTokens: estimateTranslationMaxTokens(model, protectedInput.text), signal: ctx.signal, sessionId: ctx.sessionManager.getSessionId(), }; const instrumentedCompleteSimple = (ctx as ExtensionContextWithCompleteSimple).completeSimple; const shouldUseInstrumentedTranslation = purpose === "prompt" && instrumentedCompleteSimple; const response = shouldUseInstrumentedTranslation ? await instrumentedCompleteSimple(model, llmContext, { ...options, trace: { name: "prompt-translation", extension: "pi-prompt-translate", purpose, metadata: { targetLanguage }, }, }) : await completeSimple(model, llmContext, options); if (response.stopReason === "error" || response.stopReason === "aborted") { throw new Error(response.errorMessage ?? `Translation failed: ${response.stopReason}`); } debug(ctx, `${purpose} translation usage: ${formatUsage(response.usage)}`); const translatedText = restoreProtectedSegments(getText(response).trim(), protectedInput.segments); return { text: translatedText, usage: response.usage }; } function parseModelSetting(raw: string): TranslateModelSetting | undefined { const value = raw.trim(); if (value === "current" || value === "default") return value; if (/^[^\s/]+\/.+$/.test(value)) return value as `${string}/${string}`; return undefined; } export const __test = { CONFIG_ENTRY_TYPE, FINAL_TRANSLATION_ENTRY_TYPE, STATE_ENTRY_TYPE, DEFAULT_CONFIG, ENGLISH_ONLY_AGENT_INSTRUCTION, appendEnglishOnlyInstruction, buildEnglishOnlyInstruction, extractLatestConfig, getText, hasToolCall, normalizeConfig, normalizeLanguage, createTranslationContext, estimateTranslationMaxTokens, parseModelSetting, protectFinalAnswerSegments, rebuildFinalTranslationMap, restoreProtectedSegments, rememberFinalTranslation, replaceDisplayedAssistantTextWithEnglish, resetState() { config = { ...DEFAULT_CONFIG }; pending = undefined; finalTranslationByDisplayedText = new Map(); }, setConfig(next: TranslateConfig) { config = { ...next }; }, withSingleText, }; async function statusText(ctx: ExtensionContext): Promise { let configuredModel = config.translateModel; let resolved = "unavailable"; try { resolved = modelLabel(await resolveConfiguredModel(ctx)); } catch (error) { resolved = `error: ${error instanceof Error ? error.message : String(error)}`; } return [ `prompt-translate input: ${config.enabled ? "on" : "off"}`, `responses=${config.translateResponses ? "on" : "off"}`, `target=${config.targetLanguage}`, `translateModel=${configuredModel}`, `resolvedTranslateModel=${resolved}`, `currentModel=${modelLabel(ctx.model as Model | undefined)}`, `debug=${config.debug ? "on" : "off"}`, ].join(", "); } export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { config = extractLatestConfig(ctx); rebuildFinalTranslationMap(ctx); if (ctx.hasUI) { ctx.ui.notify( `pi-prompt-translate input ${config.enabled ? "on" : "off"}, responses ${config.translateResponses ? "on" : "off"} (target: ${config.targetLanguage}, model: ${config.translateModel})`, "info" ); } }); pi.registerCommand("prompt-translate", { description: "Translate prompts to English and optionally translate final replies back to the configured language.", handler: async (args, ctx) => { const [subcommand, ...rest] = args.trim().split(/\s+/).filter(Boolean); if (!subcommand || subcommand === "status") { ctx.ui.notify(await statusText(ctx), "info"); return; } if (subcommand === "on" || subcommand === "enable") { config.enabled = true; persistConfig(pi); ctx.ui.notify(`prompt-translate input enabled (target: ${config.targetLanguage})`, "info"); return; } if (subcommand === "off" || subcommand === "disable") { config.enabled = false; pending = undefined; persistConfig(pi); ctx.ui.notify("prompt-translate input disabled", "info"); return; } if (["input", "prompt", "prompts"].includes(subcommand)) { const value = rest[0]; if (value !== "on" && value !== "off") { ctx.ui.notify("Usage: /prompt-translate input on|off", "warning"); return; } config.enabled = value === "on"; if (!config.enabled) pending = undefined; persistConfig(pi); ctx.ui.notify(`prompt-translate input ${value}`, "info"); return; } if (["response", "responses", "answer", "answers", "reply", "replies"].includes(subcommand)) { const value = rest[0]; if (value !== "on" && value !== "off") { ctx.ui.notify("Usage: /prompt-translate responses on|off", "warning"); return; } config.translateResponses = value === "on"; if (pending) pending.translateResponses = config.translateResponses; persistConfig(pi); ctx.ui.notify(`prompt-translate responses ${value}`, "info"); return; } if (subcommand === "lang" || subcommand === "language" || subcommand === "target") { const language = normalizeLanguage(rest.join(" ")); if (!language) { ctx.ui.notify("Usage: /prompt-translate lang ", "warning"); return; } config.targetLanguage = language; persistConfig(pi); ctx.ui.notify(`prompt-translate target language set to ${language}`, "info"); return; } if (subcommand === "model") { const modelSetting = parseModelSetting(rest.join(" ")); if (!modelSetting) { ctx.ui.notify("Usage: /prompt-translate model current|default|/", "warning"); return; } config.translateModel = modelSetting; persistConfig(pi); ctx.ui.notify(`prompt-translate translation model set to ${modelSetting}`, "info"); return; } if (subcommand === "debug") { const value = rest[0]; if (value !== "on" && value !== "off") { ctx.ui.notify("Usage: /prompt-translate debug on|off", "warning"); return; } config.debug = value === "on"; persistConfig(pi); ctx.ui.notify(`prompt-translate debug ${value}`, "info"); return; } if (subcommand === "reset") { config = { ...DEFAULT_CONFIG }; pending = undefined; persistConfig(pi); ctx.ui.notify("prompt-translate settings reset", "info"); return; } if (subcommand === "help") { ctx.ui.notify( "/prompt-translate on|off|status|input on|off|responses on|off|lang |model current|default|/|debug on|off|reset", "info" ); return; } ctx.ui.notify("Unknown command. Use: /prompt-translate help", "warning"); }, }); pi.on("input", async (event, ctx) => { if (!config.enabled || event.source === "extension" || event.images?.length || event.text.trim().startsWith("/")) { return { action: "continue" }; } try { if (ctx.hasUI) ctx.ui.notify("prompt-translate: translating prompt to English...", "info"); const translated = await translate(ctx, event.text, "English", "prompt"); pending = { targetLanguage: config.targetLanguage, translateResponses: config.translateResponses }; pi.appendEntry(STATE_ENTRY_TYPE, { at: new Date().toISOString(), source: event.text, english: translated.text, targetLanguage: config.targetLanguage, translateModel: config.translateModel, usage: translated.usage, }); return { action: "transform", text: translated.text }; } catch (error) { ctx.ui.notify(`prompt translation failed; continuing with original prompt: ${error instanceof Error ? error.message : String(error)}`, "error"); return { action: "continue" }; } }); pi.on("before_agent_start", (event, ctx) => { if (!pending) return; debug( ctx, pending.translateResponses ? "forcing agent run language to English; final briefing will be translated after completion" : "forcing agent run language to English; final briefing translation is disabled" ); return { systemPrompt: appendEnglishOnlyInstruction(event.systemPrompt, pending.translateResponses) }; }); pi.on("context", (event) => { if (!config.enabled || finalTranslationByDisplayedText.size === 0) return; const messages = event.messages.map(replaceDisplayedAssistantTextWithEnglish); if (messages.some((message, index) => message !== event.messages[index])) return { messages }; }); pi.on("turn_start", (event) => { if (pending && pending.turnIndex === undefined) pending.turnIndex = event.turnIndex; }); pi.on("message_end", async (event, ctx) => { if (!pending || event.message.role !== "assistant") return; // Do not translate tool-calling assistant messages. Keep the pending translation // request alive so the final work briefing after tool execution is translated. if (event.message.stopReason === "toolUse" || hasToolCall(event.message)) return; const finalText = getText(event.message); if (!finalText.trim()) return; const current = pending; pending = undefined; if (!current.translateResponses) return; try { const translated = await translate(ctx, finalText, current.targetLanguage, "answer"); rememberFinalTranslation(pi, { at: new Date().toISOString(), targetLanguage: current.targetLanguage, english: finalText.trim(), translated: translated.text, translateModel: config.translateModel, usage: translated.usage, }); return { message: withSingleText(event.message, translated.text) }; } catch (error) { ctx.ui.notify(`answer translation failed; keeping original answer: ${error instanceof Error ? error.message : String(error)}`, "error"); } }); }