aiden-runtime 4.0.2 → 4.1.1

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 (113) hide show
  1. package/README.md +19 -11
  2. package/config/hardware.json +2 -2
  3. package/dist/api/server.js +50 -52
  4. package/dist/cli/v4/aidenCLI.js +424 -7
  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 +256 -55
  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 +16 -1
  18. package/dist/cli/v4/commands/mcp.js +358 -0
  19. package/dist/cli/v4/commands/show.js +43 -0
  20. package/dist/cli/v4/commands/skills.js +169 -4
  21. package/dist/cli/v4/commands/status.js +84 -0
  22. package/dist/cli/v4/commands/subagent.js +78 -0
  23. package/dist/cli/v4/commands/verbose.js +1 -1
  24. package/dist/cli/v4/commands/voice.js +218 -0
  25. package/dist/cli/v4/cronCli.js +103 -0
  26. package/dist/cli/v4/display.js +297 -13
  27. package/dist/cli/v4/doctor.js +102 -1
  28. package/dist/cli/v4/doctorLiveness.js +329 -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/shellInterpolation.js +139 -0
  37. package/dist/cli/v4/skinEngine.js +21 -1
  38. package/dist/cli/v4/streamingPrefix.js +121 -0
  39. package/dist/cli/v4/syntaxHighlight.js +345 -0
  40. package/dist/cli/v4/table.js +216 -0
  41. package/dist/cli/v4/themeDetect.js +81 -0
  42. package/dist/cli/v4/uiBuild.js +74 -0
  43. package/dist/cli/v4/voiceCli.js +113 -0
  44. package/dist/cli/v4/voicePromptApi.js +196 -0
  45. package/dist/core/channels/discord.js +16 -10
  46. package/dist/core/channels/email.js +13 -9
  47. package/dist/core/channels/imessage.js +13 -9
  48. package/dist/core/channels/manager.js +25 -7
  49. package/dist/core/channels/pdf-extract.js +180 -0
  50. package/dist/core/channels/photo-vision.js +157 -0
  51. package/dist/core/channels/signal.js +11 -7
  52. package/dist/core/channels/slack.js +13 -10
  53. package/dist/core/channels/telegram-commands.js +154 -0
  54. package/dist/core/channels/telegram-groups.js +198 -0
  55. package/dist/core/channels/telegram-rate-limit.js +124 -0
  56. package/dist/core/channels/telegram.js +1980 -0
  57. package/dist/core/channels/twilio.js +11 -7
  58. package/dist/core/channels/webhook.js +9 -5
  59. package/dist/core/channels/whatsapp.js +15 -11
  60. package/dist/core/channels/whisper-transcribe.js +163 -0
  61. package/dist/core/cronManager.js +33 -294
  62. package/dist/core/gateway.js +29 -8
  63. package/dist/core/playwrightBridge.js +90 -0
  64. package/dist/core/v4/aidenAgent.js +35 -0
  65. package/dist/core/v4/auxiliaryClient.js +2 -2
  66. package/dist/core/v4/cron/atomicWrite.js +18 -4
  67. package/dist/core/v4/cron/cronExecute.js +300 -0
  68. package/dist/core/v4/cron/cronManager.js +502 -0
  69. package/dist/core/v4/cron/cronState.js +314 -0
  70. package/dist/core/v4/cron/cronTick.js +90 -0
  71. package/dist/core/v4/cron/diagnostics.js +104 -0
  72. package/dist/core/v4/cron/graceWindow.js +79 -0
  73. package/dist/core/v4/logger/factory.js +110 -0
  74. package/dist/core/v4/logger/index.js +22 -0
  75. package/dist/core/v4/logger/logger.js +101 -0
  76. package/dist/core/v4/logger/sinks/fileSink.js +110 -0
  77. package/dist/core/v4/logger/sinks/multiSink.js +43 -0
  78. package/dist/core/v4/logger/sinks/nullSink.js +53 -0
  79. package/dist/core/v4/logger/sinks/stdSink.js +81 -0
  80. package/dist/core/v4/mcp/server/diagnostics.js +40 -0
  81. package/dist/core/v4/mcp/server/skillBridge.js +94 -0
  82. package/dist/core/v4/mcp/server/stdioServer.js +119 -0
  83. package/dist/core/v4/mcp/server/toolBridge.js +168 -0
  84. package/dist/core/v4/platformPaths.js +105 -0
  85. package/dist/core/v4/providerFallback.js +25 -0
  86. package/dist/core/v4/skillLoader.js +21 -5
  87. package/dist/core/v4/skillMining/candidateStore.js +164 -0
  88. package/dist/core/v4/skillMining/extractorPrompt.js +118 -0
  89. package/dist/core/v4/skillMining/proposalBuilder.js +140 -0
  90. package/dist/core/v4/skillMining/skillMiner.js +191 -0
  91. package/dist/core/v4/skillMining/traceFingerprint.js +51 -0
  92. package/dist/core/v4/subagent/budget.js +76 -0
  93. package/dist/core/v4/subagent/diagnostics.js +22 -0
  94. package/dist/core/v4/subagent/fanout.js +216 -0
  95. package/dist/core/v4/subagent/merger.js +148 -0
  96. package/dist/core/v4/subagent/providerRotation.js +54 -0
  97. package/dist/core/v4/voice/audioStream.js +373 -0
  98. package/dist/core/v4/voice/cliVoice.js +393 -0
  99. package/dist/core/v4/voice/diagnostics.js +66 -0
  100. package/dist/core/v4/voice/ttsStream.js +193 -0
  101. package/dist/core/version.js +1 -1
  102. package/dist/core/visionAnalyze.js +291 -90
  103. package/dist/core/voice/audio.js +61 -5
  104. package/dist/core/voice/audioBackend.js +134 -0
  105. package/dist/core/voice/stt.js +61 -6
  106. package/dist/core/voice/tts.js +19 -3
  107. package/dist/moat/dangerousPatterns.js +1 -1
  108. package/dist/providers/v4/codexResponsesAdapter.js +7 -2
  109. package/dist/providers/v4/errors.js +51 -1
  110. package/dist/providers/v4/ollamaPromptToolsAdapter.js +9 -2
  111. package/dist/tools/v4/index.js +32 -1
  112. package/dist/tools/v4/subagent/subagentFanout.js +190 -0
  113. package/package.json +11 -2
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.MAX_PHOTO_BYTES = void 0;
8
+ exports.analyzePhotoForChannel = analyzePhotoForChannel;
9
+ // core/channels/photo-vision.ts — Phase v4.1-4.
10
+ //
11
+ // Channel-side adapter for inbound photos. Wraps the existing
12
+ // `core/visionAnalyze.ts` chain with Telegram-specific concerns:
13
+ //
14
+ // 1. 25 MB pre-download size cap. Mirrors the voice path's policy
15
+ // (Telegram Bot API getFile limit is the binding constraint).
16
+ // Refusing here saves the round-trip when we already know the
17
+ // payload is too big for the providers.
18
+ // 2. Mode decision — `native` vs `text` — based on whether the
19
+ // currently active model carries a `supportsVision: true` flag in
20
+ // `providers/v4/modelCatalog.ts`. When vision is supported, the
21
+ // channel adapter attaches the file path on the user turn so the
22
+ // provider sees pixels directly. Otherwise we pre-analyze with
23
+ // the auxiliary `analyzeImage` chain (Anthropic / OpenAI / Ollama
24
+ // llava) and prepend a description annotation — same "smuggle
25
+ // into agent turn" pattern as voice transcripts.
26
+ // 3. Result-shape contract that matches the channel adapter's
27
+ // expectations so the Telegram adapter's `handlePhotoMessage`
28
+ // can branch on `mode` and assemble the right outbound payload.
29
+ //
30
+ // Logger comes from the v4.1-1.3a contract; defaults to a noop sink
31
+ // so anything that calls into this module without a wired logger
32
+ // stays REPL-clean.
33
+ const node_fs_1 = require("node:fs");
34
+ const modelCatalog_1 = require("../../providers/v4/modelCatalog");
35
+ const visionAnalyze_1 = require("../visionAnalyze");
36
+ const logger_1 = require("../v4/logger");
37
+ // 25 MiB. Matches the voice cap and the OpenAI / Anthropic vision
38
+ // request-size envelopes; Telegram's getFile cap is 20 MB so a 25 MB
39
+ // payload would already have been rejected upstream — but keeping
40
+ // these caps consistent simplifies the operator mental model.
41
+ exports.MAX_PHOTO_BYTES = 25 * 1024 * 1024;
42
+ /**
43
+ * Default text-mode prompt. Single source so smokes and the adapter
44
+ * agree on what gets sent to the auxiliary vision chain. Phrased
45
+ * for the agent's perspective — the description ends up bracketed
46
+ * inside an "[The user sent an image. Description: ...]" annotation.
47
+ */
48
+ const DEFAULT_DESCRIBE_PROMPT = 'Describe everything visible in this image in detail. Include any ' +
49
+ 'text, code, layout, objects, people, colors, and any other notable ' +
50
+ 'visual information.';
51
+ /**
52
+ * Decide how an inbound photo should be presented to the model and
53
+ * (in text mode) generate the description the channel adapter will
54
+ * smuggle into the agent's user turn.
55
+ *
56
+ * Never throws — failures land on `success: false` with a
57
+ * human-readable `error`. Callers downstream decide whether to:
58
+ * - hand a `[The user sent an image but description failed: ...]`
59
+ * directive to the agent, or
60
+ * - render a friendly user-facing reject reply.
61
+ */
62
+ async function analyzePhotoForChannel(opts) {
63
+ const log = opts.logger ?? (0, logger_1.noopLogger)();
64
+ const cap = opts.maxBytesOverride ?? exports.MAX_PHOTO_BYTES;
65
+ // ── 1. Size precheck ────────────────────────────────────────────
66
+ let sizeBytes;
67
+ try {
68
+ const st = await node_fs_1.promises.stat(opts.filePath);
69
+ sizeBytes = st.size;
70
+ }
71
+ catch (e) {
72
+ log.warn('photo file not readable', { path: opts.filePath, error: e?.message });
73
+ return { success: false, mode: 'text', error: `photo file not readable: ${e?.message ?? 'unknown error'}` };
74
+ }
75
+ if (sizeBytes > cap) {
76
+ log.warn('photo file too large', { sizeBytes, cap });
77
+ return {
78
+ success: false,
79
+ mode: 'text',
80
+ error: `Photo too large: ${(sizeBytes / (1024 * 1024)).toFixed(1)} MB ` +
81
+ `(cap is ${(cap / (1024 * 1024)).toFixed(0)} MB).`,
82
+ };
83
+ }
84
+ // ── 2. Mode decision ────────────────────────────────────────────
85
+ const mode = decideMode(opts.providerId, opts.modelId, log);
86
+ if (mode === 'native') {
87
+ log.info('photo routed native', {
88
+ providerId: opts.providerId,
89
+ modelId: opts.modelId,
90
+ sizeBytes,
91
+ });
92
+ return { success: true, mode: 'native', nativePath: opts.filePath };
93
+ }
94
+ // ── 3. Text mode: pre-analyze via the auxiliary vision chain ────
95
+ const analyze = opts.analyzeFn ?? visionAnalyze_1.analyzeImage;
96
+ try {
97
+ const visionResult = await analyze(opts.filePath, opts.prompt ?? DEFAULT_DESCRIBE_PROMPT, log);
98
+ const description = (visionResult.description ?? '').trim();
99
+ if (!description) {
100
+ log.warn('vision chain returned empty description', { provider: visionResult.provider });
101
+ return {
102
+ success: false,
103
+ mode: 'text',
104
+ error: 'vision chain returned an empty description',
105
+ provider: visionResult.provider,
106
+ modelUsed: visionResult.modelUsed,
107
+ durationMs: visionResult.durationMs,
108
+ };
109
+ }
110
+ return {
111
+ success: true,
112
+ mode: 'text',
113
+ description,
114
+ provider: visionResult.provider,
115
+ modelUsed: visionResult.modelUsed,
116
+ durationMs: visionResult.durationMs,
117
+ };
118
+ }
119
+ catch (e) {
120
+ log.error('vision chain threw', { error: e?.message ?? String(e) });
121
+ return {
122
+ success: false,
123
+ mode: 'text',
124
+ error: `vision chain failed: ${e?.message ?? 'unknown error'}`,
125
+ };
126
+ }
127
+ }
128
+ /**
129
+ * Resolve native-vs-text routing for the active model. Returns
130
+ * `'text'` whenever:
131
+ * - either id is missing (caller didn't tell us)
132
+ * - the model isn't in `MODEL_CATALOG` (registry drift)
133
+ * - the model's `supportsVision` is false
134
+ * - the lookup throws for any reason
135
+ *
136
+ * `'text'` is the safe fallback — it always works because the
137
+ * auxiliary `analyzeImage` chain runs against its own provider keys
138
+ * independent of whatever the user has selected for the agent loop.
139
+ */
140
+ function decideMode(providerId, modelId, log) {
141
+ if (!providerId || !modelId) {
142
+ log.debug('photo mode: missing provider/model id, defaulting to text');
143
+ return 'text';
144
+ }
145
+ try {
146
+ const entry = (0, modelCatalog_1.findModel)(providerId, modelId);
147
+ if (!entry) {
148
+ log.debug('photo mode: model not in catalog, defaulting to text', { providerId, modelId });
149
+ return 'text';
150
+ }
151
+ return entry.supportsVision ? 'native' : 'text';
152
+ }
153
+ catch (e) {
154
+ log.debug('photo mode: lookup threw, defaulting to text', { error: e?.message });
155
+ return 'text';
156
+ }
157
+ }
@@ -25,9 +25,12 @@ exports.SignalAdapter = void 0;
25
25
  // SIGNAL_ALLOWED_NUMBERS — optional comma-separated allowlist
