aiden-runtime 4.0.1 → 4.1.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 (112) hide show
  1. package/README.md +11 -7
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +513 -14
  5. package/dist/cli/v4/aidenPrompt.js +317 -0
  6. package/dist/cli/v4/box.js +105 -39
  7. package/dist/cli/v4/callbacks.js +39 -6
  8. package/dist/cli/v4/chatSession.js +269 -52
  9. package/dist/cli/v4/citationFooter.js +97 -0
  10. package/dist/cli/v4/commands/channel.js +656 -0
  11. package/dist/cli/v4/commands/clear.js +1 -1
  12. package/dist/cli/v4/commands/compress.js +1 -1
  13. package/dist/cli/v4/commands/cron.js +44 -16
  14. package/dist/cli/v4/commands/fanout.js +236 -0
  15. package/dist/cli/v4/commands/help.js +15 -4
  16. package/dist/cli/v4/commands/history.js +84 -0
  17. package/dist/cli/v4/commands/index.js +19 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/setup.js +34 -0
  20. package/dist/cli/v4/commands/show.js +43 -0
  21. package/dist/cli/v4/commands/skills.js +169 -4
  22. package/dist/cli/v4/commands/status.js +84 -0
  23. package/dist/cli/v4/commands/subagent.js +78 -0
  24. package/dist/cli/v4/commands/verbose.js +1 -1
  25. package/dist/cli/v4/commands/voice.js +218 -0
  26. package/dist/cli/v4/cronCli.js +103 -0
  27. package/dist/cli/v4/display.js +300 -14
  28. package/dist/cli/v4/doctor.js +41 -0
  29. package/dist/cli/v4/envSources.js +105 -0
  30. package/dist/cli/v4/ghostMatch.js +74 -0
  31. package/dist/cli/v4/historyStore.js +163 -0
  32. package/dist/cli/v4/pasteCompression.js +124 -0
  33. package/dist/cli/v4/pasteIntercept.js +203 -0
  34. package/dist/cli/v4/replyRenderer.js +209 -0
  35. package/dist/cli/v4/resizeGuard.js +92 -0
  36. package/dist/cli/v4/setupWizard.js +466 -232
  37. package/dist/cli/v4/shellInterpolation.js +139 -0
  38. package/dist/cli/v4/skinEngine.js +21 -1
  39. package/dist/cli/v4/streamingPrefix.js +121 -0
  40. package/dist/cli/v4/syntaxHighlight.js +345 -0
  41. package/dist/cli/v4/table.js +216 -0
  42. package/dist/cli/v4/themeDetect.js +81 -0
  43. package/dist/cli/v4/uiBuild.js +74 -0
  44. package/dist/cli/v4/voiceCli.js +113 -0
  45. package/dist/cli/v4/voicePromptApi.js +196 -0
  46. package/dist/core/channels/discord.js +16 -10
  47. package/dist/core/channels/email.js +13 -9
  48. package/dist/core/channels/imessage.js +13 -9
  49. package/dist/core/channels/manager.js +25 -7
  50. package/dist/core/channels/pdf-extract.js +180 -0
  51. package/dist/core/channels/photo-vision.js +157 -0
  52. package/dist/core/channels/signal.js +11 -7
  53. package/dist/core/channels/slack.js +13 -10
  54. package/dist/core/channels/telegram-commands.js +154 -0
  55. package/dist/core/channels/telegram-groups.js +198 -0
  56. package/dist/core/channels/telegram-rate-limit.js +124 -0
  57. package/dist/core/channels/telegram.js +1980 -0
  58. package/dist/core/channels/twilio.js +11 -7
  59. package/dist/core/channels/webhook.js +9 -5
  60. package/dist/core/channels/whatsapp.js +15 -11
  61. package/dist/core/channels/whisper-transcribe.js +163 -0
  62. package/dist/core/cronManager.js +33 -294
  63. package/dist/core/gateway.js +29 -8
  64. package/dist/core/playwrightBridge.js +90 -0
  65. package/dist/core/v4/aidenAgent.js +35 -0
  66. package/dist/core/v4/auxiliaryClient.js +2 -2
  67. package/dist/core/v4/cron/atomicWrite.js +18 -4
  68. package/dist/core/v4/cron/cronExecute.js +300 -0
  69. package/dist/core/v4/cron/cronManager.js +502 -0
  70. package/dist/core/v4/cron/cronState.js +314 -0
  71. package/dist/core/v4/cron/cronTick.js +90 -0
  72. package/dist/core/v4/cron/diagnostics.js +104 -0
  73. package/dist/core/v4/cron/graceWindow.js +79 -0
  74. package/dist/core/v4/firstRun/providerDetection.js +287 -0
  75. package/dist/core/v4/logger/factory.js +110 -0
  76. package/dist/core/v4/logger/index.js +22 -0
  77. package/dist/core/v4/logger/logger.js +101 -0
  78. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  79. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  80. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  81. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  82. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  83. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  84. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  85. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  86. package/dist/core/v4/platformPaths.js +105 -0
  87. package/dist/core/v4/providerFallback.js +25 -0
  88. package/dist/core/v4/skillLoader.js +21 -5
  89. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  90. package/dist/core/v4/skillMining/extractorPrompt.js +111 -0
  91. package/dist/core/v4/skillMining/proposalBuilder.js +139 -0
  92. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  93. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  94. package/dist/core/v4/subagent/budget.js +76 -0
  95. package/dist/core/v4/subagent/diagnostics.js +22 -0
  96. package/dist/core/v4/subagent/fanout.js +216 -0
  97. package/dist/core/v4/subagent/merger.js +148 -0
  98. package/dist/core/v4/subagent/providerRotation.js +54 -0
  99. package/dist/core/v4/voice/audioStream.js +373 -0
  100. package/dist/core/v4/voice/cliVoice.js +393 -0
  101. package/dist/core/v4/voice/diagnostics.js +66 -0
  102. package/dist/core/v4/voice/ttsStream.js +193 -0
  103. package/dist/core/version.js +1 -1
  104. package/dist/core/visionAnalyze.js +291 -90
  105. package/dist/core/voice/audio.js +61 -5
  106. package/dist/core/voice/audioBackend.js +134 -0
  107. package/dist/core/voice/stt.js +61 -6
  108. package/dist/core/voice/tts.js +19 -3
  109. package/dist/providers/v4/nullAdapter.js +58 -0
  110. package/dist/tools/v4/index.js +32 -1
  111. package/dist/tools/v4/subagent/subagentFanout.js +166 -0
  112. package/package.json +11 -2
