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
@@ -0,0 +1,641 @@
1
+ import chalk from "chalk";
2
+ import { execSync } from "child_process";
3
+ import { writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import type { AgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
6
+ import { config } from "./config.ts";
7
+ import * as memory from "./memory.ts";
8
+ import * as voice from "./voice.ts";
9
+ import * as mcp from "./mcp.ts";
10
+ import * as skills from "./skills.ts";
11
+ import { setShowThinking, getShowThinking, setShowToolDetails, getShowToolDetails } from "./renderer.ts";
12
+ import { resolveSlashCommand } from "./slash_registry.ts";
13
+ import { MAIN_AGENT_IDS, SUB_AGENT_IDS, getMainAgent, getSubAgents, getAgentLabel, setMainAgent, setSubAgents, type MainAgentId, type SubAgentId, type AgentId } from "./agent_registry.ts";
14
+ import { pet } from "./pet.ts";
15
+ import type { UndoManager } from "./undo.ts";
16
+ import { t } from "./locale.ts";
17
+
18
+ export interface CommandContext {
19
+ session: AgentSession;
20
+ loader?: DefaultResourceLoader;
21
+ exit: () => void;
22
+ newSession: () => Promise<void>;
23
+ resumeSession: (path: string) => void;
24
+ // 保存当前会话(resume 会话覆盖原文件;新会话仅当有有效对话时才另存)。
25
+ // 与 Repl 退出时的保存语义一致;/change-agent 在重建会话前调用,避免对话丢失。
26
+ saveCurrentSession: () => void;
27
+ // 走完整 Agent 回合生命周期(undo/isProcessing/pet.reset/abort),但不展开 @文件 / !命令。
28
+ // /skill 调用技能时使用,避免技能内容里的 @ / ! 被当作输入展开。
29
+ runAgentTurn: (text: string) => Promise<void>;
30
+ // 暂停/恢复斜杠菜单的 keypress 监听器。交互式命令(如 /resume)接管
31
+ // 输入期间需暂停,否则上下键会被菜单监听器捕获并画菜单。
32
+ pauseMenuListener?: () => void;
33
+ resumeMenuListener?: () => void;
34
+ // /stt 切换时启停全局热键监听(pynput)
35
+ setSttHookEnabled?: (enabled: boolean) => void;
36
+ // /undo /redo 撤销/重做管理器(本地快照,不依赖 git)
37
+ undoManager?: UndoManager;
38
+ // /change-agent 切换角色后重启 TTS 进程(音色随主 Agent,spawn 时固化,需重拉)
39
+ restartTts?: () => void;
40
+ }
41
+
42
+ const HELP_TEXT = t(
43
+ `
44
+ ${chalk.bold.cyan("ARONA Agent - 命令列表")}
45
+
46
+ ${chalk.bold("会话")}
47
+ /new, /clear 开始新会话(清空上下文)
48
+ /resume 上下键选择并恢复一个已保存的会话
49
+ /exit 退出
50
+ /export 导出当前会话为 Markdown
51
+
52
+ ${chalk.bold("显示")}
53
+ /thinking 开关思考/推理块显示
54
+ /details 开关工具执行详情显示
55
+ /compact 压缩当前上下文以节省 token
56
+
57
+ ${chalk.bold("语音")}
58
+ /tts 开关文字转语音
59
+ /stt 开关语音转文字
60
+
61
+ ${chalk.bold("扩展")}
62
+ /skill 列出或调用技能(/skill <名称>)
63
+ /mcp 列出 MCP 服务器和工具
64
+
65
+ ${chalk.bold("其他")}
66
+ /change-agent 切换主 Agent + 多选子 Agent
67
+ /undo 撤销上一个回合的全部文件改动
68
+ /redo 重做已撤销的改动
69
+ /help 显示本帮助
70
+
71
+ ${chalk.cyan("提示:")}
72
+ ${chalk.cyan("• @文件名 把文件内容插入到消息中")}
73
+ ${chalk.cyan("• !命令 执行 shell 命令并把输出加入消息")}
74
+ `,
75
+ `
76
+ ${chalk.bold.cyan("ARONA Agent - Commands")}
77
+
78
+ ${chalk.bold("Session")}
79
+ /new, /clear Start a new session (clear context)
80
+ /resume Pick and resume a saved session with arrow keys
81
+ /exit Exit
82
+ /export Export current session as Markdown
83
+
84
+ ${chalk.bold("Display")}
85
+ /thinking Toggle thinking/reasoning block display
86
+ /details Toggle tool execution detail display
87
+ /compact Compact current context to save tokens
88
+
89
+ ${chalk.bold("Voice")}
90
+ /tts Toggle text-to-speech
91
+ /stt Toggle speech-to-text
92
+
93
+ ${chalk.bold("Extensions")}
94
+ /skill List or invoke a skill (/skill <name>)
95
+ /mcp List MCP servers and tools
96
+
97
+ ${chalk.bold("Other")}
98
+ /change-agent Switch main agent + multi-select sub agents
99
+ /undo Undo the previous turn's file changes
100
+ /redo Redo undone changes
101
+ /help Show this help
102
+
103
+ ${chalk.cyan("Tips:")}
104
+ ${chalk.cyan("• @filename inserts the file content into your message")}
105
+ ${chalk.cyan("• !command runs a shell command and appends its output")}
106
+ `,
107
+ );
108
+
109
+ export async function handleCommand(input: string, ctx: CommandContext): Promise<boolean> {
110
+ // Bare "/" → show menu (just like typing "/help")
111
+ const stripped = input.startsWith("/") ? input.slice(1) : input;
112
+ if (!stripped) {
113
+ console.log(HELP_TEXT);
114
+ return true;
115
+ }
116
+
117
+ const parts = stripped.split(/\s+/);
118
+ const typedName = parts[0];
119
+ const args = parts.slice(1).join(" ");
120
+
121
+ const spec = resolveSlashCommand(typedName);
122
+ if (!spec) {
123
+ console.log(chalk.red(t(`未知命令:/${typedName}。输入 /help 查看可用命令。`, `Unknown command: /${typedName}. Type /help to see available commands.`)));
124
+ return true;
125
+ }
126
+
127
+ // Dispatch by spec.name (the canonical name) so aliases route to the same handler
128
+ switch (spec.name) {
129
+ case "help":
130
+ console.log(HELP_TEXT);
131
+ return true;
132
+
133
+ case "exit":
134
+ ctx.exit();
135
+ return true;
136
+
137
+ case "new":
138
+ await ctx.newSession();
139
+ console.log(chalk.cyan(t("已开始新会话。", "Started a new session.")));
140
+ return true;
141
+
142
+ case "compact":
143
+ // 反馈(开始/完成/取消/失败)统一由 renderer 的 compaction_start/compaction_end 事件输出;
144
+ // SDK 在失败或取消时会先 emit compaction_end 再 throw,故此处静默吞掉即可。
145
+ try {
146
+ // 透传可选自定义指令(如 /compact 保留架构决策);空串视为 undefined
147
+ await ctx.session.compact(args.trim() || undefined);
148
+ } catch {
149
+ // 静默:错误信息已由 compaction_end 事件输出
150
+ }
151
+ return true;
152
+
153
+ case "thinking":
154
+ setShowThinking(!getShowThinking());
155
+ console.log(chalk.cyan(t(`思考块显示:${getShowThinking() ? "开" : "关"}`, `Thinking display: ${getShowThinking() ? "on" : "off"}`)));
156
+ return true;
157
+
158
+ case "details":
159
+ setShowToolDetails(!getShowToolDetails());
160
+ console.log(chalk.cyan(t(`工具详情显示:${getShowToolDetails() ? "开" : "关"}`, `Tool details display: ${getShowToolDetails() ? "on" : "off"}`)));
161
+ return true;
162
+
163
+ case "tts":
164
+ if (config.noVoice) {
165
+ console.log(chalk.cyan(t("语音功能已禁用(--no-voice)。", "Voice features disabled (--no-voice).")));
166
+ return true;
167
+ }
168
+ if (!voice.hasCurrentVoice()) {
169
+ console.log(chalk.yellow(t(
170
+ "当前角色未克隆音色,TTS 强制静音。运行 arona voice add 补全音色后即可启用。",
171
+ "The current agent has no cloned voice, so TTS is force-muted. Run `arona voice add` to add a voice first.",
172
+ )));
173
+ return true;
174
+ }
175
+ voice.setTtsEnabled(!voice.isTtsEnabled());
176
+ console.log(chalk.cyan(t(`TTS:${voice.isTtsEnabled() ? "开" : "关"}`, `TTS: ${voice.isTtsEnabled() ? "on" : "off"}`)));
177
+ return true;
178
+
179
+ case "stt":
180
+ if (config.noVoice) {
181
+ console.log(chalk.cyan(t("语音功能已禁用(--no-voice)。", "Voice features disabled (--no-voice).")));
182
+ return true;
183
+ }
184
+ voice.setSttEnabled(!voice.isSttEnabled());
185
+ console.log(chalk.cyan(t(`STT:${voice.isSttEnabled() ? "开" : "关"}`, `STT: ${voice.isSttEnabled() ? "on" : "off"}`)));
186
+ if (voice.isSttEnabled()) {
187
+ console.log(chalk.cyan(t("长按右 Cmd ≥2秒录音(任务中按会先中断再录音)。", "Hold right Cmd ≥2s to record (press during a task to interrupt it first).")));
188
+ }
189
+ ctx.setSttHookEnabled?.(voice.isSttEnabled());
190
+ return true;
191
+
192
+ case "resume":
193
+ await handleResume(ctx);
194
+ return true;
195
+
196
+ case "export":
197
+ handleExport(ctx);
198
+ return true;
199
+
200
+ case "undo":
201
+ handleUndo(ctx);
202
+ return true;
203
+
204
+ case "redo":
205
+ handleRedo(ctx);
206
+ return true;
207
+
208
+ case "skill":
209
+ await handleSkill(args, ctx);
210
+ return true;
211
+
212
+ case "mcp":
213
+ await handleMcp(args);
214
+ return true;
215
+
216
+ case "change-agent":
217
+ await handleChangeAgent(ctx);
218
+ return true;
219
+
220
+ default:
221
+ // Shouldn't happen since SLASH_COMMANDS is the source of truth, but be safe.
222
+ console.log(chalk.red(t(`未知命令:/${typedName}。输入 /help 查看可用命令。`, `Unknown command: /${typedName}. Type /help to see available commands.`)));
223
+ return true;
224
+ }
225
+ }
226
+
227
+ /** 计算一个字符串去除 ANSI 转义后的可见宽度(CJK/全角算 2,控制符算 0)。 */
228
+ function visibleWidth(s: string): number {
229
+ const stripped = s.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|[0-9])/g, "");
230
+ let w = 0;
231
+ for (const ch of stripped) {
232
+ const code = ch.codePointAt(0)!;
233
+ if (code >= 0x1100 && (
234
+ code <= 0x115f || (code >= 0x2e80 && code <= 0x303e) ||
235
+ (code >= 0x3040 && code <= 0x33bf) || (code >= 0x3400 && code <= 0x4dbf) ||
236
+ (code >= 0x4e00 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7af) ||
237
+ (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
238
+ (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)
239
+ )) w += 2;
240
+ else if (code >= 0x20) w += 1;
241
+ }
242
+ return w;
243
+ }
244
+
245
+ /** 截断字符串到可见宽度(保留 ANSI 颜色转义,超出加 …)。 */
246
+ function truncateStyled(text: string, maxW: number, style: (s: string) => string): string {
247
+ if (visibleWidth(text) <= maxW) return style(text);
248
+ let res = "";
249
+ let w = 0;
250
+ for (const ch of text) {
251
+ const code = ch.codePointAt(0)!;
252
+ const cw = (code >= 0x1100 && (
253
+ code <= 0x115f || (code >= 0x2e80 && code <= 0x303e) ||
254
+ (code >= 0x3040 && code <= 0x33bf) || (code >= 0x3400 && code <= 0x4dbf) ||
255
+ (code >= 0x4e00 && code <= 0xa4cf) || (code >= 0xac00 && code <= 0xd7af) ||
256
+ (code >= 0xf900 && code <= 0xfaff) || (code >= 0xfe30 && code <= 0xfe6f) ||
257
+ (code >= 0xff01 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6)
258
+ )) ? 2 : 1;
259
+ if (w + cw > maxW - 1) break;
260
+ res += ch;
261
+ w += cw;
262
+ }
263
+ return style(res + "…");
264
+ }
265
+
266
+ /**
267
+ * Interactive session picker. Uses raw-mode stdin to capture up/down arrows.
268
+ * Pressing Enter or Escape selects (Enter = current, Escape = cancel).
269
+ * Sessions are listed newest first; up arrow moves toward newer, down arrow toward older.
270
+ */
271
+ async function handleResume(ctx: CommandContext) {
272
+ const sessions = memory.listSessions();
273
+ if (sessions.length === 0) {
274
+ console.log(chalk.yellow(t("未找到已保存的会话。", "No saved sessions found.")));
275
+ return;
276
+ }
277
+
278
+ // 暂停斜杠菜单的 keypress 监听器,否则上下键会被它捕获并画菜单。
279
+ // (会话为空时已提前 return,无需暂停。)
280
+ ctx.pauseMenuListener?.();
281
+
282
+ // Stop the parent readline so it doesn't capture our keypresses
283
+ if (process.stdin.isTTY) {
284
+ process.stdin.setRawMode(true);
285
+ }
286
+ process.stdin.resume();
287
+ process.stdin.setEncoding("utf8");
288
+
289
+ let cursor = 0; // index of currently highlighted session
290
+ let drawnScreenLines = 0; // 已画出的屏幕行数(按终端宽度折行后),用于精确上移
291
+ const cols = process.stdout.columns ?? 80;
292
+
293
+ const render = () => {
294
+ // 上移已画出的屏幕行数(首次为 0,不移动),避免上移过多吞掉上方内容
295
+ if (drawnScreenLines > 0) {
296
+ process.stdout.write("\x1b[" + drawnScreenLines + "A");
297
+ }
298
+ // \r 回到第 1 列再清到屏幕末尾,确保整行被清(\x1b[0J 从光标列清起,
299
+ // 若光标停在行中间会残留行首,重绘后出现"已已保存"等叠字)
300
+ process.stdout.write("\r\x1b[0J");
301
+ // 每行截断到终端宽度,防止换行导致屏幕行数 > 逻辑行数
302
+ const maxW = cols - 1;
303
+ const out: string[] = [];
304
+ out.push(chalk.bold.cyan(t("已保存的会话(↑/↓ 选择,回车确认,Esc 取消):", "Saved sessions (↑/↓ select, Enter confirm, Esc cancel):")));
305
+ sessions.forEach((s, i) => {
306
+ const date = new Date(s.timestamp).toLocaleString();
307
+ const marker = i === cursor ? "▶ " : " ";
308
+ const text = `${marker}${i + 1}. ${s.preview} (${date} · ${s.model})`;
309
+ const styled = i === cursor
310
+ ? truncateStyled(text, maxW, (t) => chalk.bold.cyan(t))
311
+ : truncateStyled(text, maxW, (t) => t);
312
+ out.push(styled);
313
+ });
314
+ out.push(chalk.cyan(t(" (按回车恢复当前选中项)", " (press Enter to resume the highlighted item)")));
315
+ // 用 \r\n 分隔确保每行回到第 1 列(\n 不复位列,可能导致后续行错位)
316
+ process.stdout.write(out.join("\r\n") + "\r\n");
317
+ // 每行恰好占 1 个屏幕行(已截断),屏幕行数 = 逻辑行数
318
+ drawnScreenLines = out.length;
319
+ };
320
+
321
+ // Initial render
322
+ render();
323
+
324
+ // resolve 由 onData 在用户确认/取消时调用;提到外层以便 cleanup 引用,
325
+ // 避免 const onData 暂时性死区导致 Esc 时 ReferenceError。
326
+ let resolveFn: () => void = () => {};
327
+ const onData = (key: string) => {
328
+ // Arrow keys come as escape sequences: ESC [ A/B
329
+ if (key === "\x1b[A") {
330
+ // Up arrow: move toward newer (lower index)
331
+ cursor = Math.max(0, cursor - 1);
332
+ render();
333
+ } else if (key === "\x1b[B") {
334
+ // Down arrow: move toward older
335
+ cursor = Math.min(sessions.length - 1, cursor + 1);
336
+ render();
337
+ } else if (key === "\r" || key === "\n") {
338
+ // Enter: confirm selection
339
+ cleanup();
340
+ const sel = sessions[cursor];
341
+ ctx.resumeSession(sel.path);
342
+ console.log(chalk.green(t(`已恢复:${sel.preview}`, `Resumed: ${sel.preview}`)));
343
+ resolveFn();
344
+ } else if (key === "\x1b" || key === "\x1b\x1b") {
345
+ // Escape: cancel
346
+ cleanup();
347
+ console.log(chalk.cyan(t("已取消。", "Cancelled.")));
348
+ resolveFn();
349
+ } else if (key === "q" || key === "\x03") {
350
+ // q or Ctrl+C: cancel
351
+ cleanup();
352
+ console.log(chalk.cyan(t("已取消。", "Cancelled.")));
353
+ resolveFn();
354
+ }
355
+ };
356
+
357
+ const cleanup = () => {
358
+ // 仅移除本选择器的 data 监听器,不要 pause stdin 或 setRawMode(false)——
359
+ // 那会触发 readline 的 close 事件 → doExit() 导致程序意外退出。
360
+ // readline 始终在 raw mode 下工作,选择器复用同一模式即可。
361
+ process.stdin.removeListener("data", onData);
362
+ // 恢复斜杠菜单监听器
363
+ ctx.resumeMenuListener?.();
364
+ };
365
+
366
+ process.stdin.on("data", onData);
367
+ return new Promise<void>((resolve) => { resolveFn = resolve; });
368
+ }
369
+
370
+ function handleExport(ctx: CommandContext) {
371
+ const messages = ctx.session.messages;
372
+ const exportTitle = t("ARONA 会话导出", "ARONA Session Export");
373
+ const userLabel = t("用户", "User");
374
+ const aronaLabel = t("ARONA", "ARONA");
375
+ const toolLabel = t("工具", "tool");
376
+ const resultLabel = t("结果", "result");
377
+ let markdown = `# ${exportTitle}\n\n_${new Date().toISOString()}_\n\n---\n\n`;
378
+
379
+ for (const msg of messages) {
380
+ const role = msg.role === "user" ? `## ${userLabel}` : msg.role === "assistant" ? `## ${aronaLabel}` : `## ${msg.role}`;
381
+ markdown += `${role}\n\n`;
382
+
383
+ if ("content" in msg) {
384
+ if (typeof msg.content === "string") {
385
+ markdown += `${msg.content}\n\n`;
386
+ } else {
387
+ for (const part of msg.content) {
388
+ if (part.type === "text") markdown += `${part.text}\n\n`;
389
+ else if (part.type === "thinking") markdown += `> ${part.thinking}\n\n`;
390
+ else if (part.type === "toolCall") markdown += `[${toolLabel}:${part.name}]\n\n`;
391
+ else if (part.type === "image") markdown += `[${t("图片", "image")}:${part.mimeType}]\n\n`;
392
+ }
393
+ if (msg.role === "toolResult") {
394
+ markdown += `[${resultLabel}:${msg.toolName}]\n\n`;
395
+ }
396
+ }
397
+ }
398
+ markdown += "---\n\n";
399
+ }
400
+
401
+ const exportPath = join(process.cwd(), `arona-export-${Date.now()}.md`);
402
+ writeFileSync(exportPath, markdown);
403
+ console.log(chalk.green(t(`已导出到 ${exportPath}`, `Exported to ${exportPath}`)));
404
+
405
+ try {
406
+ execSync(`open "${exportPath}"`, { stdio: "ignore" });
407
+ } catch {
408
+ // Not macOS or open not available
409
+ }
410
+ }
411
+
412
+ function handleUndo(ctx: CommandContext) {
413
+ if (!ctx.undoManager) {
414
+ console.log(chalk.yellow(t("撤销系统未初始化。", "Undo system is not initialized.")));
415
+ return;
416
+ }
417
+ const r = ctx.undoManager.undo();
418
+ if (r.ok) console.log(chalk.green(r.message));
419
+ else console.log(chalk.yellow(r.message));
420
+ }
421
+
422
+ function handleRedo(ctx: CommandContext) {
423
+ if (!ctx.undoManager) {
424
+ console.log(chalk.yellow(t("撤销系统未初始化。", "Undo system is not initialized.")));
425
+ return;
426
+ }
427
+ const r = ctx.undoManager.redo();
428
+ if (r.ok) console.log(chalk.green(r.message));
429
+ else console.log(chalk.yellow(r.message));
430
+ }
431
+
432
+ async function handleSkill(args: string, ctx: CommandContext) {
433
+ if (!args) {
434
+ const allSkills = skills.listSkills(ctx.loader);
435
+ if (allSkills.length === 0) {
436
+ console.log(chalk.yellow(t("未找到技能。请在 ~/.arona/skills/<名称>/SKILL.md 中放置。", "No skills found. Place them in ~/.arona/skills/<name>/SKILL.md.")));
437
+ return;
438
+ }
439
+ console.log(chalk.bold.cyan(t("\n可用技能:", "\nAvailable skills:")));
440
+ allSkills.forEach((s, i) => {
441
+ console.log(` ${i + 1}. ${chalk.bold(s.name)} ${chalk.cyan(s.description)}`);
442
+ });
443
+ console.log(chalk.cyan(t("\n使用 /skill <名称> 调用技能。", "\nUse /skill <name> to invoke a skill.")));
444
+ return;
445
+ }
446
+
447
+ const skillName = args.trim();
448
+ const content = skills.getSkillContent(skillName, ctx.loader);
449
+ if (!content) {
450
+ console.log(chalk.red(t(`未找到技能 "${skillName}"。`, `Skill "${skillName}" not found.`)));
451
+ return;
452
+ }
453
+
454
+ // 走完整 Agent 回合生命周期(undo/isProcessing/pet.reset/abort),
455
+ // 但不展开 @文件 / !命令,避免技能 markdown 中的 @ / ! 被误解析。
456
+ console.log(chalk.green(t(`已调用技能:${skillName}`, `Invoked skill: ${skillName}`)));
457
+ await ctx.runAgentTurn(t(`[技能:${skillName}]`, `[skill: ${skillName}]`) + `\n\n${content}`);
458
+ }
459
+
460
+ /**
461
+ * /change-agent:切换主 Agent(单选)+ 子 Agent(多选)。
462
+ * 交互式 TUI:主 Agent 用 [*] 单选,子 Agent 用 [*] 多选;
463
+ * 确认后写 settings.json → 桌宠按新组合重建多窗口 → 主 Agent 变了才重启 TTS + 保存会话 + 重建会话。
464
+ */
465
+ async function handleChangeAgent(ctx: CommandContext) {
466
+ const current = getMainAgent();
467
+ const currentSubs = getSubAgents();
468
+
469
+ type Row = { kind: "main" | "sub"; id: string };
470
+ const mainRows: Row[] = MAIN_AGENT_IDS.map((id) => ({ kind: "main", id }));
471
+ const subRows: Row[] = SUB_AGENT_IDS.map((id) => ({ kind: "sub", id }));
472
+ const rows = [...mainRows, ...subRows];
473
+
474
+ let selectedMain: string = current;
475
+ const selectedSubs = new Set<string>(currentSubs);
476
+ let cursor = Math.max(0, rows.findIndex((r) => r.id === current));
477
+ let drawnScreenLines = 0;
478
+ const cols = process.stdout.columns ?? 80;
479
+
480
+ // 暂停斜杠菜单 keypress 监听器,避免上下键被菜单捕获并画菜单
481
+ ctx.pauseMenuListener?.();
482
+
483
+ if (process.stdin.isTTY) {
484
+ process.stdin.setRawMode(true);
485
+ }
486
+ process.stdin.resume();
487
+ process.stdin.setEncoding("utf8");
488
+
489
+ const render = () => {
490
+ if (drawnScreenLines > 0) {
491
+ process.stdout.write("\x1b[" + drawnScreenLines + "A");
492
+ }
493
+ process.stdout.write("\r\x1b[0J");
494
+ const maxW = cols - 1;
495
+ const out: string[] = [];
496
+ out.push(truncateStyled(
497
+ t("选择角色(↑/↓ 移动,空格选择,回车确认,Esc 取消):", "Select characters (↑/↓ move, Space select, Enter confirm, Esc cancel):"),
498
+ maxW,
499
+ (x) => chalk.bold.cyan(x),
500
+ ));
501
+ out.push(truncateStyled(t(" 主 Agent(单选)", " Main agent (single)"), maxW, (x) => chalk.cyan(x)));
502
+ mainRows.forEach((row, i) => {
503
+ const marker = cursor === i ? "▶ " : " ";
504
+ const checked = row.id === selectedMain ? "[*]" : "[ ]";
505
+ const suffix = row.id === current ? t("(当前)", " (current)") : "";
506
+ // 只显示双语显示名(中文“阿洛娜”/英文“Arona”),不显示英文 id
507
+ const text = `${marker}${checked} ${getAgentLabel(row.id as MainAgentId)}${suffix}`;
508
+ out.push(truncateStyled(text, maxW, (x) => cursor === i ? chalk.bold.cyan(x) : x));
509
+ });
510
+ out.push(truncateStyled(t(" 子 Agent(多选)", " Sub agents (multi-select)"), maxW, (x) => chalk.cyan(x)));
511
+ subRows.forEach((row, i) => {
512
+ const idx = mainRows.length + i;
513
+ const marker = cursor === idx ? "▶ " : " ";
514
+ const checked = selectedSubs.has(row.id) ? "[*]" : "[ ]";
515
+ const text = `${marker}${checked} ${getAgentLabel(row.id as SubAgentId)}`;
516
+ out.push(truncateStyled(text, maxW, (x) => cursor === idx ? chalk.bold.cyan(x) : x));
517
+ });
518
+ out.push(truncateStyled(t(" 空格:选中/取消子 Agent;主 Agent 空格直接选定", " Space: toggle sub-agent; Space on main agent selects it"), maxW, (x) => chalk.cyan(x)));
519
+ process.stdout.write(out.join("\r\n") + "\r\n");
520
+ drawnScreenLines = out.length;
521
+ };
522
+
523
+ render();
524
+
525
+ // 真正执行切换的异步流程(确认后调用,完成后才 resolve 外层 Promise)
526
+ const applySelection = async () => {
527
+ const main = selectedMain as MainAgentId;
528
+ const subs = SUB_AGENT_IDS.filter((id) => selectedSubs.has(id));
529
+ const mainChanged = main !== current;
530
+ const subsChanged = JSON.stringify(subs) !== JSON.stringify(currentSubs);
531
+ if (!mainChanged && !subsChanged) {
532
+ console.log(chalk.cyan(t("角色选择未变化。", "No character selection change.")));
533
+ return;
534
+ }
535
+ // 1. 持久化(settings.json mainAgent + subAgents 字段)
536
+ setMainAgent(main);
537
+ setSubAgents(subs);
538
+ // 2. 桌宠多窗口按新组合重建(旧进程退出后自动拉起)
539
+ pet.restartWithSelection(main, subs);
540
+ // 3. 主 Agent 切换才需要重启 TTS(音色随主 Agent 变化)与重建会话(人格变化)
541
+ if (mainChanged) {
542
+ ctx.restartTts?.();
543
+ // 保存当前会话(resume 会话覆盖原文件;新会话仅当有有效对话)——重建会话会丢弃旧 session,先落盘防丢失
544
+ ctx.saveCurrentSession();
545
+ await ctx.newSession();
546
+ console.log(chalk.green(t(
547
+ `主 Agent 已切换为 ${getAgentLabel(main)},子 Agent:${subs.length ? subs.map((s) => getAgentLabel(s)).join("、") : t("无", "none")}。`,
548
+ `Main agent switched to ${getAgentLabel(main)}; sub agents: ${subs.length ? subs.map((s) => getAgentLabel(s)).join(", ") : "none"}.`,
549
+ )));
550
+ } else {
551
+ console.log(chalk.green(t(
552
+ `子 Agent 已更新:${subs.length ? subs.map((s) => getAgentLabel(s)).join("、") : t("无", "none")}。`,
553
+ `Sub agents updated: ${subs.length ? subs.map((s) => getAgentLabel(s)).join(", ") : "none"}.`,
554
+ )));
555
+ }
556
+ };
557
+
558
+ let resolveFn: () => void = () => {};
559
+ const onData = (key: string) => {
560
+ if (key === "\x1b[A") {
561
+ cursor = (cursor - 1 + rows.length) % rows.length;
562
+ render();
563
+ } else if (key === "\x1b[B") {
564
+ cursor = (cursor + 1) % rows.length;
565
+ render();
566
+ } else if (key === " ") {
567
+ const row = rows[cursor];
568
+ if (row.kind === "main") {
569
+ selectedMain = row.id;
570
+ } else {
571
+ if (selectedSubs.has(row.id)) selectedSubs.delete(row.id);
572
+ else selectedSubs.add(row.id);
573
+ }
574
+ render();
575
+ } else if (key === "\r" || key === "\n") {
576
+ cleanup();
577
+ void applySelection()
578
+ .catch((err) => {
579
+ console.error(chalk.red(t("切换失败:", "Switch failed: ") + (err instanceof Error ? err.message : err)));
580
+ })
581
+ .finally(() => resolveFn());
582
+ } else if (key === "\x1b" || key === "\x1b\x1b" || key === "q" || key === "\x03") {
583
+ // Esc / q / Ctrl+C: cancel
584
+ cleanup();
585
+ console.log(chalk.cyan(t("已取消。", "Cancelled.")));
586
+ resolveFn();
587
+ }
588
+ };
589
+
590
+ const cleanup = () => {
591
+ // 仅移除本选择器的 data 监听器,不要 pause stdin 或 setRawMode(false)——
592
+ // 那会触发 readline 的 close 事件 → doExit() 导致程序意外退出。
593
+ process.stdin.removeListener("data", onData);
594
+ // 恢复斜杠菜单监听器
595
+ ctx.resumeMenuListener?.();
596
+ };
597
+
598
+ process.stdin.on("data", onData);
599
+ return new Promise<void>((resolve) => { resolveFn = resolve; });
600
+ }
601
+
602
+ async function handleMcp(args: string) {
603
+ const servers = mcp.listMcpServers();
604
+ if (servers.length === 0) {
605
+ console.log(chalk.yellow(t("未连接任何 MCP 服务器。在 settings.json 中配置(mcpServers)。", "No MCP servers connected. Configure them in settings.json (mcpServers).")));
606
+ return;
607
+ }
608
+
609
+ if (!args || args === "list") {
610
+ console.log(chalk.bold.cyan(t("\nMCP 服务器:", "\nMCP servers:")));
611
+ for (const s of servers) {
612
+ console.log(t(` ${chalk.bold(s.name)}:${s.tools.join("、") || "(无工具)"}`, ` ${chalk.bold(s.name)}: ${s.tools.join(", ") || "(no tools)"}`));
613
+ }
614
+ console.log(chalk.cyan(t("\n使用 /mcp <服务器> <工具> <json参数> 调用工具。", "\nUse /mcp <server> <tool> <json args> to invoke a tool.")));
615
+ return;
616
+ }
617
+
618
+ const parts = args.split(/\s+/);
619
+ const serverName = parts[0];
620
+ const toolName = parts[1];
621
+ const jsonArgs = parts.slice(2).join(" ");
622
+
623
+ if (!toolName) {
624
+ const server = servers.find((s) => s.name === serverName);
625
+ if (server) {
626
+ console.log(chalk.bold(t(`\n${serverName} 的工具:`, `\nTools of ${serverName}:`)));
627
+ server.tools.forEach((t) => console.log(` - ${t}`));
628
+ } else {
629
+ console.log(chalk.red(t(`未找到服务器 "${serverName}"。`, `Server "${serverName}" not found.`)));
630
+ }
631
+ return;
632
+ }
633
+
634
+ try {
635
+ const parsedArgs = jsonArgs ? JSON.parse(jsonArgs) : {};
636
+ const result = await mcp.callMcpTool(serverName, toolName, parsedArgs);
637
+ console.log(result);
638
+ } catch (err) {
639
+ console.error(chalk.red(t(`MCP 调用失败:${err instanceof Error ? err.message : err}`, `MCP call failed: ${err instanceof Error ? err.message : err}`)));
640
+ }
641
+ }