twinclaw 1.3.2 → 1.4.1

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.
@@ -1,6 +1,7 @@
1
1
  import { BrowserReferenceError } from '../../services/browser-service.js';
2
2
  import { sendOk, sendError, mapError } from '../shared.js';
3
3
  import { logThought } from '../../utils/logger.js';
4
+ import { getWorkspaceDir } from '../../config/workspace.js';
4
5
  import path from 'node:path';
5
6
  import { isIP } from 'node:net';
6
7
  import { unlink } from 'node:fs/promises';
@@ -150,7 +151,7 @@ export function handleBrowserSnapshot(deps) {
150
151
  await deps.browserService.navigate(validatedUrl.url);
151
152
  await logThought(`[API] Browser navigated to: ${validatedUrl.url}`);
152
153
  }
153
- const screenshotPath = path.resolve('memory', `snapshot_${Date.now()}.png`);
154
+ const screenshotPath = path.join(getWorkspaceDir(), 'memory', `snapshot_${Date.now()}.png`);
154
155
  const fullPage = body.fullPage !== false;
155
156
  const result = await deps.browserService.takeScreenshotForVlm(screenshotPath, fullPage);
156
157
  const tree = await deps.browserService.getAccessibilityTree();
@@ -2,6 +2,7 @@ import { sendOk, sendError } from '../shared.js';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
+ import { getWorkspaceDir } from '../../config/workspace.js';
5
6
  /** GET /debug — Extended diagnostics including full context budget, recent logs, active connections, performance metrics */
