arona-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
  4. package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
  5. package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
  6. package/assets/blue-archive/arona/voice.mp3 +0 -0
  7. package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
  8. package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
  9. package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
  10. package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
  11. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
  12. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
  13. package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
  14. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
  15. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
  16. package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
  17. package/assets/blue-archive/hoshino/voice.mp3 +0 -0
  18. package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
  19. package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
  20. package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
  21. package/assets/blue-archive/plana/voice.mp3 +0 -0
  22. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
  23. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
  24. package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
  25. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
  26. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
  27. package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
  28. package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
  29. package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
  30. package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
  31. package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
  32. package/assets/blue-archive/shiroko/voice.mp3 +0 -0
  33. package/bin/arona.mjs +43 -0
  34. package/package.json +37 -0
  35. package/pet/agents.cjs +197 -0
  36. package/pet/main.cjs +462 -0
  37. package/pet/preload.cjs +21 -0
  38. package/pet/renderer/fx.html +30 -0
  39. package/pet/renderer/fx.js +41 -0
  40. package/pet/renderer/gallery.html +18 -0
  41. package/pet/renderer/gallery.js +31 -0
  42. package/pet/renderer/index.html +18 -0
  43. package/pet/renderer/renderer.js +382 -0
  44. package/pet/renderer/spine_layer.js +609 -0
  45. package/pet/renderer/spinetest.html +24 -0
  46. package/pet/renderer/spinetest.js +295 -0
  47. package/pet/renderer/style.css +63 -0
  48. package/pet/tools/gallery_capture.cjs +267 -0
  49. package/pet/tools/gen_sway.cjs +170 -0
  50. package/pet/tools/meshify_region.cjs +105 -0
  51. package/pet/tools/skel_inspect.cjs +112 -0
  52. package/pet/tools/skel_to_json.cjs +410 -0
  53. package/pet/tools/spine_node.cjs +69 -0
  54. package/pet/tools/visual_test.cjs +262 -0
  55. package/pet/vendor/spine/spine-canvas.js +8472 -0
  56. package/pet/vendor/spine/spine-webgl.js +10839 -0
  57. package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
  58. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  59. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  60. package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
  61. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  62. package/python/__pycache__/stt.cpython-313.pyc +0 -0
  63. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  64. package/python/__pycache__/tts.cpython-313.pyc +0 -0
  65. package/python/__pycache__/tts.cpython-314.pyc +0 -0
  66. package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
  67. package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
  68. package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
  69. package/python/_i18n.py +18 -0
  70. package/python/computer_use.py +190 -0
  71. package/python/hotkey.py +196 -0
  72. package/python/stt.py +240 -0
  73. package/python/tts_say.py +177 -0
  74. package/python/voice_clone.py +98 -0
  75. package/requirements.txt +6 -0
  76. package/src/agent.ts +791 -0
  77. package/src/agent_registry.ts +109 -0
  78. package/src/commands.ts +641 -0
  79. package/src/config.ts +245 -0
  80. package/src/in_memory_credentials.ts +61 -0
  81. package/src/index.ts +78 -0
  82. package/src/locale.ts +131 -0
  83. package/src/logo.ts +120 -0
  84. package/src/mcp.ts +192 -0
  85. package/src/memory.ts +303 -0
  86. package/src/pet.ts +346 -0
  87. package/src/renderer.ts +350 -0
  88. package/src/repl.ts +902 -0
  89. package/src/setup.ts +371 -0
  90. package/src/skills.ts +96 -0
  91. package/src/slash_menu.ts +371 -0
  92. package/src/slash_registry.ts +53 -0
  93. package/src/speaker_context.ts +41 -0
  94. package/src/text_split.ts +79 -0
  95. package/src/tools/computer_use.ts +142 -0
  96. package/src/tools/emotion_tool.ts +55 -0
  97. package/src/tools/keep_silent_tool.ts +26 -0
  98. package/src/tools/memory_tool.ts +24 -0
  99. package/src/tools/skill_tools.ts +100 -0
  100. package/src/tools/tavily_tools.ts +431 -0
  101. package/src/tools/voice_tools.ts +28 -0
  102. package/src/tts_stream.ts +260 -0
  103. package/src/tui_select.ts +146 -0
  104. package/src/undo.ts +451 -0
  105. package/src/utils/python.ts +211 -0
  106. package/src/utils/spawn.ts +56 -0
  107. package/src/voice.ts +83 -0
  108. package/src/voice_cli.ts +198 -0
  109. package/src/voices.ts +135 -0
