cc-claw 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -3
  2. package/dist/cli.js +436 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ Send text, voice, photos, documents, or videos. Switch backends with `/claude`,
15
15
  - **Agent templates** — Define reusable agent personas as markdown files (`~/.cc-claw/agents/*.md`). Security reviewers, content writers, researchers — spawn them by name.
16
16
  - **Scheduling** — Cron jobs, one-shot tasks, and intervals. Per-job backend, model, and delivery (Telegram, webhook, or silent).
17
17
  - **Memory** — Hybrid vector + keyword search. The agent remembers what you tell it across sessions, with salience decay.
18
- - **Voice** — Send voice messages, get voice replies. Groq Whisper for transcription, ElevenLabs for synthesis.
18
+ - **Voice** — Send voice messages, get voice replies. Groq Whisper for transcription. Choose from ElevenLabs, Grok (xAI), or macOS for synthesis.
19
19
  - **50+ CLI commands** — Full system management from terminal. Every command supports `--json` for scripting.
20
20
  - **MCP support** — Model Context Protocol servers extend the agent with external tools and data.
21
21
  - **Cross-channel awareness** — Actions from CLI are visible to Telegram and vice versa via the activity log.
@@ -166,6 +166,8 @@ Read commands work offline (direct DB access). Write commands require the daemon
166
166
  | `/permissions` | Permission mode (yolo/safe/readonly/plan) |
167
167
  | `/tools` | Configure allowed tools |
168
168
  | `/voice` | Toggle voice replies |
169
+ | `/voice_config` | Configure voice provider and voice |
170
+ | `/response_style` | Set the AI response style (concise/normal/detailed) |
169
171
  | `/verbose` | Tool visibility level |
170
172
  | `/limits` | Usage limits per backend |
171
173
 
@@ -186,7 +188,7 @@ Read commands work offline (direct DB access). Write commands require the daemon
186
188
  ```
187
189
  Telegram → TelegramChannel → router (handleMessage)
188
190
  ├── command → handleCommand
189
- ├── voice → Groq Whisper STT → askAgent
191
+ ├── voice → Groq Whisper STT → askAgent → TTS (ElevenLabs/Grok/macOS)
190
192
  ├── photo/document/video → handleMedia → askAgent
191
193
  └── text → askAgent → sendResponse
192
194
  └── [SEND_FILE:/path] → channel.sendFile
@@ -251,7 +253,8 @@ Set in `~/.cc-claw/.env` (created by `cc-claw setup`):
251
253
  | `CLOUD_ML_REGION` | For Claude | e.g., `us-east5` |
252
254
  | `ANTHROPIC_VERTEX_PROJECT_ID` | For Claude | GCP project ID |
253
255
  | `GROQ_API_KEY` | No | Voice transcription |
254
- | `ELEVENLABS_API_KEY` | No | Voice replies |
256
+ | `ELEVENLABS_API_KEY` | No | Voice replies (ElevenLabs) |
257
+ | `XAI_API_KEY` | No | Voice replies (Grok/xAI) |
255
258
  | `GEMINI_API_KEY` | No | Video analysis |
256
259
  | `DASHBOARD_ENABLED` | No | `1` for web dashboard on port 3141 |
257
260
  | `EMBEDDING_PROVIDER` | No | `ollama` (default), `gemini`, `openai`, `off` |
package/dist/cli.js CHANGED
@@ -48,7 +48,7 @@ var VERSION;
48
48
  var init_version = __esm({
49
49
  "src/version.ts"() {
50
50
  "use strict";
51
- VERSION = true ? "0.4.4" : (() => {
51
+ VERSION = true ? "0.4.5" : (() => {
52
52
  try {
53
53
  return JSON.parse(readFileSync(join2(process.cwd(), "package.json"), "utf-8")).version ?? "unknown";
54
54
  } catch {
@@ -968,6 +968,7 @@ __export(store_exports3, {
968
968
  getRecentBookmarks: () => getRecentBookmarks,
969
969
  getRecentMemories: () => getRecentMemories,
970
970
  getRecentMessageLog: () => getRecentMessageLog,
971
+ getResponseStyle: () => getResponseStyle,
971
972
  getSessionId: () => getSessionId,
972
973
  getSessionStartedAt: () => getSessionStartedAt,
973
974
  getSessionSummariesWithoutEmbeddings: () => getSessionSummariesWithoutEmbeddings,
@@ -1009,6 +1010,7 @@ __export(store_exports3, {
1009
1010
  setHeartbeatConfig: () => setHeartbeatConfig,
1010
1011
  setMode: () => setMode,
1011
1012
  setModel: () => setModel,
1013
+ setResponseStyle: () => setResponseStyle,
1012
1014
  setSessionId: () => setSessionId,
1013
1015
  setSessionStartedAt: () => setSessionStartedAt,
1014
1016
  setSummarizer: () => setSummarizer,
@@ -1358,9 +1360,19 @@ function initDatabase() {
1358
1360
  db.exec(`
1359
1361
  CREATE TABLE IF NOT EXISTS chat_voice (
1360
1362
  chat_id TEXT PRIMARY KEY,
1361
- enabled INTEGER NOT NULL DEFAULT 0
1363
+ enabled INTEGER NOT NULL DEFAULT 0,
1364
+ provider TEXT DEFAULT 'elevenlabs',
1365
+ voice_id TEXT
1362
1366
  );
1363
1367
  `);
1368
+ try {
1369
+ db.exec(`ALTER TABLE chat_voice ADD COLUMN provider TEXT DEFAULT 'elevenlabs'`);
1370
+ } catch {
1371
+ }
1372
+ try {
1373
+ db.exec(`ALTER TABLE chat_voice ADD COLUMN voice_id TEXT`);
1374
+ } catch {
1375
+ }
1364
1376
  db.exec(`
