Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
/**
* 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";
/** 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: pairs with --model-id; when omitted, resolved by unique match (ambiguity is an error). */
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;
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 };
chat: { desc: string; resume: string };
serve: {
serverDesc: string;
webDesc: string;
port: string;
host: string;
noOpen: string;
};
// —— Runtime output ——
header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string;
chatHints(): string;
confirmExit(): string;
taskInterrupted(): 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 timeout/malformed: the engine retries (reconnect) carrying already-produced content; attempt is the retry count. */
reconnectLabel(status: "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 }): string;
/** Prompt shown when `/compact` has nothing to compact (session just started / two consecutive compactions). */
compactNothing(): 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;
/** --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;
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;
/** Manual-open prompt after the `penguin web` ready-poll times out (15s). */
webTimeout(url: string): string;
}
function header(kind: "chat" | "run", agentId: string, workspace: string, model: string): string {
return `PenguinHarness ${kind} — agent=${agentId} workspace=${workspace} model=${model}`;
}
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 of --model-id; when omitted, the model id must match exactly one configured entry (ambiguity is an error)",
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; inferred from the builtin catalog when omitted, else custom",
addApiKey: "API key, stored inline in the Project's hidden .project_config.toml",
addBaseUrl: "Custom base URL",
addContextWindow: "Context window size (tokens)",
addClientType: "AgentHub client type (e.g. openai); inferred from model id 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" },
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",
},
header,
chatHints: () =>
"Type a message to start a conversation; end a line with \\; /compact to compact the context; /exit to quit; and Ctrl-C interrupts the current conversation.",
confirmExit: () => "Exit penguin? [y/N] ",
taskInterrupted: () => "[current conversation interrupted]",
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" : "response incomplete or unparseable"}; sending retry #${attempt}…`,
compactionStart: (mode, reason) =>
mode === "discard"
? `[compaction] discarding context (${reason})…`
: `[compaction] summarizing context (${reason})…`,
compactionStop: (mode, status, tokens) =>
(status === "completed"
? mode === "discard"
? "[compaction] done; old context discarded"
: "[compaction] done; continuing with the summarized context"
: `[compaction] ${status}; keeping the current context`) +
(tokens ? ` · tokens ${tokens.total} (${tokens.delta})` : ""),
compactNothing: () => "[compaction] nothing to compact yet",
approveModeInvalid: (value) =>
`Invalid approval mode "${value}". Use allow-all, deny-all, read-only, or always-ask.`,
approvalDecision: (decision) => (decision === "allow" ? "✓ [approved]" : "× [denied]"),
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.`,
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}`,
webTimeout: (url) => `Server is not responding yet; open ${url} manually once it is ready.`,
};
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 数)",
addClientType: "AgentHub 客户端协议(如 openai);缺省由 model id 推断",
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" },
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: "不自动打开浏览器",
},
header,
chatHints: () =>
"输入消息发起对话;行尾 \\ 续行;/compact 压缩上下文;/exit 退出;Ctrl-C 中断对话。",
confirmExit: () => "确认退出 penguin?[y/N] ",
taskInterrupted: () => "[已中断当前对话]",
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" ? "连接超时或网络中断" : "响应不完整或无法解析"},正在发起第 ${attempt} 次重试……`,
compactionStart: (mode, reason) =>
mode === "discard"
? `[压缩] 正在丢弃旧上下文(${reason})……`
: `[压缩] 正在总结压缩上下文(${reason})……`,
compactionStop: (mode, status, tokens) =>
(status === "completed"
? mode === "discard"
? "[压缩] 完成,旧上下文已丢弃"
: "[压缩] 完成,已切换到摘要后的新上下文"
: `[压缩] ${status === "aborted" ? "已中断" : "失败"},保留当前上下文`) +
(tokens ? ` · tokens ${tokens.total} (${tokens.delta})` : ""),
compactNothing: () => "[压缩] 当前上下文为空,无需压缩",
approveModeInvalid: (value) =>
`无效的审批模式 "${value}"。请使用 allow-all、deny-all、read-only 或 always-ask。`,
approvalDecision: (decision) => (decision === "allow" ? "✓ [已批准]" : "× [已拒绝]"),
resumeNoOverride: () =>
"--resume 不接受 --workspace、--model-id 与 --provider:均沿用原 Session,创建后不可更换。",
resumeNoSession: () => "没有可恢复的 Session:当前 Agent 还没有任何会话记录。",
resumedBanner: (sessionId, messageCount) =>
`[已恢复] ${sessionId} · 当前上下文共 ${messageCount} 条消息`,
resumeHint: (command) => `继续本次对话:${command}`,
langInvalid: (value) => `无效的语言 "${value}"。请使用 en 或 zh。`,
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}`,
webTimeout: (url) => `服务尚未就绪,请稍后手动打开 ${url}。`,
};
/** 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)}`;
}