package/src/pet.ts ADDED
@@ -0,0 +1,346 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import chalk from "chalk";
5
+ import { PET_DIR, verbose } from "./config.ts";
6
+ import { getMainAgent, getSubAgents, type AgentId } from "./agent_registry.ts";
7
+ import { t } from "./locale.ts";
8
+
9
+ /** 桌宠要注入到下一轮用户消息的手势事件类型(摸头 / dizzy) */
10
+ export type PetGestureType = "pat" | "dizzy";
11
+
12
+ const PREFIX = "###PET###";
13
+ const MAX_RESTARTS = 3;
14
+ const STDERR_RING_SIZE = 100; // 崩溃时 dump 的 stderr 最近行数
15
+ // Electron 42+ 移除了 postinstall 自动下载:二进制改为首次 require 时才按需下载(供应链安全考虑)。
16
+ // pet 桥是项目里唯一 require("electron") 的地方,这里用国内镜像在 import 之前手动预下载,
17
+ // 避免首次启动时 CLI 卡在慢速的 GitHub Releases 下载上。
18
+ const ELECTRON_DIR = join(PET_DIR, "..", "node_modules", "electron");
19
+ const ELECTRON_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/npm/electron";
20
+
21
+ // --verbose 调试日志:原样转发 Electron stdout/stderr、spawn 参数、协议消息,
22
+ // 用于锁定 Windows 白屏等"进程活着但无画面"类问题(正常模式这些日志被静默/过滤)。
23
+ function vlog(...args: unknown[]): void {
24
+ if (verbose) console.error(chalk.gray("[pet:verbose]"), ...args);
25
+ }
26
+
27
+ /** 与 electron/install.js getPlatformPath() 保持一致:各平台可执行文件的相对路径 */
28
+ function electronPlatformPath(): string {
29
+ if (process.platform === "win32") return "electron.exe";
30
+ if (process.platform === "darwin") return "Electron.app/Contents/MacOS/Electron";
31
+ return "electron";
32
+ }
33
+
34
+ /** dist 二进制是否已就绪(版本匹配 + 可执行文件存在),与 install.js 的 isInstalled() 判定一致 */
35
+ function isElectronInstalled(): boolean {
36
+ try {
37
+ const pkg = JSON.parse(readFileSync(join(ELECTRON_DIR, "package.json"), "utf-8"));
38
+ const version = readFileSync(join(ELECTRON_DIR, "dist", "version"), "utf-8").replace(/^v/, "");
39
+ return version === pkg.version && existsSync(join(ELECTRON_DIR, "dist", electronPlatformPath()));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Desktop pet bridge: spawns the Electron pet window as a subprocess and
47
+ * talks to it via stdin/stdout JSON lines (protocol lines prefixed with
48
+ * ###PET### to filter Electron's own stdout noise).
49
+ *
50
+ * All commands are fire-and-forget — the pet is not a critical path.
51
+ */
52
+ class PetBridge {
53
+ private proc: ChildProcessWithoutNullStreams | null = null;
54
+ private buffer = "";
55
+ private intentionalStop = false;
56
+ private restartCount = 0;
57
+ private restartTimer: ReturnType<typeof setTimeout> | null = null;
58
+ // stderr 环形缓冲:进程异常退出时 dump 最近输出,便于定位崩溃根因
59
+ private stderrRing: string[] = [];
60
+ // 当前桌宠角色(ARONA_AGENT env 传给 pet/main.cjs);默认跟随 settings.json mainAgent
61
+ private agentId: AgentId = getMainAgent();
62
+ // 当前启用的子 Agent(ARONA_SUB_AGENTS env 传给 pet/main.cjs);默认跟随 settings.json subAgents
63
+ private subAgents: AgentId[] = getSubAgents();
64
+ // 待切换角色组合:stop() 后等待旧进程 close 再以新组合拉起(避免双窗口闪现)
65
+ private pendingSelection: { main: AgentId; subs: AgentId[] } | null = null;
66
+ // 最近一次桌宠手势(摸头/dizzy):只保留最新一次,供下一条用户消息注入一次后即消费清空
67
+ private latestGesture: PetGestureType | null = null;
68
+
69
+ /**
70
+ * Electron 42+ 把二进制下载从 postinstall 挪到了首次 require,而 pet 桥是唯一
71
+ * require("electron") 的地方。这里在 import 之前抢先检查 dist 是否就绪:
72
+ * 未就绪则用国内镜像手动执行预下载脚本(install.js 与原 postinstall 同代码、自带幂等检查),
73
+ * 并给下载过程让出一段专属输出区域提示用户等待。
74
+ *
75
+ * @returns "missing" = electron npm 包未安装;true = 已就绪或下载成功;false = 下载失败
76
+ */
77
+ private async ensureElectronBinary(): Promise<true | false | "missing"> {
78
+ if (!existsSync(join(ELECTRON_DIR, "package.json"))) return "missing";
79
+ if (isElectronInstalled()) return true;
80
+
81
+ // 下载期间只显示一行提示,忽略 --verbose;install.js 的进度/冗余输出全部静默(stdio 不继承)
82
+ console.log(chalk.cyan(t(" 正在使用国内源下载Electron……", " Downloading Electron from CN mirror…")));
83
+
84
+ const ok = await new Promise<boolean>((resolve) => {
85
+ const child = spawn(process.execPath, [join(ELECTRON_DIR, "install.js")], {
86
+ // 注入国内镜像;用户若已自定义 ELECTRON_MIRROR 则尊重其配置
87
+ env: { ...process.env, ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? ELECTRON_MIRROR },
88
+ // 默认静默(只显示一行提示);--verbose 时转发 install.js 全量输出便于排障
89
+ stdio: verbose ? "inherit" : "ignore",
90
+ });
91
+ child.on("error", (err) => {
92
+ console.warn(chalk.yellow(t(`桌宠:无法启动 Electron 安装器(${err.message})。`, `Pet: failed to launch Electron installer (${err.message}).`)));
93
+ resolve(false);
94
+ });
95
+ child.on("exit", (code) => {
96
+ if (code !== 0) {
97
+ console.warn(chalk.yellow(t("桌宠:Electron 下载失败,已跳过桌宠,可稍后重试。", "Pet: Electron download failed, pet skipped; retry later.")));
98
+ resolve(false);
99
+ return;
100
+ }
101
+ console.log(chalk.green(t(" ✓ Electron 安装完成", " ✓ Electron installed")));
102
+ resolve(true);
103
+ });
104
+ });
105
+ return ok;
106
+ }
107
+
108
+ async start(): Promise<void> {
109
+ // Headless 环境降级(Linux 无显示服务器时直接跳过)
110
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
111
+ console.warn(chalk.yellow(t("桌宠:无显示服务器,跳过启动。", "Pet: no display server, skipping startup.")));
112
+ return;
113
+ }
114
+
115
+ // 未安装 electron 包 → 维持原有降级提示;下载失败 → 提示已在 ensureElectronBinary 内输出
116
+ const binaryState = await this.ensureElectronBinary();
117
+ if (binaryState === "missing") {
118
+ console.warn(chalk.yellow(t("桌宠:未安装 electron,跳过启动(npm install -D electron)。", "Pet: electron not installed, skipping startup (npm install -D electron).")));
119
+ return;
120
+ }
121
+ if (binaryState === false) return;
122
+
123
+ let electronPath: string;
124
+ try {
125
+ // electron 包默认导出二进制路径字符串
126
+ const mod = await import("electron");
127
+ electronPath = mod.default as unknown as string;
128
+ } catch {
129
+ console.warn(chalk.yellow(t("桌宠:electron 加载失败,跳过启动。", "Pet: failed to load electron, skipping startup.")));
130
+ return;
131
+ }
132
+
133
+ this.intentionalStop = false;
134
+
135
+ try {
136
+ // 剔除 ELECTRON_RUN_AS_NODE,否则 Electron 会退化为纯 Node 运行;
137
+ // 注入 ARONA_AGENT 让桌宠主进程选择主角色,ARONA_SUB_AGENTS 选择子角色窗口(agents.cjs)
138
+ const env: NodeJS.ProcessEnv = {
139
+ ...process.env,
140
+ ARONA_AGENT: this.agentId,
141
+ ARONA_SUB_AGENTS: this.subAgents.join(","),
142
+ };
143
+ delete env.ELECTRON_RUN_AS_NODE;
144
+ // --no-sandbox:部分环境(权限受限的终端/容器)无法初始化 Chromium sandbox;
145
+ // 桌宠只加载本地文件,关闭 sandbox 风险可接受
146
+ const args = ["--no-sandbox"];
147
+ if (verbose) {
148
+ // --enable-logging:把渲染进程 console(WebGL/GPU 初始化失败等)原样打进 stderr——
149
+ // 不传的话 renderer 的 console.error 默认只进 devtools,白屏根因完全不可见。
150
+ // 同时用 env 通知 pet/main.cjs 输出主进程详细日志。
151
+ args.push("--enable-logging");
152
+ env.ARONA_PET_VERBOSE = "1";
153
+ vlog("spawn", electronPath, args.concat(join(PET_DIR, "main.cjs")), "main=", this.agentId, "subs=", this.subAgents.join(","));
154
+ }
155
+ this.proc = spawn(electronPath, args.concat(join(PET_DIR, "main.cjs")), {
156
+ env,
157
+ stdio: ["pipe", "pipe", "pipe"],
158
+ });
159
+ } catch (err) {
160
+ console.warn(chalk.yellow(t(`桌宠:启动失败(${err instanceof Error ? err.message : err}),已降级。`, `Pet: failed to start (${err instanceof Error ? err.message : err}), degraded.`)));
161
+ this.proc = null;
162
+ return;
163
+ }
164
+
165
+ this.proc.stdout.on("data", (data) => {
166
+ this.buffer += data.toString();
167
+ const lines = this.buffer.split("\n");
168
+ this.buffer = lines.pop() || "";
169
+ for (const line of lines) this.handleLine(line);
170
+ });
171
+
172
+ this.proc.stderr.on("data", (data) => {
173
+ const msg = data.toString().trim();
174
+ if (!msg) return;
175
+ // 始终入环(崩溃时 dump 用);平时仅打印非空且不常见的错误
176
+ this.stderrRing.push(msg);
177
+ if (this.stderrRing.length > STDERR_RING_SIZE) this.stderrRing.shift();
178
+ // verbose:原样转发全部 stderr(不过滤 dbus/GLib——它们可能夹带关键渲染日志)
179
+ if (verbose || (!msg.includes("dbus") && !msg.includes("GLib"))) {
180
+ console.error(chalk.gray("[pet]"), msg);
181
+ }
182
+ });
183
+
184
+ this.proc.on("error", (err) => {
185
+ vlog("spawn error:", err.message);
186
+ console.warn(chalk.yellow(t(`桌宠:进程错误(${err.message}),已降级。`, `Pet: process error (${err.message}), degraded.`)));
187
+ this.proc = null;
188
+ });
189
+
190
+ this.proc.on("close", (code, signal) => {
191
+ vlog("process closed", { code, signal, intentional: this.intentionalStop, pendingSelection: this.pendingSelection });
192
+ this.proc = null;
193
+ if (this.pendingSelection) {
194
+ // 切换角色:旧进程已退出,以新角色组合重新拉起(stop 时 intentionalStop=true 不会走退避重启)
195
+ const sel = this.pendingSelection;
196
+ this.pendingSelection = null;
197
+ this.agentId = sel.main;
198
+ this.subAgents = sel.subs;
199
+ void this.start();
200
+ } else if (!this.intentionalStop && (code === null || code !== 0)) {
201
+ // 正常退出(code 0)不重启;被信号终止(code null)或异常退出才视为崩溃
202
+ console.warn(chalk.yellow(t(`桌宠:进程异常退出(code=${code} signal=${signal}),准备重启。`, `Pet: process exited abnormally (code=${code} signal=${signal}), restarting.`)));
203
+ this.dumpStderr();
204
+ this.scheduleRestart();
205
+ }
206
+ });
207
+ }
208
+
209
+ /** 崩溃时输出 Electron 最近 stderr(渲染崩溃/主进程异常堆栈都在这里) */
210
+ private dumpStderr(): void {
211
+ if (!this.stderrRing.length) return;
212
+ console.error(chalk.gray(`[pet:stderr] 最近 ${this.stderrRing.length} 行输出(定位崩溃用):\n` + this.stderrRing.join("\n")));
213
+ this.stderrRing = [];
214
+ }
215
+
216
+ private scheduleRestart(): void {
217
+ if (this.restartCount >= MAX_RESTARTS) {
218
+ console.warn(chalk.yellow(t("桌宠:崩溃次数过多,已放弃重启。", "Pet: too many crashes, giving up restarting.")));
219
+ return;
220
+ }
221
+ const delay = 1000 * 2 ** this.restartCount; // 1s / 2s / 4s
222
+ this.restartCount++;
223
+ this.restartTimer = setTimeout(() => {
224
+ this.restartTimer = null;
225
+ void this.start();
226
+ }, delay);
227
+ }
228
+
229
+ private handleLine(line: string): void {
230
+ const trimmed = line.trim();
231
+ if (!trimmed.startsWith(PREFIX)) return;
232
+ let msg: any;
233
+ try {
234
+ msg = JSON.parse(trimmed.slice(PREFIX.length));
235
+ } catch {
236
+ return;
237
+ }
238
+ vlog("recv", JSON.stringify(msg));
239
+ if (msg.type === "ready") {
240
+ this.restartCount = 0; // 成功启动后重置退避计数
241
+ } else if (msg.type === "error") {
242
+ console.error(chalk.gray("[pet]"), msg.message);
243
+ } else if (msg.type === "crash") {
244
+ // 渲染/GPU 子进程崩溃上报(pet/main.cjs 的 render-process-gone / child-process-gone)
245
+ console.error(chalk.red(`[pet:crash] ${msg.kind ?? "process"} reason=${msg.reason ?? "?"} exitCode=${msg.exitCode ?? "?"} url=${msg.url ?? ""}`));
246
+ // verbose:附带原始 detail(main.cjs 在 ARONA_PET_VERBOSE 下会补全字段)
247
+ vlog("crash detail:", JSON.stringify(msg));
248
+ } else if (msg.type === "shake") {
249
+ // 摸头:记录最新手势。仅主 Agent 事件(sub 无摸头),无需区分 agent。
250
+ this.latestGesture = "pat";
251
+ } else if (msg.type === "dizzy") {
252
+ this.latestGesture = "dizzy";
253
+ }
254
+ // moved 目前仅作日志用途,无需处理
255
+ }
256
+
257
+ /**
258
+ * 取走待注入的手势且只保留最近一次:消费即清空,避免重复注入。
259
+ * 返回 null 表示没有待注入手势(用户没有摸头/dizzy,正常只发普通消息)。
260
+ */
261
+ takeGesture(): PetGestureType | null {
262
+ const g = this.latestGesture;
263
+ this.latestGesture = null;
264
+ return g;
265
+ }
266
+
267
+ private send(msg: Record<string, unknown>): void {
268
+ if (!this.proc || this.proc.killed) return;
269
+ vlog("send", JSON.stringify(msg));
270
+ try {
271
+ this.proc.stdin.write(JSON.stringify(msg) + "\n");
272
+ } catch {
273
+ // stdin 已关闭,忽略
274
+ }
275
+ }
276
+
277
+ setEmotion(agentId: AgentId, name: string): void {
278
+ this.send({ type: "set_emotion", agent: agentId, name });
279
+ }
280
+
281
+ sendText(agentId: AgentId, kind: string, data: string): void {
282
+ this.send({ type: "text", agent: agentId, kind, data });
283
+ }
284
+
285
+ reset(): void {
286
+ this.send({ type: "reset" });
287
+ }
288
+
289
+ /**
290
+ * 切换桌宠主角色(子 Agent 组合保持当前值)。
291
+ * 运行中:先 stop(intentional,不触发退避重启),旧进程 close 后再以新组合拉起,避免双窗口闪现。
292
+ * 未运行:直接记住角色,下次 start() 生效。
293
+ */
294
+ restartWithAgent(id: AgentId): void {
295
+ this.restartWithSelection(id, this.subAgents);
296
+ }
297
+
298
+ /**
299
+ * 切换主角色 + 子角色组合(pet/main.cjs 按 ARONA_AGENT + ARONA_SUB_AGENTS 创建多窗口)。
300
+ */
301
+ restartWithSelection(main: AgentId, subs: readonly AgentId[]): void {
302
+ if (!this.isRunning) {
303
+ this.agentId = main;
304
+ this.subAgents = [...subs];
305
+ void this.start();
306
+ return;
307
+ }
308
+ this.pendingSelection = { main, subs: [...subs] };
309
+ this.stop();
310
+ }
311
+
312
+ stop(): void {
313
+ this.intentionalStop = true;
314
+ if (this.restartTimer) {
315
+ clearTimeout(this.restartTimer);
316
+ this.restartTimer = null;
317
+ }
318
+ if (this.proc && !this.proc.killed) {
319
+ this.send({ type: "quit" });
320
+ try {
321
+ this.proc.stdin.end();
322
+ } catch {
323
+ // 忽略
324
+ }
325
+ const proc = this.proc;
326
+ setTimeout(() => {
327
+ if (!proc.killed) proc.kill("SIGTERM");
328
+ }, 500);
329
+ this.proc = null;
330
+ }
331
+ }
332
+
333
+ get isRunning(): boolean {
334
+ return this.proc !== null && !this.proc.killed;
335
+ }
336
+ }
337
+
338
+ export const pet = new PetBridge();
339
+
340
+ export async function startPet(): Promise<void> {
341
+ await pet.start();
342
+ }
343
+
344
+ export function stopPet(): void {
345
+ pet.stop();
346
+ }
@@ -0,0 +1,350 @@
1
+ import chalk from "chalk";
2
+ import { t } from "./locale.ts";
3
+ import { getAgentLabel, getMainAgent } from "./agent_registry.ts";
4
+ import { countTextUnits } from "./text_split.ts";
5
+
6
+ let showThinking = true;
7
+ let showToolDetails = true;
8
+
9
+ export function setShowThinking(v: boolean) { showThinking = v; }
10
+ export function getShowThinking() { return showThinking; }
11
+ export function setShowToolDetails(v: boolean) { showToolDetails = v; }
12
+ export function getShowToolDetails() { return showToolDetails; }
13
+
14
+ const THINKING_TAIL_LINES = 3; // 流式与历史回放统一:只显示思考尾部 N 行
15
+ const PET_MAX_BUBBLE_LEN = 50;
16
+
17
+ // ── 显示宽度 & 行数工具(CJK/全角算 2,ANSI 控制符算 0)────────────
18
+ function charWidth(code: number): number {
19
+ if (code < 0x20) return 0;
20
+ if (
21
+ code >= 0x1100 &&
22
+ (code <= 0x115f ||
23
+ (code >= 0x2e80 && code <= 0x303e) ||
24
+ (code >= 0x3040 && code <= 0x33bf) ||
25
+ (code >= 0x3400 && code <= 0x4dbf) ||
26
+ (code >= 0x4e00 && code <= 0xa4cf) ||
27
+ (code >= 0xac00 && code <= 0xd7af) ||
28
+ (code >= 0xf900 && code <= 0xfaff) ||
29
+ (code >= 0xfe30 && code <= 0xfe6f) ||
30
+ (code >= 0xff01 && code <= 0xff60) ||
31
+ (code >= 0xffe0 && code <= 0xffe6) ||
32
+ (code >= 0x1f300 && code <= 0x1f9ff))
33
+ ) {
34
+ return 2;
35
+ }
36
+ return 1;
37
+ }
38
+ function strWidth(s: string): number {
39
+ let w = 0;
40
+ for (const ch of s) w += charWidth(ch.codePointAt(0)!);
41
+ return w;
42
+ }
43
+ /** 给定一行可见文本,根据当前终端列宽计算实际占用的物理行数(自动软换行)。 */
44
+ function physicalLinesForRow(visibleText: string): number {
45
+ const cols = process.stdout.columns ?? 80;
46
+ if (cols <= 0) return 1;
47
+ const w = Math.max(1, strWidth(visibleText));
48
+ return Math.ceil(w / cols);
49
+ }
50
+
51
+ type PetTextKind = "mid" | "final";
52
+
53
+ export function createRenderer(
54
+ onTurnEnd?: (text: string) => void,
55
+ onPetText?: (kind: PetTextKind, data: string) => void,
56
+ initialSpeakerLabel?: string,
57
+ ) {
58
+ let speakerLabel = initialSpeakerLabel;
59
+ let inThinking = false;
60
+ let inText = false;
61
+ let textPrefixWritten = false;
62
+
63
+ // ── 流式思考折叠重绘 ──────────────────────────────────────────
64
+ let thinkingBuffer = ""; // 完整思考内容缓冲区
65
+ let drawnThinkingLines = 0; // 上次渲染占用的**物理行数**(含 ANSI 软换行)
66
+ // 思考前缀:每行都加 2 空格缩进(与历史回放 renderSavedMessages 对齐)
67
+ const THINKING_PREFIX = " ";
68
+
69
+ /** 擦除上次画出的思考区域,回到思考块第一行的行首。 */
70
+ function eraseDrawnThinking(): void {
71
+ if (drawnThinkingLines <= 0) return;
72
+ // 光标当前在思考块最后一行末尾。先回到该行第 1 列 → 上移 N-1 行到思考块顶行 → 清到屏幕末尾
73
+ process.stdout.write("\r");
74
+ if (drawnThinkingLines > 1) {
75
+ process.stdout.write(`\x1b[${drawnThinkingLines - 1}A`);
76
+ }
77
+ process.stdout.write("\x1b[0J");
78
+ drawnThinkingLines = 0;
79
+ }
80
+
81
+ /**
82
+ * 根据 thinkingBuffer 按折叠规则渲染(省略前缀 + 最后 THINKING_TAIL_LINES 行)。
83
+ * 与历史回放 renderSavedMessages 的格式严格对齐:
84
+ * - 空缓冲 → 什么都不画
85
+ * - 逻辑行 ≤ THINKING_TAIL_LINES → 全画
86
+ * - 逻辑行 > THINKING_TAIL_LINES → 1 行省略提示 + 尾部 N 行
87
+ * - 每行前缀 = THINKING_PREFIX
88
+ */
89
+ function renderThinking(): void {
90
+ const raw = thinkingBuffer.replace(/\n+$/, "");
91
+ if (!raw) return;
92
+ const allLines = raw.split("\n");
93
+ const total = allLines.length;
94
+ const showTail = total <= THINKING_TAIL_LINES
95
+ ? allLines
96
+ : allLines.slice(-THINKING_TAIL_LINES);
97
+ const hidden = total - showTail.length;
98
+
99
+ // 准备要写出的行(带格式、带前缀)
100
+ const renderLines: { visible: string; styled: string }[] = [];
101
+ if (hidden > 0) {
102
+ const visibleMsg = t(
103
+ `… (已省略 ${hidden} 行早期思考内容)`,
104
+ `… (${hidden} earlier thinking lines omitted)`,
105
+ );
106
+ renderLines.push({
107
+ visible: THINKING_PREFIX + visibleMsg,
108
+ styled: chalk.dim(THINKING_PREFIX + visibleMsg),
109
+ });
110
+ }
111
+ for (const line of showTail) {
112
+ renderLines.push({
113
+ visible: THINKING_PREFIX + line,
114
+ styled: chalk.dim(THINKING_PREFIX + line),
115
+ });
116
+ }
117
+
118
+ // 输出 + 统计物理行数
119
+ let physLines = 0;
120
+ const out: string[] = [];
121
+ for (const row of renderLines) {
122
+ out.push(row.styled);
123
+ physLines += physicalLinesForRow(row.visible);
124
+ }
125
+ process.stdout.write(out.join("\r\n"));
126
+ drawnThinkingLines = physLines;
127
+ }
128
+
129
+ /** 流式思考增量更新:擦旧 → 追加缓冲 → 重绘。 */
130
+ function appendThinkingDelta(delta: string): void {
131
+ eraseDrawnThinking();
132
+ thinkingBuffer += delta;
133
+ renderThinking();
134
+ }
135
+
136
+ /** 结束流式思考、准备输出正文时调用。把思考块"封版",游标移到下一行开头。 */
137
+ function finalizeThinking(): void {
138
+ if (drawnThinkingLines > 0) {
139
+ // 画完最后一帧后光标在末行末尾;切到下一行第 1 列即可。
140
+ process.stdout.write("\r\n");
141
+ }
142
+ drawnThinkingLines = 0;
143
+ thinkingBuffer = "";
144
+ }
145
+
146
+ // 回合文本累积:只保留最后一个 assistant message 的文本(TTS 与气泡都在 agent_end 收尾)
147
+ let curMsgText = ""; // 当前 message 的 text_delta 累积
148
+ let lastText = ""; // 最近一个 message 的完整文本(候选:中间段会被后续覆盖)
149
+
150
+ /** 把 lines 按"最早的优先保留、最后一条优先被挤掉"裁到总字数 < maxUnits。 */
151
+ function trimBubbleLines(lines: string[], maxUnits: number): string[] {
152
+ let total = lines.reduce((s, l) => s + countTextUnits(l), 0);
153
+ while (total > maxUnits && lines.length > 1) {
154
+ total -= countTextUnits(lines[0]);
155
+ lines.shift();
156
+ }
157
+ return lines;
158
+ }
159
+
160
+ return {
161
+ setSpeakerLabel(label: string | undefined) {
162
+ speakerLabel = label;
163
+ },
164
+ subscribe: (session: any) => {
165
+ return session.subscribe((event: any) => {
166
+ switch (event.type) {
167
+ case "message_start":
168
+ inText = false;
169
+ inThinking = false;
170
+ textPrefixWritten = false;
171
+ curMsgText = "";
172
+ thinkingBuffer = "";
173
+ drawnThinkingLines = 0;
174
+ break;
175
+
176
+ case "message_update": {
177
+ const ae = event.assistantMessageEvent;
178
+ if (ae.type === "text_delta") {
179
+ if (!inText) {
180
+ if (inThinking) {
181
+ finalizeThinking();
182
+ inThinking = false;
183
+ }
184
+ inText = true;
185
+ if (speakerLabel && !textPrefixWritten) {
186
+ process.stdout.write(chalk.magenta.bold(speakerLabel + ":"));
187
+ textPrefixWritten = true;
188
+ }
189
+ }
190
+ process.stdout.write(ae.delta);
191
+ // 文本只累积到当前 message,TTS 与气泡都在 agent_end 一次性收尾(只读最后一段)
192
+ curMsgText += ae.delta;
193
+ } else if (ae.type === "thinking_delta") {
194
+ if (showThinking) {
195
+ if (!inThinking) {
196
+ inThinking = true;
197
+ inText = false;
198
+ // 先开一个新行;后续所有增量走 erase+redraw,从这行开始覆盖
199
+ process.stdout.write("\r\n");
200
+ drawnThinkingLines = 0;
201
+ thinkingBuffer = "";
202
+ }
203
+ appendThinkingDelta(ae.delta);
204
+ }
205
+ }
206
+ break;
207
+ }
208
+
209
+ case "message_end": {
210
+ if (inThinking && showThinking) {
211
+ finalizeThinking();
212
+ }
213
+ if (inText) {
214
+ process.stdout.write("\n");
215
+ }
216
+ // 暂存最近一个 message 的完整文本作为"最后一段"候选;中间段会被后续覆盖。
217
+ // TTS 与气泡都不在此处收尾——agent_end 时才决定朗读/上屏哪一段。
218
+ lastText = curMsgText.trim();
219
+ curMsgText = "";
220
+ inThinking = false;
221
+ inText = false;
222
+ thinkingBuffer = "";
223
+ drawnThinkingLines = 0;
224
+ break;
225
+ }
226
+
227
+ case "agent_end": {
228
+ // 回合全部文字输出完毕:只取最后一个 message 的文本收尾 TTS(中间过程性发言不朗读),
229
+ // 气泡与 TTS 同步,一次性上屏最后一段(只发 final)。
230
+ if (lastText) {
231
+ onTurnEnd?.(lastText);
232
+ if (onPetText) {
233
+ const units = countTextUnits(lastText);
234
+ if (units > 0) {
235
+ if (units >= PET_MAX_BUBBLE_LEN) {
236
+ onPetText("final", lastText);
237
+ } else {
238
+ onPetText("final", trimBubbleLines([lastText], PET_MAX_BUBBLE_LEN).join("\n"));
239
+ }
240
+ }
241
+ }
242
+ }
243
+ lastText = "";
244
+ break;
245
+ }
246
+
247
+ case "tool_execution_start":
248
+ if (showToolDetails) {
249
+ const name = event.toolName;
250
+ const input = event.input ? JSON.stringify(event.input).slice(0, 100) : "";
251
+ process.stdout.write(chalk.cyan(t(`\n [工具] ${name}`, `\n [tool] ${name}`)) + (input ? ` ${input}` : "") + "\n");
252
+ }
253
+ break;
254
+
255
+ case "tool_execution_end":
256
+ if (showToolDetails) {
257
+ const status = event.isError ? chalk.red(t("错误", "error")) : chalk.green(t("完成", "done"));
258
+ process.stdout.write(t(" [工具] ", " [tool] ") + `${status}\n`);
259
+ }
260
+ break;
261
+
262
+ case "compaction_start":
263
+ process.stdout.write(chalk.yellow(t("\n[正在压缩上下文...]\n", "\n[Compacting context...]\n")));
264
+ break;
265
+
266
+ case "compaction_end":
267
+ if (event.aborted) {
268
+ process.stdout.write(chalk.yellow(t("[压缩已取消]\n\n", "[Compaction cancelled]\n\n")));
269
+ } else if (event.errorMessage) {
270
+ // SDK 错误信息带 "Compaction failed: " 前缀,去掉保持输出整洁
271
+ const msg = String(event.errorMessage).replace(/^Compaction failed:\s*/i, "");
272
+ process.stdout.write(chalk.red(t(`[压缩失败] ${msg}\n\n`, `[Compaction failed] ${msg}\n\n`)));
273
+ } else {
274
+ process.stdout.write(chalk.yellow(t("[压缩完成]\n\n", "[Compaction complete]\n\n")));
275
+ }
276
+ break;
277
+
278
+ case "auto_retry_start":
279
+ process.stdout.write(chalk.yellow(t("\n[重试中...]\n", "\n[Retrying...]\n")));
280
+ break;
281
+ }
282
+ });
283
+ },
284
+ };
285
+ }
286
+
287
+ /**
288
+ * 将已保存会话的消息回放渲染到终端(用于 /resume 恢复后重绘历史)。
289
+ * 仅渲染文件中的消息,不涉及当前 session 状态,避免上下文串扰。
290
+ *
291
+ * 消息结构(见 memory.ts loadSession):
292
+ * - { role: "user", content: [{type:"text",text}] }
293
+ * - { role: "assistant", content: [{type:"thinking"},{type:"text"},{type:"toolCall"}] }
294
+ * - { role: "toolResult", toolName, content:[{type:"text",text}], isError }
295
+ */
296
+ export function renderSavedMessages(messages: any[]) {
297
+ for (const msg of messages) {
298
+ if (msg.type === "arona-session") continue; // 跳过 header(loadSession 已过滤)
299
+
300
+ if (msg.role === "user") {
301
+ const text = extractText(msg.content);
302
+ if (text) {
303
+ process.stdout.write(chalk.cyan.bold(t("你:", "You: ")) + text + "\n\n");
304
+ }
305
+ } else if (msg.role === "assistant") {
306
+ const content = msg.content || [];
307
+ // 思考内容(仅显示尾部,与流式渲染一致)
308
+ const thinkingBlocks = content.filter((b: any) => b.type === "thinking");
309
+ for (const tb of thinkingBlocks) {
310
+ const thinking = tb.thinking || "";
311
+ if (thinking.trim()) {
312
+ const allLines = thinking.replace(/\n+$/, "").split("\n");
313
+ const tail = allLines.slice(-THINKING_TAIL_LINES);
314
+ const hidden = allLines.length - tail.length;
315
+ process.stdout.write("\n");
316
+ if (hidden > 0) {
317
+ process.stdout.write(chalk.dim(t(` … (已省略 ${hidden} 行早期思考内容)\n`, ` … (${hidden} earlier thinking lines omitted)\n`)));
318
+ }
319
+ for (const line of tail) {
320
+ process.stdout.write(chalk.dim(` ${line}\n`));
321
+ }
322
+ }
323
+ }
324
+ // 文本内容
325
+ const text = extractText(content);
326
+ if (text) {
327
+ const speaker = typeof msg.speaker === "string" ? getAgentLabel(msg.speaker as any) : getAgentLabel(getMainAgent());
328
+ process.stdout.write(chalk.magenta.bold(speaker + ":") + text + "\n\n");
329
+ }
330
+ // 工具调用
331
+ const toolCalls = content.filter((b: any) => b.type === "toolCall");
332
+ for (const tc of toolCalls) {
333
+ const input = tc.arguments ? JSON.stringify(tc.arguments).slice(0, 100) : "";
334
+ process.stdout.write(chalk.cyan(t(" [工具] ", " [tool] ") + (tc.name || "")) + (input ? ` ${input}` : "") + "\n");
335
+ }
336
+ } else if (msg.role === "toolResult") {
337
+ const status = msg.isError ? chalk.red(t("错误", "error")) : chalk.green(t("完成", "done"));
338
+ const toolName = msg.toolName || "";
339
+ process.stdout.write(chalk.cyan(t(" [工具] ", " [tool] ") + `${toolName} ${status}\n`));
340
+ }
341
+ }
342
+ }
343
+
344
+ function extractText(content: any): string {
345
+ if (!Array.isArray(content)) return "";
346
+ return content
347
+ .filter((b: any) => b.type === "text")
348
+ .map((b: any) => b.text || "")
349
+ .join("");
350
+ }