1365
1377
  CREATE TABLE IF NOT EXISTS backend_limits (
1366
1378
  backend TEXT NOT NULL,
@@ -1468,6 +1480,12 @@ function initDatabase() {
1468
1480
  initMcpTables(db);
1469
1481
  initActivityTable(db);
1470
1482
  initIdentityTables(db);
1483
+ db.exec(`
1484
+ CREATE TABLE IF NOT EXISTS chat_response_style (
1485
+ chat_id TEXT PRIMARY KEY,
1486
+ style TEXT NOT NULL DEFAULT 'normal'
1487
+ );
1488
+ `);
1471
1489
  }
1472
1490
  function getDb() {
1473
1491
  return db;
@@ -1600,6 +1618,17 @@ function applySalienceDecay() {
1600
1618
  db.prepare("DELETE FROM session_summaries WHERE salience < 0.1").run();
1601
1619
  db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_salience_decay', ?)").run(now);
1602
1620
  }
1621
+ function getResponseStyle(chatId) {
1622
+ const row = db.prepare("SELECT style FROM chat_response_style WHERE chat_id = ?").get(chatId);
1623
+ return row?.style ?? "normal";
1624
+ }
1625
+ function setResponseStyle(chatId, style) {
1626
+ db.prepare(`
1627
+ INSERT INTO chat_response_style (chat_id, style)
1628
+ VALUES (?, ?)
1629
+ ON CONFLICT(chat_id) DO UPDATE SET style = excluded.style
1630
+ `).run(chatId, style);
1631
+ }
1603
1632
  function getSessionId(chatId) {
1604
1633
  const row = db.prepare(
1605
1634
  "SELECT session_id FROM sessions WHERE chat_id = ?"
@@ -3219,7 +3248,7 @@ var init_inject = __esm({
3219
3248
  });
3220
3249
 
3221
3250
  // src/bootstrap/defaults.ts
3222
- var DEFAULT_SOUL, DEFAULT_USER;
3251
+ var DEFAULT_SOUL, DEFAULT_USER, DEFAULT_EXPERTISE;
3223
3252
  var init_defaults = __esm({
3224
3253
  "src/bootstrap/defaults.ts"() {
3225
3254
  "use strict";
@@ -3256,6 +3285,40 @@ This file is auto-generated. Use /setup-profile to customize, or edit directly.
3256
3285
  - **Timezone**: UTC
3257
3286
  - **Communication style**: concise
3258
3287
  - **Primary use**: general assistant
3288
+ `;
3289
+ DEFAULT_EXPERTISE = `# CC-Claw System Expertise
3290
+
3291
+ You are an expert user and operator of the CC-Claw architecture. Because you are reading this file, the user has likely asked you about a system feature, how the bot operates, its capabilities, or its internal architecture.
3292
+
3293
+ ## 1. Core Architecture & Philosophy
3294
+ - **Multi-Backend System**: You are decoupled from any specific LLM. The user can seamlessly hot-swap your "brain" between Claude (Anthropic), Gemini (Google), and Codex (OpenAI) at any time. You maintain persistent, unified memory across all of them.
3295
+ - **The Daemon**: CC-Claw runs a background daemon (\`cc-claw start\`, \`cc-claw stop\`) that manages the HTTP API, Webhooks, and the Cron Scheduler.
3296
+ - **Database**: All state is stored locally in SQLite (\`~/.cc-claw/data/cc-claw.db\`), ensuring total privacy and fast local access for memory, jobs, and orchestration.
3297
+
3298
+ ## 2. Identity & Context (The "Brain")
3299
+ - **SOUL & USER**: Your personality is defined in \`~/.cc-claw/workspace/SOUL.md\`, and the user's profile is in \`USER.md\`. These are the single source of truth for your behavior.
3300
+ - **On-Demand Context**: The \`context/\` directory holds files like this one. They are injected into your prompt only when triggered by relevant semantic keywords, keeping your context window lean.
3301
+ - **Proactive Memory**: You can autonomously save user facts and preferences to the database by writing \`[UPDATE_USER:key=value]\` in your replies.
3302
+
3303
+ ## 3. Advanced Agent Orchestration
3304
+ - **Sub-Agents**: You are not limited to sequential replies. If a task requires heavy research or coding, you can spawn parallel worker agents using your \`spawn_agent\` MCP tool.
3305
+ - **Supervision**: You manage sub-agents by reading their messages via \`read_inbox\` and sharing data on the orchestration whiteboard via \`set_state\` / \`get_state\`.
3306
+
3307
+ ## 4. Scheduling & Cron (Autonomous Execution)
3308
+ - **Job Management**: You manage scheduled tasks natively. Jobs can route on cron expressions, "every 4h", or "at 09:00".
3309
+ - **Dry Run & Webhooks**: Jobs aren't just for Telegram messages. Using \`--delivery dry_run\`, you can test jobs internally (output is logged to the database but not spammed to chat). You can also route AI output directly to external APIs using \`--delivery webhook\`.
3310
+ - **Auditing**: Every cron execution and its output is permanently logged in the database's \`message_log\` for verifiable auditing.
3311
+
3312
+ ## 5. System Controls & Modes
3313
+ - **Permission Modes**: Command execution is gated. \`safe\` mode requires user approval for mutations, \`yolo\` auto-runs everything without asking, and \`plan\` is strictly read-only for drafting proposals.
3314
+ - **Global Settings**: The user controls your behavior system-wide using commands like \`/response_style\` (concise, normal, detailed) to enforce verbosity constraints, and \`/voice_config\` to select Text-to-Speech voices from premium providers like ElevenLabs, Grok (xAI), or local macOS.
3315
+
3316
+ ## 6. Integrations & MCP
3317
+ - **Skills**: You can load specific workflows from the \`~/.cc-claw/workspace/skills/\` directory.
3318
+ - **Files**: You can send physical files to the user across Telegram by simply writing \`[SEND_FILE:/absolute/path/to/file]\` in your response.
3319
+ - **MCP Ecosystem**: You are deeply natively integrated with Model Context Protocol (MCP) servers (like Perplexity, NotebookLM, Context7) granting you immense external reach.
3320
+
3321
+ If the user asks *how* to do something with CC-Claw, use this expertise to suggest the most native, idiomatic approach available in the architecture.
3259
3322
  `;
3260
3323
  }
3261
3324
  });
@@ -3282,6 +3345,11 @@ function bootstrapWorkspaceFiles() {
3282
3345
  mkdirSync(CONTEXT_DIR, { recursive: true });
3283
3346
  log("[bootstrap] Created context/ directory");
3284
3347
  }
3348
+ const expertisePath = join3(CONTEXT_DIR, "cc-claw-expertise.md");
3349
+ if (!existsSync4(expertisePath)) {
3350
+ writeFileSync(expertisePath, DEFAULT_EXPERTISE, "utf-8");
3351
+ log("[bootstrap] Created default context/cc-claw-expertise.md");
3352
+ }
3285
3353
  syncNativeCliFiles();
3286
3354
  }
3287
3355
  function syncNativeCliFiles() {
@@ -3399,12 +3467,19 @@ function searchContext(userMessage) {
3399
3467
  }
3400
3468
  return null;
3401
3469
  }
3402
- async function assembleBootstrapPrompt(userMessage, tier = "full", chatId, permMode) {
3470
+ async function assembleBootstrapPrompt(userMessage, tier = "full", chatId, permMode, responseStyle) {
3403
3471
  const sections = [];
3404
3472
  syncNativeCliFiles();
3405
3473
  if (permMode && permMode !== "yolo") {
3406
3474
  sections.push(buildPermissionNotice(permMode));
3407
3475
  }
3476
+ if (responseStyle) {
3477
+ if (responseStyle === "concise") {
3478
+ sections.push("[Response Style]\nYou must be as concise and direct as possible. Avoid unnecessary verbosity, pleasantries, or long explanations.");
3479
+ } else if (responseStyle === "detailed") {
3480
+ sections.push("[Response Style]\nYou should be detailed and thorough in your responses. Explain concepts fully and provide comprehensive answers.");
3481
+ }
3482
+ }
3408
3483
  if (tier === "full") {
3409
3484
  const ctx = searchContext(userMessage);
3410
3485
  if (ctx) {
@@ -5457,7 +5532,7 @@ function startDashboard() {
5457
5532
  return jsonResponse(res, { error: "message and chatId required" }, 400);
5458
5533
  }
5459
5534
  const { askAgent: askAgent2 } = await Promise.resolve().then(() => (init_agent(), agent_exports));
5460
- const { getMode: getMode2, getCwd: getCwd2, getModel: getModel2, addUsage: addUsage2, getBackend: getBackend2 } = await Promise.resolve().then(() => (init_store4(), store_exports3));
5535
+ const { getMode: getMode2, getCwd: getCwd2, getModel: getModel3, addUsage: addUsage2, getBackend: getBackend2 } = await Promise.resolve().then(() => (init_store4(), store_exports3));
5461
5536
  const { getAdapterForChat: getAdapterForChat2 } = await Promise.resolve().then(() => (init_backends(), backends_exports));
5462
5537
  const chatId = body.chatId;
5463
5538
  const PERM_LEVEL = { plan: 0, safe: 1, yolo: 2 };
@@ -5465,7 +5540,7 @@ function startDashboard() {
5465
5540
  const requestedMode = body.mode ?? storedMode;
5466
5541
  const mode = (PERM_LEVEL[requestedMode] ?? 2) <= (PERM_LEVEL[storedMode] ?? 2) ? requestedMode : storedMode;
5467
5542
  const cwd = body.cwd ?? getCwd2(chatId);
5468
- const model2 = body.model ?? getModel2(chatId) ?? (() => {
5543
+ const model2 = body.model ?? getModel3(chatId) ?? (() => {
5469
5544
  try {
5470
5545
  return getAdapterForChat2(chatId).defaultModel;
5471
5546
  } catch {
@@ -5671,7 +5746,7 @@ data: ${JSON.stringify(data)}
5671
5746
  if (url.pathname === "/api/config/set" && req.method === "POST") {
5672
5747
  try {
5673
5748
  const body = JSON.parse(await readBody(req));
5674
- const validKeys = ["backend", "model", "thinking", "summarizer", "mode", "verbose", "cwd", "voice"];
5749
+ const validKeys = ["backend", "model", "thinking", "summarizer", "mode", "verbose", "cwd", "voice", "response-style"];
5675
5750
  if (!validKeys.includes(body.key)) {
5676
5751
  return jsonResponse(res, { error: `Invalid config key. Valid: ${validKeys.join(", ")}` }, 400);
5677
5752
  }
@@ -5689,6 +5764,9 @@ data: ${JSON.stringify(data)}
5689
5764
  } else if (body.key === "voice") {
5690
5765
  const db3 = getDb();
5691
5766
  db3.prepare("INSERT OR REPLACE INTO chat_voice (chat_id, enabled) VALUES (?, ?)").run(body.chatId, body.value === "on" || body.value === "1" ? 1 : 0);
5767
+ } else if (body.key === "response-style") {
5768
+ const { setResponseStyle: setResponseStyle2 } = await Promise.resolve().then(() => (init_store4(), store_exports3));
5769
+ setResponseStyle2(body.chatId, body.value);
5692
5770
  } else if (body.key === "backend") {
5693
5771
  const { setBackend: setBackend2, clearSession: clearSession2, clearModel: clearModel2, clearThinkingLevel: clearThinkingLevel2 } = await Promise.resolve().then(() => (init_store4(), store_exports3));
5694
5772
  clearSession2(body.chatId);
@@ -6116,10 +6194,11 @@ async function askAgentImpl(chatId, userMessage, opts) {
6116
6194
  const { cwd, onStream, model: model2, backend: backend2, permMode, onToolAction, bootstrapTier, timeoutMs } = opts ?? {};
6117
6195
  const adapter = backend2 ? getAdapter(backend2) : getAdapterForChat(chatId);
6118
6196
  const mode = permMode ?? getMode(chatId);
6197
+ const responseStyle = getResponseStyle(chatId);
6119
6198
  const thinkingLevel = getThinkingLevel(chatId);
6120
6199
  const resolvedCwd = cwd ?? WORKSPACE_PATH;
6121
6200
  const tier = bootstrapTier ?? "full";
6122
- const fullPrompt = await assembleBootstrapPrompt(userMessage, tier, chatId, mode);
6201
+ const fullPrompt = await assembleBootstrapPrompt(userMessage, tier, chatId, mode, responseStyle);
6123
6202
  const existingSessionId = getSessionId(chatId);
6124
6203
  const allowedTools = getEnabledTools(chatId);
6125
6204
  const mcpConfigPath = getMcpConfigPath(chatId);
@@ -6280,30 +6359,40 @@ function parseTelegramTarget(target) {
6280
6359
  async function deliverJobOutput(job, responseText) {
6281
6360
  if (job.deliveryMode === "none") {
6282
6361
  log(`[delivery] Job #${job.id}: delivery=none, skipping`);
6283
- return;
6362
+ return true;
6363
+ }
6364
+ if (job.deliveryMode === "dry_run") {
6365
+ log(`[delivery] Job #${job.id}: DRY RUN \u2014 output (${responseText.length} chars):
6366
+ ${responseText.slice(0, 500)}`);
6367
+ appendToLog(job.chatId, `[cron:#${job.id}:dry_run] ${job.description}`, responseText, job.backend ?? "claude", job.model, null);
6368
+ return true;
6284
6369
  }
6285
6370
  try {
6286
6371
  if (job.deliveryMode === "webhook") {
6287
6372
  await deliverWebhook(job, responseText);
6288
- return;
6373
+ appendToLog(job.chatId, `[cron:#${job.id}] ${job.description}`, responseText, job.backend ?? "claude", job.model, null);
6374
+ return true;
6289
6375
  }
6290
6376
  const channelName = job.channel ?? "telegram";
6291
6377
  const channel = registry?.get(channelName);
6292
6378
  if (!channel) {
6293
6379
  error(`[delivery] Job #${job.id}: channel "${channelName}" not found`);
6294
- return;
6380
+ return false;
6295
6381
  }
6296
6382
  const targetChatId = job.target ?? job.chatId;
6297
6383
  const cleanText = await processFileSends(targetChatId, channel, responseText);
6298
- if (!cleanText) return;
6384
+ if (!cleanText) return true;
6299
6385
  if (channelName === "telegram") {
6300
6386
  const parsed = parseTelegramTarget(targetChatId);
6301
6387
  await channel.sendText(parsed.chatId, cleanText);
6302
6388
  } else {
6303
6389
  await channel.sendText(targetChatId, cleanText);
6304
6390
  }
6391
+ appendToLog(job.chatId, `[cron:#${job.id}] ${job.description}`, cleanText, job.backend ?? "claude", job.model, null);
6392
+ return true;
6305
6393
  } catch (err) {
6306
6394
  error(`[delivery] Job #${job.id} delivery failed (non-fatal): ${errorMessage(err)}`);
6395
+ return false;
6307
6396
  }
6308
6397
  }
6309
6398
  async function deliverWebhook(job, responseText) {
@@ -6362,6 +6451,7 @@ var init_delivery = __esm({
6362
6451
  "src/scheduler/delivery.ts"() {
6363
6452
  "use strict";
6364
6453
  init_log();
6454
+ init_session_log();
6365
6455
  registry = null;
6366
6456
  BLOCKED_PATH_PATTERNS = [
6367
6457
  /\/\.ssh\//,
@@ -6691,18 +6781,22 @@ async function executeJob(job) {
6691
6781
  return;
6692
6782
  }
6693
6783
  const response = await runWithRetry(job, resolvedModel, runId, t0);
6694
- completeJobRun(runId, "success", {
6695
- usageInput: response.usage?.input,
6696
- usageOutput: response.usage?.output,
6697
- cacheRead: response.usage?.cacheRead,
6698
- durationMs: Date.now() - t0
6699
- });
6784
+ const isEmpty = !response.text || response.text.trim().length === 0 || /^\(No response from/i.test(response.text);
6785
+ const contentStatus = isEmpty ? "no_content" : "success";
6786
+ const durationMs = Date.now() - t0;
6700
6787
  updateJobLastRun(job.id, (/* @__PURE__ */ new Date()).toISOString());
6701
6788
  resetJobFailures(job.id);
6702
6789
  if (response.usage) {
6703
6790
  addUsage(job.chatId, response.usage.input, response.usage.output, response.usage.cacheRead, resolvedModel);
6704
6791
  }
6705
- await deliverJobOutput(job, response.text);
6792
+ const delivered = await deliverJobOutput(job, response.text);
6793
+ const finalStatus = !delivered && contentStatus === "success" ? "delivery_failed" : contentStatus;
6794
+ completeJobRun(runId, finalStatus, {
6795
+ usageInput: response.usage?.input,
6796
+ usageOutput: response.usage?.output,
6797
+ cacheRead: response.usage?.cacheRead,
6798
+ durationMs
6799
+ });
6706
6800
  } catch (err) {
6707
6801
  const durationMs = Date.now() - t0;
6708
6802
  const errorClass = classifyError(err);
@@ -6788,7 +6882,7 @@ function resolveJobModel(job) {
6788
6882
  const backendId = resolveJobBackendId(job);
6789
6883
  try {
6790
6884
  const adapter = getAdapter(backendId);
6791
- return getModel(job.chatId) ?? adapter.defaultModel;
6885
+ return adapter.defaultModel;
6792
6886
  } catch {
6793
6887
  return "claude-sonnet-4-6";
6794
6888
  }
@@ -7445,6 +7539,8 @@ var init_telegram2 = __esm({
7445
7539
  // Skills & profile
7446
7540
  { command: "skills", description: "List and invoke skills" },
7447
7541
  { command: "voice", description: "Toggle voice responses" },
7542
+ { command: "voice_config", description: "Configure voice provider and voice" },
7543
+ { command: "response_style", description: "Set the AI response style (concise/normal/detailed)" },
7448
7544
  { command: "imagine", description: "Generate an image from a prompt" },
7449
7545
  { command: "heartbeat", description: "Configure proactive heartbeat" },
7450
7546
  { command: "chats", description: "Manage multi-chat aliases" }
@@ -8458,6 +8554,23 @@ function toggleVoice(chatId) {
8458
8554
  `).run(chatId, newState, newState);
8459
8555
  return newState === 1;
8460
8556
  }
8557
+ function getVoiceConfig(chatId) {
8558
+ const db3 = getDb();
8559
+ const row = db3.prepare("SELECT enabled, provider, voice_id FROM chat_voice WHERE chat_id = ?").get(chatId);
8560
+ return {
8561
+ enabled: row?.enabled === 1,
8562
+ provider: row?.provider ?? "elevenlabs",
8563
+ voiceId: row?.voice_id ?? null
8564
+ };
8565
+ }
8566
+ function setVoiceProvider(chatId, provider, voiceId) {
8567
+ const db3 = getDb();
8568
+ db3.prepare(`
8569
+ INSERT INTO chat_voice (chat_id, enabled, provider, voice_id)
8570
+ VALUES (?, 1, ?, ?)
8571
+ ON CONFLICT(chat_id) DO UPDATE SET provider = ?, voice_id = ?, enabled = 1
8572
+ `).run(chatId, provider, voiceId, provider, voiceId);
8573
+ }
8461
8574
  async function transcribeAudio(audioBuffer, mimeType = "audio/ogg") {
8462
8575
  const GROQ_API_KEY = process.env.GROQ_API_KEY;
8463
8576
  if (!GROQ_API_KEY) return null;
@@ -8476,25 +8589,48 @@ async function transcribeAudio(audioBuffer, mimeType = "audio/ogg") {
8476
8589
  }
8477
8590
  return (await response.text()).trim();
8478
8591
  }
8479
- async function synthesizeSpeech(text) {
8592
+ async function synthesizeSpeech(text, chatId) {
8480
8593
  const ttsText = text.length > 4e3 ? text.slice(0, 4e3) + "..." : text;
8481
- const elevenLabsKey = process.env.ELEVENLABS_API_KEY;
8482
- if (elevenLabsKey) {
8594
+ const config2 = chatId ? getVoiceConfig(chatId) : null;
8595
+ const provider = config2?.provider ?? "elevenlabs";
8596
+ if (provider === "grok") {
8597
+ const xaiKey = process.env.XAI_API_KEY;
8598
+ if (xaiKey) {
8599
+ try {
8600
+ return await grokTts(ttsText, xaiKey, config2?.voiceId ?? "eve");
8601
+ } catch (err) {
8602
+ error("[tts] Grok failed:", err);
8603
+ }
8604
+ } else {
8605
+ log("[tts] Grok selected but XAI_API_KEY not set, falling back");
8606
+ }
8607
+ }
8608
+ if (provider === "macos") {
8483
8609
  try {
8484
- return await elevenLabsTts(ttsText, elevenLabsKey);
8610
+ return await macOsTts(ttsText, config2?.voiceId ?? "Samantha");
8485
8611
  } catch (err) {
8486
- error("[tts] ElevenLabs failed:", err);
8612
+ error("[tts] macOS TTS failed:", err);
8613
+ }
8614
+ }
8615
+ if (provider === "elevenlabs" || provider === "grok") {
8616
+ const elevenLabsKey = process.env.ELEVENLABS_API_KEY;
8617
+ if (elevenLabsKey) {
8618
+ try {
8619
+ const voiceId = provider === "elevenlabs" && config2?.voiceId ? config2.voiceId : process.env.ELEVENLABS_VOICE_ID ?? "21m00Tcm4TlvDq8ikWAM";
8620
+ return await elevenLabsTts(ttsText, elevenLabsKey, voiceId);
8621
+ } catch (err) {
8622
+ error("[tts] ElevenLabs failed:", err);
8623
+ }
8487
8624
  }
8488
8625
  }
8489
8626
  try {
8490
- return await macOsTts(ttsText);
8627
+ return await macOsTts(ttsText, "Samantha");
8491
8628
  } catch (err) {
8492
8629
  error("[tts] macOS TTS failed:", err);
8493
8630
  }
8494
8631
  return null;
8495
8632
  }
8496
- async function elevenLabsTts(text, apiKey) {
8497
- const voiceId = process.env.ELEVENLABS_VOICE_ID ?? "21m00Tcm4TlvDq8ikWAM";
8633
+ async function elevenLabsTts(text, apiKey, voiceId) {
8498
8634
  const response = await fetch(
8499
8635
  `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
8500
8636
  {
@@ -8515,12 +8651,48 @@ async function elevenLabsTts(text, apiKey) {
8515
8651
  }
8516
8652
  return Buffer.from(await response.arrayBuffer());
8517
8653
  }
8518
- async function macOsTts(text) {
8654
+ async function grokTts(text, apiKey, voiceId) {
8655
+ const response = await fetch("https://api.x.ai/v1/tts", {
8656
+ method: "POST",
8657
+ headers: {
8658
+ "Authorization": `Bearer ${apiKey}`,
8659
+ "Content-Type": "application/json"
8660
+ },
8661
+ body: JSON.stringify({
8662
+ text,
8663
+ voice_id: voiceId,
8664
+ language: "en"
8665
+ })
8666
+ });
8667
+ if (!response.ok) {
8668
+ const errText = await response.text().catch(() => "");
8669
+ throw new Error(`Grok TTS API error: ${response.status} ${errText}`);
8670
+ }
8671
+ const mp3Buffer = Buffer.from(await response.arrayBuffer());
8672
+ return await mp3ToOgg(mp3Buffer);
8673
+ }
8674
+ async function mp3ToOgg(mp3Buffer) {
8675
+ const id = crypto.randomUUID();
8676
+ const tmpMp3 = `/tmp/cc-claw-tts-${id}.mp3`;
8677
+ const tmpOgg = `/tmp/cc-claw-tts-${id}.ogg`;
8678
+ const { writeFile: writeFile4 } = await import("fs/promises");
8679
+ await writeFile4(tmpMp3, mp3Buffer);
8680
+ await execFileAsync2("ffmpeg", ["-y", "-i", tmpMp3, "-c:a", "libopus", "-b:a", "64k", tmpOgg]);
8681
+ const oggBuffer = await readFile4(tmpOgg);
8682
+ unlink(tmpMp3).catch((err) => {
8683
+ error("[tts] cleanup failed:", err);
8684
+ });
8685
+ unlink(tmpOgg).catch((err) => {
8686
+ error("[tts] cleanup failed:", err);
8687
+ });
8688
+ return oggBuffer;
8689
+ }
8690
+ async function macOsTts(text, voice2 = "Samantha") {
8519
8691
  const id = crypto.randomUUID();
8520
8692
  const tmpAiff = `/tmp/cc-claw-tts-${id}.aiff`;
8521
8693
  const tmpOgg = `/tmp/cc-claw-tts-${id}.ogg`;
8522
8694
  const sanitized = text.replace(/['"\\$`]/g, " ");
8523
- await execFileAsync2("say", ["-o", tmpAiff, sanitized]);
8695
+ await execFileAsync2("say", ["-v", voice2, "-o", tmpAiff, sanitized]);
8524
8696
  await execFileAsync2("ffmpeg", ["-y", "-i", tmpAiff, "-c:a", "libopus", "-b:a", "64k", tmpOgg]);
8525
8697
  const oggBuffer = await readFile4(tmpOgg);
8526
8698
  unlink(tmpAiff).catch((err) => {
@@ -8531,13 +8703,28 @@ async function macOsTts(text) {
8531
8703
  });
8532
8704
  return oggBuffer;
8533
8705
  }
8534
- var execFileAsync2;
8706
+ var execFileAsync2, ELEVENLABS_VOICES, GROK_VOICES, MACOS_VOICES;
8535
8707
  var init_stt = __esm({
8536
8708
  "src/voice/stt.ts"() {
8537
8709
  "use strict";
8538
8710
  init_log();
8539
8711
  init_store4();
8540
8712
  execFileAsync2 = promisify2(execFile2);
8713
+ ELEVENLABS_VOICES = {
8714
+ "21m00Tcm4TlvDq8ikWAM": { name: "Rachel", gender: "F" },
8715
+ "EXAVITQu4vr4xnSDxMaL": { name: "Sarah", gender: "F" },
8716
+ "XB0fDUnXU5powFXDhCwa": { name: "Charlotte", gender: "F" },
8717
+ "pFZP5JQG7iQjIQuC4Bku": { name: "Lily", gender: "F" },
8718
+ "pNInz6obpgDQGcFmaJgB": { name: "Adam", gender: "M" },
8719
+ "nPczCjzI2devNBz1zQrb": { name: "Brian", gender: "M" },
8720
+ "onwK4e9ZLuTAKqWW03F9": { name: "Daniel", gender: "M" },
8721
+ "TxGEqnHWrfWFTfGW9XjX": { name: "Josh", gender: "M" }
8722
+ };
8723
+ GROK_VOICES = ["eve", "ara", "rex", "sal", "leo"];
8724
+ MACOS_VOICES = {
8725
+ "Samantha": { name: "Samantha", gender: "F" },
8726
+ "Albert": { name: "Albert", gender: "M" }
8727
+ };
8541
8728
  }
8542
8729
  });
8543
8730
 
@@ -9506,7 +9693,7 @@ async function handleCommand(msg, channel) {
9506
9693
  case "help":
9507
9694
  await channel.sendText(
9508
9695
  chatId,
9509
- "Hey! I'm CC-Claw \u2014 your personal AI assistant on Telegram.\n\nI use AI coding CLIs (Claude, Gemini, Codex) as my brain. Just send me a message to get started.\n\nCommands:\n/backend [name] - Switch AI backend (or /claude /gemini /codex)\n/model - Switch model for active backend\n/summarizer - Configure session summarization model\n/status - Show session, model, backend, and usage\n/cost - Show estimated API cost (use /cost all for all-time)\n/usage - Show usage per backend with limits\n/limits - Configure usage limits per backend\n/newchat - Start a fresh conversation\n/summarize - Save session to memory (without resetting)\n/summarize all - Summarize all pending sessions (pre-restart)\n/cwd <path> - Set working directory\n/cwd - Show current working directory\n/memory - List stored memories\n/remember <text> - Save a memory\n/forget <keyword> - Remove a memory\n/voice - Toggle voice responses\n/imagine <prompt> - Generate an image (or /image)\n/cron <description> - Schedule a task (or /schedule)\n/cron - List scheduled jobs (or /jobs)\n/cron cancel <id> - Cancel a job\n/cron pause <id> - Pause a job\n/cron resume <id> - Resume a job\n/cron run <id> - Trigger a job now\n/cron runs [id] - View run history\n/cron edit <id> - Edit a job\n/cron health - Scheduler health\n/skills - List skills from all backends\n/skill-install <url> - Install a skill from GitHub\n/setup-profile - Set up your user profile\n/chats - List authorized chats and aliases\n/heartbeat - Proactive awareness (on/off/interval/hours)\n/history - List recent session summaries\n/stop - Cancel the current running task\n/tools - Configure which tools the agent can use\n/permissions - Switch permission mode (yolo/safe/plan)\n/verbose - Tool visibility (off/normal/verbose)\n/agents - List active sub-agents\n/tasks - Show task board for current orchestration\n/stopagent <id> - Cancel a specific sub-agent\n/stopall - Cancel all sub-agents in this chat\n/runners - List registered CLI runners\n/mcps - List registered MCP servers\n/help - Show this message",
9696
+ "Hey! I'm CC-Claw \u2014 your personal AI assistant on Telegram.\n\nI use AI coding CLIs (Claude, Gemini, Codex) as my brain. Just send me a message to get started.\n\nCommands:\n/backend [name] - Switch AI backend (or /claude /gemini /codex)\n/model - Switch model for active backend\n/summarizer - Configure session summarization model\n/status - Show session, model, backend, and usage\n/cost - Show estimated API cost (use /cost all for all-time)\n/usage - Show usage per backend with limits\n/limits - Configure usage limits per backend\n/newchat - Start a fresh conversation\n/summarize - Save session to memory (without resetting)\n/summarize all - Summarize all pending sessions (pre-restart)\n/cwd <path> - Set working directory\n/cwd - Show current working directory\n/memory - List stored memories\n/remember <text> - Save a memory\n/forget <keyword> - Remove a memory\n/voice - Toggle voice responses\n/voice_config - Configure voice provider and voice\n/imagine <prompt> - Generate an image (or /image)\n/cron <description> - Schedule a task (or /schedule)\n/cron - List scheduled jobs (or /jobs)\n/cron cancel <id> - Cancel a job\n/cron pause <id> - Pause a job\n/cron resume <id> - Resume a job\n/cron run <id> - Trigger a job now\n/cron runs [id] - View run history\n/cron edit <id> - Edit a job\n/cron health - Scheduler health\n/skills - List skills from all backends\n/skill-install <url> - Install a skill from GitHub\n/setup-profile - Set up your user profile\n/chats - List authorized chats and aliases\n/heartbeat - Proactive awareness (on/off/interval/hours)\n/history - List recent session summaries\n/stop - Cancel the current running task\n/tools - Configure which tools the agent can use\n/permissions - Switch permission mode (yolo/safe/plan)\n/verbose - Tool visibility (off/normal/verbose)\n/agents - List active sub-agents\n/tasks - Show task board for current orchestration\n/stopagent <id> - Cancel a specific sub-agent\n/stopall - Cancel all sub-agents in this chat\n/runners - List registered CLI runners\n/mcps - List registered MCP servers\n/help - Show this message",
9510
9697
  "plain"
9511
9698
  );
9512
9699
  break;
@@ -10000,12 +10187,37 @@ ${lines.join("\n")}`, "plain");
10000
10187
  break;
10001
10188
  }
10002
10189
  case "voice": {
10003
- const enabled = toggleVoice(chatId);
10004
- await channel.sendText(
10005
- chatId,
10006
- enabled ? "Voice responses enabled." : "Voice responses disabled.",
10007
- "plain"
10008
- );
10190
+ const vcEnabled = isVoiceEnabled(chatId);
10191
+ if (typeof channel.sendKeyboard === "function") {
10192
+ await channel.sendKeyboard(chatId, `\u{1F3A7} Voice responses: ${vcEnabled ? "ON" : "OFF"}`, [
10193
+ [
10194
+ { label: `${vcEnabled ? "" : "\u2713 "}\u{1F507} Off`, data: "voice:off" },
10195
+ { label: `${vcEnabled ? "\u2713 " : ""}\u{1F50A} On`, data: "voice:on" }
10196
+ ]
10197
+ ]);
10198
+ } else {
10199
+ const toggled = toggleVoice(chatId);
10200
+ await channel.sendText(chatId, toggled ? "Voice responses enabled." : "Voice responses disabled.", "plain");
10201
+ }
10202
+ break;
10203
+ }
10204
+ case "voice_config": {
10205
+ await sendVoiceConfigKeyboard(chatId, channel);
10206
+ break;
10207
+ }
10208
+ case "response_style": {
10209
+ const currentStyle = getResponseStyle(chatId);
10210
+ if (typeof channel.sendKeyboard === "function") {
10211
+ await channel.sendKeyboard(chatId, "\u{1F5E3}\uFE0F AI Response Style:", [
10212
+ [
10213
+ { label: `${currentStyle === "concise" ? "\u2713 " : ""}Concise`, data: "style:concise" },
10214
+ { label: `${currentStyle === "normal" ? "\u2713 " : ""}Normal`, data: "style:normal" },
10215
+ { label: `${currentStyle === "detailed" ? "\u2713 " : ""}Detailed`, data: "style:detailed" }
10216
+ ]
10217
+ ]);
10218
+ } else {
10219
+ await channel.sendText(chatId, `Current Response Style: ${currentStyle}`, "plain");
10220
+ }
10009
10221
  break;
10010
10222
  }
10011
10223
  case "imagine":
@@ -10697,24 +10909,42 @@ async function handleMedia(msg, channel) {
10697
10909
  return;
10698
10910
  }
10699
10911
  const videoBuffer = await channel.downloadFile(fileId);
10700
- const geminiPrompt = caption || "Analyze this video and describe what you see in detail.";
10701
- let analysis;
10702
- try {
10703
- const videoMime = msg.metadata?.mimeType ?? msg.mimeType ?? "video/mp4";
10704
- analysis = await analyzeVideo(videoBuffer, geminiPrompt, videoMime);
10705
- } catch (err) {
10706
- analysis = `Video analysis failed: ${errorMessage(err)}`;
10707
- }
10708
- const prompt2 = caption ? `The user sent a video with caption: "${caption}"
10912
+ const videoMime = msg.metadata?.mimeType ?? msg.mimeType ?? "video/mp4";
10913
+ const videoExt = videoMime.split("/")[1]?.replace("quicktime", "mov") || "mp4";
10914
+ const tempVideoPath = `/tmp/cc-claw-video-${Date.now()}.${videoExt}`;
10915
+ await writeFile2(tempVideoPath, videoBuffer);
10916
+ let prompt2;
10917
+ if (wantsVideoAnalysis(caption)) {
10918
+ const geminiPrompt = caption || "Analyze this video and describe what you see in detail.";
10919
+ let analysis;
10920
+ try {
10921
+ analysis = await analyzeVideo(videoBuffer, geminiPrompt, videoMime);
10922
+ } catch (err) {
10923
+ analysis = `Video analysis failed: ${errorMessage(err)}`;
10924
+ }
10925
+ prompt2 = caption ? `The user sent a video with caption: "${caption}"
10709
10926
 
10710
10927
  Gemini video analysis:
10711
10928
  ${analysis}
10712
10929
 
10930
+ The video is also saved at: ${tempVideoPath}
10931
+
10713
10932
  Respond to the user based on this analysis.` : `The user sent a video. Gemini analyzed it:
10714
10933
 
10715
10934
  ${analysis}
10716
10935
 
10936
+ The video is also saved at: ${tempVideoPath}
10937
+
10717
10938
  Summarize this for the user.`;
10939
+ } else {
10940
+ prompt2 = caption ? `The user sent a video with caption: "${caption}"
10941
+
10942
+ The video has been saved to: ${tempVideoPath}
10943
+
10944
+ Respond to their message. Do NOT analyze the video unless they ask you to.` : `The user sent a video. It has been saved to: ${tempVideoPath}
10945
+
10946
+ Acknowledge receipt. Do NOT analyze the video unless they ask you to.`;
10947
+ }
10718
10948
  const vidModel = resolveModel(chatId);
10719
10949
  const vMode = getMode(chatId);
10720
10950
  const vidVerbose = getVerboseLevel(chatId);
@@ -11068,7 +11298,7 @@ async function sendResponse(chatId, channel, text, messageId) {
11068
11298
  if (!cleanText) return;
11069
11299
  if (isVoiceEnabled(chatId)) {
11070
11300
  try {
11071
- const audioBuffer = await synthesizeSpeech(cleanText);
11301
+ const audioBuffer = await synthesizeSpeech(cleanText, chatId);
11072
11302
  if (audioBuffer) {
11073
11303
  await channel.sendVoice(chatId, audioBuffer);
11074
11304
  return;
@@ -11082,6 +11312,70 @@ async function sendResponse(chatId, channel, text, messageId) {
11082
11312
  function isImageExt(ext) {
11083
11313
  return ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(ext);
11084
11314
  }
11315
+ async function sendVoiceConfigKeyboard(chatId, channel) {
11316
+ if (typeof channel.sendKeyboard !== "function") {
11317
+ await channel.sendText(chatId, "Voice configuration requires an interactive channel (Telegram).", "plain");
11318
+ return;
11319
+ }
11320
+ const config2 = getVoiceConfig(chatId);
11321
+ const currentVoiceName = config2.provider === "elevenlabs" ? ELEVENLABS_VOICES[config2.voiceId ?? ""]?.name ?? "Rachel" : config2.provider === "macos" ? MACOS_VOICES[config2.voiceId ?? ""]?.name ?? "Samantha" : config2.voiceId ?? "eve";
11322
+ const providerLabel = config2.provider === "grok" ? "Grok (xAI)" : config2.provider === "macos" ? "macOS" : "ElevenLabs";
11323
+ const header2 = `\u{1F3A7} Voice Configuration
11324
+ Provider: ${providerLabel}
11325
+ Voice: ${currentVoiceName}
11326
+ Status: ${config2.enabled ? "ON" : "OFF"}`;
11327
+ const buttons = [];
11328
+ buttons.push([
11329
+ { label: `${config2.provider === "elevenlabs" ? "\u2713 " : ""}ElevenLabs`, data: "vcfg:p:elevenlabs" },
11330
+ { label: `${config2.provider === "grok" ? "\u2713 " : ""}Grok`, data: "vcfg:p:grok" },
11331
+ { label: `${config2.provider === "macos" ? "\u2713 " : ""}macOS`, data: "vcfg:p:macos" }
11332
+ ]);
11333
+ if (config2.provider === "elevenlabs") {
11334
+ const entries = Object.entries(ELEVENLABS_VOICES);
11335
+ const female = entries.filter(([, v]) => v.gender === "F");
11336
+ const male = entries.filter(([, v]) => v.gender === "M");
11337
+ buttons.push(female.map(([id, v]) => ({
11338
+ label: `${config2.voiceId === id ? "\u2713 " : ""}${v.name}`,
11339
+ data: `vcfg:v:${id}`
11340
+ })));
11341
+ buttons.push(male.map(([id, v]) => ({
11342
+ label: `${config2.voiceId === id ? "\u2713 " : ""}${v.name}`,
11343
+ data: `vcfg:v:${id}`
11344
+ })));
11345
+ } else if (config2.provider === "grok") {
11346
+ buttons.push(GROK_VOICES.map((v) => ({
11347
+ label: `${config2.voiceId === v ? "\u2713 " : ""}${v.charAt(0).toUpperCase() + v.slice(1)}`,
11348
+ data: `vcfg:v:${v}`
11349
+ })));
11350
+ } else {
11351
+ const entries = Object.entries(MACOS_VOICES);
11352
+ buttons.push(entries.map(([id, v]) => ({
11353
+ label: `${config2.voiceId === id ? "\u2713 " : ""}${v.name}`,
11354
+ data: `vcfg:v:${id}`
11355
+ })));
11356
+ }
11357
+ await channel.sendKeyboard(chatId, header2, buttons);
11358
+ }
11359
+ function wantsVideoAnalysis(caption) {
11360
+ if (!caption) return false;
11361
+ const lower = caption.toLowerCase();
11362
+ const patterns = [
11363
+ /\banalyze\b/,
11364
+ /\banalysis\b/,
11365
+ /\bdescribe\b/,
11366
+ /\bwhat('s| is) (in |happening)/,
11367
+ /\btell me (about|what)/,
11368
+ /\bexplain\b/,
11369
+ /\bsummarize\b/,
11370
+ /\bwhat do you see\b/,
11371
+ /\breview\b/,
11372
+ /\bwatch\b/,
11373
+ /\btranscri/,
11374
+ /\bidentif/,
11375
+ /\bwhat happens\b/
11376
+ ];
11377
+ return patterns.some((p) => p.test(lower));
11378
+ }
11085
11379
  async function sendBackendSwitchConfirmation(chatId, target, channel) {
11086
11380
  const current = getBackend(chatId);
11087
11381
  const targetAdapter = getAdapter(target);
@@ -11315,8 +11609,9 @@ ${PERM_MODES[chosen]}`,
11315
11609
  pendingInterrupts.delete(targetChatId);
11316
11610
  stopAgent(targetChatId);
11317
11611
  await channel.sendText(chatId, "\u26A1 Stopping current task and processing your message\u2026", "plain");
11318
- await new Promise((r) => setTimeout(r, 500));
11319
- await handleMessage(pending.msg, pending.channel);
11612
+ bypassBusyCheck.add(targetChatId);
11613
+ handleMessage(pending.msg, pending.channel).catch(() => {
11614
+ });
11320
11615
  } else if (action === "queue" && pending) {
11321
11616
  pendingInterrupts.delete(targetChatId);
11322
11617
  bypassBusyCheck.add(targetChatId);
@@ -11347,6 +11642,60 @@ ${PERM_MODES[chosen]}`,
11347
11642
  } else {
11348
11643
  await channel.sendText(chatId, "Fallback expired. Use /backend to switch manually.", "plain");
11349
11644
  }
11645
+ } else if (data.startsWith("voice:")) {
11646
+ const action = data.slice(6);
11647
+ if (action === "on" || action === "off") {
11648
+ const current = isVoiceEnabled(chatId);
11649
+ const desired = action === "on";
11650
+ if (current !== desired) toggleVoice(chatId);
11651
+ await channel.sendText(chatId, desired ? "\u{1F50A} Voice responses enabled." : "\u{1F507} Voice responses disabled.", "plain");
11652
+ }
11653
+ } else if (data.startsWith("style:")) {
11654
+ const selectedStyle = data.split(":")[1];
11655
+ if (selectedStyle === "concise" || selectedStyle === "normal" || selectedStyle === "detailed") {
11656
+ setResponseStyle(chatId, selectedStyle);
11657
+ if (typeof channel.sendKeyboard === "function") {
11658
+ await channel.sendKeyboard(chatId, "\u{1F5E3}\uFE0F AI Response Style:", [
11659
+ [
11660
+ { label: `${selectedStyle === "concise" ? "\u2713 " : ""}Concise`, data: "style:concise" },
11661
+ { label: `${selectedStyle === "normal" ? "\u2713 " : ""}Normal`, data: "style:normal" },
11662
+ { label: `${selectedStyle === "detailed" ? "\u2713 " : ""}Detailed`, data: "style:detailed" }
11663
+ ]
11664
+ ]);
11665
+ }
11666
+ await channel.sendText(chatId, `Response style set to: ${selectedStyle}`, "plain");
11667
+ }
11668
+ } else if (data.startsWith("vcfg:")) {
11669
+ const parts = data.slice(5).split(":");
11670
+ const action = parts[0];
11671
+ if (action === "p") {
11672
+ const provider = parts[1];
11673
+ if (provider === "elevenlabs" || provider === "grok" || provider === "macos") {
11674
+ if (provider === "grok" && !process.env.XAI_API_KEY) {
11675
+ await channel.sendText(
11676
+ chatId,
11677
+ "\u26A0\uFE0F Grok requires `XAI_API_KEY` to be set.\n\nAdd it to your config:\n```\necho 'XAI_API_KEY=your-key-here' >> ~/.cc-claw/.env\ncc-claw service restart\n```\n\nGet a key at: https://console.x.ai/team/default/api-keys\n\nSetting Grok as your provider \u2014 it will activate once the key is added.",
11678
+ "markdown"
11679
+ );
11680
+ }
11681
+ if (provider === "elevenlabs" && !process.env.ELEVENLABS_API_KEY) {
11682
+ await channel.sendText(
11683
+ chatId,
11684
+ "\u26A0\uFE0F ElevenLabs requires `ELEVENLABS_API_KEY` to be set.\n\nAdd it to your config:\n```\necho 'ELEVENLABS_API_KEY=your-key-here' >> ~/.cc-claw/.env\ncc-claw service restart\n```\n\nGet a key at: https://elevenlabs.io/api\n\nSetting ElevenLabs as your provider \u2014 it will activate once the key is added.",
11685
+ "markdown"
11686
+ );
11687
+ }
11688
+ const defaultVoice = provider === "grok" ? "eve" : provider === "macos" ? "Samantha" : "21m00Tcm4TlvDq8ikWAM";
11689
+ setVoiceProvider(chatId, provider, defaultVoice);
11690
+ await sendVoiceConfigKeyboard(chatId, channel);
11691
+ }
11692
+ } else if (action === "v") {
11693
+ const voiceId = parts.slice(1).join(":");
11694
+ const config2 = getVoiceConfig(chatId);
11695
+ setVoiceProvider(chatId, config2.provider, voiceId);
11696
+ const voiceName = config2.provider === "elevenlabs" ? ELEVENLABS_VOICES[voiceId]?.name ?? voiceId : config2.provider === "macos" ? MACOS_VOICES[voiceId]?.name ?? voiceId : voiceId;
11697
+ await channel.sendText(chatId, `\u2705 Voice set to: ${voiceName}`, "plain");
11698
+ }
11350
11699
  } else if (data.startsWith("skills:page:")) {
11351
11700
  const page = parseInt(data.slice(12), 10);
11352
11701
  const skills2 = await discoverAllSkills();
@@ -12975,7 +13324,7 @@ async function apiGet(path) {
12975
13324
  const req = httpRequest(url, {
12976
13325
  method: "GET",
12977
13326
  headers: token ? { "Authorization": `Bearer ${token}` } : {},
12978
- timeout: 1e4
13327
+ timeout: 3e3
12979
13328
  }, (res) => {
12980
13329
  const chunks = [];
12981
13330
  res.on("data", (c) => chunks.push(c));
@@ -14533,7 +14882,7 @@ var init_config = __esm({
14533
14882
  init_format();
14534
14883
  init_paths();
14535
14884
  init_resolve_chat();
14536
- RUNTIME_KEYS = ["backend", "model", "thinking", "summarizer", "mode", "verbose", "cwd", "voice"];
14885
+ RUNTIME_KEYS = ["backend", "model", "thinking", "summarizer", "mode", "verbose", "cwd", "voice", "response-style"];
14537
14886
  KEY_TABLE_MAP = {
14538
14887
  backend: { table: "chat_backend", col: "backend" },
14539
14888
  model: { table: "chat_model", col: "model" },
@@ -14542,7 +14891,8 @@ var init_config = __esm({
14542
14891
  mode: { table: "chat_mode", col: "mode" },
14543
14892
  verbose: { table: "chat_verbose", col: "level" },
14544
14893
  cwd: { table: "chat_cwd", col: "cwd" },
14545
- voice: { table: "chat_voice", col: "enabled" }
14894
+ voice: { table: "chat_voice", col: "enabled" },
14895
+ "response-style": { table: "chat_response_style", col: "style" }
14546
14896
  };
14547
14897
  ALLOWED_TABLES = new Set(Object.values(KEY_TABLE_MAP).map((v) => v.table));
14548
14898
  ALLOWED_COLS = new Set(Object.values(KEY_TABLE_MAP).map((v) => v.col));
@@ -15827,11 +16177,27 @@ async function setup() {
15827
16177
  env.GROQ_API_KEY = groqKey;
15828
16178
  console.log(green(" Voice transcription (STT) enabled!"));
15829
16179
  console.log("");
15830
- if (await confirm("Enable voice replies? (requires ElevenLabs API key)", false)) {
16180
+ console.log(dim(" Choose a voice reply provider:"));
16181
+ console.log(" 1. ElevenLabs (high-quality, requires API key)");
16182
+ console.log(" 2. Grok / xAI (high-quality, requires API key)");
16183
+ console.log(" 3. macOS (free, uses built-in voices \u2014 Samantha, Albert)");
16184
+ console.log(" 4. Skip voice replies");
16185
+ console.log("");
16186
+ const ttsChoice = await requiredInput("Enter choice (1/2/3/4)", "4");
16187
+ if (ttsChoice === "1") {
15831
16188
  console.log(dim(" Get an ElevenLabs key at: https://elevenlabs.io/api\n"));
15832
16189
  const elevenKey = await requiredInput("ElevenLabs API key", env.ELEVENLABS_API_KEY);
15833
16190
  env.ELEVENLABS_API_KEY = elevenKey;
15834
- console.log(green(" Voice replies (TTS) enabled!"));
16191
+ console.log(green(" Voice replies via ElevenLabs enabled!"));
16192
+ } else if (ttsChoice === "2") {
16193
+ console.log(dim(" Get an xAI key at: https://console.x.ai/team/default/api-keys\n"));
16194
+ const xaiKey = await requiredInput("xAI API key", env.XAI_API_KEY);
16195
+ env.XAI_API_KEY = xaiKey;
16196
+ console.log(green(" Voice replies via Grok enabled!"));
16197
+ console.log(dim(" Use /voice_config in Telegram to select a voice (Eve, Ara, Rex, Sal, Leo)."));
16198
+ } else if (ttsChoice === "3") {
16199
+ console.log(green(" Voice replies via macOS enabled!"));
16200
+ console.log(dim(" Use /voice_config in Telegram to select a voice (Samantha or Albert)."));
15835
16201
  } else {
15836
16202
  console.log(dim(" Voice replies will use macOS text-to-speech as fallback."));
15837
16203
  }
@@ -15870,6 +16236,9 @@ async function setup() {
15870
16236
  if (env.ELEVENLABS_API_KEY) {
15871
16237
  envLines.push(`ELEVENLABS_API_KEY=${env.ELEVENLABS_API_KEY}`);
15872
16238
  }
16239
+ if (env.XAI_API_KEY) {
16240
+ envLines.push(`XAI_API_KEY=${env.XAI_API_KEY}`);
16241
+ }
15873
16242
  }
15874
16243
  if (env.DASHBOARD_ENABLED) {
15875
16244
  envLines.push("", "# Dashboard", `DASHBOARD_ENABLED=${env.DASHBOARD_ENABLED}`);
@@ -16161,6 +16530,18 @@ config.command("set <key> <value>").description("Set a runtime config value (via
16161
16530
  const { configSet: configSet2 } = await Promise.resolve().then(() => (init_config(), config_exports));
16162
16531
  await configSet2(program.opts(), key, value);
16163
16532
  });
16533
+ config.command("response-style [style]").description("Get or set the AI response style (concise/normal/detailed)").action(async (style) => {
16534
+ const { configGet: configGet2, configSet: configSet2 } = await Promise.resolve().then(() => (init_config(), config_exports));
16535
+ if (style) {
16536
+ if (!["concise", "normal", "detailed"].includes(style)) {
16537
+ console.error("Invalid style. Must be concise, normal, or detailed.");
16538
+ process.exit(1);
16539
+ }
16540
+ await configSet2(program.opts(), "response-style", style);
16541
+ } else {
16542
+ await configGet2(program.opts(), "response-style");
16543
+ }
16544
+ });
16164
16545
  config.command("env").description("Print .env path and values (static config, redacted secrets)").action(async () => {
16165
16546
  const { configEnv: configEnv2 } = await Promise.resolve().then(() => (init_config(), config_exports));
16166
16547
  await configEnv2(program.opts());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-claw",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "CC-Claw: Personal AI assistant on Telegram — multi-backend (Claude, Gemini, Codex), sub-agent orchestration, MCP management",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",