import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { logInternalError } from "./utils/internal-error.ts"; type Params = Record; const namespace = "pi-crew"; const TEMPLATE_RE = /\{(\w+)\}/g; const fallback = { "agent.requiresPrompt": "Agent requires prompt.", "agent.started": "Agent {state}.", "agent.id": "Agent ID: {id}", "agent.type": "Type: {type}", "agent.description": "Description: {description}", "agent.retrieveHint": "Use get_subagent_result to retrieve output. Do not duplicate this agent's work.", "agent.foregroundStatus": "Agent {id} {status}.", "agent.noOutput": "No output.", "result.requiresAgentId": "get_subagent_result requires agent_id.", "result.notFound": "Agent not found: {id}", "result.unrecoverable": "Subagent was interrupted before its durable run id was recorded; it cannot be recovered after restart.", "result.waitAborted": "Waiting for subagent result was aborted.", "result.waitTimeout": "Timed out waiting for subagent result.", "result.stillRunning": "Agent is still running. Use wait=true or check again later.", "steer.noted": "Steering request noted for {id}.", "steer.unavailable": "Current default pi-crew backend is child-process, so mid-turn session.steer is not available yet.", "steer.cancelHint": "Use team cancel runId={runId} if the agent must be interrupted.", // PR-F12 (EXT-12): team tool user-facing strings routed through t() for // consistency with the subagent tools (Agent / get_subagent_result). "team.run.created": "Created pi-crew run {runId}.", "team.run.completed": "pi-crew run {status}: {runId} ({team})", "team.run.allCompleted": "All tasks completed successfully.", "team.run.tasksFailed": "{count} task(s) failed: {ids}. Consider retrying.", "team.unknownAction": "Unknown action: {action}", } as const; type Key = keyof typeof fallback; /** Map of locale → partial translations. Keys not present fall back to English. */ const translations: Record>> = { es: { "agent.requiresPrompt": "Agent requiere prompt.", "agent.started": "Agent {state}.", "agent.id": "ID del agente: {id}", "agent.type": "Tipo: {type}", "agent.description": "Descripción: {description}", "agent.retrieveHint": "Usa get_subagent_result para recuperar la salida. No dupliques el trabajo de este agente.", "agent.foregroundStatus": "Agent {id} {status}.", "agent.noOutput": "Sin salida.", "result.requiresAgentId": "get_subagent_result requiere agent_id.", "result.notFound": "Agente no encontrado: {id}", "result.unrecoverable": "El subagente fue interrumpido antes de registrar su ID de ejecución duradero; no se puede recuperar tras reiniciar.", "result.waitAborted": "Se canceló la espera del resultado del subagente.", "result.waitTimeout": "Se agotó el tiempo de espera del resultado del subagente.", "result.stillRunning": "El agente sigue ejecutándose. Usa wait=true o vuelve a comprobar más tarde.", "steer.noted": "Solicitud de dirección registrada para {id}.", "steer.unavailable": "El backend predeterminado actual de pi-crew es child-process, así que session.steer a mitad de turno aún no está disponible.", "steer.cancelHint": "Usa team cancel runId={runId} si hay que interrumpir el agente.", "team.run.created": "Ejecución de pi-crew creada {runId}.", "team.run.completed": "Ejecución de pi-crew {status}: {runId} ({team})", "team.run.allCompleted": "Todas las tareas se completaron con éxito.", "team.run.tasksFailed": "{count} tarea(s) fallaron: {ids}. Considera reintentar.", "team.unknownAction": "Acción desconocida: {action}", }, fr: { "agent.requiresPrompt": "Agent nécessite un prompt.", "agent.started": "Agent {state}.", "agent.id": "ID de l'agent : {id}", "agent.type": "Type : {type}", "agent.description": "Description : {description}", "agent.retrieveHint": "Utilisez get_subagent_result pour récupérer la sortie. Ne dupliquez pas le travail de cet agent.", "agent.foregroundStatus": "Agent {id} {status}.", "agent.noOutput": "Aucune sortie.", "result.requiresAgentId": "get_subagent_result nécessite agent_id.", "result.notFound": "Agent introuvable : {id}", "result.unrecoverable": "Le sous-agent a été interrompu avant l'enregistrement de son ID d'exécution durable ; il ne peut pas être récupéré après redémarrage.", "result.waitAborted": "L'attente du résultat du sous-agent a été annulée.", "result.waitTimeout": "Délai d'attente du résultat du sous-agent dépassé.", "result.stillRunning": "L'agent est toujours en cours d'exécution. Utilisez wait=true ou réessayez plus tard.", "steer.noted": "Demande de pilotage enregistrée pour {id}.", "steer.unavailable": "Le backend pi-crew par défaut actuel est child-process, donc session.steer en milieu de tour n'est pas encore disponible.", "steer.cancelHint": "Utilisez team cancel runId={runId} si l'agent doit être interrompu.", "team.run.created": "Exécution pi-crew créée {runId}.", "team.run.completed": "Exécution pi-crew {status} : {runId} ({team})", "team.run.allCompleted": "Toutes les tâches ont été effectuées avec succès.", "team.run.tasksFailed": "{count} tâche(s) ont échoué : {ids}. Envisagez de réessayer.", "team.unknownAction": "Action inconnue : {action}", }, "pt-BR": { "agent.requiresPrompt": "Agent requer prompt.", "agent.started": "Agent {state}.", "agent.id": "ID do agente: {id}", "agent.type": "Tipo: {type}", "agent.description": "Descrição: {description}", "agent.retrieveHint": "Use get_subagent_result para recuperar a saída. Não duplique o trabalho deste agente.", "agent.foregroundStatus": "Agent {id} {status}.", "agent.noOutput": "Sem saída.", "result.requiresAgentId": "get_subagent_result requer agent_id.", "result.notFound": "Agente não encontrado: {id}", "result.unrecoverable": "O subagente foi interrompido antes que seu ID de execução durável fosse registrado; ele não pode ser recuperado após reiniciar.", "result.waitAborted": "A espera pelo resultado do subagente foi abortada.", "result.waitTimeout": "Tempo limite de espera pelo resultado do subagente esgotado.", "result.stillRunning": "O agente ainda está em execução. Use wait=true ou verifique novamente mais tarde.", "steer.noted": "Solicitação de orientação registrada para {id}.", "steer.unavailable": "O backend padrão atual do pi-crew é child-process, então session.steer no meio do turno ainda não está disponível.", "steer.cancelHint": "Use team cancel runId={runId} se o agente precisar ser interrompido.", "team.run.created": "Execução do pi-crew criada {runId}.", "team.run.completed": "Execução do pi-crew {status}: {runId} ({team})", "team.run.allCompleted": "Todas as tarefas foram concluídas com sucesso.", "team.run.tasksFailed": "{count} tarefa(s) falharam: {ids}. Considere tentar novamente.", "team.unknownAction": "Ação desconhecida: {action}", }, }; // --- Runtime state --- let currentLocale: string | undefined; const warnedMissing = new Set(); // R5-L5/MISSED-1 (Round 5): warn-once dedupe Set — bounded naturally by the // translation-key count, but cap FIFO so a pathological locale/key space // cannot grow it unbounded in a long-lived host process. const MAX_WARNED_MISSING = 128; // --- Helpers --- function format(template: string, params: Params = {}): string { return template.replace(TEMPLATE_RE, (_match, key) => String(params[key] ?? `{${key}}`)); } function warnOnce(key: string): void { const tag = `${currentLocale}:${key}`; if (warnedMissing.has(tag)) return; warnedMissing.add(tag); if (warnedMissing.size > MAX_WARNED_MISSING) { const oldest = warnedMissing.values().next().value; if (oldest !== undefined) warnedMissing.delete(oldest); } logInternalError("i18n.missing", new Error(`Missing translation`), `key="${key}" locale="${currentLocale}"`); } // --- Public API --- /** * Translate a key for the currently active locale. * Falls back to English, then to the raw key as a last resort. */ export function t(key: Key, params?: Params): string { if (currentLocale && translations[currentLocale]) { const template = translations[currentLocale]?.[key]; if (template) return format(template, params); warnOnce(key); } return format(fallback[key] ?? key, params); } /** * Register or extend translations for a locale at runtime. * Useful for contributors adding new language bundles without modifying i18n.ts. * * @example * addTranslations("vi", { "agent.requiresPrompt": "Agent cần prompt." }) */ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); function stripDangerousKeys>(obj: T): T { const safe: Record = {}; for (const key of Object.keys(obj)) { if (!DANGEROUS_KEYS.has(key)) { safe[key] = obj[key]; } } return safe as T; } export function addTranslations(locale: string, bundle: Partial>): void { if (!locale) return; const safeBundle = stripDangerousKeys(bundle as Record) as Partial>; const existing = translations[locale]; if (existing) { Object.assign(existing, safeBundle); } else { translations[locale] = { ...safeBundle }; } } /** * Returns the list of currently registered locales (excluding English, which is always available). */ export function listLocales(): string[] { return Object.keys(translations); } // --- Initialization --- export function initI18n(pi: ExtensionAPI): () => void { try { pi.events?.emit?.("pi-core/i18n/registerBundle", { namespace, defaultLocale: "en", fallback, translations, }); } catch { // Non-critical. } const unsubscribe = pi.events?.on?.("pi-core/i18n/localeChanged", (event: unknown) => { if (!event || typeof event !== "object") return; const raw = String((event as { locale?: unknown }).locale ?? "").trim(); currentLocale = raw && translations[raw] ? raw : undefined; }); try { pi.events?.emit?.("pi-core/i18n/requestApi", { namespace, onApi(api: { getLocale?: () => string | undefined }) { const raw = api.getLocale?.()?.trim(); if (raw && translations[raw]) currentLocale = raw; }, }); } catch { // Non-critical. } return () => { currentLocale = undefined; unsubscribe?.(); }; } // --- Test helpers --- export function __test__resetI18n(): void { currentLocale = undefined; warnedMissing.clear(); // Clear runtime-added translations but keep built-in ones. for (const key of Object.keys(translations)) { if (!["es", "fr", "pt-BR"].includes(key)) delete translations[key]; } }