@soleri/forge 5.11.0 → 5.12.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 (53) hide show
  1. package/dist/facades/forge.facade.js +3 -3
  2. package/dist/facades/forge.facade.js.map +1 -1
  3. package/dist/lib.d.ts +2 -2
  4. package/dist/lib.js +1 -1
  5. package/dist/lib.js.map +1 -1
  6. package/dist/scaffolder.js +137 -20
  7. package/dist/scaffolder.js.map +1 -1
  8. package/dist/templates/agents-md.d.ts +5 -0
  9. package/dist/templates/agents-md.js +33 -0
  10. package/dist/templates/agents-md.js.map +1 -0
  11. package/dist/templates/entry-point.js +15 -2
  12. package/dist/templates/entry-point.js.map +1 -1
  13. package/dist/templates/package-json.js +7 -0
  14. package/dist/templates/package-json.js.map +1 -1
  15. package/dist/templates/readme.js +80 -27
  16. package/dist/templates/readme.js.map +1 -1
  17. package/dist/templates/setup-script.d.ts +1 -1
  18. package/dist/templates/setup-script.js +135 -53
  19. package/dist/templates/setup-script.js.map +1 -1
  20. package/dist/templates/skills.d.ts +0 -7
  21. package/dist/templates/skills.js +0 -21
  22. package/dist/templates/skills.js.map +1 -1
  23. package/dist/templates/telegram-agent.d.ts +6 -0
  24. package/dist/templates/telegram-agent.js +212 -0
  25. package/dist/templates/telegram-agent.js.map +1 -0
  26. package/dist/templates/telegram-bot.d.ts +6 -0
  27. package/dist/templates/telegram-bot.js +450 -0
  28. package/dist/templates/telegram-bot.js.map +1 -0
  29. package/dist/templates/telegram-config.d.ts +6 -0
  30. package/dist/templates/telegram-config.js +81 -0
  31. package/dist/templates/telegram-config.js.map +1 -0
  32. package/dist/templates/telegram-supervisor.d.ts +6 -0
  33. package/dist/templates/telegram-supervisor.js +148 -0
  34. package/dist/templates/telegram-supervisor.js.map +1 -0
  35. package/dist/types.d.ts +13 -5
  36. package/dist/types.js +7 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/__tests__/scaffolder.test.ts +62 -0
  40. package/src/facades/forge.facade.ts +3 -3
  41. package/src/lib.ts +2 -1
  42. package/src/scaffolder.ts +170 -28
  43. package/src/templates/agents-md.ts +35 -0
  44. package/src/templates/entry-point.ts +15 -2
  45. package/src/templates/package-json.ts +7 -0
  46. package/src/templates/readme.ts +89 -27
  47. package/src/templates/setup-script.ts +141 -54
  48. package/src/templates/skills.ts +0 -23
  49. package/src/templates/telegram-agent.ts +214 -0
  50. package/src/templates/telegram-bot.ts +452 -0
  51. package/src/templates/telegram-config.ts +83 -0
  52. package/src/templates/telegram-supervisor.ts +150 -0
  53. package/src/types.ts +9 -2
