@foxden-app/foxclaw 0.5.54 → 0.5.56

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 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
- THREAD_LIST_LIMIT=10
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=120000
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,28 @@
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.56 - 2026-06-21
6
+
7
+ ### 中文
8
+ - 修正 `foxclaw status` 和 Telegram `/status` 的升级状态摘要:旧的成功 self-update 记录如果不是当前 FoxClaw 版本,不再显示为 “Last update/Last service update”,避免误导当前运行版本判断。
9
+ - 多 runtime 状态聚合现在携带当前 FoxClaw 版本,用于判断升级记录是否仍然相关;失败、运行中等需要处理的升级状态仍会展示。
10
+
11
+ ### English
12
+ - Fixed `foxclaw status` and Telegram `/status` update summaries so stale successful self-update records are no longer shown as the current "Last update/Last service update" when they do not match the running FoxClaw version.
13
+ - Multi-runtime status aggregation now carries the current FoxClaw version for this relevance check; failed or in-progress update states are still shown.
14
+
15
+ ## 0.5.55 - 2026-06-21
16
+
17
+ ### 中文
18
+ - 最终答复发出后,默认删除已折叠的工具操作明细消息,让 Telegram 回顾时更接近“一问一答”;新增 `TELEGRAM_DELETE_TOOL_DETAILS_AFTER_FINAL=false` 可保留这些明细。
19
+ - “听总结”新增 `VOICE_TTS_ENGINE=soulx`,SSH 模式会先检查 SoulX `/health`,再调用 `/v1/tts` 并转成 Telegram voice;语音 TTS 默认超时缩短到 60 秒。
20
+ - 降噪内部状态通知:不再向 Telegram 展示“目标已清除”“线程 active/running”“MCP ready/starting”等低价值生命周期消息,错误和需要处理的状态仍会提示。
21
+
22
+ ### English
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+
5
27
  ## 0.5.54 - 2026-06-20
6
28
 
7
29
  ### 中文
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', 120_000),
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';
@@ -38,6 +38,7 @@ export interface CoreCoordinator {
38
38
  }>;
39
39
  statusUpdated?: (status: RuntimeStatus) => void;
40
40
  getServiceStatus?: () => Promise<{
41
+ currentVersion?: string;
41
42
  bots: NonNullable<RuntimeStatus['bots']>;
42
43
  weixinRuntime?: RuntimeStatus['weixinRuntime'];
43
44
  authMirror?: RuntimeStatus['authMirror'];
@@ -239,7 +240,7 @@ export declare class BridgeSessionCore {
239
240
  private sendTyping;
240
241
  private sendObservedCliUserMessage;
241
242
  private collapseTurnCommentary;
242
- private cleanupObservedTransientMessages;
243
+ private cleanupTransientProgressMessages;
243
244
  private hasObservedPersistentReply;
244
245
  private ensureThreadReady;
245
246
  private handleAsyncError;
@@ -7,6 +7,7 @@ import { normalizeLocale, t } from '../i18n.js';
7
7
  import { chatGptAuthMetadataMatchesCandidateName, parseChatGptAuthMetadata, readChatGptAuthRecord, readChatGptAuthMetadata, } from '../auth/mirror.js';
8
8
  import { readAccessTokenExpiresAtMs } from '../auth/cross_node_sync.js';
9
9
  import { parseCommand } from './commands.js';
10
+ import { shouldShowRuntimeLastUpdate } from '../update_status.js';
10
11
  import { buildAccessSettingsKeyboard, buildModelSettingsKeyboard, buildSetupPanelKeyboard, buildThreadListKeyboard, buildThreadsKeyboard, clampEffortToModel, formatAccessPresetLabel, formatActiveTurnMessageModeLabel, formatAccessSettingsMessage, formatApprovalPolicyLabel, formatCollaborationModeLabel, formatModelSettingsMessage, formatSandboxModeLabel, formatServiceTierStatusLabel, formatSetupPanelMessage, formatThreadContextSummary, formatThreadsMessage, formatWeixinAccessCopyPaste, formatWeixinModelCopyPaste, formatWeixinThreadsCopyPaste, formatWeixinWhereNavCopyPaste, formatWhereMessage, normalizeRequestedEffort, resolveCurrentModel, resolveActiveTurnMessageMode, resolveRequestedModel, } from './presentation.js';
11
12
  import { clampServiceTierToModel, resolveFastTierForModel } from './service_tier.js';
12
13
  import { TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, buildAttachmentPrompt, isNativeImageAttachment, planAttachmentStoragePath, summarizeTelegramInput, } from '../telegram/media.js';
@@ -496,13 +497,14 @@ export class BridgeSessionCore {
496
497
  value: formatAuthProactiveRefreshStatus(locale, serviceStatus.authProactiveRefresh),
497
498
  }));
