ai-control-center 1.15.2

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +584 -0
  3. package/bin/aicc.js +772 -0
  4. package/lib/actions/approve.js +71 -0
  5. package/lib/actions/assign-project.js +132 -0
  6. package/lib/actions/browser-test.js +64 -0
  7. package/lib/actions/cleanup.js +174 -0
  8. package/lib/actions/debug.js +298 -0
  9. package/lib/actions/deploy.js +1229 -0
  10. package/lib/actions/fix-bug.js +134 -0
  11. package/lib/actions/new-feature.js +255 -0
  12. package/lib/actions/reject.js +307 -0
  13. package/lib/actions/review.js +706 -0
  14. package/lib/actions/status.js +47 -0
  15. package/lib/agents/browser-qa-agent.js +611 -0
  16. package/lib/agents/payment-agent.js +116 -0
  17. package/lib/agents/suggestion-agent.js +88 -0
  18. package/lib/cli.js +303 -0
  19. package/lib/config.js +243 -0
  20. package/lib/hub/hub-server.js +440 -0
  21. package/lib/hub/project-poller.js +75 -0
  22. package/lib/hub/skill-registry.js +89 -0
  23. package/lib/hub/state-aggregator.js +204 -0
  24. package/lib/index.js +471 -0
  25. package/lib/init/doctor.js +523 -0
  26. package/lib/init/presets.js +222 -0
  27. package/lib/init/skill-fetcher.js +77 -0
  28. package/lib/init/wizard.js +973 -0
  29. package/lib/integrations/codex-runner.js +128 -0
  30. package/lib/integrations/github-actions.js +248 -0
  31. package/lib/integrations/github-reporter.js +229 -0
  32. package/lib/integrations/screenshot-store.js +102 -0
  33. package/lib/openclaw/bridge.js +650 -0
  34. package/lib/openclaw/generate-skill.js +235 -0
  35. package/lib/openclaw/openclaw.json +64 -0
  36. package/lib/orchestrator/autonomous-loop.js +429 -0
  37. package/lib/orchestrator/thread-triggers.js +63 -0
  38. package/lib/roleplay/agent-messenger.js +75 -0
  39. package/lib/roleplay/discussion-threads.js +303 -0
  40. package/lib/roleplay/health-monitor.js +121 -0
  41. package/lib/roleplay/pm-agent.js +513 -0
  42. package/lib/roleplay/roleplay-config.js +25 -0
  43. package/lib/roleplay/room.js +164 -0
  44. package/lib/shared/action-runner.js +2330 -0
  45. package/lib/shared/event-bus.js +185 -0
  46. package/lib/slack/bot.js +378 -0
  47. package/lib/telegram/bot.js +416 -0
  48. package/lib/telegram/commands.js +1267 -0
  49. package/lib/telegram/keyboards.js +113 -0
  50. package/lib/telegram/notifications.js +247 -0
  51. package/lib/twitch/bot.js +354 -0
  52. package/lib/twitch/commands.js +302 -0
  53. package/lib/twitch/notifications.js +63 -0
  54. package/lib/utils/achievements.js +191 -0
  55. package/lib/utils/activity-log.js +182 -0
  56. package/lib/utils/agent-leaderboard.js +119 -0
  57. package/lib/utils/audit-logger.js +232 -0
  58. package/lib/utils/codebase-context.js +288 -0
  59. package/lib/utils/codebase-indexer.js +381 -0
  60. package/lib/utils/config-schema.js +230 -0
  61. package/lib/utils/context-compressor.js +172 -0
  62. package/lib/utils/correlation.js +63 -0
  63. package/lib/utils/cost-tracker.js +423 -0
  64. package/lib/utils/cron-scheduler.js +53 -0
  65. package/lib/utils/db-adapter.js +293 -0
  66. package/lib/utils/display.js +272 -0
  67. package/lib/utils/errors.js +116 -0
  68. package/lib/utils/format.js +134 -0
  69. package/lib/utils/intent-engine.js +464 -0
  70. package/lib/utils/mcp-client.js +238 -0
  71. package/lib/utils/model-ab-test.js +164 -0
  72. package/lib/utils/notify.js +122 -0
  73. package/lib/utils/persona-loader.js +80 -0
  74. package/lib/utils/pipeline-lock.js +73 -0
  75. package/lib/utils/pipeline.js +214 -0
  76. package/lib/utils/plugin-runner.js +234 -0
  77. package/lib/utils/rate-limiter.js +84 -0
  78. package/lib/utils/rbac.js +74 -0
  79. package/lib/utils/runner.js +1809 -0
  80. package/lib/utils/security.js +191 -0
  81. package/lib/utils/self-healer.js +144 -0
  82. package/lib/utils/skill-loader.js +255 -0
  83. package/lib/utils/spinner.js +132 -0
  84. package/lib/utils/stage-queue.js +50 -0
  85. package/lib/utils/state-machine.js +89 -0
  86. package/lib/utils/status-bar.js +327 -0
  87. package/lib/utils/token-estimator.js +101 -0
  88. package/lib/utils/ux-analyzer.js +101 -0
  89. package/lib/utils/webhook-emitter.js +83 -0
  90. package/lib/web/public/css/styles.css +417 -0
  91. package/lib/web/public/dark-mode.js +44 -0
  92. package/lib/web/public/hub/kanban.html +206 -0
  93. package/lib/web/public/index.html +45 -0
  94. package/lib/web/public/js/app.js +71 -0
  95. package/lib/web/public/js/ask.js +110 -0
  96. package/lib/web/public/js/dashboard.js +165 -0
  97. package/lib/web/public/js/deploy.js +72 -0
  98. package/lib/web/public/js/feature.js +79 -0
  99. package/lib/web/public/js/health.js +65 -0
  100. package/lib/web/public/js/logs.js +93 -0
  101. package/lib/web/public/js/review.js +123 -0
  102. package/lib/web/public/js/ws-client.js +82 -0
  103. package/lib/web/public/office/css/office.css +678 -0
  104. package/lib/web/public/office/index.html +148 -0
  105. package/lib/web/public/office/js/achievements-ui.js +117 -0
  106. package/lib/web/public/office/js/character.js +1056 -0
  107. package/lib/web/public/office/js/chat-bubbles.js +177 -0
  108. package/lib/web/public/office/js/cost-overlay.js +123 -0
  109. package/lib/web/public/office/js/day-night.js +68 -0
  110. package/lib/web/public/office/js/effects.js +632 -0
  111. package/lib/web/public/office/js/engine.js +146 -0
  112. package/lib/web/public/office/js/feature-ticket.js +216 -0
  113. package/lib/web/public/office/js/hub-client.js +60 -0
  114. package/lib/web/public/office/js/main.js +1757 -0
  115. package/lib/web/public/office/js/office-layout.js +1524 -0
  116. package/lib/web/public/office/js/pathfinding.js +144 -0
  117. package/lib/web/public/office/js/pixel-sprites.js +1454 -0
  118. package/lib/web/public/office/js/progress-bars.js +117 -0
  119. package/lib/web/public/office/js/replay.js +191 -0
  120. package/lib/web/public/office/js/sound-effects.js +91 -0
  121. package/lib/web/public/office/js/sprite-renderer.js +211 -0
  122. package/lib/web/public/office/js/stamina-system.js +89 -0
  123. package/lib/web/public/office/js/ui.js +107 -0
  124. package/lib/web/public/onboarding/index.html +243 -0
  125. package/lib/web/public/timeline/index.html +195 -0
  126. package/lib/web/routes/api.js +499 -0
  127. package/lib/web/routes/logs.js +20 -0
  128. package/lib/web/routes/metrics.js +99 -0
  129. package/lib/web/server.js +183 -0
  130. package/lib/web/ws/handler.js +65 -0
  131. package/package.json +67 -0
  132. package/templates/agent-architect.md +69 -0
  133. package/templates/agent-gemini-pm.md +49 -0
  134. package/templates/agent-gemini-reviewer.md +52 -0
  135. package/templates/copilot-instructions.md +36 -0
  136. package/templates/pipelines/mobile.json +27 -0
  137. package/templates/pipelines/nodejs-api.json +27 -0
  138. package/templates/pipelines/python.json +27 -0
  139. package/templates/pipelines/react.json +27 -0
  140. package/templates/pipelines/salesforce.json +27 -0
  141. package/templates/role-gemini.md +97 -0
  142. package/templates/skill-architect.md +114 -0
  143. package/templates/skill-browser-qa.md +50 -0
  144. package/templates/skill-bug-from-qa.md +58 -0
  145. package/templates/skill-chatbot.md +93 -0
  146. package/templates/skill-implement.md +78 -0
  147. package/templates/skill-openclaw.md +174 -0
  148. package/templates/skill-payment.md +110 -0
  149. package/templates/skill-pm-spec.md +77 -0
  150. package/templates/skill-requirement-capture.md +97 -0
  151. package/templates/skill-review.md +108 -0
  152. package/templates/skill-reviewer-qa.md +44 -0
  153. package/templates/skill-suggestion.md +45 -0
  154. package/templates/skill-template.md +142 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * AI Control Center Twitch Bot — remote pipeline control + notifications.
