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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
package/src/undo.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地快照式撤销/重做 —— 完全不依赖 git。
|
|
3
|
+
*
|
|
4
|
+
* 工作模型:
|
|
5
|
+
* - 每个 ARONA Agent 回合(processInput 一次完整 prompt)结束时,
|
|
6
|
+
* 扫描工作目录,与上一回合结束时的状态对比,产出 changed files 的 diff
|
|
7
|
+
* - 每个 diff 同时记录 before 与 after 的完整内容,作为 undo/redo 的最小单元
|
|
8
|
+
* - undo():反向应用栈顶 diff → pointer--
|
|
9
|
+
* - redo():正向应用 pointer+1 处的 diff → pointer++
|
|
10
|
+
* - 跨进程持久化:状态写到 ~/.arona/undo/<cwd-hash>/{state.json,snapshots/}
|
|
11
|
+
*
|
|
12
|
+
* 排除规则(硬编码):node_modules、.git、.venv、__pycache__、.DS_Store、
|
|
13
|
+
* .trae、.workbuddy、dist、build、.cache。
|
|
14
|
+
* 大小上限:单文件 > 5MB 不存内容(只记 hash 用于检测删除,无法 undo 二进制改动)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import { join, relative, resolve } from "path";
|
|
19
|
+
import {
|
|
20
|
+
closeSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
openSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
readSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
statSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "fs";
|
|
31
|
+
import { createHash } from "crypto";
|
|
32
|
+
import { ARONA_DIR } from "./config.ts";
|
|
33
|
+
import { t } from "./locale.ts";
|
|
34
|
+
|
|
35
|
+
const UNDO_ROOT = join(ARONA_DIR, "undo");
|
|
36
|
+
const MAX_FILE_BYTES = 5 * 1024 * 1024; // 5MB
|
|
37
|
+
|
|
38
|
+
const DEFAULT_IGNORE_DIRS = new Set([
|
|
39
|
+
"node_modules",
|
|
40
|
+
".git",
|
|
41
|
+
".venv",
|
|
42
|
+
"__pycache__",
|
|
43
|
+
".trae",
|
|
44
|
+
".workbuddy",
|
|
45
|
+
"dist",
|
|
46
|
+
"build",
|
|
47
|
+
".cache",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const DEFAULT_IGNORE_FILES = new Set([
|
|
51
|
+
".DS_Store",
|
|
52
|
+
"Thumbs.db",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
interface FileEntry {
|
|
56
|
+
/** 该文件在快照时刻是否存在于工作目录 */
|
|
57
|
+
existed: boolean;
|
|
58
|
+
/** UTF-8 文本原文;二进制文件用 base64;超过大小上限或读取失败时为 null */
|
|
59
|
+
content: string | null;
|
|
60
|
+
/** 是否二进制(用 base64 存);用于 restore 时区分写入方式 */
|
|
61
|
+
isBinary: boolean;
|
|
62
|
+
/** content 的 sha1;用于扫描时检测变更 */
|
|
63
|
+
hash: string;
|
|
64
|
+
/** 文件大小(字节) */
|
|
65
|
+
size: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TurnDiff {
|
|
69
|
+
/** 该回合的时间戳 */
|
|
70
|
+
timestamp: number;
|
|
71
|
+
/** 该回合内被改的文件: abs path → { before, after } */
|
|
72
|
+
files: Record<string, { before: FileEntry; after: FileEntry }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PersistedState {
|
|
76
|
+
cwd: string;
|
|
77
|
+
pointer: number;
|
|
78
|
+
turns: Array<{ timestamp: number; snapshotFile: string; fileCount: number }>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function cwdHash(cwd: string): string {
|
|
82
|
+
return createHash("sha1").update(cwd).digest("hex").slice(0, 16);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class UndoManager {
|
|
86
|
+
private cwd: string;
|
|
87
|
+
private storeDir: string;
|
|
88
|
+
private snapshotsDir: string;
|
|
89
|
+
private stateFile: string;
|
|
90
|
+
/** turns 索引元数据(不含内容) */
|
|
91
|
+
private turns: PersistedState["turns"] = [];
|
|
92
|
+
/** 指针: -1 = 仅基线(无任何应用过的回合);0 = 已应用 turns[0];N = 已应用 turns[N] */
|
|
93
|
+
private pointer = -1;
|
|
94
|
+
/** 回合开始前的快照(等回合结束后做 diff) */
|
|
95
|
+
private pendingBefore: Map<string, FileEntry> | null = null;
|
|
96
|
+
/** 当前基线快照(= pointer 指向的回合结束时的状态,或初始状态) */
|
|
97
|
+
private baseline: Map<string, FileEntry> = new Map();
|
|
98
|
+
private ready = false;
|
|
99
|
+
|
|
100
|
+
constructor(cwd: string) {
|
|
101
|
+
this.cwd = resolve(cwd);
|
|
102
|
+
const hash = cwdHash(this.cwd);
|
|
103
|
+
this.storeDir = join(UNDO_ROOT, hash);
|
|
104
|
+
this.snapshotsDir = join(this.storeDir, "snapshots");
|
|
105
|
+
this.stateFile = join(this.storeDir, "state.json");
|
|
106
|
+
mkdirSync(this.snapshotsDir, { recursive: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 启动时调用:加载持久化状态(如有),并刷新基线为当前工作目录真实状态。
|
|
111
|
+
* 不存在持久化时,把当前状态作为初始基线。
|
|
112
|
+
*/
|
|
113
|
+
load(): void {
|
|
114
|
+
try {
|
|
115
|
+
if (existsSync(this.stateFile)) {
|
|
116
|
+
const raw = readFileSync(this.stateFile, "utf-8");
|
|
117
|
+
const data = JSON.parse(raw) as PersistedState;
|
|
118
|
+
if (data.cwd === this.cwd) {
|
|
119
|
+
this.turns = data.turns ?? [];
|
|
120
|
+
this.pointer = typeof data.pointer === "number" ? data.pointer : -1;
|
|
121
|
+
} else {
|
|
122
|
+
this.turns = [];
|
|
123
|
+
this.pointer = -1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// 状态文件损坏,从空开始
|
|
128
|
+
this.turns = [];
|
|
129
|
+
this.pointer = -1;
|
|
130
|
+
}
|
|
131
|
+
// 重新扫描当前状态,作为新一回合的 baseline
|
|
132
|
+
// (跨 ARONA 重启时:用户在 ARONA 之外可能改过文件,
|
|
133
|
+
// 原有 turns 已与新基线脱钩,清掉 redo 段以避免错乱)
|
|
134
|
+
if (this.pointer < this.turns.length - 1) {
|
|
135
|
+
this.turns = this.turns.slice(0, this.pointer + 1);
|
|
136
|
+
}
|
|
137
|
+
this.baseline = this.takeSnapshot();
|
|
138
|
+
this.pendingBefore = null;
|
|
139
|
+
this.ready = true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 每个 Agent 回合开始前调用,记录 before 快照。
|
|
144
|
+
* 与 load() 配合:回合开始时 baseline = load 时扫的;这里再扫一次 = "before"
|
|
145
|
+
* (一般等同 baseline,除非用户在回合间隙手动改了文件)。
|
|
146
|
+
*/
|
|
147
|
+
beforeTurn(): void {
|
|
148
|
+
this.pendingBefore = this.takeSnapshot();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 每个 Agent 回合结束后调用,与 before 对比,产出 diff 入栈。
|
|
153
|
+
* 没有任何变化时跳过(不污染栈)。
|
|
154
|
+
*/
|
|
155
|
+
afterTurn(): void {
|
|
156
|
+
if (!this.ready) return;
|
|
157
|
+
if (!this.pendingBefore) {
|
|
158
|
+
// 没 beforeTurn 直接 afterTurn:退化为只对 baseline 做对比
|
|
159
|
+
this.pendingBefore = new Map(this.baseline);
|
|
160
|
+
}
|
|
161
|
+
const after = this.takeSnapshot();
|
|
162
|
+
const diff = this.computeDiff(this.pendingBefore, after);
|
|
163
|
+
this.pendingBefore = null;
|
|
164
|
+
if (diff === null) {
|
|
165
|
+
// 没有任何变化:不污染栈,但更新 baseline 防止下回合做无意义 diff
|
|
166
|
+
this.baseline = after;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// 截断 redo 段(用户在中途发新消息,后续 redo 失效)
|
|
170
|
+
if (this.pointer < this.turns.length - 1) {
|
|
171
|
+
this.turns = this.turns.slice(0, this.pointer + 1);
|
|
172
|
+
}
|
|
173
|
+
const turnId = this.turns.length;
|
|
174
|
+
const snapshotFile = `turn-${String(turnId).padStart(4, "0")}.json`;
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(this.snapshotsDir, snapshotFile),
|
|
177
|
+
JSON.stringify(diff),
|
|
178
|
+
"utf-8",
|
|
179
|
+
);
|
|
180
|
+
this.turns.push({
|
|
181
|
+
timestamp: diff.timestamp,
|
|
182
|
+
snapshotFile,
|
|
183
|
+
fileCount: Object.keys(diff.files).length,
|
|
184
|
+
});
|
|
185
|
+
this.pointer = this.turns.length - 1;
|
|
186
|
+
this.baseline = after;
|
|
187
|
+
this.persist();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 撤销上一步。返回 { ok, message } 给命令调用方展示。
|
|
192
|
+
*/
|
|
193
|
+
undo(): { ok: boolean; message: string } {
|
|
194
|
+
if (!this.ready) return { ok: false, message: t("撤销系统未就绪。", "Undo system is not ready.") };
|
|
195
|
+
if (this.pointer < 0) {
|
|
196
|
+
return { ok: false, message: t("无内容可撤销。", "Nothing to undo.") };
|
|
197
|
+
}
|
|
198
|
+
const turnMeta = this.turns[this.pointer];
|
|
199
|
+
const diff = this.loadSnapshot(turnMeta.snapshotFile);
|
|
200
|
+
if (!diff) {
|
|
201
|
+
return { ok: false, message: t(`撤销快照 ${turnMeta.snapshotFile} 已损坏。`, `Undo snapshot ${turnMeta.snapshotFile} is corrupted.`) };
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
this.applyReverse(diff);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
message: t(`撤销失败:${err instanceof Error ? err.message : String(err)}`, `Undo failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
this.pointer--;
|
|
212
|
+
// 更新 baseline 为"撤销后的状态"——直接重扫一次最稳
|
|
213
|
+
this.baseline = this.takeSnapshot();
|
|
214
|
+
this.persist();
|
|
215
|
+
return {
|
|
216
|
+
ok: true,
|
|
217
|
+
message: t(
|
|
218
|
+
`已撤销第 ${this.pointer + 2} 回合的 ${Object.keys(diff.files).length} 个文件改动。使用 /redo 重做。`,
|
|
219
|
+
`Undid ${Object.keys(diff.files).length} file change(s) from turn ${this.pointer + 2}. Use /redo to redo.`,
|
|
220
|
+
),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 重做下一步(已撤销的回合)。
|
|
226
|
+
*/
|
|
227
|
+
redo(): { ok: boolean; message: string } {
|
|
228
|
+
if (!this.ready) return { ok: false, message: t("撤销系统未就绪。", "Undo system is not ready.") };
|
|
229
|
+
if (this.pointer >= this.turns.length - 1) {
|
|
230
|
+
return { ok: false, message: t("无内容可重做。", "Nothing to redo.") };
|
|
231
|
+
}
|
|
232
|
+
const turnMeta = this.turns[this.pointer + 1];
|
|
233
|
+
const diff = this.loadSnapshot(turnMeta.snapshotFile);
|
|
234
|
+
if (!diff) {
|
|
235
|
+
return { ok: false, message: t(`重做快照 ${turnMeta.snapshotFile} 已损坏。`, `Redo snapshot ${turnMeta.snapshotFile} is corrupted.`) };
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
this.applyForward(diff);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
message: t(`重做失败:${err instanceof Error ? err.message : String(err)}`, `Redo failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
this.pointer++;
|
|
246
|
+
this.baseline = this.takeSnapshot();
|
|
247
|
+
this.persist();
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
message: t(
|
|
251
|
+
`已重做第 ${this.pointer + 1} 回合的 ${Object.keys(diff.files).length} 个文件改动。`,
|
|
252
|
+
`Redid ${Object.keys(diff.files).length} file change(s) from turn ${this.pointer + 1}.`,
|
|
253
|
+
),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------- private
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 扫描工作目录,返回 path → FileEntry 的快照。
|
|
261
|
+
* 对 ≤ MAX_FILE_BYTES 的文件,同时读出完整 content(二进制 base64,文本 utf-8),
|
|
262
|
+
* 这样 diff 阶段无需再回读文件——回合内文件可能已被 Agent 改写,
|
|
263
|
+
* 回读出来的就不再是 before 内容。
|
|
264
|
+
*/
|
|
265
|
+
private takeSnapshot(): Map<string, FileEntry> {
|
|
266
|
+
const out = new Map<string, FileEntry>();
|
|
267
|
+
this.walk(this.cwd, (abs) => {
|
|
268
|
+
try {
|
|
269
|
+
const stat = statSync(abs);
|
|
270
|
+
if (!stat.isFile()) return;
|
|
271
|
+
const size = stat.size;
|
|
272
|
+
if (size > MAX_FILE_BYTES) {
|
|
273
|
+
// 太大:只记 size + hash(用流式哈希避免读全文入内存)
|
|
274
|
+
const hash = this.hashLargeFile(abs);
|
|
275
|
+
out.set(abs, { existed: true, content: null, isBinary: true, hash, size });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const buf = readFileSync(abs);
|
|
279
|
+
const isBinary = this.looksBinary(buf);
|
|
280
|
+
const hash = createHash("sha1").update(buf).digest("hex");
|
|
281
|
+
const content = isBinary ? buf.toString("base64") : buf.toString("utf-8");
|
|
282
|
+
out.set(abs, { existed: true, content, isBinary, hash, size });
|
|
283
|
+
} catch {
|
|
284
|
+
// 读失败:当作不存在
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private walk(dir: string, cb: (abs: string) => void): void {
|
|
291
|
+
let entries: { name: string; isDir: boolean }[];
|
|
292
|
+
try {
|
|
293
|
+
entries = readdirSync(dir, { withFileTypes: true }).map((e) => ({
|
|
294
|
+
name: e.name,
|
|
295
|
+
isDir: e.isDirectory(),
|
|
296
|
+
}));
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const { name, isDir } of entries) {
|
|
301
|
+
if (isDir) {
|
|
302
|
+
if (DEFAULT_IGNORE_DIRS.has(name)) continue;
|
|
303
|
+
// 跳过隐藏目录(以 . 开头)避免污染,但不强制(允许 .config 之类)
|
|
304
|
+
// 这里维持简单:仅排除默认列表中的隐藏目录
|
|
305
|
+
this.walk(join(dir, name), cb);
|
|
306
|
+
} else {
|
|
307
|
+
if (DEFAULT_IGNORE_FILES.has(name)) continue;
|
|
308
|
+
const abs = join(dir, name);
|
|
309
|
+
cb(abs);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private hashLargeFile(abs: string): string {
|
|
315
|
+
// 只读 4KB 头 + 4KB 尾 + size 当 hash 近似(够用于检测删除)
|
|
316
|
+
// 用 fd 流式读取,避免把整个大文件读入内存(原实现误用 readFileSync 全量读取)
|
|
317
|
+
let fd: number | null = null;
|
|
318
|
+
try {
|
|
319
|
+
const stat = statSync(abs);
|
|
320
|
+
const size = stat.size;
|
|
321
|
+
fd = openSync(abs, "r");
|
|
322
|
+
const headLen = Math.min(4096, size);
|
|
323
|
+
const tailLen = Math.min(4096, size);
|
|
324
|
+
const head = Buffer.alloc(headLen);
|
|
325
|
+
const tail = Buffer.alloc(tailLen);
|
|
326
|
+
if (headLen > 0) readSync(fd, head, 0, headLen, 0);
|
|
327
|
+
if (tailLen > 0) readSync(fd, tail, 0, tailLen, Math.max(0, size - tailLen));
|
|
328
|
+
return createHash("sha1")
|
|
329
|
+
.update(`${size}:`)
|
|
330
|
+
.update(head)
|
|
331
|
+
.update(":")
|
|
332
|
+
.update(tail)
|
|
333
|
+
.digest("hex");
|
|
334
|
+
} catch {
|
|
335
|
+
return "unreadable";
|
|
336
|
+
} finally {
|
|
337
|
+
if (fd !== null) {
|
|
338
|
+
try { closeSync(fd); } catch {}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private looksBinary(buf: Buffer): boolean {
|
|
344
|
+
// 简单二进制检测:含 NUL 字节 → 二进制
|
|
345
|
+
for (let i = 0; i < Math.min(buf.length, 8192); i++) {
|
|
346
|
+
if (buf[i] === 0) return true;
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private writeFileEntry(abs: string, entry: FileEntry): void {
|
|
352
|
+
if (entry.content === null) {
|
|
353
|
+
throw new Error(t(
|
|
354
|
+
`无法恢复 ${relative(this.cwd, abs)}:快照中未保存内容(可能过大)。`,
|
|
355
|
+
`Cannot restore ${relative(this.cwd, abs)}: content was not saved in the snapshot (file too large?).`,
|
|
356
|
+
));
|
|
357
|
+
}
|
|
358
|
+
mkdirSync(resolve(abs, ".."), { recursive: true });
|
|
359
|
+
const buf = entry.isBinary
|
|
360
|
+
? Buffer.from(entry.content, "base64")
|
|
361
|
+
: Buffer.from(entry.content, "utf-8");
|
|
362
|
+
writeFileSync(abs, buf);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private deleteFileEntry(abs: string): void {
|
|
366
|
+
try {
|
|
367
|
+
unlinkSync(abs);
|
|
368
|
+
} catch {
|
|
369
|
+
// 不存在就算了
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 把 before / after 两个 snapshot 对比,产出 changed files 的 diff。
|
|
375
|
+
* content 直接取自 snapshot(takeSnapshot 已读出),不再回读文件——
|
|
376
|
+
* 因为 afterTurn 调用时,文件已是 after 状态,回读会污染 before 内容。
|
|
377
|
+
* 无变化返回 null。
|
|
378
|
+
*/
|
|
379
|
+
private computeDiff(
|
|
380
|
+
before: Map<string, FileEntry>,
|
|
381
|
+
after: Map<string, FileEntry>,
|
|
382
|
+
): TurnDiff | null {
|
|
383
|
+
const files: TurnDiff["files"] = {};
|
|
384
|
+
const allPaths = new Set<string>([...before.keys(), ...after.keys()]);
|
|
385
|
+
for (const p of allPaths) {
|
|
386
|
+
const b = before.get(p);
|
|
387
|
+
const a = after.get(p);
|
|
388
|
+
const bHash = b?.hash ?? "";
|
|
389
|
+
const aHash = a?.hash ?? "";
|
|
390
|
+
const bExisted = b?.existed ?? false;
|
|
391
|
+
const aExisted = a?.existed ?? false;
|
|
392
|
+
if (bHash === aHash && bExisted === aExisted) {
|
|
393
|
+
continue; // 没变
|
|
394
|
+
}
|
|
395
|
+
const beforeEntry: FileEntry = b
|
|
396
|
+
? { ...b }
|
|
397
|
+
: { existed: false, content: null, isBinary: false, hash: "", size: 0 };
|
|
398
|
+
const afterEntry: FileEntry = a
|
|
399
|
+
? { ...a }
|
|
400
|
+
: { existed: false, content: null, isBinary: false, hash: "", size: 0 };
|
|
401
|
+
files[p] = { before: beforeEntry, after: afterEntry };
|
|
402
|
+
}
|
|
403
|
+
if (Object.keys(files).length === 0) return null;
|
|
404
|
+
return { timestamp: Date.now(), files };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 正向应用 diff(before → after)。
|
|
409
|
+
*/
|
|
410
|
+
private applyForward(diff: TurnDiff): void {
|
|
411
|
+
for (const [p, { after }] of Object.entries(diff.files)) {
|
|
412
|
+
if (after.existed) {
|
|
413
|
+
this.writeFileEntry(p, after);
|
|
414
|
+
} else {
|
|
415
|
+
this.deleteFileEntry(p);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 反向应用 diff(after → before)。
|
|
422
|
+
*/
|
|
423
|
+
private applyReverse(diff: TurnDiff): void {
|
|
424
|
+
for (const [p, { before }] of Object.entries(diff.files)) {
|
|
425
|
+
if (before.existed) {
|
|
426
|
+
this.writeFileEntry(p, before);
|
|
427
|
+
} else {
|
|
428
|
+
this.deleteFileEntry(p);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private loadSnapshot(name: string): TurnDiff | null {
|
|
434
|
+
const p = join(this.snapshotsDir, name);
|
|
435
|
+
try {
|
|
436
|
+
const raw = readFileSync(p, "utf-8");
|
|
437
|
+
return JSON.parse(raw) as TurnDiff;
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private persist(): void {
|
|
444
|
+
const data: PersistedState = {
|
|
445
|
+
cwd: this.cwd,
|
|
446
|
+
pointer: this.pointer,
|
|
447
|
+
turns: this.turns,
|
|
448
|
+
};
|
|
449
|
+
writeFileSync(this.stateFile, JSON.stringify(data, null, 2), "utf-8");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from "child_process";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { PYTHON_DIR, config } from "../config.ts";
|
|
4
|
+
import { t, getLang } from "../locale.ts";
|
|
5
|
+
import { spawnCompat, stripProxyEnv } from "./spawn.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a Python script one-shot: spawn, pass input via stdin, return stdout.
|
|
9
|
+
* 超过 timeoutMs(默认 60s)后强制 kill 进程并 reject,避免 TTS/STT 挂起导致 REPL 卡死。
|
|
10
|
+
*/
|
|
11
|
+
export async function runPython(
|
|
12
|
+
scriptName: string,
|
|
13
|
+
args: string[] = [],
|
|
14
|
+
stdinData?: string,
|
|
15
|
+
env?: Record<string, string>,
|
|
16
|
+
timeoutMs = 60000,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const scriptPath = join(PYTHON_DIR, scriptName);
|
|
19
|
+
// ARONA_LANG 传给 Python 侧做 i18n(覆盖仅靠 LANG 不可靠的场景);PYTHONUTF8 让子进程 stdin/stdout/stderr 走 UTF-8
|
|
20
|
+
// stripProxyEnv 剔除代理变量,避免 websockets 误走本机 SOCKS 代理(DashScope 国内服务应直连)
|
|
21
|
+
const baseEnv = stripProxyEnv({ ...process.env, ARONA_LANG: getLang(), PYTHONUTF8: "1", ...env });
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const proc = spawnCompat(config.pythonPath, [scriptPath, ...args], {
|
|
24
|
+
env: baseEnv,
|
|
25
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
let stdout = "";
|
|
29
|
+
let stderr = "";
|
|
30
|
+
let settled = false;
|
|
31
|
+
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
if (!settled) {
|
|
34
|
+
settled = true;
|
|
35
|
+
try { proc.kill("SIGTERM"); } catch {}
|
|
36
|
+
reject(new Error(t(`Python ${scriptName} 执行超时(${timeoutMs}ms)`, `Python ${scriptName} timed out after ${timeoutMs}ms`)));
|
|
37
|
+
}
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
|
|
40
|
+
proc.stdout.on("data", (data) => {
|
|
41
|
+
stdout += data.toString();
|
|
42
|
+
});
|
|
43
|
+
proc.stderr.on("data", (data) => {
|
|
44
|
+
stderr += data.toString();
|
|
45
|
+
});
|
|
46
|
+
proc.on("close", (code) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
if (settled) return;
|
|
49
|
+
settled = true;
|
|
50
|
+
if (code === 0) {
|
|
51
|
+
resolve(stdout.trim());
|
|
52
|
+
} else {
|
|
53
|
+
reject(new Error(t(`Python ${scriptName} 退出码 ${code}: ${stderr}`, `Python ${scriptName} exited with code ${code}: ${stderr}`)));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
proc.on("error", (err) => {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
if (settled) return;
|
|
59
|
+
settled = true;
|
|
60
|
+
reject(new Error(t(`无法启动 Python 子进程:${err.message}`, `Failed to spawn Python: ${err.message}`)));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (stdinData !== undefined) {
|
|
64
|
+
proc.stdin.write(stdinData);
|
|
65
|
+
proc.stdin.end();
|
|
66
|
+
} else {
|
|
67
|
+
proc.stdin.end();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Persistent Python bridge: long-running process communicating via JSON over stdin/stdout.
|
|
74
|
+
* Used for computer_use.py to maintain a persistent cua connection.
|
|
75
|
+
*/
|
|
76
|
+
export class PythonBridge {
|
|
77
|
+
private proc: ChildProcessWithoutNullStreams | null = null;
|
|
78
|
+
private pendingResolve: ((value: any) => void) | null = null;
|
|
79
|
+
private pendingReject: ((reason: any) => void) | null = null;
|
|
80
|
+
private pendingTimer: NodeJS.Timeout | null = null;
|
|
81
|
+
private buffer = "";
|
|
82
|
+
private scriptName: string;
|
|
83
|
+
private args: string[];
|
|
84
|
+
private env?: Record<string, string>;
|
|
85
|
+
|
|
86
|
+
constructor(scriptName: string, args: string[] = [], env?: Record<string, string>) {
|
|
87
|
+
this.scriptName = scriptName;
|
|
88
|
+
this.args = args;
|
|
89
|
+
this.env = env;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async start(): Promise<void> {
|
|
93
|
+
const scriptPath = join(PYTHON_DIR, this.scriptName);
|
|
94
|
+
this.proc = spawnCompat(config.pythonPath, ["-u", scriptPath, ...this.args], {
|
|
95
|
+
env: stripProxyEnv({ ...process.env, ARONA_LANG: getLang(), PYTHONUTF8: "1", ...this.env }),
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
this.proc.stdout.on("data", (data) => {
|
|
100
|
+
this.buffer += data.toString();
|
|
101
|
+
// Try to parse complete JSON lines
|
|
102
|
+
const lines = this.buffer.split("\n");
|
|
103
|
+
this.buffer = lines.pop() || "";
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (trimmed && this.pendingResolve) {
|
|
107
|
+
try {
|
|
108
|
+
const result = JSON.parse(trimmed);
|
|
109
|
+
this.pendingResolve(result);
|
|
110
|
+
this.pendingResolve = null;
|
|
111
|
+
this.pendingReject = null;
|
|
112
|
+
// 响应已到达,清除 30s 超时定时器,避免泄漏到事件循环
|
|
113
|
+
if (this.pendingTimer) {
|
|
114
|
+
clearTimeout(this.pendingTimer);
|
|
115
|
+
this.pendingTimer = null;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Not JSON, skip
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
this.proc.stderr.on("data", (data) => {
|
|
125
|
+
const msg = data.toString().trim();
|
|
126
|
+
if (msg) console.error(`[python:${this.scriptName}]`, msg);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
this.proc.on("close", (code) => {
|
|
130
|
+
if (this.pendingReject) {
|
|
131
|
+
if (this.pendingTimer) {
|
|
132
|
+
clearTimeout(this.pendingTimer);
|
|
133
|
+
this.pendingTimer = null;
|
|
134
|
+
}
|
|
135
|
+
this.pendingReject(new Error(t(`Python ${this.scriptName} 已退出(码 ${code})`, `Python ${this.scriptName} exited with code ${code}`)));
|
|
136
|
+
this.pendingResolve = null;
|
|
137
|
+
this.pendingReject = null;
|
|
138
|
+
}
|
|
139
|
+
this.proc = null;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Wait for ready signal
|
|
143
|
+
return new Promise<void>((resolve, reject) => {
|
|
144
|
+
const cleanup = () => {
|
|
145
|
+
clearTimeout(timeout);
|
|
146
|
+
this.proc?.stdout.off("data", readyChecker);
|
|
147
|
+
this.proc?.off("close", onClose);
|
|
148
|
+
};
|
|
149
|
+
const timeout = setTimeout(() => {
|
|
150
|
+
cleanup();
|
|
151
|
+
reject(new Error(t(`Python ${this.scriptName} 10 秒内未就绪`, `Python ${this.scriptName} failed to start within 10s`)));
|
|
152
|
+
}, 10000);
|
|
153
|
+
|
|
154
|
+
const readyChecker = (data: Buffer) => {
|
|
155
|
+
const msg = data.toString();
|
|
156
|
+
if (msg.includes("READY")) {
|
|
157
|
+
cleanup();
|
|
158
|
+
resolve();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
// 进程在 READY 前退出:立即失败,避免干等 10 秒
|
|
162
|
+
const onClose = () => {
|
|
163
|
+
cleanup();
|
|
164
|
+
reject(new Error(t(`Python ${this.scriptName} 启动失败(进程提前退出)`, `Python ${this.scriptName} exited before becoming ready`)));
|
|
165
|
+
};
|
|
166
|
+
// Temporarily listen for ready signal
|
|
167
|
+
this.proc?.stdout.on("data", readyChecker);
|
|
168
|
+
this.proc?.once("close", onClose);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async send(command: Record<string, any>): Promise<any> {
|
|
173
|
+
if (!this.proc || this.proc.killed) {
|
|
174
|
+
throw new Error(t(`Python 桥接 ${this.scriptName} 未在运行`, `Python bridge ${this.scriptName} is not running`));
|
|
175
|
+
}
|
|
176
|
+
if (this.pendingResolve) {
|
|
177
|
+
throw new Error(t(`Python 桥接 ${this.scriptName} 忙`, `Python bridge ${this.scriptName} is busy`));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
this.pendingResolve = resolve;
|
|
182
|
+
this.pendingReject = reject;
|
|
183
|
+
this.proc!.stdin.write(JSON.stringify(command) + "\n");
|
|
184
|
+
|
|
185
|
+
// Timeout after 30s
|
|
186
|
+
this.pendingTimer = setTimeout(() => {
|
|
187
|
+
if (this.pendingReject) {
|
|
188
|
+
this.pendingReject(new Error(t(`Python 桥接 ${this.scriptName} 超时`, `Python bridge ${this.scriptName} timed out`)));
|
|
189
|
+
this.pendingResolve = null;
|
|
190
|
+
this.pendingReject = null;
|
|
191
|
+
// 超时说明桥已卡死:杀掉进程避免旧响应错配到下一次 send()
|
|
192
|
+
try { this.proc?.kill("SIGTERM"); } catch {}
|
|
193
|
+
this.proc = null;
|
|
194
|
+
}
|
|
195
|
+
this.pendingTimer = null;
|
|
196
|
+
}, 30000);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
stop(): void {
|
|
201
|
+
if (this.proc && !this.proc.killed) {
|
|
202
|
+
this.proc.stdin.end();
|
|
203
|
+
this.proc.kill("SIGTERM");
|
|
204
|
+
this.proc = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
get isRunning(): boolean {
|
|
209
|
+
return this.proc !== null && !this.proc.killed;
|
|
210
|
+
}
|
|
211
|
+
}
|