498
499
  }
499
- if (serviceStatus.lastUpdate) {
500
+ if (shouldShowRuntimeLastUpdate(serviceStatus)) {
501
+ const lastUpdate = serviceStatus.lastUpdate;
500
502
  lines.push(t(locale, 'status_last_update', {
501
- from: serviceStatus.lastUpdate.fromVersion,
502
- to: serviceStatus.lastUpdate.toVersion ?? t(locale, 'unknown'),
503
- time: serviceStatus.lastUpdate.updatedAt,
503
+ from: lastUpdate.fromVersion,
504
+ to: lastUpdate.toVersion ?? t(locale, 'unknown'),
505
+ time: lastUpdate.updatedAt,
504
506
  }));
505
- const codexUpdateLine = this.formatCodexUpdateResult(serviceStatus.lastUpdate);
507
+ const codexUpdateLine = this.formatCodexUpdateResult(lastUpdate);
506
508
  if (codexUpdateLine) {
507
509
  lines.push(t(locale, 'status_last_codex_update', { value: codexUpdateLine }));
508
510
  }
@@ -1495,6 +1497,9 @@ export class BridgeSessionCore {
1495
1497
  if (status === 'idle') {
1496
1498
  return;
1497
1499
  }
1500
+ if (status === 'active' || status === 'running') {
1501
+ return;
1502
+ }
1498
1503
  const locale = this.localeForChat(scopeId);
1499
1504
  await this.sendMessage(scopeId, t(locale, 'thread_status_changed', { threadId, status }));
1500
1505
  }
@@ -1534,7 +1539,6 @@ export class BridgeSessionCore {
1534
1539
  }
1535
1540
  const locale = this.localeForChat(scopeId);
1536
1541
  if (method === 'thread/goal/cleared') {
1537
- await this.sendMessage(scopeId, t(locale, 'goal_cleared_notification', { threadId }));
1538
1542
  return;
1539
1543
  }
1540
1544
  const goal = mapGoalNotification(params?.goal);
@@ -1828,6 +1832,10 @@ export class BridgeSessionCore {
1828
1832
  const message = params?.error
1829
1833
  ? `MCP ${name}: ${status} (${String(params.error)})`
1830
1834
  : `MCP ${name}: ${status}`;
1835
+ const normalized = status.toLowerCase();
1836
+ if (!params?.error && ['ready', 'running', 'starting', 'connected'].includes(normalized)) {
1837
+ return;
1838
+ }
1831
1839
  await this.notifyBoundScopes(message);
1832
1840
  }
1833
1841
  async handleMcpOauthLoginCompleted(params) {
@@ -1877,7 +1885,7 @@ export class BridgeSessionCore {
1877
1885
  }
1878
1886
  try {
1879
1887
  await this.completeTurn(active);
1880
- await this.cleanupObservedTransientMessages(active);
1888
+ await this.cleanupTransientProgressMessages(active);
1881
1889
  await this.finalizeUserInputsForTurn(active, 'resolved');
1882
1890
  this.markQueuedTurnCompleted(active);
1883
1891
  }
@@ -3234,7 +3242,7 @@ export class BridgeSessionCore {
3234
3242
  try {
3235
3243
  this.promoteReadyToolBatch(active);
3236
3244
  await this.completeTurn(active);
3237
- await this.cleanupObservedTransientMessages(active);
3245
+ await this.cleanupTransientProgressMessages(active);
3238
3246
  await this.finalizeUserInputsForTurn(active, active.interruptRequested ? 'interrupted' : 'resolved');
3239
3247
  await this.maybeSendPlanImplementationPrompt(active);
3240
3248
  this.markQueuedTurnCompleted(active);
@@ -3481,21 +3489,25 @@ export class BridgeSessionCore {
3481
3489
  segment.messages = [];
3482
3490
  }
3483
3491
  }
3484
- async cleanupObservedTransientMessages(active) {
3485
- if (!active.isObserved || !this.hasObservedPersistentReply(active)) {
3492
+ async cleanupTransientProgressMessages(active) {
3493
+ if (!this.hasObservedPersistentReply(active)) {
3486
3494
  return;
3487
3495
  }
3488
3496
  const messageIds = new Set();
3489
- for (const segment of active.segments) {
3490
- if (segment.outputKind === 'final_answer') {
3491
- continue;
3492
- }
3493
- for (const message of segment.messages) {
3494
- messageIds.add(message.messageId);
3497
+ if (active.isObserved) {
3498
+ for (const segment of active.segments) {
3499
+ if (segment.outputKind === 'final_answer') {
3500
+ continue;
3501
+ }
3502
+ for (const message of segment.messages) {
3503
+ messageIds.add(message.messageId);
3504
+ }
3495
3505
  }
3496
3506
  }
3497
- for (const messageId of active.archivedMessageIds) {
3498
- messageIds.add(messageId);
3507
+ if (this.config.telegramDeleteToolDetailsAfterFinal) {
3508
+ for (const messageId of active.archivedMessageIds) {
3509
+ messageIds.add(messageId);
3510
+ }
3499
3511
  }
3500
3512
  for (const messageId of messageIds) {
3501
3513
  try {
@@ -3503,7 +3515,7 @@ export class BridgeSessionCore {
3503
3515
  }
3504
3516
  catch (error) {
3505
3517
  if (!isTelegramMessageGone(error)) {
3506
- this.logger.warn('telegram.observed_cleanup_delete_failed', {
3518
+ this.logger.warn('telegram.transient_progress_cleanup_delete_failed', {
3507
3519
  error: String(error),
3508
3520
  turnId: active.turnId,
3509
3521
  messageId,
@@ -8080,7 +8092,7 @@ export class BridgeSessionCore {
8080
8092
  else {
8081
8093
  messageId = await this.sendMessage(active.scopeId, content.text);
8082
8094
  }
8083
- if (active.isObserved && messageId !== null) {
8095
+ if (messageId !== null) {
8084
8096
  active.archivedMessageIds.push(messageId);
8085
8097
  }
8086
8098
  }
@@ -8098,9 +8110,7 @@ export class BridgeSessionCore {
8098
8110
  else {
8099
8111
  await this.editMessage(active.scopeId, active.previewMessageId, content.text, []);
8100
8112
  }
8101
- if (active.isObserved) {
8102
- active.archivedMessageIds.push(active.previewMessageId);
8103
- }
8113
+ active.archivedMessageIds.push(active.previewMessageId);
8104
8114
  }
8105
8115
  catch (error) {
8106
8116
  if (isTelegramMessageGone(error)) {
package/dist/main.js CHANGED
@@ -17,6 +17,7 @@ import { buildFoxclawSystemdUnitText, buildSystemdRestartHelperArgs, cgroupConta
17
17
  import { clearPendingClusterUpdateBroadcast, createSelfUpdateRuntime, inferPnpmHomeFromEntryPoint, performSelfUpdate, readPendingClusterUpdateBroadcast, readSelfUpdateStatus, writeSelfUpdateStatus, } from './update.js';
18
18
  import { TELEGRAM_VOICE_MAX_BYTES, TELEGRAM_VOICE_SUPPORTED_EXTENSIONS, telegramVoiceContentType, } from './voice/files.js';
19
19
  import { inferTelegramBotId, resolveTelegramVoiceTarget } from './voice/target.js';
20
+ import { shouldShowRuntimeLastUpdate } from './update_status.js';
20
21
  const rawCommand = process.argv[2];
21
22
  const command = rawCommand || 'serve';
22
23
  loadEnv();
@@ -396,8 +397,9 @@ function formatRuntimeStatusSummary(status) {
396
397
  if (status.authProactiveRefresh) {
397
398
  lines.push(`Auth refresh: ${status.authProactiveRefresh.state}${status.authProactiveRefresh.finishedAt ? ` ${formatAge(status.authProactiveRefresh.finishedAt)}` : ''}`);
398
399
  }
399
- if (status.lastUpdate) {
400
- lines.push(`Last update: ${status.lastUpdate.fromVersion} -> ${status.lastUpdate.toVersion ?? 'unknown'} ${formatAge(status.lastUpdate.updatedAt)}`);
400
+ if (shouldShowRuntimeLastUpdate(status, readPackageVersion())) {
401
+ const lastUpdate = status.lastUpdate;
402
+ lines.push(`Last update: ${lastUpdate.fromVersion} -> ${lastUpdate.toVersion ?? 'unknown'} ${formatAge(lastUpdate.updatedAt)}`);
401
403
  }
402
404
  if (status.lastError) {
403
405
  lines.push(`Last error: ${truncateStatusLine(status.lastError, 160)}`);
@@ -627,6 +629,7 @@ async function runServeCli() {
627
629
  authSyncTest: () => authSync?.testPeers() ?? Promise.resolve({ sent: 0, replied: 0, missing: [] }),
628
630
  statusUpdated: () => writeAggregateStatus(),
629
631
  getServiceStatus: async () => ({
632
+ currentVersion: readPackageVersion(),
630
633
  bots: await Promise.all(runtimes.map(async (runtime) => {
631
634
  const status = runtime.core.getRuntimeStatus();
632
635
  return {
@@ -0,0 +1,11 @@
1
+ type RuntimeLastUpdateStatus = {
2
+ userAgent?: string | null;
3
+ currentVersion?: string | null;
4
+ lastUpdate?: {
5
+ state: string;
6
+ toVersion: string | null;
7
+ } | null;
8
+ };
9
+ export declare function shouldShowRuntimeLastUpdate(status: RuntimeLastUpdateStatus, currentVersion?: string | null): boolean;
10
+ export declare function extractFoxClawVersionFromUserAgent(userAgent: string | null | undefined): string | null;
11
+ export {};
@@ -0,0 +1,18 @@
1
+ export function shouldShowRuntimeLastUpdate(status, currentVersion = null) {
2
+ const update = status.lastUpdate;
3
+ if (!update) {
4
+ return false;
5
+ }
6
+ if (update.state !== 'succeeded') {
7
+ return true;
8
+ }
9
+ const version = currentVersion?.trim() || status.currentVersion?.trim() || extractFoxClawVersionFromUserAgent(status.userAgent);
10
+ return Boolean(version && update.toVersion === version);
11
+ }
12
+ export function extractFoxClawVersionFromUserAgent(userAgent) {
13
+ if (!userAgent) {
14
+ return null;
15
+ }
16
+ const match = userAgent.match(/\(foxclaw;\s*([^)]+)\)/i);
17
+ return match?.[1]?.trim() || null;
18
+ }
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'] });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.5.54",
3
+ "version": "0.5.56",
4
4
  "description": "Foxden local execution claw for controlling Codex from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",