twinclaw 1.0.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.
Files changed (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,760 @@
1
+ import { Writable } from 'node:stream';
2
+ import * as readline from 'readline';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { assembleContext } from './context-assembly.js';
5
+ import { getConfigPath, readConfig, reloadConfig, writeConfig, } from '../config/config-loader.js';
6
+ import { initializeWorkspace, getWorkspaceDir } from '../config/workspace.js';
7
+ import { ensureIdentityFiles } from '../config/identity-bootstrap.js';
8
+ import { createSession, saveMessage } from '../services/db.js';
9
+ import { indexConversationTurn, retrieveMemoryContext } from '../services/semantic-memory.js';
10
+ import { ModelRouter } from '../services/model-router.js';
11
+ import { logThought } from '../utils/logger.js';
12
+ const rl = readline.createInterface({
13
+ input: process.stdin,
14
+ output: process.stdout,
15
+ });
16
+ export async function runOnboarding() {
17
+ console.log('Welcome to TwinBot Setup. I will ask you a few questions to build my persona and your preferences.');
18
+ await logThought('Onboarding flow started.');
19
+ const router = new ModelRouter();
20
+ const sessionId = `onboarding_${Date.now()}`;
21
+ createSession(sessionId);
22
+ const onboardingInstructions = 'This is the onboarding session. Ask the user 3 questions, one at a time, to establish their goals, routines, and how they want you to behave.';
23
+ const context = await assembleContext(onboardingInstructions);
24
+ const messages = [{ role: 'system', content: context }];
25
+ const askModel = async () => {
26
+ const responseMessage = await router.createChatCompletion(messages, undefined, { sessionId });
27
+ const responseContent = responseMessage.content ?? '';
28
+ messages.push({ role: 'assistant', content: responseContent });
29
+ saveMessage(Date.now().toString(), sessionId, 'assistant', responseContent);
30
+ await indexConversationTurn(sessionId, 'assistant', responseContent);
31
+ console.log(`\nTwinBot: ${responseContent}`);
32
+ rl.question('\nYou: ', async (answer) => {
33
+ const memoryContext = await retrieveMemoryContext(sessionId, answer);
34
+ messages[0] = {
35
+ role: 'system',
36
+ content: await assembleContext(`${onboardingInstructions}${memoryContext ? `\n\n${memoryContext}` : ''}`),
37
+ };
38
+ messages.push({ role: 'user', content: answer });
39
+ saveMessage(Date.now().toString(), sessionId, 'user', answer);
40
+ await indexConversationTurn(sessionId, 'user', answer);
41
+ await logThought(`Onboarding user response captured (${answer.length} chars).`);
42
+ await askModel();
43
+ });
44
+ };
45
+ await askModel();
46
+ }
47
+ export function startBasicREPL(gateway) {
48
+ console.log('TwinBot basic REPL started.');
49
+ void logThought('Basic REPL started.');
50
+ const sessionId = 'default_repl';
51
+ createSession(sessionId);
52
+ rl.on('line', async (line) => {
53
+ await logThought(`REPL input received (${line.length} chars).`);
54
+ try {
55
+ const responseText = await gateway.processText(sessionId, line);
56
+ console.log(`\nTwinBot: ${responseText}\n`);
57
+ }
58
+ catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ console.error('Error generating response:', message);
61
+ }
62
+ });
63
+ }
64
+ const WIZARD_SECTIONS = [
65
+ {
66
+ id: 'runtime',
67
+ label: 'Runtime & Security',
68
+ fields: ['API_SECRET', 'API_PORT'],
69
+ },
70
+ {
71
+ id: 'models',
72
+ label: 'Intelligence & Models',
73
+ fields: ['OPENROUTER_API_KEY', 'MODAL_API_KEY', 'GEMINI_API_KEY', 'GROQ_API_KEY'],
74
+ },
75
+ {
76
+ id: 'messaging',
77
+ label: 'Messaging & Channels',
78
+ fields: ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_USER_ID', 'WHATSAPP_PHONE_NUMBER'],
79
+ },
80
+ {
81
+ id: 'memory',
82
+ label: 'Memory & Workspace Defaults',
83
+ fields: ['EMBEDDING_PROVIDER'],
84
+ },
85
+ ];
86
+ const EMBEDDING_PROVIDERS = new Set(['openai', 'ollama']);
87
+ const MODEL_KEYS = [
88
+ 'OPENROUTER_API_KEY',
89
+ 'MODAL_API_KEY',
90
+ 'GEMINI_API_KEY',
91
+ ];
92
+ const SECRET_KEYS = new Set([
93
+ 'API_SECRET',
94
+ 'OPENROUTER_API_KEY',
95
+ 'MODAL_API_KEY',
96
+ 'GEMINI_API_KEY',
97
+ 'GROQ_API_KEY',
98
+ 'TELEGRAM_BOT_TOKEN',
99
+ ]);
100
+ const ONBOARD_FIELDS = [
101
+ {
102
+ key: 'API_SECRET',
103
+ label: 'API Secret',
104
+ hint: 'Required. Master runtime secret used for gateway security and secret-vault fallback.',
105
+ required: true,
106
+ secret: true,
107
+ },
108
+ {
109
+ key: 'OPENROUTER_API_KEY',
110
+ label: 'OpenRouter API Key',
111
+ hint: 'Optional, but at least one model key is required across OpenRouter/Modal/Gemini.',
112
+ secret: true,
113
+ },
114
+ {
115
+ key: 'MODAL_API_KEY',
116
+ label: 'Modal API Key',
117
+ hint: 'Optional, but at least one model key is required across OpenRouter/Modal/Gemini.',
118
+ secret: true,
119
+ },
120
+ {
121
+ key: 'GEMINI_API_KEY',
122
+ label: 'Gemini API Key',
123
+ hint: 'Optional, but at least one model key is required across OpenRouter/Modal/Gemini.',
124
+ secret: true,
125
+ },
126
+ {
127
+ key: 'GROQ_API_KEY',
128
+ label: 'Groq API Key',
129
+ hint: 'Optional unless messaging is configured. Needed for voice/STT features.',
130
+ secret: true,
131
+ },
132
+ {
133
+ key: 'TELEGRAM_BOT_TOKEN',
134
+ label: 'Telegram Bot Token',
135
+ hint: 'Optional. If set, TELEGRAM_USER_ID is required for deterministic pairing bootstrap.',
136
+ secret: true,
137
+ },
138
+ {
139
+ key: 'TELEGRAM_USER_ID',
140
+ label: 'Telegram User ID',
141
+ hint: 'Optional unless TELEGRAM_BOT_TOKEN is set. Must be a positive integer.',
142
+ validator: (value) => Number.isInteger(Number(value)) && Number(value) > 0
143
+ ? null
144
+ : 'TELEGRAM_USER_ID must be a positive integer.',
145
+ },
146
+ {
147
+ key: 'WHATSAPP_PHONE_NUMBER',
148
+ label: 'WhatsApp Phone Number',
149
+ hint: 'Optional. Use E.164-like format, e.g. +15551234567.',
150
+ validator: (value) => /^\+?[1-9]\d{6,14}$/.test(value)
151
+ ? null
152
+ : 'WHATSAPP_PHONE_NUMBER must be a valid phone number (E.164-like format).',
153
+ },
154
+ {
155
+ key: 'EMBEDDING_PROVIDER',
156
+ label: 'Embedding Provider',
157
+ hint: "Workspace default. Use 'openai' or 'ollama'.",
158
+ validator: (value) => EMBEDDING_PROVIDERS.has(value.toLowerCase())
159
+ ? null
160
+ : "EMBEDDING_PROVIDER must be 'openai' or 'ollama'.",
161
+ },
162
+ {
163
+ key: 'API_PORT',
164
+ label: 'API Port',
165
+ hint: 'Workspace default. Integer from 1 to 65535.',
166
+ validator: (value) => {
167
+ const parsed = Number(value);
168
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
169
+ return 'API_PORT must be an integer in range 1-65535.';
170
+ }
171
+ return null;
172
+ },
173
+ },
174
+ ];
175
+ const ONBOARD_FLAG_TO_KEY = {
176
+ '--api-secret': 'API_SECRET',
177
+ '--openrouter-api-key': 'OPENROUTER_API_KEY',
178
+ '--modal-api-key': 'MODAL_API_KEY',
179
+ '--gemini-api-key': 'GEMINI_API_KEY',
180
+ '--groq-api-key': 'GROQ_API_KEY',
181
+ '--telegram-bot-token': 'TELEGRAM_BOT_TOKEN',
182
+ '--telegram-user-id': 'TELEGRAM_USER_ID',
183
+ '--whatsapp-phone-number': 'WHATSAPP_PHONE_NUMBER',
184
+ '--embedding-provider': 'EMBEDDING_PROVIDER',
185
+ '--api-port': 'API_PORT',
186
+ };
187
+ export class OnboardingCancelledError extends Error {
188
+ constructor(message = 'Onboarding cancelled by user.') {
189
+ super(message);
190
+ this.name = 'OnboardingCancelledError';
191
+ }
192
+ }
193
+ class MutedWriter extends Writable {
194
+ muted = false;
195
+ #target;
196
+ constructor(target) {
197
+ super();
198
+ this.#target = target;
199
+ }
200
+ _write(chunk, encoding, callback) {
201
+ if (!this.muted) {
202
+ if (typeof chunk === 'string') {
203
+ this.#target.write(chunk, encoding);
204
+ }
205
+ else {
206
+ this.#target.write(chunk);
207
+ }
208
+ }
209
+ callback();
210
+ }
211
+ }
212
+ class ReadlinePrompter {
213
+ #writer;
214
+ #rl;
215
+ #pendingReject = null;
216
+ constructor() {
217
+ this.#writer = new MutedWriter(process.stdout);
218
+ this.#rl = readline.createInterface({
219
+ input: process.stdin,
220
+ output: this.#writer,
221
+ terminal: true,
222
+ });
223
+ this.#rl.on('SIGINT', () => {
224
+ const reject = this.#pendingReject;
225
+ this.#pendingReject = null;
226
+ if (reject) {
227
+ reject(new OnboardingCancelledError());
228
+ }
229
+ });
230
+ }
231
+ async prompt(question, options = {}) {
232
+ const isSecret = options.secret === true;
233
+ if (isSecret) {
234
+ process.stdout.write(question);
235
+ this.#writer.muted = true;
236
+ }
237
+ return new Promise((resolve, reject) => {
238
+ this.#pendingReject = reject;
239
+ this.#rl.question(isSecret ? '' : question, (answer) => {
240
+ this.#pendingReject = null;
241
+ this.#writer.muted = false;
242
+ if (isSecret) {
243
+ process.stdout.write('\n');
244
+ }
245
+ resolve(answer.trim());
246
+ });
247
+ });
248
+ }
249
+ close() {
250
+ this.#writer.muted = false;
251
+ this.#rl.close();
252
+ }
253
+ }
254
+ function hasValue(value) {
255
+ return typeof value === 'string' && value.trim().length > 0;
256
+ }
257
+ function cloneConfig(config) {
258
+ return JSON.parse(JSON.stringify(config));
259
+ }
260
+ function readConfigValue(config, key) {
261
+ switch (key) {
262
+ case 'API_SECRET':
263
+ return config.runtime.apiSecret ?? '';
264
+ case 'OPENROUTER_API_KEY':
265
+ return config.models.openRouterApiKey ?? '';
266
+ case 'MODAL_API_KEY':
267
+ return config.models.modalApiKey ?? '';
268
+ case 'GEMINI_API_KEY':
269
+ return config.models.geminiApiKey ?? '';
270
+ case 'GROQ_API_KEY':
271
+ return config.messaging.voice.groqApiKey ?? '';
272
+ case 'TELEGRAM_BOT_TOKEN':
273
+ return config.messaging.telegram.botToken ?? '';
274
+ case 'TELEGRAM_USER_ID':
275
+ return config.messaging.telegram.userId === null ? '' : String(config.messaging.telegram.userId);
276
+ case 'WHATSAPP_PHONE_NUMBER':
277
+ return config.messaging.whatsapp.phoneNumber ?? '';
278
+ case 'EMBEDDING_PROVIDER':
279
+ return config.integration.embeddingProvider ?? '';
280
+ case 'API_PORT':
281
+ return String(config.runtime.apiPort ?? 3100);
282
+ }
283
+ }
284
+ function applyConfigValue(config, key, value) {
285
+ switch (key) {
286
+ case 'API_SECRET':
287
+ config.runtime.apiSecret = value;
288
+ break;
289
+ case 'OPENROUTER_API_KEY':
290
+ config.models.openRouterApiKey = value;
291
+ break;
292
+ case 'MODAL_API_KEY':
293
+ config.models.modalApiKey = value;
294
+ break;
295
+ case 'GEMINI_API_KEY':
296
+ config.models.geminiApiKey = value;
297
+ break;
298
+ case 'GROQ_API_KEY':
299
+ config.messaging.voice.groqApiKey = value;
300
+ break;
301
+ case 'TELEGRAM_BOT_TOKEN':
302
+ config.messaging.telegram.botToken = value;
303
+ break;
304
+ case 'TELEGRAM_USER_ID':
305
+ config.messaging.telegram.userId =
306
+ value.trim().length === 0 ? null : Number.parseInt(value.trim(), 10);
307
+ break;
308
+ case 'WHATSAPP_PHONE_NUMBER':
309
+ config.messaging.whatsapp.phoneNumber = value;
310
+ break;
311
+ case 'EMBEDDING_PROVIDER':
312
+ config.integration.embeddingProvider =
313
+ value.toLowerCase();
314
+ break;
315
+ case 'API_PORT':
316
+ config.runtime.apiPort = Number.parseInt(value, 10);
317
+ break;
318
+ }
319
+ }
320
+ function applyUpdates(config, updates) {
321
+ const next = cloneConfig(config);
322
+ for (const [key, rawValue] of Object.entries(updates)) {
323
+ const normalized = rawValue.trim();
324
+ applyConfigValue(next, key, normalized);
325
+ }
326
+ syncDerivedConfig(next);
327
+ return next;
328
+ }
329
+ function syncDerivedConfig(config) {
330
+ const hasTelegramToken = hasValue(config.messaging.telegram.botToken);
331
+ const hasTelegramUserId = Number.isInteger(config.messaging.telegram.userId ?? NaN);
332
+ config.messaging.telegram.enabled = hasTelegramToken && hasTelegramUserId;
333
+ config.messaging.whatsapp.enabled = hasValue(config.messaging.whatsapp.phoneNumber);
334
+ }
335
+ function hasAnyModelKey(config) {
336
+ return MODEL_KEYS.some((key) => hasValue(readConfigValue(config, key)));
337
+ }
338
+ function buildQuestion(field, currentValue) {
339
+ const hasCurrentValue = hasValue(currentValue);
340
+ const suffix = hasCurrentValue
341
+ ? "Press Enter to keep current value, '-' to clear."
342
+ : field.key === 'API_SECRET'
343
+ ? 'Press Enter to auto-generate a secure secret.'
344
+ : field.required
345
+ ? 'Value required.'
346
+ : 'Optional. Press Enter to skip.';
347
+ return `\n${field.label}\n${field.hint}\n${suffix}\n${field.key}: `;
348
+ }
349
+ async function promptYesNo(prompter, question, defaultYes) {
350
+ const defaultLabel = defaultYes ? '[Y/n]' : '[y/N]';
351
+ while (true) {
352
+ const answer = (await prompter.prompt(`${question} ${defaultLabel}: `)).trim().toLowerCase();
353
+ if (answer.length === 0) {
354
+ return defaultYes;
355
+ }
356
+ if (answer === 'y' || answer === 'yes') {
357
+ return true;
358
+ }
359
+ if (answer === 'n' || answer === 'no') {
360
+ return false;
361
+ }
362
+ }
363
+ }
364
+ async function promptEmbeddingProviderValue(currentValue, prompter) {
365
+ const current = currentValue.toLowerCase() === 'ollama' ? 'ollama' : 'openai';
366
+ while (true) {
367
+ const answer = (await prompter.prompt(`\nEmbedding Provider\n1) openai (Recommended)\n2) ollama\nPress Enter to keep '${current}': `))
368
+ .trim()
369
+ .toLowerCase();
370
+ if (answer.length === 0) {
371
+ return undefined;
372
+ }
373
+ if (answer === '1' || answer === 'openai') {
374
+ return 'openai';
375
+ }
376
+ if (answer === '2' || answer === 'ollama') {
377
+ return 'ollama';
378
+ }
379
+ }
380
+ }
381
+ async function promptFieldValue(field, currentValue, prompter, logger) {
382
+ while (true) {
383
+ const answer = await prompter.prompt(buildQuestion(field, currentValue), {
384
+ secret: field.secret === true || SECRET_KEYS.has(field.key),
385
+ });
386
+ if (answer.length === 0) {
387
+ if (field.key === 'API_SECRET' && !hasValue(currentValue)) {
388
+ const generated = randomBytes(24).toString('hex');
389
+ logger.log(' Generated secure API_SECRET automatically.');
390
+ return generated;
391
+ }
392
+ if (hasValue(currentValue)) {
393
+ return undefined;
394
+ }
395
+ if (field.required) {
396
+ logger.warn(` ${field.key} is required.`);
397
+ continue;
398
+ }
399
+ return undefined;
400
+ }
401
+ if (answer === '-') {
402
+ if (field.required) {
403
+ logger.warn(` ${field.key} cannot be cleared because it is required.`);
404
+ continue;
405
+ }
406
+ return '';
407
+ }
408
+ const normalized = answer.trim();
409
+ const validationError = field.validator?.(normalized) ?? null;
410
+ if (validationError) {
411
+ logger.warn(` ${validationError}`);
412
+ continue;
413
+ }
414
+ return normalized;
415
+ }
416
+ }
417
+ async function collectInteractiveUpdates(existing, logger, prompter) {
418
+ const workspaceDir = getWorkspaceDir();
419
+ logger.log('\nTwinBot Onboarding Wizard v2.0');
420
+ logger.log('──────────────────────────────────────────────────');
421
+ logger.log(`Workspace: ${workspaceDir}`);
422
+ logger.log('Model keys, channel preferences, and workspace defaults will be configured.');
423
+ logger.log("Secret prompts are masked. Type '-' to clear an existing optional value.\n");
424
+ const updates = {};
425
+ let candidate = cloneConfig(existing);
426
+ for (const [sectionIndex, section] of WIZARD_SECTIONS.entries()) {
427
+ while (true) {
428
+ logger.log(`\nStep ${sectionIndex + 1}/${WIZARD_SECTIONS.length}: ${section.label}`);
429
+ logger.log('──────────────────────────────────────────────────');
430
+ let telegramSelected = true;
431
+ let whatsappSelected = true;
432
+ if (section.id === 'messaging') {
433
+ const telegramCurrentlyEnabled = hasValue(candidate.messaging.telegram.botToken) ||
434
+ Number.isInteger(candidate.messaging.telegram.userId ?? NaN);
435
+ const whatsappCurrentlyEnabled = hasValue(candidate.messaging.whatsapp.phoneNumber);
436
+ telegramSelected = await promptYesNo(prompter, 'Configure Telegram in this run?', telegramCurrentlyEnabled);
437
+ whatsappSelected = await promptYesNo(prompter, 'Configure WhatsApp in this run?', whatsappCurrentlyEnabled);
438
+ }
439
+ for (const fieldKey of section.fields) {
440
+ if (section.id === 'messaging' &&
441
+ ((fieldKey === 'TELEGRAM_BOT_TOKEN' || fieldKey === 'TELEGRAM_USER_ID') && !telegramSelected)) {
442
+ continue;
443
+ }
444
+ if (section.id === 'messaging' && fieldKey === 'WHATSAPP_PHONE_NUMBER' && !whatsappSelected) {
445
+ continue;
446
+ }
447
+ const field = ONBOARD_FIELDS.find((item) => item.key === fieldKey);
448
+ if (!field) {
449
+ continue;
450
+ }
451
+ const current = readConfigValue(candidate, field.key);
452
+ const nextValue = field.key === 'EMBEDDING_PROVIDER'
453
+ ? await promptEmbeddingProviderValue(current, prompter)
454
+ : await promptFieldValue(field, current, prompter, logger);
455
+ if (nextValue !== undefined) {
456
+ updates[field.key] = nextValue;
457
+ candidate = applyUpdates(candidate, { [field.key]: nextValue });
458
+ }
459
+ }
460
+ if (section.id === 'models' && !hasAnyModelKey(candidate)) {
461
+ logger.warn('\nAt least one model API key is required (OpenRouter, Modal, or Gemini).');
462
+ continue;
463
+ }
464
+ if (section.id === 'messaging') {
465
+ const telegramToken = (candidate.messaging.telegram.botToken ?? '').trim();
466
+ const telegramUserId = candidate.messaging.telegram.userId;
467
+ if (hasValue(telegramToken) && !telegramUserId) {
468
+ logger.warn('\nTELEGRAM_USER_ID is required when TELEGRAM_BOT_TOKEN is provided.');
469
+ continue;
470
+ }
471
+ if (!hasValue(telegramToken) && telegramUserId) {
472
+ logger.warn('\nTELEGRAM_BOT_TOKEN is required when TELEGRAM_USER_ID is provided.');
473
+ continue;
474
+ }
475
+ }
476
+ break;
477
+ }
478
+ }
479
+ while (true) {
480
+ logger.log('\nSummary of Configuration:');
481
+ logger.log('──────────────────────────────────────────────────');
482
+ for (const field of ONBOARD_FIELDS) {
483
+ const val = readConfigValue(candidate, field.key);
484
+ const displayVal = field.secret || SECRET_KEYS.has(field.key)
485
+ ? val
486
+ ? '********'
487
+ : '(not set)'
488
+ : val || '(not set)';
489
+ logger.log(` ${field.label}: ${displayVal}`);
490
+ }
491
+ const choice = await prompter.prompt('\nConfirm configuration? [y]es, [n]o (cancel), [e]dit: ');
492
+ const lower = choice.toLowerCase();
493
+ if (lower === 'y' || lower === 'yes') {
494
+ if (!hasAnyModelKey(candidate)) {
495
+ logger.warn('\nAt least one model API key is required (OpenRouter, Modal, or Gemini).');
496
+ continue;
497
+ }
498
+ return updates;
499
+ }
500
+ if (lower === 'n' || lower === 'no') {
501
+ throw new OnboardingCancelledError();
502
+ }
503
+ if (lower === 'e' || lower === 'edit') {
504
+ logger.log('\nSelect section to edit:');
505
+ WIZARD_SECTIONS.forEach((s, i) => {
506
+ logger.log(` ${i + 1}. ${s.label}`);
507
+ });
508
+ const sectionIdxStr = await prompter.prompt('\nSection number: ');
509
+ const sectionIdx = Number.parseInt(sectionIdxStr, 10) - 1;
510
+ if (WIZARD_SECTIONS[sectionIdx]) {
511
+ const section = WIZARD_SECTIONS[sectionIdx];
512
+ logger.log(`\nEditing Section: ${section.label}`);
513
+ logger.log('──────────────────────────────────────────────────');
514
+ for (const fieldKey of section.fields) {
515
+ const field = ONBOARD_FIELDS.find((item) => item.key === fieldKey);
516
+ if (!field) {
517
+ continue;
518
+ }
519
+ const current = readConfigValue(candidate, field.key);
520
+ const nextValue = field.key === 'EMBEDDING_PROVIDER'
521
+ ? await promptEmbeddingProviderValue(current, prompter)
522
+ : await promptFieldValue(field, current, prompter, logger);
523
+ if (nextValue !== undefined) {
524
+ updates[field.key] = nextValue;
525
+ candidate = applyUpdates(candidate, { [field.key]: nextValue });
526
+ }
527
+ }
528
+ }
529
+ else {
530
+ logger.warn('Invalid section number.');
531
+ }
532
+ continue;
533
+ }
534
+ logger.warn("Please enter 'y', 'n', or 'e'.");
535
+ }
536
+ }
537
+ export function validateOnboardConfig(config) {
538
+ const errors = [];
539
+ const warnings = [];
540
+ if (!hasValue(config.runtime.apiSecret)) {
541
+ errors.push('API_SECRET is required.');
542
+ }
543
+ if (!hasAnyModelKey(config)) {
544
+ errors.push('At least one model key must be configured (OPENROUTER_API_KEY, MODAL_API_KEY, or GEMINI_API_KEY).');
545
+ }
546
+ const telegramToken = (config.messaging.telegram.botToken ?? '').trim();
547
+ const telegramUserId = config.messaging.telegram.userId === null ? '' : String(config.messaging.telegram.userId);
548
+ if (hasValue(telegramToken) && !hasValue(telegramUserId)) {
549
+ errors.push('TELEGRAM_USER_ID is required when TELEGRAM_BOT_TOKEN is provided.');
550
+ }
551
+ if (!hasValue(telegramToken) && hasValue(telegramUserId)) {
552
+ errors.push('TELEGRAM_BOT_TOKEN is required when TELEGRAM_USER_ID is provided.');
553
+ }
554
+ if (hasValue(telegramUserId)) {
555
+ const parsed = Number(telegramUserId);
556
+ if (!Number.isInteger(parsed) || parsed <= 0) {
557
+ errors.push('TELEGRAM_USER_ID must be a positive integer.');
558
+ }
559
+ }
560
+ const whatsapp = (config.messaging.whatsapp.phoneNumber ?? '').trim();
561
+ if (hasValue(whatsapp) && !/^\+?[1-9]\d{6,14}$/.test(whatsapp)) {
562
+ errors.push('WHATSAPP_PHONE_NUMBER must be in E.164-like format (example: +15551234567).');
563
+ }
564
+ const provider = (config.integration.embeddingProvider ?? '').trim().toLowerCase();
565
+ if (!EMBEDDING_PROVIDERS.has(provider)) {
566
+ errors.push("EMBEDDING_PROVIDER must be 'openai' or 'ollama'.");
567
+ }
568
+ if (!Number.isInteger(config.runtime.apiPort) || config.runtime.apiPort < 1 || config.runtime.apiPort > 65535) {
569
+ errors.push('API_PORT must be an integer in range 1-65535.');
570
+ }
571
+ const messagingEnabled = hasValue(telegramToken) || hasValue(whatsapp);
572
+ if (messagingEnabled && !hasValue(config.messaging.voice.groqApiKey)) {
573
+ warnings.push('Messaging is configured without GROQ_API_KEY. Voice and STT features will be unavailable.');
574
+ }
575
+ if (!messagingEnabled) {
576
+ warnings.push('No messaging channel configured yet. You can add one later via `channels login`.');
577
+ }
578
+ return { errors, warnings };
579
+ }
580
+ function printWarnings(warnings, logger) {
581
+ if (warnings.length === 0) {
582
+ return;
583
+ }
584
+ logger.warn('\nOptional integration warnings:');
585
+ for (const warning of warnings) {
586
+ logger.warn(` - ${warning}`);
587
+ }
588
+ }
589
+ function printNextActions(logger) {
590
+ logger.log('\nNext actions:');
591
+ logger.log(' 1. twinbot doctor');
592
+ logger.log(' 2. twinbot channels login whatsapp');
593
+ logger.log(' 3. twinbot pairing approve <channel> <CODE>');
594
+ }
595
+ export function parseOnboardArgs(args) {
596
+ const parsed = {
597
+ help: false,
598
+ nonInteractive: false,
599
+ values: {},
600
+ };
601
+ for (let i = 0; i < args.length; i += 1) {
602
+ const token = args[i];
603
+ if (token === '--help' || token === '-h') {
604
+ parsed.help = true;
605
+ continue;
606
+ }
607
+ if (token === '--non-interactive') {
608
+ parsed.nonInteractive = true;
609
+ continue;
610
+ }
611
+ if (token === '--config') {
612
+ const nextValue = args[i + 1];
613
+ if (!nextValue) {
614
+ parsed.error = 'Missing value for --config.';
615
+ return parsed;
616
+ }
617
+ parsed.configPathOverride = nextValue;
618
+ i += 1;
619
+ continue;
620
+ }
621
+ const mappedKey = ONBOARD_FLAG_TO_KEY[token];
622
+ if (mappedKey) {
623
+ const nextValue = args[i + 1];
624
+ if (nextValue === undefined) {
625
+ parsed.error = `Missing value for ${token}.`;
626
+ return parsed;
627
+ }
628
+ parsed.values[mappedKey] = nextValue;
629
+ i += 1;
630
+ continue;
631
+ }
632
+ parsed.error = `Unknown option '${token}'.`;
633
+ return parsed;
634
+ }
635
+ return parsed;
636
+ }
637
+ function printOnboardUsage() {
638
+ console.log(`Onboard command usage:
639
+ onboard Run interactive onboarding wizard
640
+ onboard --non-interactive [flags] Run scripted onboarding mode
641
+
642
+ Options:
643
+ --config <path> Override twinbot.json output path
644
+ --api-secret <value> Set API_SECRET
645
+ --openrouter-api-key <value> Set OPENROUTER_API_KEY
646
+ --modal-api-key <value> Set MODAL_API_KEY
647
+ --gemini-api-key <value> Set GEMINI_API_KEY
648
+ --groq-api-key <value> Set GROQ_API_KEY
649
+ --telegram-bot-token <value> Set TELEGRAM_BOT_TOKEN
650
+ --telegram-user-id <value> Set TELEGRAM_USER_ID
651
+ --whatsapp-phone-number <value> Set WHATSAPP_PHONE_NUMBER
652
+ --embedding-provider <openai|ollama> Set EMBEDDING_PROVIDER
653
+ --api-port <1-65535> Set API_PORT`);
654
+ }
655
+ export async function runSetupWizard(options = {}) {
656
+ const logger = options.logger ?? console;
657
+ const configPath = getConfigPath(options.configPathOverride);
658
+ const baseConfig = await readConfig(options.configPathOverride);
659
+ // Keep default provider deterministic even when config has empty string.
660
+ if (!hasValue(baseConfig.integration.embeddingProvider)) {
661
+ baseConfig.integration.embeddingProvider = 'openai';
662
+ }
663
+ let updates = {};
664
+ const ownPrompter = options.nonInteractive || options.prompter ? null : new ReadlinePrompter();
665
+ const prompter = options.prompter ?? ownPrompter;
666
+ try {
667
+ if (options.nonInteractive) {
668
+ updates = options.providedValues ?? {};
669
+ }
670
+ else {
671
+ if (!prompter) {
672
+ throw new Error('Interactive onboarding requires an available prompt session.');
673
+ }
674
+ updates = await collectInteractiveUpdates(baseConfig, logger, prompter);
675
+ }
676
+ }
677
+ catch (error) {
678
+ if (error instanceof OnboardingCancelledError) {
679
+ logger.warn('\nOnboarding cancelled. No configuration changes were written.');
680
+ await logThought('Setup wizard cancelled before persistence.');
681
+ return {
682
+ status: 'cancelled',
683
+ configPath,
684
+ warnings: [],
685
+ errors: [error.message],
686
+ };
687
+ }
688
+ throw error;
689
+ }
690
+ finally {
691
+ ownPrompter?.close();
692
+ }
693
+ const candidate = applyUpdates(baseConfig, updates);
694
+ const validation = validateOnboardConfig(candidate);
695
+ if (validation.errors.length > 0) {
696
+ logger.error('\nOnboarding validation failed. No configuration changes were written.');
697
+ for (const error of validation.errors) {
698
+ logger.error(` - ${error}`);
699
+ }
700
+ await logThought(`Setup wizard validation failed (${validation.errors.length} issue(s)).`);
701
+ return {
702
+ status: 'validation_error',
703
+ configPath,
704
+ warnings: validation.warnings,
705
+ errors: validation.errors,
706
+ };
707
+ }
708
+ await writeConfig(candidate, options.configPathOverride);
709
+ reloadConfig();
710
+ initializeWorkspace();
711
+ ensureIdentityFiles();
712
+ logger.log(`\nConfiguration saved to ${configPath}.`);
713
+ logger.log(`Workspace initialized at ${getWorkspaceDir()}.`);
714
+ printWarnings(validation.warnings, logger);
715
+ printNextActions(logger);
716
+ await logThought('Setup wizard completed and twinbot.json updated.');
717
+ return {
718
+ status: 'success',
719
+ configPath,
720
+ warnings: validation.warnings,
721
+ errors: [],
722
+ };
723
+ }
724
+ export async function handleOnboardCli(argv) {
725
+ if (argv[0] !== 'onboard') {
726
+ return false;
727
+ }
728
+ const parsed = parseOnboardArgs(argv.slice(1));
729
+ if (parsed.help) {
730
+ printOnboardUsage();
731
+ process.exitCode = 0;
732
+ return true;
733
+ }
734
+ if (parsed.error) {
735
+ console.error(`[TwinBot Onboard] ${parsed.error}`);
736
+ printOnboardUsage();
737
+ process.exitCode = 1;
738
+ return true;
739
+ }
740
+ if (!parsed.nonInteractive && Object.keys(parsed.values).length > 0) {
741
+ console.error('[TwinBot Onboard] Value flags require --non-interactive. Use `onboard` alone for guided prompts.');
742
+ process.exitCode = 1;
743
+ return true;
744
+ }
745
+ const result = await runSetupWizard({
746
+ nonInteractive: parsed.nonInteractive,
747
+ providedValues: parsed.values,
748
+ configPathOverride: parsed.configPathOverride,
749
+ });
750
+ if (result.status === 'success') {
751
+ process.exitCode = 0;
752
+ }
753
+ else if (result.status === 'cancelled') {
754
+ process.exitCode = 130;
755
+ }
756
+ else {
757
+ process.exitCode = 1;
758
+ }
759
+ return true;
760
+ }