@foxden-app/foxclaw 0.5.54 → 0.5.55
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 +11 -4
- package/CHANGELOG.md +12 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -1
- package/dist/controller/controller.d.ts +1 -1
- package/dist/controller/controller.js +26 -18
- package/dist/voice/tts.js +63 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -32,9 +32,13 @@ LOG_LEVEL=info
|
|
|
32
32
|
DEFAULT_CWD=/absolute/path/to/workspace
|
|
33
33
|
DEFAULT_APPROVAL_POLICY=on-request
|
|
34
34
|
DEFAULT_SANDBOX_MODE=workspace-write
|
|
35
|
-
TELEGRAM_POLL_INTERVAL_MS=1200
|
|
36
|
-
TELEGRAM_PREVIEW_THROTTLE_MS=800
|
|
37
|
-
|
|
35
|
+
TELEGRAM_POLL_INTERVAL_MS=1200
|
|
36
|
+
TELEGRAM_PREVIEW_THROTTLE_MS=800
|
|
37
|
+
# Delete folded operation-detail messages after the final answer is sent.
|
|
38
|
+
# Set to false if you prefer to keep "read files / edited files / ran command"
|
|
39
|
+
# detail cards in the chat history.
|
|
40
|
+
# TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=true
|
|
41
|
+
THREAD_LIST_LIMIT=10
|
|
38
42
|
CODEX_CLI_BIN=/absolute/path/to/codex
|
|
39
43
|
|
|
40
44
|
# Optional cross-node Codex auth sync.
|
|
@@ -59,6 +63,7 @@ CODEX_CLI_BIN=/absolute/path/to/codex
|
|
|
59
63
|
# your own speech host; HTTP mode calls your own VOICE_TTS_URL and converts
|
|
60
64
|
# locally. FoxClaw does not ship with a public TTS backend.
|
|
61
65
|
# VOICE_TTS_ENABLED=false
|
|
66
|
+
# VOICE_TTS_ENGINE=qwen
|
|
62
67
|
# HTTP backend example: compatible service exposing /v1/tts/custom or
|
|
63
68
|
# /v1/tts/design and returning WAV audio. Local ffmpeg is required.
|
|
64
69
|
# VOICE_TTS_MODE=http
|
|
@@ -70,11 +75,13 @@ CODEX_CLI_BIN=/absolute/path/to/codex
|
|
|
70
75
|
# VOICE_TTS_MODE=ssh
|
|
71
76
|
# VOICE_TTS_SSH_HOST=<ssh-host>
|
|
72
77
|
# VOICE_TTS_SSH_DIR=/path/to/qwen-speech-server
|
|
78
|
+
# For SoulX-compatible backends, set VOICE_TTS_ENGINE=soulx and point
|
|
79
|
+
# VOICE_TTS_URL at the SoulX service, for example http://127.0.0.1:18082.
|
|
73
80
|
# VOICE_TTS_DESIGN_INSTRUCT=用自然清晰的中文女声朗读,语速适中。
|
|
74
81
|
# VOICE_SUMMARY_BUTTON_ENABLED=true
|
|
75
82
|
# VOICE_SUMMARY_TEXT_LIMIT=180
|
|
76
83
|
# VOICE_TEXT_LIMIT=2800
|
|
77
|
-
# VOICE_TTS_TIMEOUT_MS=
|
|
84
|
+
# VOICE_TTS_TIMEOUT_MS=60000
|
|
78
85
|
|
|
79
86
|
# Optional: standard HTTP(S) proxy for Telegram and ChatGPT/Codex backend requests.
|
|
80
87
|
# Put these in the same env file that `foxclaw start` installs into systemd/launchd.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
|
|
4
4
|
|
|
5
|
+
## 0.5.55 - 2026-06-21
|
|
6
|
+
|
|
7
|
+
### 中文
|
|
8
|
+
- 最终答复发出后,默认删除已折叠的工具操作明细消息,让 Telegram 回顾时更接近“一问一答”;新增 `TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=false` 可保留这些明细。
|
|
9
|
+
- “听总结”新增 `VOICE_TTS_ENGINE=soulx`,SSH 模式会先检查 SoulX `/health`,再调用 `/v1/tts` 并转成 Telegram voice;语音 TTS 默认超时缩短到 60 秒。
|
|
10
|
+
- 降噪内部状态通知:不再向 Telegram 展示“目标已清除”“线程 active/running”“MCP ready/starting”等低价值生命周期消息,错误和需要处理的状态仍会提示。
|
|
11
|
+
|
|
12
|
+
### English
|
|
13
|
+
- After a final answer is sent, FoxClaw now deletes archived tool-detail messages by default so Telegram history reads closer to one user turn and one final reply. Set `TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=false` to keep them.
|
|
14
|
+
- Added `VOICE_TTS_ENGINE=soulx` for voice summaries. SSH mode checks the SoulX `/health` endpoint, calls `/v1/tts`, and converts the result to Telegram voice; the default TTS timeout is now 60 seconds.
|
|
15
|
+
- Reduced noisy internal lifecycle notifications: Telegram no longer shows low-value "goal cleared", active/running thread status, or MCP ready/starting messages, while errors and actionable states are still reported.
|
|
16
|
+
|
|
5
17
|
## 0.5.54 - 2026-06-20
|
|
6
18
|
|
|
7
19
|
### 中文
|
package/dist/config.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface AppConfig {
|
|
|
40
40
|
defaultSandboxMode: SandboxModeValue;
|
|
41
41
|
telegramPollIntervalMs: number;
|
|
42
42
|
telegramPreviewThrottleMs: number;
|
|
43
|
+
telegramDeleteToolDetailsAfterFinal: boolean;
|
|
43
44
|
threadListLimit: number;
|
|
44
45
|
statusPath: string;
|
|
45
46
|
logPath: string;
|
|
@@ -64,6 +65,7 @@ export interface AppConfig {
|
|
|
64
65
|
authSyncTempDir: string;
|
|
65
66
|
authAutoDeleteNeedsRepair: boolean;
|
|
66
67
|
voiceTtsEnabled: boolean;
|
|
68
|
+
voiceTtsEngine: 'qwen' | 'soulx';
|
|
67
69
|
voiceTtsMode: 'http' | 'ssh';
|
|
68
70
|
voiceTtsUrl: string | null;
|
|
69
71
|
voiceTtsToken: string | null;
|
package/dist/config.js
CHANGED
|
@@ -77,6 +77,7 @@ export function loadConfig() {
|
|
|
77
77
|
defaultSandboxMode: parseSandboxMode(process.env.DEFAULT_SANDBOX_MODE || 'workspace-write'),
|
|
78
78
|
telegramPollIntervalMs: intEnv('TELEGRAM_POLL_INTERVAL_MS', 1200),
|
|
79
79
|
telegramPreviewThrottleMs: intEnv('TELEGRAM_PREVIEW_THROTTLE_MS', 800),
|
|
80
|
+
telegramDeleteToolDetailsAfterFinal: boolEnv('TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL', true),
|
|
80
81
|
threadListLimit: intEnv('THREAD_LIST_LIMIT', 10),
|
|
81
82
|
statusPath: DEFAULT_STATUS_PATH,
|
|
82
83
|
logPath: DEFAULT_LOG_PATH,
|
|
@@ -98,6 +99,7 @@ export function loadConfig() {
|
|
|
98
99
|
authSyncTempDir: process.env.AUTH_SYNC_TEMP_DIR || DEFAULT_AUTH_SYNC_TEMP_DIR,
|
|
99
100
|
authAutoDeleteNeedsRepair: boolEnv('AUTH_AUTO_DELETE_NEEDS_REPAIR', false),
|
|
100
101
|
voiceTtsEnabled: boolEnv('VOICE_TTS_ENABLED', false),
|
|
102
|
+
voiceTtsEngine: parseVoiceTtsEngine(process.env.VOICE_TTS_ENGINE || 'qwen'),
|
|
101
103
|
voiceTtsMode: parseVoiceTtsMode(process.env.VOICE_TTS_MODE || (process.env.VOICE_TTS_SSH_HOST?.trim() ? 'ssh' : 'http')),
|
|
102
104
|
voiceTtsUrl: optional('VOICE_TTS_URL'),
|
|
103
105
|
voiceTtsToken: optional('VOICE_TTS_TOKEN'),
|
|
@@ -108,7 +110,7 @@ export function loadConfig() {
|
|
|
108
110
|
voiceSummaryButtonEnabled: boolEnv('VOICE_SUMMARY_BUTTON_ENABLED', true),
|
|
109
111
|
voiceSummaryTextLimit: intEnv('VOICE_SUMMARY_TEXT_LIMIT', 180),
|
|
110
112
|
voiceTextLimit: intEnv('VOICE_TEXT_LIMIT', 2800),
|
|
111
|
-
voiceTtsTimeoutMs: intEnv('VOICE_TTS_TIMEOUT_MS',
|
|
113
|
+
voiceTtsTimeoutMs: intEnv('VOICE_TTS_TIMEOUT_MS', 60_000),
|
|
112
114
|
};
|
|
113
115
|
ensureAppDirs(config);
|
|
114
116
|
return config;
|
|
@@ -197,6 +199,9 @@ function parseSandboxMode(value) {
|
|
|
197
199
|
function parseVoiceTtsMode(value) {
|
|
198
200
|
return value.trim().toLowerCase() === 'http' ? 'http' : 'ssh';
|
|
199
201
|
}
|
|
202
|
+
function parseVoiceTtsEngine(value) {
|
|
203
|
+
return value.trim().toLowerCase() === 'soulx' ? 'soulx' : 'qwen';
|
|
204
|
+
}
|
|
200
205
|
function resolveCommand(commandName) {
|
|
201
206
|
try {
|
|
202
207
|
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
@@ -239,7 +239,7 @@ export declare class BridgeSessionCore {
|
|
|
239
239
|
private sendTyping;
|
|
240
240
|
private sendObservedCliUserMessage;
|
|
241
241
|
private collapseTurnCommentary;
|
|
242
|
-
private
|
|
242
|
+
private cleanupTransientProgressMessages;
|
|
243
243
|
private hasObservedPersistentReply;
|
|
244
244
|
private ensureThreadReady;
|
|
245
245
|
private handleAsyncError;
|
|
@@ -1495,6 +1495,9 @@ export class BridgeSessionCore {
|
|
|
1495
1495
|
if (status === 'idle') {
|
|
1496
1496
|
return;
|
|
1497
1497
|
}
|
|
1498
|
+
if (status === 'active' || status === 'running') {
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1498
1501
|
const locale = this.localeForChat(scopeId);
|
|
1499
1502
|
await this.sendMessage(scopeId, t(locale, 'thread_status_changed', { threadId, status }));
|
|
1500
1503
|
}
|
|
@@ -1534,7 +1537,6 @@ export class BridgeSessionCore {
|
|
|
1534
1537
|
}
|
|
1535
1538
|
const locale = this.localeForChat(scopeId);
|
|
1536
1539
|
if (method === 'thread/goal/cleared') {
|
|
1537
|
-
await this.sendMessage(scopeId, t(locale, 'goal_cleared_notification', { threadId }));
|
|
1538
1540
|
return;
|
|
1539
1541
|
}
|
|
1540
1542
|
const goal = mapGoalNotification(params?.goal);
|
|
@@ -1828,6 +1830,10 @@ export class BridgeSessionCore {
|
|
|
1828
1830
|
const message = params?.error
|
|
1829
1831
|
? `MCP ${name}: ${status} (${String(params.error)})`
|
|
1830
1832
|
: `MCP ${name}: ${status}`;
|
|
1833
|
+
const normalized = status.toLowerCase();
|
|
1834
|
+
if (!params?.error && ['ready', 'running', 'starting', 'connected'].includes(normalized)) {
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1831
1837
|
await this.notifyBoundScopes(message);
|
|
1832
1838
|
}
|
|
1833
1839
|
async handleMcpOauthLoginCompleted(params) {
|
|
@@ -1877,7 +1883,7 @@ export class BridgeSessionCore {
|
|
|
1877
1883
|
}
|
|
1878
1884
|
try {
|
|
1879
1885
|
await this.completeTurn(active);
|
|
1880
|
-
await this.
|
|
1886
|
+
await this.cleanupTransientProgressMessages(active);
|
|
1881
1887
|
await this.finalizeUserInputsForTurn(active, 'resolved');
|
|
1882
1888
|
this.markQueuedTurnCompleted(active);
|
|
1883
1889
|
}
|
|
@@ -3234,7 +3240,7 @@ export class BridgeSessionCore {
|
|
|
3234
3240
|
try {
|
|
3235
3241
|
this.promoteReadyToolBatch(active);
|
|
3236
3242
|
await this.completeTurn(active);
|
|
3237
|
-
await this.
|
|
3243
|
+
await this.cleanupTransientProgressMessages(active);
|
|
3238
3244
|
await this.finalizeUserInputsForTurn(active, active.interruptRequested ? 'interrupted' : 'resolved');
|
|
3239
3245
|
await this.maybeSendPlanImplementationPrompt(active);
|
|
3240
3246
|
this.markQueuedTurnCompleted(active);
|
|
@@ -3481,21 +3487,25 @@ export class BridgeSessionCore {
|
|
|
3481
3487
|
segment.messages = [];
|
|
3482
3488
|
}
|
|
3483
3489
|
}
|
|
3484
|
-
async
|
|
3485
|
-
if (!
|
|
3490
|
+
async cleanupTransientProgressMessages(active) {
|
|
3491
|
+
if (!this.hasObservedPersistentReply(active)) {
|
|
3486
3492
|
return;
|
|
3487
3493
|
}
|
|
3488
3494
|
const messageIds = new Set();
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
+
if (active.isObserved) {
|
|
3496
|
+
for (const segment of active.segments) {
|
|
3497
|
+
if (segment.outputKind === 'final_answer') {
|
|
3498
|
+
continue;
|
|
3499
|
+
}
|
|
3500
|
+
for (const message of segment.messages) {
|
|
3501
|
+
messageIds.add(message.messageId);
|
|
3502
|
+
}
|
|
3495
3503
|
}
|
|
3496
3504
|
}
|
|
3497
|
-
|
|
3498
|
-
|
|
3505
|
+
if (this.config.telegramDeleteToolDetailsAfterFinal) {
|
|
3506
|
+
for (const messageId of active.archivedMessageIds) {
|
|
3507
|
+
messageIds.add(messageId);
|
|
3508
|
+
}
|
|
3499
3509
|
}
|
|
3500
3510
|
for (const messageId of messageIds) {
|
|
3501
3511
|
try {
|
|
@@ -3503,7 +3513,7 @@ export class BridgeSessionCore {
|
|
|
3503
3513
|
}
|
|
3504
3514
|
catch (error) {
|
|
3505
3515
|
if (!isTelegramMessageGone(error)) {
|
|
3506
|
-
this.logger.warn('telegram.
|
|
3516
|
+
this.logger.warn('telegram.transient_progress_cleanup_delete_failed', {
|
|
3507
3517
|
error: String(error),
|
|
3508
3518
|
turnId: active.turnId,
|
|
3509
3519
|
messageId,
|
|
@@ -8080,7 +8090,7 @@ export class BridgeSessionCore {
|
|
|
8080
8090
|
else {
|
|
8081
8091
|
messageId = await this.sendMessage(active.scopeId, content.text);
|
|
8082
8092
|
}
|
|
8083
|
-
if (
|
|
8093
|
+
if (messageId !== null) {
|
|
8084
8094
|
active.archivedMessageIds.push(messageId);
|
|
8085
8095
|
}
|
|
8086
8096
|
}
|
|
@@ -8098,9 +8108,7 @@ export class BridgeSessionCore {
|
|
|
8098
8108
|
else {
|
|
8099
8109
|
await this.editMessage(active.scopeId, active.previewMessageId, content.text, []);
|
|
8100
8110
|
}
|
|
8101
|
-
|
|
8102
|
-
active.archivedMessageIds.push(active.previewMessageId);
|
|
8103
|
-
}
|
|
8111
|
+
active.archivedMessageIds.push(active.previewMessageId);
|
|
8104
8112
|
}
|
|
8105
8113
|
catch (error) {
|
|
8106
8114
|
if (isTelegramMessageGone(error)) {
|
package/dist/voice/tts.js
CHANGED
|
@@ -41,6 +41,9 @@ async function synthesizeViaHttp(text, config) {
|
|
|
41
41
|
if (!config.voiceTtsUrl) {
|
|
42
42
|
throw new Error('VOICE_TTS_URL is not configured');
|
|
43
43
|
}
|
|
44
|
+
if (config.voiceTtsEngine === 'soulx') {
|
|
45
|
+
return convertToOggOpus(await requestTtsWav(config, '/v1/tts', { text, seed: '7' }), config.voiceFfmpegBin);
|
|
46
|
+
}
|
|
44
47
|
try {
|
|
45
48
|
return convertToOggOpus(await requestTtsWav(config, '/v1/tts/custom', { text, language: 'zh' }), config.voiceFfmpegBin);
|
|
46
49
|
}
|
|
@@ -114,6 +117,9 @@ async function synthesizeViaSsh(text, config) {
|
|
|
114
117
|
if (!config.voiceTtsSshDir) {
|
|
115
118
|
throw new Error('VOICE_TTS_SSH_DIR is not configured');
|
|
116
119
|
}
|
|
120
|
+
if (config.voiceTtsEngine === 'soulx') {
|
|
121
|
+
return synthesizeSoulxViaSsh(text, config);
|
|
122
|
+
}
|
|
117
123
|
const encodedText = Buffer.from(text, 'utf8').toString('base64');
|
|
118
124
|
const script = String.raw `set -euo pipefail
|
|
119
125
|
TEXT="$(printf '%s' "$1" | base64 -d)"
|
|
@@ -177,6 +183,63 @@ PY
|
|
|
177
183
|
config.voiceTtsDesignInstruct,
|
|
178
184
|
], script, config.voiceTtsTimeoutMs);
|
|
179
185
|
}
|
|
186
|
+
async function synthesizeSoulxViaSsh(text, config) {
|
|
187
|
+
const sshHost = config.voiceTtsSshHost;
|
|
188
|
+
const envDir = config.voiceTtsSshDir;
|
|
189
|
+
if (!sshHost) {
|
|
190
|
+
throw new Error('VOICE_TTS_SSH_HOST is not configured');
|
|
191
|
+
}
|
|
192
|
+
if (!envDir) {
|
|
193
|
+
throw new Error('VOICE_TTS_SSH_DIR is not configured');
|
|
194
|
+
}
|
|
195
|
+
const encodedText = Buffer.from(text, 'utf8').toString('base64');
|
|
196
|
+
const baseUrl = config.voiceTtsUrl || 'http://127.0.0.1:18082';
|
|
197
|
+
const script = String.raw `set -euo pipefail
|
|
198
|
+
TEXT="$(printf '%s' "$1" | base64 -d)"
|
|
199
|
+
ENV_DIR="$2"
|
|
200
|
+
BASE_URL="$3"
|
|
201
|
+
cd "$ENV_DIR"
|
|
202
|
+
set -a
|
|
203
|
+
. ./.env
|
|
204
|
+
set +a
|
|
205
|
+
tmp="$(mktemp -d)"
|
|
206
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
207
|
+
curl -sS "\${BASE_URL}/health" >/dev/null
|
|
208
|
+
TEXT="$TEXT" python3 - <<'PY' > "$tmp/body.json"
|
|
209
|
+
import json
|
|
210
|
+
import os
|
|
211
|
+
print(json.dumps({"text": os.environ["TEXT"], "seed": 7, "dialect_prompt": ""}, ensure_ascii=False))
|
|
212
|
+
PY
|
|
213
|
+
status="$(curl -sS -w '%{http_code}' -X POST "\${BASE_URL}/v1/tts" \
|
|
214
|
+
-H "Authorization: Bearer $QWEN_SPEECH_API_TOKEN" \
|
|
215
|
+
-H "Content-Type: application/json" \
|
|
216
|
+
--data-binary "@$tmp/body.json" \
|
|
217
|
+
--output "$tmp/tts.wav")"
|
|
218
|
+
if [[ "$status" != "200" ]]; then
|
|
219
|
+
python3 - "$tmp/tts.wav" <<'PY' >&2
|
|
220
|
+
import pathlib
|
|
221
|
+
import sys
|
|
222
|
+
sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
223
|
+
PY
|
|
224
|
+
exit 22
|
|
225
|
+
fi
|
|
226
|
+
ffmpeg -nostdin -hide_banner -loglevel error -y -i "$tmp/tts.wav" -ac 1 -c:a libopus -b:a 32k "$tmp/voice.ogg"
|
|
227
|
+
python3 - "$tmp/voice.ogg" <<'PY'
|
|
228
|
+
import pathlib
|
|
229
|
+
import sys
|
|
230
|
+
sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())
|
|
231
|
+
PY
|
|
232
|
+
`;
|
|
233
|
+
return runSshBinary([
|
|
234
|
+
sshHost,
|
|
235
|
+
'bash',
|
|
236
|
+
'-s',
|
|
237
|
+
'--',
|
|
238
|
+
encodedText,
|
|
239
|
+
envDir,
|
|
240
|
+
baseUrl,
|
|
241
|
+
], script, config.voiceTtsTimeoutMs);
|
|
242
|
+
}
|
|
180
243
|
async function runSshBinary(args, stdin, timeoutMs) {
|
|
181
244
|
return new Promise((resolve, reject) => {
|
|
182
245
|
const child = spawn('ssh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|