@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.
@@ -0,0 +1,4547 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __esm = (fn, res) => function __init() {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ };
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
22
+
23
+ // src/core/logger.ts
24
+ import pino from "pino";
25
+ function resolveLogLevel() {
26
+ const envLevel = process.env.LOG_LEVEL?.toLowerCase();
27
+ const valid = ["fatal", "error", "warn", "info", "debug", "trace", "silent"];
28
+ return envLevel && valid.includes(envLevel) ? envLevel : "info";
29
+ }
30
+ function buildLoggerOptions() {
31
+ const level = resolveLogLevel();
32
+ const isProduction = process.env.NODE_ENV === "production";
33
+ const opts = { level };
34
+ if (!isProduction) {
35
+ opts.transport = {
36
+ target: "pino-pretty",
37
+ options: {
38
+ colorize: true,
39
+ translateTime: "SYS:HH:MM:ss",
40
+ ignore: "pid,hostname"
41
+ }
42
+ };
43
+ }
44
+ return opts;
45
+ }
46
+ function createChildLogger(name, meta) {
47
+ return logger.child({ component: name, ...meta });
48
+ }
49
+ function setLogLevel(level) {
50
+ logger.level = level;
51
+ }
52
+ var logger;
53
+ var init_logger = __esm({
54
+ "src/core/logger.ts"() {
55
+ "use strict";
56
+ logger = pino(buildLoggerOptions());
57
+ }
58
+ });
59
+
60
+ // src/core/config.ts
61
+ var config_exports = {};
62
+ __export(config_exports, {
63
+ AIConfigSchema: () => AIConfigSchema,
64
+ AppConfigSchema: () => AppConfigSchema,
65
+ BotConfigSchema: () => BotConfigSchema,
66
+ ConfigError: () => ConfigError,
67
+ EnvConfigSchema: () => EnvConfigSchema,
68
+ PluginConfigSchema: () => PluginConfigSchema,
69
+ PluginCredentialEntrySchema: () => PluginCredentialEntrySchema,
70
+ PluginHealthConfigSchema: () => PluginHealthConfigSchema,
71
+ getConfig: () => getConfig,
72
+ loadAppConfig: () => loadAppConfig,
73
+ loadEnvConfig: () => loadEnvConfig,
74
+ resetConfig: () => resetConfig
75
+ });
76
+ import { z } from "zod";
77
+ import dotenv from "dotenv";
78
+ import { readFileSync, writeFileSync, existsSync } from "fs";
79
+ function formatZodErrors(error) {
80
+ return error.issues.map((issue) => {
81
+ const path5 = issue.path.length > 0 ? issue.path.join(".") : "(root)";
82
+ return ` \u2022 ${path5}: ${issue.message}`;
83
+ }).join("\n");
84
+ }
85
+ function loadEnvConfig() {
86
+ const result = dotenv.config();
87
+ if (result.error) {
88
+ console.warn(
89
+ "[config] .env file not found or unreadable \u2014 continuing with existing environment variables"
90
+ );
91
+ }
92
+ const parsed = EnvConfigSchema.safeParse(process.env);
93
+ if (!parsed.success) {
94
+ const details = formatZodErrors(parsed.error);
95
+ throw new ConfigError(
96
+ `Environment variable validation failed:
97
+ ${details}`
98
+ );
99
+ }
100
+ return parsed.data;
101
+ }
102
+ function loadAppConfig(configPath = "./config.json") {
103
+ let raw = {};
104
+ if (existsSync(configPath)) {
105
+ try {
106
+ const content = readFileSync(configPath, "utf-8");
107
+ raw = JSON.parse(content);
108
+ } catch (err) {
109
+ throw new ConfigError(
110
+ `Failed to read or parse config file at "${configPath}": ${err.message}`
111
+ );
112
+ }
113
+ } else {
114
+ const defaults = AppConfigSchema.parse({});
115
+ try {
116
+ writeFileSync(configPath, JSON.stringify(defaults, null, 2) + "\n", "utf-8");
117
+ } catch {
118
+ console.warn(
119
+ `[config] Could not write default config to "${configPath}" \u2014 using in-memory defaults`
120
+ );
121
+ }
122
+ raw = defaults;
123
+ }
124
+ const parsed = AppConfigSchema.safeParse(raw);
125
+ if (!parsed.success) {
126
+ const details = formatZodErrors(parsed.error);
127
+ throw new ConfigError(
128
+ `Application config validation failed (${configPath}):
129
+ ${details}`
130
+ );
131
+ }
132
+ return parsed.data;
133
+ }
134
+ function getConfig() {
135
+ if (!cachedConfig) {
136
+ cachedConfig = {
137
+ env: loadEnvConfig(),
138
+ app: loadAppConfig()
139
+ };
140
+ }
141
+ return cachedConfig;
142
+ }
143
+ function resetConfig() {
144
+ cachedConfig = null;
145
+ }
146
+ var ConfigError, EnvConfigSchema, PluginCredentialEntrySchema, PluginConfigSchema, BotConfigSchema, AIConfigSchema, PluginHealthConfigSchema, AppConfigSchema, cachedConfig;
147
+ var init_config = __esm({
148
+ "src/core/config.ts"() {
149
+ "use strict";
150
+ ConfigError = class extends Error {
151
+ constructor(message2) {
152
+ super(message2);
153
+ this.name = "ConfigError";
154
+ }
155
+ };
156
+ EnvConfigSchema = z.object({
157
+ TELEGRAM_BOT_TOKEN: z.string().min(1, "TELEGRAM_BOT_TOKEN is required"),
158
+ TELEGRAM_USER_ID: z.string().min(1, "TELEGRAM_USER_ID is required"),
159
+ GITHUB_TOKEN: z.string().optional(),
160
+ LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
161
+ DEFAULT_MODEL: z.string().default("gpt-4.1"),
162
+ HEARTBEAT_INTERVAL_MINUTES: z.string().default("0"),
163
+ AI_SESSION_POOL_SIZE: z.string().default("3")
164
+ });
165
+ PluginCredentialEntrySchema = z.object({
166
+ key: z.string(),
167
+ description: z.string(),
168
+ type: z.string().optional()
169
+ });
170
+ PluginConfigSchema = z.object({
171
+ enabled: z.boolean(),
172
+ credentials: z.record(z.string(), z.string())
173
+ });
174
+ BotConfigSchema = z.object({
175
+ maxMessageLength: z.number().default(4096),
176
+ typingIndicator: z.boolean().default(true)
177
+ });
178
+ AIConfigSchema = z.object({
179
+ maxRetries: z.number().default(3),
180
+ sessionTimeout: z.number().default(36e5)
181
+ });
182
+ PluginHealthConfigSchema = z.object({
183
+ maxFailures: z.number().default(5),
184
+ checkInterval: z.number().default(6e4)
185
+ });
186
+ AppConfigSchema = z.object({
187
+ plugins: z.record(z.string(), PluginConfigSchema).default({}),
188
+ bot: BotConfigSchema.default({ maxMessageLength: 4096, typingIndicator: true }),
189
+ ai: AIConfigSchema.default({ maxRetries: 3, sessionTimeout: 36e5 }),
190
+ pluginHealth: PluginHealthConfigSchema.default({ maxFailures: 5, checkInterval: 6e4 })
191
+ });
192
+ cachedConfig = null;
193
+ }
194
+ });
195
+
196
+ // src/storage/migrations/001-initial.ts
197
+ var migration, initial_default;
198
+ var init_initial = __esm({
199
+ "src/storage/migrations/001-initial.ts"() {
200
+ "use strict";
201
+ migration = {
202
+ id: "001-initial",
203
+ up: `
204
+ CREATE TABLE IF NOT EXISTS conversations (
205
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
206
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
207
+ content TEXT NOT NULL,
208
+ model TEXT,
209
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
210
+ );
211
+
212
+ CREATE TABLE IF NOT EXISTS plugin_state (
213
+ plugin_id TEXT NOT NULL,
214
+ key TEXT NOT NULL,
215
+ value TEXT,
216
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
217
+ PRIMARY KEY (plugin_id, key)
218
+ );
219
+
220
+ CREATE TABLE IF NOT EXISTS preferences (
221
+ key TEXT PRIMARY KEY,
222
+ value TEXT NOT NULL,
223
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
224
+ );
225
+
226
+ CREATE TABLE IF NOT EXISTS plugin_health (
227
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
228
+ plugin_id TEXT NOT NULL,
229
+ status TEXT NOT NULL CHECK(status IN ('ok', 'error', 'disabled')),
230
+ error_message TEXT,
231
+ checked_at DATETIME DEFAULT CURRENT_TIMESTAMP
232
+ );
233
+
234
+ CREATE INDEX IF NOT EXISTS idx_conversations_created_at ON conversations(created_at);
235
+ CREATE INDEX IF NOT EXISTS idx_plugin_health_plugin_id ON plugin_health(plugin_id);
236
+ `
237
+ };
238
+ initial_default = migration;
239
+ }
240
+ });
241
+
242
+ // src/storage/database.ts
243
+ import Database from "better-sqlite3";
244
+ import { mkdirSync } from "fs";
245
+ import { dirname } from "path";
246
+ function ensureMigrationsTable(db) {
247
+ db.exec(`
248
+ CREATE TABLE IF NOT EXISTS _migrations (
249
+ id TEXT PRIMARY KEY,
250
+ applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
251
+ );
252
+ `);
253
+ }
254
+ function runMigrations(db) {
255
+ ensureMigrationsTable(db);
256
+ const applied = new Set(
257
+ db.prepare("SELECT id FROM _migrations").all().map(
258
+ (row) => row.id
259
+ )
260
+ );
261
+ for (const migration2 of MIGRATIONS) {
262
+ if (applied.has(migration2.id)) continue;
263
+ const applyMigration = db.transaction(() => {
264
+ db.exec(migration2.up);
265
+ db.prepare("INSERT INTO _migrations (id) VALUES (?)").run(migration2.id);
266
+ });
267
+ try {
268
+ applyMigration();
269
+ } catch (error) {
270
+ const message2 = error instanceof Error ? error.message : String(error);
271
+ throw new Error(
272
+ `Migration "${migration2.id}" failed: ${message2}`
273
+ );
274
+ }
275
+ }
276
+ }
277
+ function getDatabase(dbPath = DEFAULT_DB_PATH) {
278
+ if (instance) return instance;
279
+ mkdirSync(dirname(dbPath), { recursive: true });
280
+ instance = new Database(dbPath);
281
+ instance.pragma("journal_mode = WAL");
282
+ runMigrations(instance);
283
+ return instance;
284
+ }
285
+ function closeDatabase() {
286
+ if (instance) {
287
+ instance.close();
288
+ instance = null;
289
+ }
290
+ }
291
+ var DEFAULT_DB_PATH, MIGRATIONS, instance;
292
+ var init_database = __esm({
293
+ "src/storage/database.ts"() {
294
+ "use strict";
295
+ init_initial();
296
+ DEFAULT_DB_PATH = "./data/co-assistant.db";
297
+ MIGRATIONS = [initial_default];
298
+ instance = null;
299
+ }
300
+ });
301
+
302
+ // src/core/errors.ts
303
+ var CoAssistantError, PluginError, AIError;
304
+ var init_errors = __esm({
305
+ "src/core/errors.ts"() {
306
+ "use strict";
307
+ CoAssistantError = class extends Error {
308
+ /** Machine-readable error code. */
309
+ code;
310
+ /** Optional structured context for debugging. */
311
+ context;
312
+ constructor(message2, code, context) {
313
+ super(message2);
314
+ this.name = this.constructor.name;
315
+ this.code = code;
316
+ this.context = context;
317
+ Object.setPrototypeOf(this, new.target.prototype);
318
+ if (Error.captureStackTrace) {
319
+ Error.captureStackTrace(this, this.constructor);
320
+ }
321
+ }
322
+ };
323
+ PluginError = class _PluginError extends CoAssistantError {
324
+ /** Identifier of the plugin that produced this error. */
325
+ pluginId;
326
+ constructor(message2, code, pluginId, context) {
327
+ super(message2, code, { ...context, pluginId });
328
+ this.pluginId = pluginId;
329
+ }
330
+ /** Plugin initialisation failed. */
331
+ static initFailed(pluginId, reason) {
332
+ return new _PluginError(
333
+ `Plugin "${pluginId}" failed to initialise: ${reason}`,
334
+ "PLUGIN_INIT_FAILED",
335
+ pluginId,
336
+ { reason }
337
+ );
338
+ }
339
+ /** A tool exposed by the plugin failed during execution. */
340
+ static toolFailed(pluginId, toolName, reason) {
341
+ return new _PluginError(
342
+ `Plugin "${pluginId}" tool "${toolName}" failed: ${reason}`,
343
+ "PLUGIN_TOOL_FAILED",
344
+ pluginId,
345
+ { toolName, reason }
346
+ );
347
+ }
348
+ /** Required credentials for the plugin are missing. */
349
+ static credentialsMissing(pluginId, keys) {
350
+ return new _PluginError(
351
+ `Plugin "${pluginId}" is missing required credentials: ${keys.join(", ")}`,
352
+ "PLUGIN_CREDENTIALS_MISSING",
353
+ pluginId,
354
+ { keys }
355
+ );
356
+ }
357
+ /** Plugin health check did not pass. */
358
+ static healthCheckFailed(pluginId, reason) {
359
+ return new _PluginError(
360
+ `Plugin "${pluginId}" health check failed: ${reason}`,
361
+ "PLUGIN_HEALTH_CHECK_FAILED",
362
+ pluginId,
363
+ { reason }
364
+ );
365
+ }
366
+ /** Plugin is disabled and cannot be used. */
367
+ static disabled(pluginId, reason) {
368
+ return new _PluginError(
369
+ `Plugin "${pluginId}" is disabled: ${reason}`,
370
+ "PLUGIN_DISABLED",
371
+ pluginId,
372
+ { reason }
373
+ );
374
+ }
375
+ };
376
+ AIError = class _AIError extends CoAssistantError {
377
+ constructor(message2, code, context) {
378
+ super(message2, code, context);
379
+ }
380
+ /** The AI client failed to start. */
381
+ static clientStartFailed(reason) {
382
+ return new _AIError(
383
+ `AI client failed to start: ${reason}`,
384
+ "AI_CLIENT_START_FAILED",
385
+ { reason }
386
+ );
387
+ }
388
+ /** A new AI session could not be created. */
389
+ static sessionCreateFailed(reason) {
390
+ return new _AIError(
391
+ `Failed to create AI session: ${reason}`,
392
+ "AI_SESSION_CREATE_FAILED",
393
+ { reason }
394
+ );
395
+ }
396
+ /** The requested model was not found or is unavailable. */
397
+ static modelNotFound(model) {
398
+ return new _AIError(
399
+ `AI model not found: ${model}`,
400
+ "AI_MODEL_NOT_FOUND",
401
+ { model }
402
+ );
403
+ }
404
+ /** Sending a message to the AI failed. */
405
+ static sendFailed(reason) {
406
+ return new _AIError(
407
+ `Failed to send message to AI: ${reason}`,
408
+ "AI_SEND_FAILED",
409
+ { reason }
410
+ );
411
+ }
412
+ };
413
+ }
414
+ });
415
+
416
+ // src/ai/client.ts
417
+ var client_exports = {};
418
+ __export(client_exports, {
419
+ CopilotClientWrapper: () => CopilotClientWrapper,
420
+ copilotClient: () => copilotClient
421
+ });
422
+ import { CopilotClient } from "@github/copilot-sdk";
423
+ var logger2, CopilotClientWrapper, copilotClient;
424
+ var init_client = __esm({
425
+ "src/ai/client.ts"() {
426
+ "use strict";
427
+ init_logger();
428
+ init_errors();
429
+ logger2 = createChildLogger("ai:client");
430
+ CopilotClientWrapper = class {
431
+ client = null;
432
+ isStarted = false;
433
+ /** Start the Copilot client. Must be called before creating sessions. */
434
+ async start() {
435
+ if (this.isStarted) {
436
+ logger2.warn("Client already started \u2014 skipping");
437
+ return;
438
+ }
439
+ try {
440
+ logger2.info("Starting Copilot client\u2026");
441
+ this.client = new CopilotClient();
442
+ await this.client.start();
443
+ this.isStarted = true;
444
+ logger2.info("Copilot client started successfully");
445
+ } catch (error) {
446
+ this.client = null;
447
+ this.isStarted = false;
448
+ const reason = error instanceof Error ? error.message : String(error);
449
+ logger2.error({ err: error }, "Failed to start Copilot client");
450
+ throw AIError.clientStartFailed(reason);
451
+ }
452
+ }
453
+ /** Stop the Copilot client gracefully. */
454
+ async stop() {
455
+ if (!this.isStarted || !this.client) {
456
+ logger2.debug("Client not running \u2014 nothing to stop");
457
+ return;
458
+ }
459
+ try {
460
+ logger2.info("Stopping Copilot client\u2026");
461
+ await this.client.stop();
462
+ logger2.info("Copilot client stopped");
463
+ } catch (error) {
464
+ logger2.error({ err: error }, "Error while stopping Copilot client (ignored)");
465
+ } finally {
466
+ this.client = null;
467
+ this.isStarted = false;
468
+ }
469
+ }
470
+ /**
471
+ * Get the underlying {@link CopilotClient}.
472
+ * @throws {AIError} If the client has not been started.
473
+ */
474
+ getClient() {
475
+ if (!this.isStarted || !this.client) {
476
+ throw AIError.clientStartFailed("Client not started");
477
+ }
478
+ return this.client;
479
+ }
480
+ /** Check if the client is running. */
481
+ isRunning() {
482
+ return this.isStarted;
483
+ }
484
+ /** Restart the client (stop then start). */
485
+ async restart() {
486
+ logger2.info("Restarting Copilot client\u2026");
487
+ await this.stop();
488
+ await this.start();
489
+ }
490
+ };
491
+ copilotClient = new CopilotClientWrapper();
492
+ }
493
+ });
494
+
495
+ // src/ai/session.ts
496
+ var session_exports = {};
497
+ __export(session_exports, {
498
+ SessionManager: () => SessionManager,
499
+ sessionManager: () => sessionManager
500
+ });
501
+ import { approveAll, defineTool } from "@github/copilot-sdk";
502
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
503
+ import path from "path";
504
+ function convertTools(tools) {
505
+ return tools.map(
506
+ (t) => defineTool(t.name, {
507
+ description: t.description,
508
+ parameters: t.parameters,
509
+ handler: t.handler
510
+ })
511
+ );
512
+ }
513
+ var logger3, SessionManager, sessionManager;
514
+ var init_session = __esm({
515
+ "src/ai/session.ts"() {
516
+ "use strict";
517
+ init_logger();
518
+ init_errors();
519
+ init_client();
520
+ logger3 = createChildLogger("ai:session");
521
+ SessionManager = class _SessionManager {
522
+ // 5 minutes
523
+ constructor(clientWrapper) {
524
+ this.clientWrapper = clientWrapper;
525
+ this.logger = logger3;
526
+ this.personalityPath = path.join(process.cwd(), "personality.md");
527
+ this.userProfilePath = path.join(process.cwd(), "user.md");
528
+ }
529
+ clientWrapper;
530
+ /** Pool of parallel Copilot sessions. */
531
+ pool = [];
532
+ /** Configured pool size — how many parallel sessions to maintain. */
533
+ poolSize = 3;
534
+ currentModel = "";
535
+ tools = [];
536
+ logger;
537
+ /**
538
+ * Cached personality prompt loaded from `personality.md`.
539
+ * Reloaded from disk on each message so edits take effect immediately.
540
+ */
541
+ personalityPath;
542
+ /** Path to the user profile loaded from `user.md`. */
543
+ userProfilePath;
544
+ /**
545
+ * Callers blocked waiting for a free session.
546
+ * Drained FIFO when a session is released.
547
+ */
548
+ waiters = [];
549
+ /** Maximum time (ms) a caller will wait in the queue before giving up. */
550
+ static ACQUIRE_TIMEOUT_MS = 3e5;
551
+ /**
552
+ * Read a markdown context file from disk.
553
+ *
554
+ * Reads fresh on each call so edits take effect without restart.
555
+ * Returns an empty string if the file is missing or unreadable.
556
+ */
557
+ loadContextFile(filePath, label) {
558
+ try {
559
+ if (!existsSync2(filePath)) return "";
560
+ return readFileSync2(filePath, "utf-8").trim();
561
+ } catch {
562
+ this.logger.warn(`Could not read ${label} \u2014 skipping`);
563
+ return "";
564
+ }
565
+ }
566
+ /**
567
+ * Wrap a user prompt with system-level context (personality + user profile).
568
+ *
569
+ * Both files are read fresh from disk so edits take effect immediately.
570
+ * The personality defines *how* the assistant behaves; the user profile
571
+ * tells it *who* it's talking to.
572
+ */
573
+ applySystemContext(prompt) {
574
+ const personality = this.loadContextFile(this.personalityPath, "personality.md");
575
+ const userProfile = this.loadContextFile(this.userProfilePath, "user.md");
576
+ const parts = [];
577
+ if (personality) parts.push(personality);
578
+ if (userProfile) parts.push(userProfile);
579
+ if (parts.length === 0) return prompt;
580
+ return `<system>
581
+ ${parts.join("\n\n---\n\n")}
582
+ </system>
583
+
584
+ ${prompt}`;
585
+ }
586
+ // -----------------------------------------------------------------------
587
+ // Pool management (private)
588
+ // -----------------------------------------------------------------------
589
+ /**
590
+ * Acquire a free session from the pool.
591
+ *
592
+ * Prefers the lowest-index free session so that sequential interactions
593
+ * (e.g. back-to-back user messages) naturally reuse the same session and
594
+ * keep their conversation context. If all sessions are busy, the caller
595
+ * blocks until one is released or the acquire timeout fires.
596
+ */
597
+ acquire() {
598
+ const free = this.pool.find((s) => !s.busy);
599
+ if (free) {
600
+ free.busy = true;
601
+ return Promise.resolve(free);
602
+ }
603
+ return new Promise((resolve, reject) => {
604
+ const waiter = { resolve, reject };
605
+ const timer = setTimeout(() => {
606
+ const idx = this.waiters.indexOf(waiter);
607
+ if (idx >= 0) this.waiters.splice(idx, 1);
608
+ reject(AIError.sendFailed("Timed out waiting for a free AI session"));
609
+ }, _SessionManager.ACQUIRE_TIMEOUT_MS);
610
+ waiter.resolve = (ps) => {
611
+ clearTimeout(timer);
612
+ resolve(ps);
613
+ };
614
+ this.waiters.push(waiter);
615
+ });
616
+ }
617
+ /**
618
+ * Release a session back to the pool.
619
+ *
620
+ * If callers are queued waiting for a session, the session is handed
621
+ * directly to the next waiter (stays marked as busy) instead of being
622
+ * returned to the idle pool — this avoids an unnecessary acquire cycle.
623
+ */
624
+ release(ps) {
625
+ if (this.waiters.length > 0) {
626
+ const waiter = this.waiters.shift();
627
+ waiter.resolve(ps);
628
+ } else {
629
+ ps.busy = false;
630
+ }
631
+ }
632
+ // -----------------------------------------------------------------------
633
+ // Session creation
634
+ // -----------------------------------------------------------------------
635
+ /**
636
+ * Create a pool of Copilot sessions with the specified model and tools.
637
+ *
638
+ * Sessions are created in parallel for faster startup. If any session
639
+ * fails to create, all successful ones are cleaned up and the error
640
+ * propagates.
641
+ *
642
+ * @param model - Model identifier (e.g. `"gpt-5"`, `"claude-sonnet-4.5"`).
643
+ * @param tools - Optional array of tool definitions to register.
644
+ * @param poolSize - Number of parallel sessions (default: 3).
645
+ * @throws {AIError} If session creation fails.
646
+ */
647
+ async createSession(model, tools, poolSize) {
648
+ if (this.pool.length > 0) {
649
+ this.logger.warn("Session pool already exists \u2014 closing before re-creating");
650
+ await this.closeSession();
651
+ }
652
+ if (tools) this.tools = tools;
653
+ if (poolSize !== void 0 && poolSize > 0) this.poolSize = poolSize;
654
+ try {
655
+ const client = this.clientWrapper.getClient();
656
+ const sdkTools = convertTools(this.tools);
657
+ this.logger.info(
658
+ { model, toolCount: sdkTools.length, poolSize: this.poolSize },
659
+ "Creating session pool"
660
+ );
661
+ const results = await Promise.allSettled(
662
+ Array.from({ length: this.poolSize }, async (_, i) => {
663
+ const session = await client.createSession({
664
+ model,
665
+ tools: sdkTools.length > 0 ? sdkTools : void 0,
666
+ onPermissionRequest: approveAll
667
+ });
668
+ this.logger.debug({ index: i }, "Pool session created");
669
+ return { session, busy: false, index: i };
670
+ })
671
+ );
672
+ const created = [];
673
+ const errors = [];
674
+ for (const r of results) {
675
+ if (r.status === "fulfilled") {
676
+ created.push(r.value);
677
+ } else {
678
+ errors.push(r.reason);
679
+ }
680
+ }
681
+ if (errors.length > 0) {
682
+ for (const ps of created) {
683
+ try {
684
+ await ps.session.disconnect();
685
+ } catch {
686
+ }
687
+ }
688
+ const first = errors[0] instanceof Error ? errors[0].message : String(errors[0]);
689
+ throw new Error(`${errors.length}/${this.poolSize} sessions failed: ${first}`);
690
+ }
691
+ this.pool = created;
692
+ this.currentModel = model;
693
+ this.logger.info({ model, poolSize: this.poolSize }, "Session pool ready");
694
+ } catch (error) {
695
+ const reason = error instanceof Error ? error.message : String(error);
696
+ this.logger.error({ err: error, model }, "Failed to create session pool");
697
+ throw AIError.sessionCreateFailed(reason);
698
+ }
699
+ }
700
+ // -----------------------------------------------------------------------
701
+ // Messaging
702
+ // -----------------------------------------------------------------------
703
+ /**
704
+ * Send a prompt and wait for the complete assistant response.
705
+ *
706
+ * Acquires a free session from the pool, sends the prompt via
707
+ * `sendAndWait`, and releases the session when done. Multiple callers
708
+ * can run in parallel (up to `poolSize`). If all sessions are busy,
709
+ * the caller blocks until one frees up.
710
+ *
711
+ * @param prompt - The user message to send.
712
+ * @param timeout - Timeout in ms (default: 300 000 = 5 min). Complex prompts
713
+ * involving multiple tool calls (e.g. heartbeats) can take
714
+ * longer than the SDK's default 60 s.
715
+ * @returns The assistant's full response content.
716
+ * @throws {AIError} If no session pool is active or the send fails.
717
+ */
718
+ async sendMessage(prompt, timeout = 3e5) {
719
+ this.ensureSession();
720
+ const fullPrompt = this.applySystemContext(prompt);
721
+ const ps = await this.acquire();
722
+ try {
723
+ this.logger.debug(
724
+ { promptLength: fullPrompt.length, timeout, session: ps.index },
725
+ "Sending message (blocking)"
726
+ );
727
+ const response = await ps.session.sendAndWait({ prompt: fullPrompt }, timeout);
728
+ const content = response?.data?.content ?? "";
729
+ this.logger.debug(
730
+ { responseLength: content.length, session: ps.index },
731
+ "Received response"
732
+ );
733
+ return content;
734
+ } catch (error) {
735
+ const reason = error instanceof Error ? error.message : String(error);
736
+ this.logger.error({ err: error, session: ps.index }, "sendMessage failed");
737
+ throw AIError.sendFailed(reason);
738
+ } finally {
739
+ this.release(ps);
740
+ }
741
+ }
742
+ /**
743
+ * Send a prompt with streaming — invokes `onChunk` for each incremental
744
+ * delta and returns the full accumulated response when the session goes idle.
745
+ *
746
+ * Acquires a pooled session for the duration of the streaming call.
747
+ *
748
+ * @param prompt - The user message to send.
749
+ * @param onChunk - Callback invoked with each streaming text delta.
750
+ * @returns The full accumulated response string.
751
+ * @throws {AIError} If no session pool is active or the send fails.
752
+ */
753
+ async sendMessageStreaming(prompt, onChunk) {
754
+ this.ensureSession();
755
+ const fullPrompt = this.applySystemContext(prompt);
756
+ const ps = await this.acquire();
757
+ try {
758
+ this.logger.debug(
759
+ { promptLength: fullPrompt.length, session: ps.index },
760
+ "Sending message (streaming)"
761
+ );
762
+ let accumulated = "";
763
+ const done = new Promise((resolve, reject) => {
764
+ ps.session.on("assistant.message_delta", (event) => {
765
+ const delta = event.data.deltaContent ?? "";
766
+ if (delta) {
767
+ accumulated += delta;
768
+ onChunk(delta);
769
+ }
770
+ });
771
+ ps.session.on("assistant.message", (event) => {
772
+ const content = event.data.content ?? "";
773
+ if (content) {
774
+ accumulated = content;
775
+ }
776
+ });
777
+ ps.session.on("session.idle", () => {
778
+ resolve(accumulated);
779
+ });
780
+ ps.session.on("error", (err) => {
781
+ reject(err);
782
+ });
783
+ });
784
+ await ps.session.send({ prompt: fullPrompt });
785
+ const result = await done;
786
+ this.logger.debug(
787
+ { responseLength: result.length, session: ps.index },
788
+ "Streaming response complete"
789
+ );
790
+ return result;
791
+ } catch (error) {
792
+ const reason = error instanceof Error ? error.message : String(error);
793
+ this.logger.error({ err: error, session: ps.index }, "sendMessageStreaming failed");
794
+ throw AIError.sendFailed(reason);
795
+ } finally {
796
+ this.release(ps);
797
+ }
798
+ }
799
+ // -----------------------------------------------------------------------
800
+ // Model / tool management
801
+ // -----------------------------------------------------------------------
802
+ /**
803
+ * Switch to a different model. Closes the entire pool and creates a new
804
+ * one with the same tool set and pool size.
805
+ *
806
+ * @param model - The new model identifier.
807
+ * @throws {AIError} If pool recreation fails.
808
+ */
809
+ async switchModel(model) {
810
+ this.logger.info({ from: this.currentModel, to: model }, "Switching model");
811
+ await this.closeSession();
812
+ await this.createSession(model, this.tools);
813
+ }
814
+ /**
815
+ * Replace the registered tool set. The pool must be rebuilt because the
816
+ * Copilot SDK binds tools at session creation time.
817
+ *
818
+ * @param tools - New array of tool definitions.
819
+ * @throws {AIError} If pool recreation fails.
820
+ */
821
+ async updateTools(tools) {
822
+ this.logger.info({ toolCount: tools.length }, "Updating tools (pool rebuild required)");
823
+ this.tools = tools;
824
+ if (this.pool.length > 0) {
825
+ await this.closeSession();
826
+ await this.createSession(this.currentModel, this.tools);
827
+ }
828
+ }
829
+ // -----------------------------------------------------------------------
830
+ // Session lifecycle
831
+ // -----------------------------------------------------------------------
832
+ /**
833
+ * Close all sessions in the pool and release resources.
834
+ *
835
+ * Any callers blocked in {@link acquire} are rejected with an error.
836
+ * Safe to call even when the pool is empty (no-op).
837
+ */
838
+ async closeSession() {
839
+ if (this.pool.length === 0) {
840
+ this.logger.debug("No active sessions to close");
841
+ return;
842
+ }
843
+ for (const waiter of this.waiters) {
844
+ waiter.reject(AIError.sessionCreateFailed("Session pool is closing"));
845
+ }
846
+ this.waiters = [];
847
+ this.logger.info({ poolSize: this.pool.length }, "Closing session pool");
848
+ await Promise.allSettled(
849
+ this.pool.map(async (ps) => {
850
+ try {
851
+ await ps.session.disconnect();
852
+ } catch (err) {
853
+ this.logger.error({ err, index: ps.index }, "Error closing pool session (ignored)");
854
+ }
855
+ })
856
+ );
857
+ this.pool = [];
858
+ this.logger.info("Session pool closed");
859
+ }
860
+ /**
861
+ * Get the identifier of the model used by the current (or most recent) pool.
862
+ */
863
+ getCurrentModel() {
864
+ return this.currentModel;
865
+ }
866
+ /**
867
+ * Check whether the session pool has at least one session.
868
+ */
869
+ isActive() {
870
+ return this.pool.length > 0;
871
+ }
872
+ /**
873
+ * Number of sessions not currently processing a message.
874
+ * Useful for the Telegram handler to decide whether to show a "queued" notice.
875
+ */
876
+ getAvailableCount() {
877
+ return this.pool.filter((s) => !s.busy).length;
878
+ }
879
+ /**
880
+ * Total number of sessions in the pool.
881
+ */
882
+ getPoolSize() {
883
+ return this.poolSize;
884
+ }
885
+ // -----------------------------------------------------------------------
886
+ // Internal helpers
887
+ // -----------------------------------------------------------------------
888
+ /**
889
+ * Guard that throws if the pool is empty.
890
+ * @throws {AIError} When called without an active pool.
891
+ */
892
+ ensureSession() {
893
+ if (this.pool.length === 0) {
894
+ throw AIError.sessionCreateFailed("No active sessions \u2014 call createSession() first");
895
+ }
896
+ }
897
+ };
898
+ sessionManager = new SessionManager(copilotClient);
899
+ }
900
+ });
901
+
902
+ // src/storage/repositories/plugin-health.ts
903
+ var plugin_health_exports = {};
904
+ __export(plugin_health_exports, {
905
+ PluginHealthRepository: () => PluginHealthRepository
906
+ });
907
+ var PluginHealthRepository;
908
+ var init_plugin_health = __esm({
909
+ "src/storage/repositories/plugin-health.ts"() {
910
+ "use strict";
911
+ init_database();
912
+ PluginHealthRepository = class {
913
+ db;
914
+ /** Create a new repository backed by the singleton database. */
915
+ constructor(db) {
916
+ this.db = db ?? getDatabase();
917
+ }
918
+ /**
919
+ * Record a health-check result for a plugin.
920
+ *
921
+ * @param pluginId - The unique plugin identifier.
922
+ * @param status - Health status (`ok`, `error`, or `disabled`).
923
+ * @param errorMessage - Optional error details.
924
+ */
925
+ logHealth(pluginId, status, errorMessage) {
926
+ this.db.prepare(
927
+ "INSERT INTO plugin_health (plugin_id, status, error_message) VALUES (?, ?, ?)"
928
+ ).run(pluginId, status, errorMessage ?? null);
929
+ }
930
+ /**
931
+ * Retrieve the most recent health-check entries for a plugin.
932
+ *
933
+ * @param pluginId - The unique plugin identifier.
934
+ * @param limit - Maximum number of entries to return (default `10`).
935
+ * @returns An array of {@link PluginHealthEntry} objects, newest first.
936
+ */
937
+ getRecentHealth(pluginId, limit = 10) {
938
+ return this.db.prepare(
939
+ "SELECT id, plugin_id, status, error_message, checked_at FROM plugin_health WHERE plugin_id = ? ORDER BY checked_at DESC LIMIT ?"
940
+ ).all(pluginId, limit);
941
+ }
942
+ /**
943
+ * Count the number of `error` health entries for a plugin within a time window.
944
+ *
945
+ * @param pluginId - The unique plugin identifier.
946
+ * @param sinceMinutes - Look-back window in minutes (default `60`).
947
+ * @returns The number of error entries in the window.
948
+ */
949
+ getFailureCount(pluginId, sinceMinutes = 60) {
950
+ const row = this.db.prepare(
951
+ `SELECT COUNT(*) AS cnt FROM plugin_health
952
+ WHERE plugin_id = ? AND status = 'error'
953
+ AND checked_at >= datetime('now', '-' || ? || ' minutes')`
954
+ ).get(pluginId, sinceMinutes);
955
+ return row.cnt;
956
+ }
957
+ };
958
+ }
959
+ });
960
+
961
+ // src/bot/handlers/message.ts
962
+ var message_exports = {};
963
+ __export(message_exports, {
964
+ createMessageHandler: () => createMessageHandler,
965
+ splitMessage: () => splitMessage
966
+ });
967
+ function splitMessage(text, maxLength = 4096) {
968
+ if (text.length <= maxLength) {
969
+ return [text];
970
+ }
971
+ const chunks = [];
972
+ let remaining = text;
973
+ while (remaining.length > 0) {
974
+ if (remaining.length <= maxLength) {
975
+ chunks.push(remaining);
976
+ break;
977
+ }
978
+ let splitIdx = remaining.lastIndexOf("\n\n", maxLength);
979
+ if (splitIdx <= 0) {
980
+ splitIdx = remaining.lastIndexOf("\n", maxLength);
981
+ }
982
+ if (splitIdx <= 0) {
983
+ splitIdx = maxLength;
984
+ }
985
+ chunks.push(remaining.slice(0, splitIdx));
986
+ remaining = remaining.slice(splitIdx).replace(/^\n+/, "");
987
+ }
988
+ return chunks.filter((c) => c.length > 0);
989
+ }
990
+ function createMessageHandler(deps) {
991
+ const { sessionManager: sessionManager2, conversationRepo } = deps;
992
+ return async (ctx, text) => {
993
+ const replyToId = ctx.message?.message_id;
994
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
995
+ await ctx.sendChatAction("typing");
996
+ if (sessionManager2.getAvailableCount() === 0) {
997
+ await ctx.reply("\u23F3 All AI sessions busy \u2014 your message is queued.", replyOpts);
998
+ }
999
+ conversationRepo.addMessage("user", text);
1000
+ logger6.debug({ textLength: text.length }, "User message stored");
1001
+ if (!sessionManager2.isActive()) {
1002
+ logger6.error("No active AI session \u2014 cannot process message");
1003
+ await ctx.reply("\u26A0\uFE0F AI session is not active. Please restart the bot.", replyOpts);
1004
+ return;
1005
+ }
1006
+ const typingInterval = setInterval(() => {
1007
+ ctx.sendChatAction("typing").catch(() => {
1008
+ });
1009
+ }, 4e3);
1010
+ try {
1011
+ const response = await sessionManager2.sendMessage(text);
1012
+ if (!response) {
1013
+ logger6.warn("AI returned an empty response");
1014
+ await ctx.reply("The AI returned an empty response. Please try again.", replyOpts);
1015
+ return;
1016
+ }
1017
+ const model = sessionManager2.getCurrentModel();
1018
+ conversationRepo.addMessage("assistant", response, model);
1019
+ logger6.debug(
1020
+ { responseLength: response.length, model },
1021
+ "Assistant response stored"
1022
+ );
1023
+ const chunks = splitMessage(response);
1024
+ for (const chunk of chunks) {
1025
+ await ctx.reply(chunk, replyOpts);
1026
+ }
1027
+ } catch (error) {
1028
+ const reason = error instanceof Error ? error.message : String(error);
1029
+ logger6.error({ err: error }, "Failed to process message through AI");
1030
+ await ctx.reply("Sorry, I couldn't process that. Please try again.", replyOpts);
1031
+ } finally {
1032
+ clearInterval(typingInterval);
1033
+ }
1034
+ };
1035
+ }
1036
+ var logger6;
1037
+ var init_message = __esm({
1038
+ "src/bot/handlers/message.ts"() {
1039
+ "use strict";
1040
+ init_logger();
1041
+ logger6 = createChildLogger("bot:handlers");
1042
+ }
1043
+ });
1044
+
1045
+ // src/cli/index.ts
1046
+ init_logger();
1047
+ import { createRequire } from "module";
1048
+ import { Command } from "commander";
1049
+
1050
+ // src/core/app.ts
1051
+ init_logger();
1052
+ init_config();
1053
+ init_database();
1054
+ import dns from "dns";
1055
+
1056
+ // src/storage/repositories/conversation.ts
1057
+ init_database();
1058
+ var ConversationRepository = class {
1059
+ db;
1060
+ /** Create a new repository backed by the singleton database. */
1061
+ constructor(db) {
1062
+ this.db = db ?? getDatabase();
1063
+ }
1064
+ /**
1065
+ * Insert a new message into the conversations table.
1066
+ *
1067
+ * @param role - The message role (`user`, `assistant`, or `system`).
1068
+ * @param content - The text content of the message.
1069
+ * @param model - Optional AI model identifier.
1070
+ */
1071
+ addMessage(role, content, model) {
1072
+ this.db.prepare(
1073
+ "INSERT INTO conversations (role, content, model) VALUES (?, ?, ?)"
1074
+ ).run(role, content, model ?? null);
1075
+ }
1076
+ /**
1077
+ * Retrieve conversation history ordered newest-first.
1078
+ *
1079
+ * @param limit - Maximum number of messages to return (default `50`).
1080
+ * @returns An array of {@link ConversationMessage} objects.
1081
+ */
1082
+ getHistory(limit = 50) {
1083
+ return this.db.prepare(
1084
+ "SELECT id, role, content, model, created_at FROM conversations ORDER BY created_at DESC LIMIT ?"
1085
+ ).all(limit);
1086
+ }
1087
+ /**
1088
+ * Retrieve the most recent messages ordered oldest-first, suitable for
1089
+ * building an AI context window.
1090
+ *
1091
+ * @param limit - Maximum number of messages to return (default `20`).
1092
+ * @returns An array of {@link ConversationMessage} objects in chronological order.
1093
+ */
1094
+ getRecentContext(limit = 20) {
1095
+ return this.db.prepare(
1096
+ `SELECT id, role, content, model, created_at
1097
+ FROM (
1098
+ SELECT id, role, content, model, created_at
1099
+ FROM conversations
1100
+ ORDER BY created_at DESC
1101
+ LIMIT ?
1102
+ ) sub
1103
+ ORDER BY created_at ASC`
1104
+ ).all(limit);
1105
+ }
1106
+ /**
1107
+ * Delete all conversation messages.
1108
+ */
1109
+ clear() {
1110
+ this.db.prepare("DELETE FROM conversations").run();
1111
+ }
1112
+ /**
1113
+ * Return the total number of stored conversation messages.
1114
+ */
1115
+ count() {
1116
+ const row = this.db.prepare("SELECT COUNT(*) AS cnt FROM conversations").get();
1117
+ return row.cnt;
1118
+ }
1119
+ };
1120
+
1121
+ // src/storage/repositories/preferences.ts
1122
+ init_database();
1123
+ var PreferencesRepository = class {
1124
+ db;
1125
+ /** Create a new repository backed by the singleton database. */
1126
+ constructor(db) {
1127
+ this.db = db ?? getDatabase();
1128
+ }
1129
+ /**
1130
+ * Retrieve a preference value by key.
1131
+ *
1132
+ * @param key - The preference key.
1133
+ * @returns The stored value, or `null` if not found.
1134
+ */
1135
+ get(key) {
1136
+ const row = this.db.prepare("SELECT value FROM preferences WHERE key = ?").get(key);
1137
+ return row?.value ?? null;
1138
+ }
1139
+ /**
1140
+ * Insert or update a preference value.
1141
+ *
1142
+ * @param key - The preference key.
1143
+ * @param value - The value to store.
1144
+ */
1145
+ set(key, value) {
1146
+ this.db.prepare(
1147
+ `INSERT INTO preferences (key, value, updated_at)
1148
+ VALUES (?, ?, CURRENT_TIMESTAMP)
1149
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
1150
+ ).run(key, value);
1151
+ }
1152
+ /**
1153
+ * Retrieve all preferences as a plain object.
1154
+ *
1155
+ * @returns A record mapping each preference key to its value.
1156
+ */
1157
+ getAll() {
1158
+ const rows = this.db.prepare("SELECT key, value FROM preferences").all();
1159
+ const result = {};
1160
+ for (const row of rows) {
1161
+ result[row.key] = row.value;
1162
+ }
1163
+ return result;
1164
+ }
1165
+ /**
1166
+ * Delete a single preference by key.
1167
+ *
1168
+ * @param key - The preference key to remove.
1169
+ */
1170
+ delete(key) {
1171
+ this.db.prepare("DELETE FROM preferences WHERE key = ?").run(key);
1172
+ }
1173
+ };
1174
+
1175
+ // src/storage/repositories/plugin-state.ts
1176
+ init_database();
1177
+ var PluginStateRepository = class {
1178
+ db;
1179
+ /** Create a new repository backed by the singleton database. */
1180
+ constructor(db) {
1181
+ this.db = db ?? getDatabase();
1182
+ }
1183
+ /**
1184
+ * Retrieve a single value for a given plugin and key.
1185
+ *
1186
+ * @param pluginId - The unique plugin identifier.
1187
+ * @param key - The state key.
1188
+ * @returns The stored value, or `null` if not found.
1189
+ */
1190
+ get(pluginId, key) {
1191
+ const row = this.db.prepare("SELECT value FROM plugin_state WHERE plugin_id = ? AND key = ?").get(pluginId, key);
1192
+ return row?.value ?? null;
1193
+ }
1194
+ /**
1195
+ * Insert or update a state value for a plugin.
1196
+ *
1197
+ * @param pluginId - The unique plugin identifier.
1198
+ * @param key - The state key.
1199
+ * @param value - The value to store.
1200
+ */
1201
+ set(pluginId, key, value) {
1202
+ this.db.prepare(
1203
+ `INSERT INTO plugin_state (plugin_id, key, value, updated_at)
1204
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
1205
+ ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
1206
+ ).run(pluginId, key, value);
1207
+ }
1208
+ /**
1209
+ * Retrieve all key/value pairs for a plugin.
1210
+ *
1211
+ * @param pluginId - The unique plugin identifier.
1212
+ * @returns A plain object mapping keys to their string values.
1213
+ */
1214
+ getAll(pluginId) {
1215
+ const rows = this.db.prepare("SELECT key, value FROM plugin_state WHERE plugin_id = ?").all(pluginId);
1216
+ const result = {};
1217
+ for (const row of rows) {
1218
+ result[row.key] = row.value;
1219
+ }
1220
+ return result;
1221
+ }
1222
+ /**
1223
+ * Delete a single state key for a plugin.
1224
+ *
1225
+ * @param pluginId - The unique plugin identifier.
1226
+ * @param key - The state key to remove.
1227
+ */
1228
+ delete(pluginId, key) {
1229
+ this.db.prepare("DELETE FROM plugin_state WHERE plugin_id = ? AND key = ?").run(pluginId, key);
1230
+ }
1231
+ /**
1232
+ * Remove all stored state for a specific plugin.
1233
+ *
1234
+ * @param pluginId - The unique plugin identifier.
1235
+ */
1236
+ clearPlugin(pluginId) {
1237
+ this.db.prepare("DELETE FROM plugin_state WHERE plugin_id = ?").run(pluginId);
1238
+ }
1239
+ };
1240
+
1241
+ // src/ai/models.ts
1242
+ init_logger();
1243
+ init_config();
1244
+ var DEFAULT_MODELS = [
1245
+ // OpenAI — Premium (3x)
1246
+ { id: "gpt-5", name: "GPT-5", description: "OpenAI GPT-5 (premium, 3x)", provider: "openai" },
1247
+ { id: "o3", name: "o3", description: "OpenAI o3 reasoning (premium, 3x)", provider: "openai" },
1248
+ // OpenAI — Standard (1x)
1249
+ { id: "gpt-4.1", name: "GPT-4.1", description: "OpenAI GPT-4.1 (1x)", provider: "openai" },
1250
+ { id: "gpt-4o", name: "GPT-4o", description: "OpenAI GPT-4o multimodal (1x)", provider: "openai" },
1251
+ { id: "o4-mini", name: "o4 Mini", description: "OpenAI o4-mini reasoning (1x)", provider: "openai" },
1252
+ // OpenAI — Low (0.33x)
1253
+ { id: "gpt-4o-mini", name: "GPT-4o Mini", description: "OpenAI GPT-4o Mini (0.33x)", provider: "openai" },
1254
+ { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", description: "OpenAI GPT-4.1 Mini (0.33x)", provider: "openai" },
1255
+ { id: "gpt-5-mini", name: "GPT-5 Mini", description: "OpenAI GPT-5 Mini (0.33x)", provider: "openai" },
1256
+ { id: "o3-mini", name: "o3 Mini", description: "OpenAI o3-mini reasoning (0.33x)", provider: "openai" },
1257
+ // OpenAI — Nano (0x)
1258
+ { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", description: "OpenAI GPT-4.1 Nano (0x)", provider: "openai" },
1259
+ // Anthropic — Premium (3x)
1260
+ { id: "claude-opus-4", name: "Claude Opus 4", description: "Anthropic Claude Opus 4 (premium, 3x)", provider: "anthropic" },
1261
+ // Anthropic — Standard (1x)
1262
+ { id: "claude-sonnet-4", name: "Claude Sonnet 4", description: "Anthropic Claude Sonnet 4 (1x)", provider: "anthropic" },
1263
+ // Anthropic — Low (0.33x)
1264
+ { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", description: "Anthropic Claude Haiku 4.5 (0.33x)", provider: "anthropic" }
1265
+ ];
1266
+ var PREF_KEY = "current_model";
1267
+ var ModelRegistry = class {
1268
+ models;
1269
+ preferences;
1270
+ logger;
1271
+ /**
1272
+ * @param preferences - Repository used to persist the selected model.
1273
+ */
1274
+ constructor(preferences) {
1275
+ this.models = [...DEFAULT_MODELS];
1276
+ this.preferences = preferences;
1277
+ this.logger = createChildLogger("ai:models");
1278
+ this.logger.debug({ count: this.models.length }, "Model registry initialised");
1279
+ }
1280
+ /**
1281
+ * Return every model currently registered (built-in + custom).
1282
+ *
1283
+ * @returns A shallow copy of the model list.
1284
+ */
1285
+ getAvailableModels() {
1286
+ return [...this.models];
1287
+ }
1288
+ /**
1289
+ * Look up a single model by its identifier.
1290
+ *
1291
+ * @param modelId - Case-sensitive model identifier.
1292
+ * @returns The matching {@link ModelInfo}, or `undefined` when not found.
1293
+ */
1294
+ getModel(modelId) {
1295
+ return this.models.find((m) => m.id === modelId);
1296
+ }
1297
+ /**
1298
+ * Resolve the currently active model identifier.
1299
+ *
1300
+ * Resolution order:
1301
+ * 1. Persisted preference (`current_model` key)
1302
+ * 2. `DEFAULT_MODEL` from the environment configuration
1303
+ *
1304
+ * @returns The model identifier string.
1305
+ */
1306
+ getCurrentModelId() {
1307
+ const persisted = this.preferences.get(PREF_KEY);
1308
+ if (persisted) {
1309
+ this.logger.debug({ modelId: persisted }, "Current model resolved from preferences");
1310
+ return persisted;
1311
+ }
1312
+ try {
1313
+ const defaultModel = getConfig().env.DEFAULT_MODEL;
1314
+ this.logger.debug({ modelId: defaultModel }, "Current model resolved from env default");
1315
+ return defaultModel;
1316
+ } catch {
1317
+ const fallback = "gpt-4.1";
1318
+ this.logger.debug({ modelId: fallback }, "Current model resolved from hardcoded fallback");
1319
+ return fallback;
1320
+ }
1321
+ }
1322
+ /**
1323
+ * Select a model as the active model and persist the choice.
1324
+ *
1325
+ * Unknown model IDs are **allowed** (the Copilot SDK may support models
1326
+ * not present in our registry) but a warning is logged so operators can
1327
+ * spot potential typos.
1328
+ *
1329
+ * @param modelId - The identifier of the model to activate.
1330
+ */
1331
+ setCurrentModel(modelId) {
1332
+ if (!this.isValidModel(modelId)) {
1333
+ this.logger.warn(
1334
+ { modelId },
1335
+ "Setting model that is not in the registry \u2014 it may still be valid for the provider"
1336
+ );
1337
+ }
1338
+ this.preferences.set(PREF_KEY, modelId);
1339
+ this.logger.info({ modelId }, "Current model updated");
1340
+ }
1341
+ /**
1342
+ * Check whether a model identifier corresponds to a registered model.
1343
+ *
1344
+ * @param modelId - The identifier to validate.
1345
+ * @returns `true` when the model exists in the registry.
1346
+ */
1347
+ isValidModel(modelId) {
1348
+ return this.models.some((m) => m.id === modelId);
1349
+ }
1350
+ /**
1351
+ * Register a custom model at runtime (e.g. for BYOK scenarios).
1352
+ *
1353
+ * If a model with the same `id` already exists it will be replaced.
1354
+ *
1355
+ * @param model - The {@link ModelInfo} to add.
1356
+ */
1357
+ addModel(model) {
1358
+ const idx = this.models.findIndex((m) => m.id === model.id);
1359
+ if (idx !== -1) {
1360
+ this.models[idx] = model;
1361
+ this.logger.info({ modelId: model.id }, "Existing model replaced in registry");
1362
+ } else {
1363
+ this.models.push(model);
1364
+ this.logger.info({ modelId: model.id }, "Custom model added to registry");
1365
+ }
1366
+ }
1367
+ /**
1368
+ * Remove a model from the registry.
1369
+ *
1370
+ * @param modelId - The identifier of the model to remove.
1371
+ * @returns `true` if the model was found and removed, `false` otherwise.
1372
+ */
1373
+ removeModel(modelId) {
1374
+ const idx = this.models.findIndex((m) => m.id === modelId);
1375
+ if (idx === -1) {
1376
+ this.logger.warn({ modelId }, "Attempted to remove unknown model");
1377
+ return false;
1378
+ }
1379
+ this.models.splice(idx, 1);
1380
+ this.logger.info({ modelId }, "Model removed from registry");
1381
+ return true;
1382
+ }
1383
+ };
1384
+ function createModelRegistry(preferences) {
1385
+ return new ModelRegistry(preferences);
1386
+ }
1387
+
1388
+ // src/core/app.ts
1389
+ init_client();
1390
+ init_session();
1391
+
1392
+ // src/plugins/registry.ts
1393
+ init_logger();
1394
+ import path2 from "path";
1395
+ import {
1396
+ existsSync as existsSync3,
1397
+ mkdirSync as mkdirSync2,
1398
+ readdirSync,
1399
+ readFileSync as readFileSync3,
1400
+ statSync,
1401
+ writeFileSync as writeFileSync2
1402
+ } from "fs";
1403
+
1404
+ // src/plugins/types.ts
1405
+ import { z as z2 } from "zod";
1406
+ var CredentialRequirementSchema = z2.object({
1407
+ /** Credential key used to look up the value at runtime. */
1408
+ key: z2.string(),
1409
+ /** Human-readable explanation of what this credential is for. */
1410
+ description: z2.string(),
1411
+ /**
1412
+ * Type hint for the credential.
1413
+ * - `"text"` — plain text secret (default)
1414
+ * - `"oauth"` — OAuth token / flow
1415
+ * - `"apikey"` — API key
1416
+ */
1417
+ type: z2.enum(["text", "oauth", "apikey"]).default("text")
1418
+ });
1419
+ var PluginManifestSchema = z2.object({
1420
+ /**
1421
+ * Unique plugin identifier.
1422
+ * Must be kebab-case (`a-z`, `0-9`, and `-` only).
1423
+ */
1424
+ id: z2.string().regex(/^[a-z0-9-]+$/, "Plugin ID must be kebab-case"),
1425
+ /** Human-readable display name. */
1426
+ name: z2.string().min(1),
1427
+ /**
1428
+ * Semantic version string (e.g. `"1.2.3"`).
1429
+ * Must follow strict `MAJOR.MINOR.PATCH` format.
1430
+ */
1431
+ version: z2.string().regex(/^\d+\.\d+\.\d+$/, "Must be semver"),
1432
+ /** Short description of what this plugin does. */
1433
+ description: z2.string(),
1434
+ /** Optional author or organisation name. */
1435
+ author: z2.string().optional(),
1436
+ /**
1437
+ * Credentials the plugin requires to operate.
1438
+ * The loader will verify all required credentials are present before
1439
+ * calling `initialize()`.
1440
+ */
1441
+ requiredCredentials: z2.array(CredentialRequirementSchema).default([]),
1442
+ /**
1443
+ * IDs of other plugins this plugin depends on.
1444
+ * Dependencies are loaded and initialised first.
1445
+ */
1446
+ dependencies: z2.array(z2.string()).default([])
1447
+ });
1448
+
1449
+ // src/plugins/registry.ts
1450
+ init_config();
1451
+ var MANIFEST_FILENAME = "plugin.json";
1452
+ var CONFIG_PATH = "./config.json";
1453
+ var PluginRegistry = class {
1454
+ /**
1455
+ * @param pluginsDir - Absolute or relative path to the directory that
1456
+ * contains plugin subdirectories.
1457
+ */
1458
+ constructor(pluginsDir) {
1459
+ this.pluginsDir = pluginsDir;
1460
+ this.logger = createChildLogger("plugins:registry");
1461
+ }
1462
+ pluginsDir;
1463
+ /** Validated manifests keyed by plugin ID. */
1464
+ manifests = /* @__PURE__ */ new Map();
1465
+ /** Set of currently-enabled plugin IDs. */
1466
+ enabledPlugins = /* @__PURE__ */ new Set();
1467
+ /** Namespaced logger for registry operations. */
1468
+ logger;
1469
+ // -----------------------------------------------------------------------
1470
+ // Discovery
1471
+ // -----------------------------------------------------------------------
1472
+ /**
1473
+ * Scan the plugins directory and discover all available plugins.
1474
+ *
1475
+ * For every subdirectory that contains a valid `plugin.json` the manifest
1476
+ * is parsed, validated with {@link PluginManifestSchema}, and stored.
1477
+ * Invalid or missing manifests are logged as warnings and skipped — they
1478
+ * never crash the application.
1479
+ *
1480
+ * After discovery the enabled state for each plugin is loaded from the
1481
+ * application config (`config.json → app.plugins[id].enabled`).
1482
+ *
1483
+ * @returns Array of all successfully validated {@link PluginManifest}s.
1484
+ */
1485
+ async discoverPlugins() {
1486
+ this.manifests.clear();
1487
+ this.enabledPlugins.clear();
1488
+ if (!existsSync3(this.pluginsDir)) {
1489
+ this.logger.info(
1490
+ { pluginsDir: this.pluginsDir },
1491
+ "Plugins directory does not exist \u2014 creating it"
1492
+ );
1493
+ mkdirSync2(this.pluginsDir, { recursive: true });
1494
+ return [];
1495
+ }
1496
+ const entries = readdirSync(this.pluginsDir);
1497
+ for (const entry of entries) {
1498
+ const entryPath = path2.join(this.pluginsDir, entry);
1499
+ if (!statSync(entryPath).isDirectory()) {
1500
+ continue;
1501
+ }
1502
+ const manifestPath = path2.join(entryPath, MANIFEST_FILENAME);
1503
+ if (!existsSync3(manifestPath)) {
1504
+ this.logger.warn(
1505
+ { dir: entry },
1506
+ `Skipping "${entry}": no ${MANIFEST_FILENAME} found`
1507
+ );
1508
+ continue;
1509
+ }
1510
+ let raw;
1511
+ try {
1512
+ const content = readFileSync3(manifestPath, "utf-8");
1513
+ raw = JSON.parse(content);
1514
+ } catch (err) {
1515
+ this.logger.warn(
1516
+ { dir: entry, error: err.message },
1517
+ `Skipping "${entry}": failed to read or parse ${MANIFEST_FILENAME}`
1518
+ );
1519
+ continue;
1520
+ }
1521
+ const result = PluginManifestSchema.safeParse(raw);
1522
+ if (!result.success) {
1523
+ const issues = result.error.issues.map((i) => ` \u2022 ${i.path.join(".")}: ${i.message}`).join("\n");
1524
+ this.logger.warn(
1525
+ { dir: entry },
1526
+ `Skipping "${entry}": manifest validation failed:
1527
+ ${issues}`
1528
+ );
1529
+ continue;
1530
+ }
1531
+ const manifest = result.data;
1532
+ this.manifests.set(manifest.id, manifest);
1533
+ this.logger.info(
1534
+ { pluginId: manifest.id, version: manifest.version },
1535
+ `Discovered plugin "${manifest.name}"`
1536
+ );
1537
+ }
1538
+ this.loadEnabledState();
1539
+ return this.getManifests();
1540
+ }
1541
+ // -----------------------------------------------------------------------
1542
+ // Manifest accessors
1543
+ // -----------------------------------------------------------------------
1544
+ /**
1545
+ * Get all discovered plugin manifests.
1546
+ *
1547
+ * @returns A shallow copy of the manifests array.
1548
+ */
1549
+ getManifests() {
1550
+ return Array.from(this.manifests.values());
1551
+ }
1552
+ /**
1553
+ * Get a specific plugin's manifest by ID.
1554
+ *
1555
+ * @param pluginId - The unique plugin identifier.
1556
+ * @returns The manifest if found, otherwise `undefined`.
1557
+ */
1558
+ getManifest(pluginId) {
1559
+ return this.manifests.get(pluginId);
1560
+ }
1561
+ // -----------------------------------------------------------------------
1562
+ // Enabled / disabled state
1563
+ // -----------------------------------------------------------------------
1564
+ /**
1565
+ * Check whether a plugin is currently enabled.
1566
+ *
1567
+ * @param pluginId - The unique plugin identifier.
1568
+ * @returns `true` if the plugin is enabled.
1569
+ */
1570
+ isEnabled(pluginId) {
1571
+ return this.enabledPlugins.has(pluginId);
1572
+ }
1573
+ /**
1574
+ * Enable a plugin and persist the state to `config.json`.
1575
+ *
1576
+ * @param pluginId - The unique plugin identifier.
1577
+ */
1578
+ enablePlugin(pluginId) {
1579
+ this.enabledPlugins.add(pluginId);
1580
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" enabled`);
1581
+ this.persistPluginState(pluginId, true);
1582
+ }
1583
+ /**
1584
+ * Disable a plugin and persist the state to `config.json`.
1585
+ *
1586
+ * @param pluginId - The unique plugin identifier.
1587
+ */
1588
+ disablePlugin(pluginId) {
1589
+ this.enabledPlugins.delete(pluginId);
1590
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" disabled`);
1591
+ this.persistPluginState(pluginId, false);
1592
+ }
1593
+ /**
1594
+ * Get the list of currently-enabled plugin IDs.
1595
+ *
1596
+ * @returns Array of enabled plugin ID strings.
1597
+ */
1598
+ getEnabledPluginIds() {
1599
+ return Array.from(this.enabledPlugins);
1600
+ }
1601
+ /**
1602
+ * Get the list of all discovered plugin IDs.
1603
+ *
1604
+ * @returns Array of all known plugin ID strings.
1605
+ */
1606
+ getAllPluginIds() {
1607
+ return Array.from(this.manifests.keys());
1608
+ }
1609
+ // -----------------------------------------------------------------------
1610
+ // Private helpers
1611
+ // -----------------------------------------------------------------------
1612
+ /**
1613
+ * Load the enabled state for every discovered plugin from the application
1614
+ * config. Plugins whose config entry has `enabled: true` are added to the
1615
+ * enabled set.
1616
+ */
1617
+ loadEnabledState() {
1618
+ let appConfig;
1619
+ try {
1620
+ if (existsSync3(CONFIG_PATH)) {
1621
+ const raw = JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
1622
+ appConfig = AppConfigSchema.parse(raw);
1623
+ } else {
1624
+ appConfig = AppConfigSchema.parse({});
1625
+ }
1626
+ } catch {
1627
+ this.logger.warn("Could not load config.json \u2014 defaulting all plugins to disabled");
1628
+ return;
1629
+ }
1630
+ const plugins = appConfig.plugins ?? {};
1631
+ for (const id of this.manifests.keys()) {
1632
+ if (plugins[id]?.enabled === true) {
1633
+ this.enabledPlugins.add(id);
1634
+ this.logger.debug({ pluginId: id }, `Plugin "${id}" is enabled via config`);
1635
+ }
1636
+ }
1637
+ }
1638
+ /**
1639
+ * Persist the enabled/disabled state for a single plugin to `config.json`.
1640
+ *
1641
+ * Reads the current file, updates the relevant entry, and writes it back.
1642
+ * If the file does not exist a minimal config is created.
1643
+ */
1644
+ persistPluginState(pluginId, enabled) {
1645
+ try {
1646
+ let config = {};
1647
+ if (existsSync3(CONFIG_PATH)) {
1648
+ config = JSON.parse(readFileSync3(CONFIG_PATH, "utf-8"));
1649
+ }
1650
+ if (!config.plugins || typeof config.plugins !== "object") {
1651
+ config.plugins = {};
1652
+ }
1653
+ const plugins = config.plugins;
1654
+ if (!plugins[pluginId]) {
1655
+ plugins[pluginId] = { enabled, credentials: {} };
1656
+ } else {
1657
+ plugins[pluginId].enabled = enabled;
1658
+ }
1659
+ writeFileSync2(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
1660
+ resetConfig();
1661
+ this.logger.debug(
1662
+ { pluginId, enabled },
1663
+ "Persisted plugin state to config.json"
1664
+ );
1665
+ } catch (err) {
1666
+ this.logger.error(
1667
+ { pluginId, error: err.message },
1668
+ "Failed to persist plugin state to config.json"
1669
+ );
1670
+ }
1671
+ }
1672
+ };
1673
+ function createPluginRegistry(pluginsDir) {
1674
+ return new PluginRegistry(pluginsDir ?? path2.join(process.cwd(), "plugins"));
1675
+ }
1676
+
1677
+ // src/plugins/manager.ts
1678
+ init_logger();
1679
+ import path3 from "path";
1680
+ import { existsSync as existsSync4 } from "fs";
1681
+ import { tsImport } from "tsx/esm/api";
1682
+ var PluginManager = class {
1683
+ constructor(registry, sandbox, credentials, stateRepo) {
1684
+ this.registry = registry;
1685
+ this.sandbox = sandbox;
1686
+ this.credentials = credentials;
1687
+ this.stateRepo = stateRepo;
1688
+ this.logger = createChildLogger("plugins:manager");
1689
+ }
1690
+ registry;
1691
+ sandbox;
1692
+ credentials;
1693
+ stateRepo;
1694
+ /** Active (loaded and initialised) plugin instances keyed by ID. */
1695
+ plugins = /* @__PURE__ */ new Map();
1696
+ /** Current lifecycle status for every known plugin. */
1697
+ pluginStatuses = /* @__PURE__ */ new Map();
1698
+ /** Namespaced logger for manager operations. */
1699
+ logger;
1700
+ // -----------------------------------------------------------------------
1701
+ // Initialisation
1702
+ // -----------------------------------------------------------------------
1703
+ /**
1704
+ * Initialise the plugin system: discover plugins on disk, then load and
1705
+ * initialise every enabled plugin.
1706
+ *
1707
+ * Safe to call exactly once at application startup. Errors from individual
1708
+ * plugins are caught and logged — a single broken plugin never prevents the
1709
+ * rest from loading.
1710
+ */
1711
+ async initialize() {
1712
+ this.logger.info("Initializing plugin system\u2026");
1713
+ const manifests = await this.registry.discoverPlugins();
1714
+ const discovered = manifests.length;
1715
+ let loaded = 0;
1716
+ let failed = 0;
1717
+ const enabledIds = this.registry.getEnabledPluginIds();
1718
+ for (const pluginId of enabledIds) {
1719
+ try {
1720
+ await this.loadPlugin(pluginId);
1721
+ loaded++;
1722
+ } catch (err) {
1723
+ failed++;
1724
+ const error = err instanceof Error ? err : new Error(String(err));
1725
+ this.logger.error(
1726
+ { pluginId, error: error.message },
1727
+ `Failed to load plugin "${pluginId}"`
1728
+ );
1729
+ this.pluginStatuses.set(pluginId, "error");
1730
+ }
1731
+ }
1732
+ this.logger.info(
1733
+ { discovered, loaded, failed },
1734
+ `Plugin system ready \u2014 ${discovered} discovered, ${loaded} loaded, ${failed} failed`
1735
+ );
1736
+ }
1737
+ // -----------------------------------------------------------------------
1738
+ // Load / Unload
1739
+ // -----------------------------------------------------------------------
1740
+ /**
1741
+ * Load and initialise a specific plugin by ID.
1742
+ *
1743
+ * Steps:
1744
+ * 1. Resolve the plugin manifest from the registry.
1745
+ * 2. Validate credentials via the {@link CredentialManager}.
1746
+ * 3. Dynamically import the plugin module (preferring compiled `.js`).
1747
+ * 4. Invoke the factory to obtain a {@link CoAssistantPlugin} instance.
1748
+ * 5. Build a {@link PluginContext} and call `plugin.initialize()` inside
1749
+ * the sandbox.
1750
+ * 6. Store the active plugin and set its status to `"active"`.
1751
+ *
1752
+ * @param pluginId - Unique identifier of the plugin to load.
1753
+ * @throws If the manifest is not found, credentials are invalid, or the
1754
+ * module cannot be imported.
1755
+ */
1756
+ async loadPlugin(pluginId) {
1757
+ this.logger.info({ pluginId }, `Loading plugin "${pluginId}"\u2026`);
1758
+ const manifest = this.registry.getManifest(pluginId);
1759
+ if (!manifest) {
1760
+ throw new Error(`No manifest found for plugin "${pluginId}"`);
1761
+ }
1762
+ this.pluginStatuses.set(pluginId, "loaded");
1763
+ let creds = {};
1764
+ try {
1765
+ creds = this.credentials.getValidatedCredentials(
1766
+ pluginId,
1767
+ manifest.requiredCredentials
1768
+ );
1769
+ } catch (err) {
1770
+ const error = err instanceof Error ? err : new Error(String(err));
1771
+ this.logger.warn(
1772
+ { pluginId, error: error.message },
1773
+ `Credential validation failed for "${pluginId}" \u2014 loading with empty credentials`
1774
+ );
1775
+ }
1776
+ const pluginPath = this.resolvePluginPath(pluginId);
1777
+ this.logger.debug({ pluginId, pluginPath }, "Importing plugin module");
1778
+ let mod;
1779
+ try {
1780
+ if (pluginPath.endsWith(".ts")) {
1781
+ mod = await tsImport(pluginPath, import.meta.url);
1782
+ } else {
1783
+ mod = await import(pluginPath);
1784
+ }
1785
+ } catch (err) {
1786
+ const error = err instanceof Error ? err : new Error(String(err));
1787
+ this.pluginStatuses.set(pluginId, "error");
1788
+ throw new Error(
1789
+ `Failed to import plugin module "${pluginId}" from ${pluginPath}: ${error.message}`
1790
+ );
1791
+ }
1792
+ const factory = typeof mod.default === "function" ? mod.default : typeof mod.createPlugin === "function" ? mod.createPlugin : void 0;
1793
+ if (!factory) {
1794
+ this.pluginStatuses.set(pluginId, "error");
1795
+ throw new Error(
1796
+ `Plugin "${pluginId}" does not export a default factory or createPlugin function`
1797
+ );
1798
+ }
1799
+ const plugin = factory();
1800
+ const context = this.buildPluginContext(pluginId, creds);
1801
+ const initResult = await this.sandbox.safeExecute(
1802
+ pluginId,
1803
+ "initialize",
1804
+ () => plugin.initialize(context)
1805
+ );
1806
+ if (initResult === void 0 && manifest.requiredCredentials.length > 0) {
1807
+ this.logger.warn(
1808
+ { pluginId },
1809
+ `Plugin "${pluginId}" initialize() did not succeed \u2014 marking as error`
1810
+ );
1811
+ }
1812
+ this.plugins.set(pluginId, plugin);
1813
+ this.pluginStatuses.set(pluginId, "active");
1814
+ this.logger.info(
1815
+ { pluginId, version: manifest.version },
1816
+ `Plugin "${manifest.name}" v${manifest.version} loaded successfully`
1817
+ );
1818
+ }
1819
+ /**
1820
+ * Unload a plugin: invoke its `destroy()` hook inside the sandbox,
1821
+ * remove it from the active plugin map, and mark it as unloaded.
1822
+ *
1823
+ * @param pluginId - Unique identifier of the plugin to unload.
1824
+ */
1825
+ async unloadPlugin(pluginId) {
1826
+ const plugin = this.plugins.get(pluginId);
1827
+ if (!plugin) {
1828
+ this.logger.warn({ pluginId }, `Plugin "${pluginId}" is not loaded \u2014 nothing to unload`);
1829
+ return;
1830
+ }
1831
+ this.logger.info({ pluginId }, `Unloading plugin "${pluginId}"\u2026`);
1832
+ await this.sandbox.safeExecute(pluginId, "destroy", () => plugin.destroy());
1833
+ this.plugins.delete(pluginId);
1834
+ this.pluginStatuses.set(pluginId, "unloaded");
1835
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" unloaded`);
1836
+ }
1837
+ // -----------------------------------------------------------------------
1838
+ // Enable / Disable
1839
+ // -----------------------------------------------------------------------
1840
+ /**
1841
+ * Enable a plugin: persist the enabled state in the registry and load the
1842
+ * plugin if it is not already active.
1843
+ *
1844
+ * @param pluginId - Unique identifier of the plugin to enable.
1845
+ */
1846
+ async enablePlugin(pluginId) {
1847
+ this.logger.info({ pluginId }, `Enabling plugin "${pluginId}"`);
1848
+ this.registry.enablePlugin(pluginId);
1849
+ if (!this.plugins.has(pluginId)) {
1850
+ await this.loadPlugin(pluginId);
1851
+ }
1852
+ }
1853
+ /**
1854
+ * Disable a plugin: persist the disabled state in the registry and unload
1855
+ * the plugin if it is currently active.
1856
+ *
1857
+ * @param pluginId - Unique identifier of the plugin to disable.
1858
+ */
1859
+ async disablePlugin(pluginId) {
1860
+ this.logger.info({ pluginId }, `Disabling plugin "${pluginId}"`);
1861
+ this.registry.disablePlugin(pluginId);
1862
+ if (this.plugins.has(pluginId)) {
1863
+ await this.unloadPlugin(pluginId);
1864
+ }
1865
+ this.pluginStatuses.set(pluginId, "disabled");
1866
+ }
1867
+ // -----------------------------------------------------------------------
1868
+ // Accessors
1869
+ // -----------------------------------------------------------------------
1870
+ /**
1871
+ * Get all active (loaded and running) plugin instances.
1872
+ *
1873
+ * @returns A new Map containing only the currently active plugins.
1874
+ */
1875
+ getActivePlugins() {
1876
+ return new Map(this.plugins);
1877
+ }
1878
+ /**
1879
+ * Collect all tool definitions from every active plugin.
1880
+ *
1881
+ * Tool names are prefixed with the owning plugin's ID
1882
+ * (`<pluginId>__<toolName>`) to guarantee uniqueness. Handlers are wrapped
1883
+ * by the sandbox so errors are isolated.
1884
+ *
1885
+ * @returns Flat array of all tools across all active plugins.
1886
+ */
1887
+ getAllTools() {
1888
+ const tools = [];
1889
+ for (const [pluginId, plugin] of this.plugins) {
1890
+ try {
1891
+ const pluginTools = plugin.getTools();
1892
+ for (const tool of pluginTools) {
1893
+ tools.push({
1894
+ name: `${pluginId}__${tool.name}`,
1895
+ description: tool.description,
1896
+ parameters: tool.parameters,
1897
+ handler: this.sandbox.wrapToolHandler(pluginId, tool.name, tool.handler)
1898
+ });
1899
+ }
1900
+ } catch (err) {
1901
+ const error = err instanceof Error ? err : new Error(String(err));
1902
+ this.logger.error(
1903
+ { pluginId, error: error.message },
1904
+ `Failed to get tools from plugin "${pluginId}"`
1905
+ );
1906
+ }
1907
+ }
1908
+ return tools;
1909
+ }
1910
+ /**
1911
+ * Build a read-only info snapshot for every discovered plugin.
1912
+ *
1913
+ * Combines manifest metadata, current lifecycle status, enabled state,
1914
+ * sandbox failure counts, and registered tool names.
1915
+ *
1916
+ * @returns Array of {@link PluginInfo} objects (for display / admin API).
1917
+ */
1918
+ getPluginInfoList() {
1919
+ const manifests = this.registry.getManifests();
1920
+ return manifests.map((manifest) => {
1921
+ const pluginId = manifest.id;
1922
+ const status = this.pluginStatuses.get(pluginId) ?? "unloaded";
1923
+ const enabled = this.registry.isEnabled(pluginId);
1924
+ const failureCount = this.sandbox.getFailureCount(pluginId);
1925
+ let toolNames = [];
1926
+ const plugin = this.plugins.get(pluginId);
1927
+ if (plugin) {
1928
+ try {
1929
+ toolNames = plugin.getTools().map((t) => `${pluginId}__${t.name}`);
1930
+ } catch {
1931
+ }
1932
+ }
1933
+ const info = {
1934
+ id: pluginId,
1935
+ name: manifest.name,
1936
+ version: manifest.version,
1937
+ description: manifest.description,
1938
+ status,
1939
+ enabled,
1940
+ failureCount,
1941
+ tools: toolNames
1942
+ };
1943
+ if (status === "error") {
1944
+ info.errorMessage = this.sandbox.isDisabled(pluginId) ? "Auto-disabled due to repeated failures" : "Plugin encountered an error during lifecycle";
1945
+ }
1946
+ return info;
1947
+ });
1948
+ }
1949
+ /**
1950
+ * Get info about a specific discovered plugin.
1951
+ *
1952
+ * @param pluginId - The unique plugin identifier.
1953
+ * @returns The plugin info snapshot, or `undefined` if the plugin was not
1954
+ * discovered.
1955
+ */
1956
+ getPluginInfo(pluginId) {
1957
+ return this.getPluginInfoList().find((info) => info.id === pluginId);
1958
+ }
1959
+ // -----------------------------------------------------------------------
1960
+ // Shutdown
1961
+ // -----------------------------------------------------------------------
1962
+ /**
1963
+ * Gracefully shut down all active plugins.
1964
+ *
1965
+ * Calls `destroy()` on each plugin inside the sandbox so that a failing
1966
+ * plugin does not prevent the rest from cleaning up.
1967
+ */
1968
+ async shutdown() {
1969
+ this.logger.info("Shutting down plugin system\u2026");
1970
+ const pluginIds = Array.from(this.plugins.keys());
1971
+ for (const pluginId of pluginIds) {
1972
+ await this.unloadPlugin(pluginId);
1973
+ }
1974
+ this.logger.info(
1975
+ { count: pluginIds.length },
1976
+ `Plugin system shut down \u2014 ${pluginIds.length} plugin(s) unloaded`
1977
+ );
1978
+ }
1979
+ // -----------------------------------------------------------------------
1980
+ // Private helpers
1981
+ // -----------------------------------------------------------------------
1982
+ /**
1983
+ * Resolve the file-system path for a plugin module.
1984
+ *
1985
+ * Prefers the compiled `.js` file; falls back to `.ts` for development.
1986
+ */
1987
+ resolvePluginPath(pluginId) {
1988
+ const baseDir = path3.join(process.cwd(), "plugins", pluginId);
1989
+ const jsPath = path3.join(baseDir, "index.js");
1990
+ if (existsSync4(jsPath)) {
1991
+ return jsPath;
1992
+ }
1993
+ const tsPath = path3.join(baseDir, "index.ts");
1994
+ if (existsSync4(tsPath)) {
1995
+ return tsPath;
1996
+ }
1997
+ return jsPath;
1998
+ }
1999
+ /**
2000
+ * Build a {@link PluginContext} for a specific plugin.
2001
+ *
2002
+ * The context provides credentials, a namespaced state store backed by the
2003
+ * {@link PluginStateRepository}, and a child logger.
2004
+ */
2005
+ buildPluginContext(pluginId, credentials) {
2006
+ const stateStore = {
2007
+ get: (key) => this.stateRepo.get(pluginId, key),
2008
+ set: (key, value) => this.stateRepo.set(pluginId, key, value),
2009
+ delete: (key) => this.stateRepo.delete(pluginId, key),
2010
+ getAll: () => this.stateRepo.getAll(pluginId)
2011
+ };
2012
+ return {
2013
+ pluginId,
2014
+ credentials,
2015
+ state: stateStore,
2016
+ logger: createChildLogger(`plugin:${pluginId}`, { pluginId })
2017
+ };
2018
+ }
2019
+ };
2020
+ function createPluginManager(registry, sandbox, credentials, stateRepo) {
2021
+ return new PluginManager(registry, sandbox, credentials, stateRepo);
2022
+ }
2023
+
2024
+ // src/plugins/sandbox.ts
2025
+ init_logger();
2026
+ var DEFAULT_MAX_FAILURES = 5;
2027
+ var PluginSandbox = class {
2028
+ /** Consecutive failure counts keyed by plugin ID. */
2029
+ failureCounts = /* @__PURE__ */ new Map();
2030
+ /** Set of plugin IDs that have been auto-disabled due to repeated failures. */
2031
+ disabledPlugins = /* @__PURE__ */ new Set();
2032
+ /** Maximum consecutive failures before a plugin is disabled. */
2033
+ maxFailures;
2034
+ /** Namespaced logger for sandbox events. */
2035
+ logger;
2036
+ /**
2037
+ * Create a new sandbox instance.
2038
+ *
2039
+ * @param maxFailures - Override for the failure threshold. When omitted the
2040
+ * value is read from the application config (`app.pluginHealth.maxFailures`)
2041
+ * with a hard-coded fallback of {@link DEFAULT_MAX_FAILURES}.
2042
+ */
2043
+ constructor(maxFailures) {
2044
+ this.logger = createChildLogger("plugins:sandbox");
2045
+ if (maxFailures !== void 0) {
2046
+ this.maxFailures = maxFailures;
2047
+ } else {
2048
+ try {
2049
+ const { getConfig: getConfig2 } = (init_config(), __toCommonJS(config_exports));
2050
+ this.maxFailures = getConfig2().app.pluginHealth.maxFailures ?? DEFAULT_MAX_FAILURES;
2051
+ } catch {
2052
+ this.maxFailures = DEFAULT_MAX_FAILURES;
2053
+ }
2054
+ }
2055
+ }
2056
+ // -----------------------------------------------------------------------
2057
+ // Core execution wrapper
2058
+ // -----------------------------------------------------------------------
2059
+ /**
2060
+ * Safely execute a plugin method within a try/catch boundary.
2061
+ *
2062
+ * If the plugin is disabled the call is short-circuited and `undefined` is
2063
+ * returned. On success the failure counter is reset; on failure it is
2064
+ * incremented and the error is logged with full context.
2065
+ *
2066
+ * **Critical:** errors are _never_ allowed to propagate out of this method.
2067
+ *
2068
+ * @param pluginId - Unique identifier of the plugin.
2069
+ * @param methodName - Name of the method being invoked (for logging).
2070
+ * @param fn - The async function to execute.
2071
+ * @returns The result of `fn()` on success, or `undefined` on failure.
2072
+ */
2073
+ async safeExecute(pluginId, methodName, fn) {
2074
+ try {
2075
+ if (this.isDisabled(pluginId)) {
2076
+ this.logger.warn(
2077
+ { pluginId, methodName },
2078
+ `Plugin "${pluginId}" is disabled \u2014 skipping ${methodName}`
2079
+ );
2080
+ return void 0;
2081
+ }
2082
+ const result = await fn();
2083
+ this.recordSuccess(pluginId);
2084
+ return result;
2085
+ } catch (err) {
2086
+ const error = err instanceof Error ? err : new Error(String(err));
2087
+ this.recordFailure(pluginId, error, methodName);
2088
+ this.logger.error(
2089
+ {
2090
+ pluginId,
2091
+ methodName,
2092
+ error: error.message,
2093
+ failureCount: this.getFailureCount(pluginId),
2094
+ maxFailures: this.maxFailures
2095
+ },
2096
+ `Plugin "${pluginId}" method "${methodName}" threw: ${error.message}`
2097
+ );
2098
+ return void 0;
2099
+ }
2100
+ }
2101
+ // -----------------------------------------------------------------------
2102
+ // Tool handler wrapper
2103
+ // -----------------------------------------------------------------------
2104
+ /**
2105
+ * Wrap a tool handler so that errors are caught and returned as a
2106
+ * descriptive string rather than crashing the process.
2107
+ *
2108
+ * The AI model therefore receives actionable feedback about the failure
2109
+ * instead of an unhandled exception.
2110
+ *
2111
+ * @param pluginId - Unique identifier of the owning plugin.
2112
+ * @param toolName - Display name of the tool.
2113
+ * @param handler - The original tool handler function.
2114
+ * @returns A wrapped handler with identical signature.
2115
+ */
2116
+ wrapToolHandler(pluginId, toolName, handler) {
2117
+ return async (args) => {
2118
+ const result = await this.safeExecute(
2119
+ pluginId,
2120
+ `tool:${toolName}`,
2121
+ () => handler(args)
2122
+ );
2123
+ if (result === void 0) {
2124
+ return `Error: Tool ${toolName} failed: ${this.isDisabled(pluginId) ? "plugin has been disabled due to repeated failures" : "unexpected error during execution"}`;
2125
+ }
2126
+ return result;
2127
+ };
2128
+ }
2129
+ // -----------------------------------------------------------------------
2130
+ // Failure / success tracking
2131
+ // -----------------------------------------------------------------------
2132
+ /**
2133
+ * Record a failure for a plugin and auto-disable it if the threshold is
2134
+ * reached.
2135
+ *
2136
+ * @param pluginId - Unique identifier of the plugin.
2137
+ * @param error - The error that occurred.
2138
+ * @param methodName - The method that failed (for logging).
2139
+ * @returns `true` if the plugin was auto-disabled as a result of this failure.
2140
+ */
2141
+ recordFailure(pluginId, error, methodName) {
2142
+ const current = (this.failureCounts.get(pluginId) ?? 0) + 1;
2143
+ this.failureCounts.set(pluginId, current);
2144
+ try {
2145
+ const { PluginHealthRepository: PluginHealthRepository2 } = (init_plugin_health(), __toCommonJS(plugin_health_exports));
2146
+ const repo = new PluginHealthRepository2();
2147
+ repo.logHealth(pluginId, "error", error.message);
2148
+ } catch {
2149
+ }
2150
+ if (current >= this.maxFailures && !this.disabledPlugins.has(pluginId)) {
2151
+ this.disabledPlugins.add(pluginId);
2152
+ this.logger.warn(
2153
+ {
2154
+ pluginId,
2155
+ methodName,
2156
+ failureCount: current,
2157
+ maxFailures: this.maxFailures
2158
+ },
2159
+ `Plugin "${pluginId}" auto-disabled after ${current} consecutive failures (threshold: ${this.maxFailures})`
2160
+ );
2161
+ return true;
2162
+ }
2163
+ return false;
2164
+ }
2165
+ /**
2166
+ * Record a successful execution for a plugin.
2167
+ *
2168
+ * Resets the consecutive failure counter to zero — a successful call proves
2169
+ * the plugin is healthy.
2170
+ *
2171
+ * @param pluginId - Unique identifier of the plugin.
2172
+ */
2173
+ recordSuccess(pluginId) {
2174
+ this.failureCounts.set(pluginId, 0);
2175
+ }
2176
+ // -----------------------------------------------------------------------
2177
+ // Status queries
2178
+ // -----------------------------------------------------------------------
2179
+ /**
2180
+ * Check whether a plugin has been auto-disabled due to repeated failures.
2181
+ *
2182
+ * @param pluginId - Unique identifier of the plugin.
2183
+ * @returns `true` if the plugin is currently disabled.
2184
+ */
2185
+ isDisabled(pluginId) {
2186
+ return this.disabledPlugins.has(pluginId);
2187
+ }
2188
+ /**
2189
+ * Get the current consecutive failure count for a plugin.
2190
+ *
2191
+ * @param pluginId - Unique identifier of the plugin.
2192
+ * @returns The number of consecutive failures (0 when not tracked).
2193
+ */
2194
+ getFailureCount(pluginId) {
2195
+ return this.failureCounts.get(pluginId) ?? 0;
2196
+ }
2197
+ /**
2198
+ * Reset the failure counter and re-enable a previously disabled plugin.
2199
+ *
2200
+ * @param pluginId - Unique identifier of the plugin.
2201
+ */
2202
+ resetPlugin(pluginId) {
2203
+ this.failureCounts.delete(pluginId);
2204
+ this.disabledPlugins.delete(pluginId);
2205
+ this.logger.info({ pluginId }, `Plugin "${pluginId}" has been reset and re-enabled`);
2206
+ }
2207
+ /**
2208
+ * Build a health summary for every plugin the sandbox has interacted with.
2209
+ *
2210
+ * @returns A map from plugin ID to its current failure count and disabled
2211
+ * status.
2212
+ */
2213
+ getHealthSummary() {
2214
+ const summary = /* @__PURE__ */ new Map();
2215
+ for (const [pluginId, failures] of this.failureCounts) {
2216
+ summary.set(pluginId, {
2217
+ failures,
2218
+ disabled: this.disabledPlugins.has(pluginId)
2219
+ });
2220
+ }
2221
+ for (const pluginId of this.disabledPlugins) {
2222
+ if (!summary.has(pluginId)) {
2223
+ summary.set(pluginId, {
2224
+ failures: 0,
2225
+ disabled: true
2226
+ });
2227
+ }
2228
+ }
2229
+ return summary;
2230
+ }
2231
+ };
2232
+ var pluginSandbox = new PluginSandbox();
2233
+
2234
+ // src/plugins/credentials.ts
2235
+ init_config();
2236
+ init_errors();
2237
+ init_logger();
2238
+ var CredentialManager = class {
2239
+ logger;
2240
+ constructor() {
2241
+ this.logger = createChildLogger("plugins:credentials");
2242
+ }
2243
+ /**
2244
+ * Get credentials for a plugin from config.json.
2245
+ * Returns the credentials `Record` or an empty object if not configured.
2246
+ */
2247
+ getPluginCredentials(pluginId) {
2248
+ const { app } = getConfig();
2249
+ return app.plugins[pluginId]?.credentials ?? {};
2250
+ }
2251
+ /**
2252
+ * Validate that all required credentials are present and non-empty.
2253
+ *
2254
+ * @returns `{ valid: true }` when every required key is present with a
2255
+ * non-empty value, or `{ valid: false, missing }` listing the keys that
2256
+ * are absent or empty.
2257
+ */
2258
+ validateCredentials(pluginId, requiredCredentials) {
2259
+ const credentials = this.getPluginCredentials(pluginId);
2260
+ const missing = requiredCredentials.filter(({ key }) => {
2261
+ const value = credentials[key];
2262
+ return value === void 0 || value === "";
2263
+ }).map(({ key }) => key);
2264
+ if (missing.length === 0) {
2265
+ this.logger.debug({ pluginId }, "All required credentials present");
2266
+ return { valid: true };
2267
+ }
2268
+ this.logger.warn(
2269
+ { pluginId, missing },
2270
+ "Missing required credentials for plugin"
2271
+ );
2272
+ return { valid: false, missing };
2273
+ }
2274
+ /**
2275
+ * Get credentials for a plugin, throwing {@link PluginError} if any
2276
+ * required ones are missing.
2277
+ *
2278
+ * This is the main method plugins use during initialisation.
2279
+ *
2280
+ * @throws {PluginError} with code `PLUGIN_CREDENTIALS_MISSING` when one or
2281
+ * more required keys are absent or empty.
2282
+ */
2283
+ getValidatedCredentials(pluginId, requiredCredentials) {
2284
+ const result = this.validateCredentials(pluginId, requiredCredentials);
2285
+ if (!result.valid) {
2286
+ throw PluginError.credentialsMissing(pluginId, result.missing);
2287
+ }
2288
+ this.logger.info(
2289
+ { pluginId, count: requiredCredentials.length },
2290
+ "Credentials validated successfully"
2291
+ );
2292
+ return this.getPluginCredentials(pluginId);
2293
+ }
2294
+ /**
2295
+ * Check whether a plugin has all required credentials configured.
2296
+ *
2297
+ * Non-throwing convenience wrapper around {@link validateCredentials}.
2298
+ */
2299
+ hasRequiredCredentials(pluginId, requiredCredentials) {
2300
+ return this.validateCredentials(pluginId, requiredCredentials).valid;
2301
+ }
2302
+ /**
2303
+ * Get a summary of credential status for all known plugins.
2304
+ * Useful for the CLI status command.
2305
+ *
2306
+ * @param plugins - Array of plugin descriptors containing their id and
2307
+ * required credential definitions.
2308
+ * @returns Per-plugin breakdown of which credentials are configured vs
2309
+ * missing.
2310
+ */
2311
+ getCredentialStatus(plugins) {
2312
+ return plugins.map(({ id, requiredCredentials }) => {
2313
+ const credentials = this.getPluginCredentials(id);
2314
+ const configured = [];
2315
+ const missing = [];
2316
+ for (const { key } of requiredCredentials) {
2317
+ const value = credentials[key];
2318
+ if (value !== void 0 && value !== "") {
2319
+ configured.push(key);
2320
+ } else {
2321
+ missing.push(key);
2322
+ }
2323
+ }
2324
+ return { pluginId: id, configured, missing };
2325
+ });
2326
+ }
2327
+ };
2328
+ var credentialManager = new CredentialManager();
2329
+
2330
+ // src/bot/bot.ts
2331
+ init_logger();
2332
+ import { Telegraf } from "telegraf";
2333
+ import { message } from "telegraf/filters";
2334
+
2335
+ // src/bot/middleware/logging.ts
2336
+ init_logger();
2337
+ var logger4 = createChildLogger("bot:middleware:logging");
2338
+ function createLoggingMiddleware() {
2339
+ return async (ctx, next) => {
2340
+ const start = Date.now();
2341
+ const updateId = ctx.update.update_id;
2342
+ const updateType = ctx.updateType;
2343
+ const userId = ctx.from?.id;
2344
+ const rawText = (ctx.message && "text" in ctx.message ? ctx.message.text : void 0) ?? (ctx.callbackQuery && "data" in ctx.callbackQuery ? ctx.callbackQuery.data : void 0);
2345
+ const preview = rawText ? rawText.slice(0, 50) : void 0;
2346
+ logger4.debug(
2347
+ { updateId, updateType, userId, preview },
2348
+ "Incoming update"
2349
+ );
2350
+ await next();
2351
+ const durationMs = Date.now() - start;
2352
+ logger4.debug(
2353
+ { updateId, updateType, userId, durationMs },
2354
+ "Update processed"
2355
+ );
2356
+ };
2357
+ }
2358
+
2359
+ // src/bot/middleware/auth.ts
2360
+ init_logger();
2361
+ var logger5 = createChildLogger("bot:auth");
2362
+ function createAuthMiddleware(allowedUserId) {
2363
+ return async (ctx, next) => {
2364
+ const senderId = ctx.from?.id;
2365
+ if (senderId === void 0) {
2366
+ logger5.warn(
2367
+ { updateId: ctx.update.update_id },
2368
+ "Update has no sender \u2014 dropping"
2369
+ );
2370
+ return;
2371
+ }
2372
+ if (senderId !== allowedUserId) {
2373
+ const username = ctx.from?.username;
2374
+ logger5.warn(
2375
+ { senderId, username, allowedUserId, updateId: ctx.update.update_id },
2376
+ "Unauthorized access attempt \u2014 dropping update"
2377
+ );
2378
+ return;
2379
+ }
2380
+ await next();
2381
+ };
2382
+ }
2383
+
2384
+ // src/bot/bot.ts
2385
+ var TelegramBot = class {
2386
+ bot;
2387
+ isRunning = false;
2388
+ logger;
2389
+ /**
2390
+ * @param token - The Telegram Bot API token obtained from BotFather.
2391
+ */
2392
+ constructor(token) {
2393
+ this.bot = new Telegraf(token);
2394
+ this.logger = createChildLogger("bot");
2395
+ }
2396
+ // -----------------------------------------------------------------------
2397
+ // Initialisation
2398
+ // -----------------------------------------------------------------------
2399
+ /**
2400
+ * Register the middleware stack and message / command handlers.
2401
+ *
2402
+ * **Must** be called before {@link launch}. Middleware is applied in the
2403
+ * following order:
2404
+ *
2405
+ * 1. Logging middleware — logs every incoming update
2406
+ * 2. Auth guard middleware — drops unauthorised updates
2407
+ * 3. Error-handling middleware — catches downstream errors
2408
+ * 4. Command handler (if `onCommand` provided)
2409
+ * 5. Text-message handler
2410
+ * 6. Catch-all for unhandled update types
2411
+ */
2412
+ initialize(options) {
2413
+ const { allowedUserId, onMessage, onCommand } = options;
2414
+ this.bot.use(createLoggingMiddleware());
2415
+ this.bot.use(createAuthMiddleware(allowedUserId));
2416
+ this.bot.use(async (ctx, next) => {
2417
+ try {
2418
+ await next();
2419
+ } catch (err) {
2420
+ const errorMessage = err instanceof Error ? err.message : String(err);
2421
+ this.logger.error(
2422
+ { err, updateId: ctx.update.update_id },
2423
+ "Unhandled error in middleware chain"
2424
+ );
2425
+ try {
2426
+ await ctx.reply(
2427
+ "\u26A0\uFE0F An unexpected error occurred. Please try again later."
2428
+ );
2429
+ } catch (replyErr) {
2430
+ this.logger.error(
2431
+ { err: replyErr },
2432
+ "Failed to send error reply to user"
2433
+ );
2434
+ }
2435
+ }
2436
+ });
2437
+ if (onCommand) {
2438
+ this.bot.on(message("text"), async (ctx, next) => {
2439
+ const text = ctx.message.text;
2440
+ if (!text.startsWith("/")) {
2441
+ return next();
2442
+ }
2443
+ const parts = text.slice(1).split(/\s+/);
2444
+ const command = parts[0] ?? "";
2445
+ const cleanCommand = command.split("@")[0];
2446
+ const args = parts.slice(1).join(" ");
2447
+ onCommand(ctx, cleanCommand, args).catch((err) => {
2448
+ this.logger.error({ err, command: cleanCommand }, "Unhandled error in command handler");
2449
+ });
2450
+ });
2451
+ }
2452
+ this.bot.on(message("text"), async (ctx) => {
2453
+ onMessage(ctx, ctx.message.text).catch((err) => {
2454
+ this.logger.error({ err }, "Unhandled error in message handler");
2455
+ });
2456
+ });
2457
+ this.bot.on("message", (ctx) => {
2458
+ this.logger.debug(
2459
+ { updateType: ctx.updateType, messageType: "non-text" },
2460
+ "Received non-text message \u2014 ignoring"
2461
+ );
2462
+ });
2463
+ this.logger.info("Bot initialised \u2014 middleware and handlers registered");
2464
+ }
2465
+ // -----------------------------------------------------------------------
2466
+ // Lifecycle
2467
+ // -----------------------------------------------------------------------
2468
+ /**
2469
+ * Start the bot in long-polling mode.
2470
+ *
2471
+ * Telegraf's `launch()` never resolves — it runs the polling loop forever.
2472
+ * We use the `onLaunch` callback to detect when the initial handshake
2473
+ * (getMe + deleteWebhook) succeeds, then let polling continue in the
2474
+ * background.
2475
+ *
2476
+ * @throws {Error} If the Telegram API is unreachable after all retry attempts.
2477
+ */
2478
+ async launch() {
2479
+ if (this.isRunning) {
2480
+ this.logger.warn("launch() called but bot is already running");
2481
+ return;
2482
+ }
2483
+ const maxRetries = 3;
2484
+ const retryDelayMs = 5e3;
2485
+ const connectTimeoutMs = 3e4;
2486
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
2487
+ try {
2488
+ await new Promise((resolve, reject) => {
2489
+ const timer = setTimeout(
2490
+ () => reject(Object.assign(
2491
+ new Error("Telegram connection timed out"),
2492
+ { code: "ETIMEDOUT" }
2493
+ )),
2494
+ connectTimeoutMs
2495
+ );
2496
+ this.bot.launch(() => {
2497
+ clearTimeout(timer);
2498
+ resolve();
2499
+ }).catch((err) => {
2500
+ clearTimeout(timer);
2501
+ reject(err);
2502
+ });
2503
+ });
2504
+ this.isRunning = true;
2505
+ const botInfo = this.bot.botInfo;
2506
+ if (botInfo) {
2507
+ this.logger.info(
2508
+ { username: botInfo.username },
2509
+ `Bot launched as @${botInfo.username}`
2510
+ );
2511
+ } else {
2512
+ this.logger.info("Bot launched (username not yet available)");
2513
+ }
2514
+ return;
2515
+ } catch (err) {
2516
+ const msg = err instanceof Error ? err.message : String(err);
2517
+ const code = err.code;
2518
+ const isNetworkError = code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN" || code === "ENETUNREACH";
2519
+ if (isNetworkError && attempt < maxRetries) {
2520
+ console.log(` \u26A0 Telegram connection failed (${code}). Retrying in ${retryDelayMs / 1e3}s\u2026 (${attempt}/${maxRetries})`);
2521
+ this.logger.warn(
2522
+ { attempt, maxRetries, code },
2523
+ `Telegram connection failed (${code}). Retrying in ${retryDelayMs / 1e3}s\u2026`
2524
+ );
2525
+ await new Promise((r) => setTimeout(r, retryDelayMs));
2526
+ continue;
2527
+ }
2528
+ this.logger.error({ err, code }, `Failed to connect to Telegram API: ${msg}`);
2529
+ throw new Error(
2530
+ `Could not connect to Telegram (${code || "UNKNOWN"}): ${msg}
2531
+ \u2192 Check your internet connection and bot token.
2532
+ \u2192 Verify the token with: https://api.telegram.org/bot<TOKEN>/getMe`
2533
+ );
2534
+ }
2535
+ }
2536
+ }
2537
+ /**
2538
+ * Stop the bot gracefully.
2539
+ *
2540
+ * Safe to call multiple times — subsequent calls are no-ops.
2541
+ */
2542
+ async stop() {
2543
+ if (!this.isRunning) {
2544
+ this.logger.debug("stop() called but bot is not running");
2545
+ return;
2546
+ }
2547
+ this.bot.stop("graceful shutdown");
2548
+ this.isRunning = false;
2549
+ this.logger.info("Bot stopped");
2550
+ }
2551
+ // -----------------------------------------------------------------------
2552
+ // Accessors
2553
+ // -----------------------------------------------------------------------
2554
+ /**
2555
+ * Return the underlying Telegraf instance for advanced or low-level usage.
2556
+ */
2557
+ getBot() {
2558
+ return this.bot;
2559
+ }
2560
+ /**
2561
+ * Check whether the bot is currently running (polling).
2562
+ */
2563
+ isActive() {
2564
+ return this.isRunning;
2565
+ }
2566
+ };
2567
+ function createBot(token) {
2568
+ return new TelegramBot(token);
2569
+ }
2570
+
2571
+ // src/core/app.ts
2572
+ init_message();
2573
+
2574
+ // src/core/heartbeat.ts
2575
+ init_logger();
2576
+ import { readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync3, unlinkSync, existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
2577
+ import { join, basename } from "path";
2578
+ var HEARTBEATS_DIR = "./heartbeats";
2579
+ var HEARTBEAT_EXT = ".heartbeat.md";
2580
+ var STATE_EXT = ".state.json";
2581
+ var DEDUP_PLACEHOLDER = "{{DEDUP_STATE}}";
2582
+ var MAX_STATE_IDS = 200;
2583
+ var PROCESSED_MARKER_RE = /<!--\s*PROCESSED:\s*(.*?)\s*-->/gi;
2584
+ var HeartbeatManager = class {
2585
+ logger;
2586
+ timer = null;
2587
+ isRunning = false;
2588
+ /** Prevents overlapping cycles when a run takes longer than the interval. */
2589
+ cycleInProgress = false;
2590
+ constructor() {
2591
+ this.logger = createChildLogger("heartbeat");
2592
+ ensureHeartbeatsDir();
2593
+ }
2594
+ // -----------------------------------------------------------------------
2595
+ // File operations
2596
+ // -----------------------------------------------------------------------
2597
+ /**
2598
+ * Discover all heartbeat event files in the heartbeats directory.
2599
+ *
2600
+ * @returns Array of {@link HeartbeatEvent} objects, one per file.
2601
+ */
2602
+ listEvents() {
2603
+ ensureHeartbeatsDir();
2604
+ const files = readdirSync2(HEARTBEATS_DIR).filter((f) => f.endsWith(HEARTBEAT_EXT));
2605
+ return files.map((file) => {
2606
+ const filePath = join(HEARTBEATS_DIR, file);
2607
+ const name = basename(file, HEARTBEAT_EXT);
2608
+ const prompt = readFileSync4(filePath, "utf-8").trim();
2609
+ return { name, prompt, filePath };
2610
+ });
2611
+ }
2612
+ /**
2613
+ * Add a new heartbeat event by creating a `.heartbeat.md` file.
2614
+ *
2615
+ * @param name - Event name (used as the filename, kebab-cased).
2616
+ * @param prompt - The prompt text to send to the AI on each heartbeat.
2617
+ * @throws {Error} If an event with the same name already exists.
2618
+ */
2619
+ addEvent(name, prompt) {
2620
+ ensureHeartbeatsDir();
2621
+ const safeName = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
2622
+ const filePath = join(HEARTBEATS_DIR, `${safeName}${HEARTBEAT_EXT}`);
2623
+ if (existsSync5(filePath)) {
2624
+ throw new Error(`Heartbeat event "${safeName}" already exists at ${filePath}`);
2625
+ }
2626
+ writeFileSync3(filePath, prompt.trim() + "\n", "utf-8");
2627
+ this.logger.info({ name: safeName, filePath }, "Heartbeat event created");
2628
+ }
2629
+ /**
2630
+ * Remove a heartbeat event by name.
2631
+ *
2632
+ * @param name - The event name to remove.
2633
+ * @throws {Error} If the event does not exist.
2634
+ */
2635
+ removeEvent(name) {
2636
+ const safeName = name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
2637
+ const filePath = join(HEARTBEATS_DIR, `${safeName}${HEARTBEAT_EXT}`);
2638
+ if (!existsSync5(filePath)) {
2639
+ throw new Error(`Heartbeat event "${safeName}" not found`);
2640
+ }
2641
+ unlinkSync(filePath);
2642
+ const statePath = join(HEARTBEATS_DIR, `${safeName}${STATE_EXT}`);
2643
+ if (existsSync5(statePath)) {
2644
+ unlinkSync(statePath);
2645
+ }
2646
+ this.logger.info({ name: safeName }, "Heartbeat event removed");
2647
+ }
2648
+ // -----------------------------------------------------------------------
2649
+ // Deduplication state
2650
+ // -----------------------------------------------------------------------
2651
+ /**
2652
+ * Load the persisted deduplication state for an event.
2653
+ *
2654
+ * @param eventName - The event name (kebab-cased, no extension).
2655
+ * @returns The stored state, or a fresh empty state if none exists.
2656
+ */
2657
+ loadState(eventName) {
2658
+ const statePath = join(HEARTBEATS_DIR, `${eventName}${STATE_EXT}`);
2659
+ if (!existsSync5(statePath)) {
2660
+ return { processedIds: [], lastRun: null };
2661
+ }
2662
+ try {
2663
+ const raw = readFileSync4(statePath, "utf-8");
2664
+ const parsed = JSON.parse(raw);
2665
+ return {
2666
+ processedIds: Array.isArray(parsed.processedIds) ? parsed.processedIds : [],
2667
+ lastRun: parsed.lastRun ?? null
2668
+ };
2669
+ } catch (err) {
2670
+ this.logger.warn({ err, eventName }, "Failed to parse state file \u2014 resetting");
2671
+ return { processedIds: [], lastRun: null };
2672
+ }
2673
+ }
2674
+ /**
2675
+ * Persist deduplication state for an event. Caps stored IDs at
2676
+ * {@link MAX_STATE_IDS} to prevent the file from growing unboundedly.
2677
+ *
2678
+ * @param eventName - The event name (kebab-cased, no extension).
2679
+ * @param state - The state to persist.
2680
+ */
2681
+ saveState(eventName, state) {
2682
+ const statePath = join(HEARTBEATS_DIR, `${eventName}${STATE_EXT}`);
2683
+ const trimmed = {
2684
+ processedIds: state.processedIds.slice(-MAX_STATE_IDS),
2685
+ lastRun: state.lastRun
2686
+ };
2687
+ writeFileSync3(statePath, JSON.stringify(trimmed, null, 2) + "\n", "utf-8");
2688
+ this.logger.debug({ eventName, idCount: trimmed.processedIds.length }, "State saved");
2689
+ }
2690
+ /**
2691
+ * Inject deduplication context into a prompt. If the prompt contains the
2692
+ * `{{DEDUP_STATE}}` placeholder, it is replaced with a block listing the
2693
+ * previously-processed IDs. If no placeholder is present, the prompt is
2694
+ * returned unchanged.
2695
+ *
2696
+ * @param prompt - The raw prompt text from the `.heartbeat.md` file.
2697
+ * @param state - The loaded deduplication state.
2698
+ * @returns The prompt with dedup context injected.
2699
+ */
2700
+ injectDedupContext(prompt, state) {
2701
+ if (!prompt.includes(DEDUP_PLACEHOLDER)) {
2702
+ return prompt;
2703
+ }
2704
+ if (state.processedIds.length === 0) {
2705
+ return prompt.replace(DEDUP_PLACEHOLDER, "No previously processed items \u2014 this is the first run.");
2706
+ }
2707
+ const idList = state.processedIds.map((id) => `- ${id}`).join("\n");
2708
+ const context = [
2709
+ `Previously processed IDs (${state.processedIds.length} total) \u2014 SKIP these:`,
2710
+ idList
2711
+ ].join("\n");
2712
+ return prompt.replace(DEDUP_PLACEHOLDER, context);
2713
+ }
2714
+ /**
2715
+ * Extract newly-processed item IDs from the AI's response. Looks for
2716
+ * `<!-- PROCESSED: id1, id2, id3 -->` markers anywhere in the text.
2717
+ *
2718
+ * @param response - The full AI response text.
2719
+ * @returns Array of extracted IDs (may be empty if no marker found).
2720
+ */
2721
+ extractProcessedIds(response) {
2722
+ const ids = [];
2723
+ let match;
2724
+ while ((match = PROCESSED_MARKER_RE.exec(response)) !== null) {
2725
+ const raw = match[1] ?? "";
2726
+ for (const id of raw.split(",")) {
2727
+ const trimmed = id.trim();
2728
+ if (trimmed) ids.push(trimmed);
2729
+ }
2730
+ }
2731
+ PROCESSED_MARKER_RE.lastIndex = 0;
2732
+ return ids;
2733
+ }
2734
+ // -----------------------------------------------------------------------
2735
+ // Scheduling
2736
+ // -----------------------------------------------------------------------
2737
+ /**
2738
+ * Start the heartbeat scheduler.
2739
+ *
2740
+ * Runs all heartbeat events immediately on first tick, then repeats at the
2741
+ * configured interval.
2742
+ *
2743
+ * @param intervalMinutes - Minutes between each heartbeat cycle.
2744
+ * @param sendFn - Function that sends a prompt to the AI and returns the response.
2745
+ * @param notifyFn - Function that delivers the AI response to the user.
2746
+ */
2747
+ start(intervalMinutes, sendFn, notifyFn) {
2748
+ if (this.isRunning) {
2749
+ this.logger.warn("Heartbeat scheduler already running");
2750
+ return;
2751
+ }
2752
+ if (intervalMinutes <= 0) {
2753
+ this.logger.info("Heartbeat disabled (interval is 0)");
2754
+ return;
2755
+ }
2756
+ const events = this.listEvents();
2757
+ if (events.length === 0) {
2758
+ this.logger.info("No heartbeat events found \u2014 scheduler idle");
2759
+ }
2760
+ const intervalMs = intervalMinutes * 60 * 1e3;
2761
+ this.isRunning = true;
2762
+ this.logger.info(
2763
+ { intervalMinutes, eventCount: events.length },
2764
+ `Heartbeat scheduler started (every ${intervalMinutes} min, ${events.length} events)`
2765
+ );
2766
+ const runCycle = async () => {
2767
+ if (this.cycleInProgress) {
2768
+ this.logger.debug("Skipping heartbeat cycle \u2014 previous cycle still in progress");
2769
+ return;
2770
+ }
2771
+ this.cycleInProgress = true;
2772
+ try {
2773
+ const currentEvents = this.listEvents();
2774
+ if (currentEvents.length === 0) return;
2775
+ this.logger.debug({ count: currentEvents.length }, "Running heartbeat cycle");
2776
+ for (const event of currentEvents) {
2777
+ try {
2778
+ this.logger.debug({ event: event.name }, `Executing heartbeat: ${event.name}`);
2779
+ const useDedup = event.prompt.includes(DEDUP_PLACEHOLDER);
2780
+ const state = useDedup ? this.loadState(event.name) : null;
2781
+ const finalPrompt = state ? this.injectDedupContext(event.prompt, state) : event.prompt;
2782
+ const response = await sendFn(finalPrompt);
2783
+ if (response) {
2784
+ if (useDedup && state) {
2785
+ const newIds = this.extractProcessedIds(response);
2786
+ if (newIds.length > 0) {
2787
+ const existing = new Set(state.processedIds);
2788
+ const uniqueNew = newIds.filter((id) => !existing.has(id));
2789
+ if (uniqueNew.length > 0) {
2790
+ state.processedIds.push(...uniqueNew);
2791
+ state.lastRun = (/* @__PURE__ */ new Date()).toISOString();
2792
+ this.saveState(event.name, state);
2793
+ this.logger.info(
2794
+ { event: event.name, newIds: uniqueNew.length, totalIds: state.processedIds.length },
2795
+ `Dedup: recorded ${uniqueNew.length} new IDs for "${event.name}"`
2796
+ );
2797
+ }
2798
+ }
2799
+ }
2800
+ const cleanResponse = response.replace(PROCESSED_MARKER_RE, "").trim();
2801
+ PROCESSED_MARKER_RE.lastIndex = 0;
2802
+ if (cleanResponse) {
2803
+ await notifyFn(event.name, cleanResponse);
2804
+ }
2805
+ this.logger.info({ event: event.name }, `Heartbeat "${event.name}" completed`);
2806
+ } else {
2807
+ this.logger.warn({ event: event.name }, `Heartbeat "${event.name}" returned no response`);
2808
+ }
2809
+ } catch (err) {
2810
+ this.logger.error(
2811
+ { err, event: event.name },
2812
+ `Heartbeat "${event.name}" failed`
2813
+ );
2814
+ }
2815
+ }
2816
+ } finally {
2817
+ this.cycleInProgress = false;
2818
+ }
2819
+ };
2820
+ this.timer = setInterval(() => {
2821
+ runCycle().catch((err) => {
2822
+ this.logger.error({ err }, "Heartbeat cycle failed");
2823
+ });
2824
+ }, intervalMs);
2825
+ }
2826
+ /**
2827
+ * Stop the heartbeat scheduler.
2828
+ */
2829
+ stop() {
2830
+ if (this.timer) {
2831
+ clearInterval(this.timer);
2832
+ this.timer = null;
2833
+ }
2834
+ this.isRunning = false;
2835
+ this.cycleInProgress = false;
2836
+ this.logger.info("Heartbeat scheduler stopped");
2837
+ }
2838
+ /**
2839
+ * Check whether the scheduler is currently running.
2840
+ */
2841
+ isActive() {
2842
+ return this.isRunning;
2843
+ }
2844
+ };
2845
+ function ensureHeartbeatsDir() {
2846
+ if (!existsSync5(HEARTBEATS_DIR)) {
2847
+ mkdirSync3(HEARTBEATS_DIR, { recursive: true });
2848
+ }
2849
+ }
2850
+
2851
+ // src/core/gc.ts
2852
+ init_logger();
2853
+ var DEFAULT_INTERVAL_MINUTES = 30;
2854
+ var DEFAULT_CONVERSATION_RETENTION_DAYS = 30;
2855
+ var DEFAULT_HEALTH_RETENTION_DAYS = 7;
2856
+ var GarbageCollector = class {
2857
+ logger;
2858
+ timer = null;
2859
+ db = null;
2860
+ intervalMinutes;
2861
+ conversationRetentionDays;
2862
+ healthRetentionDays;
2863
+ constructor(options) {
2864
+ this.logger = createChildLogger("gc");
2865
+ this.intervalMinutes = options?.intervalMinutes ?? DEFAULT_INTERVAL_MINUTES;
2866
+ this.conversationRetentionDays = options?.conversationRetentionDays ?? DEFAULT_CONVERSATION_RETENTION_DAYS;
2867
+ this.healthRetentionDays = options?.healthRetentionDays ?? DEFAULT_HEALTH_RETENTION_DAYS;
2868
+ }
2869
+ // -----------------------------------------------------------------------
2870
+ // Lifecycle
2871
+ // -----------------------------------------------------------------------
2872
+ /**
2873
+ * Start the periodic GC timer.
2874
+ *
2875
+ * @param db - The SQLite database handle to prune.
2876
+ */
2877
+ start(db) {
2878
+ this.db = db;
2879
+ if (this.intervalMinutes <= 0) {
2880
+ this.logger.info("Garbage collector disabled (interval = 0)");
2881
+ return;
2882
+ }
2883
+ this.runCycle();
2884
+ const intervalMs = this.intervalMinutes * 6e4;
2885
+ this.timer = setInterval(() => this.runCycle(), intervalMs);
2886
+ if (this.timer.unref) this.timer.unref();
2887
+ this.logger.info(
2888
+ {
2889
+ intervalMinutes: this.intervalMinutes,
2890
+ conversationRetentionDays: this.conversationRetentionDays,
2891
+ healthRetentionDays: this.healthRetentionDays
2892
+ },
2893
+ `GC started (every ${this.intervalMinutes} min)`
2894
+ );
2895
+ }
2896
+ /**
2897
+ * Stop the periodic GC timer.
2898
+ */
2899
+ stop() {
2900
+ if (this.timer) {
2901
+ clearInterval(this.timer);
2902
+ this.timer = null;
2903
+ this.logger.info("GC stopped");
2904
+ }
2905
+ }
2906
+ // -----------------------------------------------------------------------
2907
+ // GC cycle
2908
+ // -----------------------------------------------------------------------
2909
+ /**
2910
+ * Execute a single GC cycle: prune old records and log memory stats.
2911
+ */
2912
+ runCycle() {
2913
+ const start = Date.now();
2914
+ try {
2915
+ const conversationsPruned = this.pruneConversations();
2916
+ const healthPruned = this.prunePluginHealth();
2917
+ const memStats = this.getMemoryStats();
2918
+ const elapsed = Date.now() - start;
2919
+ this.logger.info(
2920
+ {
2921
+ conversationsPruned,
2922
+ healthPruned,
2923
+ heapUsedMB: memStats.heapUsedMB,
2924
+ rssMB: memStats.rssMB,
2925
+ elapsed
2926
+ },
2927
+ `GC cycle complete \u2014 pruned ${conversationsPruned} conversations, ${healthPruned} health records (${elapsed}ms)`
2928
+ );
2929
+ } catch (err) {
2930
+ this.logger.error({ err }, "GC cycle failed");
2931
+ }
2932
+ }
2933
+ // -----------------------------------------------------------------------
2934
+ // Pruning operations
2935
+ // -----------------------------------------------------------------------
2936
+ /**
2937
+ * Delete conversation messages older than the retention window.
2938
+ * @returns Number of rows deleted.
2939
+ */
2940
+ pruneConversations() {
2941
+ if (!this.db) return 0;
2942
+ try {
2943
+ const result = this.db.prepare(
2944
+ `DELETE FROM conversations
2945
+ WHERE created_at < datetime('now', '-' || ? || ' days')`
2946
+ ).run(this.conversationRetentionDays);
2947
+ return result.changes;
2948
+ } catch (err) {
2949
+ this.logger.error({ err }, "Failed to prune conversations");
2950
+ return 0;
2951
+ }
2952
+ }
2953
+ /**
2954
+ * Delete plugin health records older than the retention window.
2955
+ * @returns Number of rows deleted.
2956
+ */
2957
+ prunePluginHealth() {
2958
+ if (!this.db) return 0;
2959
+ try {
2960
+ const result = this.db.prepare(
2961
+ `DELETE FROM plugin_health
2962
+ WHERE checked_at < datetime('now', '-' || ? || ' days')`
2963
+ ).run(this.healthRetentionDays);
2964
+ return result.changes;
2965
+ } catch (err) {
2966
+ this.logger.error({ err }, "Failed to prune plugin health records");
2967
+ return 0;
2968
+ }
2969
+ }
2970
+ // -----------------------------------------------------------------------
2971
+ // Memory monitoring
2972
+ // -----------------------------------------------------------------------
2973
+ /**
2974
+ * Capture current process memory usage.
2975
+ * @returns Object with heap and RSS in megabytes.
2976
+ */
2977
+ getMemoryStats() {
2978
+ const mem = process.memoryUsage();
2979
+ return {
2980
+ heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10,
2981
+ heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10,
2982
+ rssMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
2983
+ externalMB: Math.round(mem.external / 1024 / 1024 * 10) / 10
2984
+ };
2985
+ }
2986
+ };
2987
+
2988
+ // src/core/app.ts
2989
+ dns.setDefaultResultOrder("ipv4first");
2990
+ var App = class {
2991
+ logger;
2992
+ bot;
2993
+ pluginManager;
2994
+ heartbeatManager;
2995
+ gc;
2996
+ isShuttingDown = false;
2997
+ verbose = false;
2998
+ constructor() {
2999
+ this.logger = createChildLogger("app");
3000
+ }
3001
+ // -----------------------------------------------------------------------
3002
+ // Startup
3003
+ // -----------------------------------------------------------------------
3004
+ /**
3005
+ * Boot the entire application.
3006
+ *
3007
+ * Initialisation order:
3008
+ * 1. Configuration & logging
3009
+ * 2. Database & repositories
3010
+ * 3. Model registry
3011
+ * 4. Plugin system (registry → manager)
3012
+ * 5. Copilot AI client & session
3013
+ * 6. Telegram bot (handlers → launch)
3014
+ * 7. OS signal handlers for graceful shutdown
3015
+ *
3016
+ * @param options - Optional start-up flags.
3017
+ */
3018
+ async start(options) {
3019
+ console.log(" \u25B8 Loading configuration\u2026");
3020
+ const config = getConfig();
3021
+ this.logger.info("Configuration loaded");
3022
+ if (options?.verbose) {
3023
+ setLogLevel("debug");
3024
+ this.verbose = true;
3025
+ this.logger.debug("Verbose logging enabled");
3026
+ }
3027
+ console.log(" \u25B8 Initializing database\u2026");
3028
+ const db = getDatabase();
3029
+ this.logger.info("Database ready");
3030
+ const conversationRepo = new ConversationRepository(db);
3031
+ const preferencesRepo = new PreferencesRepository(db);
3032
+ const pluginStateRepo = new PluginStateRepository(db);
3033
+ this.logger.info("Repositories created");
3034
+ const gc = new GarbageCollector({
3035
+ intervalMinutes: 30,
3036
+ conversationRetentionDays: 30,
3037
+ healthRetentionDays: 7
3038
+ });
3039
+ gc.start(db);
3040
+ this.gc = gc;
3041
+ const modelRegistry = createModelRegistry(preferencesRepo);
3042
+ this.logger.info("Model registry initialised");
3043
+ console.log(" \u25B8 Discovering plugins\u2026");
3044
+ const pluginRegistry = createPluginRegistry();
3045
+ await pluginRegistry.discoverPlugins();
3046
+ const pluginManager = createPluginManager(
3047
+ pluginRegistry,
3048
+ pluginSandbox,
3049
+ credentialManager,
3050
+ pluginStateRepo
3051
+ );
3052
+ await pluginManager.initialize();
3053
+ this.pluginManager = pluginManager;
3054
+ this.logger.info("Plugin system ready");
3055
+ console.log(" \u25B8 Starting Copilot SDK client\u2026");
3056
+ await copilotClient.start();
3057
+ const currentModel = modelRegistry.getCurrentModelId();
3058
+ const activePlugins = pluginManager.getActivePlugins();
3059
+ const pluginTools = pluginManager.getAllTools();
3060
+ const poolSize = Math.max(1, parseInt(config.env.AI_SESSION_POOL_SIZE || "3", 10));
3061
+ console.log(` \u25B8 Creating AI session pool (model: ${currentModel}, sessions: ${poolSize})\u2026`);
3062
+ await sessionManager.createSession(currentModel, pluginTools, poolSize);
3063
+ console.log(" \u25B8 Connecting to Telegram\u2026");
3064
+ const bot = createBot(config.env.TELEGRAM_BOT_TOKEN);
3065
+ const rawMessageHandler = createMessageHandler({
3066
+ sessionManager,
3067
+ conversationRepo
3068
+ });
3069
+ const messageHandler = async (ctx, text) => {
3070
+ const ts = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
3071
+ const preview = text.length > 60 ? text.slice(0, 60) + "\u2026" : text;
3072
+ if (this.verbose) {
3073
+ console.log(` \u21E3 [${ts()}] Message received: "${preview}"`);
3074
+ }
3075
+ const startTime = Date.now();
3076
+ await rawMessageHandler(ctx, text);
3077
+ if (this.verbose) {
3078
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
3079
+ console.log(` \u21E1 [${ts()}] Message completed (${elapsed}s)`);
3080
+ }
3081
+ };
3082
+ const heartbeatManager = new HeartbeatManager();
3083
+ this.heartbeatManager = heartbeatManager;
3084
+ const runHeartbeatOnDemand = async (ctx, eventName) => {
3085
+ const replyToId = ctx.message?.message_id;
3086
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
3087
+ const allEvents = heartbeatManager.listEvents();
3088
+ if (allEvents.length === 0) {
3089
+ await ctx.reply("No heartbeat events configured.\nAdd one with: co-assistant heartbeat add", replyOpts);
3090
+ return;
3091
+ }
3092
+ let eventsToRun = allEvents;
3093
+ if (eventName) {
3094
+ const match = allEvents.find((e) => e.name === eventName);
3095
+ if (!match) {
3096
+ const names = allEvents.map((e) => e.name).join(", ");
3097
+ await ctx.reply(`\u274C Heartbeat "${eventName}" not found.
3098
+ Available: ${names}`, replyOpts);
3099
+ return;
3100
+ }
3101
+ eventsToRun = [match];
3102
+ }
3103
+ await ctx.reply(`\u{1F680} Running ${eventsToRun.length} heartbeat event(s)\u2026`, replyOpts);
3104
+ const typingInterval = setInterval(() => {
3105
+ ctx.sendChatAction("typing").catch(() => {
3106
+ });
3107
+ }, 4e3);
3108
+ try {
3109
+ for (const event of eventsToRun) {
3110
+ try {
3111
+ const useDedup = event.prompt.includes("{{DEDUP_STATE}}");
3112
+ const state = useDedup ? heartbeatManager.loadState(event.name) : null;
3113
+ let finalPrompt = event.prompt;
3114
+ if (state) {
3115
+ finalPrompt = event.prompt.replace(
3116
+ "{{DEDUP_STATE}}",
3117
+ state.processedIds.length > 0 ? `Previously processed IDs (${state.processedIds.length} total) \u2014 SKIP these:
3118
+ ${state.processedIds.map((id) => `- ${id}`).join("\n")}` : "No previously processed items \u2014 this is the first run."
3119
+ );
3120
+ }
3121
+ const response = await sessionManager.sendMessage(finalPrompt);
3122
+ if (!response) {
3123
+ await ctx.reply(`\u26A0\uFE0F Heartbeat "${event.name}" returned no response.`, replyOpts);
3124
+ continue;
3125
+ }
3126
+ const processedRe = /<!--\s*PROCESSED:\s*(.*?)\s*-->/gi;
3127
+ if (useDedup && state) {
3128
+ const existing = new Set(state.processedIds);
3129
+ let m;
3130
+ while ((m = processedRe.exec(response)) !== null) {
3131
+ for (const id of (m[1] ?? "").split(",")) {
3132
+ const t = id.trim();
3133
+ if (t && !existing.has(t)) {
3134
+ state.processedIds.push(t);
3135
+ existing.add(t);
3136
+ }
3137
+ }
3138
+ }
3139
+ processedRe.lastIndex = 0;
3140
+ state.lastRun = (/* @__PURE__ */ new Date()).toISOString();
3141
+ heartbeatManager.saveState(event.name, state);
3142
+ }
3143
+ const clean = response.replace(/<!--\s*PROCESSED:\s*.*?\s*-->/gi, "").trim();
3144
+ if (clean) {
3145
+ const header = `\u{1F493} *Heartbeat: ${event.name}*
3146
+
3147
+ `;
3148
+ const chunks = splitMessage(header + clean);
3149
+ for (const chunk of chunks) {
3150
+ await ctx.reply(chunk, { parse_mode: "Markdown", ...replyOpts });
3151
+ }
3152
+ }
3153
+ } catch (err) {
3154
+ const msg = err instanceof Error ? err.message : String(err);
3155
+ this.logger.error({ err, event: event.name }, "On-demand heartbeat failed");
3156
+ await ctx.reply(`\u274C Heartbeat "${event.name}" failed: ${msg}`, replyOpts);
3157
+ }
3158
+ }
3159
+ } finally {
3160
+ clearInterval(typingInterval);
3161
+ }
3162
+ };
3163
+ const commandHandler = async (ctx, command, args) => {
3164
+ const ts = () => (/* @__PURE__ */ new Date()).toLocaleTimeString();
3165
+ const replyToId = ctx.message?.message_id;
3166
+ const replyOpts = replyToId ? { reply_parameters: { message_id: replyToId } } : {};
3167
+ if (this.verbose) {
3168
+ console.log(` \u21E3 [${ts()}] Command received: /${command}${args ? " " + args : ""}`);
3169
+ }
3170
+ const startTime = Date.now();
3171
+ switch (command) {
3172
+ case "heartbeat":
3173
+ case "hb":
3174
+ await runHeartbeatOnDemand(ctx, args.trim() || void 0);
3175
+ break;
3176
+ case "help":
3177
+ await ctx.reply(
3178
+ "\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\\.",
3179
+ { parse_mode: "MarkdownV2", ...replyOpts }
3180
+ );
3181
+ break;
3182
+ default:
3183
+ await messageHandler(ctx, `/${command} ${args}`.trim());
3184
+ }
3185
+ if (this.verbose) {
3186
+ const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
3187
+ console.log(` \u21E1 [${ts()}] Command completed: /${command} (${elapsed}s)`);
3188
+ }
3189
+ };
3190
+ bot.initialize({
3191
+ allowedUserId: Number(config.env.TELEGRAM_USER_ID),
3192
+ onMessage: messageHandler,
3193
+ onCommand: commandHandler
3194
+ });
3195
+ try {
3196
+ await bot.launch();
3197
+ } catch (err) {
3198
+ const msg = err instanceof Error ? err.message : String(err);
3199
+ this.logger.error({ err }, "Telegram bot failed to start");
3200
+ console.error(`
3201
+ \u2717 Telegram bot failed to start:
3202
+ ${msg}
3203
+ `);
3204
+ await this.shutdown();
3205
+ return;
3206
+ }
3207
+ this.bot = bot;
3208
+ const pluginCount = activePlugins.size;
3209
+ const botUsername = bot.getBot().botInfo?.username ?? "unknown";
3210
+ const pluginNames = [...activePlugins.keys()].join(", ") || "none";
3211
+ this.logger.info(
3212
+ { model: currentModel, plugins: pluginCount, version: "1.0.0" },
3213
+ `Co-Assistant is running! Model: ${currentModel}, Plugins: ${pluginCount}`
3214
+ );
3215
+ console.log("");
3216
+ 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");
3217
+ console.log(" \u2551 \u{1F916} Co-Assistant is running! \u2551");
3218
+ 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");
3219
+ console.log("");
3220
+ console.log(` Bot: @${botUsername}`);
3221
+ console.log(` Model: ${currentModel}`);
3222
+ console.log(` Sessions: ${poolSize} (parallel processing)`);
3223
+ console.log(` Plugins: ${pluginNames} (${pluginCount} active)`);
3224
+ const heartbeatInterval = parseInt(config.env.HEARTBEAT_INTERVAL_MINUTES || "0", 10);
3225
+ const heartbeatEvents = heartbeatManager.listEvents();
3226
+ if (heartbeatInterval > 0 && heartbeatEvents.length > 0) {
3227
+ heartbeatManager.start(
3228
+ heartbeatInterval,
3229
+ // Send heartbeat prompt to the AI session
3230
+ async (prompt) => sessionManager.sendMessage(prompt),
3231
+ // Forward the AI's response to the user via Telegram
3232
+ async (eventName, response) => {
3233
+ try {
3234
+ const chatId = config.env.TELEGRAM_USER_ID;
3235
+ const header = `\u{1F493} *Heartbeat: ${eventName}*
3236
+
3237
+ `;
3238
+ const fullMessage = header + response;
3239
+ const chunks = splitMessage(fullMessage);
3240
+ for (const chunk of chunks) {
3241
+ await bot.getBot().telegram.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
3242
+ }
3243
+ } catch (err) {
3244
+ this.logger.error({ err, eventName }, "Failed to send heartbeat response via Telegram");
3245
+ }
3246
+ }
3247
+ );
3248
+ console.log(` Heartbeat: every ${heartbeatInterval} min (${heartbeatEvents.length} events)`);
3249
+ } else if (heartbeatInterval > 0) {
3250
+ console.log(` Heartbeat: every ${heartbeatInterval} min (no events \u2014 add with: co-assistant heartbeat add)`);
3251
+ } else {
3252
+ console.log(" Heartbeat: disabled (set HEARTBEAT_INTERVAL_MINUTES in .env)");
3253
+ }
3254
+ console.log("");
3255
+ console.log(" Open Telegram and send a message to your bot to get started.");
3256
+ console.log(" Press Ctrl+C to stop.\n");
3257
+ const shutdownHandler = async (signal) => {
3258
+ this.logger.info({ signal }, "Received shutdown signal");
3259
+ await this.shutdown();
3260
+ };
3261
+ process.once("SIGINT", () => shutdownHandler("SIGINT"));
3262
+ process.once("SIGTERM", () => shutdownHandler("SIGTERM"));
3263
+ }
3264
+ // -----------------------------------------------------------------------
3265
+ // Shutdown
3266
+ // -----------------------------------------------------------------------
3267
+ /**
3268
+ * Gracefully shut down all subsystems in reverse order.
3269
+ *
3270
+ * Each step is wrapped in its own try/catch so a failure in one subsystem
3271
+ * never prevents the others from cleaning up.
3272
+ */
3273
+ async shutdown() {
3274
+ if (this.isShuttingDown) {
3275
+ this.logger.warn("Shutdown already in progress \u2014 skipping");
3276
+ return;
3277
+ }
3278
+ this.isShuttingDown = true;
3279
+ this.logger.info("Initiating graceful shutdown\u2026");
3280
+ if (this.heartbeatManager) {
3281
+ this.heartbeatManager.stop();
3282
+ this.logger.info("Heartbeat scheduler stopped");
3283
+ }
3284
+ if (this.gc) {
3285
+ this.gc.stop();
3286
+ }
3287
+ try {
3288
+ if (this.bot) {
3289
+ await this.bot.stop();
3290
+ this.logger.info("Telegram bot stopped");
3291
+ }
3292
+ } catch (err) {
3293
+ this.logger.error({ err }, "Error stopping Telegram bot");
3294
+ }
3295
+ try {
3296
+ await sessionManager.closeSession();
3297
+ this.logger.info("AI session closed");
3298
+ } catch (err) {
3299
+ this.logger.error({ err }, "Error closing AI session");
3300
+ }
3301
+ try {
3302
+ await copilotClient.stop();
3303
+ this.logger.info("Copilot client stopped");
3304
+ } catch (err) {
3305
+ this.logger.error({ err }, "Error stopping Copilot client");
3306
+ }
3307
+ try {
3308
+ if (this.pluginManager) {
3309
+ await this.pluginManager.shutdown();
3310
+ this.logger.info("Plugins shut down");
3311
+ }
3312
+ } catch (err) {
3313
+ this.logger.error({ err }, "Error shutting down plugins");
3314
+ }
3315
+ try {
3316
+ closeDatabase();
3317
+ this.logger.info("Database closed");
3318
+ } catch (err) {
3319
+ this.logger.error({ err }, "Error closing database");
3320
+ }
3321
+ this.logger.info("\u{1F44B} Goodbye!");
3322
+ process.exit(0);
3323
+ }
3324
+ };
3325
+
3326
+ // src/cli/commands/start.ts
3327
+ function registerStartCommand(program2) {
3328
+ program2.command("start").description("Start the Co-Assistant bot").option("-v, --verbose", "Enable verbose/debug logging").action(async (options) => {
3329
+ const app = new App();
3330
+ await app.start({ verbose: options.verbose });
3331
+ });
3332
+ }
3333
+
3334
+ // src/cli/commands/setup.ts
3335
+ import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3336
+
3337
+ // src/utils/prompt.ts
3338
+ import * as readline from "readline";
3339
+ function ask(question, options) {
3340
+ return new Promise((resolve) => {
3341
+ const rl = readline.createInterface({
3342
+ input: process.stdin,
3343
+ output: process.stdout,
3344
+ ...options
3345
+ });
3346
+ rl.on("close", () => {
3347
+ });
3348
+ rl.question(question, (answer) => {
3349
+ rl.close();
3350
+ resolve(answer.trim());
3351
+ });
3352
+ });
3353
+ }
3354
+ async function promptText(question, defaultValue) {
3355
+ const suffix = defaultValue !== void 0 ? ` [${defaultValue}]` : "";
3356
+ const answer = await ask(`${question}${suffix}: `);
3357
+ return answer || defaultValue || "";
3358
+ }
3359
+ async function promptSecret(question) {
3360
+ return new Promise((resolve) => {
3361
+ const rl = readline.createInterface({
3362
+ input: process.stdin,
3363
+ output: process.stdout
3364
+ });
3365
+ const stdout = process.stdout;
3366
+ let muted = false;
3367
+ const originalWrite = stdout.write.bind(stdout);
3368
+ stdout.write = function(chunk, ...args) {
3369
+ if (muted) return true;
3370
+ return originalWrite(chunk, ...args);
3371
+ };
3372
+ rl.question(`${question}: `, (answer) => {
3373
+ muted = false;
3374
+ stdout.write = originalWrite;
3375
+ stdout.write("\n");
3376
+ rl.close();
3377
+ resolve(answer.trim());
3378
+ });
3379
+ muted = true;
3380
+ });
3381
+ }
3382
+ async function promptConfirm(question, defaultValue = false) {
3383
+ const hint = defaultValue ? "(Y/n)" : "(y/N)";
3384
+ const answer = await ask(`${question} ${hint}: `);
3385
+ if (answer === "") return defaultValue;
3386
+ return answer.toLowerCase().startsWith("y");
3387
+ }
3388
+ async function promptSelect(question, choices) {
3389
+ console.log(`
3390
+ ${question}`);
3391
+ choices.forEach((choice, index) => {
3392
+ console.log(` ${index + 1}. ${choice}`);
3393
+ });
3394
+ while (true) {
3395
+ const answer = await ask("\nSelect an option: ");
3396
+ const num = parseInt(answer, 10);
3397
+ if (num >= 1 && num <= choices.length) {
3398
+ return choices[num - 1];
3399
+ }
3400
+ console.log(` \u26A0 Please enter a number between 1 and ${choices.length}.`);
3401
+ }
3402
+ }
3403
+ async function promptFilePath(question) {
3404
+ const { existsSync: existsSync9, readFileSync: readFileSync6 } = await import("fs");
3405
+ const { resolve } = await import("path");
3406
+ while (true) {
3407
+ const raw = await ask(`${question}: `);
3408
+ if (!raw) {
3409
+ console.log(" \u26A0 Please enter a file path.");
3410
+ continue;
3411
+ }
3412
+ const filePath = resolve(raw.replace(/^~/, process.env.HOME || "~"));
3413
+ if (!existsSync9(filePath)) {
3414
+ console.log(` \u26A0 File not found: ${filePath}`);
3415
+ continue;
3416
+ }
3417
+ try {
3418
+ return readFileSync6(filePath, "utf-8");
3419
+ } catch {
3420
+ console.log(` \u26A0 Could not read file: ${filePath}`);
3421
+ }
3422
+ }
3423
+ }
3424
+
3425
+ // src/utils/google-oauth.ts
3426
+ import { createServer } from "http";
3427
+ import { URL } from "url";
3428
+ import { exec } from "child_process";
3429
+ var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
3430
+ var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
3431
+ var TIMEOUT_MS = 12e4;
3432
+ var GOOGLE_PLUGIN_SCOPES = {
3433
+ gmail: ["https://mail.google.com/"],
3434
+ "google-calendar": ["https://www.googleapis.com/auth/calendar"]
3435
+ };
3436
+ var SUCCESS_HTML = `<!DOCTYPE html>
3437
+ <html lang="en"><head><meta charset="utf-8"><title>Authorization Successful</title>
3438
+ <style>
3439
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
3440
+ display: flex; justify-content: center; align-items: center;
3441
+ height: 100vh; margin: 0; background: #f8f9fa; }
3442
+ .card { background: #fff; padding: 2.5rem 3rem; border-radius: 12px;
3443
+ box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; max-width: 420px; }
3444
+ h1 { color: #1a73e8; font-size: 1.5rem; }
3445
+ p { color: #5f6368; line-height: 1.6; }
3446
+ </style></head>
3447
+ <body><div class="card">
3448
+ <h1>\u2705 Authorization Successful</h1>
3449
+ <p>Your Google account has been linked.<br>You can close this tab and return to the terminal.</p>
3450
+ </div></body></html>`;
3451
+ function errorHtml(message2) {
3452
+ return `<!DOCTYPE html>
3453
+ <html lang="en"><head><meta charset="utf-8"><title>Authorization Failed</title>
3454
+ <style>
3455
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
3456
+ display: flex; justify-content: center; align-items: center;
3457
+ height: 100vh; margin: 0; background: #f8f9fa; }
3458
+ .card { background: #fff; padding: 2.5rem 3rem; border-radius: 12px;
3459
+ box-shadow: 0 2px 12px rgba(0,0,0,0.08); text-align: center; max-width: 420px; }
3460
+ h1 { color: #d93025; font-size: 1.5rem; }
3461
+ p { color: #5f6368; line-height: 1.6; }
3462
+ </style></head>
3463
+ <body><div class="card">
3464
+ <h1>\u274C Authorization Failed</h1>
3465
+ <p>${message2}</p>
3466
+ <p>Please check your terminal for details.</p>
3467
+ </div></body></html>`;
3468
+ }
3469
+ function performGoogleOAuthFlow(clientId, clientSecret, scopes) {
3470
+ return new Promise((resolve, reject) => {
3471
+ let settled = false;
3472
+ let listenPort = 0;
3473
+ const server = createServer(async (req, res) => {
3474
+ if (settled) {
3475
+ res.writeHead(404);
3476
+ res.end();
3477
+ return;
3478
+ }
3479
+ if (req.method !== "GET") {
3480
+ res.writeHead(405);
3481
+ res.end();
3482
+ return;
3483
+ }
3484
+ const redirectUri = `http://127.0.0.1:${listenPort}`;
3485
+ const reqUrl = new URL(req.url || "/", redirectUri);
3486
+ const error = reqUrl.searchParams.get("error");
3487
+ if (error) {
3488
+ res.writeHead(200, { "Content-Type": "text/html" });
3489
+ res.end(errorHtml(`Google returned an error: <strong>${error}</strong>`));
3490
+ settle(new Error(`Google OAuth error: ${error}`));
3491
+ return;
3492
+ }
3493
+ const code = reqUrl.searchParams.get("code");
3494
+ if (!code) {
3495
+ res.writeHead(404);
3496
+ res.end();
3497
+ return;
3498
+ }
3499
+ try {
3500
+ const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
3501
+ method: "POST",
3502
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3503
+ body: new URLSearchParams({
3504
+ code,
3505
+ client_id: clientId,
3506
+ client_secret: clientSecret,
3507
+ redirect_uri: redirectUri,
3508
+ grant_type: "authorization_code"
3509
+ }).toString()
3510
+ });
3511
+ if (!tokenRes.ok) {
3512
+ const text = await tokenRes.text();
3513
+ throw new Error(`Token exchange failed (${tokenRes.status}): ${text}`);
3514
+ }
3515
+ const data = await tokenRes.json();
3516
+ if (!data.refresh_token) {
3517
+ throw new Error(
3518
+ "No refresh token received. Try revoking app access at https://myaccount.google.com/permissions and re-running setup."
3519
+ );
3520
+ }
3521
+ res.writeHead(200, { "Content-Type": "text/html" });
3522
+ res.end(SUCCESS_HTML);
3523
+ settle(null, { refreshToken: data.refresh_token, accessToken: data.access_token });
3524
+ } catch (err) {
3525
+ res.writeHead(200, { "Content-Type": "text/html" });
3526
+ res.end(errorHtml("Token exchange failed. Check your terminal."));
3527
+ settle(err instanceof Error ? err : new Error(String(err)));
3528
+ }
3529
+ });
3530
+ function settle(err, result) {
3531
+ if (settled) return;
3532
+ settled = true;
3533
+ clearTimeout(timer);
3534
+ server.close();
3535
+ if (err) reject(err);
3536
+ else resolve(result);
3537
+ }
3538
+ const timer = setTimeout(() => {
3539
+ settle(new Error("OAuth authorization timed out after 2 minutes. Please try again."));
3540
+ }, TIMEOUT_MS);
3541
+ server.listen(0, "127.0.0.1", () => {
3542
+ const addr = server.address();
3543
+ listenPort = addr.port;
3544
+ const redirectUri = `http://127.0.0.1:${listenPort}`;
3545
+ const authUrl = new URL(GOOGLE_AUTH_URL);
3546
+ authUrl.searchParams.set("client_id", clientId);
3547
+ authUrl.searchParams.set("redirect_uri", redirectUri);
3548
+ authUrl.searchParams.set("response_type", "code");
3549
+ authUrl.searchParams.set("scope", scopes.join(" "));
3550
+ authUrl.searchParams.set("access_type", "offline");
3551
+ authUrl.searchParams.set("prompt", "consent");
3552
+ const fullUrl = authUrl.toString();
3553
+ console.log("\n \u{1F310} Opening browser for Google authorization...");
3554
+ console.log(" If the browser doesn't open automatically, visit this URL:\n");
3555
+ console.log(` ${fullUrl}
3556
+ `);
3557
+ console.log(" \u23F3 Waiting for authorization (timeout: 2 minutes)...\n");
3558
+ openBrowser(fullUrl);
3559
+ });
3560
+ server.on("error", (err) => {
3561
+ settle(new Error(`Failed to start local OAuth server: ${err.message}`));
3562
+ });
3563
+ });
3564
+ }
3565
+ function openBrowser(url) {
3566
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3567
+ exec(`${cmd} "${url}"`, () => {
3568
+ });
3569
+ }
3570
+
3571
+ // src/cli/commands/setup.ts
3572
+ var ENV_PATH = "./.env";
3573
+ var CONFIG_PATH2 = "./config.json";
3574
+ function parseEnvFile() {
3575
+ const vars = /* @__PURE__ */ new Map();
3576
+ if (!existsSync6(ENV_PATH)) return vars;
3577
+ const content = readFileSync5(ENV_PATH, "utf-8");
3578
+ for (const line of content.split("\n")) {
3579
+ const trimmed = line.trim();
3580
+ if (!trimmed || trimmed.startsWith("#")) continue;
3581
+ const eqIdx = trimmed.indexOf("=");
3582
+ if (eqIdx === -1) continue;
3583
+ const key = trimmed.slice(0, eqIdx).trim();
3584
+ const value = trimmed.slice(eqIdx + 1).trim();
3585
+ vars.set(key, value);
3586
+ }
3587
+ return vars;
3588
+ }
3589
+ function writeEnvFile(vars) {
3590
+ const lines = [];
3591
+ const written = /* @__PURE__ */ new Set();
3592
+ if (existsSync6(ENV_PATH)) {
3593
+ const content = readFileSync5(ENV_PATH, "utf-8");
3594
+ for (const line of content.split("\n")) {
3595
+ const trimmed = line.trim();
3596
+ if (!trimmed || trimmed.startsWith("#")) {
3597
+ lines.push(line);
3598
+ continue;
3599
+ }
3600
+ const eqIdx = trimmed.indexOf("=");
3601
+ if (eqIdx === -1) {
3602
+ lines.push(line);
3603
+ continue;
3604
+ }
3605
+ const key = trimmed.slice(0, eqIdx).trim();
3606
+ if (vars.has(key)) {
3607
+ lines.push(`${key}=${vars.get(key)}`);
3608
+ written.add(key);
3609
+ } else {
3610
+ lines.push(line);
3611
+ }
3612
+ }
3613
+ }
3614
+ for (const [key, value] of vars) {
3615
+ if (!written.has(key)) {
3616
+ lines.push(`${key}=${value}`);
3617
+ }
3618
+ }
3619
+ writeFileSync4(ENV_PATH, lines.join("\n") + "\n", "utf-8");
3620
+ }
3621
+ function loadConfigJson() {
3622
+ if (!existsSync6(CONFIG_PATH2)) return {};
3623
+ try {
3624
+ return JSON.parse(readFileSync5(CONFIG_PATH2, "utf-8"));
3625
+ } catch {
3626
+ return {};
3627
+ }
3628
+ }
3629
+ function saveConfigJson(config) {
3630
+ writeFileSync4(CONFIG_PATH2, JSON.stringify(config, null, 2) + "\n", "utf-8");
3631
+ }
3632
+ var MODEL_CHOICES = [
3633
+ // Premium (3x per request)
3634
+ "gpt-5 \u2014 OpenAI GPT-5 [3x]",
3635
+ "o3 \u2014 OpenAI o3 reasoning [3x]",
3636
+ "claude-opus-4 \u2014 Anthropic Claude Opus 4 [3x]",
3637
+ // Standard (1x per request)
3638
+ "gpt-4.1 \u2014 OpenAI GPT-4.1 [1x]",
3639
+ "gpt-4o \u2014 OpenAI GPT-4o multimodal [1x]",
3640
+ "o4-mini \u2014 OpenAI o4-mini reasoning [1x]",
3641
+ "claude-sonnet-4 \u2014 Anthropic Claude Sonnet 4 [1x]",
3642
+ // Low (0.33x per request)
3643
+ "gpt-4o-mini \u2014 OpenAI GPT-4o Mini [0.33x]",
3644
+ "gpt-4.1-mini \u2014 OpenAI GPT-4.1 Mini [0.33x]",
3645
+ "gpt-5-mini \u2014 OpenAI GPT-5 Mini [0.33x]",
3646
+ "o3-mini \u2014 OpenAI o3-mini reasoning [0.33x]",
3647
+ "claude-haiku-4.5 \u2014 Anthropic Claude Haiku 4.5 [0.33x]",
3648
+ // Free (0x per request)
3649
+ "gpt-4.1-nano \u2014 OpenAI GPT-4.1 Nano [0x]"
3650
+ ];
3651
+ function extractModelId(choice) {
3652
+ return choice.split("\u2014")[0].trim();
3653
+ }
3654
+ var PLUGIN_CREDENTIAL_GUIDES = {
3655
+ gmail: `
3656
+ \u{1F4CB} Gmail Plugin \u2014 Setup Guide
3657
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3658
+ To use the Gmail plugin you need Google OAuth2 credentials:
3659
+
3660
+ 1. Go to https://console.cloud.google.com/apis/credentials
3661
+ 2. Create a project (or select an existing one)
3662
+ 3. Configure the OAuth consent screen (APIs & Services \u2192 OAuth consent screen)
3663
+ - Set to "External" for personal use, add your email as a test user
3664
+ 4. Enable the "Gmail API" under APIs & Services \u2192 Library
3665
+ 5. Go to APIs & Services \u2192 Credentials \u2192 Create Credentials \u2192 OAuth client ID
3666
+ 6. Application type: "Desktop app" (recommended \u2014 allows automatic localhost redirect)
3667
+ 7. Download the client secret JSON file
3668
+
3669
+ The setup will import your JSON file and then automatically open your browser
3670
+ to authorize access. No manual token copy-pasting required!
3671
+ `,
3672
+ "google-calendar": `
3673
+ \u{1F4CB} Google Calendar Plugin \u2014 Setup Guide
3674
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3675
+ To use the Google Calendar plugin you need Google OAuth2 credentials:
3676
+
3677
+ 1. Go to https://console.cloud.google.com/apis/credentials
3678
+ 2. Create a project (or select an existing one)
3679
+ 3. Configure the OAuth consent screen (APIs & Services \u2192 OAuth consent screen)
3680
+ - Set to "External" for personal use, add your email as a test user
3681
+ 4. Enable the "Google Calendar API" under APIs & Services \u2192 Library
3682
+ 5. Go to APIs & Services \u2192 Credentials \u2192 Create Credentials \u2192 OAuth client ID
3683
+ 6. Application type: "Desktop app" (recommended \u2014 allows automatic localhost redirect)
3684
+ 7. Download the client secret JSON file
3685
+
3686
+ The setup will import your JSON file and then automatically open your browser
3687
+ to authorize access. No manual token copy-pasting required!
3688
+ `
3689
+ };
3690
+ function parseGoogleClientSecretJson(content) {
3691
+ try {
3692
+ const parsed = JSON.parse(content);
3693
+ const creds = parsed.installed ?? parsed.web;
3694
+ if (!creds || !creds.client_id || !creds.client_secret) {
3695
+ return null;
3696
+ }
3697
+ return {
3698
+ client_id: creds.client_id,
3699
+ client_secret: creds.client_secret
3700
+ };
3701
+ } catch {
3702
+ return null;
3703
+ }
3704
+ }
3705
+ var GOOGLE_OAUTH_PLUGINS = /* @__PURE__ */ new Set(["gmail", "google-calendar"]);
3706
+ async function setupPlugin(manifest) {
3707
+ console.log(`
3708
+ Configuring ${manifest.id}...`);
3709
+ const guide = PLUGIN_CREDENTIAL_GUIDES[manifest.id];
3710
+ if (guide) {
3711
+ console.log(guide);
3712
+ }
3713
+ const config = loadConfigJson();
3714
+ const plugins = config.plugins ?? {};
3715
+ const existing = plugins[manifest.id] ?? {};
3716
+ const existingCreds = existing.credentials ?? {};
3717
+ const newCreds = { ...existingCreds };
3718
+ if (GOOGLE_OAUTH_PLUGINS.has(manifest.id)) {
3719
+ const prefix = manifest.id === "gmail" ? "GMAIL" : "GCAL";
3720
+ const clientIdKey = `${prefix}_CLIENT_ID`;
3721
+ const clientSecretKey = `${prefix}_CLIENT_SECRET`;
3722
+ const refreshTokenKey = `${prefix}_REFRESH_TOKEN`;
3723
+ const hasExisting = existingCreds[clientIdKey] && existingCreds[clientSecretKey];
3724
+ const skipJson = hasExisting && !await promptConfirm(
3725
+ " Existing credentials found. Re-import client secret JSON?",
3726
+ false
3727
+ );
3728
+ if (!skipJson) {
3729
+ console.log(" Provide the path to your Google OAuth client_secret JSON file.");
3730
+ console.log(" (Downloaded from Google Cloud Console \u2192 Credentials)");
3731
+ console.log(" Note: Only the credentials are extracted \u2014 the file is not stored or referenced.\n");
3732
+ while (true) {
3733
+ const jsonContent = await promptFilePath(" Path to client_secret JSON file");
3734
+ const parsed = parseGoogleClientSecretJson(jsonContent);
3735
+ if (parsed) {
3736
+ newCreds[clientIdKey] = parsed.client_id;
3737
+ newCreds[clientSecretKey] = parsed.client_secret;
3738
+ console.log(` \u2713 Extracted client_id: ${parsed.client_id.slice(0, 20)}...`);
3739
+ console.log(` \u2713 Extracted client_secret: ${"*".repeat(8)}`);
3740
+ break;
3741
+ } else {
3742
+ console.log(" \u26A0 Could not parse client_secret JSON. Expected format:");
3743
+ console.log(' { "installed": { "client_id": "...", "client_secret": "..." } }');
3744
+ console.log(" Please try again.\n");
3745
+ }
3746
+ }
3747
+ }
3748
+ const clientId = newCreds[clientIdKey];
3749
+ const clientSecret = newCreds[clientSecretKey];
3750
+ const scopes = GOOGLE_PLUGIN_SCOPES[manifest.id];
3751
+ const hasRefreshToken = Boolean(existingCreds[refreshTokenKey]);
3752
+ const runOAuth = !hasRefreshToken || await promptConfirm(
3753
+ " Run Google authorization to obtain a new refresh token?",
3754
+ !hasRefreshToken
3755
+ // default to yes if no existing token
3756
+ );
3757
+ if (runOAuth && clientId && clientSecret && scopes) {
3758
+ try {
3759
+ const result = await performGoogleOAuthFlow(clientId, clientSecret, scopes);
3760
+ newCreds[refreshTokenKey] = result.refreshToken;
3761
+ console.log(" \u2705 Authorization successful! Refresh token obtained and stored.");
3762
+ } catch (err) {
3763
+ const message2 = err instanceof Error ? err.message : String(err);
3764
+ console.log(`
3765
+ \u26A0 OAuth flow failed: ${message2}`);
3766
+ console.log(" Falling back to manual entry...\n");
3767
+ const manualToken = await promptSecret(` ${refreshTokenKey} (paste refresh token manually)`);
3768
+ newCreds[refreshTokenKey] = manualToken || existingCreds[refreshTokenKey] || "";
3769
+ }
3770
+ } else if (!runOAuth) {
3771
+ newCreds[refreshTokenKey] = existingCreds[refreshTokenKey] || "";
3772
+ }
3773
+ } else {
3774
+ for (const cred of manifest.requiredCredentials) {
3775
+ const current = existingCreds[cred.key] || void 0;
3776
+ const isSensitive = cred.type === "apikey" || cred.type === "oauth" || cred.key.toLowerCase().includes("secret") || cred.key.toLowerCase().includes("token");
3777
+ if (isSensitive) {
3778
+ const value = await promptSecret(` ${cred.key} (${cred.description})`);
3779
+ newCreds[cred.key] = value || current || "";
3780
+ } else {
3781
+ const value = await promptText(` ${cred.key} (${cred.description})`, current);
3782
+ newCreds[cred.key] = value;
3783
+ }
3784
+ }
3785
+ }
3786
+ const enable = await promptConfirm(" Enable this plugin?", false);
3787
+ plugins[manifest.id] = {
3788
+ ...existing,
3789
+ enabled: enable,
3790
+ credentials: newCreds
3791
+ };
3792
+ config.plugins = plugins;
3793
+ saveConfigJson(config);
3794
+ console.log(` \u2713 ${manifest.id} configured${enable ? " and enabled" : ""}`);
3795
+ }
3796
+ async function runGlobalSetup() {
3797
+ console.log("\n\u{1F527} Co-Assistant Setup Wizard");
3798
+ console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
3799
+ const envVars = parseEnvFile();
3800
+ console.log("Step 1: Telegram Bot Configuration");
3801
+ console.log(`
3802
+ \u{1F4CB} How to get your Telegram Bot Token:
3803
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3804
+ 1. Open Telegram and search for @BotFather
3805
+ 2. Send /newbot and follow the prompts to name your bot
3806
+ 3. BotFather will reply with a token like: 123456789:ABCdefGHI...
3807
+ 4. Copy that token and paste it below
3808
+
3809
+ \u{1F4CB} How to get your Telegram User ID:
3810
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3811
+ 1. Open Telegram and search for @userinfobot
3812
+ 2. Send any message (e.g. /start)
3813
+ 3. It will reply with your numeric User ID (e.g. 123456789)
3814
+ 4. This restricts the bot so only you can use it
3815
+ `);
3816
+ const existingToken = envVars.get("TELEGRAM_BOT_TOKEN");
3817
+ const existingUserId = envVars.get("TELEGRAM_USER_ID");
3818
+ if (existingToken || existingUserId) {
3819
+ console.log(" Existing values (press Enter to keep):");
3820
+ if (existingToken) console.log(` Bot Token: ${existingToken.slice(0, 8)}${"*".repeat(12)}`);
3821
+ if (existingUserId) console.log(` User ID: ${existingUserId}`);
3822
+ console.log("");
3823
+ }
3824
+ const botToken = await promptSecret(
3825
+ " Telegram Bot Token (Enter to keep existing)"
3826
+ );
3827
+ envVars.set("TELEGRAM_BOT_TOKEN", botToken || existingToken || "");
3828
+ const userId = await promptText(
3829
+ " Your Telegram User ID (from @userinfobot)",
3830
+ existingUserId
3831
+ );
3832
+ envVars.set("TELEGRAM_USER_ID", userId || existingUserId || "");
3833
+ console.log("\nStep 2: AI Model Selection");
3834
+ const currentModel = envVars.get("DEFAULT_MODEL") || "gpt-4.1";
3835
+ console.log(` Current model: ${currentModel}`);
3836
+ const changeModel = await promptConfirm(" Would you like to select a different model?", false);
3837
+ if (changeModel) {
3838
+ const choice = await promptSelect(" Available Copilot models:", MODEL_CHOICES);
3839
+ const selectedModel = extractModelId(choice);
3840
+ envVars.set("DEFAULT_MODEL", selectedModel);
3841
+ console.log(` \u2713 Model set to: ${selectedModel}`);
3842
+ } else {
3843
+ envVars.set("DEFAULT_MODEL", currentModel);
3844
+ }
3845
+ console.log("\nStep 3: GitHub Token (for Copilot SDK)");
3846
+ console.log(`
3847
+ \u{1F4CB} How to get a GitHub Personal Access Token:
3848
+ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3849
+ 1. Go to https://github.com/settings/tokens?type=beta
3850
+ 2. Click "Generate new token"
3851
+ 3. Give it a name (e.g. "co-assistant")
3852
+ 4. Set expiration as needed
3853
+ 5. Under "Permissions", enable:
3854
+ - "Copilot" \u2192 Read-only (required for Copilot SDK access)
3855
+ 6. Click "Generate token" and copy it
3856
+
3857
+ Alternatively, if you have the GitHub CLI installed:
3858
+ gh auth token
3859
+
3860
+ Note: Your account must have an active GitHub Copilot subscription.
3861
+ `);
3862
+ const existingGhToken = envVars.get("GITHUB_TOKEN");
3863
+ if (existingGhToken) {
3864
+ console.log(` Existing token: ${existingGhToken.slice(0, 8)}${"*".repeat(12)} (Enter to keep)
3865
+ `);
3866
+ }
3867
+ const ghToken = await promptSecret(
3868
+ " GitHub Token (Enter to keep existing / skip)"
3869
+ );
3870
+ if (ghToken) {
3871
+ envVars.set("GITHUB_TOKEN", ghToken);
3872
+ }
3873
+ if (!envVars.has("LOG_LEVEL")) {
3874
+ envVars.set("LOG_LEVEL", "info");
3875
+ }
3876
+ console.log("\nStep 4: Heartbeat Events (Scheduled AI Prompts)");
3877
+ console.log(`
3878
+ Heartbeat events are prompts the AI processes on a recurring schedule.
3879
+ Set the interval in minutes (e.g. 30 = every 30 minutes).
3880
+ Set to 0 to disable heartbeat events.
3881
+ `);
3882
+ const currentInterval = envVars.get("HEARTBEAT_INTERVAL_MINUTES") || "0";
3883
+ const interval = await promptText(
3884
+ " Heartbeat interval in minutes (0 to disable)",
3885
+ currentInterval
3886
+ );
3887
+ envVars.set("HEARTBEAT_INTERVAL_MINUTES", interval);
3888
+ writeEnvFile(envVars);
3889
+ console.log("\n\u2713 Configuration saved to .env");
3890
+ console.log(`
3891
+ \u{1F4A1} Tip: Personalise your assistant!
3892
+ \u2022 Edit personality.md to change the AI's tone and behaviour.
3893
+ \u2022 Copy user.md.example \u2192 user.md and fill in your details
3894
+ so the AI knows your name, role, timezone, and preferences.
3895
+ Both files are picked up automatically \u2014 no restart needed.
3896
+ `);
3897
+ const configurePlugins = await promptConfirm(
3898
+ "\nWould you like to configure plugins now?",
3899
+ false
3900
+ );
3901
+ if (!configurePlugins) return;
3902
+ const registry = createPluginRegistry();
3903
+ const manifests = await registry.discoverPlugins();
3904
+ if (manifests.length === 0) {
3905
+ console.log("\n No plugins found in the plugins/ directory.");
3906
+ return;
3907
+ }
3908
+ while (true) {
3909
+ console.log("\n Available plugins:");
3910
+ manifests.forEach((m, i) => {
3911
+ console.log(` ${i + 1}. ${m.id} - ${m.name}`);
3912
+ });
3913
+ const answer = await promptText(
3914
+ "\n Select a plugin to configure (number or 'done' to finish)"
3915
+ );
3916
+ if (answer.toLowerCase() === "done" || answer === "") break;
3917
+ const num = parseInt(answer, 10);
3918
+ if (num >= 1 && num <= manifests.length) {
3919
+ await setupPlugin(manifests[num - 1]);
3920
+ } else {
3921
+ console.log(" \u26A0 Invalid selection. Enter a number or 'done'.");
3922
+ }
3923
+ }
3924
+ }
3925
+ function registerSetupCommand(program2) {
3926
+ program2.command("setup").description("Run the interactive setup wizard").option("--plugin <id>", "Configure a specific plugin only").action(async (options) => {
3927
+ process.on("SIGINT", () => {
3928
+ console.log("\n\n\u{1F44B} Setup cancelled.");
3929
+ process.exit(0);
3930
+ });
3931
+ try {
3932
+ if (options.plugin) {
3933
+ const registry = createPluginRegistry();
3934
+ const manifests = await registry.discoverPlugins();
3935
+ const manifest = manifests.find((m) => m.id === options.plugin);
3936
+ if (!manifest) {
3937
+ console.error(`
3938
+ \u2717 Plugin "${options.plugin}" not found.`);
3939
+ const ids = manifests.map((m) => m.id);
3940
+ if (ids.length > 0) {
3941
+ console.error(` Available plugins: ${ids.join(", ")}`);
3942
+ }
3943
+ process.exit(1);
3944
+ }
3945
+ console.log(`
3946
+ Configuring plugin: ${manifest.id}`);
3947
+ await setupPlugin(manifest);
3948
+ } else {
3949
+ await runGlobalSetup();
3950
+ }
3951
+ console.log("");
3952
+ } catch (err) {
3953
+ if (err.code === "ERR_USE_AFTER_CLOSE") {
3954
+ console.log("\n\n\u{1F44B} Setup cancelled.");
3955
+ process.exit(0);
3956
+ }
3957
+ throw err;
3958
+ }
3959
+ });
3960
+ }
3961
+
3962
+ // src/cli/commands/plugin.ts
3963
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
3964
+ import path4 from "path";
3965
+ async function loadRegistry() {
3966
+ const registry = createPluginRegistry();
3967
+ const manifests = await registry.discoverPlugins();
3968
+ return { registry, manifests };
3969
+ }
3970
+ function requireManifest(manifests, pluginId) {
3971
+ const manifest = manifests.find((m) => m.id === pluginId);
3972
+ if (!manifest) {
3973
+ console.error(`\u2717 Plugin '${pluginId}' not found. Run 'plugin list' to see available plugins.`);
3974
+ process.exit(1);
3975
+ }
3976
+ return manifest;
3977
+ }
3978
+ function toTitleCase(id) {
3979
+ return id.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
3980
+ }
3981
+ async function handleList() {
3982
+ const { registry, manifests } = await loadRegistry();
3983
+ if (manifests.length === 0) {
3984
+ console.log("\n\u{1F50C} No plugins discovered. Add plugins to the plugins/ directory.\n");
3985
+ return;
3986
+ }
3987
+ let credMap = /* @__PURE__ */ new Map();
3988
+ try {
3989
+ const credStatuses = credentialManager.getCredentialStatus(manifests);
3990
+ credMap = new Map(credStatuses.map((s) => [s.pluginId, s]));
3991
+ } catch {
3992
+ }
3993
+ console.log("\n\u{1F50C} Discovered Plugins:\n");
3994
+ for (const manifest of manifests) {
3995
+ const enabled = registry.isEnabled(manifest.id);
3996
+ const cred = credMap.get(manifest.id);
3997
+ const statusIcon = enabled ? "\u2705 Enabled" : "\u274C Disabled";
3998
+ let credLabel;
3999
+ if (!manifest.requiredCredentials.length) {
4000
+ credLabel = "\u2713 none required";
4001
+ } else if (!cred) {
4002
+ credLabel = "\u26A0 unknown (config unavailable)";
4003
+ } else if (cred.missing.length === 0) {
4004
+ credLabel = "\u2713 configured";
4005
+ } else {
4006
+ credLabel = `\u2717 missing (${cred.missing.join(", ")})`;
4007
+ }
4008
+ console.log(` ${manifest.id} (v${manifest.version}) - ${manifest.name}`);
4009
+ console.log(` Status: ${statusIcon} | Credentials: ${credLabel}
4010
+ `);
4011
+ }
4012
+ }
4013
+ async function handleEnable(pluginId) {
4014
+ const { registry, manifests } = await loadRegistry();
4015
+ requireManifest(manifests, pluginId);
4016
+ if (registry.isEnabled(pluginId)) {
4017
+ console.log(`\u2139 Plugin '${pluginId}' is already enabled.`);
4018
+ return;
4019
+ }
4020
+ registry.enablePlugin(pluginId);
4021
+ console.log(`\u2713 Plugin '${pluginId}' enabled`);
4022
+ }
4023
+ async function handleDisable(pluginId) {
4024
+ const { registry, manifests } = await loadRegistry();
4025
+ requireManifest(manifests, pluginId);
4026
+ if (!registry.isEnabled(pluginId)) {
4027
+ console.log(`\u2139 Plugin '${pluginId}' is already disabled.`);
4028
+ return;
4029
+ }
4030
+ registry.disablePlugin(pluginId);
4031
+ console.log(`\u2713 Plugin '${pluginId}' disabled`);
4032
+ }
4033
+ async function handleInfo(pluginId) {
4034
+ const { registry, manifests } = await loadRegistry();
4035
+ const manifest = requireManifest(manifests, pluginId);
4036
+ const enabled = registry.isEnabled(manifest.id);
4037
+ const divider = "\u2500".repeat(22);
4038
+ console.log(`
4039
+ \u{1F4CB} Plugin: ${manifest.name}`);
4040
+ console.log(divider);
4041
+ console.log(`ID: ${manifest.id}`);
4042
+ console.log(`Version: ${manifest.version}`);
4043
+ console.log(`Description: ${manifest.description}`);
4044
+ if (manifest.author) {
4045
+ console.log(`Author: ${manifest.author}`);
4046
+ }
4047
+ console.log(`Status: ${enabled ? "Enabled" : "Disabled"}`);
4048
+ if (manifest.requiredCredentials.length > 0) {
4049
+ let cred;
4050
+ try {
4051
+ const credStatuses = credentialManager.getCredentialStatus([manifest]);
4052
+ cred = credStatuses[0];
4053
+ } catch {
4054
+ }
4055
+ console.log("\nRequired Credentials:");
4056
+ for (const req of manifest.requiredCredentials) {
4057
+ const isConfigured = cred?.configured.includes(req.key) ?? false;
4058
+ const tag = isConfigured ? "[configured]" : "[missing]";
4059
+ console.log(` ${req.key} - ${req.description} ${tag}`);
4060
+ }
4061
+ } else {
4062
+ console.log("\nNo credentials required.");
4063
+ }
4064
+ console.log();
4065
+ }
4066
+ async function handleCreate(pluginId) {
4067
+ if (!/^[a-z0-9-]+$/.test(pluginId)) {
4068
+ console.error("\u2717 Plugin ID must be kebab-case (lowercase letters, numbers, and hyphens only).");
4069
+ process.exit(1);
4070
+ }
4071
+ const pluginDir = path4.join(process.cwd(), "plugins", pluginId);
4072
+ if (existsSync7(pluginDir)) {
4073
+ console.error(`\u2717 Directory already exists: plugins/${pluginId}/`);
4074
+ process.exit(1);
4075
+ }
4076
+ mkdirSync4(pluginDir, { recursive: true });
4077
+ const displayName = toTitleCase(pluginId);
4078
+ const manifestContent = JSON.stringify(
4079
+ {
4080
+ id: pluginId,
4081
+ name: displayName,
4082
+ version: "1.0.0",
4083
+ description: `Description of the ${displayName} plugin`,
4084
+ author: "co-assistant",
4085
+ requiredCredentials: [],
4086
+ dependencies: []
4087
+ },
4088
+ null,
4089
+ 2
4090
+ );
4091
+ const indexContent = `import type { CoAssistantPlugin, PluginContext, ToolDefinition } from "../../src/plugins/types.js";
4092
+ import { tools } from "./tools.js";
4093
+
4094
+ export default function createPlugin(): CoAssistantPlugin {
4095
+ return {
4096
+ id: "${pluginId}",
4097
+ name: "${displayName} Plugin",
4098
+ version: "1.0.0",
4099
+ description: "Description of your plugin",
4100
+ requiredCredentials: [],
4101
+
4102
+ async initialize(context: PluginContext): Promise<void> {
4103
+ context.logger.info("Plugin initialized");
4104
+ },
4105
+
4106
+ getTools(): ToolDefinition[] {
4107
+ return tools;
4108
+ },
4109
+
4110
+ async destroy(): Promise<void> {},
4111
+
4112
+ async healthCheck(): Promise<boolean> {
4113
+ return true;
4114
+ },
4115
+ };
4116
+ }
4117
+ `;
4118
+ const toolsContent = `import type { ToolDefinition } from "../../src/plugins/types.js";
4119
+
4120
+ /**
4121
+ * Tools exposed by the ${displayName} plugin.
4122
+ */
4123
+ export const tools: ToolDefinition[] = [
4124
+ {
4125
+ name: "example",
4126
+ description: "An example tool \u2014 replace with your own",
4127
+ parameters: {
4128
+ type: "object",
4129
+ properties: {
4130
+ input: { type: "string", description: "Example input parameter" },
4131
+ },
4132
+ required: ["input"],
4133
+ },
4134
+ handler: async (args) => {
4135
+ const input = args.input as string;
4136
+ return \`Echo: \${input}\`;
4137
+ },
4138
+ },
4139
+ ];
4140
+ `;
4141
+ const readmeContent = `# ${displayName} Plugin
4142
+
4143
+ > Description of the ${displayName} plugin
4144
+
4145
+ ## Setup
4146
+
4147
+ 1. Add any required credentials to \`config.json\`:
4148
+
4149
+ \`\`\`json
4150
+ {
4151
+ "plugins": {
4152
+ "${pluginId}": {
4153
+ "enabled": true,
4154
+ "credentials": {}
4155
+ }
4156
+ }
4157
+ }
4158
+ \`\`\`
4159
+
4160
+ 2. Enable the plugin:
4161
+
4162
+ \`\`\`bash
4163
+ co-assistant plugin enable ${pluginId}
4164
+ \`\`\`
4165
+
4166
+ ## Tools
4167
+
4168
+ | Tool | Description |
4169
+ |------|-------------|
4170
+ | \`example\` | An example tool \u2014 replace with your own |
4171
+
4172
+ ## Development
4173
+
4174
+ Edit \`tools.ts\` to add or modify the tools this plugin exposes.
4175
+ `;
4176
+ writeFileSync5(path4.join(pluginDir, "plugin.json"), manifestContent + "\n", "utf-8");
4177
+ writeFileSync5(path4.join(pluginDir, "index.ts"), indexContent, "utf-8");
4178
+ writeFileSync5(path4.join(pluginDir, "tools.ts"), toolsContent, "utf-8");
4179
+ writeFileSync5(path4.join(pluginDir, "README.md"), readmeContent, "utf-8");
4180
+ console.log(`\u2713 Plugin '${pluginId}' scaffolded at plugins/${pluginId}/`);
4181
+ }
4182
+ function registerPluginCommand(program2) {
4183
+ const plugin = program2.command("plugin").description("Manage plugins");
4184
+ plugin.command("list").description("List all available plugins").action(handleList);
4185
+ plugin.command("enable").description("Enable a plugin").argument("<id>", "Plugin ID to enable").action(handleEnable);
4186
+ plugin.command("disable").description("Disable a plugin").argument("<id>", "Plugin ID to disable").action(handleDisable);
4187
+ plugin.command("info").description("Show detailed information about a plugin").argument("<id>", "Plugin ID to inspect").action(handleInfo);
4188
+ plugin.command("create").description("Scaffold a new plugin from a template").argument("<id>", "ID for the new plugin (kebab-case)").action(handleCreate);
4189
+ }
4190
+
4191
+ // src/cli/commands/model.ts
4192
+ init_database();
4193
+ function initRegistry() {
4194
+ const db = getDatabase();
4195
+ const prefs = new PreferencesRepository(db);
4196
+ return createModelRegistry(prefs);
4197
+ }
4198
+ function printModelTable(models, currentId) {
4199
+ const idHeader = "ID";
4200
+ const providerHeader = "Provider";
4201
+ const descHeader = "Description";
4202
+ const idWidth = Math.max(idHeader.length, ...models.map((m) => m.id.length + 2));
4203
+ const providerWidth = Math.max(providerHeader.length, ...models.map((m) => m.provider.length));
4204
+ const descWidth = Math.max(descHeader.length, ...models.map((m) => m.description.length));
4205
+ const pad = (str, len) => str + " ".repeat(Math.max(0, len - str.length));
4206
+ const row = (id, prov, desc) => `\u2502 ${pad(id, idWidth)} \u2502 ${pad(prov, providerWidth)} \u2502 ${pad(desc, descWidth)} \u2502`;
4207
+ const top = `\u250C\u2500${"\u2500".repeat(idWidth)}\u2500\u252C\u2500${"\u2500".repeat(providerWidth)}\u2500\u252C\u2500${"\u2500".repeat(descWidth)}\u2500\u2510`;
4208
+ const mid = `\u251C\u2500${"\u2500".repeat(idWidth)}\u2500\u253C\u2500${"\u2500".repeat(providerWidth)}\u2500\u253C\u2500${"\u2500".repeat(descWidth)}\u2500\u2524`;
4209
+ const bot = `\u2514\u2500${"\u2500".repeat(idWidth)}\u2500\u2534\u2500${"\u2500".repeat(providerWidth)}\u2500\u2534\u2500${"\u2500".repeat(descWidth)}\u2500\u2518`;
4210
+ console.log("\nAvailable Models:");
4211
+ console.log(top);
4212
+ console.log(row(idHeader, providerHeader, descHeader));
4213
+ console.log(mid);
4214
+ for (const m of models) {
4215
+ const label = m.id === currentId ? `* ${m.id}` : ` ${m.id}`;
4216
+ console.log(row(label, m.provider, m.description));
4217
+ }
4218
+ console.log(bot);
4219
+ console.log("* = currently selected\n");
4220
+ }
4221
+ function registerModelCommand(program2) {
4222
+ const model = program2.command("model").description("Manage AI models");
4223
+ model.command("list").description("List all available AI models").action(async () => {
4224
+ try {
4225
+ const registry = initRegistry();
4226
+ const models = registry.getAvailableModels();
4227
+ const currentId = registry.getCurrentModelId();
4228
+ printModelTable(models, currentId);
4229
+ } finally {
4230
+ closeDatabase();
4231
+ }
4232
+ });
4233
+ model.command("get").description("Show the currently configured AI model").action(async () => {
4234
+ try {
4235
+ const registry = initRegistry();
4236
+ const currentId = registry.getCurrentModelId();
4237
+ console.log(`Current model: ${currentId}`);
4238
+ } finally {
4239
+ closeDatabase();
4240
+ }
4241
+ });
4242
+ model.command("set").description("Set the active AI model").argument("<modelId>", "Model identifier to activate").action(async (modelId) => {
4243
+ try {
4244
+ const registry = initRegistry();
4245
+ if (!registry.isValidModel(modelId)) {
4246
+ console.log(`\u26A0 Warning: '${modelId}' is not in the known models list`);
4247
+ }
4248
+ registry.setCurrentModel(modelId);
4249
+ console.log(`\u2713 Model set to: ${modelId}`);
4250
+ } finally {
4251
+ closeDatabase();
4252
+ }
4253
+ });
4254
+ }
4255
+
4256
+ // src/cli/commands/status.ts
4257
+ import { existsSync as existsSync8 } from "fs";
4258
+ init_database();
4259
+ function registerStatusCommand(program2) {
4260
+ program2.command("status").description("Show bot and plugin status").action(async () => {
4261
+ console.log("\n\u{1F4CA} Co-Assistant Status\n");
4262
+ const envExists = existsSync8(".env");
4263
+ const configExists = existsSync8("config.json");
4264
+ console.log(` .env: ${envExists ? "\u2713 found" : "\u2717 not found"}`);
4265
+ console.log(` config.json: ${configExists ? "\u2713 found" : "\u2717 not found"}`);
4266
+ try {
4267
+ const db = getDatabase();
4268
+ const prefs = new PreferencesRepository(db);
4269
+ const registry = createModelRegistry(prefs);
4270
+ console.log(` AI model: ${registry.getCurrentModelId()}`);
4271
+ closeDatabase();
4272
+ } catch {
4273
+ console.log(" AI model: \u26A0 could not determine");
4274
+ }
4275
+ try {
4276
+ const pluginRegistry = createPluginRegistry();
4277
+ const manifests = await pluginRegistry.discoverPlugins();
4278
+ if (manifests.length === 0) {
4279
+ console.log("\n \u{1F50C} No plugins discovered.\n");
4280
+ } else {
4281
+ console.log(`
4282
+ \u{1F50C} Plugins (${manifests.length}):
4283
+ `);
4284
+ for (const m of manifests) {
4285
+ const enabled = pluginRegistry.isEnabled(m.id);
4286
+ const icon = enabled ? "\u2705" : "\u274C";
4287
+ console.log(` ${icon} ${m.id} v${m.version}`);
4288
+ }
4289
+ console.log();
4290
+ }
4291
+ } catch {
4292
+ console.log("\n \u{1F50C} Plugins: \u26A0 could not scan\n");
4293
+ }
4294
+ });
4295
+ }
4296
+
4297
+ // src/cli/commands/heartbeat.ts
4298
+ function registerHeartbeatCommand(program2) {
4299
+ const heartbeat = program2.command("heartbeat").description("Manage heartbeat events (scheduled AI prompts)");
4300
+ heartbeat.command("list").description("List all heartbeat events").action(() => {
4301
+ const manager = new HeartbeatManager();
4302
+ const events = manager.listEvents();
4303
+ if (events.length === 0) {
4304
+ console.log("\n No heartbeat events found.");
4305
+ console.log(" Add one with: co-assistant heartbeat add\n");
4306
+ return;
4307
+ }
4308
+ console.log(`
4309
+ \u{1F4CB} Heartbeat Events (${events.length}):`);
4310
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
4311
+ for (const event of events) {
4312
+ const preview = event.prompt.split("\n")[0]?.slice(0, 60) ?? "";
4313
+ const ellipsis = event.prompt.length > 60 ? "\u2026" : "";
4314
+ console.log(` \u2022 ${event.name}`);
4315
+ console.log(` "${preview}${ellipsis}"`);
4316
+ }
4317
+ console.log("");
4318
+ });
4319
+ heartbeat.command("add").description("Add a new heartbeat event").action(async () => {
4320
+ const manager = new HeartbeatManager();
4321
+ console.log("\n \u{1F4DD} Add Heartbeat Event");
4322
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
4323
+ console.log(" Heartbeat events are prompts sent to the AI agent on a schedule.\n");
4324
+ const name = await promptText(" Event name (e.g. morning-briefing)");
4325
+ if (!name) {
4326
+ console.log(" \u26A0 Name cannot be empty.");
4327
+ return;
4328
+ }
4329
+ console.log("\n Enter the prompt for this heartbeat event.");
4330
+ console.log(" This is what the AI will process on each interval.\n");
4331
+ const prompt = await promptText(" Prompt");
4332
+ if (!prompt) {
4333
+ console.log(" \u26A0 Prompt cannot be empty.");
4334
+ return;
4335
+ }
4336
+ try {
4337
+ manager.addEvent(name, prompt);
4338
+ console.log(`
4339
+ \u2713 Heartbeat event "${name}" created.`);
4340
+ console.log(` File: heartbeats/${name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")}.heartbeat.md
4341
+ `);
4342
+ } catch (err) {
4343
+ const msg = err instanceof Error ? err.message : String(err);
4344
+ console.error(`
4345
+ \u2717 ${msg}
4346
+ `);
4347
+ }
4348
+ });
4349
+ heartbeat.command("remove <name>").description("Remove a heartbeat event by name").action((name) => {
4350
+ const manager = new HeartbeatManager();
4351
+ try {
4352
+ manager.removeEvent(name);
4353
+ console.log(`
4354
+ \u2713 Heartbeat event "${name}" removed.
4355
+ `);
4356
+ } catch (err) {
4357
+ const msg = err instanceof Error ? err.message : String(err);
4358
+ console.error(`
4359
+ \u2717 ${msg}
4360
+ `);
4361
+ }
4362
+ });
4363
+ heartbeat.command("show <name>").description("Show a heartbeat event's prompt").action((name) => {
4364
+ const manager = new HeartbeatManager();
4365
+ const events = manager.listEvents();
4366
+ const event = events.find((e) => e.name === name);
4367
+ if (!event) {
4368
+ console.error(`
4369
+ \u2717 Heartbeat event "${name}" not found.`);
4370
+ const names = events.map((e) => e.name);
4371
+ if (names.length > 0) {
4372
+ console.error(` Available: ${names.join(", ")}`);
4373
+ }
4374
+ console.error("");
4375
+ return;
4376
+ }
4377
+ console.log(`
4378
+ \u{1F4CB} ${event.name}`);
4379
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
4380
+ console.log(` File: ${event.filePath}`);
4381
+ console.log("");
4382
+ for (const line of event.prompt.split("\n")) {
4383
+ console.log(` ${line}`);
4384
+ }
4385
+ console.log("");
4386
+ });
4387
+ heartbeat.command("run [name]").description("Run heartbeat(s) on demand \u2014 boots AI, sends results to Telegram").option("--no-telegram", "Print results to console only, don't send to Telegram").action(async (name, opts) => {
4388
+ const manager = new HeartbeatManager();
4389
+ const allEvents = manager.listEvents();
4390
+ if (allEvents.length === 0) {
4391
+ console.log("\n No heartbeat events found.");
4392
+ console.log(" Add one with: co-assistant heartbeat add\n");
4393
+ return;
4394
+ }
4395
+ let eventsToRun = allEvents;
4396
+ if (name) {
4397
+ const event = allEvents.find((e) => e.name === name);
4398
+ if (!event) {
4399
+ console.error(`
4400
+ \u2717 Heartbeat event "${name}" not found.`);
4401
+ const names = allEvents.map((e) => e.name);
4402
+ if (names.length > 0) console.error(` Available: ${names.join(", ")}`);
4403
+ console.error("");
4404
+ return;
4405
+ }
4406
+ eventsToRun = [event];
4407
+ }
4408
+ console.log(`
4409
+ \u{1F680} Running ${eventsToRun.length} heartbeat event(s) on demand\u2026
4410
+ `);
4411
+ const { getConfig: getConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
4412
+ const { copilotClient: copilotClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
4413
+ const { sessionManager: sessionManager2 } = await Promise.resolve().then(() => (init_session(), session_exports));
4414
+ const { splitMessage: splitMessage2 } = await Promise.resolve().then(() => (init_message(), message_exports));
4415
+ let config;
4416
+ try {
4417
+ config = getConfig2();
4418
+ } catch {
4419
+ console.error(" \u2717 Configuration not found. Run `co-assistant setup` first.\n");
4420
+ return;
4421
+ }
4422
+ console.log(" \u25B8 Starting Copilot SDK client\u2026");
4423
+ try {
4424
+ await copilotClient2.start();
4425
+ } catch (err) {
4426
+ const msg = err instanceof Error ? err.message : String(err);
4427
+ console.error(` \u2717 Failed to start Copilot client: ${msg}
4428
+ `);
4429
+ return;
4430
+ }
4431
+ const model = config.env.DEFAULT_MODEL || "gpt-4.1";
4432
+ console.log(` \u25B8 Creating AI session (model: ${model})\u2026`);
4433
+ try {
4434
+ await sessionManager2.createSession(model, void 0, 1);
4435
+ } catch (err) {
4436
+ const msg = err instanceof Error ? err.message : String(err);
4437
+ console.error(` \u2717 Failed to create AI session: ${msg}
4438
+ `);
4439
+ await copilotClient2.stop();
4440
+ return;
4441
+ }
4442
+ let telegram = null;
4443
+ if (opts.telegram && config.env.TELEGRAM_BOT_TOKEN) {
4444
+ try {
4445
+ const { Telegraf: Telegraf2 } = await import("telegraf");
4446
+ const tgBot = new Telegraf2(config.env.TELEGRAM_BOT_TOKEN);
4447
+ telegram = tgBot.telegram;
4448
+ } catch {
4449
+ console.log(" \u26A0 Could not initialize Telegram \u2014 results will print to console only.");
4450
+ }
4451
+ }
4452
+ for (const event of eventsToRun) {
4453
+ console.log(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500`);
4454
+ console.log(` \u{1F493} Running: ${event.name}`);
4455
+ const useDedup = event.prompt.includes("{{DEDUP_STATE}}");
4456
+ const state = useDedup ? manager.loadState(event.name) : null;
4457
+ const finalPrompt = state ? event.prompt.replace(
4458
+ "{{DEDUP_STATE}}",
4459
+ state.processedIds.length > 0 ? `Previously processed IDs (${state.processedIds.length} total) \u2014 SKIP these:
4460
+ ${state.processedIds.map((id) => `- ${id}`).join("\n")}` : "No previously processed items \u2014 this is the first run."
4461
+ ) : event.prompt;
4462
+ try {
4463
+ console.log(" \u25B8 Sending prompt to AI\u2026");
4464
+ const response = await sessionManager2.sendMessage(finalPrompt);
4465
+ if (!response) {
4466
+ console.log(" \u26A0 No response from AI.\n");
4467
+ continue;
4468
+ }
4469
+ const processedMarkerRe = /<!--\s*PROCESSED:\s*(.*?)\s*-->/gi;
4470
+ if (useDedup && state) {
4471
+ let match;
4472
+ const newIds = [];
4473
+ while ((match = processedMarkerRe.exec(response)) !== null) {
4474
+ for (const id of (match[1] ?? "").split(",")) {
4475
+ const trimmed = id.trim();
4476
+ if (trimmed) newIds.push(trimmed);
4477
+ }
4478
+ }
4479
+ processedMarkerRe.lastIndex = 0;
4480
+ if (newIds.length > 0) {
4481
+ const existing = new Set(state.processedIds);
4482
+ const uniqueNew = newIds.filter((id) => !existing.has(id));
4483
+ if (uniqueNew.length > 0) {
4484
+ state.processedIds.push(...uniqueNew);
4485
+ state.lastRun = (/* @__PURE__ */ new Date()).toISOString();
4486
+ manager.saveState(event.name, state);
4487
+ console.log(` \u2713 Dedup: recorded ${uniqueNew.length} new IDs`);
4488
+ }
4489
+ }
4490
+ }
4491
+ const cleanResponse = response.replace(/<!--\s*PROCESSED:\s*.*?\s*-->/gi, "").trim();
4492
+ console.log("\n \u2500\u2500 AI Response \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
4493
+ for (const line of cleanResponse.split("\n")) {
4494
+ console.log(` ${line}`);
4495
+ }
4496
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
4497
+ if (telegram) {
4498
+ try {
4499
+ const chatId = config.env.TELEGRAM_USER_ID;
4500
+ const header = `\u{1F493} *Heartbeat: ${event.name}*
4501
+
4502
+ `;
4503
+ const chunks = splitMessage2(header + cleanResponse);
4504
+ for (const chunk of chunks) {
4505
+ await telegram.sendMessage(chatId, chunk, { parse_mode: "Markdown" });
4506
+ }
4507
+ console.log(" \u2713 Sent to Telegram");
4508
+ } catch (err) {
4509
+ const msg = err instanceof Error ? err.message : String(err);
4510
+ console.log(` \u26A0 Failed to send to Telegram: ${msg}`);
4511
+ }
4512
+ }
4513
+ } catch (err) {
4514
+ const msg = err instanceof Error ? err.message : String(err);
4515
+ console.error(` \u2717 Failed: ${msg}
4516
+ `);
4517
+ }
4518
+ }
4519
+ console.log("\n \u25B8 Shutting down\u2026");
4520
+ try {
4521
+ await sessionManager2.closeSession();
4522
+ } catch {
4523
+ }
4524
+ try {
4525
+ await copilotClient2.stop();
4526
+ } catch {
4527
+ }
4528
+ console.log(" \u2713 Done.\n");
4529
+ });
4530
+ }
4531
+
4532
+ // src/cli/index.ts
4533
+ if (!process.env.LOG_LEVEL) {
4534
+ setLogLevel("silent");
4535
+ }
4536
+ var require2 = createRequire(import.meta.url);
4537
+ var pkg = require2("../../package.json");
4538
+ var program = new Command();
4539
+ program.name("co-assistant").description("AI-powered Telegram personal assistant using GitHub Copilot SDK").version(pkg.version);
4540
+ registerStartCommand(program);
4541
+ registerSetupCommand(program);
4542
+ registerPluginCommand(program);
4543
+ registerModelCommand(program);
4544
+ registerHeartbeatCommand(program);
4545
+ registerStatusCommand(program);
4546
+ program.parse(process.argv);
4547
+ //# sourceMappingURL=index.js.map