6
7
  export function handleDebug(deps) {
7
8
  return async (req, res) => {
@@ -46,7 +47,7 @@ export function handleDebug(deps) {
46
47
  }
47
48
  async function getRecentLogs(limit) {
48
49
  const dateIso = new Date().toISOString().slice(0, 10);
49
- const logPath = path.resolve('memory', `${dateIso}.md`);
50
+ const logPath = path.join(getWorkspaceDir(), 'memory', `${dateIso}.md`);
50
51
  let content = '';
51
52
  try {
52
53
  content = await readFile(logPath, 'utf8');
@@ -16,6 +16,7 @@ import { logThought } from '../utils/logger.js';
16
16
  import { getCallbackOutcomeCounts } from '../services/db-incidents.js';
17
17
  import { readFile } from 'node:fs/promises';
18
18
  import path from 'node:path';
19
+ import { getWorkspaceDir } from '../config/workspace.js';
19
20
  import { getConfigValue } from '../config/json-config.js';
20
21
  import { getDevicePairingService } from '../services/device-pairing.js';
21
22
  import { registerSelfRoutes } from './handlers/self-routes.js';
@@ -225,7 +226,7 @@ export function startApiServer(deps) {
225
226
  app.get('/logs', async (_req, res) => {
226
227
  try {
227
228
  const dateIso = new Date().toISOString().slice(0, 10);
228
- const logPath = path.resolve('memory', `${dateIso}.md`);
229
+ const logPath = path.join(getWorkspaceDir(), 'memory', `${dateIso}.md`);
229
230
  let content = '';
230
231
  try {
231
232
  content = await readFile(logPath, 'utf8');
@@ -297,6 +297,97 @@ export const STATIC_MODEL_CATALOG = [
297
297
  pricing: 'Free tier available',
298
298
  description: 'Fast inference with Groq'
299
299
  },
300
+ // Alias entries for twinclaw.json model IDs that differ from catalog IDs
301
+ {
302
+ id: 'groq-qwen-qwen3-32b',
303
+ name: 'Qwen 3 32B (Groq)',
304
+ provider: 'groq',
305
+ model: 'qwen/qwen3-32b',
306
+ contextLength: 32768,
307
+ supportsStreaming: true,
308
+ pricing: 'Free tier available',
309
+ description: 'Fast inference with Groq'
310
+ },
311
+ {
312
+ id: 'groq-moonshotai-kimi-k2-instruct-0905',
313
+ name: 'Kimi K2 Instruct (Groq)',
314
+ provider: 'groq',
315
+ model: 'moonshotai/kimi-k2-instruct-0905',
316
+ contextLength: 32768,
317
+ supportsStreaming: true,
318
+ pricing: 'Free tier available',
319
+ description: 'Fast inference with Groq'
320
+ },
321
+ {
322
+ id: 'openrouter-z-ai-glm-4-5-air-free',
323
+ name: 'GLM-4.5 Air (Free)',
324
+ provider: 'openrouter',
325
+ model: 'z-ai/glm-4.5-air:free',
326
+ contextLength: 32000,
327
+ supportsStreaming: true,
328
+ pricing: 'Free',
329
+ description: 'Free model via OpenRouter'
330
+ },
331
+ {
332
+ id: 'openrouter-stepfun-step-3-5-flash-free',
333
+ name: 'StepFun Flash (Free)',
334
+ provider: 'openrouter',
335
+ model: 'stepfun/step-3.5-flash:free',
336
+ contextLength: 32000,
337
+ supportsStreaming: true,
338
+ pricing: 'Free',
339
+ description: 'Free model via OpenRouter'
340
+ },
341
+ {
342
+ id: 'openrouter-qwen-qwen3-coder-free',
343
+ name: 'Qwen3 Coder (Free)',
344
+ provider: 'openrouter',
345
+ model: 'qwen/qwen3-coder:free',
346
+ contextLength: 32000,
347
+ supportsStreaming: true,
348
+ pricing: 'Free',
349
+ description: 'Free model via OpenRouter'
350
+ },
351
+ {
352
+ id: 'openrouter-arcee-ai-trinity-mini-free',
353
+ name: 'Trinity Mini (Free)',
354
+ provider: 'openrouter',
355
+ model: 'arcee-ai/trinity-mini:free',
356
+ contextLength: 32000,
357
+ supportsStreaming: true,
358
+ pricing: 'Free',
359
+ description: 'Free model via OpenRouter'
360
+ },
361
+ {
362
+ id: 'openrouter-arcee-ai-trinity-large-preview-free',
363
+ name: 'Trinity Large Preview (Free)',
364
+ provider: 'openrouter',
365
+ model: 'arcee-ai/trinity-large-preview:free',
366
+ contextLength: 32000,
367
+ supportsStreaming: true,
368
+ pricing: 'Free',
369
+ description: 'Free model via OpenRouter'
370
+ },
371
+ {
372
+ id: 'copilot-gpt-4o',
373
+ name: 'GPT-4o (Copilot)',
374
+ provider: 'copilot',
375
+ model: 'gpt-4o',
376
+ contextLength: 128000,
377
+ supportsStreaming: true,
378
+ pricing: 'Included with Copilot',
379
+ description: 'GitHub Copilot model'
380
+ },
381
+ {
382
+ id: 'copilot-gemini-flash',
383
+ name: 'Gemini Flash (Copilot)',
384
+ provider: 'copilot',
385
+ model: 'gemini-2.0-flash',
386
+ contextLength: 1000000,
387
+ supportsStreaming: true,
388
+ pricing: 'Included with Copilot',
389
+ description: 'GitHub Copilot model'
390
+ },
300
391
  // OpenRouter Free Models
301
392
  {
302
393
  id: 'openrouter-stepfun-flash',
@@ -1,5 +1,7 @@
1
1
  import WAWebJS from 'whatsapp-web.js';
2
2
  import qrcode from 'qrcode-terminal';
3
+ import path from 'node:path';
4
+ import { getWorkspaceDir } from '../config/workspace.js';
3
5
  import { readConfig, writeConfig } from '../config/json-config.js';
4
6
  const { Client, LocalAuth } = WAWebJS;
5
7
  function printUsage() {
@@ -69,7 +71,7 @@ async function runWhatsappLogin() {
69
71
  console.log('Initializing secure browser environment...');
70
72
  const disableChromiumSandbox = process.env.WHATSAPP_DISABLE_CHROMIUM_SANDBOX === 'true';
71
73
  const clientConfig = {
72
- authStrategy: new LocalAuth({ dataPath: './memory/whatsapp_auth' }),
74
+ authStrategy: new LocalAuth({ dataPath: path.join(getWorkspaceDir(), 'memory', 'whatsapp_auth') }),
73
75
  };
74
76
  if (disableChromiumSandbox) {
75
77
  clientConfig.puppeteer = {
@@ -0,0 +1,198 @@
1
+ import { readConfig, writeConfig } from '../config/json-config.js';
2
+ import { getModelCatalogService } from '../services/model-catalog-service.js';
3
+ export const CHAT_COMMANDS = [
4
+ { cmd: '/help', desc: 'Show all commands' },
5
+ { cmd: '/status', desc: 'Show gateway status' },
6
+ { cmd: '/models', desc: 'Show configured models' },
7
+ { cmd: '/model list', desc: 'List available models in catalog' },
8
+ { cmd: '/model set <name>', desc: 'Set primary model (e.g., /model set modal)' },
9
+ { cmd: '/keys', desc: 'Show API keys status' },
10
+ { cmd: '/key set <provider> <key>', desc: 'Set API key (e.g., /key set modal sk-xxx)' },
11
+ { cmd: '/channel', desc: 'Show messaging channels status' },
12
+ { cmd: '/clear', desc: 'Clear conversation' },
13
+ { cmd: '/quit', desc: 'Exit chat mode (terminal only)' },
14
+ ];
15
+ export async function handleChatCommand(command, platform) {
16
+ const parts = command.toLowerCase().trim().split(/\s+/);
17
+ const cmd = parts[0];
18
+ const args = command.trim().substring(cmd.length).trim();
19
+ switch (cmd) {
20
+ case '/start':
21
+ case '/menu':
22
+ return {
23
+ handled: true,
24
+ response: `🎯 *TwinClaw Menu*
25
+
26
+ ${CHAT_COMMANDS.map((c) => `${c.cmd} - ${c.desc}`).join('\n')}
27
+
28
+ _How can I help you today?_`,
29
+ };
30
+ case '/help':
31
+ return {
32
+ handled: true,
33
+ response: `📋 *Available Commands:*
34
+
35
+ ${CHAT_COMMANDS.map((c) => `${c.cmd} - ${c.desc}`).join('\n')}
36
+
37
+ _Just send me a message and I'll respond!_`,
38
+ };
39
+ case '/status':
40
+ return {
41
+ handled: true,
42
+ response: `✅ *Gateway Status:*
43
+
44
+ • Status: Running
45
+ • Platform: ${platform}
46
+ • Mode: AI Assistant
47
+
48
+ _All systems operational_`,
49
+ };
50
+ case '/models':
51
+ case '/model':
52
+ if (args.startsWith('list')) {
53
+ const catalog = getModelCatalogService().getAllModels();
54
+ const config = await readConfig();
55
+ const currentPrimary = config.models.primaryModel || 'not set';
56
+ let modelList = `📦 *Available Models*
57
+
58
+ Current: *${currentPrimary}*
59
+
60
+ `;
61
+ const providerGroups = new Map();
62
+ for (const model of catalog) {
63
+ const models = providerGroups.get(model.provider) || [];
64
+ models.push(model);
65
+ providerGroups.set(model.provider, models);
66
+ }
67
+ let num = 1;
68
+ for (const [provider, models] of providerGroups) {
69
+ modelList += `*${provider.toUpperCase()}:*\n`;
70
+ for (const m of models.slice(0, 5)) {
71
+ const isCurrent = m.model === currentPrimary || m.id === currentPrimary;
72
+ modelList += `${num}. ${m.name}${isCurrent ? ' ✅' : ''}\n`;
73
+ num++;
74
+ }
75
+ if (models.length > 5)
76
+ modelList += ` ...and ${models.length - 5} more\n`;
77
+ modelList += '\n';
78
+ }
79
+ modelList += '_To switch: /model set <number or name>_';
80
+ return { handled: true, response: modelList };
81
+ }
82
+ else if (args.startsWith('set ') && args.length > 4) {
83
+ const modelIdOrNum = args.substring(4).trim();
84
+ const catalog = getModelCatalogService().getAllModels();
85
+ let newModelId = modelIdOrNum;
86
+ if (/^\d+$/.test(modelIdOrNum)) {
87
+ const idx = parseInt(modelIdOrNum, 10) - 1;
88
+ if (idx >= 0 && idx < catalog.length) {
89
+ newModelId = `${catalog[idx].provider}/${catalog[idx].model}`;
90
+ }
91
+ else {
92
+ return { handled: true, response: '❌ Invalid model number. Use /model list to see available models.' };
93
+ }
94
+ }
95
+ const config = await readConfig();
96
+ config.models.primaryModel = newModelId;
97
+ await writeConfig(config);
98
+ return {
99
+ handled: true,
100
+ response: `✅ *Model updated!*
101
+
102
+ New primary model: *${newModelId}*
103
+
104
+ Restart TwinClaw for changes to take effect.`,
105
+ };
106
+ }
107
+ else {
108
+ const config = await readConfig();
109
+ const currentPrimary = config.models.primaryModel || 'not set';
110
+ return {
111
+ handled: true,
112
+ response: `📦 *Models*
113
+
114
+ Current: *${currentPrimary}*
115
+
116
+ Commands:
117
+ • /model list - List available models
118
+ • /model set <number> - Set primary model
119
+
120
+ Example: /model set 1`,
121
+ };
122
+ }
123
+ case '/keys':
124
+ case '/key':
125
+ if (args.startsWith('set ') && args.length > 4) {
126
+ const keyParts = args.substring(4).trim().split(/\s+/);
127
+ if (keyParts.length >= 2) {
128
+ const provider = keyParts[0];
129
+ const key = keyParts.slice(1).join(' ');
130
+ try {
131
+ const config = await readConfig();
132
+ const keyLower = provider.toLowerCase();
133
+ if (keyLower === 'modal')
134
+ config.models.modalApiKey = key;
135
+ else if (keyLower === 'openrouter')
136
+ config.models.openRouterApiKey = key;
137
+ else if (keyLower === 'gemini')
138
+ config.models.geminiApiKey = key;
139
+ else if (keyLower === 'groq')
140
+ config.messaging.voice.groqApiKey = key;
141
+ else if (keyLower === 'github')
142
+ config.models.githubToken = key;
143
+ else {
144
+ return { handled: true, response: `Unknown provider: ${provider}\nValid providers: modal, openrouter, gemini, groq, github` };
145
+ }
146
+ await writeConfig(config);
147
+ return { handled: true, response: `✅ ${provider} API key updated` };
148
+ }
149
+ catch (err) {
150
+ return { handled: true, response: `❌ Error setting key` };
151
+ }
152
+ }
153
+ else {
154
+ return { handled: true, response: `Usage: /key set <provider> <api-key>\n\nExample: /key set modal sk-xxxxx` };
155
+ }
156
+ }
157
+ else {
158
+ return {
159
+ handled: true,
160
+ response: `🔑 *API Keys*
161
+
162
+ Use *twinclaw config* to manage API keys.
163
+
164
+ Commands:
165
+ • /keys show - Show key status
166
+ • /key set <provider> <key> - Set API key
167
+
168
+ _Or run: twinclaw config_`,
169
+ };
170
+ }
171
+ case '/channel':
172
+ case '/channels':
173
+ return {
174
+ handled: true,
175
+ response: `📱 *Channels*
176
+
177
+ WhatsApp: ✅ Connected
178
+ Telegram: ✅ Connected
179
+
180
+ Run: *twinclaw channels* in terminal for full details`,
181
+ };
182
+ case '/clear':
183
+ return { handled: true, response: '🗑️ Conversation cleared! Starting fresh.' };
184
+ case '/quit':
185
+ if (platform === 'repl') {
186
+ return { handled: true, response: 'Exiting chat mode. Use Ctrl+C to exit.', shouldExit: true };
187
+ }
188
+ return { handled: true, response: 'Quit is only available in terminal REPL.' };
189
+ case '/persona':
190
+ case '/config':
191
+ return {
192
+ handled: true,
193
+ response: `Command ${cmd} is best run from the terminal (e.g. twinclaw config).`,
194
+ };
195
+ default:
196
+ return { handled: false };
197
+ }
198
+ }
@@ -0,0 +1,290 @@
1
+ import { readConfig, writeConfig } from '../config/json-config.js';
2
+ import { getModelCatalogService } from '../services/model-catalog-service.js';
3
+ export class CommandRouter {
4
+ gateway;
5
+ constructor(gateway) {
6
+ this.gateway = gateway;
7
+ }
8
+ /**
9
+ * Dispatches a /command from a given platform.
10
+ * Return string is the response to display to the user.
11
+ */
12
+ async dispatch(commandText, platform, sessionId) {
13
+ const parts = commandText.trim().split(/\s+/);
14
+ const cmd = parts[0].toLowerCase();
15
+ const args = parts.slice(1).join(' ');
16
+ switch (cmd) {
17
+ case '/start':
18
+ case '/menu':
19
+ case '/help':
20
+ return this.handleHelp(platform);
21
+ case '/status':
22
+ return this.handleStatus(sessionId, platform);
23
+ case '/models':
24
+ case '/model':
25
+ return this.handleModels(args, platform);
26
+ case '/keys':
27
+ case '/key':
28
+ return this.handleKeys(args, platform);
29
+ case '/channel':
30
+ case '/channels':
31
+ return this.handleChannelStatus(platform);
32
+ case '/persona':
33
+ return this.handlePersona(args, platform);
34
+ case '/doctor':
35
+ return this.handleDoctor(platform);
36
+ case '/config':
37
+ return this.handleConfig(platform);
38
+ case '/clear':
39
+ return this.handleClear(platform);
40
+ case '/quit':
41
+ if (platform === 'repl') {
42
+ return '\nExiting chat mode. Use Ctrl+C to exit.\n';
43
+ }
44
+ return `Unknown command: ${cmd}\nType /menu for available commands.`;
45
+ default:
46
+ return `Unknown command: ${cmd}\nType /menu for available commands.`;
47
+ }
48
+ }
49
+ handleHelp(platform) {
50
+ const commands = [
51
+ { cmd: '/help', desc: 'Show all commands' },
52
+ { cmd: '/status', desc: 'Show gateway status' },
53
+ { cmd: '/models', desc: 'Show configured models' },
54
+ { cmd: '/model set <val>', desc: 'Set primary model (e.g., /model set modal)' },
55
+ { cmd: '/model list', desc: 'List available models in catalog' },
56
+ { cmd: '/keys', desc: 'Show API keys status' },
57
+ { cmd: '/key set <prov> <key>', desc: 'Set API key (e.g., /key set modal sk-xxx)' },
58
+ { cmd: '/channel', desc: 'Show messaging channels status' },
59
+ { cmd: '/persona', desc: 'Show current persona' },
60
+ { cmd: '/doctor', desc: 'Run diagnostics' },
61
+ { cmd: '/config', desc: 'Open configuration menu (terminal)' },
62
+ { cmd: '/clear', desc: 'Clear conversation' },
63
+ ];
64
+ if (platform === 'repl') {
65
+ commands.push({ cmd: '/quit', desc: 'Exit chat mode' });
66
+ }
67
+ if (platform === 'telegram' || platform === 'whatsapp') {
68
+ return `🎯 *TwinClaw Menu*\n\n📋 *Available Commands:*\n\n${commands.map(c => `${c.cmd} - ${c.desc}`).join('\n')}\n\n_How can I help you today?_`;
69
+ }
70
+ // REPL formatting
71
+ let output = '\n╔═══════════════════════════════════════════╗\n';
72
+ output += '║ TwinClaw Chat Commands ║\n';
73
+ output += '╠═══════════════════════════════════════════╣\n';
74
+ for (const c of commands) {
75
+ output += `║ ${c.cmd.padEnd(35)} ║\n`;
76
+ output += `║ ${c.desc.padEnd(33)} ║\n`;
77
+ output += '║ ║\n';
78
+ }
79
+ output += '╚═══════════════════════════════════════════╝\n';
80
+ return output;
81
+ }
82
+ handleStatus(sessionId, platform) {
83
+ if (platform === 'telegram' || platform === 'whatsapp') {
84
+ return `✅ *Gateway Status:*\n\n• Status: Running\n• Platform: ${platform === 'telegram' ? 'Telegram' : 'WhatsApp'}\n• Mode: AI Assistant\n\n_All systems operational_`;
85
+ }
86
+ return `\n--- Gateway Status ---\nStatus: Running ✅\nSession: ${sessionId}\n----------------------\n`;
87
+ }
88
+ async handleModels(args, platform) {
89
+ if (args.startsWith('list')) {
90
+ const catalog = getModelCatalogService().getAllModels();
91
+ const config = await readConfig();
92
+ const currentPrimary = config.models.primaryModel || 'not set';
93
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
94
+ let modelList = isMd ? `📦 *Available Models*\n\nCurrent: *${currentPrimary}*\n\n` : `\n--- Model Catalog ---\nCurrent: ${currentPrimary}\n\n`;
95
+ const providerGroups = new Map();
96
+ for (const model of catalog) {
97
+ const models = providerGroups.get(model.provider) || [];
98
+ models.push(model);
99
+ providerGroups.set(model.provider, models);
100
+ }
101
+ let num = 1;
102
+ for (const [provider, models] of providerGroups) {
103
+ modelList += isMd ? `*${provider.toUpperCase()}:*\n` : `${provider.toUpperCase()}:\n`;
104
+ for (const m of models.slice(0, 5)) {
105
+ const isCurrent = m.model === currentPrimary || m.id === currentPrimary;
106
+ modelList += `${num}. ${m.name}${isCurrent ? ' ✅' : ''}\n`;
107
+ num++;
108
+ }
109
+ if (models.length > 5)
110
+ modelList += ` ...and ${models.length - 5} more\n`;
111
+ modelList += '\n';
112
+ }
113
+ modelList += isMd ? '_To switch: /model set <number or id>_' : 'To switch: /model set <number or id>';
114
+ if (platform === 'repl')
115
+ modelList += '\n--------------------\n';
116
+ return modelList;
117
+ }
118
+ else if (args.startsWith('set ')) {
119
+ const modelIdOrNum = args.substring(4).trim();
120
+ if (!modelIdOrNum) {
121
+ return `Usage: /model set <provider/model or number>\n\nExamples:\n /model set modal\n /model set 1\n /model set openrouter/meta-llama/llama-3.3-70b-instruct`;
122
+ }
123
+ const catalog = getModelCatalogService().getAllModels();
124
+ let newModelId = modelIdOrNum;
125
+ if (/^\d+$/.test(modelIdOrNum)) {
126
+ const idx = parseInt(modelIdOrNum, 10) - 1;
127
+ if (idx >= 0 && idx < catalog.length) {
128
+ newModelId = `${catalog[idx].provider}/${catalog[idx].model}`;
129
+ }
130
+ else {
131
+ return `❌ Invalid model number. Use /model list to see available models.`;
132
+ }
133
+ }
134
+ else {
135
+ // Allow setting provider/model string dynamically
136
+ if (!newModelId.includes('/')) {
137
+ // Usually handled gracefully by ModelRouter if it maps to a well-known default provider,
138
+ // but we can warn.
139
+ }
140
+ }
141
+ try {
142
+ const config = await readConfig();
143
+ config.models.primaryModel = newModelId;
144
+ await writeConfig(config);
145
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
146
+ return isMd
147
+ ? `✅ *Model updated!*\n\nNew primary model: *${newModelId}*\n\nRestart TwinClaw for changes to take effect.`
148
+ : `\n✅ Primary model set to: ${newModelId}\n\nNote: Restart gateway for changes to take effect.`;
149
+ }
150
+ catch (err) {
151
+ return `Error setting model: ${err instanceof Error ? err.message : String(err)}`;
152
+ }
153
+ }
154
+ else {
155
+ try {
156
+ const config = await readConfig();
157
+ const currentPrimary = config.models.primaryModel || 'not set';
158
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
159
+ if (isMd) {
160
+ return `📦 *Models*\n\nCurrent: *${currentPrimary}*\n\nCommands:\n• /model list - List available models\n• /model set <number or id> - Set primary model\n\nExample: /model set 1`;
161
+ }
162
+ let out = `\n--- Models ---\nPrimary: ${currentPrimary}\n`;
163
+ if (config.models.definitions?.length) {
164
+ out += '\nConfigured Models:\n';
165
+ for (const def of config.models.definitions) {
166
+ const isPrimary = def.model === currentPrimary || def.id === currentPrimary ? ' *' : '';
167
+ out += ` - ${def.provider}/${def.model}${isPrimary}\n`;
168
+ }
169
+ }
170
+ out += '\nTo change: /model set <provider/model or number>\n-----------\n';
171
+ return out;
172
+ }
173
+ catch (err) {
174
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
175
+ }
176
+ }
177
+ }
178
+ async handleKeys(args, platform) {
179
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
180
+ if (args.startsWith('set ')) {
181
+ const keyParts = args.substring(4).trim().split(/\s+/);
182
+ if (keyParts.length >= 2) {
183
+ const provider = keyParts[0];
184
+ const key = keyParts.slice(1).join(' ');
185
+ if (isMd) {
186
+ return `🔐 To set *${provider}* API key, run in your terminal:\n\n\`twinclaw secret set ${provider.toUpperCase()}_API_KEY ${key}\`\n\n_Or use: twinclaw config edit_`;
187
+ }
188
+ try {
189
+ const config = await readConfig();
190
+ const keyLower = provider.toLowerCase();
191
+ if (keyLower === 'modal')
192
+ config.models.modalApiKey = key;
193
+ else if (keyLower === 'openrouter')
194
+ config.models.openRouterApiKey = key;
195
+ else if (keyLower === 'gemini')
196
+ config.models.geminiApiKey = key;
197
+ else if (keyLower === 'groq')
198
+ config.messaging.voice.groqApiKey = key;
199
+ else if (keyLower === 'github')
200
+ config.models.githubToken = key;
201
+ else {
202
+ return `Unknown provider: ${provider}\nValid providers: modal, openrouter, gemini, groq, github`;
203
+ }
204
+ await writeConfig(config);
205
+ return `\n✅ ${provider} API key updated\n`;
206
+ }
207
+ catch (err) {
208
+ return `Error setting key: ${err instanceof Error ? err.message : String(err)}`;
209
+ }
210
+ }
211
+ else {
212
+ return `Usage: /key set <provider> <api-key>\n\nExample: /key set modal sk-xxxxx`;
213
+ }
214
+ }
215
+ if (isMd) {
216
+ return `🔑 *API Keys*\n\nUse *twinclaw config* in terminal to manage API keys.\n\nCommands:\n• /keys show - Show key status\n• /key set <provider> <key> - Set API key\n\n_Or run: twinclaw config_`;
217
+ }
218
+ try {
219
+ const config = await readConfig();
220
+ let out = '\n╔═══════════════════════════════════════════╗\n';
221
+ out += '║ Current Configuration ║\n';
222
+ out += '╠═══════════════════════════════════════════╣\n';
223
+ out += `║ API Keys ║\n`;
224
+ out += `║ Modal: ${config.models.modalApiKey ? '***set***' : 'not set'.padEnd(22)} ║\n`;
225
+ out += `║ OpenRouter: ${config.models.openRouterApiKey ? '***set***' : 'not set'.padEnd(17)} ║\n`;
226
+ out += `║ Gemini: ${config.models.geminiApiKey ? '***set***' : 'not set'.padEnd(21)} ║\n`;
227
+ out += `║ Groq: ${config.messaging.voice.groqApiKey ? '***set***' : 'not set'.padEnd(24)} ║\n`;
228
+ out += `║ GitHub: ${config.models.githubToken ? '***set***' : 'not set'.padEnd(21)} ║\n`;
229
+ out += '╚═══════════════════════════════════════════╝\n';
230
+ return out;
231
+ }
232
+ catch (err) {
233
+ return `Error loading config: ${err instanceof Error ? err.message : String(err)}`;
234
+ }
235
+ }
236
+ async handleChannelStatus(platform) {
237
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
238
+ try {
239
+ const config = await readConfig();
240
+ if (isMd) {
241
+ return `📱 *Channels*\n\nTelegram: ${config.messaging.telegram.enabled ? '✅ Connected' : '❌ Disabled'}\nWhatsApp: ${config.messaging.whatsapp.enabled ? '✅ Connected' : '❌ Disabled'}\n\nRun: *twinclaw channels* in terminal to manage`;
242
+ }
243
+ let out = '\n--- Messaging Channels ---\n';
244
+ out += `Telegram: ${config.messaging.telegram.enabled ? 'enabled' : 'disabled'}\n`;
245
+ if (config.messaging.telegram.enabled) {
246
+ out += ` Bot Token: ${config.messaging.telegram.botToken ? '***set***' : 'not set'}\n`;
247
+ out += ` User ID: ${config.messaging.telegram.userId || 'not set'}\n`;
248
+ out += ` Allow From: ${config.messaging.telegram.allowFrom.join(', ') || 'none'}\n`;
249
+ }
250
+ out += `\nWhatsApp: ${config.messaging.whatsapp.enabled ? 'enabled' : 'disabled'}\n`;
251
+ if (config.messaging.whatsapp.enabled) {
252
+ out += ` Phone: ${config.messaging.whatsapp.phoneNumber || 'not set'}\n`;
253
+ out += ` Allow From: ${config.messaging.whatsapp.allowFrom.join(', ') || 'none'}\n`;
254
+ }
255
+ out += '\nTo configure: twinclaw channels\n--------------\n';
256
+ return out;
257
+ }
258
+ catch (err) {
259
+ return `Error: ${err instanceof Error ? err.message : String(err)}`;
260
+ }
261
+ }
262
+ handlePersona(args, platform) {
263
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
264
+ if (isMd) {
265
+ return `👤 *Persona*\n\nRun: *twinclaw persona* in terminal to view/edit\n\nCommands:\n• twinclaw persona show - View persona\n• twinclaw persona edit soul "text" - Edit soul`;
266
+ }
267
+ return '\nTo view/edit persona, use: twinclaw persona\nTo edit in chat: /persona edit <soul|identity|user> <text>\n--------------\n';
268
+ }
269
+ handleDoctor(platform) {
270
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
271
+ if (isMd) {
272
+ return `🩺 *Diagnostics*\n\nRun: *twinclaw doctor* in terminal to check system health\n\nChecks:\n• Environment variables\n• API keys\n• Configuration\n• Channels`;
273
+ }
274
+ return '\nRunning diagnostics...\n\nUse: twinclaw doctor\n------\n';
275
+ }
276
+ handleConfig(platform) {
277
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
278
+ if (isMd) {
279
+ return `⚙️ *Configuration*\n\nUse: *twinclaw config* in terminal to:\n• Edit configuration\n• Set API keys\n• Change models\n• Manage channels\n\nRun: *twinclaw config* in terminal`;
280
+ }
281
+ return '\n--- Configuration Menu ---\nCommands:\n /config show - Show full config\n /config edit - Open interactive editor\n /model set <id> - Change primary model\n /key set <prov> <key> - Set API key\n------------------------\n';
282
+ }
283
+ handleClear(platform) {
284
+ const isMd = platform === 'telegram' || platform === 'whatsapp';
285
+ if (isMd) {
286
+ return '🗑️ Conversation cleared! Starting fresh.';
287
+ }
288
+ return '\nConversation cleared.\n';
289
+ }
290
+ }