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,214 @@
1
+ import fs from 'node:fs';
2
+ import { logThought } from '../utils/logger.js';
3
+ import { getDmPairingService, normalizePairingSenderId, } from '../services/dm-pairing.js';
4
+ import { InboundDebounceService } from '../services/inbound-debounce.js';
5
+ import { EmbeddedBlockChunker } from '../services/block-chunker.js';
6
+ import { getConfigValue } from '../config/config-loader.js';
7
+ const DEFAULT_ACCESS_CONFIG = {
8
+ dmPolicy: 'pairing',
9
+ allowFrom: [],
10
+ };
11
+ function buildPairingChallenge(channel, code) {
12
+ return (`[TwinBot] Pairing required before I can process your messages on ${channel}.\n` +
13
+ `Run: twinbot pairing approve ${channel} ${code}`);
14
+ }
15
+ /**
16
+ * Unified Interface Dispatcher
17
+ *
18
+ * Connects all active interface adapters (Telegram, WhatsApp, โ€ฆ) to the core gateway.
19
+ * Responsibilities:
20
+ * 1. Register as the message callback on each adapter.
21
+ * 2. Transcribe voice notes via the STT service before forwarding to the gateway.
22
+ * 3. Route the normalized InboundMessage to the gateway for processing.
23
+ * 4. Send the gateway's text response back to the originating platform
24
+ * with retry/backoff and delivery tracking.
25
+ * 5. Clean up ephemeral audio files after transcription.
26
+ *
27
+ * Adding a new interface adapter in the future only requires registering it here.
28
+ */
29
+ export class Dispatcher {
30
+ #telegram;
31
+ #whatsapp;
32
+ #stt;
33
+ #tts;
34
+ #gateway;
35
+ #queue;
36
+ #pairingService;
37
+ #accessConfig;
38
+ #debounce;
39
+ #chunker;
40
+ #humanDelayMs;
41
+ constructor(telegram, whatsapp, stt, tts, gateway, queue, options = {}) {
42
+ this.#telegram = telegram;
43
+ this.#whatsapp = whatsapp;
44
+ this.#stt = stt;
45
+ this.#tts = tts;
46
+ this.#gateway = gateway;
47
+ this.#queue = queue;
48
+ this.#pairingService = options.pairingService ?? getDmPairingService();
49
+ this.#accessConfig = {
50
+ telegram: this.#resolveAccessConfig('telegram', options.telegram),
51
+ whatsapp: this.#resolveAccessConfig('whatsapp', options.whatsapp),
52
+ };
53
+ this.#debounce = new InboundDebounceService(options.debounce);
54
+ const streamingEnabled = getConfigValue('BLOCK_STREAMING_DEFAULT') === 'true';
55
+ if (streamingEnabled) {
56
+ this.#chunker = new EmbeddedBlockChunker({
57
+ minChars: Number(getConfigValue('BLOCK_STREAMING_MIN_CHARS')) || 50,
58
+ maxChars: Number(getConfigValue('BLOCK_STREAMING_MAX_CHARS')) || 800,
59
+ breakOn: getConfigValue('BLOCK_STREAMING_BREAK') || 'paragraph',
60
+ coalesce: getConfigValue('BLOCK_STREAMING_COALESCE') !== 'false',
61
+ });
62
+ this.#humanDelayMs = Number(getConfigValue('HUMAN_DELAY_MS')) || 800;
63
+ }
64
+ else {
65
+ this.#chunker = new EmbeddedBlockChunker({
66
+ minChars: options.streaming?.minChars ?? 50,
67
+ maxChars: options.streaming?.maxChars ?? 800,
68
+ breakOn: options.streaming?.breakOn ?? 'paragraph',
69
+ coalesce: options.streaming?.coalesce ?? true,
70
+ });
71
+ this.#humanDelayMs = options.streaming?.humanDelayMs ?? 0;
72
+ }
73
+ // Wire inbound message callbacks through debounce layer.
74
+ if (this.#telegram)
75
+ this.#telegram.onMessage = (msg) => this.#handleDebounced(msg);
76
+ if (this.#whatsapp)
77
+ this.#whatsapp.onMessage = (msg) => this.#handleDebounced(msg);
78
+ }
79
+ /** Expose the queue service for reliability and dead-letter controls. */
80
+ get queue() {
81
+ return this.#queue;
82
+ }
83
+ get debounceService() {
84
+ return this.#debounce;
85
+ }
86
+ // โ”€โ”€ Core Dispatch Loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
+ async #handleDebounced(message) {
88
+ try {
89
+ const debounced = await this.#debounce.debounce(message);
90
+ await this.#handle(debounced);
91
+ }
92
+ catch (err) {
93
+ console.error('[Dispatcher] Unhandled error in debounce handling:', err);
94
+ await logThought(`[Dispatcher] Debounce error: ${err instanceof Error ? err.message : String(err)}`);
95
+ }
96
+ }
97
+ async #handle(message) {
98
+ try {
99
+ const access = this.#authorizeSender(message);
100
+ if (!access.allowed) {
101
+ if (access.challengeText) {
102
+ this.#queue.enqueue(message.platform, message.chatId, access.challengeText);
103
+ }
104
+ return;
105
+ }
106
+ const normalized = await this.#resolveAudio(message);
107
+ const responseText = await this.#gateway.processMessage(normalized);
108
+ await this.#dispatch(normalized, responseText);
109
+ }
110
+ catch (err) {
111
+ console.error('[Dispatcher] Unhandled error processing message:', err);
112
+ await logThought(`[Dispatcher] Unhandled error: ${err instanceof Error ? err.message : String(err)}`);
113
+ }
114
+ }
115
+ #resolveAccessConfig(channel, config) {
116
+ const dmPolicy = config?.dmPolicy === 'allowlist' ? 'allowlist' : 'pairing';
117
+ const allowFrom = [...new Set((config?.allowFrom ?? DEFAULT_ACCESS_CONFIG.allowFrom)
118
+ .map((senderId) => normalizePairingSenderId(channel, senderId))
119
+ .filter((senderId) => senderId.length > 0))];
120
+ this.#pairingService.seedAllowFrom(channel, allowFrom);
121
+ return { dmPolicy, allowFrom };
122
+ }
123
+ #authorizeSender(message) {
124
+ const channel = message.platform;
125
+ const normalizedSenderId = normalizePairingSenderId(channel, message.senderId);
126
+ if (!normalizedSenderId) {
127
+ return { allowed: false };
128
+ }
129
+ const config = this.#accessConfig[message.platform];
130
+ const allowlist = new Set(config.allowFrom);
131
+ if (allowlist.has(normalizedSenderId) || this.#pairingService.isApproved(channel, normalizedSenderId)) {
132
+ return { allowed: true };
133
+ }
134
+ if (config.dmPolicy !== 'pairing') {
135
+ return { allowed: false };
136
+ }
137
+ const request = this.#pairingService.requestPairing(channel, normalizedSenderId);
138
+ if (request.status === 'created' && request.request) {
139
+ return {
140
+ allowed: false,
141
+ challengeText: buildPairingChallenge(channel, request.request.code),
142
+ };
143
+ }
144
+ return { allowed: false };
145
+ }
146
+ /**
147
+ * If the message contains an audio file, transcribe it and substitute the
148
+ * result as `text`. The temp file is deleted after transcription regardless
149
+ * of the outcome (best-effort cleanup).
150
+ */
151
+ async #resolveAudio(message) {
152
+ if (!message.audioFilePath)
153
+ return message;
154
+ const filePath = message.audioFilePath;
155
+ let transcribedText;
156
+ try {
157
+ transcribedText = await this.#stt.transcribeFile(filePath);
158
+ }
159
+ finally {
160
+ fs.unlink(filePath, (unlinkErr) => {
161
+ if (unlinkErr) {
162
+ console.warn('[Dispatcher] Could not delete temp audio file:', unlinkErr.message);
163
+ }
164
+ });
165
+ }
166
+ return { ...message, audioFilePath: undefined, text: transcribedText };
167
+ }
168
+ /**
169
+ * Route the gateway's response back to the originating platform
170
+ * via the persistent delivery queue. Chunks text if block streaming is enabled.
171
+ */
172
+ async #dispatch(origin, responseText) {
173
+ const safeText = EmbeddedBlockChunker.ensureCodeFenceClosed(responseText);
174
+ const chunks = this.#chunker.chunk(safeText);
175
+ if (chunks.length <= 1) {
176
+ this.#queue.enqueue(origin.platform, origin.chatId, responseText);
177
+ return;
178
+ }
179
+ for (let i = 0; i < chunks.length; i++) {
180
+ const isLast = i === chunks.length - 1;
181
+ const chunk = chunks[i];
182
+ this.#queue.enqueue(origin.platform, origin.chatId, chunk);
183
+ if (!isLast && this.#humanDelayMs > 0) {
184
+ await new Promise((resolve) => setTimeout(resolve, this.#humanDelayMs));
185
+ }
186
+ }
187
+ }
188
+ /**
189
+ * Send a proactive (agent-initiated) message to a specific platform target
190
+ * via the persistent delivery queue. Uses chunking if enabled.
191
+ */
192
+ async sendProactive(platform, chatId, text) {
193
+ const safeText = EmbeddedBlockChunker.ensureCodeFenceClosed(text);
194
+ const chunks = this.#chunker.chunk(safeText);
195
+ if (chunks.length <= 1) {
196
+ this.#queue.enqueue(platform, chatId, text);
197
+ return;
198
+ }
199
+ for (let i = 0; i < chunks.length; i++) {
200
+ const isLast = i === chunks.length - 1;
201
+ const chunk = chunks[i];
202
+ this.#queue.enqueue(platform, chatId, chunk);
203
+ if (!isLast && this.#humanDelayMs > 0) {
204
+ await new Promise((resolve) => setTimeout(resolve, this.#humanDelayMs));
205
+ }
206
+ }
207
+ }
208
+ /** Tear down all active interface adapters cleanly. */
209
+ shutdown() {
210
+ this.#debounce.clear();
211
+ this.#telegram?.stop();
212
+ this.#whatsapp?.stop();
213
+ }
214
+ }
@@ -0,0 +1,82 @@
1
+ import TelegramBot from 'node-telegram-bot-api';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /** Minimum ms delay between processing successive messages (human-like pacing). */
5
+ const RATE_LIMIT_MS = 1500;
6
+ /**
7
+ * Wraps the Telegram Bot API to provide:
8
+ * - Inbound message normalization for the dispatcher
9
+ * - Human-like rate limiting between messages
10
+ * - Normalized InboundMessage delivery including voice-note file paths
11
+ */
12
+ export class TelegramHandler {
13
+ #bot;
14
+ #lastMessageAt = 0;
15
+ /**
16
+ * @param token - Telegram Bot token from @BotFather (TELEGRAM_BOT_TOKEN).
17
+ */
18
+ constructor(token) {
19
+ this.#bot = new TelegramBot(token, { polling: true });
20
+ this.#registerListeners();
21
+ }
22
+ /** Callback invoked by the dispatcher for every authorized, normalized message. */
23
+ onMessage;
24
+ // โ”€โ”€ Private Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
+ async #applyRateLimit() {
26
+ const elapsed = Date.now() - this.#lastMessageAt;
27
+ if (elapsed < RATE_LIMIT_MS) {
28
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_MS - elapsed));
29
+ }
30
+ this.#lastMessageAt = Date.now();
31
+ }
32
+ #registerListeners() {
33
+ this.#bot.on('message', async (msg) => {
34
+ if (!msg.from)
35
+ return;
36
+ await this.#applyRateLimit();
37
+ const base = {
38
+ platform: 'telegram',
39
+ senderId: String(msg.from.id),
40
+ chatId: msg.chat.id,
41
+ text: msg.text,
42
+ rawPayload: msg,
43
+ };
44
+ // Voice note: download to tmp dir, transcription happens in the dispatcher.
45
+ if (msg.voice) {
46
+ try {
47
+ const tmpDir = os.tmpdir();
48
+ const localPath = await this.#bot.downloadFile(msg.voice.file_id, tmpDir);
49
+ const inbound = {
50
+ ...base,
51
+ audioFilePath: path.resolve(localPath),
52
+ };
53
+ await this.onMessage?.(inbound);
54
+ }
55
+ catch (err) {
56
+ console.error('[TelegramHandler] Failed to download voice note:', err);
57
+ }
58
+ return;
59
+ }
60
+ await this.onMessage?.(base);
61
+ });
62
+ this.#bot.on('polling_error', (err) => {
63
+ console.error('[TelegramHandler] Polling error:', err.message);
64
+ });
65
+ }
66
+ // โ”€โ”€ Public Send Methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+ /** Send a plain-text reply to a chat. */
68
+ async sendText(chatId, text) {
69
+ await this.#bot.sendMessage(Number(chatId), text);
70
+ }
71
+ /**
72
+ * Send a synthesized voice reply as an audio/wav message.
73
+ * @param audio - Raw WAV buffer produced by the TTS service.
74
+ */
75
+ async sendVoice(chatId, audio) {
76
+ await this.#bot.sendVoice(Number(chatId), audio, {}, { contentType: 'audio/wav', filename: 'response.wav' });
77
+ }
78
+ /** Gracefully stop the polling loop. */
79
+ stop() {
80
+ this.#bot.stopPolling();
81
+ }
82
+ }
@@ -0,0 +1,53 @@
1
+ import blessed from 'blessed';
2
+ import contrib from 'blessed-contrib';
3
+ // Capture native logs so we can pipe them to the dashboard log view
4
+ const nativeLog = console.log;
5
+ const nativeError = console.error;
6
+ export function startTUI() {
7
+ const screen = blessed.screen({ smartCSR: true });
8
+ screen.title = 'TwinBot Native Dashboard';
9
+ const grid = new contrib.grid({ rows: 12, cols: 12, screen: screen });
10
+ const logView = grid.set(0, 0, 8, 8, contrib.log, {
11
+ fg: "green",
12
+ selectedFg: "green",
13
+ label: ' Live System Logs '
14
+ });
15
+ const memoryDonut = grid.set(0, 8, 4, 4, contrib.donut, {
16
+ label: ' System Memory ',
17
+ radius: 12,
18
+ arcWidth: 4,
19
+ yPadding: 2
20
+ });
21
+ const providerView = grid.set(4, 8, 4, 4, contrib.markdown, {
22
+ label: ' Active Provider '
23
+ });
24
+ const statusView = grid.set(8, 0, 4, 12, contrib.markdown, {
25
+ label: ' TwinBot Status '
26
+ });
27
+ // Replace console defaults
28
+ console.log = (...args) => {
29
+ const text = args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ');
30
+ logView.log(text);
31
+ screen.render();
32
+ };
33
+ console.error = (...args) => {
34
+ const text = args.map(a => typeof a === 'object' ? JSON.stringify(a) : a).join(' ');
35
+ logView.log(`{red-fg}[ERROR]{/red-fg} ${text}`);
36
+ screen.render();
37
+ };
38
+ setInterval(() => {
39
+ const mem = process.memoryUsage();
40
+ const percent = Math.round((mem.heapUsed / mem.heapTotal) * 100);
41
+ memoryDonut.update([
42
+ { percent: percent, label: 'Heap', color: 'blue' }
43
+ ]);
44
+ providerView.setMarkdown(`**Current Target:**\nOpenRouter (Claude 3.5 Sonnet)\n\n**Fallback Chains:**\n1. Google AI Studio\n2. Modal Serverless\n\n**Status:** Online ๐ŸŸข`);
45
+ statusView.setMarkdown(`TwinBot Autonomous Service running.\nMemory: Local SQLite + sqlite-vec.\nBackground Job Engine: Active.\n\nPress **Escape**, **q**, or **C-c** to quit.`);
46
+ screen.render();
47
+ }, 1000);
48
+ screen.key(['escape', 'q', 'C-c'], () => {
49
+ return process.exit(0);
50
+ });
51
+ console.log('TUI Initialized. Monitoring agent activities...');
52
+ screen.render();
53
+ }
@@ -0,0 +1,94 @@
1
+ import WAWebJS from 'whatsapp-web.js';
2
+ import qrcode from 'qrcode-terminal';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs/promises';
6
+ import { randomUUID } from 'node:crypto';
7
+ const { Client, LocalAuth, MessageMedia } = WAWebJS;
8
+ const RATE_LIMIT_MS = 1500;
9
+ export class WhatsAppHandler {
10
+ #client;
11
+ #lastMessageAt = 0;
12
+ constructor() {
13
+ this.#client = new Client({
14
+ authStrategy: new LocalAuth({ dataPath: './memory/whatsapp_auth' }),
15
+ puppeteer: {
16
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
17
+ }
18
+ });
19
+ this.#registerListeners();
20
+ }
21
+ onMessage;
22
+ async #applyRateLimit() {
23
+ const elapsed = Date.now() - this.#lastMessageAt;
24
+ if (elapsed < RATE_LIMIT_MS) {
25
+ await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_MS - elapsed));
26
+ }
27
+ this.#lastMessageAt = Date.now();
28
+ }
29
+ #registerListeners() {
30
+ this.#client.on('qr', (qr) => {
31
+ console.log('[WhatsAppHandler] Scan this QR code to authenticate:');
32
+ qrcode.generate(qr, { small: true });
33
+ });
34
+ this.#client.on('ready', () => {
35
+ console.log('[WhatsAppHandler] Client is ready!');
36
+ });
37
+ this.#client.on('message', async (msg) => {
38
+ await this.#applyRateLimit();
39
+ const base = {
40
+ platform: 'whatsapp',
41
+ senderId: msg.from,
42
+ chatId: msg.from,
43
+ text: msg.body,
44
+ rawPayload: msg,
45
+ };
46
+ if (msg.hasMedia) {
47
+ try {
48
+ const media = await msg.downloadMedia();
49
+ // If it's audio (voice note or audio file)
50
+ if (media && media.mimetype.startsWith('audio/')) {
51
+ const tmpDir = os.tmpdir();
52
+ let ext = '.ogg'; // Default for voice notes
53
+ if (media.mimetype.includes('mp3'))
54
+ ext = '.mp3';
55
+ else if (media.mimetype.includes('mp4'))
56
+ ext = '.mp4';
57
+ const fileName = `wa_${randomUUID()}${ext}`;
58
+ const localPath = path.join(tmpDir, fileName);
59
+ const buffer = Buffer.from(media.data, 'base64');
60
+ await fs.writeFile(localPath, buffer);
61
+ const inbound = {
62
+ ...base,
63
+ audioFilePath: localPath,
64
+ };
65
+ await this.onMessage?.(inbound);
66
+ return;
67
+ }
68
+ }
69
+ catch (err) {
70
+ console.error('[WhatsAppHandler] Failed to download media:', err);
71
+ }
72
+ }
73
+ // Exclude empty bodies unless they were intercepted voice notes
74
+ if (msg.body) {
75
+ await this.onMessage?.(base);
76
+ }
77
+ });
78
+ this.#client.initialize().catch(err => {
79
+ console.error('[WhatsAppHandler] Failed to initialize client:', err);
80
+ });
81
+ }
82
+ async sendText(chatId, text) {
83
+ await this.#client.sendMessage(chatId, text);
84
+ }
85
+ async sendVoice(chatId, audio) {
86
+ const media = new MessageMedia('audio/wav', audio.toString('base64'), 'response.wav');
87
+ await this.#client.sendMessage(chatId, media, { sendAudioAsVoice: true });
88
+ }
89
+ stop() {
90
+ this.#client.destroy().catch(err => {
91
+ console.error('[WhatsAppHandler] Failed to destroy client:', err);
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,97 @@
1
+ import { ReleasePipelineService } from '../services/release-pipeline.js';
2
+ function parseArgs(argv) {
3
+ const [command, ...rest] = argv;
4
+ const parsed = { command };
5
+ for (let index = 0; index < rest.length; index += 1) {
6
+ const token = rest[index];
7
+ const next = rest[index + 1];
8
+ if (token === '--health-url' && next) {
9
+ parsed.healthUrl = next;
10
+ index += 1;
11
+ continue;
12
+ }
13
+ if (token === '--snapshot' && next) {
14
+ parsed.snapshotId = next;
15
+ index += 1;
16
+ continue;
17
+ }
18
+ if (token === '--restart-command' && next) {
19
+ parsed.restartCommand = next;
20
+ index += 1;
21
+ continue;
22
+ }
23
+ if (token === '--release-id' && next) {
24
+ parsed.releaseId = next;
25
+ index += 1;
26
+ continue;
27
+ }
28
+ if (token === '--retention' && next) {
29
+ const parsedNumber = Number(next);
30
+ if (Number.isFinite(parsedNumber) && parsedNumber >= 1) {
31
+ parsed.retentionLimit = Math.floor(parsedNumber);
32
+ }
33
+ index += 1;
34
+ continue;
35
+ }
36
+ }
37
+ return parsed;
38
+ }
39
+ function printUsage() {
40
+ console.error([
41
+ 'Usage:',
42
+ ' tsx src/release/cli.ts preflight [--health-url <url>]',
43
+ ' tsx src/release/cli.ts prepare [--health-url <url>] [--release-id <id>] [--retention <n>]',
44
+ ' tsx src/release/cli.ts rollback [--snapshot <id>] [--health-url <url>] [--restart-command "<cmd>"]',
45
+ ' tsx src/release/cli.ts drill [--snapshot <id>] [--health-url <url>]',
46
+ ].join('\n'));
47
+ }
48
+ function printJson(payload) {
49
+ console.log(JSON.stringify(payload, null, 2));
50
+ }
51
+ async function main() {
52
+ const parsed = parseArgs(process.argv.slice(2));
53
+ if (!parsed.command) {
54
+ printUsage();
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+ const service = new ReleasePipelineService();
59
+ if (parsed.command === 'preflight') {
60
+ const result = await service.runPreflight({ healthUrl: parsed.healthUrl });
61
+ printJson(result);
62
+ process.exitCode = result.passed ? 0 : 1;
63
+ return;
64
+ }
65
+ if (parsed.command === 'prepare') {
66
+ const manifest = await service.prepareRelease({
67
+ healthUrl: parsed.healthUrl,
68
+ releaseId: parsed.releaseId,
69
+ retentionLimit: parsed.retentionLimit,
70
+ });
71
+ printJson(manifest);
72
+ process.exitCode = manifest.status === 'ready' ? 0 : 1;
73
+ return;
74
+ }
75
+ if (parsed.command === 'rollback') {
76
+ const result = await service.rollback({
77
+ snapshotId: parsed.snapshotId,
78
+ healthUrl: parsed.healthUrl,
79
+ restartCommand: parsed.restartCommand,
80
+ });
81
+ printJson(result);
82
+ process.exitCode = result.status === 'failed' ? 1 : 0;
83
+ return;
84
+ }
85
+ if (parsed.command === 'drill') {
86
+ const result = await service.runDrill({
87
+ snapshotId: parsed.snapshotId,
88
+ healthUrl: parsed.healthUrl,
89
+ });
90
+ printJson(result);
91
+ process.exitCode = result.status === 'passed' ? 0 : 1;
92
+ return;
93
+ }
94
+ printUsage();
95
+ process.exitCode = 1;
96
+ }
97
+ void main();
@@ -0,0 +1,118 @@
1
+ import { MvpGateService } from '../services/mvp-gate.js';
2
+ function parseArgs(argv) {
3
+ const parsed = { skipHealth: false, ci: false };
4
+ for (let i = 0; i < argv.length; i += 1) {
5
+ const token = argv[i];
6
+ const next = argv[i + 1];
7
+ if (token === '--health-url' && next) {
8
+ parsed.healthUrl = next;
9
+ i += 1;
10
+ continue;
11
+ }
12
+ if (token === '--report-dir' && next) {
13
+ parsed.reportDir = next;
14
+ i += 1;
15
+ continue;
16
+ }
17
+ if (token === '--skip-health') {
18
+ parsed.skipHealth = true;
19
+ continue;
20
+ }
21
+ if (token === '--ci') {
22
+ parsed.ci = true;
23
+ continue;
24
+ }
25
+ }
26
+ return parsed;
27
+ }
28
+ function printUsage() {
29
+ console.error([
30
+ 'Usage:',
31
+ ' tsx src/release/mvp-gate-cli.ts [--health-url <url>] [--report-dir <path>] [--skip-health] [--ci]',
32
+ '',
33
+ 'Flags:',
34
+ ' --health-url <url> Override the health endpoint URL (default: http://localhost:18789/health)',
35
+ ' --report-dir <path> Override the report output directory',
36
+ ' --skip-health Skip the api-health check entirely',
37
+ ' --ci CI mode; output remains deterministic for workflow parsing',
38
+ '',
39
+ 'Exit codes:',
40
+ ' 0 go (all hard gates passed, no advisory failures)',
41
+ ' 1 no-go (one or more hard gates failed)',
42
+ ' 2 advisory-only (hard gates passed but advisory checks failed)',
43
+ ].join('\n'));
44
+ }
45
+ function printHumanSummary(report) {
46
+ const verdictLabel = report.verdict === 'go'
47
+ ? '๐ŸŸข GO'
48
+ : report.verdict === 'no-go'
49
+ ? '๐Ÿ”ด NO-GO'
50
+ : '๐ŸŸก ADVISORY-ONLY';
51
+ console.error('');
52
+ console.error('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
53
+ console.error(` MVP Gate Report ยท ${report.reportId}`);
54
+ console.error(` Verdict: ${verdictLabel}`);
55
+ console.error('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•');
56
+ console.error('');
57
+ console.error(` ${report.summary}`);
58
+ console.error('');
59
+ if (report.failedHardGates.length > 0) {
60
+ console.error(' โ”€โ”€ Blocking Failures โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
61
+ for (const check of report.failedHardGates) {
62
+ console.error(` โŒ [${check.id}] ${check.detail}`);
63
+ }
64
+ console.error('');
65
+ }
66
+ if (report.advisoryFailures.length > 0) {
67
+ console.error(' โ”€โ”€ Advisory Failures (non-blocking) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
68
+ for (const check of report.advisoryFailures) {
69
+ console.error(` โš ๏ธ [${check.id}] ${check.detail}`);
70
+ }
71
+ console.error('');
72
+ }
73
+ if (report.triage.length > 0) {
74
+ console.error(' โ”€โ”€ Triage & Next Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
75
+ for (const entry of report.triage) {
76
+ const icon = entry.severity === 'blocker' ? '๐Ÿ”ด' : '๐ŸŸก';
77
+ console.error(` ${icon} ${entry.checkId}`);
78
+ console.error(` Owner: ${entry.ownerTrack}`);
79
+ console.error(` Action: ${entry.nextAction}`);
80
+ console.error('');
81
+ }
82
+ }
83
+ const smokePass = report.smokeScenarios.filter((s) => s.pass).length;
84
+ const smokeTotal = report.smokeScenarios.length;
85
+ console.error(` Smoke scenarios: ${smokePass}/${smokeTotal} passed`);
86
+ console.error('');
87
+ console.error(` JSON report โ†’ ${report.reportPath}`);
88
+ console.error(` MD report โ†’ ${report.markdownPath}`);
89
+ console.error('โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€');
90
+ console.error('');
91
+ }
92
+ async function main() {
93
+ if (process.argv.includes('--help') || process.argv.includes('-h')) {
94
+ printUsage();
95
+ return;
96
+ }
97
+ const args = parseArgs(process.argv.slice(2));
98
+ const service = new MvpGateService({ reportDir: args.reportDir });
99
+ const report = await service.runGate({
100
+ healthUrl: args.healthUrl,
101
+ skipHealth: args.skipHealth,
102
+ });
103
+ // Machine-readable JSON โ†’ stdout
104
+ console.log(JSON.stringify(report, null, 2));
105
+ // Human-readable summary โ†’ stderr
106
+ printHumanSummary(report);
107
+ // Exit code conveys the verdict
108
+ if (report.verdict === 'no-go') {
109
+ process.exitCode = 1;
110
+ }
111
+ else if (report.verdict === 'advisory-only') {
112
+ process.exitCode = 2;
113
+ }
114
+ else {
115
+ process.exitCode = 0;
116
+ }
117
+ }
118
+ void main();