arona-agent 1.0.2 → 1.0.4

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 (51) hide show
  1. package/README.md +16 -9
  2. package/assets/blue-archive/arona/voice_sovits.mp3 +0 -0
  3. package/assets/blue-archive/arona/voice_text.txt +1 -0
  4. package/assets/blue-archive/hoshino/voice_sovits.mp3 +0 -0
  5. package/assets/blue-archive/hoshino/voice_text.txt +1 -0
  6. package/assets/blue-archive/plana/voice_sovits.mp3 +0 -0
  7. package/assets/blue-archive/plana/voice_text.txt +1 -0
  8. package/assets/blue-archive/shiroko/voice_sovits.mp3 +0 -0
  9. package/assets/blue-archive/shiroko/voice_text.txt +1 -0
  10. package/bin/arona.mjs +3 -1
  11. package/bin/postinstall-fix.mjs +46 -0
  12. package/package.json +3 -2
  13. package/pet/renderer/gallery.js +2 -2
  14. package/pet/renderer/renderer.js +1 -1
  15. package/pet/renderer/style.css +16 -2
  16. package/pet/tools/gen_sway.cjs +1 -1
  17. package/pet/tools/visual_test.cjs +2 -2
  18. package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
  19. package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
  20. package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
  21. package/python/__pycache__/oss_upload.cpython-314.pyc +0 -0
  22. package/python/__pycache__/stt.cpython-314.pyc +0 -0
  23. package/python/__pycache__/tts_say.cpython-314.pyc +0 -0
  24. package/python/hotkey.py +7 -1
  25. package/python/oss_upload.py +70 -0
  26. package/python/tts_say.py +182 -19
  27. package/src/agent.ts +155 -147
  28. package/src/commands.ts +17 -23
  29. package/src/config.ts +45 -2
  30. package/src/gesture_context.ts +48 -0
  31. package/src/gpt_sovits_local.ts +706 -0
  32. package/src/index.ts +13 -10
  33. package/src/memory.ts +3 -10
  34. package/src/oss_upload.ts +116 -0
  35. package/src/renderer.ts +17 -6
  36. package/src/repl.ts +70 -59
  37. package/src/setup.ts +349 -15
  38. package/src/skills.ts +32 -2
  39. package/src/slash_menu.ts +1 -1
  40. package/src/slash_registry.ts +6 -5
  41. package/src/tools/read_docs_tool.ts +84 -0
  42. package/src/tools/tavily_tools.ts +24 -10
  43. package/src/tools/voice_tools.ts +1 -1
  44. package/src/tts_provider.ts +501 -0
  45. package/src/tts_stream.ts +321 -31
  46. package/src/tui_select.ts +31 -2
  47. package/src/undo.ts +82 -18
  48. package/src/utils/python.ts +22 -0
  49. package/src/voice.ts +17 -7
  50. package/src/voice_cli.ts +117 -9
  51. package/src/voices.ts +197 -28
package/src/index.ts CHANGED
@@ -3,36 +3,39 @@ import { Repl } from "./repl.ts";
3
3
  import { resetConversationFlag, loadSession } from "./memory.ts";
4
4
  import { startPet } from "./pet.ts";
5
5
  import { config, settingsExist } from "./config.ts";
6
+ import { preloadGptSovitsLocal } from "./tts_provider.ts";
7
+ import { syncSkillsFromAgentsDir } from "./skills.ts";
6
8
  import { t } from "./locale.ts";
7
9
  import chalk from "chalk";
8
10
 