26
26
  const axios_1 = __importDefault(require("axios"));
27
27
  const gateway_1 = require("../gateway");
28
+ const logger_1 = require("../v4/logger");
28
29
  class SignalAdapter {
29
30
  constructor() {
30
31
  this.name = 'signal';
32
+ // Phase v4.1-1.3a — diagnostics route through scope logger.
33
+ this.log = (0, logger_1.noopLogger)();
31
34
  this.healthy = false;
32
35
  this.pollTimer = null;
33
36
  this.lastReceived = 0;
@@ -36,21 +39,22 @@ class SignalAdapter {
36
39
  const raw = process.env.SIGNAL_ALLOWED_NUMBERS ?? '';
37
40
  this.allowedNumbers = raw ? new Set(raw.split(',').map(s => s.trim()).filter(Boolean)) : new Set();
38
41
  }
42
+ attachLogger(logger) { this.log = logger; }
39
43
  // ── Lifecycle ──────────────────────────────────────────────
40
44
  async start() {
41
45
  if (!this.myNumber) {
42
- console.log('[Signal] Disabled — set SIGNAL_PHONE_NUMBER to enable');
46
+ this.log.info('Disabled — set SIGNAL_PHONE_NUMBER to enable');
43
47
  return;
44
48
  }
45
49
  // Verify signal-cli is reachable
46
50
  const reachable = await this.checkHealth();
47
51
  if (!reachable) {
48
- console.log(`[Signal] Disabled — signal-cli-rest-api not reachable at ${this.baseUrl}`);
52
+ this.log.info('Disabled — signal-cli-rest-api not reachable at ${this.baseUrl}');
49
53
  return;
50
54
  }
51
55
  this.healthy = true;
52
56
  this.lastReceived = Date.now();
53
- console.log(`[Signal] Connected — polling ${this.baseUrl}`);
57
+ this.log.info('Connected — polling ${this.baseUrl}');
54
58
  // Register outbound delivery
55
59
  gateway_1.gateway.registerChannel('signal', async (msg) => {
56
60
  await this.send(msg.channelId, msg.text);
@@ -66,7 +70,7 @@ class SignalAdapter {
66
70
  this.pollTimer = null;
67
71
  }
68
72
  gateway_1.gateway.unregisterChannel('signal');
69
- console.log('[Signal] Disconnected');
73
+ this.log.info('Disconnected');
70
74
  }
71
75
  async send(target, message) {
72
76
  if (!this.healthy)
@@ -75,7 +79,7 @@ class SignalAdapter {
75
79
  await axios_1.default.post(`${this.baseUrl}/v2/send`, { message, number: this.myNumber, recipients: [target] }, { timeout: 10000 });
76
80
  }
77
81
  catch (e) {
78
- console.error('[Signal] send error:', e.message);
82
+ this.log.error(`send error:${e.message}`);
79
83
  }
80
84
  }
81
85
  isHealthy() { return this.healthy; }
@@ -112,7 +116,7 @@ class SignalAdapter {
112
116
  catch (e) {
113
117
  // Don't spam logs on transient poll errors
114
118
  if (this.healthy) {
115
- console.error('[Signal] poll error:', e.message);
119
+ this.log.error(`poll error:${e.message}`);
116
120
  }
117
121
  }
118
122
  }
@@ -132,7 +136,7 @@ class SignalAdapter {
132
136
  });
133
137
  }
134
138
  catch (e) {
135
- console.error('[Signal] routeMessage error:', e.message);
139
+ this.log.error(`routeMessage error:${e.message}`);
136
140
  return '❌ Something went wrong. Try again.';
137
141
  }
138
142
  }
@@ -27,11 +27,13 @@ exports.SlackAdapter = void 0;
27
27
  // - Graceful degradation: missing creds → disabled, no crash
28
28
  const bolt_1 = require("@slack/bolt");
29
29
  const gateway_1 = require("../gateway");
30
+ const logger_1 = require("../v4/logger");
30
31
  class SlackAdapter {
31
32
  constructor() {
32
33
  this.name = 'slack';
33
34
  this.app = null;
34
35
  this.healthy = false;
36
+ this.log = (0, logger_1.noopLogger)(); // Phase v4.1-1.3a — wired by ChannelManager.register
35
37
  this.botToken = process.env.SLACK_BOT_TOKEN ?? '';
36
38
  this.signingSecret = process.env.SLACK_SIGNING_SECRET ?? '';
37
39
  this.appToken = process.env.SLACK_APP_TOKEN ?? '';
@@ -40,10 +42,11 @@ class SlackAdapter {
40
42
  ? new Set(rawChannels.split(',').map(s => s.trim()).filter(Boolean))
41
43
  : new Set();
42
44
  }
45
+ attachLogger(logger) { this.log = logger; }
43
46
  // ── Lifecycle ──────────────────────────────────────────────
44
47
  async start() {
45
48
  if (!this.botToken || !this.signingSecret) {
46
- console.log('[Slack] Disabled — set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET to enable');
49
+ this.log.info('Disabled — set SLACK_BOT_TOKEN and SLACK_SIGNING_SECRET to enable');
47
50
  return;
48
51
  }
49
52
  const useSocketMode = !!this.appToken;
@@ -63,7 +66,7 @@ class SlackAdapter {
63
66
  if (this.allowedChannels.size > 0 && !this.allowedChannels.has(msg.channel))
64
67
  return;
65
68
  const response = await this.processMessage(msg.channel, msg.user ?? 'unknown', msg.text ?? '');
66
- await say({ text: response, thread_ts: msg.ts }).catch((e) => console.error('[Slack] say error:', e.message));
69
+ await say({ text: response, thread_ts: msg.ts }).catch((e) => this.log.error(`say error: ${e.message}`));
67
70
  });
68
71
  // ── App mentions (@Aiden) ────────────────────────────────
69
72
  this.app.event('app_mention', async ({ event, say }) => {
@@ -72,7 +75,7 @@ class SlackAdapter {
72
75
  // Strip the @mention prefix from the message
73
76
  const text = (event.text ?? '').replace(/<@[^>]+>/g, '').trim();
74
77
  const response = await this.processMessage(event.channel, event.user, text);
75
- await say({ text: response, thread_ts: event.ts }).catch((e) => console.error('[Slack] mention reply error:', e.message));
78
+ await say({ text: response, thread_ts: event.ts }).catch((e) => this.log.error(`mention reply error: ${e.message}`));
76
79
  });
77
80
  // ── Slash command /aiden ─────────────────────────────────
78
81
  this.app.command('/aiden', async ({ command, ack, say }) => {
@@ -82,7 +85,7 @@ class SlackAdapter {
82
85
  return;
83
86
  }
84
87
  const response = await this.processMessage(command.channel_id, command.user_id, command.text);
85
- await say(response).catch((e) => console.error('[Slack] slash reply error:', e.message));
88
+ await say(response).catch((e) => this.log.error(`slash reply error: ${e.message}`));
86
89
  });
87
90
  // Register outbound delivery so gateway.deliver() and broadcast() work
88
91
  gateway_1.gateway.registerChannel('slack', async (msg) => {
@@ -91,7 +94,7 @@ class SlackAdapter {
91
94
  return true;
92
95
  }
93
96
  catch (e) {
94
- console.error('[Slack] Delivery error:', e.message);
97
+ this.log.error(`Delivery error: ${e.message}`);
95
98
  return false;
96
99
  }
97
100
  });
@@ -99,10 +102,10 @@ class SlackAdapter {
99
102
  try {
100
103
  await this.app.start(port);
101
104
  this.healthy = true;
102
- console.log(`[Slack] Connected (${useSocketMode ? 'socket mode' : `HTTP mode port ${port}`})`);
105
+ this.log.info(`Connected (${useSocketMode ? 'socket mode' : `HTTP mode port ${port}`})`);
103
106
  }
104
107
  catch (e) {
105
- console.error('[Slack] Start failed:', e.message);
108
+ this.log.error(`Start failed: ${e.message}`);
106
109
  this.healthy = false;
107
110
  }
108
111
  }
@@ -113,10 +116,10 @@ class SlackAdapter {
113
116
  await this.app.stop().catch(() => { });
114
117
  this.app = null;
115
118
  }
116
- console.log('[Slack] Disconnected');
119
+ this.log.info('Disconnected');
117
120
  }
118
121
  async send(channelId, message) {
119
- await this.app?.client.chat.postMessage({ channel: channelId, text: message }).catch((e) => console.error('[Slack] send error:', e.message));
122
+ await this.app?.client.chat.postMessage({ channel: channelId, text: message }).catch((e) => this.log.error(`send error: ${e.message}`));
120
123
  }
121
124
  isHealthy() { return this.healthy; }
122
125
  // ── Helpers ────────────────────────────────────────────────
@@ -131,7 +134,7 @@ class SlackAdapter {
131
134
  });
132
135
  }
133
136
  catch (e) {
134
- console.error('[Slack] routeMessage error:', e.message);
137
+ this.log.error(`routeMessage error: ${e.message}`);
135
138
  return '❌ Something went wrong. Try again.';
136
139
  }
137
140
  }
