twinclaw 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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');
@@ -315,9 +315,9 @@ export const MODEL_PROVIDERS = [
315
315
  { id: 'vllm', name: 'vLLM', baseUrl: 'http://localhost:8000/v1', defaultModel: 'meta-llama/Llama-3.1-70B-Instruct', supportsStreaming: true, supportsFunctionCalling: true, contextWindow: 128000 },
316
316
  { id: 'minimax', name: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1', defaultModel: 'abab6.5s-chat', supportsStreaming: true, contextWindow: 245760 },
317
317
  { id: 'moonshot', name: 'Moonshot AI (Kimi K2.5)', baseUrl: 'https://api.moonshot.cn/v1', defaultModel: 'moonshot-v1-8k', supportsStreaming: true, contextWindow: 128000 },
318
- { id: 'google', name: 'Google', baseUrl: 'https://generativelanguage.googleapis.com/v1', defaultModel: 'gemini-2.0-flash-exp', supportsStreaming: true, contextWindow: 1000000 },
318
+ { id: 'google', name: 'Google', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', defaultModel: 'gemini-2.0-flash-exp', supportsStreaming: true, contextWindow: 1000000 },
319
319
  { id: 'xai', name: 'xAI (Grok)', baseUrl: 'https://api.x.ai/v1', defaultModel: 'grok-2-1212', supportsStreaming: true, contextWindow: 131072 },
320
- { id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: 'anthropic/claude-sonnet-4-20250514', supportsStreaming: true, supportsFunctionCalling: true, contextWindow: 200000 },
320
+ { id: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1/chat/completions', defaultModel: 'anthropic/claude-sonnet-4-20250514', supportsStreaming: true, supportsFunctionCalling: true, contextWindow: 200000 },
321
321
  { id: 'qwen', name: 'Qwen', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-plus', supportsStreaming: true, contextWindow: 131072 },
322
322
  { id: 'zai', name: 'Z.AI', baseUrl: 'https://api.zai.net/v1', defaultModel: 'zai/llama-3.3-70b-instruct', supportsStreaming: true, contextWindow: 128000 },
323
323
  { id: 'qianfan', name: 'Qianfan', baseUrl: 'https://qianfan.baidubce.com/v3', defaultModel: 'ernie-4.0-8k', supportsStreaming: true, contextWindow: 32000 },
@@ -331,7 +331,8 @@ export const MODEL_PROVIDERS = [
331
331
  { id: 'venice', name: 'Venice AI', baseUrl: 'https://api.venice.ai/api/v1', defaultModel: 'qwen2.5-72b', supportsStreaming: true, contextWindow: 131072 },
332
332
  { id: 'litellm', name: 'LiteLLM', baseUrl: '', defaultModel: '', supportsStreaming: true },
333
333
  { id: 'cloudflare', name: 'Cloudflare AI Gateway', baseUrl: '', defaultModel: '@cf/meta/llama-3.1-70b-instruct', supportsStreaming: false, contextWindow: 128000 },
334
- { id: 'modal', name: 'Modal', baseUrl: 'https://api.us-west-2.modal.direct/v1', defaultModel: 'zai-org/GLM-5-FP8', supportsStreaming: true, contextWindow: 200000 },
334
+ { id: 'modal', name: 'Modal', baseUrl: 'https://api.us-west-2.modal.direct/v1/chat/completions', defaultModel: 'zai-org/GLM-5-FP8', supportsStreaming: true, contextWindow: 200000 },
335
+ { id: 'groq', name: 'Groq', baseUrl: 'https://api.groq.com/openai/v1/chat/completions', defaultModel: 'llama-3.3-70b-versatile', supportsStreaming: true, contextWindow: 131072 },
335
336
  { id: 'custom', name: 'Custom Provider', baseUrl: '', defaultModel: '', supportsStreaming: true },
336
337
  ];
337
338
  export const DEFAULT_CONFIG = {
@@ -372,31 +372,31 @@ export const PROVIDER_INFO = {
372
372
  },
373
373
  openrouter: {
374
374
  name: 'OpenRouter',
375
- baseURL: 'https://openrouter.ai/api/v1',
375
+ baseURL: 'https://openrouter.ai/api/v1/chat/completions',
376
376
  apiKeyEnvName: 'OPENROUTER_API_KEY',
377
377
  supportsVision: true
378
378
  },
379
379
  modal: {
380
380
  name: 'Modal',
381
- baseURL: 'https://api.us-west-2.modal.direct/v1',
381
+ baseURL: 'https://api.us-west-2.modal.direct/v1/chat/completions',
382
382
  apiKeyEnvName: 'MODAL_API_KEY',
383
383
  supportsVision: false
384
384
  },
385
385
  groq: {
386
386
  name: 'Groq',
387
- baseURL: 'https://api.groq.com/openai/v1',
387
+ baseURL: 'https://api.groq.com/openai/v1/chat/completions',
388
388
  apiKeyEnvName: 'GROQ_API_KEY',
389
389
  supportsVision: false
390
390
  },
391
391
  github: {
392
392
  name: 'GitHub Copilot',
393
- baseURL: 'https://api.githubcopilot.com',
393
+ baseURL: 'https://api.githubcopilot.com/chat/completions',
394
394
  apiKeyEnvName: 'GITHUB_TOKEN',
395
395
  supportsVision: true
396
396
  },
397
397
  copilot: {
398
398
  name: 'GitHub Copilot',
399
- baseURL: 'https://api.githubcopilot.com',
399
+ baseURL: 'https://api.githubcopilot.com/chat/completions',
400
400
  apiKeyEnvName: 'GITHUB_TOKEN',
401
401
  supportsVision: true
402
402
  }
@@ -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
+ }
@@ -184,7 +184,7 @@ export function checkEnvVar(check) {
184
184
  /** @internal exported for testing */
185
185
  export function checkFilesystem(check) {
186
186
  const targetMap = {
187
- 'memory-dir': path.resolve('memory'),
187
+ 'memory-dir': getWorkspaceSubdir('memory'),
188
188
  'env-file': path.resolve('.env'),
189
189
  'identity-soul': path.join(getIdentityDir(), 'soul.md'),
190
190
  'identity-identity': path.join(getIdentityDir(), 'identity.md'),
@@ -245,7 +245,7 @@ export function checkConfigSchema(check) {
245
245
  /** @internal exported for testing */
246
246
  export function checkChannelAuth(check) {
247
247
  if (check.name === 'whatsapp') {
248
- const authDir = path.resolve('memory', 'whatsapp_auth');
248
+ const authDir = path.join(getWorkspaceSubdir('memory'), 'whatsapp_auth');
249
249
  if (!fs.existsSync(authDir)) {
250
250
  return {
251
251
  check,