9
11
  async function main() {
10
- // Require settings.json — if missing, tell user to run arona setup
11
12
  if (!settingsExist()) {
12
- console.log(chalk.yellow(t("未找到配置文件。", "Config file not found.")));
13
- console.log(chalk.cyan(t("请先运行 arona setup 进行初始化配置。", "Please run arona setup to initialize first.")));
13
+ console.log(chalk.yellow(t("未找到配置文件,请运行 arona setup 初始化。", "Config file not found. Run `arona setup` to initialize.")));
14
14
  process.exit(1);
15
15
  }
16
16
 
17
- // Check for required environment
18
17
  if (!config.apiKey) {
19
- console.log(chalk.yellow(t("警告:未找到 LLM API Key。", "Warning: LLM API Key not found.")));
20
- console.log(chalk.cyan(t("请运行 arona setup 进行配置。", "Please run arona setup to configure.")));
21
- console.log();
18
+ console.log(chalk.yellow(t("警告:未找到 LLM API Key,请运行 arona setup 配置。", "Warning: LLM API Key not found. Run `arona setup` to configure.")));
22
19
  }
23
20
 
24
21
  if (config.noVoice) {
25
22
  console.log(chalk.cyan(t("语音功能已禁用(--no-voice)。", "Voice features disabled (--no-voice).")));
26
23
  }
27
24
 
28
- // Initialize agent
29
- console.log(chalk.cyan(t("正在初始化 ARONA...", "Initializing ARONA...")));
25
+ // 补全缺失 Skill(仅补缺,不覆盖用户定制)
26
+ const synced = syncSkillsFromAgentsDir();
27
+ if (synced > 0) {
28
+ console.log(chalk.cyan(t(`已同步 ${synced} 个 Skill`, `Synced ${synced} skill(s)`)));
29
+ }
30
30
 
31
31
  let { session, modelRuntime, loader } = await initAgent();
32
32
 
33
- // 启动桌宠(首次需下载 Electron 二进制,须等下载完成再进入 REPL,避免卡在后台静默下载)
33
+ // 首次启动需下载 Electron,须等下载完成再进入 REPL
34
34
  await startPet();
35
35
 
36
+ // 后台预热本地 GPT-SoVITS,避免首句合成冷启动等待(fire-and-forget)
37
+ preloadGptSovitsLocal();
38
+
36
39
  // Handle session resume from command line arg
37
40
  // 只加载消息到 session,渲染交给 Repl.start() 处理(确保 logo 在历史记录之前)
38
41
  // initialSessionPath 传给 Repl,退出时覆盖保存回原文件而非另存为新文件
package/src/memory.ts CHANGED
@@ -171,12 +171,7 @@ interface SessionHeader {
171
171
  preview: string;
172
172
  }
173
173
 
174
- /**
175
- * 从首条 user 消息提取会话预览。
176
- * 剥离 repl.ts 注入的桌宠手势场景块(全角括号 `(…)` 包裹、后随空行),
177
- * 避免"摸头/dizzy 提示词"污染会话命名(用户首条消息常是手势触发)。
178
- * 仅影响预览命名,不改动存储内容。
179
- */
174
+ /** 从首条 user 消息提取会话预览。 */
180
175
  function firstUserPreview(messages: any[]): string {
181
176
  const firstUserMsg = messages.find((m) => m.role === "user");
182
177
  if (!firstUserMsg) return "(empty)";
@@ -189,10 +184,8 @@ function firstUserPreview(messages: any[]): string {
189
184
  .map((c: any) => c.text)
190
185
  .join(" ")
191
186
  : "";
192
- // 剥离开头的注入块:`(…)\n\n<用户真实输入>`
193
- const cleaned = content.replace(/^([^)]*)[ \t]*\n*/u, "");
194
- let preview = cleaned.slice(0, 50).replace(/\n/g, " ");
195
- if (cleaned.length > 50) preview += "...";
187
+ let preview = content.slice(0, 50).replace(/\n/g, " ");
188
+ if (content.length > 50) preview += "...";
196
189
  return preview;
197
190
  }
198
191
 
@@ -0,0 +1,116 @@
1
+ // 本地文件 → 阿里云 DashScope 托管 OSS 的工具:上传换取可长期引用的 URL,供云端 GPT-SoVITS
2
+ // 作为 ref_audio_path 引用(复用 python/oss_upload.py,与 voice_clone.py 同一 DashScope 通道)。
3
+ //
4
+ // - 同一本地路径会话内只上传一次(URL 长期有效),缓存于 urlCache;并发上传共享同一 in-flight Promise。
5
+ // - ensureOssUrlAlive 用 Range GET 探测 URL 有效性,失效则 invalidate 触发下次重传
6
+ // ("发现无效→重新走上传流程")。
7
+ // - promptText 若为 URL 用 fetchTextFromUrl 抓内容;joinPromptLines 把多行文字合并为一行,
8
+ // 行尾缺句末标点时按中英文智能补「。」或「.」。
9
+ import { verbose } from "./config.ts";
10
+ import { t } from "./locale.ts";
11
+ import { runPython } from "./utils/python.ts";
12
+
13
+ const urlCache = new Map<string, string>(); // 本地源路径 → OSS URL(会话内复用)
14
+ const inflight = new Map<string, Promise<string>>(); // 在途上传(并发去重)
15
+ const verifiedSet = new Set<string>(); // 已校验且仍有效的源路径(避免每 drain 重复探测)
16
+
17
+ /** 是否 http(s) URL(用于区分"本地路径"与"远端 URL"输入)。 */
18
+ export function isHttpUrl(s: string): boolean {
19
+ return /^https?:\/\/\S+$/i.test(s.trim());
20
+ }
21
+
22
+ /** 上传本地文件到 DashScope 托管 OSS,返回长期有效的 URL(同源路径会话内缓存,只上传一次)。 */
23
+ export function uploadToOss(path: string, apiKey: string): Promise<string> {
24
+ const cached = urlCache.get(path);
25
+ if (cached) return Promise.resolve(cached);
26
+ const pending = inflight.get(path);
27
+ if (pending) return pending;
28
+ const task = runPython("oss_upload.py", [], JSON.stringify({ path, apiKey }), {}, 120_000)
29
+ .then((out) => {
30
+ let parsed: { url?: string; error?: string };
31
+ try {
32
+ parsed = JSON.parse(out);
33
+ } catch {
34
+ throw new Error(t(`OSS 上传输出解析失败: ${out}`, `OSS upload parse failed: ${out}`));
35
+ }
36
+ if (!parsed.url) {
37
+ throw new Error(parsed.error || t("OSS 上传未返回 URL", "OSS upload returned no URL"));
38
+ }
39
+ urlCache.set(path, parsed.url);
40
+ if (verbose) console.error(`[oss] uploaded ${path} -> ${parsed.url}`);
41
+ return parsed.url;
42
+ })
43
+ .finally(() => {
44
+ inflight.delete(path);
45
+ });
46
+ inflight.set(path, task);
47
+ return task;
48
+ }
49
+
50
+ /** 作废某源路径的缓存 URL(发现无效时调用,下次上传会重新走上传流程)。 */
51
+ export function invalidateOssUrl(path: string): void {
52
+ urlCache.delete(path);
53
+ verifiedSet.delete(path);
54
+ }
55
+
56
+ /** Range GET 探测 URL 是否仍可访问(OSS 支持 range,200/206/304 均视为有效)。 */
57
+ async function checkUrlAlive(url: string): Promise<boolean> {
58
+ try {
59
+ const res = await fetch(url, {
60
+ method: "GET",
61
+ headers: { Range: "bytes=0-0" },
62
+ redirect: "follow",
63
+ signal: AbortSignal.timeout(6000),
64
+ });
65
+ return res.status === 200 || res.status === 206 || res.status === 304;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 校验 OSS URL 有效性;失效则强制重传并返回新 URL("发现无效→重新走上传流程")。
73
+ * 会话内每个源路径只校验一次(URL 长期有效,无需每 drain 都探测)。
74
+ */
75
+ export async function ensureOssUrlAlive(sourcePath: string, url: string, apiKey: string): Promise<string> {
76
+ if (verifiedSet.has(sourcePath)) return url;
77
+ if (await checkUrlAlive(url)) {
78
+ verifiedSet.add(sourcePath);
79
+ return url;
80
+ }
81
+ if (verbose) console.error(`[oss] URL 失效,重新上传: ${url}`);
82
+ invalidateOssUrl(sourcePath);
83
+ const fresh = await uploadToOss(sourcePath, apiKey);
84
+ verifiedSet.add(sourcePath);
85
+ return fresh;
86
+ }
87
+
88
+ /** 抓取 URL 文本内容(promptText 为 URL 时使用)。 */
89
+ export async function fetchTextFromUrl(url: string): Promise<string> {
90
+ const res = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(10_000) });
91
+ if (!res.ok) {
92
+ throw new Error(t(`拉取文字失败 (HTTP ${res.status})`, `Fetch text failed (HTTP ${res.status})`));
93
+ }
94
+ return await res.text();
95
+ }
96
+
97
+ /**
98
+ * 把多行文字合并为一行:丢弃空行;行尾缺少句末标点(。..!?!?…)时,按该行是否含
99
+ * 中日韩字符智能补「。」或「.」;最后一行不补(官方会自动补全)。单行输入原样返回。
100
+ */
101
+ export function joinPromptLines(text: string): string {
102
+ const lines = text
103
+ .split(/\r?\n/)
104
+ .map((l) => l.trim())
105
+ .filter(Boolean);
106
+ if (lines.length <= 1) return lines[0] || "";
107
+ const CJK = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
108
+ const END = /[。..!?!?…]$/;
109
+ return lines
110
+ .map((line, i) => {
111
+ if (i === lines.length - 1) return line; // 最后一行不补标点
112
+ if (END.test(line)) return line;
113
+ return line + (CJK.test(line) ? "。" : ".");
114
+ })
115
+ .join(" ");
116
+ }
package/src/renderer.ts CHANGED
@@ -161,6 +161,17 @@ export function createRenderer(
161
161
  setSpeakerLabel(label: string | undefined) {
162
162
  speakerLabel = label;
163
163
  },
164
+ // 切 session(setActiveAgent)时显式复位回合状态:消除跨会话 curMsgText/lastText 残留,
165
+ // 防止被新 session 的 agent_end 误读上一角色文本。
166
+ resetTurn() {
167
+ curMsgText = "";
168
+ lastText = "";
169
+ thinkingBuffer = "";
170
+ drawnThinkingLines = 0;
171
+ inThinking = false;
172
+ inText = false;
173
+ textPrefixWritten = false;
174
+ },
164
175
  subscribe: (session: any) => {
165
176
  return session.subscribe((event: any) => {
166
177
  switch (event.type) {
@@ -248,19 +259,19 @@ export function createRenderer(
248
259
  if (showToolDetails) {
249
260
  const name = event.toolName;
250
261
  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");
262
+ process.stdout.write(chalk.cyan(`\n → ${name} ${input}`.trimEnd() + "\n"));
252
263
  }
253
264
  break;
254
265
 
255
266
  case "tool_execution_end":
256
267
  if (showToolDetails) {
257
- const status = event.isError ? chalk.red(t("错误", "error")) : chalk.green(t("完成", "done"));
258
- process.stdout.write(t(" [工具] ", " [tool] ") + `${status}\n`);
268
+ const status = event.isError ? chalk.red(t("✗", "✗")) : chalk.green(t("✓", "✓"));
269
+ process.stdout.write(status + "\n");
259
270
  }
260
271
  break;
261
272
 
262
273
  case "compaction_start":
263
- process.stdout.write(chalk.yellow(t("\n[正在压缩上下文...]\n", "\n[Compacting context...]\n")));
274
+ process.stdout.write(chalk.yellow(t("\n[压缩上下文…]\n", "\n[Compacting…]\n")));
264
275
  break;
265
276
 
266
277
  case "compaction_end":
@@ -271,12 +282,12 @@ export function createRenderer(
271
282
  const msg = String(event.errorMessage).replace(/^Compaction failed:\s*/i, "");
272
283
  process.stdout.write(chalk.red(t(`[压缩失败] ${msg}\n\n`, `[Compaction failed] ${msg}\n\n`)));
273
284
  } else {
274
- process.stdout.write(chalk.yellow(t("[压缩完成]\n\n", "[Compaction complete]\n\n")));
285
+ process.stdout.write(chalk.yellow(t("[压缩完成]\n\n", "[Compaction done]\n\n")));
275
286
  }
276
287
  break;
277
288
 
278
289
  case "auto_retry_start":
279
- process.stdout.write(chalk.yellow(t("\n[重试中...]\n", "\n[Retrying...]\n")));
290
+ process.stdout.write(chalk.yellow(t("\n[重试中…]\n", "\n[Retrying…]\n")));
280
291
  break;
281
292
  }
282
293
  });
package/src/repl.ts CHANGED
@@ -4,15 +4,17 @@ import { execSync, type ChildProcessWithoutNullStreams } from "child_process";
4
4
  import { readFileSync, existsSync } from "fs";
5
5
  import { join, resolve } from "path";
6
6
  import type { AgentSession, ModelRuntime, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
7
- import { config } from "./config.ts";
7
+ import { config, sttHotkeyKey, sttHotkeyLabel } from "./config.ts";
8
8
  import { handleCommand, type CommandContext } from "./commands.ts";
9
9
  import { createRenderer, renderSavedMessages } from "./renderer.ts";
10
10
  import * as memory from "./memory.ts";
11
11
  import * as voice from "./voice.ts";
12
+ import { getTtsProvider } from "./tts_provider.ts";
12
13
  import { TtsStream } from "./tts_stream.ts";
14
+ import { stopGptSovitsLocalServer } from "./gpt_sovits_local.ts";
13
15
  import { stopComputerUse } from "./tools/computer_use.ts";
14
16
  import { disconnectAllMcp } from "./mcp.ts";
15
- import { pet, stopPet, type PetGestureType } from "./pet.ts";
17
+ import { pet, stopPet } from "./pet.ts";
16
18
  import { SlashMenu } from "./slash_menu.ts";
17
19
  import { printLogo } from "./logo.ts";
18
20
  import { PYTHON_DIR } from "./config.ts";
@@ -25,20 +27,6 @@ import { getMainAgent, getSubAgents, getAgentLabel, type AgentId, type SubAgentI
25
27
  // STT 长按阈值:按下录音热键持续 ≥ 该毫秒数并在释放时才触发录音;提前松开视为误触
26
28
  const STT_HOLD_MS = 2000;
27
29
 
28
- /** 桌宠手势 → 注入用户消息头部的上下文场景行(双语)。引导 Agent 在回复中自然流露出被摸头/被摇晃的感受。 */
29
- function petGestureScene(type: PetGestureType): string {
30
- if (type === "dizzy") {
31
- return t(
32
- "(Sensei刚才拖着你到处晃来晃去,你感觉有些头晕。请在回复里自然地体现出这份晕眩感。)",
33
- "(Sensei just swung you around, and you feel a bit dizzy. Let that dizziness show naturally in your reply.)",
34
- );
35
- }
36
- return t(
37
- "(Sensei刚才摸了摸你的头。请在回复里自然地体现出被摸头的感受。)",
38
- "(Sensei just petted your head. Let that show naturally in your reply.)",
39
- );
40
- }
41
-
42
30
  export class Repl {
43
31
  private rl: readline.Interface;
44
32
  private session: AgentSession;
@@ -223,6 +211,9 @@ export class Repl {
223
211
  this.activeSession = this.session;
224
212
  this.activeAgentId = getMainAgent();
225
213
  this.currentSessionPath = null; // 新会话清除源文件路径,退出时另存为新文件
214
+ // 复制上下文方案下子会话消息每轮被覆盖,无需清历史;此处回收子 session 对象,
215
+ // 让下轮按新主会话重新初始化(刷新 initSubAgent 期捕获的 memory 等快照)
216
+ this.resetSubSessions();
226
217
  this.rendererUnsub?.();
227
218
  this.renderer.setSpeakerLabel(getAgentLabel(getMainAgent()));
228
219
  this.rendererUnsub = this.renderer.subscribe(this.session);
@@ -252,6 +243,9 @@ export class Repl {
252
243
  },
253
244
  undoManager: this.undoManager,
254
245
  restartTts: () => this.ttsStream.restartVoice(),
246
+ // /change-agent 只改子 Agent 组合时回收旧的子 session 对象(dispose + 清空),
247
+ // 下轮 ensureSubSessions 按新组合重新初始化(刷新 initSubAgent 期捕获的 memory/工具状态)。
248
+ resetSubAgents: () => this.resetSubSessions(),
255
249
  };
256
250
  }
257
251
 
@@ -266,7 +260,7 @@ export class Repl {
266
260
 
267
261
  /**
268
262
  * 启动全局热键监听(Python pynput 独立进程)。
269
- * 检测到右 Cmd 长按 ≥2 秒时,hotkey.py 输出 {"event":"trigger"},触发 STT 录音。
263
+ * 检测到全局录音热键(macOS 右 Cmd / 其他右 Ctrl)长按 ≥2 秒时,hotkey.py 输出 {"event":"trigger"},触发 STT 录音。
270
264
  */
271
265
  private async startHotkeyHook() {
272
266
  if (this.hotkeyProc) return; // 已启动
@@ -278,7 +272,7 @@ export class Repl {
278
272
  env: {
279
273
  ...process.env,
280
274
  PYTHONUTF8: "1",
281
- ARONA_HOTKEY_KEY: "cmd_r",
275
+ ARONA_HOTKEY_KEY: sttHotkeyKey(),
282
276
  ARONA_HOTKEY_HOLD_MS: String(STT_HOLD_MS),
283
277
  },
284
278
  stdio: ["pipe", "pipe", "pipe"],
@@ -323,6 +317,16 @@ export class Repl {
323
317
  }
324
318
  });
325
319
 
320
+ // spawn 失败(pythonPath 不存在 / 脚本缺失)不会触发 close,必须监听 error,
321
+ // 否则 Node 会因未处理的 'error' 事件直接崩溃。
322
+ proc.on("error", (err) => {
323
+ this.hotkeyProc = null;
324
+ this.hotkeyReady = false;
325
+ if (this.hotkeyReadyReject) {
326
+ this.hotkeyReadyReject(new Error(t(`hotkey.py 启动失败:${err.message}`, `hotkey.py failed to start: ${err.message}`)));
327
+ }
328
+ });
329
+
326
330
  // 等待 ready 信号(最多 5 秒;进程提前退出会立即 reject)
327
331
  await new Promise<void>((resolve, reject) => {
328
332
  const cleanup = () => {
@@ -348,14 +352,15 @@ export class Repl {
348
352
  this.hotkeyReadyReject = null;
349
353
  } catch (err) {
350
354
  this.hotkeyProc = null;
355
+ const isMac = process.platform === "darwin";
351
356
  console.warn(chalk.yellow(
352
357
  t(
353
- "STT 热键不可用:全局键盘监听启动失败。\n" +
354
- " 请确认已安装 pynput(pip install pynput),\n" +
355
- " 并在 系统设置 → 隐私与安全性 → 辅助功能 中为Python 授权。",
356
- "STT hotkey unavailable: global keyboard listener failed to start.\n" +
357
- " Make sure pynput is installed (pip install pynput),\n" +
358
- " and grant Accessibility permission to Python in System Settings.",
358
+ isMac
359
+ ? "STT 热键不可用:请安装 pynput(pip install pynput),并在 系统设置 → 隐私与安全性 → 辅助功能 中为Python授权。"
360
+ : "STT 热键不可用:请安装 pynput(pip install pynput),并检查 Python 是否有权限监听全局键盘。",
361
+ isMac
362
+ ? "STT hotkey unavailable: install pynput (pip install pynput) and grant Accessibility permission to Python in System Settings."
363
+ : "STT hotkey unavailable: install pynput (pip install pynput) and check that Python has permission to listen for global keys.",
359
364
  )
360
365
  ));
361
366
  }
@@ -400,7 +405,7 @@ export class Repl {
400
405
  }
401
406
  // 让 readline 先把当前提示符行清掉,再启动录音 UI
402
407
  await new Promise((r) => setImmediate(r));
403
- console.log(chalk.cyan(t("正在聆听... 请说话", "Listening... speak now")));
408
+ console.log(chalk.cyan(t("聆听…请说话", "Listening…")));
404
409
  this.rl.prompt();
405
410
  const text = await voice.listen();
406
411
  if (text) {
@@ -444,6 +449,9 @@ export class Repl {
444
449
  try {
445
450
  const messages = memory.loadSession(path);
446
451
  this.session.agent.state.messages = messages;
452
+ // 复制上下文方案下子会话消息每轮被覆盖,无需清历史;此处回收子 session 对象,
453
+ // 让下轮按恢复的会话重新初始化(刷新 initSubAgent 期捕获的 memory 等快照)
454
+ this.resetSubSessions();
447
455
  memory.resetConversationFlag();
448
456
  this.currentSessionPath = path; // 退出时覆盖保存回此文件
449
457
  console.clear();
@@ -489,28 +497,18 @@ export class Repl {
489
497
  console.clear();
490
498
  }
491
499
  printLogo();
492
- console.log(chalk.cyan(t(` 模型:${config.model}`, ` Model: ${config.model}`)));
500
+ const statusLine: string[] = [];
501
+ statusLine.push(t(`模型:${config.model}`, `Model: ${config.model}`));
493
502
  if (config.noVoice) {
494
- console.log(chalk.cyan(t(" 语音:已禁用(--no-voice)", " Voice: disabled (--no-voice)")));
503
+ statusLine.push(t("语音:关", "Voice: off"));
495
504
  } else {
496
- // 当前主 Agent 无音色时强制显示"未配置声音"(权重大于 settings.ttsEnabled),
497
- // TTS 在 isTtsEnabled() 里已被挡掉,不会启动
498
- if (!voice.hasCurrentVoice()) {
499
- console.log(chalk.cyan(t(" TTS:关(未配置声音)", " TTS: off (no voice configured)")));
500
- } else {
501
- console.log(chalk.cyan(t(` TTS:${voice.isTtsEnabled() ? "开" : "关"}`, ` TTS: ${voice.isTtsEnabled() ? "on" : "off"}`)));
502
- }
503
- console.log(chalk.cyan(t(` STT:${voice.isSttEnabled() ? "开" : "关"}`, ` STT: ${voice.isSttEnabled() ? "on" : "off"}`)));
504
- // STT 开启时打印热键提示:长按右 Cmd ≥2秒录音;任务中按会先 abort 再录音
505
- if (voice.isSttEnabled()) {
506
- console.log(chalk.cyan(t(` 录音:长按右 Cmd ≥${STT_HOLD_MS / 1000}秒(提前松开取消)`, ` Record: hold right Cmd ≥${STT_HOLD_MS / 1000}s (release early to cancel)`)));
507
- // 非 macOS 无 系统设置 → 隐私与安全性 → 辅助功能 这一路径,提示仅对 macOS 显示
508
- if (process.platform === "darwin") {
509
- console.log(chalk.cyan(t(" 提示:若热键无响应,请在 系统设置 → 隐私与安全性 → 辅助功能 中为Python授权", " Tip: if the hotkey does not respond, grant Accessibility permission to Python in System Settings → Privacy & Security")));
510
- }
511
- }
505
+ statusLine.push(voice.hasCurrentVoice()
506
+ ? t(`TTS:${voice.isTtsEnabled() ? `开(${getTtsProvider().label()})` : "关"}`, `TTS: ${voice.isTtsEnabled() ? `on (${getTtsProvider().label()})` : "off"}`)
507
+ : t("TTS:关(未配置声音)", "TTS: off (no voice configured)"));
508
+ statusLine.push(t(`STT:${voice.isSttEnabled() ? "开" : "关"}`, `STT: ${voice.isSttEnabled() ? "on" : "off"}`));
512
509
  }
513
- console.log(chalk.cyan(t(" 输入 / 弹出命令菜单,/help 查看完整命令,/exit 退出。\n", " Type / to open the command menu, /help for all commands, /exit to quit.\n")));
510
+ console.log(chalk.cyan(" " + statusLine.join(" · ")));
511
+ // 仅在有编程语义的操作提示时打印帮助行
514
512
 
515
513
  // --resume= 启动时渲染恢复的历史记录(在 logo 之后,提示符之前)
516
514
  if (this.resumedMessages) {
@@ -623,25 +621,18 @@ export class Repl {
623
621
  this.doExit();
624
622
  });
625
623
 
626
- // STT 开启时启动全局热键监听(pynput,长按右 Cmd ≥2秒触发录音)
624
+ // STT 开启时启动全局热键监听(pynput,长按全局录音热键 ≥2秒触发录音)
627
625
  if (voice.isSttEnabled()) {
628
626
  this.startHotkeyHook();
629
627
  }
630
628
  }
631
629
 
632
630
  private async processInput(input: string) {
633
- // 桌宠手势(摸头/dizzy)注入:若用户摸过头/拖住晃过,把"刚才发生了什么"作为
634
- // 上下文行写在用户消息头部,引导 Agent 在回复中自然流露出确实感受到了。
635
- // takeGesture 消费即清空 → 只注入最近一次、不重复注入;若用户发的是纯命令消息,
636
- // processInput 不会被调用(见 line handler 的 / 分支),手势保留到下一条真实消息。
637
- const gesture = pet.takeGesture();
638
- let effective = input;
639
- if (gesture) {
640
- const scene = petGestureScene(gesture);
641
- if (scene) effective = `${scene}\n\n${input}`;
642
- }
631
+ // 桌宠手势(摸头/dizzy)不再拼进用户消息:落到主 Agent 发送边界注入(gesture_context.ts),
632
+ // 不进 state.messages → 子 Agent 复制主 session 历史时看不到、会话命名/存储零污染。
633
+ // takeGesture 消费即清空由发送边界扩展完成,只注入最近一次。
643
634
  // 展开 @文件 / !命令 后走完整回合生命周期
644
- await this.runRawTurn(this.parseInput(effective));
635
+ await this.runRawTurn(this.parseInput(input));
645
636
  }
646
637
 
647
638
  /** 把 renderer 订阅切到指定角色 session,并记录当前发言者。 */
@@ -649,10 +640,29 @@ export class Repl {
649
640
  this.activeAgentId = agentId;
650
641
  this.activeSession = session;
651
642
  this.renderer.setSpeakerLabel(getAgentLabel(agentId));
643
+ // 显式复位回合状态,杜绝跨 session 残留 curMsgText/lastText 被误读
644
+ this.renderer.resetTurn();
652
645
  this.rendererUnsub?.();
653
646
  this.rendererUnsub = this.renderer.subscribe(session);
654
647
  }
655
648
 
649
+ /**
650
+ * 回收全部子 session 对象(dispose + 清空)。
651
+ * 复制上下文方案下子会话每轮被主 session 消息覆盖,无需借此清历史;此处目的是释放
652
+ * session 对象,并让下轮 ensureSubSessions 按新主会话/新子组合重新初始化,刷新
653
+ * initSubAgent 期捕获的 memoryContent 等快照与工具状态(/new、/resume、/change-agent 时调用)。
654
+ */
655
+ private resetSubSessions(): void {
656
+ for (const subSession of this.subSessions.values()) {
657
+ try {
658
+ subSession.dispose();
659
+ } catch {
660
+ // 回收失败不影响主流程
661
+ }
662
+ }
663
+ this.subSessions.clear();
664
+ }
665
+
656
666
  /** 按 settings.json 当前启用的子 Agent 初始化/补齐子 session。 */
657
667
  private async ensureSubSessions(): Promise<void> {
658
668
  const enabled = getSubAgents();
@@ -800,7 +810,7 @@ export class Repl {
800
810
  this.hidePetBubble();
801
811
  // 回合开始前打 checkpoint:扫一次当前工作目录,作为 before 快照
802
812
  // (回合结束后 afterTurn 会与它做 diff 入 undo 栈)
803
- this.undoManager.beforeTurn();
813
+ await this.undoManager.beforeTurn();
804
814
  // 回合开始:不主动切换情绪,保持默认待机动画;
805
815
  // 由 agent 调用 change_emotion 一步到位地确定本回合情绪,避免先 saying 再切换的跳变
806
816
 
@@ -835,7 +845,7 @@ export class Repl {
835
845
  } finally {
836
846
  // 回合结束后打 after checkpoint,产出 diff 入 undo 栈
837
847
  try {
838
- this.undoManager.afterTurn();
848
+ await this.undoManager.afterTurn();
839
849
  } catch (err) {
840
850
  // undo checkpoint 失败不影响主流程
841
851
  console.warn(chalk.yellow(t(`撤销快照记录失败:${err instanceof Error ? err.message : err}`, `Failed to record undo snapshot: ${err instanceof Error ? err.message : err}`)));
@@ -876,8 +886,9 @@ export class Repl {
876
886
  // 停止全局热键监听(pynput)
877
887
  this.stopHotkeyHook();
878
888
 
879
- // 停止 TTS:终止当前合成进程并清空队列
889
+ // 停止 TTS:终止当前合成进程并清空队列;同时回收本进程自动启动的 GPT-SoVITS api_v2 子进程
880
890
  this.ttsStream.shutdown();
891
+ stopGptSovitsLocalServer();
881
892
 
882
893
  // Cleanup
883
894
  stopComputerUse();