3
+ *
4
+ * Setup:
5
+ * 1. Create a Twitch application at https://dev.twitch.tv/console
6
+ * 2. Generate an OAuth access token (chat:read, chat:edit scopes)
7
+ * 3. Set env vars (prefix with your project prefix from aicc.config.js):
8
+ * export {PREFIX}_TWITCH_CHANNEL=your_channel_name
9
+ * export {PREFIX}_TWITCH_ACCESS_TOKEN=your_oauth_token
10
+ * export {PREFIX}_TWITCH_CLIENT_ID=your_client_id
11
+ * export {PREFIX}_TWITCH_CLIENT_SECRET=your_client_secret (optional, for token refresh)
12
+ * export {PREFIX}_TWITCH_ALLOWED_IDS=id1,id2 (Twitch user IDs — comma-separated)
13
+ * 4. Run: aicc twitch
14
+ *
15
+ * Commands (use ! prefix in chat, e.g. !status):
16
+ * !status — Pipeline status
17
+ * !feature — Start a new feature
18
+ * !review — Run code review
19
+ * !deploy — Deploy
20
+ * !approve — Approve current feature
21
+ * !reject — Reject / request fixes
22
+ * !logs — Show recent logs
23
+ * !health — System health check
24
+ * !ai — Ask the AI a question
25
+ *
26
+ * Security:
27
+ * - TWITCH_ALLOWED_IDS restricts which Twitch user IDs can use commands.
28
+ * - Commands from unauthorized users are silently ignored.
29
+ *
30
+ * Rate Limiting:
31
+ * - Twitch allows 20 messages per 30 seconds for non-moderator bots.
32
+ * - This bot enforces a queue to stay within limits.
33
+ */
34
+ import https from 'https';
35
+ import { WebSocket } from 'ws';
36
+ import { env, getConfig, loadConfig } from '../config.js';
37
+ import { autoResumePipeline } from '../shared/action-runner.js';
38
+ import { bus } from '../shared/event-bus.js';
39
+ import { registerCommands } from './commands.js';
40
+ import { setupNotifications } from './notifications.js';
41
+
42
+ // ─── Constants ────────────────────────────────────────────────────────────────
43
+
44
+ const IRC_URL = 'wss://irc-ws.chat.twitch.tv:443';
45
+ const MAX_MSG_LENGTH = 500;
46
+ const RATE_LIMIT_MSGS = 20;
47
+ const RATE_LIMIT_MS = 30000;
48
+
49
+ // ─── Message truncation ───────────────────────────────────────────────────────
50
+
51
+ export function truncate(text) {
52
+ if (!text) return '';
53
+ const flat = text.replace(/\n+/g, ' ').trim();
54
+ return flat.length <= MAX_MSG_LENGTH ? flat : flat.slice(0, MAX_MSG_LENGTH - 3) + '...';
55
+ }
56
+
57
+ // ─── Validate OAuth token → get bot username ──────────────────────────────────
58
+
59
+ async function getBotUsername(accessToken) {
60
+ return new Promise((resolve) => {
61
+ const options = {
62
+ hostname: 'id.twitch.tv',
63
+ path: '/oauth2/validate',
64
+ method: 'GET',
65
+ headers: { Authorization: `OAuth ${accessToken}` },
66
+ };
67
+ const req = https.request(options, (res) => {
68
+ let body = '';
69
+ res.on('data', d => { body += d; });
70
+ res.on('end', () => {
71
+ try {
72
+ const json = JSON.parse(body);
73
+ resolve(json.login || null);
74
+ } catch {
75
+ resolve(null);
76
+ }
77
+ });
78
+ });
79
+ req.on('error', () => resolve(null));
80
+ req.end();
81
+ });
82
+ }
83
+
84
+ // ─── Bot class ────────────────────────────────────────────────────────────────
85
+
86
+ class TwitchBot {
87
+ constructor({ channel, accessToken, allowedIds, botUsername }) {
88
+ this.channel = channel.toLowerCase().replace(/^#/, '');
89
+ this.accessToken = accessToken;
90
+ this.allowedIds = allowedIds; // Set of allowed Twitch user IDs
91
+ this.botUsername = botUsername;
92
+ this.ws = null;
93
+ this._connected = false;
94
+ this._reconnectDelay = 5000;
95
+
96
+ // Rate-limit queue: max 20 messages per 30 seconds
97
+ this._msgQueue = [];
98
+ this._msgCount = 0;
99
+ this._rateReset = null;
100
+ }
101
+
102
+ /** Send a chat message, respecting rate limits */
103
+ say(message) {
104
+ const text = truncate(message);
105
+ if (!text) return;
106
+ this._msgQueue.push(text);
107
+ this._drainQueue();
108
+ }
109
+
110
+ _drainQueue() {
111
+ if (!this._connected || this._msgQueue.length === 0) return;
112
+
113
+ if (this._msgCount >= RATE_LIMIT_MSGS) {
114
+ // Queue is full — wait for rate limit window to reset
115
+ return;
116
+ }
117
+
118
+ const msg = this._msgQueue.shift();
119
+ if (!msg) return;
120
+
121
+ this._send(`PRIVMSG #${this.channel} :${msg}`);
122
+ this._msgCount++;
123
+
124
+ // Schedule rate limit reset
125
+ if (!this._rateReset) {
126
+ this._rateReset = setTimeout(() => {
127
+ this._msgCount = 0;
128
+ this._rateReset = null;
129
+ this._drainQueue(); // Process any queued messages
130
+ }, RATE_LIMIT_MS);
131
+ }
132
+
133
+ // Continue draining if under limit
134
+ if (this._msgCount < RATE_LIMIT_MSGS && this._msgQueue.length > 0) {
135
+ setImmediate(() => this._drainQueue());
136
+ }
137
+ }
138
+
139
+ _send(raw) {
140
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
141
+ this.ws.send(raw);
142
+ }
143
+ }
144
+
145
+ /** Connect to Twitch IRC over WebSocket */
146
+ connect() {
147
+ this.ws = new WebSocket(IRC_URL);
148
+
149
+ this.ws.on('open', () => {
150
+ this._connected = false; // not yet authenticated
151
+ // Request tags capability for user-id in messages
152
+ this._send('CAP REQ :twitch.tv/tags');
153
+ this._send(`PASS oauth:${this.accessToken}`);
154
+ this._send(`NICK ${this.botUsername}`);
155
+ });
156
+
157
+ this.ws.on('message', (raw) => {
158
+ const line = raw.toString().trim();
159
+
160
+ // Handle PING / PONG
161
+ if (line.startsWith('PING')) {
162
+ this._send('PONG :tmi.twitch.tv');
163
+ return;
164
+ }
165
+
166
+ // Authentication result
167
+ if (line.includes('376') || line.includes('001')) {
168
+ // RPL_ENDOFMOTD or RPL_WELCOME — authenticated
169
+ this._send(`JOIN #${this.channel}`);
170
+ }
171
+
172
+ // Joined channel
173
+ if (line.includes(`JOIN #${this.channel}`)) {
174
+ this._connected = true;
175
+ this._reconnectDelay = 5000; // reset backoff on success
176
+ console.log(`\n ${getConfig().name} Twitch Bot`);
177
+ console.log(` @${this.botUsername} → #${this.channel}`);
178
+ if (this.allowedIds.size > 0) {
179
+ console.log(` Allowed IDs: ${[...this.allowedIds].join(', ')}`);
180
+ } else {
181
+ console.log(' WARNING: No allowed IDs configured — all commands are blocked');
182
+ }
183
+ console.log('');
184
+
185
+ // Resume any interrupted pipeline
186
+ setTimeout(() => autoResumePipeline().catch(err => console.error('[Twitch] Resume error:', err.message)), 3000);
187
+ }
188
+
189
+ // Authentication failure
190
+ if (line.includes('NOTICE') && line.includes('Login authentication failed')) {
191
+ console.error('\n [Twitch] Authentication failed — check TWITCH_ACCESS_TOKEN\n');
192
+ this.ws.close();
193
+ return;
194
+ }
195
+
196
+ // PRIVMSG — incoming chat message
197
+ if (line.includes('PRIVMSG')) {
198
+ this._handleMessage(line);
199
+ }
200
+ });
201
+
202
+ this.ws.on('error', (err) => {
203
+ console.error(` [Twitch] WebSocket error: ${err.message}`);
204
+ });
205
+
206
+ this.ws.on('close', () => {
207
+ this._connected = false;
208
+ console.log(` [Twitch] Disconnected. Reconnecting in ${this._reconnectDelay / 1000}s...`);
209
+ setTimeout(() => {
210
+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, 60000); // exponential backoff, max 60s
211
+ this.connect();
212
+ }, this._reconnectDelay);
213
+ });
214
+ }
215
+
216
+ /** Parse a PRIVMSG line and dispatch to command or message handler */
217
+ _handleMessage(raw) {
218
+ // Parse IRCv3 tags + message
219
+ // Format: @key=val;key=val :user!user@user.tmi.twitch.tv PRIVMSG #channel :message
220
+ let tags = {};
221
+ let rest = raw;
222
+
223
+ if (raw.startsWith('@')) {
224
+ const spaceIdx = raw.indexOf(' ');
225
+ const tagStr = raw.slice(1, spaceIdx);
226
+ rest = raw.slice(spaceIdx + 1);
227
+ for (const part of tagStr.split(';')) {
228
+ const eqIdx = part.indexOf('=');
229
+ if (eqIdx !== -1) tags[part.slice(0, eqIdx)] = part.slice(eqIdx + 1);
230
+ }
231
+ }
232
+
233
+ const privmsgMatch = rest.match(/^:(\S+)!\S+ PRIVMSG #\S+ :(.+)$/);
234
+ if (!privmsgMatch) return;
235
+
236
+ const username = privmsgMatch[1];
237
+ const message = privmsgMatch[2].trim();
238
+ const userId = tags['user-id'] || '';
239
+
240
+ // Auth check — enforce TWITCH_ALLOWED_IDS allowlist (applies to both ! commands and NL)
241
+ if (this.allowedIds.size > 0 && !this.allowedIds.has(userId) && !this.allowedIds.has(username)) {
242
+ // Log the blocked attempt for audit, but do NOT respond in chat
243
+ console.log(` [Twitch] BLOCKED: ${username} (user-id: ${userId || 'unknown'}) — not in TWITCH_ALLOWED_IDS`);
244
+ return;
245
+ }
246
+
247
+ if (message.startsWith('!')) {
248
+ // Explicit bot command — ! prefix
249
+ console.log(` [Twitch] Command from ${username} (${userId}): ${message}`);
250
+ const [cmdRaw, ...argParts] = message.slice(1).split(' ');
251
+ const cmd = cmdRaw.toLowerCase();
252
+ const args = argParts.join(' ').trim();
253
+ this._dispatch(cmd, args, username);
254
+ } else if (this._messageHandler) {
255
+ // Natural language — pass to NL handler if registered
256
+ console.log(` [Twitch] Message from ${username} (${userId}): ${message.slice(0, 80)}`);
257
+ this._messageHandler(message, username);
258
+ }
259
+ }
260
+
261
+ _dispatch(cmd, args, username) {
262
+ if (this._commandHandler) {
263
+ this._commandHandler(cmd, args, username, this);
264
+ }
265
+ }
266
+
267
+ onCommand(handler) {
268
+ this._commandHandler = handler;
269
+ }
270
+
271
+ /** Register a handler for non-! natural language messages from authorized users */
272
+ onMessage(handler) {
273
+ this._messageHandler = handler;
274
+ }
275
+
276
+ disconnect() {
277
+ this._connected = false;
278
+ if (this.ws) {
279
+ this.ws.removeAllListeners('close'); // prevent auto-reconnect on intentional close
280
+ this.ws.close();
281
+ }
282
+ if (this._rateReset) {
283
+ clearTimeout(this._rateReset);
284
+ this._rateReset = null;
285
+ }
286
+ }
287
+ }
288
+
289
+ // ─── Start bot ────────────────────────────────────────────────────────────────
290
+
291
+ export async function startBot() {
292
+ // Guard against double execution
293
+ const GUARD_KEY = '__aicc_twitch_bot_started__';
294
+ if (globalThis[GUARD_KEY]) return;
295
+ globalThis[GUARD_KEY] = true;
296
+
297
+ await loadConfig().catch(() => {});
298
+
299
+ const prefix = (() => { try { return getConfig().envPrefix; } catch { return 'AICC'; } })();
300
+
301
+ const CHANNEL = env('TWITCH_CHANNEL');
302
+ const ACCESS_TOKEN = env('TWITCH_ACCESS_TOKEN');
303
+ const ALLOWED_RAW = env('TWITCH_ALLOWED_IDS');
304
+
305
+ if (!CHANNEL) {
306
+ console.error(`\n Missing ${prefix}_TWITCH_CHANNEL environment variable.\n`);
307
+ process.exit(1);
308
+ }
309
+
310
+ if (!ACCESS_TOKEN) {
311
+ console.error(`\n Missing ${prefix}_TWITCH_ACCESS_TOKEN environment variable.`);
312
+ console.error(' Generate one at https://dev.twitch.tv/console with chat:read and chat:edit scopes.\n');
313
+ process.exit(1);
314
+ }
315
+
316
+ // Validate token and get bot username
317
+ const botUsername = await getBotUsername(ACCESS_TOKEN);
318
+ if (!botUsername) {
319
+ console.error('\n Failed to validate TWITCH_ACCESS_TOKEN. Check the token is valid and not expired.\n');
320
+ process.exit(1);
321
+ }
322
+
323
+ // Build allowed user ID set
324
+ const allowedIds = new Set();
325
+ if (ALLOWED_RAW) {
326
+ ALLOWED_RAW.split(',').map(s => s.trim()).filter(Boolean).forEach(id => allowedIds.add(id));
327
+ }
328
+
329
+ const bot = new TwitchBot({ channel: CHANNEL, accessToken: ACCESS_TOKEN, allowedIds, botUsername });
330
+
331
+ // Register command handlers
332
+ registerCommands(bot);
333
+
334
+ // Set up pipeline notifications
335
+ setupNotifications(bot, bus);
336
+
337
+ // Start watching status.json
338
+ bus.startWatching();
339
+
340
+ // Connect to Twitch IRC
341
+ bot.connect();
342
+
343
+ // Cleanup on exit
344
+ process.on('SIGINT', () => {
345
+ bus.stopWatching();
346
+ bot.disconnect();
347
+ process.exit(0);
348
+ });
349
+ }
350
+
351
+ // Auto-start when run directly
352
+ if (process.argv[1]?.endsWith('bot.js')) {
353
+ startBot();
354
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Twitch bot command handlers — with intent engine parity to Telegram.
3
+ *
4
+ * Two layers of input handling:
5
+ * 1. Explicit ! commands: !status, !feature, !bug, !review, !approve,
6
+ * !reject, !deploy, !logs, !health, !ai,
7
+ * !yes, !no, !intel, !help
8
+ * 2. Natural language: Any non-! message from an authorized user is
9
+ * passed through the shared intent engine, which
10
+ * maps phrases like "deploy now" to the deploy intent.
11
+ *
12
+ * Confirmation flow (Twitch has no inline keyboards):
13
+ * State-changing intents (approve/deploy/reject/fix/implement/fix_and_review)
14
+ * are held in pendingActions[username] and require !yes to execute.
15
+ * Read-only intents (review/status/logs/health) run immediately.
16
+ *
17
+ * Shared modules (same as Telegram):
18
+ * - lib/shared/action-runner.js — all pipeline actions
19
+ * - lib/utils/intent-engine.js — matchIntent, matchLearnedPhrase, learnPhrase
20
+ * - lib/utils/format.js — formatForPlatform
21
+ */
22
+ import * as actions from '../shared/action-runner.js';
23
+ import { formatForPlatform } from '../utils/format.js';
24
+ import { getIntelStats, learnPhrase, matchIntent, matchLearnedPhrase } from '../utils/intent-engine.js';
25
+ import { truncate } from './bot.js';
26
+
27
+ // Strip markdown and truncate AI output for Twitch plain text
28
+ function formatReply(text) {
29
+ const { text: formatted } = formatForPlatform(
30
+ typeof text === 'object' ? (text?.message || JSON.stringify(text)) : String(text || ''),
31
+ 'twitch'
32
+ );
33
+ return truncate(formatted);
34
+ }
35
+
36
+ // ── Per-user pending confirmation state ────────────────────────────────────
37
+ // Shape: { intent, desc, actionFn, rawText }
38
+ const pendingActions = new Map();
39
+ function setPending(username, data) { pendingActions.set(username, data); }
40
+ function getPending(username) { return pendingActions.get(username) || null; }
41
+ function clearPending(username) { pendingActions.delete(username); }
42
+
43
+ // ── Unified action executor ────────────────────────────────────────────────
44
+ // Awaits actionFn(), sends result to chat, and reinforces learning on success.
45
+ async function runAction(bot, actionFn, { successMsg = null, errorMsg = 'Error', rawText = null, intent = null } = {}) {
46
+ try {
47
+ const res = await actionFn();
48
+ const msg = res?.message || res?.summary || successMsg || 'Done.';
49
+ bot.say(truncate(msg));
50
+ if (rawText && intent) learnPhrase(rawText, intent, { trigger: 'twitch_confirmed' });
51
+ } catch (err) {
52
+ bot.say(truncate(`${errorMsg}: ${err.message}`));
53
+ }
54
+ }
55
+
56
+ // ── Intent → action mapper (mirrors Telegram) ──────────────────────────────
57
+ // Returns { requiresConfirm, desc, actionFn } or null for unknown intents.
58
+ function buildIntentContext(intent, rawText, statusData) {
59
+ const cleanDesc = rawText
60
+ .replace(/\b(can you|please|could you|let'?s|we should|implement|fix|create|add|build|deploy|review|approve|reject)\b/gi, '')
61
+ .replace(/\s+/g, ' ').trim() || rawText;
62
+
63
+ switch (intent) {
64
+ case 'review':
65
+ return { requiresConfirm: false, desc: 'Run code review', actionFn: () => actions.runReview() };
66
+ case 'approve':
67
+ return { requiresConfirm: true, desc: `Approve current feature (stage: ${statusData?.stage || 'unknown'})`, actionFn: () => actions.runApprove() };
68
+ case 'deploy':
69
+ return { requiresConfirm: true, desc: `Deploy to target (stage: ${statusData?.stage || 'unknown'})`, actionFn: () => actions.runDeploy() };
70
+ case 'fix':
71
+ return { requiresConfirm: true, desc: `Fix bug: "${cleanDesc.slice(0, 80)}"`, actionFn: () => actions.runBugFix(cleanDesc) };
72
+ case 'implement':
73
+ return { requiresConfirm: true, desc: `New feature: "${cleanDesc.slice(0, 80)}"`, actionFn: () => actions.runNewFeature(cleanDesc) };
74
+ case 'fix_and_review':
75
+ return {
76
+ requiresConfirm: true,
77
+ desc: 'Fix bug then run code review',
78
+ actionFn: async () => { await actions.runBugFix(cleanDesc); return actions.runReview(); },
79
+ };
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // ── Natural language handler ───────────────────────────────────────────────
86
+ // Called by bot.onMessage() for every non-! message from an authorized user.
87
+ async function handleNaturalLanguage(bot, text, username) {
88
+ const statusData = actions.getStatusData();
89
+ const learned = matchLearnedPhrase(text);
90
+ const intentResult = learned || matchIntent(text, statusData);
91
+ if (!intentResult) return;
92
+
93
+ const { intent, confidence } = intentResult;
94
+ // Higher threshold for Twitch (noisier public chat)
95
+ if (confidence < 0.65) {
96
+ console.log(` [Twitch] NL low confidence (${confidence.toFixed(2)}) for "${text.slice(0, 50)}" — skipped`);
97
+ return;
98
+ }
99
+ console.log(` [Twitch] NL intent: ${intent} (${confidence.toFixed(2)}) from ${username}`);
100
+
101
+ const ctx = buildIntentContext(intent, text, statusData);
102
+ if (!ctx) return;
103
+
104
+ if (!ctx.requiresConfirm) {
105
+ bot.say(`→ ${ctx.desc}...`);
106
+ await runAction(bot, ctx.actionFn, { rawText: text, intent });
107
+ } else {
108
+ setPending(username, { intent, desc: ctx.desc, actionFn: ctx.actionFn, rawText: text });
109
+ bot.say(truncate(`${ctx.desc} — reply !yes to confirm or !no to cancel`));
110
+ }
111
+ }
112
+
113
+ // ── Command registry ───────────────────────────────────────────────────────
114
+ export function registerCommands(bot) {
115
+
116
+ // Wire natural-language handler for non-! messages
117
+ bot.onMessage(async (text, username) => {
118
+ await handleNaturalLanguage(bot, text, username);
119
+ });
120
+
121
+ bot.onCommand(async (cmd, args, username) => {
122
+ switch (cmd) {
123
+
124
+ // ── !help ────────────────────────────────────────────────────────────
125
+ case 'help': {
126
+ bot.say(
127
+ '!status !feature <desc> !bug <desc> !review !approve !reject [reason] ' +
128
+ '!deploy !logs !health !ai <q> !intel — or just chat naturally!'
129
+ );
130
+ break;
131
+ }
132
+
133
+ // ── !status ──────────────────────────────────────────────────────────
134
+ case 'status': {
135
+ const s = actions.getStatusData();
136
+ if (!s) { bot.say('No pipeline status available.'); break; }
137
+ bot.say(truncate(`Stage: ${s.stage || 'idle'} | Feature: ${s.current_feature || 'none'} | Mode: ${s.pipeline_mode || 'manual'}`));
138
+ break;
139
+ }
140
+
141
+ // ── !feature <desc> ──────────────────────────────────────────────────
142
+ case 'feature': {
143
+ if (!args || !args.trim()) { bot.say('Usage: !feature <description>'); break; }
144
+ bot.say(`Starting feature: ${truncate(args)}...`);
145
+ await runAction(bot, () => actions.runNewFeature(args.trim(), 'manual', 'feature'), {
146
+ successMsg: 'Feature submitted!', errorMsg: 'Feature failed',
147
+ rawText: args.trim(), intent: 'implement',
148
+ });
149
+ break;
150
+ }
151
+
152
+ // ── !bug <desc> ──────────────────────────────────────────────────────
153
+ case 'bug': {
154
+ if (!args || !args.trim()) { bot.say('Usage: !bug <description>'); break; }
155
+ bot.say(`Fixing: ${truncate(args)}...`);
156
+ await runAction(bot, () => actions.runBugFix(args.trim()), {
157
+ successMsg: 'Bug fix started!', errorMsg: 'Bug fix failed',
158
+ rawText: args.trim(), intent: 'fix',
159
+ });
160
+ break;
161
+ }
162
+
163
+ // ── !review ──────────────────────────────────────────────────────────
164
+ case 'review': {
165
+ bot.say('Running code review...');
166
+ await runAction(bot, () => actions.runReview(), {
167
+ successMsg: 'Review complete.', errorMsg: 'Review failed',
168
+ rawText: '!review', intent: 'review',
169
+ });
170
+ break;
171
+ }
172
+
173
+ // ── !deploy ──────────────────────────────────────────────────────────
174
+ case 'deploy': {
175
+ bot.say('Deploying...');
176
+ await runAction(bot, () => actions.runDeploy(), {
177
+ successMsg: 'Deploy successful!', errorMsg: 'Deploy failed',
178
+ rawText: '!deploy', intent: 'deploy',
179
+ });
180
+ break;
181
+ }
182
+
183
+ // ── !approve ─────────────────────────────────────────────────────────
184
+ case 'approve': {
185
+ bot.say('Approving feature...');
186
+ await runAction(bot, () => actions.runApprove(), {
187
+ successMsg: 'Feature approved!', errorMsg: 'Approve failed',
188
+ rawText: '!approve', intent: 'approve',
189
+ });
190
+ break;
191
+ }
192
+
193
+ // ── !reject [reason] ─────────────────────────────────────────────────
194
+ case 'reject': {
195
+ const reason = args && args.trim() ? args.trim() : 'Rejected via Twitch';
196
+ bot.say(`Rejecting: ${truncate(reason)}...`);
197
+ await runAction(bot, () => actions.runReject(reason), {
198
+ successMsg: 'Feature rejected.', errorMsg: 'Reject failed',
199
+ rawText: reason, intent: 'reject',
200
+ });
201
+ break;
202
+ }
203
+
204
+ // ── !yes — confirm pending action ────────────────────────────────────
205
+ case 'yes': {
206
+ const pending = getPending(username);
207
+ if (!pending) { bot.say('Nothing pending. Trigger an action via chat or !commands first.'); break; }
208
+ clearPending(username);
209
+ bot.say(`Confirmed: ${truncate(pending.desc)}...`);
210
+ await runAction(bot, pending.actionFn, {
211
+ successMsg: 'Done!', errorMsg: 'Action failed',
212
+ rawText: pending.rawText, intent: pending.intent,
213
+ });
214
+ break;
215
+ }
216
+
217
+ // ── !no — cancel pending action ──────────────────────────────────────
218
+ case 'no': {
219
+ const pending = getPending(username);
220
+ if (!pending) { bot.say('Nothing pending to cancel.'); break; }
221
+ clearPending(username);
222
+ bot.say('Cancelled.');
223
+ break;
224
+ }
225
+
226
+ // ── !logs ────────────────────────────────────────────────────────────
227
+ case 'logs': {
228
+ const logData = actions.getLogsData(5);
229
+ if (!logData || logData.length === 0) { bot.say('No recent logs.'); break; }
230
+ const lines = Array.isArray(logData)
231
+ ? logData.slice(-3).join(' | ')
232
+ : String(logData).split('\n').filter(Boolean).slice(-3).join(' | ');
233
+ bot.say(truncate(`Logs: ${lines}`));
234
+ break;
235
+ }
236
+
237
+ // ── !health ──────────────────────────────────────────────────────────
238
+ case 'health': {
239
+ const data = await actions.getHealthData();
240
+ const ok = (v) => v ? '✓' : '✗';
241
+ bot.say(
242
+ `Health — gemini:${ok(data.gemini?.available)} ` +
243
+ `claude:${ok(data.claude?.available)} ` +
244
+ `copilot:${ok(data.copilot?.available)} ` +
245
+ `stage:${data.pipeline?.stage || 'idle'}`
246
+ );
247
+ break;
248
+ }
249
+
250
+ // ── !ai <question> ───────────────────────────────────────────────────
251
+ case 'ai': {
252
+ if (!args || !args.trim()) { bot.say('Usage: !ai <question>'); break; }
253
+ bot.say('Thinking...');
254
+ try {
255
+ const result = await actions.askAI(args.trim());
256
+ if (result?.confirm === true) {
257
+ const desc = result.desc || result.action || 'Proposed action';
258
+ setPending(username, { intent: result.action || 'implement', desc, actionFn: () => actions.runNewFeature(desc), rawText: args.trim() });
259
+ bot.say(truncate(`Ready: "${desc}" — reply !yes to confirm or !no to cancel`));
260
+ break;
261
+ }
262
+ if (result?.pipelineAction) {
263
+ const pa = result.pipelineAction;
264
+ const pipeMap = {
265
+ review: () => actions.runReview(),
266
+ approve: () => actions.runApprove(),
267
+ deploy: () => actions.runDeploy(),
268
+ fix: () => actions.runBugFix(result.desc || args.trim()),
269
+ implement: () => actions.runNewFeature(result.desc || args.trim()),
270
+ fix_and_review: async () => { await actions.runBugFix(result.desc || args.trim()); return actions.runReview(); },
271
+ };
272
+ if (pipeMap[pa]) {
273
+ bot.say(`→ Executing: ${pa}...`);
274
+ await runAction(bot, pipeMap[pa], { successMsg: `${pa} complete.`, errorMsg: `${pa} failed`, rawText: args.trim(), intent: pa });
275
+ } else {
276
+ bot.say(formatReply(result));
277
+ }
278
+ break;
279
+ }
280
+ bot.say(formatReply(result));
281
+ } catch (err) {
282
+ bot.say(truncate(`AI error: ${err.message}`));
283
+ }
284
+ break;
285
+ }
286
+
287
+ // ── !intel — intent engine diagnostics ───────────────────────────────
288
+ case 'intel': {
289
+ try {
290
+ const stats = getIntelStats();
291
+ bot.say(truncate(`Intent engine — patterns: ${stats.patternCount ?? '?'} | learned: ${stats.learnedCount ?? '?'} | confidence: ${stats.avgConfidence ?? '?'}`));
292
+ } catch {
293
+ bot.say('Intent engine stats unavailable.');
294
+ }
295
+ break;
296
+ }
297
+
298
+ default:
299
+ break;
300
+ }
301
+ });
302
+ }