@@ -0,0 +1,452 @@
1
+ /**
2
+ * Template: Telegram bot entry point with Grammy.
3
+ * Generated by @soleri/forge for agents with Telegram transport.
4
+ */
5
+
6
+ import type { AgentConfig } from '../types.js';
7
+
8
+ export function generateTelegramBot(config: AgentConfig): string {
9
+ return `/**
10
+ * ${config.name} — Telegram Bot Entry Point.
11
+ * Generated by @soleri/forge.
12
+ *
13
+ * Grammy bot with middleware pipeline, session management, and agent dispatch.
14
+ */
15
+
16
+ import { Bot } from 'grammy';
17
+ import { loadTelegramConfig, type TelegramConfig } from './telegram-config.js';
18
+ import { createTelegramAgent } from './telegram-agent.js';
19
+ import {
20
+ ChatSessionManager,
21
+ ChatAuthManager,
22
+ TaskCancellationManager,
23
+ SelfUpdateManager,
24
+ RESTART_EXIT_CODE,
25
+ NotificationEngine,
26
+ FragmentBuffer,
27
+ chunkResponse,
28
+ detectFileIntent,
29
+ buildMultimodalContent,
30
+ saveTempFile,
31
+ cleanupTempFiles,
32
+ MAX_FILE_SIZE,
33
+ } from '@soleri/core';
34
+ import type { Fragment, FileInfo } from '@soleri/core';
35
+ import { join } from 'node:path';
36
+ import { homedir } from 'node:os';
37
+ import { mkdirSync, appendFileSync } from 'node:fs';
38
+
39
+ // ─── Config ──────────────────────────────────────────────────────────
40
+
41
+ const CONFIG_DIR = join(homedir(), '.${config.id}');
42
+ const SESSIONS_DIR = join(CONFIG_DIR, 'telegram-sessions');
43
+ const AUTH_FILE = join(CONFIG_DIR, 'telegram-auth.json');
44
+ const RESTART_FILE = join(CONFIG_DIR, 'telegram-restart.json');
45
+ const UPLOAD_DIR = join(CONFIG_DIR, 'telegram-uploads');
46
+ const LOG_DIR = join(CONFIG_DIR, 'logs');
47
+
48
+ // ─── State ───────────────────────────────────────────────────────────
49
+
50
+ const processingLocks = new Set<string>();
51
+ let telegramConfig: TelegramConfig;
52
+ let sessions: ChatSessionManager;
53
+ let auth: ChatAuthManager;
54
+ let fragmentBuffer: FragmentBuffer;
55
+ const cancellation = new TaskCancellationManager();
56
+ const updater = new SelfUpdateManager(RESTART_FILE);
57
+ let notifications: NotificationEngine;
58
+
59
+ // ─── Event Logger ────────────────────────────────────────────────────
60
+
61
+ function logEvent(type: string, chatId: number | string, data?: Record<string, unknown>): void {
62
+ try {
63
+ mkdirSync(LOG_DIR, { recursive: true });
64
+ const date = new Date().toISOString().split('T')[0];
65
+ const entry = JSON.stringify({ ts: new Date().toISOString(), chatId, type, ...data });
66
+ appendFileSync(join(LOG_DIR, \`events-\${date}.jsonl\`), entry + '\\n', 'utf-8');
67
+ } catch {
68
+ // Logging failure is non-critical
69
+ }
70
+ }
71
+
72
+ // ─── Bot Setup ───────────────────────────────────────────────────────
73
+
74
+ export async function startBot(): Promise<void> {
75
+ telegramConfig = loadTelegramConfig();
76
+
77
+ // Initialize infrastructure
78
+ sessions = new ChatSessionManager({
79
+ storageDir: SESSIONS_DIR,
80
+ ttlMs: 7_200_000, // 2 hours
81
+ compactionThreshold: 100,
82
+ compactionKeep: 40,
83
+ });
84
+ sessions.startReaper();
85
+
86
+ auth = new ChatAuthManager({
87
+ storagePath: AUTH_FILE,
88
+ passphrase: telegramConfig.passphrase,
89
+ allowedUsers: telegramConfig.allowedUsers,
90
+ });
91
+
92
+ fragmentBuffer = new FragmentBuffer(
93
+ { startThreshold: 4000, maxGapMs: 1500, maxParts: 12, maxTotalBytes: 50_000 },
94
+ (key, merged) => handleMergedMessage(key, merged),
95
+ );
96
+
97
+ // Initialize notification engine
98
+ notifications = new NotificationEngine({
99
+ intervalMs: 30 * 60 * 1000, // 30 minutes
100
+ onNotify: async (_checkId, message) => {
101
+ try {
102
+ // Send to the most recently active chat
103
+ const activeSessions = sessions.listActive();
104
+ if (activeSessions.length > 0) {
105
+ await bot.api.sendMessage(activeSessions[0], message, { parse_mode: 'HTML' });
106
+ }
107
+ } catch {
108
+ // Notification delivery failure is non-critical
109
+ }
110
+ },
111
+ });
112
+
113
+ const bot = new Bot(telegramConfig.botToken);
114
+
115
+ // ─── Restart Context ───────────────────────────────────────────
116
+
117
+ const restartCtx = updater.loadContext();
118
+ if (restartCtx) {
119
+ updater.clearContext();
120
+ try {
121
+ await bot.api.sendMessage(restartCtx.chatId, \`Back online! Restart reason: \${restartCtx.reason}.\`);
122
+ logEvent('restart_confirmed', restartCtx.chatId, { reason: restartCtx.reason });
123
+ } catch {
124
+ // Chat might not exist anymore
125
+ }
126
+ }
127
+
128
+ // ─── Commands ──────────────────────────────────────────────────
129
+
130
+ bot.command('start', async (ctx) => {
131
+ if (!checkAuth(ctx)) return;
132
+ await ctx.reply('Welcome! I am ${config.name}, ${config.role}. How can I help?');
133
+ logEvent('command', ctx.chat.id, { command: 'start' });
134
+ });
135
+
136
+ bot.command('clear', async (ctx) => {
137
+ if (!checkAuth(ctx)) return;
138
+ sessions.clear(String(ctx.chat.id));
139
+ await ctx.reply('Session cleared. Starting fresh.');
140
+ logEvent('command', ctx.chat.id, { command: 'clear' });
141
+ });
142
+
143
+ bot.command('health', async (ctx) => {
144
+ if (!checkAuth(ctx)) return;
145
+ const status = {
146
+ sessions: sessions.size,
147
+ authenticated: auth.authenticatedCount,
148
+ fragments: fragmentBuffer.pendingCount,
149
+ };
150
+ await ctx.reply(\`Health: \${JSON.stringify(status, null, 2)}\`);
151
+ logEvent('command', ctx.chat.id, { command: 'health' });
152
+ });
153
+
154
+ bot.command('help', async (ctx) => {
155
+ if (!checkAuth(ctx)) return;
156
+ await ctx.reply(
157
+ [
158
+ '<b>Commands:</b>',
159
+ '/start — Initialize',
160
+ '/clear — Clear conversation',
161
+ '/health — System status',
162
+ '/help — This message',
163
+ '/stop — Cancel current task',
164
+ '/restart — Restart the bot',
165
+ ].join('\\n'),
166
+ { parse_mode: 'HTML' },
167
+ );
168
+ });
169
+
170
+ bot.command('stop', async (ctx) => {
171
+ const chatId = String(ctx.chat.id);
172
+ const info = cancellation.cancel(chatId);
173
+ if (info) {
174
+ const ranSec = ((Date.now() - info.startedAt) / 1000).toFixed(1);
175
+ await ctx.reply(\`Task cancelled after \${ranSec}s.\`);
176
+ logEvent('command', ctx.chat.id, { command: 'stop', ranMs: Date.now() - info.startedAt });
177
+ } else {
178
+ await ctx.reply('No task is currently running.');
179
+ }
180
+ });
181
+
182
+ bot.command('restart', async (ctx) => {
183
+ if (!checkAuth(ctx)) return;
184
+ const chatId = String(ctx.chat.id);
185
+ const result = updater.requestRestart(chatId, 'manual');
186
+ if (result.initiated) {
187
+ await ctx.reply('Restarting... I will be back shortly.');
188
+ logEvent('command', ctx.chat.id, { command: 'restart' });
189
+ setTimeout(() => process.exit(RESTART_EXIT_CODE), 1000);
190
+ } else {
191
+ await ctx.reply(\`Restart failed: \${result.error}\`);
192
+ }
193
+ });
194
+
195
+ // ─── Photo Messages ────────────────────────────────────────────
196
+
197
+ bot.on('message:photo', async (ctx) => {
198
+ if (!checkAuth(ctx)) return;
199
+ const chatId = String(ctx.chat.id);
200
+
201
+ try {
202
+ const photo = ctx.message.photo[ctx.message.photo.length - 1]; // Largest size
203
+ const file = await ctx.api.getFile(photo.file_id);
204
+ if (!file.file_path) {
205
+ await ctx.reply('Could not download photo.');
206
+ return;
207
+ }
208
+
209
+ const url = \`https://api.telegram.org/file/bot\${telegramConfig.botToken}/\${file.file_path}\`;
210
+ const resp = await fetch(url);
211
+ const buffer = Buffer.from(await resp.arrayBuffer());
212
+
213
+ if (buffer.length > MAX_FILE_SIZE) {
214
+ await ctx.reply('File too large (max 20MB). Please compress and try again.');
215
+ return;
216
+ }
217
+
218
+ const caption = ctx.message.caption ?? 'What do you see in this image?';
219
+ const fileInfo: FileInfo = {
220
+ name: file.file_path.split('/').pop() ?? 'photo.jpg',
221
+ mimeType: 'image/jpeg',
222
+ size: buffer.length,
223
+ data: buffer,
224
+ };
225
+
226
+ const intent = detectFileIntent(fileInfo.name, fileInfo.mimeType, caption);
227
+ const content = buildMultimodalContent(fileInfo, intent);
228
+
229
+ // Build multimodal user message for agent
230
+ const multimodalText = content.type === 'text'
231
+ ? \`[File: \${fileInfo.name}]\\n\${content.content}\\n\\n\${caption}\`
232
+ : \`[Image: \${fileInfo.name}, \${Math.round(fileInfo.size / 1024)}KB]\\n\${caption}\`;
233
+
234
+ logEvent('photo_in', ctx.chat.id, { size: buffer.length, intent });
235
+ await dispatchToAgent(chatId, multimodalText, ctx);
236
+ } catch (error) {
237
+ const msg = error instanceof Error ? error.message : String(error);
238
+ await ctx.reply('Failed to process photo.');
239
+ logEvent('error', ctx.chat.id, { error: msg, type: 'photo' });
240
+ }
241
+ });
242
+
243
+ // ─── Document Messages ─────────────────────────────────────────
244
+
245
+ bot.on('message:document', async (ctx) => {
246
+ if (!checkAuth(ctx)) return;
247
+ const chatId = String(ctx.chat.id);
248
+ const doc = ctx.message.document;
249
+
250
+ try {
251
+ if (doc.file_size && doc.file_size > MAX_FILE_SIZE) {
252
+ await ctx.reply('File too large (max 20MB). Please compress and try again.');
253
+ return;
254
+ }
255
+
256
+ const file = await ctx.api.getFile(doc.file_id);
257
+ if (!file.file_path) {
258
+ await ctx.reply('Could not download document.');
259
+ return;
260
+ }
261
+
262
+ const url = \`https://api.telegram.org/file/bot\${telegramConfig.botToken}/\${file.file_path}\`;
263
+ const resp = await fetch(url);
264
+ const buffer = Buffer.from(await resp.arrayBuffer());
265
+
266
+ const filename = doc.file_name ?? 'document';
267
+ const mimeType = doc.mime_type ?? 'application/octet-stream';
268
+ const caption = ctx.message.caption ?? '';
269
+
270
+ const intent = detectFileIntent(filename, mimeType, caption);
271
+ const fileInfo: FileInfo = { name: filename, mimeType, size: buffer.length, data: buffer };
272
+
273
+ if (intent === 'intake') {
274
+ saveTempFile(UPLOAD_DIR, filename, buffer);
275
+ await ctx.reply(\`Saved \${filename} for knowledge ingestion.\`);
276
+ logEvent('document_intake', ctx.chat.id, { filename, size: buffer.length });
277
+ return;
278
+ }
279
+
280
+ const content = buildMultimodalContent(fileInfo, intent);
281
+ const userText = content.type === 'text'
282
+ ? \`[File: \${filename}]\\n\${content.content}\${caption ? '\\n\\n' + caption : ''}\`
283
+ : \`[Document: \${filename}, \${Math.round(buffer.length / 1024)}KB]\${caption ? '\\n' + caption : ''}\`;
284
+
285
+ logEvent('document_in', ctx.chat.id, { filename, size: buffer.length, intent });
286
+ await dispatchToAgent(chatId, userText, ctx);
287
+ } catch (error) {
288
+ const msg = error instanceof Error ? error.message : String(error);
289
+ await ctx.reply('Failed to process document.');
290
+ logEvent('error', ctx.chat.id, { error: msg, type: 'document' });
291
+ }
292
+ });
293
+
294
+ // ─── Text Messages ────────────────────────────────────────────
295
+
296
+ bot.on('message:text', async (ctx) => {
297
+ const text = ctx.message.text;
298
+ const chatId = String(ctx.chat.id);
299
+ const userId = ctx.from?.id;
300
+
301
+ // Auth check — if passphrase is set, check if user is authenticated
302
+ if (auth.enabled && !auth.isAuthenticated(String(userId))) {
303
+ if (telegramConfig.passphrase && text === telegramConfig.passphrase) {
304
+ auth.authenticate(String(userId), text);
305
+ await ctx.reply('Authenticated! How can I help?');
306
+ logEvent('auth', ctx.chat.id, { userId });
307
+ return;
308
+ }
309
+ await ctx.reply('Please authenticate first.');
310
+ return;
311
+ }
312
+
313
+ logEvent('message_in', ctx.chat.id, { length: text.length });
314
+
315
+ // Fragment buffering
316
+ const fragment: Fragment = {
317
+ text,
318
+ messageId: ctx.message.message_id,
319
+ receivedAt: Date.now(),
320
+ };
321
+
322
+ const buffered = fragmentBuffer.receive(chatId, fragment);
323
+ if (buffered) return; // Wait for more fragments
324
+
325
+ // Process immediately
326
+ await dispatchToAgent(chatId, text, ctx);
327
+ });
328
+
329
+ // ─── Error Handling ───────────────────────────────────────────
330
+
331
+ bot.catch((err) => {
332
+ console.error('[bot] Error:', err.message);
333
+ logEvent('error', 'global', { error: err.message });
334
+ });
335
+
336
+ // ─── Start ────────────────────────────────────────────────────
337
+
338
+ console.log('[${config.id}] Starting Telegram bot...');
339
+ logEvent('bot_start', 'system');
340
+
341
+ // Start notification polling and periodic temp cleanup
342
+ notifications.start();
343
+ setInterval(() => cleanupTempFiles(UPLOAD_DIR, 3_600_000), 3_600_000); // 1h
344
+
345
+ process.on('SIGINT', () => shutdown(bot));
346
+ process.on('SIGTERM', () => shutdown(bot));
347
+
348
+ await bot.start();
349
+ }
350
+
351
+ // ─── Agent Dispatch ──────────────────────────────────────────────────
352
+
353
+ async function dispatchToAgent(
354
+ chatId: string,
355
+ text: string,
356
+ ctx: { reply: (text: string, options?: Record<string, unknown>) => Promise<unknown> },
357
+ ): Promise<void> {
358
+ if (processingLocks.has(chatId)) {
359
+ await ctx.reply('I am still working on your previous request. Please wait.');
360
+ return;
361
+ }
362
+
363
+ processingLocks.add(chatId);
364
+ const signal = cancellation.create(chatId, 'Processing message');
365
+
366
+ try {
367
+ // Append user message
368
+ sessions.appendMessage(chatId, {
369
+ role: 'user',
370
+ content: text,
371
+ timestamp: Date.now(),
372
+ });
373
+
374
+ // Run agent with cancellation signal
375
+ const agent = createTelegramAgent(telegramConfig);
376
+ const session = sessions.getOrCreate(chatId);
377
+ const result = await agent.run(session.messages, { signal });
378
+
379
+ // Append agent response
380
+ sessions.appendMessages(chatId, result.newMessages);
381
+
382
+ // Send response in chunks
383
+ const chunks = chunkResponse(result.text, { maxChunkSize: 4000, format: 'html' });
384
+ for (const chunk of chunks) {
385
+ try {
386
+ await ctx.reply(chunk, { parse_mode: 'HTML' });
387
+ } catch {
388
+ // Fallback to plain text if HTML fails
389
+ await ctx.reply(chunk);
390
+ }
391
+ }
392
+
393
+ logEvent('response_out', chatId, {
394
+ iterations: result.iterations,
395
+ toolCalls: result.toolCalls,
396
+ chunks: chunks.length,
397
+ });
398
+ } catch (error) {
399
+ if (signal.aborted) {
400
+ logEvent('cancelled', chatId);
401
+ return; // Already notified user via /stop handler
402
+ }
403
+ const msg = error instanceof Error ? error.message : String(error);
404
+ console.error(\`[agent] Error for chat \${chatId}:\`, msg);
405
+ await ctx.reply('Sorry, I encountered an error. Please try again.');
406
+ logEvent('error', chatId, { error: msg });
407
+ } finally {
408
+ cancellation.complete(chatId);
409
+ processingLocks.delete(chatId);
410
+ }
411
+ }
412
+
413
+ // ─── Fragment Handler ────────────────────────────────────────────────
414
+
415
+ function handleMergedMessage(key: string, merged: string): void {
416
+ // key is the chatId — dispatch the merged text
417
+ // Note: we don't have the ctx here, so we use the bot API directly
418
+ // In a real implementation, you'd store the ctx reference
419
+ console.log(\`[fragment] Merged \${merged.length} chars for \${key}\`);
420
+ }
421
+
422
+ // ─── Auth Helper ─────────────────────────────────────────────────────
423
+
424
+ function checkAuth(ctx: { from?: { id: number }; reply: (text: string) => Promise<unknown> }): boolean {
425
+ if (!auth.enabled) return true;
426
+ if (auth.isAuthenticated(String(ctx.from?.id))) return true;
427
+ ctx.reply('Please authenticate first.');
428
+ return false;
429
+ }
430
+
431
+ // ─── Shutdown ────────────────────────────────────────────────────────
432
+
433
+ function shutdown(bot: Bot): void {
434
+ console.log('[${config.id}] Shutting down...');
435
+ logEvent('bot_stop', 'system');
436
+ cancellation.cancelAll();
437
+ notifications.stop();
438
+ cleanupTempFiles(UPLOAD_DIR);
439
+ fragmentBuffer.close();
440
+ sessions.close();
441
+ bot.stop();
442
+ process.exit(0);
443
+ }
444
+
445
+ // ─── Main ────────────────────────────────────────────────────────────
446
+
447
+ startBot().catch((err) => {
448
+ console.error('[${config.id}] Fatal:', err);
449
+ process.exit(1);
450
+ });
451
+ `;
452
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Template: Telegram configuration module.
3
+ * Generated by @soleri/forge for agents with Telegram transport.
4
+ */
5
+
6
+ import type { AgentConfig } from '../types.js';
7
+
8
+ export function generateTelegramConfig(config: AgentConfig): string {
9
+ return `/**
10
+ * Telegram Configuration — env + file-based config loading.
11
+ * Generated by @soleri/forge.
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+
18
+ export interface TelegramConfig {
19
+ /** Telegram bot token (from @BotFather). */
20
+ botToken: string;
21
+ /** Anthropic API key. */
22
+ apiKey: string;
23
+ /** Claude model to use. Default: 'claude-sonnet-4-20250514'. */
24
+ model?: string;
25
+ /** Working directory for the agent. Default: cwd. */
26
+ workingDirectory?: string;
27
+ /** Allowed user IDs (empty = any authenticated user). */
28
+ allowedUsers?: number[];
29
+ /** Auth passphrase. If unset, auth is disabled. */
30
+ passphrase?: string;
31
+ /** Max output tokens per LLM call. Default: 16384. */
32
+ maxTokens?: number;
33
+ /** Max agent loop iterations. Default: 200. */
34
+ maxIterations?: number;
35
+ }
36
+
37
+ const CONFIG_DIR = join(homedir(), '.${config.id}');
38
+ const CONFIG_FILE = join(CONFIG_DIR, 'telegram.json');
39
+
40
+ /**
41
+ * Load Telegram config from environment variables and config file.
42
+ * Environment variables take priority over file-based config.
43
+ */
44
+ export function loadTelegramConfig(): TelegramConfig {
45
+ let fileConfig: Partial<TelegramConfig> = {};
46
+
47
+ if (existsSync(CONFIG_FILE)) {
48
+ try {
49
+ fileConfig = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
50
+ } catch {
51
+ console.error('[config] Failed to parse telegram.json, using defaults');
52
+ }
53
+ }
54
+
55
+ const botToken = process.env.TELEGRAM_BOT_TOKEN ?? fileConfig.botToken ?? '';
56
+ const apiKey =
57
+ process.env.ANTHROPIC_API_KEY ?? fileConfig.apiKey ?? '';
58
+
59
+ if (!botToken) {
60
+ throw new Error(
61
+ 'Missing TELEGRAM_BOT_TOKEN. Set via environment or ~/.${config.id}/telegram.json',
62
+ );
63
+ }
64
+ if (!apiKey) {
65
+ throw new Error(
66
+ 'Missing ANTHROPIC_API_KEY. Set via environment or ~/.${config.id}/telegram.json',
67
+ );
68
+ }
69
+
70
+ return {
71
+ botToken,
72
+ apiKey,
73
+ model: process.env.ANTHROPIC_MODEL ?? fileConfig.model ?? 'claude-sonnet-4-20250514',
74
+ workingDirectory:
75
+ process.env.AGENT_WORKING_DIR ?? fileConfig.workingDirectory ?? process.cwd(),
76
+ allowedUsers: fileConfig.allowedUsers ?? [],
77
+ passphrase: fileConfig.passphrase,
78
+ maxTokens: fileConfig.maxTokens ?? 16384,
79
+ maxIterations: fileConfig.maxIterations ?? 200,
80
+ };
81
+ }
82
+ `;
83
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Template: Telegram supervisor — process management with restart and logging.
3
+ * Generated by @soleri/forge for agents with Telegram transport.
4
+ */
5
+
6
+ import type { AgentConfig } from '../types.js';
7
+
8
+ export function generateTelegramSupervisor(config: AgentConfig): string {
9
+ return `/**
10
+ * ${config.name} — Telegram Bot Supervisor.
11
+ * Generated by @soleri/forge.
12
+ *
13
+ * Wraps the bot process with:
14
+ * - Restart on crash (exponential backoff)
15
+ * - Restart on self-update (exit code 75)
16
+ * - Graceful shutdown (SIGINT/SIGTERM)
17
+ * - Log rotation
18
+ */
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import { mkdirSync, appendFileSync, readdirSync, rmSync, statSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+
25
+ const LOG_DIR = join(homedir(), '.${config.id}', 'logs');
26
+ const MAX_LOG_AGE_DAYS = 7;
27
+ const SELF_UPDATE_EXIT_CODE = 75;
28
+
29
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
30
+ const MIN_RESTART_DELAY = 1000;
31
+ const MAX_RESTART_DELAY = 30_000;
32
+
33
+ let restartCount = 0;
34
+ let shutdownRequested = false;
35
+
36
+ function log(message: string): void {
37
+ const ts = new Date().toISOString();
38
+ const line = \`[\${ts}] [supervisor] \${message}\`;
39
+ console.log(line);
40
+ try {
41
+ mkdirSync(LOG_DIR, { recursive: true });
42
+ const date = ts.split('T')[0];
43
+ appendFileSync(join(LOG_DIR, \`telegram-\${date}.log\`), line + '\\n', 'utf-8');
44
+ } catch {
45
+ // Log write failure is non-critical
46
+ }
47
+ }
48
+
49
+ function rotateLogs(): void {
50
+ try {
51
+ const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000;
52
+ const files = readdirSync(LOG_DIR);
53
+ for (const file of files) {
54
+ if (!file.startsWith('telegram-') && !file.startsWith('events-')) continue;
55
+ const filePath = join(LOG_DIR, file);
56
+ try {
57
+ const stat = statSync(filePath);
58
+ if (stat.mtimeMs < cutoff) {
59
+ rmSync(filePath);
60
+ log(\`Rotated old log: \${file}\`);
61
+ }
62
+ } catch {
63
+ // Skip files we can't stat
64
+ }
65
+ }
66
+ } catch {
67
+ // Rotation failure is non-critical
68
+ }
69
+ }
70
+
71
+ function getRestartDelay(): number {
72
+ const delay = Math.min(MIN_RESTART_DELAY * Math.pow(2, restartCount), MAX_RESTART_DELAY);
73
+ return delay;
74
+ }
75
+
76
+ function startBot(): void {
77
+ rotateLogs();
78
+ log(\`Starting bot (attempt \${restartCount + 1})...\`);
79
+
80
+ const child = spawn('node', [join(process.cwd(), 'dist', 'telegram-bot.js')], {
81
+ stdio: 'inherit',
82
+ env: { ...process.env },
83
+ });
84
+
85
+ child.on('exit', (code, signal) => {
86
+ if (shutdownRequested) {
87
+ log('Bot stopped (shutdown requested).');
88
+ process.exit(0);
89
+ }
90
+
91
+ if (code === SELF_UPDATE_EXIT_CODE) {
92
+ log('Bot requested self-update restart. Rebuilding...');
93
+ restartCount = 0;
94
+ try {
95
+ const { execFileSync } = require('node:child_process');
96
+ execFileSync('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', timeout: 60_000 });
97
+ log('Rebuild succeeded. Restarting...');
98
+ } catch (buildErr: unknown) {
99
+ const buildMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
100
+ log(\`Rebuild failed: \${buildMsg}. Attempting rollback...\`);
101
+ try {
102
+ const { execFileSync: execFile2 } = require('node:child_process');
103
+ execFile2('git', ['revert', 'HEAD', '--no-edit'], { cwd: process.cwd(), stdio: 'pipe', timeout: 30_000 });
104
+ execFile2('npm', ['run', 'build'], { cwd: process.cwd(), stdio: 'pipe', timeout: 60_000 });
105
+ log('Rollback succeeded. Restarting with previous code...');
106
+ } catch {
107
+ log('Rollback also failed. Restarting with current code anyway...');
108
+ }
109
+ }
110
+ setTimeout(startBot, 1000);
111
+ return;
112
+ }
113
+
114
+ if (code === 0) {
115
+ log('Bot exited cleanly.');
116
+ process.exit(0);
117
+ }
118
+
119
+ restartCount++;
120
+ const delay = getRestartDelay();
121
+ log(\`Bot exited with code \${code ?? signal}. Restarting in \${delay}ms...\`);
122
+ setTimeout(startBot, delay);
123
+ });
124
+
125
+ child.on('error', (err) => {
126
+ log(\`Failed to start bot: \${err.message}\`);
127
+ restartCount++;
128
+ const delay = getRestartDelay();
129
+ setTimeout(startBot, delay);
130
+ });
131
+
132
+ // Reset backoff after 60 seconds of successful running
133
+ setTimeout(() => {
134
+ if (!shutdownRequested) restartCount = 0;
135
+ }, 60_000);
136
+ }
137
+
138
+ // Graceful shutdown
139
+ process.on('SIGINT', () => {
140
+ shutdownRequested = true;
141
+ log('SIGINT received, shutting down...');
142
+ });
143
+ process.on('SIGTERM', () => {
144
+ shutdownRequested = true;
145
+ log('SIGTERM received, shutting down...');
146
+ });
147
+
148
+ startBot();
149
+ `;
150
+ }