@@ -0,0 +1,656 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 Shiva Deore (Taracod).
4
+ * Licensed under AGPL-3.0. See LICENSE for details.
5
+ *
6
+ * Aiden — local-first agent.
7
+ */
8
+ /**
9
+ * cli/v4/commands/channel.ts — Phase v4.1-1.1
10
+ *
11
+ * `/channel` — manage channel adapters from inside the REPL.
12
+ *
13
+ * Subcommands (Phase 1.1 — Telegram only; the other 8 land iteratively):
14
+ *
15
+ * /channel list — table of all 9 channel slots + state
16
+ * /channel telegram add — paste token, validate via getMe,
17
+ * write .env atomically, restart adapter
18
+ * /channel telegram remove — confirm, stop polling, strip token
19
+ * from .env
20
+ * /channel telegram status — bot username, polling state,
21
+ * last-message wall-clock, error count
22
+ *
23
+ * The CLI now hosts a `ChannelManager` directly (Phase v4.1-1.1 boot
24
+ * change in aidenCLI.ts), so this command operates on a live manager
25
+ * — no HTTP round-trip to a separate API server.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.channel = void 0;
32
+ const node_fs_1 = require("node:fs");
33
+ const node_path_1 = __importDefault(require("node:path"));
34
+ const telegram_1 = require("../../../core/channels/telegram");
35
+ const table_1 = require("../table");
36
+ const CHANNEL_DESCRIPTORS = [
37
+ { id: 'telegram', envVars: ['TELEGRAM_BOT_TOKEN'],
38
+ shortHelp: 'Bot via @BotFather' },
39
+ { id: 'discord', envVars: ['DISCORD_BOT_TOKEN'],
40
+ shortHelp: 'Bot via Discord developer portal' },
41
+ { id: 'slack', envVars: ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
42
+ shortHelp: 'Slack app + Socket Mode token' },
43
+ { id: 'whatsapp', envVars: ['WHATSAPP_BUSINESS_API_KEY'],
44
+ shortHelp: 'WhatsApp Business Cloud API' },
45
+ { id: 'email', envVars: ['EMAIL_IMAP_PASSWORD', 'EMAIL_SMTP_PASSWORD'],
46
+ shortHelp: 'IMAP+SMTP credentials' },
47
+ { id: 'webhook', envVars: [],
48
+ shortHelp: 'HTTP POST endpoint (always-on when API server runs)' },
49
+ { id: 'twilio', envVars: ['TWILIO_AUTH_TOKEN'],
50
+ shortHelp: 'Twilio SMS' },
51
+ { id: 'imessage', envVars: ['BLUEBUBBLES_PASSWORD'],
52
+ shortHelp: 'BlueBubbles bridge (macOS)' },
53
+ { id: 'signal', envVars: [],
54
+ shortHelp: 'signal-cli bridge' },
55
+ ];
56
+ // ── .env upsert (atomic) ------------------------------------------
57
+ //
58
+ // Mirrors `cli/v4/setupWizard.upsertEnvVar` but local to this command
59
+ // so the slash-command path doesn't pull a wizard import. Tmp + rename
60
+ // guarantees no half-written .env files even if the process is killed
61
+ // mid-write.
62
+ async function upsertEnv(envFile, key, value) {
63
+ const k = key.toUpperCase();
64
+ let body = '';
65
+ try {
66
+ body = await node_fs_1.promises.readFile(envFile, 'utf8');
67
+ }
68
+ catch { /* fresh file */ }
69
+ const lines = body.split(/\r?\n/);
70
+ let replaced = false;
71
+ for (let i = 0; i < lines.length; i += 1) {
72
+ if (lines[i].startsWith(`${k}=`)) {
73
+ lines[i] = `${k}=${value}`;
74
+ replaced = true;
75
+ }
76
+ }
77
+ if (!replaced)
78
+ lines.push(`${k}=${value}`);
79
+ while (lines.length > 0 && lines[lines.length - 1] === '')
80
+ lines.pop();
81
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(envFile), { recursive: true });
82
+ const tmp = `${envFile}.${process.pid}.tmp`;
83
+ await node_fs_1.promises.writeFile(tmp, `${lines.join('\n')}\n`, 'utf8');
84
+ await node_fs_1.promises.rename(tmp, envFile);
85
+ }
86
+ async function deleteEnvKey(envFile, key) {
87
+ const k = key.toUpperCase();
88
+ let body = '';
89
+ try {
90
+ body = await node_fs_1.promises.readFile(envFile, 'utf8');
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ const lines = body.split(/\r?\n/);
96
+ const filtered = lines.filter((l) => !l.startsWith(`${k}=`));
97
+ if (filtered.length === lines.length)
98
+ return false;
99
+ while (filtered.length > 0 && filtered[filtered.length - 1] === '')
100
+ filtered.pop();
101
+ const tmp = `${envFile}.${process.pid}.tmp`;
102
+ await node_fs_1.promises.writeFile(tmp, `${filtered.join('\n')}\n`, 'utf8');
103
+ await node_fs_1.promises.rename(tmp, envFile);
104
+ return true;
105
+ }
106
+ async function validateTelegramToken(token) {
107
+ // Phase v4.1-1.1: validate before persisting. A bad token costs us
108
+ // one HTTP call but spares the user the "I saved it but the adapter
109
+ // won't start" debugging path. Hard-cap the request at 10s so a
110
+ // network stall can't lock the REPL.
111
+ const ctrl = new AbortController();
112
+ const timer = setTimeout(() => ctrl.abort(), 10000);
113
+ try {
114
+ const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: ctrl.signal });
115
+ if (!res.ok) {
116
+ return { ok: false, reason: `HTTP ${res.status} ${res.statusText}` };
117
+ }
118
+ const json = (await res.json());
119
+ if (!json.ok) {
120
+ return { ok: false, reason: json.description ?? 'Telegram returned ok=false' };
121
+ }
122
+ return {
123
+ ok: true,
124
+ username: json.result?.username,
125
+ firstName: json.result?.first_name,
126
+ };
127
+ }
128
+ catch (err) {
129
+ return { ok: false, reason: err?.name === 'AbortError' ? 'request timed out (10s)' : (err?.message ?? 'network error') };
130
+ }
131
+ finally {
132
+ clearTimeout(timer);
133
+ }
134
+ }
135
+ // ── /channel list -------------------------------------------------
136
+ function formatList(ctx) {
137
+ const { display } = ctx;
138
+ const manager = ctx.channelManager;
139
+ display.write('\n Configured channels:\n');
140
+ const rows = CHANNEL_DESCRIPTORS.map((desc) => {
141
+ const adapter = manager?.get(desc.id);
142
+ const healthy = adapter?.isHealthy() === true;
143
+ const tg = adapter;
144
+ const tgState = typeof tg?.getState === 'function' ? tg.getState() : null;
145
+ if (!adapter) {
146
+ return { channel: desc.id, state: 'not registered', status: 'not-registered', detail: '' };
147
+ }
148
+ if (healthy) {
149
+ const username = typeof tg?.getBotUsername === 'function' ? tg.getBotUsername() : null;
150
+ return { channel: desc.id, state: 'active', status: 'active', detail: username ? `@${username}` : '' };
151
+ }
152
+ if (tgState === 'conflict') {
153
+ return { channel: desc.id, state: 'conflict', status: 'conflict',
154
+ detail: 'another instance polling — /channel telegram takeover' };
155
+ }
156
+ if (tgState === 'connecting') {
157
+ return { channel: desc.id, state: 'connecting', status: 'connecting', detail: '' };
158
+ }
159
+ if (tgState === 'degraded') {
160
+ return { channel: desc.id, state: 'degraded', status: 'degraded',
161
+ detail: 'see /channel telegram status' };
162
+ }
163
+ const envHit = desc.envVars.some((v) => (process.env[v] ?? '').trim() !== '');
164
+ return envHit
165
+ ? { channel: desc.id, state: 'offline', status: 'offline', detail: 'env set, not connected' }
166
+ : { channel: desc.id, state: 'not configured', status: 'not-configured', detail: '' };
167
+ });
168
+ display.write((0, table_1.renderTable)(rows, [
169
+ { key: 'channel', header: 'Channel', align: 'left', minWidth: 10 },
170
+ { key: 'state', header: 'State', align: 'left',
171
+ color: (_v, row) => {
172
+ switch (row.status) {
173
+ case 'active': return 'success';
174
+ case 'conflict':
175
+ case 'degraded':
176
+ case 'offline': return 'warn';
177
+ default: return 'muted';
178
+ }
179
+ } },
180
+ { key: 'detail', header: 'Detail', align: 'left', flex: true,
181
+ color: () => 'muted' },
182
+ ]));
183
+ display.write(` ${display.muted('Set up Telegram: /channel telegram add')}\n\n`);
184
+ }
185
+ // ── /channel telegram add -----------------------------------------
186
+ async function telegramAdd(ctx) {
187
+ const { display, prompt } = ctx;
188
+ if (!prompt) {
189
+ display.printError('Cannot prompt for input in this context.');
190
+ return;
191
+ }
192
+ if (!ctx.paths) {
193
+ display.printError('Cannot resolve .env path — paths missing in context.');
194
+ return;
195
+ }
196
+ display.write('\n Open Telegram, message @BotFather, send /newbot, copy the token.\n');
197
+ const raw = await prompt(' Paste your Telegram bot token: ');
198
+ const token = (raw ?? '').trim();
199
+ if (!token) {
200
+ display.dim(' Empty token — cancelled.');
201
+ return;
202
+ }
203
+ if (!/^\d+:[A-Za-z0-9_-]{20,}$/.test(token)) {
204
+ display.printError('That doesn\'t look like a Telegram bot token.', 'Format is `<bot_id>:<secret>`. Double-check what BotFather sent.');
205
+ return;
206
+ }
207
+ const spinner = display.startSpinner('Validating token via Telegram /getMe…');
208
+ let probe;
209
+ try {
210
+ probe = await validateTelegramToken(token);
211
+ }
212
+ finally {
213
+ spinner.stop();
214
+ }
215
+ if (!probe.ok) {
216
+ display.printError(`Telegram rejected the token: ${probe.reason ?? 'unknown error'}.`, 'Re-run /channel telegram add with a fresh token from BotFather.');
217
+ return;
218
+ }
219
+ // Persist to the env file Aiden's runtime resolves at boot. ChannelManager
220
+ // re-reads process.env on adapter restart (Phase v4.1-1.1 contract).
221
+ process.env.TELEGRAM_BOT_TOKEN = token;
222
+ await upsertEnv(ctx.paths.envFile, 'TELEGRAM_BOT_TOKEN', token);
223
+ // Restart the adapter through the manager — stop() then start(); start()
224
+ // re-reads the env we just updated.
225
+ const manager = ctx.channelManager;
226
+ if (!manager) {
227
+ display.warn('Token saved, but no channel manager wired in this session — restart aiden to apply.');
228
+ return;
229
+ }
230
+ let adapter = manager.get('telegram');
231
+ if (!adapter) {
232
+ // Manager doesn't have a Telegram adapter registered — register one now.
233
+ adapter = new telegram_1.TelegramAdapter();
234
+ manager.register(adapter);
235
+ }
236
+ const result = await manager.restart('telegram');
237
+ if (result.status === 'started' && adapter.isHealthy()) {
238
+ const username = probe.username ?? probe.firstName ?? 'bot';
239
+ display.success(`Bot connected as @${username}. Ready to chat!`);
240
+ display.dim(` Token saved to ${ctx.paths.envFile}`);
241
+ }
242
+ else {
243
+ display.printError(`Token saved but adapter did not come up: ${result.error ?? result.status}.`, 'Run /channel telegram status for diagnostics.');
244
+ }
245
+ }
246
+ // ── /channel telegram remove --------------------------------------
247
+ async function telegramRemove(ctx) {
248
+ const { display, confirm } = ctx;
249
+ if (!confirm) {
250
+ display.printError('Cannot confirm in this context.');
251
+ return;
252
+ }
253
+ if (!ctx.paths) {
254
+ display.printError('Cannot resolve .env path — paths missing.');
255
+ return;
256
+ }
257
+ const proceed = await confirm('Remove the Telegram bot token? This stops polling.');
258
+ if (!proceed) {
259
+ display.dim(' Cancelled.');
260
+ return;
261
+ }
262
+ // Stop the live adapter first so polling actually halts even if the
263
+ // .env write fails for some reason.
264
+ const manager = ctx.channelManager;
265
+ const adapter = manager?.get('telegram');
266
+ if (adapter && adapter.isHealthy()) {
267
+ try {
268
+ await adapter.stop();
269
+ }
270
+ catch { /* shutdown best-effort */ }
271
+ }
272
+ delete process.env.TELEGRAM_BOT_TOKEN;
273
+ const removed = await deleteEnvKey(ctx.paths.envFile, 'TELEGRAM_BOT_TOKEN');
274
+ if (removed) {
275
+ display.success('Telegram disabled. TELEGRAM_BOT_TOKEN removed from .env.');
276
+ }
277
+ else {
278
+ display.dim('Telegram disabled. (No TELEGRAM_BOT_TOKEN entry was in .env.)');
279
+ }
280
+ }
281
+ // ── /channel telegram takeover ------------------------------------
282
+ //
283
+ // When two Aiden instances share TELEGRAM_BOT_TOKEN, both poll the
284
+ // same bot and Telegram returns 409 Conflict to the loser. Phase
285
+ // v4.1-1.2 — `/channel telegram takeover` reaches the network
286
+ // directly to evict the rival poller (deleteWebhook +
287
+ // drop_pending_updates + getUpdates offset reset) and re-arms this
288
+ // instance's adapter from a clean state.
289
+ async function telegramTakeover(ctx) {
290
+ const { display, confirm } = ctx;
291
+ const manager = ctx.channelManager;
292
+ const adapter = manager?.get('telegram');
293
+ if (!adapter || typeof adapter.takeoverPolling !== 'function') {
294
+ display.warn('No Telegram adapter registered in this session.');
295
+ return;
296
+ }
297
+ const proceed = confirm
298
+ ? await confirm('Take over Telegram polling? This will boot any other Aiden instance off the bot.')
299
+ : true;
300
+ if (!proceed) {
301
+ display.dim(' Cancelled.');
302
+ return;
303
+ }
304
+ const spinner = display.startSpinner('Reclaiming Telegram polling…');
305
+ let result;
306
+ try {
307
+ result = await adapter.takeoverPolling();
308
+ }
309
+ finally {
310
+ spinner.stop();
311
+ }
312
+ if (result.ok) {
313
+ display.success('Takeover successful — this instance is now polling.');
314
+ }
315
+ else {
316
+ display.printError(`Takeover failed: ${result.reason ?? 'unknown error'}.`, 'Verify your token via /channel telegram status, or close the other Aiden instance and retry.');
317
+ }
318
+ }
319
+ // ── /channel telegram status --------------------------------------
320
+ function telegramStatus(ctx) {
321
+ const { display } = ctx;
322
+ const manager = ctx.channelManager;
323
+ const adapter = manager?.get('telegram');
324
+ if (!adapter || typeof adapter.getDiagnostics !== 'function') {
325
+ display.warn('No Telegram adapter registered in this session.');
326
+ return;
327
+ }
328
+ const d = adapter.getDiagnostics();
329
+ // Phase v4.1-1.2 — render the coarse state explicitly. "conflict"
330
+ // means another aiden instance is polling this bot; the user's
331
+ // remediation hint is /channel telegram takeover.
332
+ let stateLabel;
333
+ if (d.state === 'active')
334
+ stateLabel = display.paint('active', 'success');
335
+ else if (d.state === 'conflict')
336
+ stateLabel = display.paint('conflict (another instance is polling)', 'warn');
337
+ else if (d.state === 'degraded')
338
+ stateLabel = display.paint('degraded', 'warn');
339
+ else if (d.state === 'connecting')
340
+ stateLabel = display.muted('connecting…');
341
+ else
342
+ stateLabel = display.muted('inactive');
343
+ display.write('\n Telegram status:\n');
344
+ display.write(` state: ${stateLabel}\n`);
345
+ display.write(` bot: ${d.botUsername ? '@' + d.botUsername : '(not connected)'}\n`);
346
+ display.write(` healthy: ${d.healthy ? display.paint('yes', 'success') : display.paint('no', 'warn')}\n`);
347
+ display.write(` token set: ${d.hasToken ? 'yes' : display.paint('no', 'warn')}\n`);
348
+ display.write(` polling: ${d.pollingActive ? display.paint('active', 'success') : display.muted('idle')}\n`);
349
+ display.write(` last message: ${d.lastMessageAt ? new Date(d.lastMessageAt).toISOString() : display.muted('never')}\n`);
350
+ display.write(` polling errors:${d.errorCount > 0 ? ' ' + display.paint(String(d.errorCount), 'warn') : ' 0'}\n`);
351
+ if (typeof d.consecutiveConflicts === 'number' && d.consecutiveConflicts > 0) {
352
+ display.write(` 409 streak: ${display.paint(String(d.consecutiveConflicts), 'warn')}\n`);
353
+ }
354
+ if (ctx.paths) {
355
+ display.write(` log file: ${ctx.paths.logsDir}/telegram.log\n`);
356
+ }
357
+ if (d.state === 'conflict') {
358
+ display.write(`\n ${display.muted('Run /channel telegram takeover to reclaim this bot from the other instance.')}\n`);
359
+ }
360
+ display.write('\n');
361
+ }
362
+ // ── /channel telegram allowlist (Phase v4.1-2) --------------------
363
+ //
364
+ // Manages the TELEGRAM_ALLOWED_GROUPS env var, which gates which
365
+ // group ids the bot will respond in. Empty = open (default); populated
366
+ // = strict allowlist. Mutations write to the live process.env AND
367
+ // persist to .env so the setting survives restart.
368
+ async function telegramAllowlist(ctx, rest) {
369
+ const { display } = ctx;
370
+ const sub = (rest[0] ?? 'list').toLowerCase();
371
+ const arg = rest[1] ?? '';
372
+ const current = (process.env.TELEGRAM_ALLOWED_GROUPS ?? '')
373
+ .split(',').map(s => s.trim()).filter(Boolean);
374
+ if (sub === 'list' || sub === 'ls') {
375
+ if (current.length === 0) {
376
+ display.write('\n Group allowlist: ' + display.muted('disabled (open — bot replies in any group it is added to)') + '\n');
377
+ display.write(` ${display.muted('Add a group: /channel telegram allowlist add <group_id>')}\n\n`);
378
+ }
379
+ else {
380
+ display.write('\n Group allowlist (strict — only these groups):\n');
381
+ for (const id of current)
382
+ display.write(` ${id}\n`);
383
+ display.write('\n');
384
+ }
385
+ return;
386
+ }
387
+ if (!ctx.paths) {
388
+ display.printError('Cannot resolve .env path — paths missing.');
389
+ return;
390
+ }
391
+ if (sub === 'add') {
392
+ if (!arg) {
393
+ display.printError('Usage: /channel telegram allowlist add <group_id>');
394
+ return;
395
+ }
396
+ if (!/^-?\d+$/.test(arg)) {
397
+ display.printError('Group id must be a numeric Telegram chat id.');
398
+ return;
399
+ }
400
+ if (current.includes(arg)) {
401
+ display.dim(` Already on allowlist: ${arg}`);
402
+ return;
403
+ }
404
+ const next = [...current, arg];
405
+ process.env.TELEGRAM_ALLOWED_GROUPS = next.join(',');
406
+ await upsertEnv(ctx.paths.envFile, 'TELEGRAM_ALLOWED_GROUPS', next.join(','));
407
+ display.success(`Added ${arg} to TELEGRAM_ALLOWED_GROUPS (${next.length} group(s) allowed).`);
408
+ return;
409
+ }
410
+ if (sub === 'remove' || sub === 'rm') {
411
+ if (!arg) {
412
+ display.printError('Usage: /channel telegram allowlist remove <group_id>');
413
+ return;
414
+ }
415
+ const next = current.filter(g => g !== arg);
416
+ if (next.length === current.length) {
417
+ display.dim(` Not on allowlist: ${arg}`);
418
+ return;
419
+ }
420
+ if (next.length === 0) {
421
+ process.env.TELEGRAM_ALLOWED_GROUPS = '';
422
+ await deleteEnvKey(ctx.paths.envFile, 'TELEGRAM_ALLOWED_GROUPS');
423
+ display.success(`Removed ${arg}; allowlist now empty (open mode restored).`);
424
+ }
425
+ else {
426
+ process.env.TELEGRAM_ALLOWED_GROUPS = next.join(',');
427
+ await upsertEnv(ctx.paths.envFile, 'TELEGRAM_ALLOWED_GROUPS', next.join(','));
428
+ display.success(`Removed ${arg} (${next.length} group(s) allowed).`);
429
+ }
430
+ return;
431
+ }
432
+ display.printError(`Unknown allowlist action '${sub}'.`, 'Try: /channel telegram allowlist list | add <id> | remove <id>');
433
+ }
434
+ // ── /channel telegram groups (Phase v4.1-2) -----------------------
435
+ //
436
+ // Surfaces the persistent group state managed by TelegramGroupStore.
437
+ // `list` is the headline — shows every group the bot has observed,
438
+ // status (active / paused), title, last message timestamp.
439
+ // `pause` / `resume` flip the persistent pause flag so a group can
440
+ // be silenced from the CLI without leaving the chat.
441
+ async function telegramGroups(ctx, rest) {
442
+ const { display } = ctx;
443
+ const manager = ctx.channelManager;
444
+ const adapter = manager?.get('telegram');
445
+ const store = typeof adapter?.getGroupStore === 'function' ? adapter.getGroupStore() : null;
446
+ const sub = (rest[0] ?? 'list').toLowerCase();
447
+ const arg = rest[1] ?? '';
448
+ if (sub === 'list' || sub === 'ls') {
449
+ if (!store) {
450
+ display.warn('Telegram adapter has not started yet — no group state available.');
451
+ return;
452
+ }
453
+ const groups = store.list();
454
+ if (groups.length === 0) {
455
+ display.write('\n No groups observed yet. ' + display.muted('Add the bot to a group to start.') + '\n\n');
456
+ return;
457
+ }
458
+ display.write('\n Telegram groups:\n');
459
+ for (const g of groups) {
460
+ const status = g.paused ? display.paint('paused', 'warn') : display.paint('active', 'success');
461
+ const title = g.title ?? display.muted('(unknown title)');
462
+ const lastSeen = g.lastMessageAt
463
+ ? new Date(g.lastMessageAt).toISOString()
464
+ : display.muted('never');
465
+ display.write(` ${g.groupId} ${status} ${title}\n`);
466
+ display.write(` last message: ${lastSeen}\n`);
467
+ if (g.allowedUsers.length > 0) {
468
+ display.write(` allowed users: ${g.allowedUsers.length}\n`);
469
+ }
470
+ }
471
+ display.write('\n');
472
+ return;
473
+ }
474
+ if (sub === 'pause' || sub === 'resume') {
475
+ if (!store) {
476
+ display.warn('Telegram adapter has not started yet — cannot mutate group state.');
477
+ return;
478
+ }
479
+ if (!arg) {
480
+ display.printError(`Usage: /channel telegram groups ${sub} <group_id>`);
481
+ return;
482
+ }
483
+ if (!store.get(arg)) {
484
+ display.printError(`Unknown group id: ${arg}.`, 'Run /channel telegram groups list to see observed groups.');
485
+ return;
486
+ }
487
+ store.setPaused(arg, sub === 'pause', 'cli');
488
+ display.success(`Group ${arg} ${sub === 'pause' ? 'paused' : 'resumed'}.`);
489
+ return;
490
+ }
491
+ display.printError(`Unknown groups action '${sub}'.`, 'Try: /channel telegram groups list | pause <id> | resume <id>');
492
+ }
493
+ // ── /channel telegram voice (Phase v4.1-3) ------------------------
494
+ //
495
+ // Status / enable / disable for the Telegram voice-note path.
496
+ // Persists the toggle to TELEGRAM_VOICE_ENABLED in .env (atomic write
497
+ // via the upsertEnv helper above). Status reads back the live counter
498
+ // state from the adapter plus the on-disk cache footprint, so the user
499
+ // can confirm voice messages are landing in the cache and being
500
+ // transcribed.
501
+ async function telegramVoice(ctx, rest) {
502
+ const { display } = ctx;
503
+ const sub = (rest[0] ?? 'status').toLowerCase();
504
+ const manager = ctx.channelManager;
505
+ const adapter = manager?.get('telegram');
506
+ if (sub === 'status' || sub === '') {
507
+ if (!adapter || typeof adapter.getVoiceDiagnostics !== 'function') {
508
+ display.warn('No Telegram adapter registered in this session.');
509
+ return;
510
+ }
511
+ const d = await adapter.getVoiceDiagnostics();
512
+ const stateLabel = d.enabled
513
+ ? display.paint('enabled', 'success')
514
+ : display.paint('disabled', 'warn');
515
+ const sizeMb = (d.cacheBytes / (1024 * 1024)).toFixed(1);
516
+ display.write('\n Telegram voice status:\n');
517
+ display.write(` voice notes: ${stateLabel}\n`);
518
+ display.write(` confidence: threshold ${d.threshold} (echo when below)\n`);
519
+ display.write(` language: ${d.language ?? display.muted('auto-detect')}\n`);
520
+ display.write(` cache dir: ${d.cacheDir}\n`);
521
+ display.write(` cache size: ${sizeMb} MB across ${d.cacheFileCount} file(s)\n`);
522
+ display.write(` received: ${d.receivedCount} (since adapter start)\n`);
523
+ display.write(` transcribed: ${d.transcribedCount}\n`);
524
+ display.write('\n');
525
+ return;
526
+ }
527
+ if (sub === 'enable' || sub === 'disable') {
528
+ if (!ctx.paths) {
529
+ display.printError('Cannot resolve .env path — paths missing.');
530
+ return;
531
+ }
532
+ const next = sub === 'enable' ? 'true' : 'false';
533
+ process.env.TELEGRAM_VOICE_ENABLED = next;
534
+ await upsertEnv(ctx.paths.envFile, 'TELEGRAM_VOICE_ENABLED', next);
535
+ if (sub === 'enable') {
536
+ display.success('Voice notes enabled. Send a voice DM to test.');
537
+ }
538
+ else {
539
+ display.success('Voice notes disabled. Inbound voice will get a friendly reject.');
540
+ }
541
+ return;
542
+ }
543
+ display.printError(`Unknown voice action '${sub}'.`, 'Try: /channel telegram voice status | enable | disable');
544
+ }
545
+ // ── /channel telegram media (Phase v4.1-4) ------------------------
546
+ //
547
+ // Aggregate status / enable / disable for the photo + document path.
548
+ // Voice retains its own /channel telegram voice subcommand. Status
549
+ // reads back the live counter state from the adapter plus the
550
+ // on-disk cache footprint for all three media subdirs.
551
+ async function telegramMedia(ctx, rest) {
552
+ const { display } = ctx;
553
+ const sub = (rest[0] ?? 'status').toLowerCase();
554
+ const manager = ctx.channelManager;
555
+ const adapter = manager?.get('telegram');
556
+ if (sub === 'status' || sub === '') {
557
+ if (!adapter || typeof adapter.getMediaDiagnostics !== 'function') {
558
+ display.warn('No Telegram adapter registered in this session.');
559
+ return;
560
+ }
561
+ const d = await adapter.getMediaDiagnostics();
562
+ const stateLabel = d.enabled
563
+ ? display.paint('enabled', 'success')
564
+ : display.paint('disabled', 'warn');
565
+ const fmt = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
566
+ display.write('\n Telegram media status:\n');
567
+ display.write(` photos + docs: ${stateLabel}\n`);
568
+ display.write(` supported docs: ${d.supportedDocTypes.join(', ')}\n`);
569
+ display.write(` voice cache: ${fmt(d.voice.bytes)} across ${d.voice.files} file(s)\n`);
570
+ display.write(` photo cache: ${fmt(d.photos.bytes)} across ${d.photos.files} file(s)\n`);
571
+ display.write(` document cache: ${fmt(d.documents.bytes)} across ${d.documents.files} file(s)\n`);
572
+ display.write(` photos received: ${d.photos.receivedCount} / processed: ${d.photos.processedCount}\n`);
573
+ display.write(` docs received: ${d.documents.receivedCount} / processed: ${d.documents.processedCount}\n`);
574
+ display.write('\n');
575
+ return;
576
+ }
577
+ if (sub === 'enable' || sub === 'disable') {
578
+ if (!ctx.paths) {
579
+ display.printError('Cannot resolve .env path — paths missing.');
580
+ return;
581
+ }
582
+ const next = sub === 'enable' ? 'true' : 'false';
583
+ process.env.TELEGRAM_MEDIA_ENABLED = next;
584
+ await upsertEnv(ctx.paths.envFile, 'TELEGRAM_MEDIA_ENABLED', next);
585
+ if (sub === 'enable') {
586
+ display.success('Photos + documents enabled. Send a photo or PDF to test.');
587
+ }
588
+ else {
589
+ display.success('Photos + documents disabled. Inbound media will get a friendly reject.');
590
+ }
591
+ return;
592
+ }
593
+ display.printError(`Unknown media action '${sub}'.`, 'Try: /channel telegram media status | enable | disable');
594
+ }
595
+ // ── Top-level command --------------------------------------------
596
+ exports.channel = {
597
+ name: 'channel',
598
+ description: 'Manage channel adapters (telegram, discord, slack, …).',
599
+ category: 'system',
600
+ icon: '📡',
601
+ handler: async (ctx) => {
602
+ const args = ctx.rawArgs.trim().split(/\s+/).filter(Boolean);
603
+ const sub = args[0]?.toLowerCase();
604
+ if (!sub || sub === 'list' || sub === 'ls') {
605
+ formatList(ctx);
606
+ return;
607
+ }
608
+ if (sub === 'telegram') {
609
+ const action = args[1]?.toLowerCase() ?? 'status';
610
+ switch (action) {
611
+ case 'add':
612
+ await telegramAdd(ctx);
613
+ return;
614
+ case 'remove':
615
+ case 'rm':
616
+ await telegramRemove(ctx);
617
+ return;
618
+ case 'status':
619
+ telegramStatus(ctx);
620
+ return;
621
+ case 'takeover':
622
+ await telegramTakeover(ctx);
623
+ return;
624
+ // Phase v4.1-2 subcommands.
625
+ case 'allowlist':
626
+ await telegramAllowlist(ctx, args.slice(2));
627
+ return;
628
+ case 'groups':
629
+ await telegramGroups(ctx, args.slice(2));
630
+ return;
631
+ // Phase v4.1-3 subcommand.
632
+ case 'voice':
633
+ await telegramVoice(ctx, args.slice(2));
634
+ return;
635
+ // Phase v4.1-4 subcommand.
636
+ case 'media':
637
+ await telegramMedia(ctx, args.slice(2));
638
+ return;
639
+ default:
640
+ ctx.display.printError(`Unknown telegram action '${action}'.`, 'Try: /channel telegram add | remove | status | takeover | allowlist | groups | voice | media');
641
+ return;
642
+ }
643
+ }
644
+ // Other channel ids — Phase 2 surface area. Honest stub.
645
+ if (CHANNEL_DESCRIPTORS.some((c) => c.id === sub)) {
646
+ ctx.display.write(`\n /channel ${sub} management is coming in a later phase.\n` +
647
+ ` For now, set the env var(s) directly and restart aiden:\n`);
648
+ const desc = CHANNEL_DESCRIPTORS.find((c) => c.id === sub);
649
+ for (const v of desc.envVars)
650
+ ctx.display.write(` ${v}=...\n`);
651
+ ctx.display.write('\n');
652
+ return;
653
+ }
654
+ ctx.display.printError(`Unknown channel '${sub}'.`, 'Try: /channel list | /channel telegram add');
655
+ },
656
+ };
@@ -5,7 +5,7 @@ exports.clear = {
5
5
  name: 'clear',
6
6
  description: 'Clear conversation history.',
7
7
  category: 'system',
8
- icon: '🧹',
8
+ icon: '*',
9
9
  handler: async (ctx) => {
10
10
  if (ctx.session)
11
11
  ctx.session.clearHistory();
@@ -5,7 +5,7 @@ exports.compress = {
5
5
  name: 'compress',
6
6
  description: 'Summarise older history to free up context.',
7
7
  category: 'system',
8
- icon: '📦',
8
+ icon: '#',
9
9
  handler: async (ctx) => {
10
10
  if (!ctx.compressor || !ctx.session) {
11
11
  ctx.display.warn('Compressor or session not wired.');