@hmawla/co-assistant 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,3258 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __esm = (fn, res) => function __init() {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ };
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/core/config.ts
23
+ var config_exports = {};
24
+ __export(config_exports, {
25
+ AIConfigSchema: () => AIConfigSchema,
26
+ AppConfigSchema: () => AppConfigSchema,
27
+ BotConfigSchema: () => BotConfigSchema,
28
+ ConfigError: () => ConfigError,
29
+ EnvConfigSchema: () => EnvConfigSchema,
30
+ PluginConfigSchema: () => PluginConfigSchema,
31
+ PluginCredentialEntrySchema: () => PluginCredentialEntrySchema,
32
+ PluginHealthConfigSchema: () => PluginHealthConfigSchema,
33
+ getConfig: () => getConfig,
34
+ loadAppConfig: () => loadAppConfig,
35
+ loadEnvConfig: () => loadEnvConfig,
36
+ resetConfig: () => resetConfig
37
+ });
38
+ import { z } from "zod";
39
+ import dotenv from "dotenv";
40
+ import { readFileSync, writeFileSync, existsSync } from "fs";
41
+ function formatZodErrors(error) {
42
+ return error.issues.map((issue) => {
43
+ const path4 = issue.path.length > 0 ? issue.path.join(".") : "(root)";
44
+ return ` \u2022 ${path4}: ${issue.message}`;
45
+ }).join("\n");
46
+ }
47
+ function loadEnvConfig() {
48
+ const result = dotenv.config();
49
+ if (result.error) {
50
+ console.warn(
51
+ "[config] .env file not found or unreadable \u2014 continuing with existing environment variables"
52
+ );
53
+ }
54
+ const parsed = EnvConfigSchema.safeParse(process.env);
55
+ if (!parsed.success) {
56
+ const details = formatZodErrors(parsed.error);
57
+ throw new ConfigError(
58
+ `Environment variable validation failed:
59
+ ${details}`
60
+ );
61
+ }
62
+ return parsed.data;
63
+ }
64
+ function loadAppConfig(configPath = "./config.json") {
65
+ let raw = {};
66
+ if (existsSync(configPath)) {
67
+ try {
68
+ const content = readFileSync(configPath, "utf-8");
69
+ raw = JSON.parse(content);
70
+ } catch (err) {
71
+ throw new ConfigError(
72
+ `Failed to read or parse config file at "${configPath}": ${err.message}`
73
+ );
74
+ }
75
+ } else {
76
+ const defaults = AppConfigSchema.parse({});
77
+ try {
78
+ writeFileSync(configPath, JSON.stringify(defaults, null, 2) + "\n", "utf-8");
79
+ } catch {
80
+ console.warn(
81
+ `[config] Could not write default config to "${configPath}" \u2014 using in-memory defaults`
82
+ );
83
+ }
84
+ raw = defaults;
85
+ }
86
+ const parsed = AppConfigSchema.safeParse(raw);
87
+ if (!parsed.success) {
88
+ const details = formatZodErrors(parsed.error);
89
+ throw new ConfigError(
90
+ `Application config validation failed (${configPath}):
91
+ ${details}`
92
+ );
93
+ }
94
+ return parsed.data;
95
+ }
96
+ function getConfig() {
97
+ if (!cachedConfig) {
98
+ cachedConfig = {
99
+ env: loadEnvConfig(),
100
+ app: loadAppConfig()
101
+ };
102
+ }
103
+ return cachedConfig;
104
+ }
105
+ function resetConfig() {
106
+ cachedConfig = null;
107
+ }
108
+ var ConfigError, EnvConfigSchema, PluginCredentialEntrySchema, PluginConfigSchema, BotConfigSchema, AIConfigSchema, PluginHealthConfigSchema, AppConfigSchema, cachedConfig;
109
+ var init_config = __esm({
110
+ "src/core/config.ts"() {
111
+ "use strict";
112
+ ConfigError = class extends Error {
113
+ constructor(message2) {
114
+ super(message2);
115
+ this.name = "ConfigError";
116
+ }
117
+ };
118
+ EnvConfigSchema = z.object({
119
+ TELEGRAM_BOT_TOKEN: z.string().min(1, "TELEGRAM_BOT_TOKEN is required"),
120
+ TELEGRAM_USER_ID: z.string().min(1, "TELEGRAM_USER_ID is required"),
121
+ GITHUB_TOKEN: z.string().optional(),
122
+ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
123
+ DEFAULT_MODEL: z.string().default("gpt-4.1"),
124
+ HEARTBEAT_INTERVAL_MINUTES: z.string().default("0"),
125
+ AI_SESSION_POOL_SIZE: z.string().default("3")
126
+ });
127
+ PluginCredentialEntrySchema = z.object({
128
+ key: z.string(),
129
+ description: z.string(),
130
+ type: z.string().optional()
131
+ });
132
+ PluginConfigSchema = z.object({
133
+ enabled: z.boolean(),
134
+ credentials: z.record(z.string(), z.string())
135
+ });
136
+ BotConfigSchema = z.object({
137
+ maxMessageLength: z.number().default(4096),
138
+ typingIndicator: z.boolean().default(true)
139
+ });
140
+ AIConfigSchema = z.object({
141
+ maxRetries: z.number().default(3),
142
+ sessionTimeout: z.number().default(36e5)
143
+ });
144
+ PluginHealthConfigSchema = z.object({
145
+ maxFailures: z.number().default(5),
146
+ checkInterval: z.number().default(6e4)
147
+ });
148
+ AppConfigSchema = z.object({
149
+ plugins: z.record(z.string(), PluginConfigSchema).default({}),
150
+ bot: BotConfigSchema.default({ maxMessageLength: 4096, typingIndicator: true }),
151
+ ai: AIConfigSchema.default({ maxRetries: 3, sessionTimeout: 36e5 }),
152
+ pluginHealth: PluginHealthConfigSchema.default({ maxFailures: 5, checkInterval: 6e4 })
153
+ });
154
+ cachedConfig = null;
155
+ }
156
+ });
157
+
158
+ // src/storage/migrations/001-initial.ts
159
+ var migration, initial_default;
160
+ var init_initial = __esm({
161
+ "src/storage/migrations/001-initial.ts"() {
162
+ "use strict";
163
+ migration = {
164
+ id: "001-initial",
165
+ up: `
166
+ CREATE TABLE IF NOT EXISTS conversations (
167
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
168
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
169
+ content TEXT NOT NULL,
170
+ model TEXT,
171
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
172
+ );
173
+
174
+ CREATE TABLE IF NOT EXISTS plugin_state (
175
+ plugin_id TEXT NOT NULL,
176
+ key TEXT NOT NULL,
177
+ value TEXT,
178
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
179
+ PRIMARY KEY (plugin_id, key)
180
+ );
181
+
182
+ CREATE TABLE IF NOT EXISTS preferences (
183
+ key TEXT PRIMARY KEY,
184
+ value TEXT NOT NULL,
185
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
186
+ );
187
+
188
+ CREATE TABLE IF NOT EXISTS plugin_health (
189
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
190
+ plugin_id TEXT NOT NULL,
191
+ status TEXT NOT NULL CHECK(status IN ('ok', 'error', 'disabled')),
192
+ error_message TEXT,
193
+ checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
194
+ );
195
+
196
+ CREATE INDEX IF NOT EXISTS idx_conversations_created_at ON conversations(created_at);
197
+ CREATE INDEX IF NOT EXISTS idx_plugin_health_plugin_id ON plugin_health(plugin_id);
198
+ `
199
+ };
200
+ initial_default = migration;
201
+ }
202
+ });
203
+
204
+ // src/storage/database.ts
205
+ import Database from "better-sqlite3";
206
+ import { mkdirSync } from "fs";
207
+ import { dirname } from "path";
208
+ function ensureMigrationsTable(db) {
209
+ db.exec(`
210
+ CREATE TABLE IF NOT EXISTS _migrations (
211
+ id TEXT PRIMARY KEY,
212
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
213
+ );
214
+ `);
215
+ }
216
+ function runMigrations(db) {
217
+ ensureMigrationsTable(db);
218
+ const applied = new Set(
219
+ db.prepare("SELECT id FROM _migrations").all().map(
220
+ (row) => row.id
221
+ )
222
+ );
223
+ for (const migration2 of MIGRATIONS) {
224
+ if (applied.has(migration2.id)) continue;
225
+ const applyMigration = db.transaction(() => {
226
+ db.exec(migration2.up);
227
+ db.prepare("INSERT INTO _migrations (id) VALUES (?)").run(migration2.id);
228
+ });
229
+ try {
230
+ applyMigration();
231
+ } catch (error) {
232
+ const message2 = error instanceof Error ? error.message : String(error);
233
+ throw new Error(
234
+ `Migration "${migration2.id}" failed: ${message2}`
235
+ );
236
+ }
237
+ }
238
+ }
239
+ function getDatabase(dbPath = DEFAULT_DB_PATH) {
240
+ if (instance) return instance;
241
+ mkdirSync(dirname(dbPath), { recursive: true });
242
+ instance = new Database(dbPath);
243
+ instance.pragma("journal_mode = WAL");
244
+ runMigrations(instance);
245
+ return instance;
246
+ }
247
+ function closeDatabase() {
248
+ if (instance) {
249
+ instance.close();
250
+ instance = null;
251
+ }
252
+ }
253
+ var DEFAULT_DB_PATH, MIGRATIONS, instance;
254
+ var init_database = __esm({
255
+ "src/storage/database.ts"() {
256
+ "use strict";
257
+ init_initial();
258
+ DEFAULT_DB_PATH = "./data/co-assistant.db";
259
+ MIGRATIONS = [initial_default];
260
+ instance = null;
261
+ }
262
+ });
263
+
264
+ // src/storage/repositories/plugin-health.ts
265
+ var plugin_health_exports = {};
266
+ __export(plugin_health_exports, {
267
+ PluginHealthRepository: () => PluginHealthRepository
268
+ });
269
+ var PluginHealthRepository;
270
+ var init_plugin_health = __esm({
271
+ "src/storage/repositories/plugin-health.ts"() {
272
+ "use strict";
273
+ init_database();
274
+ PluginHealthRepository = class {
275
+ db;
276
+ /** Create a new repository backed by the singleton database. */
277
+ constructor(db) {
278
+ this.db = db ?? getDatabase();
279
+ }
280
+ /**
281
+ * Record a health-check result for a plugin.
282
+ *
283
+ * @param pluginId - The unique plugin identifier.
284
+ * @param status - Health status (`ok`, `error`, or `disabled`).
285
+ * @param errorMessage - Optional error details.
286
+ */
287
+ logHealth(pluginId, status, errorMessage) {
288
+ this.db.prepare(
289
+ "INSERT INTO plugin_health (plugin_id, status, error_message) VALUES (?, ?, ?)"
290
+ ).run(pluginId, status, errorMessage ?? null);
291
+ }
292
+ /**
293
+ * Retrieve the most recent health-check entries for a plugin.
294
+ *
295
+ * @param pluginId - The unique plugin identifier.
296
+ * @param limit - Maximum number of entries to return (default `10`).
297
+ * @returns An array of {@link PluginHealthEntry} objects, newest first.
298
+ */
299
+ getRecentHealth(pluginId, limit = 10) {
300
+ return this.db.prepare(
301
+ "SELECT id, plugin_id, status, error_message, checked_at FROM plugin_health WHERE plugin_id = ? ORDER BY checked_at DESC LIMIT ?"
302
+ ).all(pluginId, limit);
303
+ }
304
+ /**
305
+ * Count the number of `error` health entries for a plugin within a time window.
306
+ *
307
+ * @param pluginId - The unique plugin identifier.
308
+ * @param sinceMinutes - Look-back window in minutes (default `60`).
309
+ * @returns The number of error entries in the window.
310
+ */
311
+ getFailureCount(pluginId, sinceMinutes = 60) {
312
+ const row = this.db.prepare(
313
+ `SELECT COUNT(*) AS cnt FROM plugin_health
314
+ WHERE plugin_id = ? AND status = 'error'
315
+ AND checked_at >= datetime('now', '-' || ? || ' minutes')`
316
+ ).get(pluginId, sinceMinutes);
317
+ return row.cnt;
318
+ }
319
+ };
320
+ }
321
+ });
322
+
323
+ // src/core/app.ts
324
+ import dns from "dns";
325
+
326
+ // src/core/logger.ts
327
+ import pino from "pino";
328
+ function resolveLogLevel() {
329
+ const envLevel = process.env.LOG_LEVEL?.toLowerCase();
330
+ const valid = ["fatal", "error", "warn", "info", "debug", "trace", "silent"];
331
+ return envLevel && valid.includes(envLevel) ? envLevel : "info";
332
+ }
333
+ function buildLoggerOptions() {
334
+ const level = resolveLogLevel();
335
+ const isProduction = process.env.NODE_ENV === "production";
336
+ const opts = { level };
337
+ if (!isProduction) {
338
+ opts.transport = {
339
+ target: "pino-pretty",
340
+ options: {
341
+ colorize: true,
342
+ translateTime: "SYS:HH:MM:ss",
343
+ ignore: "pid,hostname"
344
+ }
345
+ };
346
+ }
347
+ return opts;
348
+ }
349
+ var logger = pino(buildLoggerOptions());
350
+ function createChildLogger(name, meta) {
351
+ return logger.child({ component: name, ...meta });
352
+ }
353
+ function setLogLevel(level) {
354
+ logger.level = level;
355
+ }
356
+
357
+ // src/core/app.ts
358
+ init_config();
359
+ init_database();
360
+
361
+ // src/storage/repositories/conversation.ts
362
+ init_database();
363
+ var ConversationRepository = class {
364
+ db;
365
+ /** Create a new repository backed by the singleton database. */
366
+ constructor(db) {
367
+ this.db = db ?? getDatabase();
368
+ }
369
+ /**
370
+ * Insert a new message into the conversations table.
371
+ *
372
+ * @param role - The message role (`user`, `assistant`, or `system`).
373
+ * @param content - The text content of the message.
374
+ * @param model - Optional AI model identifier.
375
+ */
376
+ addMessage(role, content, model) {
377
+ this.db.prepare(
378
+ "INSERT INTO conversations (role, content, model) VALUES (?, ?, ?)"
379
+ ).run(role, content, model ?? null);
380
+ }
381
+ /**
382
+ * Retrieve conversation history ordered newest-first.
383
+ *
384
+ * @param limit - Maximum number of messages to return (default `50`).
385
+ * @returns An array of {@link ConversationMessage} objects.
386
+ */
387
+ getHistory(limit = 50) {
388
+ return this.db.prepare(
389
+ "SELECT id, role, content, model, created_at FROM conversations ORDER BY created_at DESC LIMIT ?"
390
+ ).all(limit);
391
+ }
392
+ /**
393
+ * Retrieve the most recent messages ordered oldest-first, suitable for
394
+ * building an AI context window.
395
+ *
396
+ * @param limit - Maximum number of messages to return (default `20`).
397
+ * @returns An array of {@link ConversationMessage} objects in chronological order.
398
+ */
399
+ getRecentContext(limit = 20) {
400
+ return this.db.prepare(
401
+ `SELECT id, role, content, model, created_at
402
+ FROM (
403
+ SELECT id, role, content, model, created_at
404
+ FROM conversations
405
+ ORDER BY created_at DESC
406
+ LIMIT ?
407
+ ) sub
408
+ ORDER BY created_at ASC`
409
+ ).all(limit);
410
+ }
411
+ /**
412
+ * Delete all conversation messages.
413
+ */
414
+ clear() {
415
+ this.db.prepare("DELETE FROM conversations").run();
416
+ }
417
+ /**
418
+ * Return the total number of stored conversation messages.
419
+ */
420
+ count() {
421
+ const row = this.db.prepare("SELECT COUNT(*) AS cnt FROM conversations").get();
422
+ return row.cnt;
423
+ }
424
+ };
425
+
426
+ // src/storage/repositories/preferences.ts
427
+ init_database();
428
+ var PreferencesRepository = class {
429
+ db;
430
+ /** Create a new repository backed by the singleton database. */
431
+ constructor(db) {
432
+ this.db = db ?? getDatabase();
433
+ }
434
+ /**
435
+ * Retrieve a preference value by key.
436
+ *
437
+ * @param key - The preference key.
438
+ * @returns The stored value, or `null` if not found.
439
+ */
440
+ get(key) {
441
+ const row = this.db.prepare("SELECT value FROM preferences WHERE key = ?").get(key);
442
+ return row?.value ?? null;
443
+ }
444
+ /**
445
+ * Insert or update a preference value.
446
+ *
447
+ * @param key - The preference key.
448
+ * @param value - The value to store.
449
+ */
450
+ set(key, value) {
451
+ this.db.prepare(
452
+ `INSERT INTO preferences (key, value, updated_at)
453
+ VALUES (?, ?, CURRENT_TIMESTAMP)
454
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
455
+ ).run(key, value);
456
+ }
457
+ /**
458
+ * Retrieve all preferences as a plain object.
459
+ *
460
+ * @returns A record mapping each preference key to its value.
461
+ */
462
+ getAll() {
463
+ const rows = this.db.prepare("SELECT key, value FROM preferences").all();
464
+ const result = {};
465
+ for (const row of rows) {
466
+ result[row.key] = row.value;
467
+ }
468
+ return result;
469
+ }
470
+ /**
471
+ * Delete a single preference by key.
472
+ *
473
+ * @param key - The preference key to remove.
474
+ */
475
+ delete(key) {
476
+ this.db.prepare("DELETE FROM preferences WHERE key = ?").run(key);
477
+ }
478
+ };
479
+
480
+ // src/storage/repositories/plugin-state.ts
481
+ init_database();
482
+ var PluginStateRepository = class {
483
+ db;
484
+ /** Create a new repository backed by the singleton database. */
485
+ constructor(db) {
486
+ this.db = db ?? getDatabase();
487
+ }
488
+ /**
489
+ * Retrieve a single value for a given plugin and key.
490
+ *
491
+ * @param pluginId - The unique plugin identifier.
492
+ * @param key - The state key.
493
+ * @returns The stored value, or `null` if not found.
494
+ */
495
+ get(pluginId, key) {
496
+ const row = this.db.prepare("SELECT value FROM plugin_state WHERE plugin_id = ? AND key = ?").get(pluginId, key);
497
+ return row?.value ?? null;
498
+ }
499
+ /**
500
+ * Insert or update a state value for a plugin.
501
+ *
502
+ * @param pluginId - The unique plugin identifier.
503
+ * @param key - The state key.
504
+ * @param value - The value to store.
505
+ */
506
+ set(pluginId, key, value) {
507
+ this.db.prepare(
508
+ `INSERT INTO plugin_state (plugin_id, key, value, updated_at)
509
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
510
+ ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
511
+ ).run(pluginId, key, value);
512
+ }
513
+ /**
514
+ * Retrieve all key/value pairs for a plugin.
515
+ *
516
+ * @param pluginId - The unique plugin identifier.
517
+ * @returns A plain object mapping keys to their string values.
518
+ */
519
+ getAll(pluginId) {
520
+ const rows = this.db.prepare("SELECT key, value FROM plugin_state WHERE plugin_id = ?").all(pluginId);
521
+ const result = {};
522
+ for (const row of rows) {
523
+ result[row.key] = row.value;
524
+ }
525
+ return result;
526
+ }
527
+ /**
528
+ * Delete a single state key for a plugin.
529
+ *
530
+ * @param pluginId - The unique plugin identifier.
531
+ * @param key - The state key to remove.
532
+ */
533
+ delete(pluginId, key) {
534
+ this.db.prepare("DELETE FROM plugin_state WHERE plugin_id = ? AND key = ?").run(pluginId, key);
535
+ }
536
+ /**
537
+ * Remove all stored state for a specific plugin.
538
+ *
539
+ * @param pluginId - The unique plugin identifier.
540
+ */
541
+ clearPlugin(pluginId) {
542
+ this.db.prepare("DELETE FROM plugin_state WHERE plugin_id = ?").run(pluginId);
543
+ }
544
+ };
545
+
546
+ // src/ai/models.ts
547
+ init_config();
548
+ var DEFAULT_MODELS = [
549
+ // OpenAI — Premium (3x)
550
+ { id: "gpt-5", name: "GPT-5", description: "OpenAI GPT-5 (premium, 3x)", provider: "openai" },
551
+ { id: "o3", name: "o3", description: "OpenAI o3 reasoning (premium, 3x)", provider: "openai" },
552
+ // OpenAI — Standard (1x)
553
+ { id: "gpt-4.1", name: "GPT-4.1", description: "OpenAI GPT-4.1 (1x)", provider: "openai" },
554
+ { id: "gpt-4o", name: "GPT-4o", description: "OpenAI GPT-4o multimodal (1x)", provider: "openai" },
555
+ { id: "o4-mini", name: "o4 Mini", description: "OpenAI o4-mini reasoning (1x)", provider: "openai" },
556
+ // OpenAI — Low (0.33x)
557
+ { id: "gpt-4o-mini", name: "GPT-4o Mini", description: "OpenAI GPT-4o Mini (0.33x)", provider: "openai" },
558
+ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "OpenAI GPT-4.1 Mini (0.33x)", provider: "openai" },
559
+ { id: "gpt-5-mini", name: "GPT-5 Mini", description: "OpenAI GPT-5 Mini (0.33x)", provider: "openai" },
560
+ { id: "o3-mini", name: "o3 Mini", description: "OpenAI o3-mini reasoning (0.33x)", provider: "openai" },
561
+ // OpenAI — Nano (0x)
562
+ { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", description: "OpenAI GPT-4.1 Nano (0x)", provider: "openai" },
563
+ // Anthropic — Premium (3x)
564
+ { id: "claude-opus-4", name: "Claude Opus 4", description: "Anthropic Claude Opus 4 (premium, 3x)", provider: "anthropic" },
565
+ // Anthropic — Standard (1x)
566
+ { id: "claude-sonnet-4", name: "Claude Sonnet 4", description: "Anthropic Claude Sonnet 4 (1x)", provider: "anthropic" },
567
+ // Anthropic — Low (0.33x)
568
+ { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", description: "Anthropic Claude Haiku 4.5 (0.33x)", provider: "anthropic" }
569
+ ];
570
+ var PREF_KEY = "current_model";
571
+ var ModelRegistry = class {
572
+ models;
573
+ preferences;
574
+ logger;
575
+ /**
576
+ * @param preferences - Repository used to persist the selected model.
577
+ */
578
+ constructor(preferences) {
579
+ this.models = [...DEFAULT_MODELS];
580
+ this.preferences = preferences;
581
+ this.logger = createChildLogger("ai:models");
582
+ this.logger.debug({ count: this.models.length }, "Model registry initialised");
583
+ }
584
+ /**
585
+ * Return every model currently registered (built-in + custom).
586
+ *
587
+ * @returns A shallow copy of the model list.
588
+ */
589
+ getAvailableModels() {
590
+ return [...this.models];
591
+ }
592
+ /**
593
+ * Look up a single model by its identifier.
594
+ *
595
+ * @param modelId - Case-sensitive model identifier.
596
+ * @returns The matching {@link ModelInfo}, or `undefined` when not found.
597
+ */
598
+ getModel(modelId) {
599
+ return this.models.find((m) => m.id === modelId);
600
+ }
601
+ /**
602
+ * Resolve the currently active model identifier.
603
+ *
604
+ * Resolution order:
605
+ * 1. Persisted preference (`current_model` key)
606
+ * 2. `DEFAULT_MODEL` from the environment configuration
607
+ *
608
+ * @returns The model identifier string.
609
+ */
610
+ getCurrentModelId() {
611
+ const persisted = this.preferences.get(PREF_KEY);
612
+ if (persisted) {
613
+ this.logger.debug({ modelId: persisted }, "Current model resolved from preferences");
614
+ return persisted;
615
+ }
616
+ try {
617
+ const defaultModel = getConfig().env.DEFAULT_MODEL;
618
+ this.logger.debug({ modelId: defaultModel }, "Current model resolved from env default");
619
+ return defaultModel;
620
+ } catch {
621
+ const fallback = "gpt-4.1";
622
+ this.logger.debug({ modelId: fallback }, "Current model resolved from hardcoded fallback");
623
+ return fallback;
624
+ }
625
+ }
626
+ /**
627
+ * Select a model as the active model and persist the choice.
628
+ *
629
+ * Unknown model IDs are **allowed** (the Copilot SDK may support models
630
+ * not present in our registry) but a warning is logged so operators can
631
+ * spot potential typos.
632
+ *
633
+ * @param modelId - The identifier of the model to activate.
634
+ */
635
+ setCurrentModel(modelId) {
636
+ if (!this.isValidModel(modelId)) {
637
+ this.logger.warn(
638
+ { modelId },
639
+ "Setting model that is not in the registry \u2014 it may still be valid for the provider"
640
+ );
641
+ }
642
+ this.preferences.set(PREF_KEY, modelId);
643
+ this.logger.info({ modelId }, "Current model updated");
644
+ }
645
+ /**
646
+ * Check whether a model identifier corresponds to a registered model.
647
+ *
648
+ * @param modelId - The identifier to validate.
649
+ * @returns `true` when the model exists in the registry.
650
+ */
651
+ isValidModel(modelId) {
652
+ return this.models.some((m) => m.id === modelId);
653
+ }
654
+ /**
655
+ * Register a custom model at runtime (e.g. for BYOK scenarios).
656
+ *
657
+ * If a model with the same `id` already exists it will be replaced.
658
+ *
659
+ * @param model - The {@link ModelInfo} to add.
660
+ */
661
+ addModel(model) {
662
+ const idx = this.models.findIndex((m) => m.id === model.id);
663
+ if (idx !== -1) {
664
+ this.models[idx] = model;
665
+ this.logger.info({ modelId: model.id }, "Existing model replaced in registry");
666
+ } else {
667
+ this.models.push(model);
668
+ this.logger.info({ modelId: model.id }, "Custom model added to registry");
669
+ }
670
+ }
671
+ /**
672
+ * Remove a model from the registry.
673
+ *
674
+ * @param modelId - The identifier of the model to remove.
675
+ * @returns `true` if the model was found and removed, `false` otherwise.
676
+ */
677
+ removeModel(modelId) {
678
+ const idx = this.models.findIndex((m) => m.id === modelId);
679
+ if (idx === -1) {
680
+ this.logger.warn({ modelId }, "Attempted to remove unknown model");
681
+ return false;
682
+ }
683
+ this.models.splice(idx, 1);
684
+ this.logger.info({ modelId }, "Model removed from registry");
685
+ return true;
686
+ }
687
+ };
688
+ function createModelRegistry(preferences) {
689
+ return new ModelRegistry(preferences);
690
+ }
691
+
692
+ // src/ai/client.ts
693
+ import { CopilotClient } from "@github/copilot-sdk";
694
+
695
+ // src/core/errors.ts
696
+ var CoAssistantError = class extends Error {
697
+ /** Machine-readable error code. */
698
+ code;
699
+ /** Optional structured context for debugging. */
700
+ context;
701
+ constructor(message2, code, context) {
702
+ super(message2);
703
+ this.name = this.constructor.name;
704
+ this.code = code;
705
+ this.context = context;
706
+ Object.setPrototypeOf(this, new.target.prototype);
707
+ if (Error.captureStackTrace) {
708
+ Error.captureStackTrace(this, this.constructor);
709
+ }
710
+ }
711
+ };
712
+ var PluginError = class _PluginError extends CoAssistantError {
713
+ /** Identifier of the plugin that produced this error. */
714
+ pluginId;
715
+ constructor(message2, code, pluginId, context) {
716
+ super(message2, code, { ...context, pluginId });
717
+ this.pluginId = pluginId;
718
+ }
719
+ /** Plugin initialisation failed. */
720
+ static initFailed(pluginId, reason) {
721
+ return new _PluginError(
722
+ `Plugin "${pluginId}" failed to initialise: ${reason}`,
723
+ "PLUGIN_INIT_FAILED",
724
+ pluginId,
725
+ { reason }
726
+ );
727
+ }
728
+ /** A tool exposed by the plugin failed during execution. */
729
+ static toolFailed(pluginId, toolName, reason) {
730
+ return new _PluginError(
731
+ `Plugin "${pluginId}" tool "${toolName}" failed: ${reason}`,
732
+ "PLUGIN_TOOL_FAILED",
733
+ pluginId,
734
+ { toolName, reason }
735
+ );
736
+ }
737
+ /** Required credentials for the plugin are missing. */
738
+ static credentialsMissing(pluginId, keys) {
739
+ return new _PluginError(
740
+ `Plugin "${pluginId}" is missing required credentials: ${keys.join(", ")}`,
741
+ "PLUGIN_CREDENTIALS_MISSING",
742
+ pluginId,
743
+ { keys }
744
+ );
745
+ }
746
+ /** Plugin health check did not pass. */
747
+ static healthCheckFailed(pluginId, reason) {
748
+ return new _PluginError(
749
+ `Plugin "${pluginId}" health check failed: ${reason}`,
750
+ "PLUGIN_HEALTH_CHECK_FAILED",
751
+ pluginId,
752
+ { reason }
753
+ );
754
+ }
755
+ /** Plugin is disabled and cannot be used. */
756
+ static disabled(pluginId, reason) {
757
+ return new _PluginError(
758
+ `Plugin "${pluginId}" is disabled: ${reason}`,
759
+ "PLUGIN_DISABLED",
760
+ pluginId,
761
+ { reason }
762
+ );
763
+ }
764
+ };
765
+ var AIError = class _AIError extends CoAssistantError {
766
+ constructor(message2, code, context) {
767
+ super(message2, code, context);
768
+ }
769
+ /** The AI client failed to start. */
770
+ static clientStartFailed(reason) {
771
+ return new _AIError(
772
+ `AI client failed to start: ${reason}`,
773
+ "AI_CLIENT_START_FAILED",
774
+ { reason }
775
+ );
776
+ }
777
+ /** A new AI session could not be created. */
778
+ static sessionCreateFailed(reason) {
779
+ return new _AIError(
780
+ `Failed to create AI session: ${reason}`,
781
+ "AI_SESSION_CREATE_FAILED",
782
+ { reason }
783
+ );
784
+ }
785
+ /** The requested model was not found or is unavailable. */
786
+ static modelNotFound(model) {
787
+ return new _AIError(
788
+ `AI model not found: ${model}`,
789
+ "AI_MODEL_NOT_FOUND",
790
+ { model }
791
+ );
792
+ }
793
+ /** Sending a message to the AI failed. */
794
+ static sendFailed(reason) {
795
+ return new _AIError(
796
+ `Failed to send message to AI: ${reason}`,
797
+ "AI_SEND_FAILED",
798
+ { reason }
799
+ );
800
+ }
801
+ };
802
+
803
+ // src/ai/client.ts
804
+ var logger2 = createChildLogger("ai:client");
805
+ var CopilotClientWrapper = class {
806
+ client = null;
807
+ isStarted = false;
808
+ /** Start the Copilot client. Must be called before creating sessions. */
809
+ async start() {
810
+ if (this.isStarted) {
811
+ logger2.warn("Client already started \u2014 skipping");
812
+ return;
813
+ }
814
+ try {
815
+ logger2.info("Starting Copilot client\u2026");
816
+ this.client = new CopilotClient();
817
+ await this.client.start();
818
+ this.isStarted = true;
819
+ logger2.info("Copilot client started successfully");
820
+ } catch (error) {
821
+ this.client = null;
822
+ this.isStarted = false;
823
+ const reason = error instanceof Error ? error.message : String(error);
824
+ logger2.error({ err: error }, "Failed to start Copilot client");
825
+ throw AIError.clientStartFailed(reason);
826
+ }
827
+ }
828
+ /** Stop the Copilot client gracefully. */
829
+ async stop() {
830
+ if (!this.isStarted || !this.client) {
831
+ logger2.debug("Client not running \u2014 nothing to stop");
832
+ return;
833
+ }
834
+ try {
835
+ logger2.info("Stopping Copilot client\u2026");
836
+ await this.client.stop();
837
+ logger2.info("Copilot client stopped");
838
+ } catch (error) {
839
+ logger2.error({ err: error }, "Error while stopping Copilot client (ignored)");
840
+ } finally {
841
+ this.client = null;
842
+ this.isStarted = false;
843
+ }
844
+ }
845
+ /**
846
+ * Get the underlying {@link CopilotClient}.
847
+ * @throws {AIError} If the client has not been started.
848
+ */
849
+ getClient() {
850
+ if (!this.isStarted || !this.client) {
851
+ throw AIError.clientStartFailed("Client not started");
852
+ }
853
+ return this.client;
854
+ }
855
+ /** Check if the client is running. */
856
+ isRunning() {
857
+ return this.isStarted;
858
+ }
859
+ /** Restart the client (stop then start). */
860
+ async restart() {
861
+ logger2.info("Restarting Copilot client\u2026");
862
+ await this.stop();
863
+ await this.start();
864
+ }
865
+ };
866
+ var copilotClient = new CopilotClientWrapper();
867
+
868
+ // src/ai/session.ts
869
+ import { approveAll, defineTool } from "@github/copilot-sdk";
870
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
871
+ import path from "path";
872
+ var logger3 = createChildLogger("ai:session");
873
+ function convertTools(tools) {
874
+ return tools.map(
875
+ (t) => defineTool(t.name, {
876
+ description: t.description,
877
+ parameters: t.parameters,
878
+ handler: t.handler
879
+ })
880
+ );
881
+ }
882
+ var SessionManager = class _SessionManager {
883
+ // 5 minutes
884
+ constructor(clientWrapper) {
885
+ this.clientWrapper = clientWrapper;
886
+ this.logger = logger3;
887
+ this.personalityPath = path.join(process.cwd(), "personality.md");
888
+ this.userProfilePath = path.join(process.cwd(), "user.md");
889
+ }
890
+ clientWrapper;
891
+ /** Pool of parallel Copilot sessions. */
892
+ pool = [];
893
+ /** Configured pool size — how many parallel sessions to maintain. */
894
+ poolSize = 3;
895
+ currentModel = "";
896
+ tools = [];
897
+ logger;
898
+ /**
899
+ * Cached personality prompt loaded from `personality.md`.
900
+ * Reloaded from disk on each message so edits take effect immediately.
901
+ */
902
+ personalityPath;
903
+ /** Path to the user profile loaded from `user.md`. */
904
+ userProfilePath;
905
+ /**
906
+ * Callers blocked waiting for a free session.
907
+ * Drained FIFO when a session is released.
908
+ */
909
+ waiters = [];
910
+ /** Maximum time (ms) a caller will wait in the queue before giving up. */
911
+ static ACQUIRE_TIMEOUT_MS = 3e5;
912
+ /**
913
+ * Read a markdown context file from disk.
914
+ *
915
+ * Reads fresh on each call so edits take effect without restart.
916
+ * Returns an empty string if the file is missing or unreadable.
917
+ */
918
+ loadContextFile(filePath, label) {
919
+ try {
920
+ if (!existsSync2(filePath)) return "";
921
+ return readFileSync2(filePath, "utf-8").trim();
922
+ } catch {
923
+ this.logger.warn(`Could not read ${label} \u2014 skipping`);
924
+ return "";
925
+ }
926
+ }
927
+ /**
928
+ * Wrap a user prompt with system-level context (personality + user profile).
929
+ *
930
+ * Both files are read fresh from disk so edits take effect immediately.
931
+ * The personality defines *how* the assistant behaves; the user profile
932
+ * tells it *who* it's talking to.
933
+ */
934
+ applySystemContext(prompt) {
935
+ const personality = this.loadContextFile(this.personalityPath, "personality.md");
936
+ const userProfile = this.loadContextFile(this.userProfilePath, "user.md");
937
+ const parts = [];
938
+ if (personality) parts.push(personality);
939
+ if (userProfile) parts.push(userProfile);
940
+ if (parts.length === 0) return prompt;
941
+ return `<system>
942
+ ${parts.join("\n\n---\n\n")}
943
+ </system>
944
+
945
+ ${prompt}`;
946
+ }
947
+ // -----------------------------------------------------------------------
948
+ // Pool management (private)
949
+ // -----------------------------------------------------------------------
950
+ /**
951
+ * Acquire a free session from the pool.
952
+ *
953
+ * Prefers the lowest-index free session so that sequential interactions
954
+ * (e.g. back-to-back user messages) naturally reuse the same session and
955
+ * keep their conversation context. If all sessions are busy, the caller
956
+ * blocks until one is released or the acquire timeout fires.
957
+ */
958
+ acquire() {
959
+ const free = this.pool.find((s) => !s.busy);
960
+ if (free) {
961
+ free.busy = true;
962
+ return Promise.resolve(free);
963
+ }
964
+ return new Promise((resolve, reject) => {
965
+ const waiter = { resolve, reject };
966
+ const timer = setTimeout(() => {
967
+ const idx = this.waiters.indexOf(waiter);
968
+ if (idx >= 0) this.waiters.splice(idx, 1);
969
+ reject(AIError.sendFailed("Timed out waiting for a free AI session"));
970
+ }, _SessionManager.ACQUIRE_TIMEOUT_MS);
971
+ waiter.resolve = (ps) => {
972
+ clearTimeout(timer);
973
+ resolve(ps);
974
+ };
975
+ this.waiters.push(waiter);
976
+ });
977
+ }
978
+ /**
979
+ * Release a session back to the pool.
980
+ *
981
+ * If callers are queued waiting for a session, the session is handed
982
+ * directly to the next waiter (stays marked as busy) instead of being
983
+ * returned to the idle pool — this avoids an unnecessary acquire cycle.
984
+ */
985
+ release(ps) {
986
+ if (this.waiters.length > 0) {
987
+ const waiter = this.waiters.shift();
988
+ waiter.resolve(ps);
989
+ } else {
990
+ ps.busy = false;
991
+ }
992
+ }
993
+ // -----------------------------------------------------------------------
994
+ // Session creation
995
+ // -----------------------------------------------------------------------
996
+ /**
997
+ * Create a pool of Copilot sessions with the specified model and tools.
998
+ *
999
+ * Sessions are created in parallel for faster startup. If any session
1000
+ * fails to create, all successful ones are cleaned up and the error
1001
+ * propagates.
1002
+ *
1003
+ * @param model - Model identifier (e.g. `"gpt-5"`, `"claude-sonnet-4.5"`).
1004
+ * @param tools - Optional array of tool definitions to register.
1005
+ * @param poolSize - Number of parallel sessions (default: 3).
1006
+ * @throws {AIError} If session creation fails.
1007
+ */
1008
+ async createSession(model, tools, poolSize) {
1009
+ if (this.pool.length > 0) {
1010
+ this.logger.warn("Session pool already exists \u2014 closing before re-creating");
1011
+ await this.closeSession();
1012
+ }
1013
+ if (tools) this.tools = tools;
1014
+ if (poolSize !== void 0 && poolSize > 0) this.poolSize = poolSize;
1015
+ try {
1016
+ const client = this.clientWrapper.getClient();
1017
+ const sdkTools = convertTools(this.tools);
1018
+ this.logger.info(
1019
+ { model, toolCount: sdkTools.length, poolSize: this.poolSize },
1020
+ "Creating session pool"
1021
+ );
1022
+ const results = await Promise.allSettled(
1023
+ Array.from({ length: this.poolSize }, async (_, i) => {
1024
+ const session = await client.createSession({
1025
+ model,
1026
+ tools: sdkTools.length > 0 ? sdkTools : void 0,
1027
+ onPermissionRequest: approveAll
1028
+ });
1029
+ this.logger.debug({ index: i }, "Pool session created");
1030
+ return { session, busy: false, index: i };
1031
+ })
1032
+ );
1033
+ const created = [];
1034
+ const errors = [];
1035
+ for (const r of results) {
1036
+ if (r.status === "fulfilled") {
1037
+ created.push(r.value);
1038
+ } else {
1039
+ errors.push(r.reason);
1040
+ }
1041
+ }
1042
+ if (errors.length > 0) {
1043
+ for (const ps of created) {
1044
+ try {
1045
+ await ps.session.disconnect();
1046
+ } catch {
1047
+ }
1048
+ }
1049
+ const first = errors[0] instanceof Error ? errors[0].message : String(errors[0]);
1050
+ throw new Error(`${errors.length}/${this.poolSize} sessions failed: ${first}`);
1051
+ }
1052
+ this.pool = created;
1053
+ this.currentModel = model;
1054
+ this.logger.info({ model, poolSize: this.poolSize }, "Session pool ready");
1055
+ } catch (error) {
1056
+ const reason = error instanceof Error ? error.message : String(error);
1057
+ this.logger.error({ err: error, model }, "Failed to create session pool");
1058
+ throw AIError.sessionCreateFailed(reason);
1059
+ }
1060
+ }
1061
+ // -----------------------------------------------------------------------
1062
+ // Messaging
1063
+ // -----------------------------------------------------------------------
1064
+ /**
1065
+ * Send a prompt and wait for the complete assistant response.
1066
+ *
1067
+ * Acquires a free session from the pool, sends the prompt via
1068
+ * `sendAndWait`, and releases the session when done. Multiple callers
1069
+ * can run in parallel (up to `poolSize`). If all sessions are busy,
1070
+ * the caller blocks until one frees up.
1071
+ *
1072
+ * @param prompt - The user message to send.
1073
+ * @param timeout - Timeout in ms (default: 300 000 = 5 min). Complex prompts
1074
+ * involving multiple tool calls (e.g. heartbeats) can take
1075
+ * longer than the SDK's default 60 s.
1076
+ * @returns The assistant's full response content.
1077
+ * @throws {AIError} If no session pool is active or the send fails.
1078
+ */
1079
+ async sendMessage(prompt, timeout = 3e5) {
1080
+ this.ensureSession();
1081
+ const fullPrompt = this.applySystemContext(prompt);
1082
+ const ps = await this.acquire();
1083
+ try {
1084
+ this.logger.debug(
1085
+ { promptLength: fullPrompt.length, timeout, session: ps.index },
1086
+ "Sending message (blocking)"
1087
+ );
1088
+ const response = await ps.session.sendAndWait({ prompt: fullPrompt }, timeout);
1089
+ const content = response?.data?.content ?? "";
1090
+ this.logger.debug(
1091
+ { responseLength: content.length, session: ps.index },
1092
+ "Received response"
1093
+ );
1094
+ return content;
1095
+ } catch (error) {
1096
+ const reason = error instanceof Error ? error.message : String(error);
1097
+ this.logger.error({ err: error, session: ps.index }, "sendMessage failed");
1098
+ throw AIError.sendFailed(reason);
1099
+ } finally {
1100
+ this.release(ps);
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Send a prompt with streaming — invokes `onChunk` for each incremental
1105
+ * delta and returns the full accumulated response when the session goes idle.
1106
+ *
1107
+ * Acquires a pooled session for the duration of the streaming call.
1108
+ *
1109
+ * @param prompt - The user message to send.
1110
+ * @param onChunk - Callback invoked with each streaming text delta.
1111
+ * @returns The full accumulated response string.
1112
+ * @throws {AIError} If no session pool is active or the send fails.
1113
+ */
1114
+ async sendMessageStreaming(prompt, onChunk) {
1115
+ this.ensureSession();
1116
+ const fullPrompt = this.applySystemContext(prompt);
1117
+ const ps = await this.acquire();
1118
+ try {
1119
+ this.logger.debug(
1120
+ { promptLength: fullPrompt.length, session: ps.index },
1121
+ "Sending message (streaming)"
1122
+ );
1123
+ let accumulated = "";
1124
+ const done = new Promise((resolve, reject) => {
1125
+ ps.session.on("assistant.message_delta", (event) => {
1126
+ const delta = event.data.deltaContent ?? "";
1127
+ if (delta) {
1128
+ accumulated += delta;
1129
+ onChunk(delta);
1130
+ }
1131
+ });
1132
+ ps.session.on("assistant.message", (event) => {
1133
+ const content = event.data.content ?? "";
1134
+ if (content) {
1135
+ accumulated = content;
1136
+ }
1137
+ });
1138
+ ps.session.on("session.idle", () => {
1139
+ resolve(accumulated);
1140
+ });
1141
+ ps.session.on("error", (err) => {
1142
+ reject(err);
1143
+ });
1144
+ });
1145
+ await ps.session.send({ prompt: fullPrompt });
1146
+ const result = await done;
1147
+ this.logger.debug(
1148
+ { responseLength: result.length, session: ps.index },
1149
+ "Streaming response complete"
1150
+ );
1151
+ return result;
1152
+ } catch (error) {
1153
+ const reason = error instanceof Error ? error.message : String(error);
1154
+ this.logger.error({ err: error, session: ps.index }, "sendMessageStreaming failed");
1155
+ throw AIError.sendFailed(reason);
1156
+ } finally {
1157
+ this.release(ps);
1158
+ }
1159
+ }
1160
+ // -----------------------------------------------------------------------
1161
+ // Model / tool management
1162
+ // -----------------------------------------------------------------------
1163
+ /**
1164
+ * Switch to a different model. Closes the entire pool and creates a new
1165
+ * one with the same tool set and pool size.
1166
+ *
1167
+ * @param model - The new model identifier.
1168
+ * @throws {AIError} If pool recreation fails.
1169
+ */
1170
+ async switchModel(model) {
1171
+ this.logger.info({ from: this.currentModel, to: model }, "Switching model");
1172
+ await this.closeSession();
1173
+ await this.createSession(model, this.tools);
1174
+ }
1175
+ /**
1176
+ * Replace the registered tool set. The pool must be rebuilt because the
1177
+ * Copilot SDK binds tools at session creation time.
1178
+ *
1179
+ * @param tools - New array of tool definitions.
1180
+ * @throws {AIError} If pool recreation fails.
1181
+ */
1182
+ async updateTools(tools) {
1183
+ this.logger.info({ toolCount: tools.length }, "Updating tools (pool rebuild required)");
1184
+ this.tools = tools;
1185
+ if (this.pool.length > 0) {
1186
+ await this.closeSession();
1187
+ await this.createSession(this.currentModel, this.tools);
1188
+ }
1189
+ }
1190
+ // -----------------------------------------------------------------------
1191
+ // Session lifecycle
1192
+ // -----------------------------------------------------------------------
1193
+ /**
1194
+ * Close all sessions in the pool and release resources.
1195
+ *
1196
+ * Any callers blocked in {@link acquire} are rejected with an error.
1197
+ * Safe to call even when the pool is empty (no-op).
1198
+ */
1199
+ async closeSession() {
1200
+ if (this.pool.length === 0) {
1201
+ this.logger.debug("No active sessions to close");
1202
+ return;
1203
+ }
1204
+ for (const waiter of this.waiters) {
1205
+ waiter.reject(AIError.sessionCreateFailed("Session pool is closing"));
1206
+ }
1207
+ this.waiters = [];
1208
+ this.logger.info({ poolSize: this.pool.length }, "Closing session pool");
1209
+ await Promise.allSettled(
1210
+ this.pool.map(async (ps) => {
1211
+ try {
1212
+ await ps.session.disconnect();
1213
+ } catch (err) {
1214
+ this.logger.error({ err, index: ps.index }, "Error closing pool session (ignored)");
1215
+ }
1216
+ })
1217
+ );
1218
+ this.pool = [];
1219
+ this.logger.info("Session pool closed");
1220
+ }
1221
+ /**
1222
+ * Get the identifier of the model used by the current (or most recent) pool.
1223
+ */
1224
+ getCurrentModel() {
1225
+ return this.currentModel;
1226
+ }
1227
+ /**
1228
+ * Check whether the session pool has at least one session.
1229
+ */
1230
+ isActive() {
1231
+ return this.pool.length > 0;
1232
+ }
1233
+ /**
1234
+ * Number of sessions not currently processing a message.
1235
+ * Useful for the Telegram handler to decide whether to show a "queued" notice.
1236
+ */
1237
+ getAvailableCount() {
1238
+ return this.pool.filter((s) => !s.busy).length;
1239
+ }
1240
+ /**
1241
+ * Total number of sessions in the pool.
1242
+ */
1243
+ getPoolSize() {
1244
+ return this.poolSize;
1245
+ }
1246
+ // -----------------------------------------------------------------------
1247
+ // Internal helpers
1248
+ // -----------------------------------------------------------------------
1249
+ /**
1250
+ * Guard that throws if the pool is empty.
1251
+ * @throws {AIError} When called without an active pool.
1252
+ */
1253
+ ensureSession() {
1254
+ if (this.pool.length === 0) {
1255
+ throw AIError.sessionCreateFailed("No active sessions \u2014 call createSession() first");
1256
+ }
1257
+ }
1258
+ };
1259
+ var sessionManager = new SessionManager(copilotClient);
1260
+
1261
+ // src/plugins/registry.ts
1262
+ import path2 from "path";
1263
+ import {
1264
+ existsSync as existsSync3,
1265
+ mkdirSync as mkdirSync2,
1266
+ readdirSync,
1267
+ readFileSync as readFileSync3,
1268
+ statSync,
1269
+ writeFileSync as writeFileSync2
1270
+ } from "fs";
1271
+
1272
+ // src/plugins/types.ts
1273
+ import { z as z2 } from "zod";
1274
+ var CredentialRequirementSchema = z2.object({
1275
+ /** Credential key used to look up the value at runtime. */
1276
+ key: z2.string(),
1277
+ /** Human-readable explanation of what this credential is for. */
1278
+ description: z2.string(),
1279
+ /**
1280
+ * Type hint for the credential.
1281
+ * - `"text"` — plain text secret (default)
1282
+ * - `"oauth"` — OAuth token / flow
1283
+ * - `"apikey"` — API key
1284
+ */
1285
+ type: z2.enum(["text", "oauth", "apikey"]).default("text")
1286
+ });
1287
+ var PluginManifestSchema = z2.object({
1288
+ /**
1289
+ * Unique plugin identifier.
1290
+ * Must be kebab-case (`a-z`, `0-9`, and `-` only).
1291
+ */
1292
+ id: z2.string().regex(/^[a-z0-9-]+$/, "Plugin ID must be kebab-case"),
1293
+ /** Human-readable display name. */
1294
+ name: z2.string().min(1),
1295
+ /**
1296
+ * Semantic version string (e.g. `"1.2.3"`).
1297
+ * Must follow strict `MAJOR.MINOR.PATCH` format.
1298
+ */
1299
+ version: z2.string().regex(/^\d+\.\d+\.\d+$/, "Must be semver"),
1300
+ /** Short description of what this plugin does. */
1301
+ description: z2.string(),
1302
+ /** Optional author or organisation name. */
1303
+ author: z2.string().optional(),
1304
+ /**
1305
+ * Credentials the plugin requires to operate.
1306
+ * The loader will verify all required credentials are present before
1307
+ * calling `initialize()`.
1308
+ */
1309
+ requiredCredentials: z2.array(CredentialRequirementSchema).default([]),
1310
+ /**
1311
+ * IDs of other plugins this plugin depends on.
1312
+ * Dependencies are loaded and initialised first.
1313
+ */
1314
+ dependencies: z2.array(z2.string()).default([])
1315
+ });
1316
+
1317
+ // src/plugins/registry.ts
1318
+ init_config();
1319
+ var MANIFEST_FILENAME = "plugin.json";
1320
+ var CONFIG_PATH = "./config.json";
1321
+ var PluginRegistry = class {
1322
+ /**
1323
+ * @param pluginsDir - Absolute or relative path to the directory that
1324
+ * contains plugin subdirectories.
1325
+ */
1326
+ constructor(pluginsDir) {
1327
+ this.pluginsDir = pluginsDir;
1328
+ this.logger = createChildLogger("plugins:registry");
1329
+ }
1330
+ pluginsDir;
1331
+ /** Validated manifests keyed by plugin ID. */
1332
+ manifests = /* @__PURE__ */ new Map();
1333
+ /** Set of currently-enabled plugin IDs. */
1334
+ enabledPlugins = /* @__PURE__ */ new Set();
1335
+ /** Namespaced logger for registry operations. */
1336
+ logger;
1337
+ // -----------------------------------------------------------------------
1338
+ // Discovery
1339
+ // -----------------------------------------------------------------------
1340
+ /**
1341
+ * Scan the plugins directory and discover all available plugins.
1342
+ *
1343
+ * For every subdirectory that contains a valid `plugin.json` the manifest
1344
+ * is parsed, validated with {@link PluginManifestSchema}, and stored.
1345
+ * Invalid or missing manifests are logged as warnings and skipped — they
1346
+ * never crash the application.
1347
+ *
1348
+ * After discovery the enabled state for each plugin is loaded from the
1349
+ * application config (`config.json → app.plugins[id].enabled`).
1350
+ *
1351
+ * @returns Array of all successfully validated {@link PluginManifest}s.
1352
+ */
1353
+ async discoverPlugins() {
1354
+ this.manifests.clear();
1355
+ this.enabledPlugins.clear();
1356
+ if (!existsSync3(this.pluginsDir)) {
1357
+ this.logger.info(
1358
+ { pluginsDir: this.pluginsDir },
1359
+ "Plugins directory does not exist \u2014 creating it"
1360
+ );
1361
+ mkdirSync2(this.pluginsDir, { recursive: true });
1362
+ return [];
1363
+ }
1364
+ const entries = readdirSync(this.pluginsDir);
1365
+ for (const entry of entries) {
1366
+ const entryPath = path2.join(this.pluginsDir, entry);
1367
+ if (!statSync(entryPath).isDirectory()) {
1368
+ continue;
1369
+ }
1370
+ const manifestPath = path2.join(entryPath, MANIFEST_FILENAME);
1371
+ if (!existsSync3(manifestPath)) {
1372
+ this.logger.warn(
1373
+ { dir: entry },
1374
+ `Skipping "${entry}": no ${MANIFEST_FILENAME} found`
1375
+ );
1376
+ continue;
1377
+ }
1378
+ let raw;
1379
+ try {
1380
+ const content = readFileSync3(manifestPath, "utf-8");
1381
+ raw = JSON.parse(content);
1382
+ } catch (err) {
1383
+ this.logger.warn(
1384
+ { dir: entry, error: err.message },
1385
+ `Skipping "${entry}": failed to read or parse ${MANIFEST_FILENAME}`
1386
+ );
1387
+ continue;
1388
+ }
1389
+ const result = PluginManifestSchema.safeParse(raw);
1390
+ if (!result.success) {
1391
+ const issues = result.error.issues.map((i) => ` \u2022 ${i.path.join(".")}: ${i.message}`).join("\n");
1392
+ this.logger.warn(
1393
+ { dir: entry },
1394
+ `Skipping "${entry}": manifest validation failed:
1395
+ ${issues}`
1396
+ );
1397
+ continue;
1398
+ }
1399
+ const manifest = result.data;
1400
+ this.manifests.set(manifest.id, manifest);
1401
+ this.logger.info(
1402
+ { pluginId: manifest.id, version: manifest.version },
1403
+ `Discovered plugin "${manifest.name}"`
1404
+ );
1405
+ }
1406
+ this.loadEnabledState();
1407
+ return this.getManifests();
1408
+ }
1409
+ // -----------------------------------------------------------------------
1410
+ // Manifest accessors
1411
+ // -----------------------------------------------------------------------
1412
+ /**
1413
+ * Get all discovered plugin manifests.
1414
+ *
1415
+ * @returns A shallow copy of the manifests array.
1416
+ */
1417
+ getManifests() {
1418
+ return Array.from(this.manifests.values());
1419
+ }
1420
+ /**
1421
+ * Get a specific plugin's manifest by ID.
1422
+ *
1423
+ * @param pluginId - The unique plugin identifier.
1424
+ * @returns The manifest if found, otherwise `undefined`.
1425
+ */
1426
+ getManifest(pluginId) {
1427
+ return this.manifests.get(pluginId);
1428
+ }
1429
+ // -----------------------------------------------------------------------
1430
+ // Enabled / disabled state
1431
+ // -----------------------------------------------------------------------
1432
+ /**
1433
+ * Check whether a plugin is currently enabled.
1434
+ *
1435
+ * @param pluginId - The unique plugin identifier.
1436
+ * @returns `true` if the plugin is enabled.
1437
+ */
1438
+ isEnabled(pluginId) {
1439
+ return this.enabledPlugins.has(pluginId);
1440
+ }
1441
+ /**
1442
+ * Enable a plugin and persist the state to `config.json`.
1443
+ *
1444
+ * @param pluginId - The unique plugin identifier.
1445
+ */
1446
+ enablePlugin(pluginId) {
1447
+ this.enabledPlugins.add(pluginId);
1448
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" enabled`);
1449
+ this.persistPluginState(pluginId, true);
1450
+ }
1451
+ /**
1452
+ * Disable a plugin and persist the state to `config.json`.
1453
+ *
1454
+ * @param pluginId - The unique plugin identifier.
1455
+ */
1456
+ disablePlugin(pluginId) {
1457
+ this.enabledPlugins.delete(pluginId);
1458
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" disabled`);
1459
+ this.persistPluginState(pluginId, false);
1460
+ }
1461
+ /**
1462
+ * Get the list of currently-enabled plugin IDs.
1463
+ *
1464
+ * @returns Array of enabled plugin ID strings.
1465
+ */
1466
+ getEnabledPluginIds() {
1467
+ return Array.from(this.enabledPlugins);
1468
+ }
1469
+ /**
1470
+ * Get the list of all discovered plugin IDs.
1471
+ *
1472
+ * @returns Array of all known plugin ID strings.
1473
+ */
1474
+ getAllPluginIds() {
1475
+ return Array.from(this.manifests.keys());
1476
+ }
1477
+ // -----------------------------------------------------------------------
1478
+ // Private helpers
1479
+ // -----------------------------------------------------------------------
1480
+ /**
1481
+ * Load the enabled state for every discovered plugin from the application
1482
+ * config. Plugins whose config entry has `enabled: true` are added to the
1483
+ * enabled set.
1484
+ */
1485
+ loadEnabledState() {
1486
+ let appConfig;
1487
+ try {
1488
+ if (existsSync3(CONFIG_PATH)) {
1489
+ const raw = JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
1490
+ appConfig = AppConfigSchema.parse(raw);
1491
+ } else {
1492
+ appConfig = AppConfigSchema.parse({});
1493
+ }
1494
+ } catch {
1495
+ this.logger.warn("Could not load config.json \u2014 defaulting all plugins to disabled");
1496
+ return;
1497
+ }
1498
+ const plugins = appConfig.plugins ?? {};
1499
+ for (const id of this.manifests.keys()) {
1500
+ if (plugins[id]?.enabled === true) {
1501
+ this.enabledPlugins.add(id);
1502
+ this.logger.debug({ pluginId: id }, `Plugin "${id}" is enabled via config`);
1503
+ }
1504
+ }
1505
+ }
1506
+ /**
1507
+ * Persist the enabled/disabled state for a single plugin to `config.json`.
1508
+ *
1509
+ * Reads the current file, updates the relevant entry, and writes it back.
1510
+ * If the file does not exist a minimal config is created.
1511
+ */
1512
+ persistPluginState(pluginId, enabled) {
1513
+ try {
1514
+ let config = {};
1515
+ if (existsSync3(CONFIG_PATH)) {
1516
+ config = JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
1517
+ }
1518
+ if (!config.plugins || typeof config.plugins !== "object") {
1519
+ config.plugins = {};
1520
+ }
1521
+ const plugins = config.plugins;
1522
+ if (!plugins[pluginId]) {
1523
+ plugins[pluginId] = { enabled, credentials: {} };
1524
+ } else {
1525
+ plugins[pluginId].enabled = enabled;
1526
+ }
1527
+ writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
1528
+ resetConfig();
1529
+ this.logger.debug(
1530
+ { pluginId, enabled },
1531
+ "Persisted plugin state to config.json"
1532
+ );
1533
+ } catch (err) {
1534
+ this.logger.error(
1535
+ { pluginId, error: err.message },
1536
+ "Failed to persist plugin state to config.json"
1537
+ );
1538
+ }
1539
+ }
1540
+ };
1541
+ function createPluginRegistry(pluginsDir) {
1542
+ return new PluginRegistry(pluginsDir ?? path2.join(process.cwd(), "plugins"));
1543
+ }
1544
+
1545
+ // src/plugins/manager.ts
1546
+ import path3 from "path";
1547
+ import { existsSync as existsSync4 } from "fs";
1548
+ import { tsImport } from "tsx/esm/api";
1549
+ var PluginManager = class {
1550
+ constructor(registry, sandbox, credentials, stateRepo) {
1551
+ this.registry = registry;
1552
+ this.sandbox = sandbox;
1553
+ this.credentials = credentials;
1554
+ this.stateRepo = stateRepo;
1555
+ this.logger = createChildLogger("plugins:manager");
1556
+ }
1557
+ registry;
1558
+ sandbox;
1559
+ credentials;
1560
+ stateRepo;
1561
+ /** Active (loaded and initialised) plugin instances keyed by ID. */
1562
+ plugins = /* @__PURE__ */ new Map();
1563
+ /** Current lifecycle status for every known plugin. */
1564
+ pluginStatuses = /* @__PURE__ */ new Map();
1565
+ /** Namespaced logger for manager operations. */
1566
+ logger;
1567
+ // -----------------------------------------------------------------------
1568
+ // Initialisation
1569
+ // -----------------------------------------------------------------------
1570
+ /**
1571
+ * Initialise the plugin system: discover plugins on disk, then load and
1572
+ * initialise every enabled plugin.
1573
+ *
1574
+ * Safe to call exactly once at application startup. Errors from individual
1575
+ * plugins are caught and logged — a single broken plugin never prevents the
1576
+ * rest from loading.
1577
+ */
1578
+ async initialize() {
1579
+ this.logger.info("Initializing plugin system\u2026");
1580
+ const manifests = await this.registry.discoverPlugins();
1581
+ const discovered = manifests.length;
1582
+ let loaded = 0;
1583
+ let failed = 0;
1584
+ const enabledIds = this.registry.getEnabledPluginIds();
1585
+ for (const pluginId of enabledIds) {
1586
+ try {
1587
+ await this.loadPlugin(pluginId);
1588
+ loaded++;
1589
+ } catch (err) {
1590
+ failed++;
1591
+ const error = err instanceof Error ? err : new Error(String(err));
1592
+ this.logger.error(
1593
+ { pluginId, error: error.message },
1594
+ `Failed to load plugin "${pluginId}"`
1595
+ );
1596
+ this.pluginStatuses.set(pluginId, "error");
1597
+ }
1598
+ }
1599
+ this.logger.info(
1600
+ { discovered, loaded, failed },
1601
+ `Plugin system ready \u2014 ${discovered} discovered, ${loaded} loaded, ${failed} failed`
1602
+ );
1603
+ }
1604
+ // -----------------------------------------------------------------------
1605
+ // Load / Unload
1606
+ // -----------------------------------------------------------------------
1607
+ /**
1608
+ * Load and initialise a specific plugin by ID.
1609
+ *
1610
+ * Steps:
1611
+ * 1. Resolve the plugin manifest from the registry.
1612
+ * 2. Validate credentials via the {@link CredentialManager}.
1613
+ * 3. Dynamically import the plugin module (preferring compiled `.js`).
1614
+ * 4. Invoke the factory to obtain a {@link CoAssistantPlugin} instance.
1615
+ * 5. Build a {@link PluginContext} and call `plugin.initialize()` inside
1616
+ * the sandbox.
1617
+ * 6. Store the active plugin and set its status to `"active"`.
1618
+ *
1619
+ * @param pluginId - Unique identifier of the plugin to load.
1620
+ * @throws If the manifest is not found, credentials are invalid, or the
1621
+ * module cannot be imported.
1622
+ */
1623
+ async loadPlugin(pluginId) {
1624
+ this.logger.info({ pluginId }, `Loading plugin "${pluginId}"\u2026`);
1625
+ const manifest = this.registry.getManifest(pluginId);
1626
+ if (!manifest) {
1627
+ throw new Error(`No manifest found for plugin "${pluginId}"`);
1628
+ }
1629
+ this.pluginStatuses.set(pluginId, "loaded");
1630
+ let creds = {};
1631
+ try {
1632
+ creds = this.credentials.getValidatedCredentials(
1633
+ pluginId,
1634
+ manifest.requiredCredentials
1635
+ );
1636
+ } catch (err) {
1637
+ const error = err instanceof Error ? err : new Error(String(err));
1638
+ this.logger.warn(
1639
+ { pluginId, error: error.message },
1640
+ `Credential validation failed for "${pluginId}" \u2014 loading with empty credentials`
1641
+ );
1642
+ }
1643
+ const pluginPath = this.resolvePluginPath(pluginId);
1644
+ this.logger.debug({ pluginId, pluginPath }, "Importing plugin module");
1645
+ let mod;
1646
+ try {
1647
+ if (pluginPath.endsWith(".ts")) {
1648
+ mod = await tsImport(pluginPath, import.meta.url);
1649
+ } else {
1650
+ mod = await import(pluginPath);
1651
+ }
1652
+ } catch (err) {
1653
+ const error = err instanceof Error ? err : new Error(String(err));
1654
+ this.pluginStatuses.set(pluginId, "error");
1655
+ throw new Error(
1656
+ `Failed to import plugin module "${pluginId}" from ${pluginPath}: ${error.message}`
1657
+ );
1658
+ }
1659
+ const factory = typeof mod.default === "function" ? mod.default : typeof mod.createPlugin === "function" ? mod.createPlugin : void 0;
1660
+ if (!factory) {
1661
+ this.pluginStatuses.set(pluginId, "error");
1662
+ throw new Error(
1663
+ `Plugin "${pluginId}" does not export a default factory or createPlugin function`
1664
+ );
1665
+ }
1666
+ const plugin = factory();
1667
+ const context = this.buildPluginContext(pluginId, creds);
1668
+ const initResult = await this.sandbox.safeExecute(
1669
+ pluginId,
1670
+ "initialize",
1671
+ () => plugin.initialize(context)
1672
+ );
1673
+ if (initResult === void 0 && manifest.requiredCredentials.length > 0) {
1674
+ this.logger.warn(
1675
+ { pluginId },
1676
+ `Plugin "${pluginId}" initialize() did not succeed \u2014 marking as error`
1677
+ );
1678
+ }
1679
+ this.plugins.set(pluginId, plugin);
1680
+ this.pluginStatuses.set(pluginId, "active");
1681
+ this.logger.info(
1682
+ { pluginId, version: manifest.version },
1683
+ `Plugin "${manifest.name}" v${manifest.version} loaded successfully`
1684
+ );
1685
+ }
1686
+ /**
1687
+ * Unload a plugin: invoke its `destroy()` hook inside the sandbox,
1688
+ * remove it from the active plugin map, and mark it as unloaded.
1689
+ *
1690
+ * @param pluginId - Unique identifier of the plugin to unload.
1691
+ */
1692
+ async unloadPlugin(pluginId) {
1693
+ const plugin = this.plugins.get(pluginId);
1694
+ if (!plugin) {
1695
+ this.logger.warn({ pluginId }, `Plugin "${pluginId}" is not loaded \u2014 nothing to unload`);
1696
+ return;
1697
+ }
1698
+ this.logger.info({ pluginId }, `Unloading plugin "${pluginId}"\u2026`);
1699
+ await this.sandbox.safeExecute(pluginId, "destroy", () => plugin.destroy());
1700
+ this.plugins.delete(pluginId);
1701
+ this.pluginStatuses.set(pluginId, "unloaded");
1702
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" unloaded`);
1703
+ }
1704
+ // -----------------------------------------------------------------------
1705
+ // Enable / Disable
1706
+ // -----------------------------------------------------------------------
1707
+ /**
1708
+ * Enable a plugin: persist the enabled state in the registry and load the
1709
+ * plugin if it is not already active.
1710
+ *
1711
+ * @param pluginId - Unique identifier of the plugin to enable.
1712
+ */
1713
+ async enablePlugin(pluginId) {
1714
+ this.logger.info({ pluginId }, `Enabling plugin "${pluginId}"`);
1715
+ this.registry.enablePlugin(pluginId);
1716
+ if (!this.plugins.has(pluginId)) {
1717
+ await this.loadPlugin(pluginId);
1718
+ }
1719
+ }
1720
+ /**
1721
+ * Disable a plugin: persist the disabled state in the registry and unload
1722
+ * the plugin if it is currently active.
1723
+ *
1724
+ * @param pluginId - Unique identifier of the plugin to disable.
1725
+ */
1726
+ async disablePlugin(pluginId) {
1727
+ this.logger.info({ pluginId }, `Disabling plugin "${pluginId}"`);
1728
+ this.registry.disablePlugin(pluginId);
1729
+ if (this.plugins.has(pluginId)) {
1730
+ await this.unloadPlugin(pluginId);
1731
+ }
1732
+ this.pluginStatuses.set(pluginId, "disabled");
1733
+ }
1734
+ // -----------------------------------------------------------------------
1735
+ // Accessors
1736
+ // -----------------------------------------------------------------------
1737
+ /**
1738
+ * Get all active (loaded and running) plugin instances.
1739
+ *
1740
+ * @returns A new Map containing only the currently active plugins.
1741
+ */
1742
+ getActivePlugins() {
1743
+ return new Map(this.plugins);
1744
+ }
1745
+ /**
1746
+ * Collect all tool definitions from every active plugin.
1747
+ *
1748
+ * Tool names are prefixed with the owning plugin's ID
1749
+ * (`<pluginId>__<toolName>`) to guarantee uniqueness. Handlers are wrapped
1750
+ * by the sandbox so errors are isolated.
1751
+ *
1752
+ * @returns Flat array of all tools across all active plugins.
1753
+ */
1754
+ getAllTools() {
1755
+ const tools = [];
1756
+ for (const [pluginId, plugin] of this.plugins) {
1757
+ try {
1758
+ const pluginTools = plugin.getTools();
1759
+ for (const tool of pluginTools) {
1760
+ tools.push({
1761
+ name: `${pluginId}__${tool.name}`,
1762
+ description: tool.description,
1763
+ parameters: tool.parameters,
1764
+ handler: this.sandbox.wrapToolHandler(pluginId, tool.name, tool.handler)
1765
+ });
1766
+ }
1767
+ } catch (err) {
1768
+ const error = err instanceof Error ? err : new Error(String(err));
1769
+ this.logger.error(
1770
+ { pluginId, error: error.message },
1771
+ `Failed to get tools from plugin "${pluginId}"`
1772
+ );
1773
+ }
1774
+ }
1775
+ return tools;
1776
+ }
1777
+ /**
1778
+ * Build a read-only info snapshot for every discovered plugin.
1779
+ *
1780
+ * Combines manifest metadata, current lifecycle status, enabled state,
1781
+ * sandbox failure counts, and registered tool names.
1782
+ *
1783
+ * @returns Array of {@link PluginInfo} objects (for display / admin API).
1784
+ */
1785
+ getPluginInfoList() {
1786
+ const manifests = this.registry.getManifests();
1787
+ return manifests.map((manifest) => {
1788
+ const pluginId = manifest.id;
1789
+ const status = this.pluginStatuses.get(pluginId) ?? "unloaded";
1790
+ const enabled = this.registry.isEnabled(pluginId);
1791
+ const failureCount = this.sandbox.getFailureCount(pluginId);
1792
+ let toolNames = [];
1793
+ const plugin = this.plugins.get(pluginId);
1794
+ if (plugin) {
1795
+ try {
1796
+ toolNames = plugin.getTools().map((t) => `${pluginId}__${t.name}`);
1797
+ } catch {
1798
+ }
1799
+ }
1800
+ const info = {
1801
+ id: pluginId,
1802
+ name: manifest.name,
1803
+ version: manifest.version,
1804
+ description: manifest.description,
1805
+ status,
1806
+ enabled,
1807
+ failureCount,
1808
+ tools: toolNames
1809
+ };
1810
+ if (status === "error") {
1811
+ info.errorMessage = this.sandbox.isDisabled(pluginId) ? "Auto-disabled due to repeated failures" : "Plugin encountered an error during lifecycle";
1812
+ }
1813
+ return info;
1814
+ });
1815
+ }
1816
+ /**
1817
+ * Get info about a specific discovered plugin.
1818
+ *
1819
+ * @param pluginId - The unique plugin identifier.
1820
+ * @returns The plugin info snapshot, or `undefined` if the plugin was not
1821
+ * discovered.
1822
+ */
1823
+ getPluginInfo(pluginId) {
1824
+ return this.getPluginInfoList().find((info) => info.id === pluginId);
1825
+ }
1826
+ // -----------------------------------------------------------------------
1827
+ // Shutdown
1828
+ // -----------------------------------------------------------------------
1829
+ /**
1830
+ * Gracefully shut down all active plugins.
1831
+ *
1832
+ * Calls `destroy()` on each plugin inside the sandbox so that a failing
1833
+ * plugin does not prevent the rest from cleaning up.
1834
+ */
1835
+ async shutdown() {
1836
+ this.logger.info("Shutting down plugin system\u2026");
1837
+ const pluginIds = Array.from(this.plugins.keys());
1838
+ for (const pluginId of pluginIds) {
1839
+ await this.unloadPlugin(pluginId);
1840
+ }
1841
+ this.logger.info(
1842
+ { count: pluginIds.length },
1843
+ `Plugin system shut down \u2014 ${pluginIds.length} plugin(s) unloaded`
1844
+ );
1845
+ }
1846
+ // -----------------------------------------------------------------------
1847
+ // Private helpers
1848
+ // -----------------------------------------------------------------------
1849
+ /**
1850
+ * Resolve the file-system path for a plugin module.
1851
+ *
1852
+ * Prefers the compiled `.js` file; falls back to `.ts` for development.
1853
+ */
1854
+ resolvePluginPath(pluginId) {
1855
+ const baseDir = path3.join(process.cwd(), "plugins", pluginId);
1856
+ const jsPath = path3.join(baseDir, "index.js");
1857
+ if (existsSync4(jsPath)) {
1858
+ return jsPath;
1859
+ }
1860
+ const tsPath = path3.join(baseDir, "index.ts");
1861
+ if (existsSync4(tsPath)) {
1862
+ return tsPath;
1863
+ }
1864
+ return jsPath;
1865
+ }
1866
+ /**
1867
+ * Build a {@link PluginContext} for a specific plugin.
1868
+ *
1869
+ * The context provides credentials, a namespaced state store backed by the
1870
+ * {@link PluginStateRepository}, and a child logger.
1871
+ */
1872
+ buildPluginContext(pluginId, credentials) {
1873
+ const stateStore = {
1874
+ get: (key) => this.stateRepo.get(pluginId, key),
1875
+ set: (key, value) => this.stateRepo.set(pluginId, key, value),
1876
+ delete: (key) => this.stateRepo.delete(pluginId, key),
1877
+ getAll: () => this.stateRepo.getAll(pluginId)
1878
+ };
1879
+ return {
1880
+ pluginId,
1881
+ credentials,
1882
+ state: stateStore,
1883
+ logger: createChildLogger(`plugin:${pluginId}`, { pluginId })
1884
+ };
1885
+ }
1886
+ };
1887
+ function createPluginManager(registry, sandbox, credentials, stateRepo) {
1888
+ return new PluginManager(registry, sandbox, credentials, stateRepo);
1889
+ }
1890
+
1891
+ // src/plugins/sandbox.ts
1892
+ var DEFAULT_MAX_FAILURES = 5;
1893
+ var PluginSandbox = class {
1894
+ /** Consecutive failure counts keyed by plugin ID. */
1895
+ failureCounts = /* @__PURE__ */ new Map();
1896
+ /** Set of plugin IDs that have been auto-disabled due to repeated failures. */
1897
+ disabledPlugins = /* @__PURE__ */ new Set();
1898
+ /** Maximum consecutive failures before a plugin is disabled. */
1899
+ maxFailures;
1900
+ /** Namespaced logger for sandbox events. */
1901
+ logger;
1902
+ /**
1903
+ * Create a new sandbox instance.
1904
+ *
1905
+ * @param maxFailures - Override for the failure threshold. When omitted the
1906
+ * value is read from the application config (`app.pluginHealth.maxFailures`)
1907
+ * with a hard-coded fallback of {@link DEFAULT_MAX_FAILURES}.
1908
+ */
1909
+ constructor(maxFailures) {
1910
+ this.logger = createChildLogger("plugins:sandbox");
1911
+ if (maxFailures !== void 0) {
1912
+ this.maxFailures = maxFailures;
1913
+ } else {
1914
+ try {
1915
+ const { getConfig: getConfig2 } = (init_config(), __toCommonJS(config_exports));
1916
+ this.maxFailures = getConfig2().app.pluginHealth.maxFailures ?? DEFAULT_MAX_FAILURES;
1917
+ } catch {
1918
+ this.maxFailures = DEFAULT_MAX_FAILURES;
1919
+ }
1920
+ }
1921
+ }
1922
+ // -----------------------------------------------------------------------
1923
+ // Core execution wrapper
1924
+ // -----------------------------------------------------------------------
1925
+ /**
1926
+ * Safely execute a plugin method within a try/catch boundary.
1927
+ *
1928
+ * If the plugin is disabled the call is short-circuited and `undefined` is
1929
+ * returned. On success the failure counter is reset; on failure it is
1930
+ * incremented and the error is logged with full context.
1931
+ *
1932
+ * **Critical:** errors are _never_ allowed to propagate out of this method.
1933
+ *
1934
+ * @param pluginId - Unique identifier of the plugin.
1935
+ * @param methodName - Name of the method being invoked (for logging).
1936
+ * @param fn - The async function to execute.
1937
+ * @returns The result of `fn()` on success, or `undefined` on failure.
1938
+ */
1939
+ async safeExecute(pluginId, methodName, fn) {
1940
+ try {
1941
+ if (this.isDisabled(pluginId)) {
1942
+ this.logger.warn(
1943
+ { pluginId, methodName },
1944
+ `Plugin "${pluginId}" is disabled \u2014 skipping ${methodName}`
1945
+ );
1946
+ return void 0;
1947
+ }
1948
+ const result = await fn();
1949
+ this.recordSuccess(pluginId);
1950
+ return result;
1951
+ } catch (err) {
1952
+ const error = err instanceof Error ? err : new Error(String(err));
1953
+ this.recordFailure(pluginId, error, methodName);
1954
+ this.logger.error(
1955
+ {
1956
+ pluginId,
1957
+ methodName,
1958
+ error: error.message,
1959
+ failureCount: this.getFailureCount(pluginId),
1960
+ maxFailures: this.maxFailures
1961
+ },
1962
+ `Plugin "${pluginId}" method "${methodName}" threw: ${error.message}`
1963
+ );
1964
+ return void 0;
1965
+ }
1966
+ }
1967
+ // -----------------------------------------------------------------------
1968
+ // Tool handler wrapper
1969
+ // -----------------------------------------------------------------------
1970
+ /**
1971
+ * Wrap a tool handler so that errors are caught and returned as a
1972
+ * descriptive string rather than crashing the process.
1973
+ *
1974
+ * The AI model therefore receives actionable feedback about the failure
1975
+ * instead of an unhandled exception.
1976
+ *
1977
+ * @param pluginId - Unique identifier of the owning plugin.
1978
+ * @param toolName - Display name of the tool.
1979
+ * @param handler - The original tool handler function.
1980
+ * @returns A wrapped handler with identical signature.
1981
+ */
1982
+ wrapToolHandler(pluginId, toolName, handler) {
1983
+ return async (args) => {
1984
+ const result = await this.safeExecute(
1985
+ pluginId,
1986
+ `tool:${toolName}`,
1987
+ () => handler(args)
1988
+ );
1989
+ if (result === void 0) {
1990
+ return `Error: Tool ${toolName} failed: ${this.isDisabled(pluginId) ? "plugin has been disabled due to repeated failures" : "unexpected error during execution"}`;
1991
+ }
1992
+ return result;
1993
+ };
1994
+ }
1995
+ // -----------------------------------------------------------------------
1996
+ // Failure / success tracking
1997
+ // -----------------------------------------------------------------------
1998
+ /**
1999
+ * Record a failure for a plugin and auto-disable it if the threshold is
2000
+ * reached.
2001
+ *
2002
+ * @param pluginId - Unique identifier of the plugin.
2003
+ * @param error - The error that occurred.
2004
+ * @param methodName - The method that failed (for logging).
2005
+ * @returns `true` if the plugin was auto-disabled as a result of this failure.
2006
+ */
2007
+ recordFailure(pluginId, error, methodName) {
2008
+ const current = (this.failureCounts.get(pluginId) ?? 0) + 1;
2009
+ this.failureCounts.set(pluginId, current);
2010
+ try {
2011
+ const { PluginHealthRepository: PluginHealthRepository2 } = (init_plugin_health(), __toCommonJS(plugin_health_exports));
2012
+ const repo = new PluginHealthRepository2();
2013
+ repo.logHealth(pluginId, "error", error.message);
2014
+ } catch {
2015
+ }
2016
+ if (current >= this.maxFailures && !this.disabledPlugins.has(pluginId)) {
2017
+ this.disabledPlugins.add(pluginId);
2018
+ this.logger.warn(
2019
+ {
2020
+ pluginId,
2021
+ methodName,
2022
+ failureCount: current,
2023
+ maxFailures: this.maxFailures
2024
+ },
2025
+ `Plugin "${pluginId}" auto-disabled after ${current} consecutive failures (threshold: ${this.maxFailures})`
2026
+ );
2027
+ return true;
2028
+ }
2029
+ return false;
2030
+ }
2031
+ /**
2032
+ * Record a successful execution for a plugin.
2033
+ *
2034
+ * Resets the consecutive failure counter to zero — a successful call proves
2035
+ * the plugin is healthy.
2036
+ *
2037
+ * @param pluginId - Unique identifier of the plugin.
2038
+ */
2039
+ recordSuccess(pluginId) {
2040
+ this.failureCounts.set(pluginId, 0);
2041
+ }
2042
+ // -----------------------------------------------------------------------
2043
+ // Status queries
2044
+ // -----------------------------------------------------------------------
2045
+ /**
2046
+ * Check whether a plugin has been auto-disabled due to repeated failures.
2047
+ *
2048
+ * @param pluginId - Unique identifier of the plugin.
2049
+ * @returns `true` if the plugin is currently disabled.
2050
+ */
2051
+ isDisabled(pluginId) {
2052
+ return this.disabledPlugins.has(pluginId);
2053
+ }
2054
+ /**
2055
+ * Get the current consecutive failure count for a plugin.
2056
+ *
2057
+ * @param pluginId - Unique identifier of the plugin.
2058
+ * @returns The number of consecutive failures (0 when not tracked).
2059
+ */
2060
+ getFailureCount(pluginId) {
2061
+ return this.failureCounts.get(pluginId) ?? 0;
2062
+ }
2063
+ /**
2064
+ * Reset the failure counter and re-enable a previously disabled plugin.
2065
+ *
2066
+ * @param pluginId - Unique identifier of the plugin.
2067
+ */
2068
+ resetPlugin(pluginId) {
2069
+ this.failureCounts.delete(pluginId);
2070
+ this.disabledPlugins.delete(pluginId);
2071
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" has been reset and re-enabled`);
2072
+ }
2073
+ /**
2074
+ * Build a health summary for every plugin the sandbox has interacted with.
2075
+ *
2076
+ * @returns A map from plugin ID to its current failure count and disabled
2077
+ * status.
2078
+ */
2079
+ getHealthSummary() {
2080
+ const summary = /* @__PURE__ */ new Map();
2081
+ for (const [pluginId, failures] of this.failureCounts) {
2082
+ summary.set(pluginId, {
2083
+ failures,
2084
+ disabled: this.disabledPlugins.has(pluginId)
2085
+ });
2086
+ }
2087
+ for (const pluginId of this.disabledPlugins) {
2088
+ if (!summary.has(pluginId)) {
2089
+ summary.set(pluginId, {
2090
+ failures: 0,
2091
+ disabled: true
2092
+ });
2093
+ }
2094
+ }
2095
+ return summary;
2096
+ }
2097
+ };
2098
+ var pluginSandbox = new PluginSandbox();
2099
+
2100
+ // src/plugins/credentials.ts
2101
+ init_config();
2102
+ var CredentialManager = class {
2103
+ logger;
2104
+ constructor() {
2105
+ this.logger = createChildLogger("plugins:credentials");
2106
+ }
2107
+ /**
2108
+ * Get credentials for a plugin from config.json.
2109
+ * Returns the credentials `Record` or an empty object if not configured.
2110
+ */
2111
+ getPluginCredentials(pluginId) {
2112
+ const { app } = getConfig();
2113
+ return app.plugins[pluginId]?.credentials ?? {};
2114
+ }
2115
+ /**
2116
+ * Validate that all required credentials are present and non-empty.
2117
+ *
2118
+ * @returns `{ valid: true }` when every required key is present with a
2119
+ * non-empty value, or `{ valid: false, missing }` listing the keys that
2120
+ * are absent or empty.
2121
+ */
2122
+ validateCredentials(pluginId, requiredCredentials) {
2123
+ const credentials = this.getPluginCredentials(pluginId);
2124
+ const missing = requiredCredentials.filter(({ key }) => {
2125
+ const value = credentials[key];
2126
+ return value === void 0 || value === "";
2127
+ }).map(({ key }) => key);
2128
+ if (missing.length === 0) {
2129
+ this.logger.debug({ pluginId }, "All required credentials present");
2130
+ return { valid: true };
2131
+ }
2132
+ this.logger.warn(
2133
+ { pluginId, missing },
2134
+ "Missing required credentials for plugin"
2135
+ );
2136
+ return { valid: false, missing };
2137
+ }
2138
+ /**
2139
+ * Get credentials for a plugin, throwing {@link PluginError} if any
2140
+ * required ones are missing.
2141
+ *
2142
+ * This is the main method plugins use during initialisation.
2143
+ *
2144
+ * @throws {PluginError} with code `PLUGIN_CREDENTIALS_MISSING` when one or
2145
+ * more required keys are absent or empty.
2146
+ */
2147
+ getValidatedCredentials(pluginId, requiredCredentials) {
2148
+ const result = this.validateCredentials(pluginId, requiredCredentials);
2149
+ if (!result.valid) {
2150
+ throw PluginError.credentialsMissing(pluginId, result.missing);
2151
+ }
2152
+ this.logger.info(
2153
+ { pluginId, count: requiredCredentials.length },
2154
+ "Credentials validated successfully"
2155
+ );
2156
+ return this.getPluginCredentials(pluginId);
2157
+ }
2158
+ /**
2159
+ * Check whether a plugin has all required credentials configured.
2160
+ *
2161
+ * Non-throwing convenience wrapper around {@link validateCredentials}.
2162
+ */
2163
+ hasRequiredCredentials(pluginId, requiredCredentials) {
2164
+ return this.validateCredentials(pluginId, requiredCredentials).valid;
2165
+ }
2166
+ /**
2167
+ * Get a summary of credential status for all known plugins.
2168
+ * Useful for the CLI status command.
2169
+ *
2170
+ * @param plugins - Array of plugin descriptors containing their id and
2171
+ * required credential definitions.
2172
+ * @returns Per-plugin breakdown of which credentials are configured vs
2173
+ * missing.
2174
+ */
2175
+ getCredentialStatus(plugins) {
2176
+ return plugins.map(({ id, requiredCredentials }) => {
2177
+ const credentials = this.getPluginCredentials(id);
2178
+ const configured = [];
2179
+ const missing = [];
2180
+ for (const { key } of requiredCredentials) {
2181
+ const value = credentials[key];
2182
+ if (value !== void 0 && value !== "") {
2183
+ configured.push(key);
2184
+ } else {
2185
+ missing.push(key);
2186
+ }
2187
+ }
2188
+ return { pluginId: id, configured, missing };
2189
+ });
2190
+ }
2191
+ };
2192
+ var credentialManager = new CredentialManager();
2193
+
2194
+ // src/bot/bot.ts
2195
+ import { Telegraf } from "telegraf";
2196
+ import { message } from "telegraf/filters";
2197
+
2198
+ // src/bot/middleware/logging.ts
2199
+ var logger4 = createChildLogger("bot:middleware:logging");
2200
+ function createLoggingMiddleware() {
2201
+ return async (ctx, next) => {
2202
+ const start = Date.now();
2203
+ const updateId = ctx.update.update_id;
2204
+ const updateType = ctx.updateType;
2205
+ const userId = ctx.from?.id;
2206
+ const rawText = (ctx.message && "text" in ctx.message ? ctx.message.text : void 0) ?? (ctx.callbackQuery && "data" in ctx.callbackQuery ? ctx.callbackQuery.data : void 0);
2207
+ const preview = rawText ? rawText.slice(0, 50) : void 0;
2208
+ logger4.debug(
2209
+ { updateId, updateType, userId, preview },
2210
+ "Incoming update"
2211
+ );
2212
+ await next();
2213
+ const durationMs = Date.now() - start;
2214
+ logger4.debug(
2215
+ { updateId, updateType, userId, durationMs },
2216
+ "Update processed"
2217
+ );
2218
+ };
2219
+ }
2220
+
2221
+ // src/bot/middleware/auth.ts
2222
+ var logger5 = createChildLogger("bot:auth");
2223
+ function createAuthMiddleware(allowedUserId) {
2224
+ return async (ctx, next) => {
2225
+ const senderId = ctx.from?.id;
2226
+ if (senderId === void 0) {
2227
+ logger5.warn(
2228
+ { updateId: ctx.update.update_id },
2229
+ "Update has no sender \u2014 dropping"
2230
+ );
2231
+ return;
2232
+ }
2233
+ if (senderId !== allowedUserId) {
2234
+ const username = ctx.from?.username;
2235
+ logger5.warn(
2236
+ { senderId, username, allowedUserId, updateId: ctx.update.update_id },
2237
+ "Unauthorized access attempt \u2014 dropping update"
2238
+ );
2239
+ return;
2240
+ }
2241
+ await next();
2242
+ };
2243
+ }
2244
+
2245
+ // src/bot/bot.ts
2246
+ var TelegramBot = class {
2247
+ bot;
2248
+ isRunning = false;
2249
+ logger;
2250
+ /**
2251
+ * @param token - The Telegram Bot API token obtained from BotFather.
2252
+ */
2253
+ constructor(token) {
2254
+ this.bot = new Telegraf(token);
2255
+ this.logger = createChildLogger("bot");
2256
+ }
2257
+ // -----------------------------------------------------------------------
2258
+ // Initialisation
2259
+ // -----------------------------------------------------------------------
2260
+ /**
2261
+ * Register the middleware stack and message / command handlers.
2262
+ *
2263
+ * **Must** be called before {@link launch}. Middleware is applied in the
2264
+ * following order:
2265
+ *
2266
+ * 1. Logging middleware — logs every incoming update
2267
+ * 2. Auth guard middleware — drops unauthorised updates
2268
+ * 3. Error-handling middleware — catches downstream errors
2269
+ * 4. Command handler (if `onCommand` provided)
2270
+ * 5. Text-message handler
2271
+ * 6. Catch-all for unhandled update types
2272
+ */
2273
+ initialize(options) {
2274
+ const { allowedUserId, onMessage, onCommand } = options;
2275
+ this.bot.use(createLoggingMiddleware());
2276
+ this.bot.use(createAuthMiddleware(allowedUserId));
2277
+ this.bot.use(async (ctx, next) => {
2278
+ try {
2279
+ await next();
2280
+ } catch (err) {
2281
+ const errorMessage = err instanceof Error ? err.message : String(err);
2282
+ this.logger.error(
2283
+ { err, updateId: ctx.update.update_id },
2284
+ "Unhandled error in middleware chain"
2285
+ );
2286
+ try {
2287
+ await ctx.reply(
2288
+ "\u26A0\uFE0F An unexpected error occurred. Please try again later."
2289
+ );
2290
+ } catch (replyErr) {
2291
+ this.logger.error(
2292
+ { err: replyErr },
2293
+ "Failed to send error reply to user"
2294
+ );
2295
+ }
2296
+ }
2297
+ });
2298
+ if (onCommand) {
2299
+ this.bot.on(message("text"), async (ctx, next) => {
2300
+ const text = ctx.message.text;
2301
+ if (!text.startsWith("/")) {
2302
+ return next();
2303
+ }
2304
+ const parts = text.slice(1).split(/\s+/);
2305
+ const command = parts[0] ?? "";
2306
+ const cleanCommand = command.split("@")[0];
2307
+ const args = parts.slice(1).join(" ");
2308
+ onCommand(ctx, cleanCommand, args).catch((err) => {
2309
+ this.logger.error({ err, command: cleanCommand }, "Unhandled error in command handler");
2310
+ });
2311
+ });
2312
+ }
2313
+ this.bot.on(message("text"), async (ctx) => {
2314
+ onMessage(ctx, ctx.message.text).catch((err) => {
2315
+ this.logger.error({ err }, "Unhandled error in message handler");
2316
+ });
2317
+ });
2318
+ this.bot.on("message", (ctx) => {
2319
+ this.logger.debug(
2320
+ { updateType: ctx.updateType, messageType: "non-text" },
2321
+ "Received non-text message \u2014 ignoring"
2322
+ );
2323
+ });
2324
+ this.logger.info("Bot initialised \u2014 middleware and handlers registered");
2325
+ }
2326
+ // -----------------------------------------------------------------------
2327
+ // Lifecycle
2328
+ // -----------------------------------------------------------------------
2329
+ /**
2330
+ * Start the bot in long-polling mode.
2331
+ *
2332
+ * Telegraf's `launch()` never resolves — it runs the polling loop forever.
2333
+ * We use the `onLaunch` callback to detect when the initial handshake
2334
+ * (getMe + deleteWebhook) succeeds, then let polling continue in the
2335
+ * background.
2336
+ *
2337
+ * @throws {Error} If the Telegram API is unreachable after all retry attempts.
2338
+ */
2339
+ async launch() {
2340
+ if (this.isRunning) {
2341
+ this.logger.warn("launch() called but bot is already running");
2342
+ return;
2343
+ }
2344
+ const maxRetries = 3;
2345
+ const retryDelayMs = 5e3;
2346
+ const connectTimeoutMs = 3e4;
2347
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
2348
+ try {
2349
+ await new Promise((resolve, reject) => {
2350
+ const timer = setTimeout(
2351
+ () => reject(Object.assign(
2352
+ new Error("Telegram connection timed out"),
2353
+ { code: "ETIMEDOUT" }
2354
+ )),
2355
+ connectTimeoutMs
2356
+ );
2357
+ this.bot.launch(() => {
2358
+ clearTimeout(timer);
2359
+ resolve();
2360
+ }).catch((err) => {
2361
+ clearTimeout(timer);
2362
+ reject(err);
2363
+ });
2364
+ });
2365
+ this.isRunning = true;
2366
+ const botInfo = this.bot.botInfo;
2367
+ if (botInfo) {
2368
+ this.logger.info(
2369
+ { username: botInfo.username },
2370
+ `Bot launched as @${botInfo.username}`
2371
+ );
2372
+ } else {
2373
+ this.logger.info("Bot launched (username not yet available)");
2374
+ }
2375
+ return;
2376
+ } catch (err) {
2377
+ const msg = err instanceof Error ? err.message : String(err);
2378
+ const code = err.code;
2379
+ const isNetworkError = code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN" || code === "ENETUNREACH";
2380
+ if (isNetworkError && attempt < maxRetries) {
2381
+ console.log(` \u26A0 Telegram connection failed (${code}). Retrying in ${retryDelayMs / 1e3}s\u2026 (${attempt}/${maxRetries})`);
2382
+ this.logger.warn(
2383
+ { attempt, maxRetries, code },
2384
+ `Telegram connection failed (${code}). Retrying in ${retryDelayMs / 1e3}s\u2026`
2385
+ );
2386
+ await new Promise((r) => setTimeout(r, retryDelayMs));
2387
+ continue;
2388
+ }
2389
+ this.logger.error({ err, code }, `Failed to connect to Telegram API: ${msg}`);
2390
+ throw new Error(
2391
+ `Could not connect to Telegram (${code || "UNKNOWN"}): ${msg}
2392
+ \u2192 Check your internet connection and bot token.
2393
+ \u2192 Verify the token with: https://api.telegram.org/bot<TOKEN>/getMe`
2394
+ );
2395
+ }
2396
+ }
2397
+ }
2398
+ /**
2399
+ * Stop the bot gracefully.
2400
+ *
2401
+ * Safe to call multiple times — subsequent calls are no-ops.
2402
+ */
2403
+ async stop() {
2404
+ if (!this.isRunning) {
2405
+ this.logger.debug("stop() called but bot is not running");
2406
+ return;
2407
+ }
2408
+ this.bot.stop("graceful shutdown");
2409
+ this.isRunning = false;
2410
+ this.logger.info("Bot stopped");
2411
+ }
2412
+ // -----------------------------------------------------------------------
2413
+ // Accessors
2414
+ // -----------------------------------------------------------------------
2415
+ /**
2416
+ * Return the underlying Telegraf instance for advanced or low-level usage.
2417
+ */
2418
+ getBot() {
2419
+ return this.bot;
2420
+ }
2421
+ /**
2422
+ * Check whether the bot is currently running (polling).
2423
+ */
2424
+ isActive() {
2425
+ return this.isRunning;
2426
+ }
2427
+ };
2428
+ function createBot(token) {
2429
+ return new TelegramBot(token);
2430
+ }
2431
+
2432
+ // src/bot/handlers/message.ts
2433
+ var logger6 = createChildLogger("bot:handlers");
2434
+ function splitMessage(text, maxLength = 4096) {
2435
+ if (text.length <= maxLength) {
2436
+ return [text];
2437
+ }
2438
+ const chunks = [];
2439
+ let remaining = text;
2440
+ while (remaining.length > 0) {
2441
+ if (remaining.length <= maxLength) {
2442
+ chunks.push(remaining);
2443
+ break;
2444
+ }
2445
+ let splitIdx = remaining.lastIndexOf("\n\n", maxLength);
2446
+ if (splitIdx <= 0) {
2447
+ splitIdx = remaining.lastIndexOf("\n", maxLength);
2448
+ }
2449
+ if (splitIdx <= 0) {
2450
+ splitIdx = maxLength;
2451
+ }
2452
+ chunks.push(remaining.slice(0, splitIdx));
2453
+ remaining = remaining.slice(splitIdx).replace(/^\n+/, "");
2454
+ }
2455
+ return chunks.filter((c) => c.length > 0);
2456
+ }
2457
+ function createMessageHandler(deps) {
2458
+ const { sessionManager: sessionManager2, conversationRepo } = deps;
2459
+ return async (ctx, text) => {
2460
+ const replyToId = ctx.message?.message_id;
2461
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
2462
+ await ctx.sendChatAction("typing");
2463
+ if (sessionManager2.getAvailableCount() === 0) {
2464
+ await ctx.reply("\u23F3 All AI sessions busy \u2014 your message is queued.", replyOpts);
2465
+ }
2466
+ conversationRepo.addMessage("user", text);
2467
+ logger6.debug({ textLength: text.length }, "User message stored");
2468
+ if (!sessionManager2.isActive()) {
2469
+ logger6.error("No active AI session \u2014 cannot process message");
2470
+ await ctx.reply("\u26A0\uFE0F AI session is not active. Please restart the bot.", replyOpts);
2471
+ return;
2472
+ }
2473
+ const typingInterval = setInterval(() => {
2474
+ ctx.sendChatAction("typing").catch(() => {
2475
+ });
2476
+ }, 4e3);
2477
+ try {
2478
+ const response = await sessionManager2.sendMessage(text);
2479
+ if (!response) {
2480
+ logger6.warn("AI returned an empty response");
2481
+ await ctx.reply("The AI returned an empty response. Please try again.", replyOpts);
2482
+ return;
2483
+ }
2484
+ const model = sessionManager2.getCurrentModel();
2485
+ conversationRepo.addMessage("assistant", response, model);
2486
+ logger6.debug(
2487
+ { responseLength: response.length, model },
2488
+ "Assistant response stored"
2489
+ );
2490
+ const chunks = splitMessage(response);
2491
+ for (const chunk of chunks) {
2492
+ await ctx.reply(chunk, replyOpts);
2493
+ }
2494
+ } catch (error) {
2495
+ const reason = error instanceof Error ? error.message : String(error);
2496
+ logger6.error({ err: error }, "Failed to process message through AI");
2497
+ await ctx.reply("Sorry, I couldn't process that. Please try again.", replyOpts);
2498
+ } finally {
2499
+ clearInterval(typingInterval);
2500
+ }
2501
+ };
2502
+ }
2503
+
2504
+ // src/core/heartbeat.ts
2505
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync, existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
2506
+ import { join, basename } from "path";
2507
+ var HEARTBEATS_DIR = "./heartbeats";
2508
+ var HEARTBEAT_EXT = ".heartbeat.md";
2509
+ var STATE_EXT = ".state.json";
2510
+ var DEDUP_PLACEHOLDER = "{{DEDUP_STATE}}";
2511
+ var MAX_STATE_IDS = 200;
2512
+ var PROCESSED_MARKER_RE = /<!--\s*PROCESSED:\s*(.*?)\s*-->/gi;
2513
+ var HeartbeatManager = class {
2514
+ logger;
2515
+ timer = null;
2516
+ isRunning = false;
2517
+ /** Prevents overlapping cycles when a run takes longer than the interval. */
2518
+ cycleInProgress = false;
2519
+ constructor() {
2520
+ this.logger = createChildLogger("heartbeat");
2521
+ ensureHeartbeatsDir();
2522
+ }
2523
+ // -----------------------------------------------------------------------
2524
+ // File operations
2525
+ // -----------------------------------------------------------------------
2526
+ /**
2527
+ * Discover all heartbeat event files in the heartbeats directory.
2528
+ *
2529
+ * @returns Array of {@link HeartbeatEvent} objects, one per file.
2530
+ */
2531
+ listEvents() {
2532
+ ensureHeartbeatsDir();
2533
+ const files = readdirSync2(HEARTBEATS_DIR).filter((f) => f.endsWith(HEARTBEAT_EXT));
2534
+ return files.map((file) => {
2535
+ const filePath = join(HEARTBEATS_DIR, file);
2536
+ const name = basename(file, HEARTBEAT_EXT);
2537
+ const prompt = readFileSync4(filePath, "utf-8").trim();
2538
+ return { name, prompt, filePath };
2539
+ });
2540
+ }
2541
+ /**
2542
+ * Add a new heartbeat event by creating a `.heartbeat.md` file.
2543
+ *
2544
+ * @param name - Event name (used as the filename, kebab-cased).
2545
+ * @param prompt - The prompt text to send to the AI on each heartbeat.
2546
+ * @throws {Error} If an event with the same name already exists.
2547
+ */
2548
+ addEvent(name, prompt) {
2549
+ ensureHeartbeatsDir();
2550
+ const safeName = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
2551
+ const filePath = join(HEARTBEATS_DIR, `${safeName}${HEARTBEAT_EXT}`);
2552
+ if (existsSync5(filePath)) {
2553
+ throw new Error(`Heartbeat event "${safeName}" already exists at ${filePath}`);
2554
+ }
2555
+ writeFileSync3(filePath, prompt.trim() + "\n", "utf-8");
2556
+ this.logger.info({ name: safeName, filePath }, "Heartbeat event created");
2557
+ }
2558
+ /**
2559
+ * Remove a heartbeat event by name.
2560
+ *
2561
+ * @param name - The event name to remove.
2562
+ * @throws {Error} If the event does not exist.
2563
+ */
2564
+ removeEvent(name) {
2565
+ const safeName = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
2566
+ const filePath = join(HEARTBEATS_DIR, `${safeName}${HEARTBEAT_EXT}`);
2567
+ if (!existsSync5(filePath)) {
2568
+ throw new Error(`Heartbeat event "${safeName}" not found`);
2569
+ }
2570
+ unlinkSync(filePath);
2571
+ const statePath = join(HEARTBEATS_DIR, `${safeName}${STATE_EXT}`);
2572
+ if (existsSync5(statePath)) {
2573
+ unlinkSync(statePath);
2574
+ }
2575
+ this.logger.info({ name: safeName }, "Heartbeat event removed");
2576
+ }
2577
+ // -----------------------------------------------------------------------
2578
+ // Deduplication state
2579
+ // -----------------------------------------------------------------------
2580
+ /**
2581
+ * Load the persisted deduplication state for an event.
2582
+ *
2583
+ * @param eventName - The event name (kebab-cased, no extension).
2584
+ * @returns The stored state, or a fresh empty state if none exists.
2585
+ */
2586
+ loadState(eventName) {
2587
+ const statePath = join(HEARTBEATS_DIR, `${eventName}${STATE_EXT}`);
2588
+ if (!existsSync5(statePath)) {
2589
+ return { processedIds: [], lastRun: null };
2590
+ }
2591
+ try {
2592
+ const raw = readFileSync4(statePath, "utf-8");
2593
+ const parsed = JSON.parse(raw);
2594
+ return {
2595
+ processedIds: Array.isArray(parsed.processedIds) ? parsed.processedIds : [],
2596
+ lastRun: parsed.lastRun ?? null
2597
+ };
2598
+ } catch (err) {
2599
+ this.logger.warn({ err, eventName }, "Failed to parse state file \u2014 resetting");
2600
+ return { processedIds: [], lastRun: null };
2601
+ }
2602
+ }
2603
+ /**
2604
+ * Persist deduplication state for an event. Caps stored IDs at
2605
+ * {@link MAX_STATE_IDS} to prevent the file from growing unboundedly.
2606
+ *
2607
+ * @param eventName - The event name (kebab-cased, no extension).
2608
+ * @param state - The state to persist.
2609
+ */
2610
+ saveState(eventName, state) {
2611
+ const statePath = join(HEARTBEATS_DIR, `${eventName}${STATE_EXT}`);
2612
+ const trimmed = {
2613
+ processedIds: state.processedIds.slice(-MAX_STATE_IDS),
2614
+ lastRun: state.lastRun
2615
+ };
2616
+ writeFileSync3(statePath, JSON.stringify(trimmed, null, 2) + "\n", "utf-8");
2617
+ this.logger.debug({ eventName, idCount: trimmed.processedIds.length }, "State saved");
2618
+ }
2619
+ /**
2620
+ * Inject deduplication context into a prompt. If the prompt contains the
2621
+ * `{{DEDUP_STATE}}` placeholder, it is replaced with a block listing the
2622
+ * previously-processed IDs. If no placeholder is present, the prompt is
2623
+ * returned unchanged.
2624
+ *
2625
+ * @param prompt - The raw prompt text from the `.heartbeat.md` file.
2626
+ * @param state - The loaded deduplication state.
2627
+ * @returns The prompt with dedup context injected.
2628
+ */
2629
+ injectDedupContext(prompt, state) {
2630
+ if (!prompt.includes(DEDUP_PLACEHOLDER)) {
2631
+ return prompt;
2632
+ }
2633
+ if (state.processedIds.length === 0) {
2634
+ return prompt.replace(DEDUP_PLACEHOLDER, "No previously processed items \u2014 this is the first run.");
2635
+ }
2636
+ const idList = state.processedIds.map((id) => `- ${id}`).join("\n");
2637
+ const context = [
2638
+ `Previously processed IDs (${state.processedIds.length} total) \u2014 SKIP these:`,
2639
+ idList
2640
+ ].join("\n");
2641
+ return prompt.replace(DEDUP_PLACEHOLDER, context);
2642
+ }
2643
+ /**
2644
+ * Extract newly-processed item IDs from the AI's response. Looks for
2645
+ * `<!-- PROCESSED: id1, id2, id3 -->` markers anywhere in the text.
2646
+ *
2647
+ * @param response - The full AI response text.
2648
+ * @returns Array of extracted IDs (may be empty if no marker found).
2649
+ */
2650
+ extractProcessedIds(response) {
2651
+ const ids = [];
2652
+ let match;
2653
+ while ((match = PROCESSED_MARKER_RE.exec(response)) !== null) {
2654
+ const raw = match[1] ?? "";
2655
+ for (const id of raw.split(",")) {
2656
+ const trimmed = id.trim();
2657
+ if (trimmed) ids.push(trimmed);
2658
+ }
2659
+ }
2660
+ PROCESSED_MARKER_RE.lastIndex = 0;
2661
+ return ids;
2662
+ }
2663
+ // -----------------------------------------------------------------------
2664
+ // Scheduling
2665
+ // -----------------------------------------------------------------------
2666
+ /**
2667
+ * Start the heartbeat scheduler.
2668
+ *
2669
+ * Runs all heartbeat events immediately on first tick, then repeats at the
2670
+ * configured interval.
2671
+ *
2672
+ * @param intervalMinutes - Minutes between each heartbeat cycle.
2673
+ * @param sendFn - Function that sends a prompt to the AI and returns the response.
2674
+ * @param notifyFn - Function that delivers the AI response to the user.
2675
+ */
2676
+ start(intervalMinutes, sendFn, notifyFn) {
2677
+ if (this.isRunning) {
2678
+ this.logger.warn("Heartbeat scheduler already running");
2679
+ return;
2680
+ }
2681
+ if (intervalMinutes <= 0) {
2682
+ this.logger.info("Heartbeat disabled (interval is 0)");
2683
+ return;
2684
+ }
2685
+ const events = this.listEvents();
2686
+ if (events.length === 0) {
2687
+ this.logger.info("No heartbeat events found \u2014 scheduler idle");
2688
+ }
2689
+ const intervalMs = intervalMinutes * 60 * 1e3;
2690
+ this.isRunning = true;
2691
+ this.logger.info(
2692
+ { intervalMinutes, eventCount: events.length },
2693
+ `Heartbeat scheduler started (every ${intervalMinutes} min, ${events.length} events)`
2694
+ );
2695
+ const runCycle = async () => {
2696
+ if (this.cycleInProgress) {
2697
+ this.logger.debug("Skipping heartbeat cycle \u2014 previous cycle still in progress");
2698
+ return;
2699
+ }
2700
+ this.cycleInProgress = true;
2701
+ try {
2702
+ const currentEvents = this.listEvents();
2703
+ if (currentEvents.length === 0) return;
2704
+ this.logger.debug({ count: currentEvents.length }, "Running heartbeat cycle");
2705
+ for (const event of currentEvents) {
2706
+ try {
2707
+ this.logger.debug({ event: event.name }, `Executing heartbeat: ${event.name}`);
2708
+ const useDedup = event.prompt.includes(DEDUP_PLACEHOLDER);
2709
+ const state = useDedup ? this.loadState(event.name) : null;
2710
+ const finalPrompt = state ? this.injectDedupContext(event.prompt, state) : event.prompt;
2711
+ const response = await sendFn(finalPrompt);
2712
+ if (response) {
2713
+ if (useDedup && state) {
2714
+ const newIds = this.extractProcessedIds(response);
2715
+ if (newIds.length > 0) {
2716
+ const existing = new Set(state.processedIds);
2717
+ const uniqueNew = newIds.filter((id) => !existing.has(id));
2718
+ if (uniqueNew.length > 0) {
2719
+ state.processedIds.push(...uniqueNew);
2720
+ state.lastRun = (/* @__PURE__ */ new Date()).toISOString();
2721
+ this.saveState(event.name, state);
2722
+ this.logger.info(
2723
+ { event: event.name, newIds: uniqueNew.length, totalIds: state.processedIds.length },
2724
+ `Dedup: recorded ${uniqueNew.length} new IDs for "${event.name}"`
2725
+ );
2726
+ }
2727
+ }
2728
+ }
2729
+ const cleanResponse = response.replace(PROCESSED_MARKER_RE, "").trim();
2730
+ PROCESSED_MARKER_RE.lastIndex = 0;
2731
+ if (cleanResponse) {
2732
+ await notifyFn(event.name, cleanResponse);
2733
+ }
2734
+ this.logger.info({ event: event.name }, `Heartbeat "${event.name}" completed`);
2735
+ } else {
2736
+ this.logger.warn({ event: event.name }, `Heartbeat "${event.name}" returned no response`);
2737
+ }
2738
+ } catch (err) {
2739
+ this.logger.error(
2740
+ { err, event: event.name },
2741
+ `Heartbeat "${event.name}" failed`
2742
+ );
2743
+ }
2744
+ }
2745
+ } finally {
2746
+ this.cycleInProgress = false;
2747
+ }
2748
+ };
2749
+ this.timer = setInterval(() => {
2750
+ runCycle().catch((err) => {
2751
+ this.logger.error({ err }, "Heartbeat cycle failed");
2752
+ });
2753
+ }, intervalMs);
2754
+ }
2755
+ /**
2756
+ * Stop the heartbeat scheduler.
2757
+ */
2758
+ stop() {
2759
+ if (this.timer) {
2760
+ clearInterval(this.timer);
2761
+ this.timer = null;
2762
+ }
2763
+ this.isRunning = false;
2764
+ this.cycleInProgress = false;
2765
+ this.logger.info("Heartbeat scheduler stopped");
2766
+ }
2767
+ /**
2768
+ * Check whether the scheduler is currently running.
2769
+ */
2770
+ isActive() {
2771
+ return this.isRunning;
2772
+ }
2773
+ };
2774
+ function ensureHeartbeatsDir() {
2775
+ if (!existsSync5(HEARTBEATS_DIR)) {
2776
+ mkdirSync3(HEARTBEATS_DIR, { recursive: true });
2777
+ }
2778
+ }
2779
+
2780
+ // src/core/gc.ts
2781
+ var DEFAULT_INTERVAL_MINUTES = 30;
2782
+ var DEFAULT_CONVERSATION_RETENTION_DAYS = 30;
2783
+ var DEFAULT_HEALTH_RETENTION_DAYS = 7;
2784
+ var GarbageCollector = class {
2785
+ logger;
2786
+ timer = null;
2787
+ db = null;
2788
+ intervalMinutes;
2789
+ conversationRetentionDays;
2790
+ healthRetentionDays;
2791
+ constructor(options) {
2792
+ this.logger = createChildLogger("gc");
2793
+ this.intervalMinutes = options?.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES;
2794
+ this.conversationRetentionDays = options?.conversationRetentionDays ?? DEFAULT_CONVERSATION_RETENTION_DAYS;
2795
+ this.healthRetentionDays = options?.healthRetentionDays ?? DEFAULT_HEALTH_RETENTION_DAYS;
2796
+ }
2797
+ // -----------------------------------------------------------------------
2798
+ // Lifecycle
2799
+ // -----------------------------------------------------------------------
2800
+ /**
2801
+ * Start the periodic GC timer.
2802
+ *
2803
+ * @param db - The SQLite database handle to prune.
2804
+ */
2805
+ start(db) {
2806
+ this.db = db;
2807
+ if (this.intervalMinutes <= 0) {
2808
+ this.logger.info("Garbage collector disabled (interval = 0)");
2809
+ return;
2810
+ }
2811
+ this.runCycle();
2812
+ const intervalMs = this.intervalMinutes * 6e4;
2813
+ this.timer = setInterval(() => this.runCycle(), intervalMs);
2814
+ if (this.timer.unref) this.timer.unref();
2815
+ this.logger.info(
2816
+ {
2817
+ intervalMinutes: this.intervalMinutes,
2818
+ conversationRetentionDays: this.conversationRetentionDays,
2819
+ healthRetentionDays: this.healthRetentionDays
2820
+ },
2821
+ `GC started (every ${this.intervalMinutes} min)`
2822
+ );
2823
+ }
2824
+ /**
2825
+ * Stop the periodic GC timer.
2826
+ */
2827
+ stop() {
2828
+ if (this.timer) {
2829
+ clearInterval(this.timer);
2830
+ this.timer = null;
2831
+ this.logger.info("GC stopped");
2832
+ }
2833
+ }
2834
+ // -----------------------------------------------------------------------
2835
+ // GC cycle
2836
+ // -----------------------------------------------------------------------
2837
+ /**
2838
+ * Execute a single GC cycle: prune old records and log memory stats.
2839
+ */
2840
+ runCycle() {
2841
+ const start = Date.now();
2842
+ try {
2843
+ const conversationsPruned = this.pruneConversations();
2844
+ const healthPruned = this.prunePluginHealth();
2845
+ const memStats = this.getMemoryStats();
2846
+ const elapsed = Date.now() - start;
2847
+ this.logger.info(
2848
+ {
2849
+ conversationsPruned,
2850
+ healthPruned,
2851
+ heapUsedMB: memStats.heapUsedMB,
2852
+ rssMB: memStats.rssMB,
2853
+ elapsed
2854
+ },
2855
+ `GC cycle complete \u2014 pruned ${conversationsPruned} conversations, ${healthPruned} health records (${elapsed}ms)`
2856
+ );
2857
+ } catch (err) {
2858
+ this.logger.error({ err }, "GC cycle failed");
2859
+ }
2860
+ }
2861
+ // -----------------------------------------------------------------------
2862
+ // Pruning operations
2863
+ // -----------------------------------------------------------------------
2864
+ /**
2865
+ * Delete conversation messages older than the retention window.
2866
+ * @returns Number of rows deleted.
2867
+ */
2868
+ pruneConversations() {
2869
+ if (!this.db) return 0;
2870
+ try {
2871
+ const result = this.db.prepare(
2872
+ `DELETE FROM conversations
2873
+ WHERE created_at < datetime('now', '-' || ? || ' days')`
2874
+ ).run(this.conversationRetentionDays);
2875
+ return result.changes;
2876
+ } catch (err) {
2877
+ this.logger.error({ err }, "Failed to prune conversations");
2878
+ return 0;
2879
+ }
2880
+ }
2881
+ /**
2882
+ * Delete plugin health records older than the retention window.
2883
+ * @returns Number of rows deleted.
2884
+ */
2885
+ prunePluginHealth() {
2886
+ if (!this.db) return 0;
2887
+ try {
2888
+ const result = this.db.prepare(
2889
+ `DELETE FROM plugin_health
2890
+ WHERE checked_at < datetime('now', '-' || ? || ' days')`
2891
+ ).run(this.healthRetentionDays);
2892
+ return result.changes;
2893
+ } catch (err) {
2894
+ this.logger.error({ err }, "Failed to prune plugin health records");
2895
+ return 0;
2896
+ }
2897
+ }
2898
+ // -----------------------------------------------------------------------
2899
+ // Memory monitoring
2900
+ // -----------------------------------------------------------------------
2901
+ /**
2902
+ * Capture current process memory usage.
2903
+ * @returns Object with heap and RSS in megabytes.
2904
+ */
2905
+ getMemoryStats() {
2906
+ const mem = process.memoryUsage();
2907
+ return {
2908
+ heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10,
2909
+ heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10,
2910
+ rssMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
2911
+ externalMB: Math.round(mem.external / 1024 / 1024 * 10) / 10
2912
+ };
2913
+ }
2914
+ };
2915
+
2916
+ // src/core/app.ts
2917
+ dns.setDefaultResultOrder("ipv4first");
2918
+ var App = class {
2919
+ logger;
2920
+ bot;
2921
+ pluginManager;
2922
+ heartbeatManager;
2923
+ gc;
2924
+ isShuttingDown = false;
2925
+ verbose = false;
2926
+ constructor() {
2927
+ this.logger = createChildLogger("app");
2928
+ }
2929
+ // -----------------------------------------------------------------------
2930
+ // Startup
2931
+ // -----------------------------------------------------------------------
2932
+ /**
2933
+ * Boot the entire application.
2934
+ *
2935
+ * Initialisation order:
2936
+ * 1. Configuration & logging
2937
+ * 2. Database & repositories
2938
+ * 3. Model registry
2939
+ * 4. Plugin system (registry → manager)
2940
+ * 5. Copilot AI client & session
2941
+ * 6. Telegram bot (handlers → launch)
2942
+ * 7. OS signal handlers for graceful shutdown
2943
+ *
2944
+ * @param options - Optional start-up flags.
2945
+ */
2946
+ async start(options) {
2947
+ console.log(" \u25B8 Loading configuration\u2026");
2948
+ const config = getConfig();
2949
+ this.logger.info("Configuration loaded");
2950
+ if (options?.verbose) {
2951
+ setLogLevel("debug");
2952
+ this.verbose = true;
2953
+ this.logger.debug("Verbose logging enabled");
2954
+ }
2955
+ console.log(" \u25B8 Initializing database\u2026");
2956
+ const db = getDatabase();
2957
+ this.logger.info("Database ready");
2958
+ const conversationRepo = new ConversationRepository(db);
2959
+ const preferencesRepo = new PreferencesRepository(db);
2960
+ const pluginStateRepo = new PluginStateRepository(db);
2961
+ this.logger.info("Repositories created");
2962
+ const gc = new GarbageCollector({
2963
+ intervalMinutes: 30,
2964
+ conversationRetentionDays: 30,
2965
+ healthRetentionDays: 7
2966
+ });
2967
+ gc.start(db);
2968
+ this.gc = gc;
2969
+ const modelRegistry = createModelRegistry(preferencesRepo);
2970
+ this.logger.info("Model registry initialised");
2971
+ console.log(" \u25B8 Discovering plugins\u2026");
2972
+ const pluginRegistry = createPluginRegistry();
2973
+ await pluginRegistry.discoverPlugins();
2974
+ const pluginManager = createPluginManager(
2975
+ pluginRegistry,
2976
+ pluginSandbox,
2977
+ credentialManager,
2978
+ pluginStateRepo
2979
+ );
2980
+ await pluginManager.initialize();
2981
+ this.pluginManager = pluginManager;
2982
+ this.logger.info("Plugin system ready");
2983
+ console.log(" \u25B8 Starting Copilot SDK client\u2026");
2984
+ await copilotClient.start();
2985
+ const currentModel = modelRegistry.getCurrentModelId();
2986
+ const activePlugins = pluginManager.getActivePlugins();
2987
+ const pluginTools = pluginManager.getAllTools();
2988
+ const poolSize = Math.max(1, parseInt(config.env.AI_SESSION_POOL_SIZE || "3", 10));
2989
+ console.log(` \u25B8 Creating AI session pool (model: ${currentModel}, sessions: ${poolSize})\u2026`);
2990
+ await sessionManager.createSession(currentModel, pluginTools, poolSize);
2991
+ console.log(" \u25B8 Connecting to Telegram\u2026");
2992
+ const bot = createBot(config.env.TELEGRAM_BOT_TOKEN);
2993
+ const rawMessageHandler = createMessageHandler({
2994
+ sessionManager,
2995
+ conversationRepo
2996
+ });
2997
+ const messageHandler = async (ctx, text) => {
2998
+ const ts = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
2999
+ const preview = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
3000
+ if (this.verbose) {
3001
+ console.log(` \u21E3 [${ts()}] Message received: "${preview}"`);
3002
+ }
3003
+ const startTime = Date.now();
3004
+ await rawMessageHandler(ctx, text);
3005
+ if (this.verbose) {
3006
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
3007
+ console.log(` \u21E1 [${ts()}] Message completed (${elapsed}s)`);
3008
+ }
3009
+ };
3010
+ const heartbeatManager = new HeartbeatManager();
3011
+ this.heartbeatManager = heartbeatManager;
3012
+ const runHeartbeatOnDemand = async (ctx, eventName) => {
3013
+ const replyToId = ctx.message?.message_id;
3014
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
3015
+ const allEvents = heartbeatManager.listEvents();
3016
+ if (allEvents.length === 0) {
3017
+ await ctx.reply("No heartbeat events configured.\nAdd one with: co-assistant heartbeat add", replyOpts);
3018
+ return;
3019
+ }
3020
+ let eventsToRun = allEvents;
3021
+ if (eventName) {
3022
+ const match = allEvents.find((e) => e.name === eventName);
3023
+ if (!match) {
3024
+ const names = allEvents.map((e) => e.name).join(", ");
3025
+ await ctx.reply(`\u274C Heartbeat "${eventName}" not found.
3026
+ Available: ${names}`, replyOpts);
3027
+ return;
3028
+ }
3029
+ eventsToRun = [match];
3030
+ }
3031
+ await ctx.reply(`\u{1F680} Running ${eventsToRun.length} heartbeat event(s)\u2026`, replyOpts);
3032
+ const typingInterval = setInterval(() => {
3033
+ ctx.sendChatAction("typing").catch(() => {
3034
+ });
3035
+ }, 4e3);
3036
+ try {
3037
+ for (const event of eventsToRun) {
3038
+ try {
3039
+ const useDedup = event.prompt.includes("{{DEDUP_STATE}}");
3040
+ const state = useDedup ? heartbeatManager.loadState(event.name) : null;
3041
+ let finalPrompt = event.prompt;
3042
+ if (state) {
3043
+ finalPrompt = event.prompt.replace(
3044
+ "{{DEDUP_STATE}}",
3045
+ state.processedIds.length > 0 ? `Previously processed IDs (${state.processedIds.length} total) \u2014 SKIP these:
3046
+ ${state.processedIds.map((id) => `- ${id}`).join("\n")}` : "No previously processed items \u2014 this is the first run."
3047
+ );
3048
+ }
3049
+ const response = await sessionManager.sendMessage(finalPrompt);
3050
+ if (!response) {
3051
+ await ctx.reply(`\u26A0\uFE0F Heartbeat "${event.name}" returned no response.`, replyOpts);
3052
+ continue;
3053
+ }
3054
+ const processedRe = /<!--\s*PROCESSED:\s*(.*?)\s*-->/gi;
3055
+ if (useDedup && state) {
3056
+ const existing = new Set(state.processedIds);
3057
+ let m;
3058
+ while ((m = processedRe.exec(response)) !== null) {
3059
+ for (const id of (m[1] ?? "").split(",")) {
3060
+ const t = id.trim();
3061
+ if (t && !existing.has(t)) {
3062
+ state.processedIds.push(t);
3063
+ existing.add(t);
3064
+ }
3065
+ }
3066
+ }
3067
+ processedRe.lastIndex = 0;
3068
+ state.lastRun = (/* @__PURE__ */ new Date()).toISOString();
3069
+ heartbeatManager.saveState(event.name, state);
3070
+ }
3071
+ const clean = response.replace(/<!--\s*PROCESSED:\s*.*?\s*-->/gi, "").trim();
3072
+ if (clean) {
3073
+ const header = `\u{1F493} *Heartbeat: ${event.name}*
3074
+
3075
+ `;
3076
+ const chunks = splitMessage(header + clean);
3077
+ for (const chunk of chunks) {
3078
+ await ctx.reply(chunk, { parse_mode: "Markdown", ...replyOpts });
3079
+ }
3080
+ }
3081
+ } catch (err) {
3082
+ const msg = err instanceof Error ? err.message : String(err);
3083
+ this.logger.error({ err, event: event.name }, "On-demand heartbeat failed");
3084
+ await ctx.reply(`\u274C Heartbeat "${event.name}" failed: ${msg}`, replyOpts);
3085
+ }
3086
+ }
3087
+ } finally {
3088
+ clearInterval(typingInterval);
3089
+ }
3090
+ };
3091
+ const commandHandler = async (ctx, command, args) => {
3092
+ const ts = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
3093
+ const replyToId = ctx.message?.message_id;
3094
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
3095
+ if (this.verbose) {
3096
+ console.log(` \u21E3 [${ts()}] Command received: /${command}${args ? " " + args : ""}`);
3097
+ }
3098
+ const startTime = Date.now();
3099
+ switch (command) {
3100
+ case "heartbeat":
3101
+ case "hb":
3102
+ await runHeartbeatOnDemand(ctx, args.trim() || void 0);
3103
+ break;
3104
+ case "help":
3105
+ await ctx.reply(
3106
+ "\u{1F916} *Co-Assistant Commands*\n\n/heartbeat \\[name\\] \u2014 Run heartbeat event\\(s\\)\n/hb \\[name\\] \u2014 Shorthand for /heartbeat\n/help \u2014 Show this message\n\nOr just send a message to chat with the AI\\.",
3107
+ { parse_mode: "MarkdownV2", ...replyOpts }
3108
+ );
3109
+ break;
3110
+ default:
3111
+ await messageHandler(ctx, `/${command} ${args}`.trim());
3112
+ }
3113
+ if (this.verbose) {
3114
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
3115
+ console.log(` \u21E1 [${ts()}] Command completed: /${command} (${elapsed}s)`);
3116
+ }
3117
+ };
3118
+ bot.initialize({
3119
+ allowedUserId: Number(config.env.TELEGRAM_USER_ID),
3120
+ onMessage: messageHandler,
3121
+ onCommand: commandHandler
3122
+ });
3123
+ try {
3124
+ await bot.launch();
3125
+ } catch (err) {
3126
+ const msg = err instanceof Error ? err.message : String(err);
3127
+ this.logger.error({ err }, "Telegram bot failed to start");
3128
+ console.error(`
3129
+ \u2717 Telegram bot failed to start:
3130
+ ${msg}
3131
+ `);
3132
+ await this.shutdown();
3133
+ return;
3134
+ }
3135
+ this.bot = bot;
3136
+ const pluginCount = activePlugins.size;
3137
+ const botUsername = bot.getBot().botInfo?.username ?? "unknown";
3138
+ const pluginNames = [...activePlugins.keys()].join(", ") || "none";
3139
+ this.logger.info(
3140
+ { model: currentModel, plugins: pluginCount, version: "1.0.0" },
3141
+ `Co-Assistant is running! Model: ${currentModel}, Plugins: ${pluginCount}`
3142
+ );
3143
+ console.log("");
3144
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
3145
+ console.log(" \u2551 \u{1F916} Co-Assistant is running! \u2551");
3146
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
3147
+ console.log("");
3148
+ console.log(` Bot: @${botUsername}`);
3149
+ console.log(` Model: ${currentModel}`);
3150
+ console.log(` Sessions: ${poolSize} (parallel processing)`);
3151
+ console.log(` Plugins: ${pluginNames} (${pluginCount} active)`);
3152
+ const heartbeatInterval = parseInt(config.env.HEARTBEAT_INTERVAL_MINUTES || "0", 10);
3153
+ const heartbeatEvents = heartbeatManager.listEvents();
3154
+ if (heartbeatInterval > 0 && heartbeatEvents.length > 0) {
3155
+ heartbeatManager.start(
3156
+ heartbeatInterval,
3157
+ // Send heartbeat prompt to the AI session
3158
+ async (prompt) => sessionManager.sendMessage(prompt),
3159
+ // Forward the AI's response to the user via Telegram
3160
+ async (eventName, response) => {
3161
+ try {
3162
+ const chatId = config.env.TELEGRAM_USER_ID;
3163
+ const header = `\u{1F493} *Heartbeat: ${eventName}*
3164
+
3165
+ `;
3166
+ const fullMessage = header + response;
3167
+ const chunks = splitMessage(fullMessage);
3168
+ for (const chunk of chunks) {
3169
+ await bot.getBot().telegram.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
3170
+ }
3171
+ } catch (err) {
3172
+ this.logger.error({ err, eventName }, "Failed to send heartbeat response via Telegram");
3173
+ }
3174
+ }
3175
+ );
3176
+ console.log(` Heartbeat: every ${heartbeatInterval} min (${heartbeatEvents.length} events)`);
3177
+ } else if (heartbeatInterval > 0) {
3178
+ console.log(` Heartbeat: every ${heartbeatInterval} min (no events \u2014 add with: co-assistant heartbeat add)`);
3179
+ } else {
3180
+ console.log(" Heartbeat: disabled (set HEARTBEAT_INTERVAL_MINUTES in .env)");
3181
+ }
3182
+ console.log("");
3183
+ console.log(" Open Telegram and send a message to your bot to get started.");
3184
+ console.log(" Press Ctrl+C to stop.\n");
3185
+ const shutdownHandler = async (signal) => {
3186
+ this.logger.info({ signal }, "Received shutdown signal");
3187
+ await this.shutdown();
3188
+ };
3189
+ process.once("SIGINT", () => shutdownHandler("SIGINT"));
3190
+ process.once("SIGTERM", () => shutdownHandler("SIGTERM"));
3191
+ }
3192
+ // -----------------------------------------------------------------------
3193
+ // Shutdown
3194
+ // -----------------------------------------------------------------------
3195
+ /**
3196
+ * Gracefully shut down all subsystems in reverse order.
3197
+ *
3198
+ * Each step is wrapped in its own try/catch so a failure in one subsystem
3199
+ * never prevents the others from cleaning up.
3200
+ */
3201
+ async shutdown() {
3202
+ if (this.isShuttingDown) {
3203
+ this.logger.warn("Shutdown already in progress \u2014 skipping");
3204
+ return;
3205
+ }
3206
+ this.isShuttingDown = true;
3207
+ this.logger.info("Initiating graceful shutdown\u2026");
3208
+ if (this.heartbeatManager) {
3209
+ this.heartbeatManager.stop();
3210
+ this.logger.info("Heartbeat scheduler stopped");
3211
+ }
3212
+ if (this.gc) {
3213
+ this.gc.stop();
3214
+ }
3215
+ try {
3216
+ if (this.bot) {
3217
+ await this.bot.stop();
3218
+ this.logger.info("Telegram bot stopped");
3219
+ }
3220
+ } catch (err) {
3221
+ this.logger.error({ err }, "Error stopping Telegram bot");
3222
+ }
3223
+ try {
3224
+ await sessionManager.closeSession();
3225
+ this.logger.info("AI session closed");
3226
+ } catch (err) {
3227
+ this.logger.error({ err }, "Error closing AI session");
3228
+ }
3229
+ try {
3230
+ await copilotClient.stop();
3231
+ this.logger.info("Copilot client stopped");
3232
+ } catch (err) {
3233
+ this.logger.error({ err }, "Error stopping Copilot client");
3234
+ }
3235
+ try {
3236
+ if (this.pluginManager) {
3237
+ await this.pluginManager.shutdown();
3238
+ this.logger.info("Plugins shut down");
3239
+ }
3240
+ } catch (err) {
3241
+ this.logger.error({ err }, "Error shutting down plugins");
3242
+ }
3243
+ try {
3244
+ closeDatabase();
3245
+ this.logger.info("Database closed");
3246
+ } catch (err) {
3247
+ this.logger.error({ err }, "Error closing database");
3248
+ }
3249
+ this.logger.info("\u{1F44B} Goodbye!");
3250
+ process.exit(0);
3251
+ }
3252
+ };
3253
+ export {
3254
+ App,
3255
+ copilotClient,
3256
+ sessionManager
3257
+ };
3258
+ //# sourceMappingURL=index.js.map