@foxden-app/foxclaw 0.7.3 → 0.11.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/.env.example +22 -8
- package/CHANGELOG.md +134 -0
- package/README.md +21 -1
- package/README_EN.md +21 -1
- package/dist/antigravity/adapter.d.ts +18 -0
- package/dist/antigravity/adapter.js +394 -0
- package/dist/antigravity/auth.d.ts +126 -0
- package/dist/antigravity/auth.js +933 -0
- package/dist/antigravity/client.d.ts +32 -0
- package/dist/antigravity/client.js +244 -0
- package/dist/antigravity/controller.d.ts +101 -0
- package/dist/antigravity/controller.js +2007 -0
- package/dist/antigravity/conversations.d.ts +29 -0
- package/dist/antigravity/conversations.js +289 -0
- package/dist/antigravity/events.d.ts +102 -0
- package/dist/antigravity/events.js +143 -0
- package/dist/antigravity/runtime.d.ts +49 -0
- package/dist/antigravity/runtime.js +82 -0
- package/dist/antigravity/subagents.d.ts +66 -0
- package/dist/antigravity/subagents.js +459 -0
- package/dist/channels/telegram/telegram_channel_adapter.d.ts +10 -3
- package/dist/channels/telegram/telegram_channel_adapter.js +2 -1
- package/dist/channels/weixin/weixin_channel_adapter.d.ts +5 -1
- package/dist/codex_app/adapter.d.ts +20 -0
- package/dist/codex_app/adapter.js +213 -0
- package/dist/codex_app/client.d.ts +1 -1
- package/dist/codex_app/client.js +12 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +65 -16
- package/dist/controller/controller.d.ts +12 -5
- package/dist/controller/controller.js +109 -8
- package/dist/core/attachments.d.ts +11 -0
- package/dist/core/attachments.js +56 -0
- package/dist/core/engine_spi.d.ts +77 -0
- package/dist/core/engine_spi.js +1 -0
- package/dist/core/orchestrator.d.ts +106 -0
- package/dist/core/orchestrator.js +1556 -0
- package/dist/core/stream_preview.d.ts +29 -0
- package/dist/core/stream_preview.js +146 -0
- package/dist/core/turn_queue.d.ts +19 -0
- package/dist/core/turn_queue.js +41 -0
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +49 -0
- package/dist/main.js +375 -446
- package/dist/opencode/adapter.d.ts +11 -0
- package/dist/opencode/adapter.js +169 -0
- package/dist/opencode/controller.js +2 -2
- package/dist/store/database.d.ts +44 -0
- package/dist/store/database.js +275 -43
- package/dist/store/token_usage.d.ts +23 -0
- package/dist/store/token_usage.js +61 -0
- package/dist/telegram/api.d.ts +1 -0
- package/dist/telegram/api.js +80 -3
- package/dist/telegram/bot_home.js +5 -4
- package/dist/telegram/gateway.d.ts +4 -0
- package/dist/telegram/gateway.js +11 -0
- package/dist/types.d.ts +3 -0
- package/dist/update.d.ts +6 -0
- package/dist/update.js +95 -7
- package/dist/voice/tts.js +4 -2
- package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/02./347/273/237/344/270/200/351/200/232/351/201/223/350/260/203/345/272/246/345/231/250/344/270/216/345/217/257/346/217/222/346/213/224/345/274/225/346/223/216SPI/357/274/232FoxClaw/344/273/216/345/215/225/344/275/223/350/265/260/345/220/221/345/244/232Agent/345/272/225/345/272/247.md +192 -0
- package/docs/devlogs//347/254/254/344/270/200/344/272/272/347/247/260/347/211/210/02./347/257/207/344/272/214_/345/275/223/344/270/200/344/270/252/346/241/245/346/216/245/345/231/250/345/274/200/345/247/213/345/255/246/344/274/232/345/220/254/346/207/202/346/211/200/346/234/211Agent.md +45 -0
- package/package.json +2 -2
package/dist/telegram/api.js
CHANGED
|
@@ -4,7 +4,30 @@ import https from 'node:https';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
const DEFAULT_API_BASE_URL = 'https://api.telegram.org';
|
|
7
|
-
export
|
|
7
|
+
export function isTransientNetworkError(err) {
|
|
8
|
+
if (!err)
|
|
9
|
+
return false;
|
|
10
|
+
const msg = String(err.message || err).toLowerCase();
|
|
11
|
+
return (msg.includes('client network socket disconnected') ||
|
|
12
|
+
msg.includes('econnreset') ||
|
|
13
|
+
msg.includes('etimedout') ||
|
|
14
|
+
msg.includes('eai_again') ||
|
|
15
|
+
msg.includes('enotfound') ||
|
|
16
|
+
msg.includes('econnrefused') ||
|
|
17
|
+
msg.includes('socket hang up') ||
|
|
18
|
+
msg.includes('timed out') ||
|
|
19
|
+
msg.includes('timeout') ||
|
|
20
|
+
msg.includes('network socket disconnected') ||
|
|
21
|
+
msg.includes('network error') ||
|
|
22
|
+
msg.includes('fetch failed') ||
|
|
23
|
+
msg.includes('und_err_') ||
|
|
24
|
+
msg.includes('read tcp') ||
|
|
25
|
+
msg.includes('connection timed out') ||
|
|
26
|
+
msg.includes('broken pipe') ||
|
|
27
|
+
msg.includes('ehostunreach') ||
|
|
28
|
+
msg.includes('enetunreach'));
|
|
29
|
+
}
|
|
30
|
+
function callTelegramApiOnce(botToken, method, body) {
|
|
8
31
|
const payload = JSON.stringify(body);
|
|
9
32
|
const endpoint = telegramApiUrl(botToken, method);
|
|
10
33
|
return new Promise((resolve, reject) => {
|
|
@@ -39,7 +62,25 @@ export async function callTelegramApi(botToken, method, body) {
|
|
|
39
62
|
request.end();
|
|
40
63
|
});
|
|
41
64
|
}
|
|
42
|
-
export async function
|
|
65
|
+
export async function callTelegramApi(botToken, method, body) {
|
|
66
|
+
const maxAttempts = method === 'getUpdates' ? 1 : 4;
|
|
67
|
+
let lastError;
|
|
68
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
return await callTelegramApiOnce(botToken, method, body);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
lastError = err;
|
|
74
|
+
if (attempt < maxAttempts && isTransientNetworkError(err)) {
|
|
75
|
+
await new Promise((r) => setTimeout(r, Math.min(3000, 500 * Math.pow(2, attempt - 1))));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw lastError;
|
|
82
|
+
}
|
|
83
|
+
function callTelegramMultipartApiOnce(botToken, method, fields, files) {
|
|
43
84
|
const boundary = `foxclaw-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
44
85
|
const parts = [];
|
|
45
86
|
for (const [name, value] of Object.entries(fields)) {
|
|
@@ -85,6 +126,24 @@ export async function callTelegramMultipartApi(botToken, method, fields, files)
|
|
|
85
126
|
request.end();
|
|
86
127
|
});
|
|
87
128
|
}
|
|
129
|
+
export async function callTelegramMultipartApi(botToken, method, fields, files) {
|
|
130
|
+
const maxAttempts = 3;
|
|
131
|
+
let lastError;
|
|
132
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
133
|
+
try {
|
|
134
|
+
return await callTelegramMultipartApiOnce(botToken, method, fields, files);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
lastError = err;
|
|
138
|
+
if (attempt < maxAttempts && isTransientNetworkError(err)) {
|
|
139
|
+
await new Promise((r) => setTimeout(r, Math.min(3000, 500 * Math.pow(2, attempt - 1))));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw lastError;
|
|
146
|
+
}
|
|
88
147
|
export async function getTelegramFile(botToken, fileId) {
|
|
89
148
|
const result = await callTelegramApi(botToken, 'getFile', { file_id: fileId });
|
|
90
149
|
if (!result.ok || !result.result) {
|
|
@@ -92,7 +151,7 @@ export async function getTelegramFile(botToken, fileId) {
|
|
|
92
151
|
}
|
|
93
152
|
return result.result;
|
|
94
153
|
}
|
|
95
|
-
|
|
154
|
+
async function downloadTelegramFileOnce(botToken, remoteFilePath, destinationPath) {
|
|
96
155
|
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
|
|
97
156
|
const tempPath = `${destinationPath}.tmp-${process.pid}-${Date.now()}`;
|
|
98
157
|
let response = null;
|
|
@@ -129,6 +188,24 @@ export async function downloadTelegramFile(botToken, remoteFilePath, destination
|
|
|
129
188
|
response?.destroy();
|
|
130
189
|
}
|
|
131
190
|
}
|
|
191
|
+
export async function downloadTelegramFile(botToken, remoteFilePath, destinationPath) {
|
|
192
|
+
const maxAttempts = 3;
|
|
193
|
+
let lastError;
|
|
194
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
195
|
+
try {
|
|
196
|
+
return await downloadTelegramFileOnce(botToken, remoteFilePath, destinationPath);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
lastError = err;
|
|
200
|
+
if (attempt < maxAttempts && isTransientNetworkError(err)) {
|
|
201
|
+
await new Promise((r) => setTimeout(r, Math.min(3000, 500 * Math.pow(2, attempt - 1))));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastError;
|
|
208
|
+
}
|
|
132
209
|
function escapeMultipartName(value) {
|
|
133
210
|
return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\r', '').replaceAll('\n', '');
|
|
134
211
|
}
|
|
@@ -5,16 +5,17 @@ const ID_FILE = '.foxclaw-bot.json';
|
|
|
5
5
|
export function prepareTelegramBotHome(baseDir, botId, username, sharedHome = null) {
|
|
6
6
|
if (!/^bot\d+$/.test(botId))
|
|
7
7
|
throw new Error('Invalid Telegram bot identity');
|
|
8
|
-
|
|
8
|
+
const normalizedUsername = username ? username.replace(/^@/, '') : null;
|
|
9
|
+
if (normalizedUsername !== null && !/^[A-Za-z0-9_]{1,64}$/.test(normalizedUsername))
|
|
9
10
|
throw new Error('Invalid Telegram username for directory');
|
|
10
|
-
|
|
11
|
-
fs.
|
|
11
|
+
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
|
12
|
+
const base = fs.realpathSync(path.resolve(baseDir));
|
|
12
13
|
const legacy = path.join(base, botId);
|
|
13
14
|
const legacyStat = lstat(legacy);
|
|
14
15
|
const source = legacyStat?.isSymbolicLink() ? fs.realpathSync(legacy) : legacy;
|
|
15
16
|
if (path.dirname(source) !== base)
|
|
16
17
|
throw new Error(`Bot directory points outside its storage root: ${legacy}`);
|
|
17
|
-
const target =
|
|
18
|
+
const target = normalizedUsername ? path.join(base, `@${normalizedUsername}`) : source;
|
|
18
19
|
const sourceStat = lstat(source);
|
|
19
20
|
const targetStat = lstat(target);
|
|
20
21
|
if (sourceStat && !sourceStat.isDirectory())
|
|
@@ -100,6 +100,10 @@ export declare class TelegramGateway extends EventEmitter {
|
|
|
100
100
|
downloadResolvedFile(remoteFilePath: string, destinationPath: string): Promise<number>;
|
|
101
101
|
private resolveBotIdentity;
|
|
102
102
|
private registerCommands;
|
|
103
|
+
setChatCommands(chatId: number | string, commands: Array<{
|
|
104
|
+
command: string;
|
|
105
|
+
description: string;
|
|
106
|
+
}>): Promise<void>;
|
|
103
107
|
private pollLoop;
|
|
104
108
|
private handleUpdate;
|
|
105
109
|
private isAllowedChat;
|
package/dist/telegram/gateway.js
CHANGED
|
@@ -248,6 +248,17 @@ export class TelegramGateway extends EventEmitter {
|
|
|
248
248
|
language_code: 'zh',
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
|
+
async setChatCommands(chatId, commands) {
|
|
252
|
+
try {
|
|
253
|
+
await callTelegramApi(this.botToken, 'setMyCommands', {
|
|
254
|
+
commands,
|
|
255
|
+
scope: { type: 'chat', chat_id: chatId },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
this.logger.warn('telegram.set_chat_commands_failed', { chatId, error: String(err) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
251
262
|
async pollLoop() {
|
|
252
263
|
let remoteInitialized = false;
|
|
253
264
|
while (this.running) {
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface ChatSessionSettings {
|
|
|
23
23
|
collaborationMode: CollaborationModeValue | null;
|
|
24
24
|
serviceTier: string | null;
|
|
25
25
|
activeTurnMessageMode: ActiveTurnMessageMode | null;
|
|
26
|
+
activeBackendId?: string | null;
|
|
26
27
|
updatedAt: number;
|
|
27
28
|
}
|
|
28
29
|
export type QueuedTurnInputStatus = 'queued' | 'processing' | 'completed' | 'cancelled' | 'failed';
|
|
@@ -402,7 +403,9 @@ export interface RuntimeStatus {
|
|
|
402
403
|
activeTurns: number;
|
|
403
404
|
runtimeKind?: 'default' | 'isolated';
|
|
404
405
|
currentAuth?: string | null;
|
|
406
|
+
defaultBackend?: 'codex' | 'antigravity';
|
|
405
407
|
codexHome?: string | null;
|
|
408
|
+
antigravityHome?: string | null;
|
|
406
409
|
codexAppServer?: RuntimeStatus['codexAppServer'];
|
|
407
410
|
}>;
|
|
408
411
|
weixinRuntime?: {
|
package/dist/update.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface SelfUpdateStatus {
|
|
|
12
12
|
codexUpdate?: string | null;
|
|
13
13
|
codexFromVersion?: string | null;
|
|
14
14
|
codexToVersion?: string | null;
|
|
15
|
+
agyUpdate?: string | null;
|
|
16
|
+
agyFromVersion?: string | null;
|
|
17
|
+
agyToVersion?: string | null;
|
|
15
18
|
error: string | null;
|
|
16
19
|
updatedAt: string;
|
|
17
20
|
}
|
|
@@ -34,6 +37,7 @@ interface CreateSelfUpdateRuntimeOptions {
|
|
|
34
37
|
statusPath: string;
|
|
35
38
|
logPath: string;
|
|
36
39
|
codexCliBin?: string;
|
|
40
|
+
agyCliBin?: string;
|
|
37
41
|
pendingTimeoutMs?: number;
|
|
38
42
|
now?: () => Date;
|
|
39
43
|
}
|
|
@@ -44,6 +48,7 @@ interface PerformSelfUpdateOptions {
|
|
|
44
48
|
notificationFile?: string;
|
|
45
49
|
clusterBroadcastFile?: string;
|
|
46
50
|
codexCliBin?: string;
|
|
51
|
+
agyCliBin?: string;
|
|
47
52
|
env?: NodeJS.ProcessEnv;
|
|
48
53
|
}
|
|
49
54
|
export interface SelfUpdateLaunchCommand {
|
|
@@ -77,6 +82,7 @@ export declare function buildSelfUpdateLaunchCommand(options: {
|
|
|
77
82
|
statusFile: string;
|
|
78
83
|
logPath: string;
|
|
79
84
|
codexCliBin?: string;
|
|
85
|
+
agyCliBin?: string;
|
|
80
86
|
env?: NodeJS.ProcessEnv;
|
|
81
87
|
platform?: NodeJS.Platform;
|
|
82
88
|
systemdRunPath?: string | null;
|
package/dist/update.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import process from 'node:process';
|
|
4
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
5
6
|
const PACKAGE_SPEC = '@foxden-app/foxclaw@latest';
|
|
@@ -32,7 +33,23 @@ export function inferPnpmFallbackPackageFromEntryPoint(entryPoint) {
|
|
|
32
33
|
return 'pnpm@latest';
|
|
33
34
|
}
|
|
34
35
|
export function resolveSelfUpdateInstaller(entryPoint, nodePath = process.execPath, exists = fs.existsSync, env = process.env) {
|
|
35
|
-
|
|
36
|
+
let pnpmHome = inferPnpmHomeFromEntryPoint(entryPoint);
|
|
37
|
+
const normalizedEntryPoint = entryPoint.replace(/\\/g, '/');
|
|
38
|
+
const isExplicitNpm = normalizedEntryPoint.includes('/node_modules/@foxden-app/foxclaw/')
|
|
39
|
+
&& !normalizedEntryPoint.includes('/.pnpm/')
|
|
40
|
+
&& !normalizedEntryPoint.includes('/global/');
|
|
41
|
+
if (!pnpmHome && !isExplicitNpm) {
|
|
42
|
+
const candidatePnpmHome = env.PNPM_HOME?.trim() || (process.platform === 'win32'
|
|
43
|
+
? (env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'pnpm') : null)
|
|
44
|
+
: path.join(os.homedir(), '.local', 'share', 'pnpm'));
|
|
45
|
+
if (candidatePnpmHome && exists(candidatePnpmHome)) {
|
|
46
|
+
const foxclawBin = path.join(candidatePnpmHome, process.platform === 'win32' ? 'foxclaw.cmd' : 'foxclaw');
|
|
47
|
+
const globalDir = path.join(candidatePnpmHome, 'global');
|
|
48
|
+
if (exists(foxclawBin) || exists(globalDir)) {
|
|
49
|
+
pnpmHome = candidatePnpmHome;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
36
53
|
if (pnpmHome) {
|
|
37
54
|
const npmCommandName = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
38
55
|
const npmCommand = executableCandidates(npmCommandName, nodePath, env)
|
|
@@ -203,6 +220,7 @@ export function createSelfUpdateRuntime(options) {
|
|
|
203
220
|
statusFile,
|
|
204
221
|
logPath: options.logPath,
|
|
205
222
|
...(options.codexCliBin ? { codexCliBin: options.codexCliBin } : {}),
|
|
223
|
+
...(options.agyCliBin ? { agyCliBin: options.agyCliBin } : {}),
|
|
206
224
|
});
|
|
207
225
|
if (launch.viaSystemdRun) {
|
|
208
226
|
const result = spawnSync(launch.command, launch.args, {
|
|
@@ -237,6 +255,9 @@ export function createSelfUpdateRuntime(options) {
|
|
|
237
255
|
codexUpdate: null,
|
|
238
256
|
codexFromVersion: null,
|
|
239
257
|
codexToVersion: null,
|
|
258
|
+
agyUpdate: null,
|
|
259
|
+
agyFromVersion: null,
|
|
260
|
+
agyToVersion: null,
|
|
240
261
|
error: formatError(error),
|
|
241
262
|
updatedAt: new Date().toISOString(),
|
|
242
263
|
});
|
|
@@ -255,9 +276,11 @@ export function createSelfUpdateRuntime(options) {
|
|
|
255
276
|
};
|
|
256
277
|
}
|
|
257
278
|
export function buildSelfUpdateLaunchCommand(options) {
|
|
258
|
-
const env = options.
|
|
259
|
-
|
|
260
|
-
|
|
279
|
+
const env = { ...(options.env ?? process.env) };
|
|
280
|
+
if (options.codexCliBin)
|
|
281
|
+
env.CODEX_CLI_BIN = options.codexCliBin;
|
|
282
|
+
if (options.agyCliBin)
|
|
283
|
+
env.AGY_CLI_BIN = options.agyCliBin;
|
|
261
284
|
const updateArgs = [options.entryPoint, 'update', '--notification-file', options.statusFile];
|
|
262
285
|
const platform = options.platform ?? process.platform;
|
|
263
286
|
const systemdRunPath = options.systemdRunPath === undefined
|
|
@@ -292,9 +315,12 @@ export function performSelfUpdate(options) {
|
|
|
292
315
|
const env = options.env ?? process.env;
|
|
293
316
|
let toVersion = null;
|
|
294
317
|
let codexUpdate = null;
|
|
318
|
+
let agyUpdate = null;
|
|
295
319
|
try {
|
|
296
320
|
codexUpdate = updateManagedCodexCli(options.codexCliBin ?? env.CODEX_CLI_BIN ?? '', options.nodePath, env);
|
|
297
321
|
console.log(`[UPDATE] ${codexUpdate.message}`);
|
|
322
|
+
agyUpdate = updateManagedAgyCli(options.agyCliBin ?? env.AGY_CLI_BIN ?? '', env);
|
|
323
|
+
console.log(`[UPDATE] ${agyUpdate.message}`);
|
|
298
324
|
const installer = resolveSelfUpdateInstaller(options.entryPoint, options.nodePath, fs.existsSync, env);
|
|
299
325
|
const installerEnv = buildInstallerEnv(options.entryPoint, installer, env);
|
|
300
326
|
console.log(`[UPDATE] Installing ${PACKAGE_SPEC} with ${installer.manager}...`);
|
|
@@ -304,7 +330,7 @@ export function performSelfUpdate(options) {
|
|
|
304
330
|
const releaseNotes = readInstalledReleaseNotes(updatedEntryPoint, toVersion, options.notificationFile);
|
|
305
331
|
console.log('[UPDATE] Running checks and restarting the FoxClaw service...');
|
|
306
332
|
runInherited(options.nodePath, [updatedEntryPoint, 'start'], installerEnv);
|
|
307
|
-
completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes);
|
|
333
|
+
completeNotification(options.notificationFile, 'succeeded', toVersion, codexUpdate, null, releaseNotes, agyUpdate);
|
|
308
334
|
if (options.clusterBroadcastFile && env.FOXCLAW_SUPPRESS_UPDATE_BROADCAST !== '1') {
|
|
309
335
|
writePendingClusterUpdateBroadcast(options.clusterBroadcastFile, {
|
|
310
336
|
targetVersion: toVersion,
|
|
@@ -322,7 +348,7 @@ export function performSelfUpdate(options) {
|
|
|
322
348
|
}
|
|
323
349
|
catch (error) {
|
|
324
350
|
const message = formatError(error);
|
|
325
|
-
completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message, null);
|
|
351
|
+
completeNotification(options.notificationFile, 'failed', toVersion, codexUpdate, message, null, agyUpdate);
|
|
326
352
|
console.error(`[FAIL] FoxClaw update failed: ${message}`);
|
|
327
353
|
return {
|
|
328
354
|
ok: false,
|
|
@@ -407,6 +433,65 @@ function readCodexCliVersion(codexCliBin, env) {
|
|
|
407
433
|
function parseCodexCliVersion(output) {
|
|
408
434
|
return output.match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
|
|
409
435
|
}
|
|
436
|
+
function updateManagedAgyCli(agyCliBin, env) {
|
|
437
|
+
const candidateBins = [
|
|
438
|
+
agyCliBin,
|
|
439
|
+
resolveCommand('agy', env),
|
|
440
|
+
path.join(os.homedir(), '.local', 'bin', 'agy'),
|
|
441
|
+
'/usr/local/bin/agy',
|
|
442
|
+
].filter(Boolean);
|
|
443
|
+
const resolved = candidateBins.find((c) => c && fs.existsSync(c));
|
|
444
|
+
if (!resolved) {
|
|
445
|
+
return {
|
|
446
|
+
message: 'Antigravity CLI (agy) update skipped: agy binary not found.',
|
|
447
|
+
fromVersion: null,
|
|
448
|
+
toVersion: null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const fromVersion = readAgyCliVersion(resolved, env);
|
|
452
|
+
try {
|
|
453
|
+
const res = spawnSync(resolved, ['update'], { encoding: 'utf8', env });
|
|
454
|
+
const toVersion = readAgyCliVersion(resolved, env) ?? fromVersion;
|
|
455
|
+
const stdout = (res.stdout || '').trim();
|
|
456
|
+
if (stdout.includes('already on the latest')) {
|
|
457
|
+
return {
|
|
458
|
+
message: `Antigravity CLI (agy): already on latest version (${toVersion || fromVersion || 'latest'}).`,
|
|
459
|
+
fromVersion,
|
|
460
|
+
toVersion,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (fromVersion && toVersion && fromVersion !== toVersion) {
|
|
464
|
+
return {
|
|
465
|
+
message: `Antigravity CLI (agy) updated: ${fromVersion} -> ${toVersion}.`,
|
|
466
|
+
fromVersion,
|
|
467
|
+
toVersion,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
message: stdout || `Antigravity CLI (agy) checked: ${toVersion || fromVersion || 'ok'}.`,
|
|
472
|
+
fromVersion,
|
|
473
|
+
toVersion,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
return {
|
|
478
|
+
message: `Antigravity CLI (agy) update failed without blocking FoxClaw update: ${formatError(error)}`,
|
|
479
|
+
fromVersion,
|
|
480
|
+
toVersion: readAgyCliVersion(resolved, env) ?? fromVersion,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function readAgyCliVersion(agyCliBin, env) {
|
|
485
|
+
try {
|
|
486
|
+
const result = spawnSync(agyCliBin, ['--version'], { encoding: 'utf8', env });
|
|
487
|
+
if (result.error || result.status !== 0)
|
|
488
|
+
return null;
|
|
489
|
+
return (result.stdout || '').trim().match(/\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b/)?.[0] ?? null;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
410
495
|
function executableCandidates(commandName, nodePath, env, preferred = []) {
|
|
411
496
|
return [
|
|
412
497
|
...preferred,
|
|
@@ -590,7 +675,7 @@ function extractLocalizedReleaseNoteSection(section, locale) {
|
|
|
590
675
|
function escapeRegExp(value) {
|
|
591
676
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
592
677
|
}
|
|
593
|
-
function completeNotification(notificationFile, state, toVersion, codexUpdate, error, releaseNotes) {
|
|
678
|
+
function completeNotification(notificationFile, state, toVersion, codexUpdate, error, releaseNotes, agyUpdate) {
|
|
594
679
|
if (!notificationFile) {
|
|
595
680
|
return;
|
|
596
681
|
}
|
|
@@ -607,6 +692,9 @@ function completeNotification(notificationFile, state, toVersion, codexUpdate, e
|
|
|
607
692
|
codexUpdate: codexUpdate?.message ?? null,
|
|
608
693
|
codexFromVersion: codexUpdate?.fromVersion ?? null,
|
|
609
694
|
codexToVersion: codexUpdate?.toVersion ?? null,
|
|
695
|
+
agyUpdate: agyUpdate?.message ?? null,
|
|
696
|
+
agyFromVersion: agyUpdate?.fromVersion ?? null,
|
|
697
|
+
agyToVersion: agyUpdate?.toVersion ?? null,
|
|
610
698
|
error,
|
|
611
699
|
updatedAt: new Date().toISOString(),
|
|
612
700
|
});
|
package/dist/voice/tts.js
CHANGED
|
@@ -96,6 +96,8 @@ async function convertToOggOpus(input, ffmpegBin) {
|
|
|
96
96
|
'-y',
|
|
97
97
|
'-i',
|
|
98
98
|
inputPath,
|
|
99
|
+
'-filter:a',
|
|
100
|
+
'atempo=1.18',
|
|
99
101
|
'-ac',
|
|
100
102
|
'1',
|
|
101
103
|
'-c:a',
|
|
@@ -166,7 +168,7 @@ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
|
166
168
|
PY
|
|
167
169
|
exit 22
|
|
168
170
|
fi
|
|
169
|
-
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
171
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -filter:a "atempo=1.18" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
170
172
|
python3 - "$tmp/voice.ogg" <<'PY'
|
|
171
173
|
import pathlib
|
|
172
174
|
import sys
|
|
@@ -225,7 +227,7 @@ sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
|
225
227
|
PY
|
|
226
228
|
exit 22
|
|
227
229
|
fi
|
|
228
|
-
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
230
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -filter:a "atempo=1.18" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
229
231
|
python3 - "$tmp/voice.ogg" <<'PY'
|
|
230
232
|
import pathlib
|
|
231
233
|
import sys
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# 统一通道调度器与可插拔引擎 SPI:FoxClaw 从单体走向多 Agent 底座
|
|
2
|
+
|
|
3
|
+
> 记录 FoxClaw 从单一 Codex 专属桥接器,演进为支持 Codex、OpenCode 与 Google Antigravity 多引擎并行的统一 Agent 调度底座的架构重构全过程。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. 演进背景:为什么“给新 Agent 复制一套桥”走不通
|
|
8
|
+
|
|
9
|
+
FoxClaw 最初的诞生,是为了解决自管本机 Codex 的最后一公里:在离开电脑时,通过受信任的 Telegram / 微信移动端入口完成审批接管、线程观察、多账号轮转与跨节点同步。
|
|
10
|
+
|
|
11
|
+
在最初的单体架构下,整个系统以 Codex 的 `app-server` JSON-RPC 协议与状态机为绝对中心:
|
|
12
|
+
- 会话轮次(Turn)的并发控制紧耦合在 Codex 的 RPC client 回调中;
|
|
13
|
+
- Telegram 消息流式编辑、工具执行折叠、长文本分块等交互逻辑直接混杂在 `TelegramBotController` 内;
|
|
14
|
+
- 崩溃恢复、异常捕获与队列状态紧密依赖单一 Codex thread 机制。
|
|
15
|
+
|
|
16
|
+
当系统进一步引入支持多模型的 OpenCode,以及 Google 最新的 Antigravity (`agy`) 深度编程 Agent 时,一个严重的架构矛盾暴露在眼前:
|
|
17
|
+
|
|
18
|
+
如果为每个新 Agent 各自编写一套 Telegram 桥接控制器,整个工程将迅速陷入“代码爆炸与维护灾难”:
|
|
19
|
+
1. **交互逻辑严重冗余**:Markdown 富文本转义、700ms 节流流式编辑(Telegram 429 规避)、长消息(4000 字符)安全切分、工具调用块折叠(`<blockquote expandable>`)、活跃态 Typing 维持等通用交互在各个控制器中反复重抄(单是 Antigravity 控制器最初就复制了近 900 行交互代码);
|
|
20
|
+
2. **调度行为难以统一**:各个 Agent 面对瞬时并发请求时的行为不一致,有的在内存中丢弃,有的挂起阻塞,缺乏统一的抢占、队列与重启自愈保障;
|
|
21
|
+
3. **运维与状态观测割裂**:指令体系如 `/setup`、`/status`、`/models`、`/effort`、`/steer`、`/interrupt` 在各个 Agent 之间表现各异,账号候选池与配额展示缺乏标准抽象。
|
|
22
|
+
|
|
23
|
+
“重构吧,架构很重要。多花点精力没毛病。”
|
|
24
|
+
|
|
25
|
+
这是一次必然的系统升级。FoxClaw 决定重塑内核,拆分核心责任:将**与渠道无关的会话调度、流式预览、消息切分、交互折叠与队列恢复**下沉为通用的 `UnifiedChannelOrchestrator`;将**与具体模型/引擎相关的执行交互、鉴权生命周期与参数控制**抽象为标准化的 `IEngineAdapter`(Engine Service Provider Interface)。
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 2. 架构设计:两层模型与分工契约
|
|
30
|
+
|
|
31
|
+
重构后的 FoxClaw 采用分层解耦架构:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
┌────────────────────────────────────────────────────────┐
|
|
35
|
+
│ Telegram / 微信 / 移动端渠道层 │
|
|
36
|
+
└───────────────────────────┬────────────────────────────┘
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
┌─────────────────────────────────────────────────────────┐
|
|
40
|
+
│ UnifiedChannelOrchestrator (统一通道调度器) │
|
|
41
|
+
│ - Turn 抢占与并发排队 (TurnQueue) │
|
|
42
|
+
│ - 700ms 智能节流流式预览 (StreamPreviewController) │
|
|
43
|
+
│ - 消息分块 (chunkTelegramMessage) 与富文本安全转义 │
|
|
44
|
+
│ - 工具调用过程动态折叠 (<blockquote expandable>) │
|
|
45
|
+
│ - 服务崩溃/重启后的队列中断与自愈恢复 │
|
|
46
|
+
│ - 统一通用命令分发 (/setup, /status, /steer, /interrupt) │
|
|
47
|
+
└────────────────────────────┬────────────────────────────┘
|
|
48
|
+
│
|
|
49
|
+
Engine SPI (IEngineAdapter)
|
|
50
|
+
│
|
|
51
|
+
┌──────────────────────────────────┼──────────────────────────────────┐
|
|
52
|
+
▼ ▼ ▼
|
|
53
|
+
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
|
54
|
+
│ Codex Adapter │ │ Antigravity Adp │ │ OpenCode Adapter │
|
|
55
|
+
│ (App-Server RPC) │ │ (CLI ChildProc) │ │ (HTTP Streaming) │
|
|
56
|
+
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
|
57
|
+
│ │ │
|
|
58
|
+
▼ ▼ ▼
|
|
59
|
+
Codex Runtime Antigravity Engine OpenCode Engine
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 2.1 引擎服务提供者接口(Engine SPI)
|
|
63
|
+
|
|
64
|
+
在 `src/core/engine_spi.ts` 中,我们提炼了面向 Agent 运行时的最小完备接口:
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
export interface IEngineAdapter {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly displayName: string;
|
|
70
|
+
|
|
71
|
+
executeTurn(
|
|
72
|
+
request: EngineTurnRequest,
|
|
73
|
+
callbacks: EngineTurnCallbacks
|
|
74
|
+
): Promise<EngineTurnExecution>;
|
|
75
|
+
|
|
76
|
+
cancelTurn(turnId: string, runId?: string): Promise<boolean>;
|
|
77
|
+
|
|
78
|
+
buildStatusSummary(): Promise<string>;
|
|
79
|
+
buildSetupKeyboard?(): Promise<{ text: string; reply_markup?: any }>;
|
|
80
|
+
|
|
81
|
+
handleCommand?(command: string, args: string[], ctx: EngineCommandContext): Promise<boolean>;
|
|
82
|
+
handleAction?(action: string, ctx: EngineActionContext): Promise<boolean>;
|
|
83
|
+
|
|
84
|
+
resolveCurrentModel?(): Promise<string | null>;
|
|
85
|
+
resolveCurrentEffort?(): Promise<string | null>;
|
|
86
|
+
switchModel?(modelId: string): Promise<void>;
|
|
87
|
+
setEffort?(effort: string): Promise<void>;
|
|
88
|
+
|
|
89
|
+
handleTurnError?(context: EngineTurnErrorContext): Promise<string | null>;
|
|
90
|
+
onTurnComplete?(turnId: string): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
任何接入 FoxClaw 的后端 Agent,只要实现该接口,就能立刻获得:
|
|
95
|
+
- 零代码成本获得防抖、防限流的流畅流式打字输出;
|
|
96
|
+
- 完整的工具调用过程折叠;
|
|
97
|
+
- 命令排队、插队、打断与重启自愈;
|
|
98
|
+
- 标准的移动端交互键盘。
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 3. 统一通道调度器(UnifiedChannelOrchestrator)的核心实现
|
|
103
|
+
|
|
104
|
+
### 3.1 700ms 节流预览与 Telegram 限流平衡
|
|
105
|
+
|
|
106
|
+
在移动端即时通信网络中,Agent 的 token 增量生成速度(可达 50~150 tokens/s)远高于平台的 API 速率限制。Telegram 针对消息编辑(`editMessageText`)实施严格的单会话并发与频次限制,频繁编辑极易触发 HTTP 429 Retry-After。
|
|
107
|
+
|
|
108
|
+
`StreamPreviewController` 采用基于时间窗口的双缓冲节流设计:
|
|
109
|
+
1. **最小编辑间隔**:固定 700ms 节流窗口;
|
|
110
|
+
2. **内容差异校验**:如果当前渲染的 Markdown 内容与上一次已发送成功的内容无实质变化,跳过该帧;
|
|
111
|
+
3. **最终帧绝对交付**:流式结束后,无论节流计时器是否到期,强制刷新最终帧,确保输出不丢字、不截断;
|
|
112
|
+
4. **Typing 心跳保活**:在长文本推理或工具执行期间,后台每 4 秒维持一次 `sendChatAction('typing')`,消除用户的“死机焦虑”。
|
|
113
|
+
|
|
114
|
+
### 3.2 工具调用动态折叠与 4000 字符分块
|
|
115
|
+
|
|
116
|
+
复杂任务中,Agent 会产生大量终端执行、文件查找、代码替换等中间工具调用。若平铺在 Telegram 会话中,会迅速淹没正文结论。
|
|
117
|
+
|
|
118
|
+
调度器实现了工具调用的自适应折叠:
|
|
119
|
+
- 当工具开始执行时,进入内部 buffer;
|
|
120
|
+
- 转换为 HTML 规范的 `<blockquote expandable>...</blockquote>` 折叠块;
|
|
121
|
+
- 最终正文超出 Telegram 单条消息 4096 字符上限时,通过 `chunkTelegramMessage` 按照段落、代码块、句末标点边界安全切分为连续消息序列,防止 Markdown 标签不配对导致的富文本渲染崩溃。
|
|
122
|
+
|
|
123
|
+
### 3.3 崩溃自愈与跨重启状态机
|
|
124
|
+
|
|
125
|
+
守护进程更新升级或异常重启时,处于运行中的任务不能处于不可知的悬空状态。
|
|
126
|
+
`UnifiedChannelOrchestrator` 与 SQLite 存储层打通:
|
|
127
|
+
- 在系统启动时扫描处于 `running` 或 `queued` 的历史 Turn;
|
|
128
|
+
- 对未完成 Turn 标记为 `interrupted` 并向关联会话发送恢复通知;
|
|
129
|
+
- 释放旧的并发互斥锁,确保用户在服务重启后无需手动清空队列即可继续提问。
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 4. 典型引擎适配实战:Antigravity 适配器
|
|
134
|
+
|
|
135
|
+
Google Antigravity (`agy`) 是新一代面向复杂任务的深度智能体,其底层调用 CloudCode PA 接口与 Gemini 3.8/3.7/3.1 和 Claude 模型。将 Antigravity 接入 FoxClaw 涉及几个独特的挑战:
|
|
136
|
+
|
|
137
|
+
### 4.1 Token 预检与全自动刷新(Token Preflight)
|
|
138
|
+
|
|
139
|
+
Antigravity 的 OAuth Token 存储在 `~/.gemini/antigravity-cli/`。在真实环境中,Token 的有效寿命通常仅为 1 小时。若直接发起长任务 CLI 调用,过期的 Access Token 会导致数分钟后任务在模型层报错失败。
|
|
140
|
+
|
|
141
|
+
`AntigravityEngineAdapter` 引入了**预检与静默刷新机制**:
|
|
142
|
+
- 在每次执行 Turn 前,预先计算 access token 的剩余有效期;
|
|
143
|
+
- 若剩余时间少于 5 分钟,自动调用 Google OAuth 凭据端点执行静默刷新;
|
|
144
|
+
- 若刷新过程出现临时网络波动,执行回退并利用 `id_token` 中的账户哈希进行安全校验。
|
|
145
|
+
|
|
146
|
+
### 4.2 应对 Google 503 容量饱和与配额冷却轮换
|
|
147
|
+
|
|
148
|
+
在高峰期,大型推理模型可能频繁返回 `503 Service Unavailable` 或 `MODEL_CAPACITY_EXHAUSTED`。单次报错不应直接宣告任务失败。
|
|
149
|
+
|
|
150
|
+
我们在适配器中实现了两层自愈:
|
|
151
|
+
1. **指数退避重试**:针对瞬时容量饱和,适配器进行 1s、2s、4s 指数退避重试;
|
|
152
|
+
2. **配额超限自动轮换(Quota Cooldown Rotation)**:当当前 Google 账号遭遇真正的用量限额(ResourceExhausted)时,适配器自动将该账号标记为冷却态,无缝切换至候选池中的下一个可用账号,并在新账号下自动恢复执行。
|
|
153
|
+
|
|
154
|
+
### 4.3 彻底厘清配额展示:从时间误区到真实的 5h/7d 百分比
|
|
155
|
+
|
|
156
|
+
在早期的候选账号切换面板中,按钮前缀曾显示类似 `100|80|...` 的字符。这引发了一个严重的认知混淆:
|
|
157
|
+
“竖线前面的意义不是时间啊,为什么把百分比当时间展示?”
|
|
158
|
+
|
|
159
|
+
我们彻底重构了配额可观测模块:
|
|
160
|
+
- **真实指标定义**:Google PA API 返回的配额窗口为 `5 小时突发额度`(5h Window)与 `7 天长期额度`(7d Window),其返回值为 0~100 的**百分比数值(Remaining Percentage)**,而非倒计时或小时数;
|
|
161
|
+
- **显式 Badge 格式化**:在 `formatCandidateButtonPrefix` 中全面规范化为明确带百分号的徽标:`100%|80%|account_name`;
|
|
162
|
+
- **全链路感知**:在 `/auth` 列表、`/status` 面板与 `/setup` 概览中,统一以 `5h: 100% | 7d: 80%` 格式完整呈现,使开发者在手机上一眼看清各个候选账号的真实健康度。
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 5. 重构收益与系统验证
|
|
167
|
+
|
|
168
|
+
### 5.1 代码精简与架构对称
|
|
169
|
+
|
|
170
|
+
| 模块 | 重构前 | 重构后 | 变化收益 |
|
|
171
|
+
| :--- | :--- | :--- | :--- |
|
|
172
|
+
| **`AntigravityBridgeCore`** | ~1100 行(混杂所有 Telegram 交互) | 185 行(聚焦引擎交互与适配) | **减少 83% 样板代码** |
|
|
173
|
+
| **流式交互与折叠** | 各 Agent 自行实现、版本不齐 | 集中在 `UnifiedChannelOrchestrator` | 一处改进,所有引擎同享 |
|
|
174
|
+
| **新增 Agent 接入成本** | 需要复制完整 Telegram 控制器(~1000行) | 仅需实现 `IEngineAdapter`(~150行) | **降低 85% 扩展成本** |
|
|
175
|
+
|
|
176
|
+
### 5.2 测试覆盖与生产验证
|
|
177
|
+
|
|
178
|
+
本次重构涵盖全套自动化单元测试与端到端验证:
|
|
179
|
+
- **测试套件覆盖**:全仓库 **449 个自动化测试 100% 通过**(覆盖 `orchestrator.test.ts`、`adapter.test.ts`、`turn_queue.test.ts`、`stream_preview.test.ts`、`attachments.test.ts` 等);
|
|
180
|
+
- **TypeScript 严格类型检查**:`npm run typecheck` 0 错误;
|
|
181
|
+
- **全生产构建验证**:`npm run build` 干净生成生产级 `dist/`;
|
|
182
|
+
- **线上 Daemon 热升级**:在生产环境无缝热重启 `foxclaw.service`,进程持续健康运行,实测双 Agent 并发、503 退避重试与多账号按额度轮换表现稳定。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 6. 结语
|
|
187
|
+
|
|
188
|
+
FoxClaw 从一个面向 Codex 的个人小工具,迈出了走向通用 Agent 基础设施的关键一步。
|
|
189
|
+
|
|
190
|
+
统一通道调度器与可插拔引擎 SPI 的确立,不仅抹平了不同厂商 Agent 在命令行与网络协议上的差异,更让开发者无论面对哪一个大模型内核,都能在手机端拥有完全一致、稳定、丝滑的沉浸式控制体验。
|
|
191
|
+
|
|
192
|
+
AI 工具在不断演进,而沉淀在工作流最后一公里的这套运行保障底座,才是让 Agent 真正能够全天候陪伴人类工程师前行的坚实桥梁。
|