744 lines
39 KiB
TypeScript
744 lines
39 KiB
TypeScript
/**
|
||
* CLI text internationalization (i18n).
|
||
*
|
||
* Language comes from the `PENGUIN_LANG` env var (`en` / `zh`), defaulting to English (en) —
|
||
* independent of Project config or CLI options. This module centralizes all user-visible text:
|
||
* command/option help descriptions and runtime output, one implementation per language.
|
||
*/
|
||
|
||
/** UI language. */
|
||
export type Language = "en" | "zh";
|
||
|
||
/** Installer locations are localized at the message boundary, not embedded in update logic. */
|
||
export type InstallerSource = "configured" | "oss" | "github";
|
||
|
||
/** Readiness probe failure classes; selects which hint `webProbeFailed` appends. */
|
||
export type WebProbeFailureKind =
|
||
"timeout" | "refused" | "reset" | "permission" | "dns" | "unknown";
|
||
|
||
/** Resolve the language from the env var; `zh` matches exactly, everything else falls back to English (see comment #2). */
|
||
export function resolveLanguage(): Language {
|
||
const v = (process.env.PENGUIN_LANG ?? "").trim().toLowerCase();
|
||
return v === "zh" ? "zh" : "en";
|
||
}
|
||
|
||
export interface Messages {
|
||
// —— Command/option help descriptions ——
|
||
cliDescription: string;
|
||
versionDesc: string;
|
||
common: {
|
||
projectId: string;
|
||
agentId: string;
|
||
modelId: string;
|
||
/** run/chat's --provider: must be given together with --model-id (the group is never inferred). */
|
||
provider: string;
|
||
/** Data root directory option (priority: --root > PENGUIN_HOME > ~/.penguin/data). */
|
||
root: string;
|
||
workspace: string;
|
||
approve: string;
|
||
};
|
||
config: {
|
||
desc: string;
|
||
modelDesc: string;
|
||
addDesc: string;
|
||
addModelId: string;
|
||
addProvider: string;
|
||
addApiKey: string;
|
||
addBaseUrl: string;
|
||
addContextWindow: string;
|
||
addMaxTokens: string;
|
||
addClientType: string;
|
||
addVision: string;
|
||
addNoVision: string;
|
||
addPriceCacheRead: string;
|
||
addPriceCacheWrite: string;
|
||
addPriceOutput: string;
|
||
addSetDefault: string;
|
||
defaultDesc: string;
|
||
visionDesc: string;
|
||
/** `model default` / `model vision`'s --model-id: the upstream request id (pairs with --provider as a reference). */
|
||
refModelId: string;
|
||
/** `model default` / `model vision`'s --provider: the provider group of the referenced entry (required). */
|
||
refProvider: string;
|
||
listDesc: string;
|
||
langDesc: string;
|
||
langArg: string;
|
||
vaultDesc: string;
|
||
vaultSetDesc: string;
|
||
vaultListDesc: string;
|
||
vaultRemoveDesc: string;
|
||
vaultKey: string;
|
||
vaultValue: string;
|
||
};
|
||
run: {
|
||
desc: string;
|
||
message: string;
|
||
/** run's --goal: goal mode, with an optional token budget value (`--goal 500k`). */
|
||
goal: string;
|
||
};
|
||
chat: { desc: string; resume: string };
|
||
serve: {
|
||
serverDesc: string;
|
||
webDesc: string;
|
||
port: string;
|
||
host: string;
|
||
noOpen: string;
|
||
};
|
||
/** `penguin update`: help text and every line the command can print. */
|
||
update: {
|
||
desc: string;
|
||
check: string;
|
||
releaseOpt: string;
|
||
yes: string;
|
||
/** `--check` header: the running version against the resolved target. */
|
||
checkReport(current: string, latest: string): string;
|
||
upgradeAvailable(target: string): string;
|
||
upToDate(current: string): string;
|
||
/** `--release` naming a release older than the running one: allowed, but said out loud. */
|
||
targetIsOlder(target: string): string;
|
||
/** Pre-confirmation plan for a tarball install (mechanism, target, install dir, data-dir guarantee). */
|
||
planTarball(current: string, target: string, installDir: string, universal: boolean): string;
|
||
/** Pre-confirmation plan for a global npm install. */
|
||
planNpm(current: string, target: string, manager: string, command: string): string;
|
||
confirm(): string;
|
||
/** stdin is not a TTY: require --yes instead of blocking on a prompt nobody can answer. */
|
||
needsYes(): string;
|
||
cancelled(): string;
|
||
done(version: string): string;
|
||
failed(): string;
|
||
/** Running from a source checkout: refuse, because overwriting a working tree destroys work. */
|
||
sourceCheckout(): string;
|
||
unknownInstall(modulePath: string): string;
|
||
/** A global install whose package manager could not be identified: print the command, never guess. */
|
||
npmUnknownManager(globalRoot: string, target: string): string;
|
||
/** Windows, tarball install: the official installer is a POSIX shell script. */
|
||
windowsUnsupported(): string;
|
||
/**
|
||
* Windows, global install: `spawn` cannot run a `.cmd` shim without a shell, so hand the user
|
||
* the exact command instead of failing generically.
|
||
*/
|
||
windowsGlobalInstall(command: string): string;
|
||
networkFailed(url: string): string;
|
||
rateLimited(): string;
|
||
apiFailed(status: number): string;
|
||
apiMalformed(): string;
|
||
invalidDownloadSource(): string;
|
||
downloadBaseMustBeHttps(name: string): string;
|
||
ossUnavailable(): string;
|
||
installerFetchFailed(sources: InstallerSource[]): string;
|
||
};
|
||
|
||
// —— Runtime output ——
|
||
/** Startup banner: product + subcommand + CLI version on the first line, then Agent / Workspace / Model each on its own line. */
|
||
header(
|
||
kind: "chat" | "run",
|
||
version: string,
|
||
agentId: string,
|
||
workspace: string,
|
||
model: string,
|
||
): string;
|
||
chatHints(): string;
|
||
confirmExit(): string;
|
||
taskInterrupted(): string;
|
||
/** Acknowledgment printed when a line typed mid-run is queued as steering (echoes the text; delivered between turns). */
|
||
steerQueued(text: string): string;
|
||
/** Prefix for a rendered [user_steering] line (a mid-run user message delivered between turns). */
|
||
steerLinePrefix(): string;
|
||
error(message: string): string;
|
||
/** Approval prompt text (the tool call is already streamed above and directly precedes this prompt, so no index and no re-rendering). */
|
||
approvePrompt(): string;
|
||
/**
|
||
* Stats shown at the end of each Task: Session cumulative values plus this task's delta —
|
||
* context window length, Token usage, elapsed time. Delta strings carry their own sign
|
||
* (contextDelta can be negative after context is compacted), e.g.
|
||
* `[stats] context 4k (+1k) · tokens 6k (+1.2k) · 5.1s (+2.3s)`.
|
||
*/
|
||
taskStats(s: {
|
||
context: string;
|
||
contextDelta: string;
|
||
tokens: string;
|
||
tokensDelta: string;
|
||
elapsed: string;
|
||
elapsedDelta: string;
|
||
}): string;
|
||
/** Abort event label (may include a reason). */
|
||
abortLabel(reason?: string): string;
|
||
/**
|
||
* request_end ended with a status the engine reconnects on (`failed` / `timeout` /
|
||
* `malformed` — only `auth` is terminal): the engine retries carrying already-produced
|
||
* content; attempt is the retry count.
|
||
*/
|
||
reconnectLabel(status: "failed" | "timeout" | "malformed", attempt: number): string;
|
||
/** compaction start event: indicates compaction in progress (mode is summarize/discard, reason is context/turns/manual). */
|
||
compactionStart(mode: string, reason: string): string;
|
||
/**
|
||
* compaction stop event: the compaction result (status is completed/failed/aborted;
|
||
* completed varies its text by mode). tokens is Token usage (same convention as the stats
|
||
* line: total = Session cumulative, delta = consumed by this compaction, carrying its own
|
||
* sign); when present it is appended at the end of the line, e.g. ` · tokens 14k (+6k)`.
|
||
*/
|
||
compactionStop(
|
||
mode: string,
|
||
status: string,
|
||
tokens?: { total: string; delta: string },
|
||
errorMessage?: string,
|
||
): string;
|
||
/** Prompt shown when `/compact` has nothing to compact (session just started / two consecutive compactions). */
|
||
compactNothing(): string;
|
||
/** Dim line announcing one goal round (printed before the round runs). */
|
||
goalRound(round: number): string;
|
||
/** Dim summary line after a goal ends: how it ended, rounds run, tokens consumed. */
|
||
goalFinished(
|
||
outcome: "complete" | "blocked" | "budget_limited" | "aborted",
|
||
rounds: number,
|
||
tokens: string,
|
||
): string;
|
||
/** `/goal` usage error (missing objective / malformed command). */
|
||
goalUsage(): string;
|
||
/** Invalid token-budget value (chat `/goal:<budget>` or run `--goal <budget>`). */
|
||
goalBudgetInvalid(value: string): string;
|
||
/** run's --goal given an empty/whitespace -m (the objective must be non-empty text). */
|
||
goalObjectiveEmpty(): string;
|
||
/** Prompt for an invalid --approve mode. */
|
||
approveModeInvalid(value: string): string;
|
||
/** Render label for an approval decision (frontend renders the approval_decision event; one label each for allow/deny). */
|
||
approvalDecision(decision: "allow" | "deny"): string;
|
||
/** run/chat given only one of --model-id / --provider: a model reference is always an explicit pair, never a lookup. */
|
||
modelRefIncomplete(): string;
|
||
/** --resume is mutually exclusive with --workspace/--model-id (neither can change once the Session is created). */
|
||
resumeNoOverride(): string;
|
||
/** --resume given without a session id, and the current Agent has no Session at all. */
|
||
resumeNoSession(): string;
|
||
/** One-line prompt shown after a successful resume, before rendering history. */
|
||
resumedBanner(sessionId: string, messageCount: number): string;
|
||
/** Example resume command shown when the REPL exits (dim print; only when this session has a resumable record). */
|
||
resumeHint(command: string): string;
|
||
langInvalid(value: string): string;
|
||
/** `config lang` persists via POSIX shell startup files; on Windows it refuses with a pointer to a user env var instead. */
|
||
langWindowsUnsupported(lang: string): string;
|
||
langSet(lang: string, rcPath: string): string;
|
||
langRestartConfirm(): string;
|
||
langRestart(): string;
|
||
langRestartHint(rcPath: string): string;
|
||
/** Result output for model add/default/vision: the argument is the already-formatted pair reference (formatModelRef). */
|
||
modelAdded(model: string, defaultModel: string | undefined): string;
|
||
modelUpdated(model: string, defaultModel: string | undefined): string;
|
||
defaultModelSet(model: string): string;
|
||
visionModelSet(model: string): string;
|
||
modelListTitle(): string;
|
||
modelListEmpty(): string;
|
||
vaultSet(key: string): string;
|
||
vaultRemoved(key: string): string;
|
||
vaultKeyMissing(key: string): string;
|
||
vaultListTitle(): string;
|
||
vaultListEmpty(): string;
|
||
/** URL prompt once the `penguin web` service is ready. */
|
||
webReady(url: string): string;
|
||
/** Refusal when `penguin server` finds a live server on the same data root. */
|
||
serverAlreadyRunning(url: string): string;
|
||
/** Notice when `penguin web` finds a live server on the same data root (it opens that instance instead). */
|
||
webAlreadyRunning(url: string): string;
|
||
/** Diagnostic shown after the `penguin web` ready-poll times out (15s). */
|
||
webProbeFailed(url: string, detail: string, kind: WebProbeFailureKind, port: number): string;
|
||
}
|
||
|
||
function headerEn(
|
||
kind: "chat" | "run",
|
||
version: string,
|
||
agentId: string,
|
||
workspace: string,
|
||
model: string,
|
||
): string {
|
||
return [
|
||
`PenguinHarness ${kind} v${version}`,
|
||
`Agent: ${agentId}`,
|
||
`Workspace: ${workspace}`,
|
||
`Model: ${model}`,
|
||
].join("\n");
|
||
}
|
||
|
||
function headerZh(
|
||
kind: "chat" | "run",
|
||
version: string,
|
||
agentId: string,
|
||
workspace: string,
|
||
model: string,
|
||
): string {
|
||
return [
|
||
`PenguinHarness ${kind} v${version}`,
|
||
`Agent:${agentId}`,
|
||
`Workspace:${workspace}`,
|
||
`模型:${model}`,
|
||
].join("\n");
|
||
}
|
||
|
||
const en: Messages = {
|
||
cliDescription: "PenguinHarness CLI",
|
||
versionDesc: "output the version number",
|
||
common: {
|
||
projectId: "Project id",
|
||
agentId: "Agent id",
|
||
modelId: "Model to use (upstream model id; defaults to the Project default model)",
|
||
provider:
|
||
"Provider group of --model-id; required whenever --model-id is given (the group is never inferred)",
|
||
root: "Data root directory (overrides PENGUIN_HOME and ~/.penguin/data)",
|
||
workspace: "Workspace directory; must already exist (defaults to the current directory)",
|
||
approve:
|
||
"Approval mode: allow-all (auto-approve, default), deny-all (auto-reject), read-only (auto-approve read-only tools, prompt for the rest), always-ask (prompt per tool)",
|
||
},
|
||
config: {
|
||
desc: "Manage Project configuration",
|
||
modelDesc: "Manage model credentials and the default model",
|
||
addDesc: "Add or update a model, optionally writing a credential",
|
||
addModelId: "Upstream model id sent to AgentHub as-is (e.g. claude-sonnet-4-6)",
|
||
addProvider:
|
||
"Provider group stored alongside model_id; required, never inferred (use custom for anything without a vendor group)",
|
||
addApiKey: "API key, stored inline in the Project's hidden .project_config.toml",
|
||
addBaseUrl: "Custom base URL",
|
||
addContextWindow: "Context window size (tokens)",
|
||
addMaxTokens:
|
||
"Per-model max output tokens (positive integer); when set it overrides the Agent's max_tokens, omit to inherit — lower it for small-context models",
|
||
addClientType: "AgentHub client type (e.g. openai); defaults by provider group when omitted",
|
||
addVision: "Mark the model as supporting image input (vision)",
|
||
addNoVision: "Mark the model as NOT supporting image input; omit both to keep current",
|
||
addPriceCacheRead: "Price per 1M tokens: cache read (USD)",
|
||
addPriceCacheWrite: "Price per 1M tokens: cache write (USD)",
|
||
addPriceOutput: "Price per 1M tokens: output (USD)",
|
||
addSetDefault: "Also set as the Project default model",
|
||
defaultDesc: "Set the Project default model",
|
||
visionDesc: "Set the vision model used by read_image for non-vision session models",
|
||
refModelId: "Upstream model id; forms the (provider, model_id) pair reference with --provider",
|
||
refProvider: "Provider group of the referenced entry (see `penguin config model list`)",
|
||
listDesc: "List the Project's models (API keys hidden)",
|
||
langDesc:
|
||
"Set the interface language (en|zh); persists PENGUIN_LANG to your shell startup file",
|
||
langArg: "Language: en or zh",
|
||
vaultDesc: "Manage an Agent's vault (environment variables injected into its shell commands)",
|
||
vaultSetDesc: "Set a vault environment variable (added or overwritten)",
|
||
vaultListDesc: "List vault environment variables (values masked)",
|
||
vaultRemoveDesc: "Remove a vault environment variable",
|
||
vaultKey: "Variable name (letters, digits and underscores; must not start with a digit)",
|
||
vaultValue: "Variable value, written to the Agent's agent_state/.vault.toml",
|
||
},
|
||
run: {
|
||
desc: "Run a single Task",
|
||
message: "Prompt for this Task",
|
||
goal: "Goal mode: loop until the goal completes; optional token budget (e.g. 500k, 2m)",
|
||
},
|
||
chat: {
|
||
desc: "Open the interactive REPL",
|
||
resume:
|
||
"Resume an existing Session (defaults to the agent's most recent one); workspace and model follow the original Session",
|
||
},
|
||
serve: {
|
||
serverDesc: "Start the Web service (HTTP API and the built-in frontend, same process)",
|
||
webDesc: "Start the Web service and open the UI in a browser once it is ready",
|
||
port: "Listen port (falls back to the PORT env var, default 7364)",
|
||
host: "Listen address (falls back to the HOST env var, default 127.0.0.1)",
|
||
noOpen: "Do not open a browser automatically",
|
||
},
|
||
update: {
|
||
desc: "Upgrade this PenguinHarness install in place",
|
||
check: "Only report the current and latest versions; change nothing",
|
||
releaseOpt:
|
||
"Target a specific release tag instead of the latest (e.g. v0.1.2 or 0.1.2); named --release because -v/--version is the CLI's own version flag",
|
||
yes: "Skip the confirmation prompt",
|
||
checkReport: (current, latest) => `Installed ${current} · latest ${latest}`,
|
||
upgradeAvailable: (target) =>
|
||
`An upgrade is available: run \`penguin update\` to install ${target}.`,
|
||
upToDate: (current) => `Already on the latest version (${current}); nothing to do.`,
|
||
targetIsOlder: (target) =>
|
||
`${target} is older than the installed version — this would be a downgrade.`,
|
||
planTarball: (current, target, installDir, universal) =>
|
||
[
|
||
`Upgrade ${current} -> ${target}`,
|
||
` how: re-run the official installer (this install came from the tarball)`,
|
||
` install dir: ${installDir}${universal ? " (universal package, no bundled Node runtime)" : ""}`,
|
||
` replaces: bin, lib, web${universal ? "" : ", node"} — your data dir is NOT touched`,
|
||
].join("\n"),
|
||
planNpm: (current, target, manager, command) =>
|
||
[
|
||
`Upgrade ${current} -> ${target}`,
|
||
` how: global ${manager} install (this install came from ${manager})`,
|
||
` command: ${command}`,
|
||
` your data dir is NOT touched`,
|
||
].join("\n"),
|
||
confirm: () => "Proceed? [y/N] ",
|
||
needsYes: () =>
|
||
"Not running in a terminal, so the confirmation cannot be answered. Re-run with --yes to upgrade non-interactively.",
|
||
cancelled: () => "Cancelled; nothing was changed.",
|
||
done: (version) =>
|
||
`PenguinHarness ${version} installed. Run \`penguin --version\` in a new shell to confirm.`,
|
||
failed: () => "Upgrade failed; the previous install was left in place where possible.",
|
||
sourceCheckout: () =>
|
||
"This penguin runs from a source checkout, so there is nothing to download — update it with `git pull` and rebuild (`pnpm install && pnpm -r build`).",
|
||
unknownInstall: (modulePath) =>
|
||
`Cannot tell how this penguin was installed (running from ${modulePath}), so it will not be replaced. Re-install with the official installer, or upgrade with the package manager you used.`,
|
||
npmUnknownManager: (globalRoot, target) =>
|
||
`This is a global install under ${globalRoot}, but the package manager that owns it could not be identified. Upgrade it yourself with that manager, e.g. \`npm install -g @prismshadow/penguin-cli@${target}\`.`,
|
||
windowsUnsupported: () =>
|
||
"The official installer is a POSIX shell script and does not run on Windows. Re-install from the GitHub Releases page, or use a global npm install instead.",
|
||
windowsGlobalInstall: (command) =>
|
||
[
|
||
"On Windows, penguin cannot run your package manager for you: Node will not execute an npm/pnpm/yarn `.cmd` shim without a shell.",
|
||
"Run this yourself in a terminal, then reopen it:",
|
||
` ${command}`,
|
||
].join("\n"),
|
||
networkFailed: (url) => `Could not reach ${url}. Check your network and retry.`,
|
||
rateLimited: () =>
|
||
"GitHub rate-limited the release lookup. Wait a few minutes and retry, or pass --release <tag> to skip the lookup.",
|
||
apiFailed: (status) => `The GitHub release lookup failed with HTTP ${status}.`,
|
||
apiMalformed: () =>
|
||
"The GitHub release lookup returned an unexpected response with no usable version tag.",
|
||
invalidDownloadSource: () => "PENGUIN_DOWNLOAD_SOURCE must be auto, oss, or github.",
|
||
downloadBaseMustBeHttps: (name) => `${name} must be an absolute HTTPS URL.`,
|
||
ossUnavailable: () => "The OSS mirror is unavailable or its release metadata is invalid.",
|
||
installerFetchFailed: (sources) =>
|
||
`Could not download the installer from ${sources
|
||
.map((source) =>
|
||
source === "configured"
|
||
? "the configured mirror"
|
||
: source === "oss"
|
||
? "the OSS mirror"
|
||
: "GitHub",
|
||
)
|
||
.join(" or ")}. Check your network and retry.`,
|
||
},
|
||
|
||
header: headerEn,
|
||
chatHints: () =>
|
||
"Type a message to start a conversation; end a line with \\; typing while a task runs steers the agent; /goal runs a goal to completion; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
|
||
confirmExit: () => "Exit penguin? [y/N] ",
|
||
taskInterrupted: () => "[current conversation interrupted]",
|
||
steerQueued: (text) => `» steering queued (delivered with the next turn): ${text}`,
|
||
steerLinePrefix: () => "↪ user: ",
|
||
error: (message) => `[error] ${message}`,
|
||
approvePrompt: () => "? Approve this tool call? [Y/n] ",
|
||
taskStats: (s) =>
|
||
`[stats] context ${s.context} (${s.contextDelta}) · tokens ${s.tokens} (${s.tokensDelta}) · ${s.elapsed} (${s.elapsedDelta})`,
|
||
abortLabel: (reason) => `[abort]${reason ? `: ${reason}` : ""}`,
|
||
reconnectLabel: (status, attempt) =>
|
||
`[retry] ${
|
||
status === "timeout"
|
||
? "connection timed out"
|
||
: status === "malformed"
|
||
? "response incomplete or unparseable"
|
||
: "the model provider returned an error"
|
||
}; sending retry #${attempt}…`,
|
||
compactionStart: (mode, reason) =>
|
||
mode === "discard"
|
||
? `[compaction] discarding context (${reason})…`
|
||
: `[compaction] summarizing context (${reason})…`,
|
||
compactionStop: (mode, status, tokens, errorMessage) =>
|
||
(status === "completed"
|
||
? mode === "discard"
|
||
? "[compaction] done; old context discarded"
|
||
: "[compaction] done; continuing with the summarized context"
|
||
: `[compaction] ${status}${errorMessage !== undefined ? ` (${errorMessage})` : ""}; keeping the current context`) +
|
||
(tokens ? ` · tokens ${tokens.total} (${tokens.delta})` : ""),
|
||
compactNothing: () => "[compaction] nothing to compact yet",
|
||
goalRound: (round) => `[goal] round ${round}`,
|
||
goalFinished: (outcome, rounds, tokens) => {
|
||
const label = {
|
||
complete: "completed",
|
||
blocked: "blocked (see the final reply for what it needs)",
|
||
budget_limited: "stopped: token budget exhausted",
|
||
aborted: "interrupted",
|
||
}[outcome];
|
||
return `[goal] ${label} · ${rounds} round${rounds === 1 ? "" : "s"} · tokens ${tokens}`;
|
||
},
|
||
goalUsage: () => "Usage: /goal[:<budget>] <objective> (e.g. /goal:500k fix all failing tests)",
|
||
goalBudgetInvalid: (value) =>
|
||
`Invalid token budget "${value}". Use a positive number with an optional k/m suffix (500k, 2m).`,
|
||
goalObjectiveEmpty: () => "Goal mode requires a non-empty objective: pass it via -m.",
|
||
approveModeInvalid: (value) =>
|
||
`Invalid approval mode "${value}". Use allow-all, deny-all, read-only, or always-ask.`,
|
||
approvalDecision: (decision) => (decision === "allow" ? "✓ [approved]" : "× [denied]"),
|
||
modelRefIncomplete: () =>
|
||
"--model-id and --provider must be given together: a model reference is always an explicit (provider, model_id) pair. Omit both to use the Project default model.",
|
||
resumeNoOverride: () =>
|
||
"--resume does not accept --workspace, --model-id or --provider: they follow the original Session and cannot change.",
|
||
resumeNoSession: () => "No session to resume: this agent has no recorded sessions yet.",
|
||
resumedBanner: (sessionId, messageCount) =>
|
||
`[resumed] ${sessionId} · ${messageCount} message${messageCount === 1 ? "" : "s"} in the current context`,
|
||
resumeHint: (command) => `To continue this conversation: ${command}`,
|
||
langInvalid: (value) => `Invalid language "${value}". Use en or zh.`,
|
||
langWindowsUnsupported: (lang) =>
|
||
`penguin config lang persists via POSIX shell startup files, which Windows does not have.\n` +
|
||
`Set the user environment variable instead: setx PENGUIN_LANG ${lang} (new terminals pick it up).`,
|
||
langSet: (lang, rcPath) => `Language set to ${lang}; wrote PENGUIN_LANG to ${rcPath}.`,
|
||
langRestartConfirm: () => "Open a new shell now to apply? [y/N] ",
|
||
langRestart: () => "Opening a new shell with the new language (type exit to return)…",
|
||
langRestartHint: (rcPath) => `Open a new terminal, or run: source ${rcPath}`,
|
||
modelAdded: (model, def) => `Added model ${model}. Default model: ${def ?? "(unset)"}`,
|
||
modelUpdated: (model, def) => `Updated model ${model}. Default model: ${def ?? "(unset)"}`,
|
||
defaultModelSet: (model) => `Default model set to ${model}.`,
|
||
visionModelSet: (model) => `Vision model set to ${model}.`,
|
||
modelListTitle: () => "Configured models:",
|
||
modelListEmpty: () => "No models configured yet. Add one with `penguin config model add`.",
|
||
vaultSet: (key) => `Saved vault entry ${key}.`,
|
||
vaultRemoved: (key) => `Removed vault entry ${key}.`,
|
||
vaultKeyMissing: (key) => `Vault entry ${key} does not exist.`,
|
||
vaultListTitle: () => "Vault environment variables (values masked):",
|
||
vaultListEmpty: () => "The vault is empty. Add one with `penguin config vault set`.",
|
||
webReady: (url) => `Web UI ready: ${url}`,
|
||
serverAlreadyRunning: (url) =>
|
||
`A PenguinHarness server is already running on this data root: ${url}\n` +
|
||
`Stop it first, or point PENGUIN_HOME at a separate data root.`,
|
||
webAlreadyRunning: (url) =>
|
||
`Already running on this data root — opening the existing instance: ${url}`,
|
||
webProbeFailed: (url, detail, kind, port) => {
|
||
const hint = {
|
||
timeout:
|
||
`The connection timed out. Check whether a firewall or security application is blocking it. ` +
|
||
`Allow PenguinHarness to communicate on local port ${port}.`,
|
||
refused:
|
||
"Nothing accepted the connection. Check whether the server exited or HOST/PORT points somewhere else.",
|
||
reset:
|
||
"The connection closed before an HTTP response. Check local security software and retry.",
|
||
permission:
|
||
"The operating system denied the connection. Check firewall or security policy permissions.",
|
||
dns: "The host name could not be resolved. Check --host or HOST.",
|
||
unknown: `Open ${url} manually after the server is ready.`,
|
||
}[kind];
|
||
return `Server readiness check failed for ${url}.\nLast probe error: ${detail}\n${hint}`;
|
||
},
|
||
};
|
||
|
||
const zh: Messages = {
|
||
cliDescription: "PenguinHarness CLI",
|
||
versionDesc: "输出版本号",
|
||
common: {
|
||
projectId: "Project id",
|
||
agentId: "Agent id",
|
||
modelId: "本次使用的模型(上游模型 id;默认 Project 默认模型)",
|
||
provider: "--model-id 的 provider 分组;给出 --model-id 时必须一并给出(分组不作任何推断)",
|
||
root: "数据根目录(优先于 PENGUIN_HOME 与 ~/.penguin/data)",
|
||
workspace: "Workspace 目录,须为已存在目录(默认当前目录)",
|
||
approve:
|
||
"审批模式:allow-all(全部放行,缺省)、deny-all(全部拒绝)、read-only(自动放行只读工具,其余仍逐个询问)、always-ask(逐个询问)",
|
||
},
|
||
config: {
|
||
desc: "管理 Project 配置",
|
||
modelDesc: "管理模型 credential 与默认模型",
|
||
addDesc: "新增或更新一个模型,并可写入 credential",
|
||
addModelId: "上游模型 id(如 claude-sonnet-4-6,原样发给 AgentHub)",
|
||
addProvider: "与 model_id 分列存储的 provider 分组;必填,不作推断(无厂商分组时填 custom)",
|
||
addApiKey: "API key,内联存入 Project 的隐藏文件 .project_config.toml",
|
||
addBaseUrl: "自定义 base url",
|
||
addContextWindow: "上下文窗口大小(token 数)",
|
||
addMaxTokens:
|
||
"该模型的最大输出长度(正整数);设置后覆盖 Agent 的 max_tokens,缺省沿用——小上下文模型建议调低",
|
||
addClientType: "AgentHub 客户端协议(如 openai);缺省按 provider 分组的语义取值",
|
||
addVision: "标注该模型支持图片输入(视觉)",
|
||
addNoVision: "标注该模型不支持图片输入;两者都不给则保留原值",
|
||
addPriceCacheRead: "每百万 token 价格:缓存读取(USD)",
|
||
addPriceCacheWrite: "每百万 token 价格:缓存写入(USD)",
|
||
addPriceOutput: "每百万 token 价格:输出(USD)",
|
||
addSetDefault: "同时设为该 Project 的默认模型",
|
||
defaultDesc: "设置 Project 的默认模型",
|
||
visionDesc: "设置 read_image 代读用的视觉模型(供不支持图片的会话模型读图)",
|
||
refModelId: "上游模型 id;与 --provider 构成 (provider, model_id) 成对引用",
|
||
refProvider: "引用条目的 provider 分组(见 `penguin config model list`)",
|
||
listDesc: "列出当前 Project 的模型(API key 隐藏)",
|
||
langDesc: "设置界面语言(en|zh);将 PENGUIN_LANG 写入 shell 启动文件并持久化",
|
||
langArg: "语言:en 或 zh",
|
||
vaultDesc: "管理 Agent vault(注入该 Agent shell 命令的环境变量)",
|
||
vaultSetDesc: "写入一个 vault 环境变量(不存在则新增,存在则覆盖)",
|
||
vaultListDesc: "列出 vault 环境变量(值掩码显示)",
|
||
vaultRemoveDesc: "删除一个 vault 环境变量",
|
||
vaultKey: "变量名(字母、数字与下划线,不能以数字开头)",
|
||
vaultValue: "变量值,写入该 Agent 的 agent_state/.vault.toml",
|
||
},
|
||
run: {
|
||
desc: "单次运行一个 Task",
|
||
message: "本次 Task 的 Prompt",
|
||
goal: "目标模式:循环运行直至目标完成;可选 token 预算(如 500k、2m)",
|
||
},
|
||
chat: {
|
||
desc: "打开交互式 REPL",
|
||
resume:
|
||
"恢复既有 Session 继续对话(缺省恢复当前 Agent 最近一次);Workspace 与模型沿用原 Session",
|
||
},
|
||
serve: {
|
||
serverDesc: "启动 Web 服务(HTTP API 与内置前端,同一进程)",
|
||
webDesc: "启动 Web 服务,就绪后用浏览器打开界面",
|
||
port: "监听端口(其次取环境变量 PORT,缺省 7364)",
|
||
host: "监听地址(其次取环境变量 HOST,缺省 127.0.0.1)",
|
||
noOpen: "不自动打开浏览器",
|
||
},
|
||
update: {
|
||
desc: "原地升级当前的 PenguinHarness 安装",
|
||
check: "只报告当前版本与最新版本,不做任何修改",
|
||
releaseOpt:
|
||
"指定目标版本而不是最新版(如 v0.1.2 或 0.1.2);之所以叫 --release,是因为 -v/--version 是 CLI 自身的版本参数",
|
||
yes: "跳过确认提示",
|
||
checkReport: (current, latest) => `已安装 ${current} · 最新 ${latest}`,
|
||
upgradeAvailable: (target) => `有可用升级:执行 \`penguin update\` 安装 ${target}。`,
|
||
upToDate: (current) => `已是最新版本(${current}),无需升级。`,
|
||
targetIsOlder: (target) => `${target} 低于当前已安装的版本——这将是一次降级。`,
|
||
planTarball: (current, target, installDir, universal) =>
|
||
[
|
||
`升级 ${current} -> ${target}`,
|
||
` 方式: 重新执行官方安装脚本(当前安装来自 tarball)`,
|
||
` 安装目录:${installDir}${universal ? "(universal 包,不含内置 Node 运行时)" : ""}`,
|
||
` 将替换: bin、lib、web${universal ? "" : "、node"}——数据目录不会被改动`,
|
||
].join("\n"),
|
||
planNpm: (current, target, manager, command) =>
|
||
[
|
||
`升级 ${current} -> ${target}`,
|
||
` 方式: ${manager} 全局安装(当前安装来自 ${manager})`,
|
||
` 命令: ${command}`,
|
||
` 数据目录不会被改动`,
|
||
].join("\n"),
|
||
confirm: () => "确认继续?[y/N] ",
|
||
needsYes: () => "当前不在终端中,无法回答确认提示。请加 --yes 以非交互方式升级。",
|
||
cancelled: () => "已取消,未做任何修改。",
|
||
done: (version) =>
|
||
`PenguinHarness ${version} 安装完成。在新 shell 中执行 \`penguin --version\` 确认。`,
|
||
failed: () => "升级失败;在可能的情况下已保留原有安装。",
|
||
sourceCheckout: () =>
|
||
"当前 penguin 运行自源码检出,无需下载——请用 `git pull` 更新并重新构建(`pnpm install && pnpm -r build`)。",
|
||
unknownInstall: (modulePath) =>
|
||
`无法判断当前 penguin 的安装方式(运行自 ${modulePath}),因此不会替换它。请用官方安装脚本重新安装,或用你当初使用的包管理器升级。`,
|
||
npmUnknownManager: (globalRoot, target) =>
|
||
`这是位于 ${globalRoot} 的全局安装,但无法确定是哪个包管理器安装的。请自行用该包管理器升级,例如 \`npm install -g @prismshadow/penguin-cli@${target}\`。`,
|
||
windowsUnsupported: () =>
|
||
"官方安装脚本是 POSIX shell 脚本,无法在 Windows 上运行。请从 GitHub Releases 页面重新安装,或改用 npm 全局安装。",
|
||
windowsGlobalInstall: (command) =>
|
||
[
|
||
"在 Windows 上,penguin 无法代你调用包管理器:Node 不会在没有 shell 的情况下执行 npm/pnpm/yarn 的 `.cmd` 包装脚本。",
|
||
"请在终端里自行执行下面的命令,然后重新打开终端:",
|
||
` ${command}`,
|
||
].join("\n"),
|
||
networkFailed: (url) => `无法访问 ${url}。请检查网络后重试。`,
|
||
rateLimited: () =>
|
||
"GitHub 对版本查询做了限流。请等待几分钟后重试,或用 --release <tag> 跳过查询。",
|
||
apiFailed: (status) => `GitHub 版本查询失败,HTTP ${status}。`,
|
||
apiMalformed: () => "GitHub 版本查询返回了非预期的响应,其中没有可用的版本号。",
|
||
invalidDownloadSource: () => "PENGUIN_DOWNLOAD_SOURCE 必须是 auto、oss 或 github。",
|
||
downloadBaseMustBeHttps: (name) => `${name} 必须是绝对 HTTPS URL。`,
|
||
ossUnavailable: () => "OSS 镜像不可用,或其版本元数据无效。",
|
||
installerFetchFailed: (sources) => {
|
||
const sourceText = sources
|
||
.map((source) =>
|
||
source === "configured" ? "配置的镜像" : source === "oss" ? "OSS 镜像" : "GitHub",
|
||
)
|
||
.join("或 ");
|
||
const leadingSpace = /^[A-Za-z]/.test(sourceText) ? " " : "";
|
||
const trailingSpace = /[A-Za-z]$/.test(sourceText) ? " " : "";
|
||
return `无法从${leadingSpace}${sourceText}${trailingSpace}下载安装脚本。请检查网络后重试。`;
|
||
},
|
||
},
|
||
|
||
header: headerZh,
|
||
chatHints: () =>
|
||
"输入消息发起对话;行尾 \\ 续行;运行中输入可插话引导;/goal 以目标模式运行至完成;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
|
||
confirmExit: () => "确认退出 penguin?[y/N] ",
|
||
taskInterrupted: () => "[已中断当前对话]",
|
||
steerQueued: (text) => `» 插话已排队(随下一轮送达):${text}`,
|
||
steerLinePrefix: () => "↪ 用户: ",
|
||
error: (message) => `[错误] ${message}`,
|
||
approvePrompt: () => "? 批准此工具调用?[Y/n] ",
|
||
taskStats: (s) =>
|
||
`[统计信息] 上下文 ${s.context} (${s.contextDelta}) · tokens ${s.tokens} (${s.tokensDelta}) · 用时 ${s.elapsed} (${s.elapsedDelta})`,
|
||
abortLabel: (reason) => `[已中断]${reason ? `:${reason}` : ""}`,
|
||
reconnectLabel: (status, attempt) =>
|
||
`[重试] ${
|
||
status === "timeout"
|
||
? "连接超时或网络中断"
|
||
: status === "malformed"
|
||
? "响应不完整或无法解析"
|
||
: "模型服务返回错误"
|
||
},正在发起第 ${attempt} 次重试……`,
|
||
compactionStart: (mode, reason) =>
|
||
mode === "discard"
|
||
? `[压缩] 正在丢弃旧上下文(${reason})……`
|
||
: `[压缩] 正在总结压缩上下文(${reason})……`,
|
||
compactionStop: (mode, status, tokens, errorMessage) =>
|
||
(status === "completed"
|
||
? mode === "discard"
|
||
? "[压缩] 完成,旧上下文已丢弃"
|
||
: "[压缩] 完成,已切换到摘要后的新上下文"
|
||
: `[压缩] ${status === "aborted" ? "已中断" : `失败${errorMessage !== undefined ? `(${errorMessage})` : ""}`},保留当前上下文`) +
|
||
(tokens ? ` · tokens ${tokens.total} (${tokens.delta})` : ""),
|
||
compactNothing: () => "[压缩] 当前上下文为空,无需压缩",
|
||
goalRound: (round) => `[目标] 第 ${round} 轮`,
|
||
goalFinished: (outcome, rounds, tokens) => {
|
||
const label = {
|
||
complete: "已完成",
|
||
blocked: "受阻(所缺条件见最后一条回复)",
|
||
budget_limited: "已停止:token 预算耗尽",
|
||
aborted: "已中断",
|
||
}[outcome];
|
||
return `[目标] ${label} · 共 ${rounds} 轮 · tokens ${tokens}`;
|
||
},
|
||
goalUsage: () => "用法:/goal[:<预算>] <目标>(例如 /goal:500k 修复所有失败的测试)",
|
||
goalBudgetInvalid: (value) =>
|
||
`无效的 token 预算 "${value}":应为正数,可带 k/m 后缀(500k、2m)。`,
|
||
goalObjectiveEmpty: () => "目标模式需要非空的目标文本:请通过 -m 传入。",
|
||
approveModeInvalid: (value) =>
|
||
`无效的审批模式 "${value}"。请使用 allow-all、deny-all、read-only 或 always-ask。`,
|
||
approvalDecision: (decision) => (decision === "allow" ? "✓ [已批准]" : "× [已拒绝]"),
|
||
modelRefIncomplete: () =>
|
||
"--model-id 与 --provider 必须成对给出:模型引用始终是显式的 (provider, model_id) 组合。两者都不给则使用 Project 默认模型。",
|
||
resumeNoOverride: () =>
|
||
"--resume 不接受 --workspace、--model-id 与 --provider:均沿用原 Session,创建后不可更换。",
|
||
resumeNoSession: () => "没有可恢复的 Session:当前 Agent 还没有任何会话记录。",
|
||
resumedBanner: (sessionId, messageCount) =>
|
||
`[已恢复] ${sessionId} · 当前上下文共 ${messageCount} 条消息`,
|
||
resumeHint: (command) => `继续本次对话:${command}`,
|
||
langInvalid: (value) => `无效的语言 "${value}"。请使用 en 或 zh。`,
|
||
langWindowsUnsupported: (lang) =>
|
||
`penguin config lang 通过 POSIX shell 启动文件持久化语言,Windows 上没有对应机制。\n` +
|
||
`请改为设置用户环境变量:setx PENGUIN_LANG ${lang}(新终端生效)。`,
|
||
langSet: (lang, rcPath) => `语言已设为 ${lang};已将 PENGUIN_LANG 写入 ${rcPath}。`,
|
||
langRestartConfirm: () => "现在打开新 shell 使其生效?[y/N] ",
|
||
langRestart: () => "正在打开使用新语言的新 shell(输入 exit 可返回)……",
|
||
langRestartHint: (rcPath) => `请打开新终端,或执行:source ${rcPath}`,
|
||
modelAdded: (model, def) => `已添加模型 ${model}。当前默认模型:${def ?? "(未设置)"}`,
|
||
modelUpdated: (model, def) => `已更新模型 ${model}。当前默认模型:${def ?? "(未设置)"}`,
|
||
defaultModelSet: (model) => `默认模型已设为 ${model}。`,
|
||
visionModelSet: (model) => `视觉模型已设为 ${model}。`,
|
||
modelListTitle: () => "已配置的模型:",
|
||
modelListEmpty: () => "尚未配置任何模型。用 `penguin config model add` 添加。",
|
||
vaultSet: (key) => `已保存 vault 条目 ${key}。`,
|
||
vaultRemoved: (key) => `已删除 vault 条目 ${key}。`,
|
||
vaultKeyMissing: (key) => `vault 条目 ${key} 不存在。`,
|
||
vaultListTitle: () => "vault 环境变量(值已掩码):",
|
||
vaultListEmpty: () => "vault 为空。用 `penguin config vault set` 添加。",
|
||
webReady: (url) => `Web 界面已就绪:${url}`,
|
||
serverAlreadyRunning: (url) =>
|
||
`该数据根目录已有 PenguinHarness 服务在运行:${url}\n请先停止它,或用 PENGUIN_HOME 指定另一个数据根目录。`,
|
||
webAlreadyRunning: (url) => `该数据根目录已有服务在运行,打开既有实例:${url}`,
|
||
webProbeFailed: (url, detail, kind, port) => {
|
||
const hint = {
|
||
timeout: `连接超时。请检查防火墙或安全软件是否拦截。请允许 PenguinHarness 在本机端口 ${port} 上通信。`,
|
||
refused: "没有进程接受连接。请检查服务是否已经退出,或 HOST/PORT 是否指向了其他地址。",
|
||
reset: "连接在收到 HTTP 响应前已关闭。请检查本机安全软件后重试。",
|
||
permission: "操作系统拒绝了连接。请检查防火墙或安全策略权限。",
|
||
dns: "无法解析主机名。请检查 --host 或 HOST。",
|
||
unknown: `请在服务就绪后手动打开 ${url}。`,
|
||
}[kind];
|
||
return `服务探活失败:${url}\n最后一次探测错误:${detail}\n${hint}`;
|
||
},
|
||
};
|
||
|
||
/** Get the message set for a language. */
|
||
export function getMessages(language: Language): Messages {
|
||
return language === "zh" ? zh : en;
|
||
}
|
||
|
||
/** Resolve the language from the env var and return its message set (the default used when no explicit `t` is given). */
|
||
export function defaultMessages(): Messages {
|
||
return getMessages(resolveLanguage());
|
||
}
|
||
|
||
/** Mask an API key: keep only a few trailing characters; return `-` when unconfigured. */
|
||
export function maskApiKey(apiKey: string | undefined): string {
|
||
if (!apiKey) return "-";
|
||
// Mask the whole thing when ≤12 chars: `****last4` reveals too much of a short secret (same threshold as the server-side mask).
|
||
if (apiKey.length <= 12) return "***";
|
||
return `****${apiKey.slice(-4)}`;
|
||
}
|