@@ -0,0 +1,154 @@
1
+ "use strict";
2
+ // ============================================================
3
+ // DevOS — Autonomous AI Execution System
4
+ // Copyright (c) 2026 Shiva Deore. All rights reserved.
5
+ // ============================================================
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.TelegramCommandRouter = void 0;
8
+ const logger_1 = require("../v4/logger");
9
+ class TelegramCommandRouter {
10
+ constructor(opts) {
11
+ this.store = opts.store;
12
+ this.log = opts.logger ?? (0, logger_1.noopLogger)();
13
+ this.botUsername = opts.botUsername ?? (() => null);
14
+ this.fetchGroupAdmins = opts.fetchGroupAdmins;
15
+ }
16
+ /**
17
+ * Inspect a Telegram message and decide whether to consume it as a
18
+ * command or pass it through to the agent. Pure dispatch — never
19
+ * sends messages itself; the caller renders the reply text and
20
+ * applies state changes.
21
+ */
22
+ async route(msg) {
23
+ const text = (msg.text ?? '').trim();
24
+ if (!text.startsWith('/'))
25
+ return { kind: 'agent' };
26
+ const { cmd, args } = this.parseCommand(text);
27
+ if (!cmd)
28
+ return { kind: 'agent' };
29
+ const chatId = String(msg.chat.id);
30
+ const chatType = msg.chat.type;
31
+ const senderId = msg.from?.id ? String(msg.from.id) : '';
32
+ switch (cmd) {
33
+ case '/help':
34
+ case '/start':
35
+ return { kind: 'reply', text: this.helpText(chatType) };
36
+ case '/status':
37
+ return { kind: 'reply', text: '✓ Online' };
38
+ case '/clear': {
39
+ // DMs: anyone may /clear their own chat. Groups: admin only.
40
+ if (chatType !== 'private' && !(await this.isAdmin(senderId, chatId))) {
41
+ this.log.info(`/clear ignored — non-admin in group`, { chatId, senderId });
42
+ return { kind: 'handled' };
43
+ }
44
+ if (chatType !== 'private')
45
+ this.store.recordAdminAction(chatId, 'clear', senderId);
46
+ return { kind: 'cleared' };
47
+ }
48
+ case '/pause': {
49
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
50
+ this.log.info(`/pause ignored`, { chatId, senderId, chatType });
51
+ return { kind: 'handled' };
52
+ }
53
+ this.store.setPaused(chatId, true, senderId);
54
+ return { kind: 'paused', groupId: chatId };
55
+ }
56
+ case '/resume': {
57
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
58
+ this.log.info(`/resume ignored`, { chatId, senderId, chatType });
59
+ return { kind: 'handled' };
60
+ }
61
+ this.store.setPaused(chatId, false, senderId);
62
+ return { kind: 'resumed', groupId: chatId };
63
+ }
64
+ case '/allowusers': {
65
+ if (!this.requireGroup(chatType) || !(await this.isAdmin(senderId, chatId))) {
66
+ this.log.info(`/allowusers ignored`, { chatId, senderId, chatType });
67
+ return { kind: 'handled' };
68
+ }
69
+ // Comma- or space-separated. `/allowusers reset` clears the list.
70
+ const raw = args.join(' ').trim();
71
+ if (raw === '' || raw === 'reset' || raw === 'clear') {
72
+ this.store.setAllowedUsers(chatId, [], senderId);
73
+ return { kind: 'reply', text: '✓ Cleared user allowlist for this group.' };
74
+ }
75
+ const ids = raw.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
76
+ this.store.setAllowedUsers(chatId, ids, senderId);
77
+ return {
78
+ kind: 'reply',
79
+ text: `✓ User allowlist updated: ${ids.length} id(s) — only these users may chat with the bot here.`,
80
+ };
81
+ }
82
+ default:
83
+ // Unknown slash command — fall through to the agent. The
84
+ // model can decide whether to interpret it as natural input.
85
+ return { kind: 'agent' };
86
+ }
87
+ }
88
+ // ── Admin checks ────────────────────────────────────────────────
89
+ /**
90
+ * True when `senderId` is allowed to issue admin-only commands here.
91
+ * Owner takes priority; TELEGRAM_ADMIN_USERS escalates additional
92
+ * ids; TELEGRAM_TRUST_GROUP_ADMINS=true (off by default) accepts
93
+ * Telegram-side group admins.
94
+ */
95
+ async isAdmin(senderId, chatId) {
96
+ if (!senderId)
97
+ return false;
98
+ const ownerId = (process.env.TELEGRAM_OWNER_ID ?? '').trim();
99
+ if (ownerId && senderId === ownerId)
100
+ return true;
101
+ const adminCsv = (process.env.TELEGRAM_ADMIN_USERS ?? '').trim();
102
+ if (adminCsv) {
103
+ const admins = adminCsv.split(',').map((s) => s.trim()).filter(Boolean);
104
+ if (admins.includes(senderId))
105
+ return true;
106
+ }
107
+ const trustGroupAdmins = (process.env.TELEGRAM_TRUST_GROUP_ADMINS ?? '').toLowerCase() === 'true';
108
+ if (trustGroupAdmins && this.fetchGroupAdmins) {
109
+ try {
110
+ const admins = await this.fetchGroupAdmins(chatId);
111
+ if (admins.includes(senderId))
112
+ return true;
113
+ }
114
+ catch (err) {
115
+ this.log.warn(`getChatAdministrators failed: ${err?.message ?? err}`, { chatId });
116
+ }
117
+ }
118
+ return false;
119
+ }
120
+ // ── Internals ───────────────────────────────────────────────────
121
+ /**
122
+ * Extract `/cmd` and the args list from raw text.
123
+ * Strips `@bot_username` suffixes Telegram appends in groups so
124
+ * `/clear@aiden_test_bot` resolves to `/clear`.
125
+ */
126
+ parseCommand(raw) {
127
+ const parts = raw.split(/\s+/);
128
+ if (!parts[0] || !parts[0].startsWith('/'))
129
+ return { cmd: null, args: [] };
130
+ const username = (this.botUsername() ?? '').toLowerCase();
131
+ let head = parts[0].toLowerCase();
132
+ if (username && head.endsWith(`@${username}`)) {
133
+ head = head.slice(0, head.length - username.length - 1);
134
+ }
135
+ return { cmd: head, args: parts.slice(1) };
136
+ }
137
+ requireGroup(chatType) {
138
+ return chatType === 'group' || chatType === 'supergroup';
139
+ }
140
+ helpText(chatType) {
141
+ const groupExtras = chatType !== 'private'
142
+ ? '`/pause` admin: stop bot in this group\n' +
143
+ '`/resume` admin: resume bot\n' +
144
+ '`/allowusers` admin: restrict who may chat\n'
145
+ : '';
146
+ return ('*Aiden* — your local AI assistant.\n\n' +
147
+ 'Send any message (or @mention me in a group) to start. Built-in commands:\n' +
148
+ '`/help` show this message\n' +
149
+ '`/status` bot health check\n' +
150
+ '`/clear` wipe this chat\'s memory\n' +
151
+ groupExtras);
152
+ }
153
+ }
154
+ exports.TelegramCommandRouter = TelegramCommandRouter;