@opencoreai/opencore 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,2523 @@
1
+ import "dotenv/config";
2
+ import figlet from "figlet";
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { promises as fs } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createRequire } from "node:module";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import readline from "node:readline/promises";
10
+ import { promisify } from "node:util";
11
+ import { stdin as input, stdout as output } from "node:process";
12
+ import { DashboardServer } from "./dashboard-server.js";
13
+ import { MacController } from "./mac-controller.mjs";
14
+ import { SKILL_CATALOG } from "./skill-catalog.mjs";
15
+ import {
16
+ buildCredentialExecutionContext,
17
+ ensureCredentialStore,
18
+ extractCredentialSaveCandidates,
19
+ readCredentialStore,
20
+ summarizeCredentialStoreForPrompt,
21
+ updateCredentialDefaults,
22
+ upsertCredentialEntry,
23
+ } from "./credential-store.mjs";
24
+
25
+ const MAX_STEPS = Math.max(1, Number(process.env.COMPUTER_USE_MAX_STEPS || 40));
26
+ const ACTION_DELAY_MS = Math.max(0, Number(process.env.COMPUTER_USE_ACTION_DELAY_MS || 350));
27
+ const OPENAI_MODEL = "gpt-5.4";
28
+ const OPENAI_COMPUTER_MODEL_CANDIDATES = [OPENAI_MODEL, "computer-use-preview", "computer-use-preview-2025-03-11"];
29
+ const API_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
30
+ const API_IMAGE_TARGET_BYTES = 4_900_000;
31
+ const MANAGER_HEARTBEAT_INTERVAL_MS = Math.max(
32
+ 15_000,
33
+ Number(process.env.OPENCORE_MANAGER_HEARTBEAT_INTERVAL_MS || 60_000),
34
+ );
35
+ const OPENCORE_HOME = path.join(os.homedir(), ".opencore");
36
+ const SOUL_PATH = path.join(OPENCORE_HOME, "soul.md");
37
+ const MEMORY_PATH = path.join(OPENCORE_HOME, "memory.md");
38
+ const HEARTBEAT_PATH = path.join(OPENCORE_HOME, "heartbeat.md");
39
+ const GUIDELINES_PATH = path.join(OPENCORE_HOME, "guidelines.md");
40
+ const INSTRUCTIONS_PATH = path.join(OPENCORE_HOME, "instructions.md");
41
+ const SETTINGS_PATH = path.join(OPENCORE_HOME, "configs", "settings.json");
42
+ const SCHEDULES_PATH = path.join(OPENCORE_HOME, "configs", "schedules.json");
43
+ const SCREENSHOT_DIR = path.join(OPENCORE_HOME, "screenshots");
44
+ const SKILLS_DIR = path.join(OPENCORE_HOME, "skills");
45
+ const INDICATOR_STATE_PATH = path.join(OPENCORE_HOME, "indicator-state.json");
46
+ const SCHEDULER_LOG_PATH = path.join(OPENCORE_HOME, "logs", "scheduler.log");
47
+ const DASHBOARD_PORT = Number(process.env.OPENCORE_DASHBOARD_PORT || 4111);
48
+ const __filename = fileURLToPath(import.meta.url);
49
+ const __dirname = path.dirname(__filename);
50
+ const ROOT_DIR = path.resolve(__dirname, "..");
51
+ const TEMPLATE_DIR = path.join(ROOT_DIR, "templates");
52
+ const INDICATOR_SCRIPT_PATH = path.join(ROOT_DIR, "src", "opencore-indicator.js");
53
+ const DEFAULT_SOUL = "# OpenCore Soul\nName: OpenCore\n";
54
+ const DEFAULT_MEMORY = "# OpenCore Memory\n";
55
+ const DEFAULT_HEARTBEAT = `# OpenCore Heartbeat
56
+
57
+ ## Current Task
58
+ No active task.
59
+
60
+ ## Big Steps
61
+ 1. Wait for a new task.
62
+
63
+ ## Execution Notes
64
+ - Rewrite this file at the start of every new task.
65
+ `;
66
+ const HEARTBEAT_SCHEDULE_START = "<!-- OPENCORE_SCHEDULES_START -->";
67
+ const HEARTBEAT_SCHEDULE_END = "<!-- OPENCORE_SCHEDULES_END -->";
68
+ const DEFAULT_GUIDELINES = "# OpenCore Guidelines\n";
69
+ const DEFAULT_INSTRUCTIONS = "# OpenCore Instructions\n";
70
+ const LEGACY_DEFAULT_MEMORY = `# OpenCore Memory
71
+
72
+ This file stores durable notes and action history for both the Manager Agent and Computer Agent.
73
+ `;
74
+ const EDITABLE_FILES: Record<string, string> = {
75
+ soul: SOUL_PATH,
76
+ memory: MEMORY_PATH,
77
+ heartbeat: HEARTBEAT_PATH,
78
+ guidelines: GUIDELINES_PATH,
79
+ instructions: INSTRUCTIONS_PATH,
80
+ };
81
+
82
+ const DEFAULT_SYSTEM_SKILLS = SKILL_CATALOG.filter((skill: any) => skill?.config?.builtin);
83
+
84
+ type ManagerRoute = "direct" | "computer" | "edit_file" | "local";
85
+ type ManagerDecision = {
86
+ route: ManagerRoute;
87
+ direct_answer?: string;
88
+ computer_task?: string;
89
+ file_target?: string;
90
+ edit_instructions?: string;
91
+ local_task?: string;
92
+ };
93
+
94
+ type HeartbeatTickDecision = {
95
+ run_now: boolean;
96
+ task?: string;
97
+ reason?: string;
98
+ };
99
+
100
+ type ManagerInferredUpdate = {
101
+ target: string;
102
+ instructions: string;
103
+ };
104
+
105
+ type ManagerPersistentUpdatePlan = {
106
+ memory_notes?: string[];
107
+ file_updates?: ManagerInferredUpdate[];
108
+ };
109
+
110
+ type ScheduledTaskPlan = {
111
+ should_schedule: boolean;
112
+ cron_expression?: string;
113
+ task?: string;
114
+ summary?: string;
115
+ schedule_kind?: "one_time" | "recurring";
116
+ expected_time_iso?: string;
117
+ };
118
+
119
+ type ScheduledTaskRecord = {
120
+ id: string;
121
+ task: string;
122
+ cron_expression: string;
123
+ summary: string;
124
+ schedule_kind: "one_time" | "recurring";
125
+ created_at: string;
126
+ updated_at: string;
127
+ last_run_at?: string;
128
+ expected_time_iso?: string;
129
+ last_status?: "scheduled" | "running" | "done" | "error" | "missed" | "cancelled";
130
+ last_error?: string;
131
+ active: boolean;
132
+ source_prompt: string;
133
+ };
134
+
135
+ type ProviderRuntime = { provider: "chatgpt"; openai: any };
136
+
137
+ type TelegramConfig = {
138
+ enabled: boolean;
139
+ botToken: string;
140
+ chatId: string;
141
+ userId: string;
142
+ pairingCode: string;
143
+ paired: boolean;
144
+ lastUpdateId: number;
145
+ };
146
+
147
+ const require = createRequire(import.meta.url);
148
+ const { version: APP_VERSION } = require("../package.json");
149
+ const execFileAsync = promisify(execFile);
150
+
151
+ async function readSettings() {
152
+ try {
153
+ const raw = await fs.readFile(SETTINGS_PATH, "utf8");
154
+ return JSON.parse(raw || "{}");
155
+ } catch {
156
+ return {};
157
+ }
158
+ }
159
+
160
+ async function readTemplate(name: string, fallback: string) {
161
+ try {
162
+ return await fs.readFile(path.join(TEMPLATE_DIR, name), "utf8");
163
+ } catch {
164
+ return fallback;
165
+ }
166
+ }
167
+
168
+ async function ensureTemplateApplied(filePath: string, template: string, legacyDefaults: string[] = []) {
169
+ try {
170
+ const existing = await fs.readFile(filePath, "utf8");
171
+ const normalized = existing.trim();
172
+ const isLegacy = legacyDefaults.some((value) => normalized === String(value || "").trim());
173
+ if (isLegacy) {
174
+ await fs.writeFile(filePath, template, "utf8");
175
+ }
176
+ } catch {
177
+ await fs.writeFile(filePath, template, "utf8");
178
+ }
179
+ }
180
+
181
+ async function ensureOpenCoreHome() {
182
+ await fs.mkdir(path.join(OPENCORE_HOME, "configs"), { recursive: true });
183
+ await fs.mkdir(path.join(OPENCORE_HOME, "logs"), { recursive: true });
184
+ await fs.mkdir(path.join(OPENCORE_HOME, "cache"), { recursive: true });
185
+ await fs.mkdir(SCREENSHOT_DIR, { recursive: true });
186
+ await fs.mkdir(SKILLS_DIR, { recursive: true });
187
+
188
+ const soulTemplate = await readTemplate("default-soul.md", DEFAULT_SOUL);
189
+ const memoryTemplate = await readTemplate("default-memory.md", LEGACY_DEFAULT_MEMORY);
190
+ const guidelinesTemplate = await readTemplate("default-guidelines.md", DEFAULT_GUIDELINES);
191
+ const instructionsTemplate = await readTemplate("default-instructions.md", DEFAULT_INSTRUCTIONS);
192
+ const heartbeatTemplate = await readTemplate("default-heartbeat.md", DEFAULT_HEARTBEAT);
193
+
194
+ await ensureTemplateApplied(SOUL_PATH, soulTemplate, [DEFAULT_SOUL]);
195
+ await ensureTemplateApplied(MEMORY_PATH, memoryTemplate, [LEGACY_DEFAULT_MEMORY, DEFAULT_MEMORY]);
196
+ await ensureTemplateApplied(HEARTBEAT_PATH, heartbeatTemplate, [DEFAULT_HEARTBEAT]);
197
+ await ensureTemplateApplied(GUIDELINES_PATH, guidelinesTemplate, [DEFAULT_GUIDELINES]);
198
+ await ensureTemplateApplied(INSTRUCTIONS_PATH, instructionsTemplate, [DEFAULT_INSTRUCTIONS]);
199
+ try {
200
+ await fs.access(SETTINGS_PATH);
201
+ } catch {
202
+ await fs.writeFile(
203
+ SETTINGS_PATH,
204
+ `${JSON.stringify(
205
+ {
206
+ name: "OpenCore",
207
+ platform: "macOS",
208
+ provider: "chatgpt",
209
+ openai_api_key: "",
210
+ telegram_enabled: false,
211
+ telegram_bot_token: "",
212
+ telegram_chat_id: "",
213
+ telegram_user_id: "",
214
+ telegram_pairing_code: "",
215
+ telegram_paired: false,
216
+ telegram_last_update_id: 0,
217
+ user_display_name: "",
218
+ assistant_tone: "",
219
+ schema_version: 1,
220
+ },
221
+ null,
222
+ 2,
223
+ )}\n`,
224
+ "utf8",
225
+ );
226
+ }
227
+ try {
228
+ await fs.access(SCHEDULES_PATH);
229
+ } catch {
230
+ await fs.writeFile(SCHEDULES_PATH, "[]\n", "utf8");
231
+ }
232
+ await ensureDefaultOpenCoreSkills();
233
+ await ensureCredentialStore();
234
+ }
235
+
236
+ async function ensureDefaultOpenCoreSkills() {
237
+ for (const skill of DEFAULT_SYSTEM_SKILLS) {
238
+ const dir = path.join(SKILLS_DIR, skill.id);
239
+ const skillFile = path.join(dir, "SKILL.md");
240
+ const configFile = path.join(dir, "config.json");
241
+ await fs.mkdir(dir, { recursive: true });
242
+ try {
243
+ await fs.access(skillFile);
244
+ } catch {
245
+ await fs.writeFile(skillFile, `${skill.markdown.trim()}\n`, "utf8");
246
+ }
247
+ try {
248
+ await fs.access(configFile);
249
+ } catch {
250
+ await fs.writeFile(
251
+ configFile,
252
+ `${JSON.stringify(
253
+ {
254
+ id: skill.id,
255
+ name: skill.name,
256
+ description: skill.description,
257
+ ...skill.config,
258
+ },
259
+ null,
260
+ 2,
261
+ )}\n`,
262
+ "utf8",
263
+ );
264
+ }
265
+ }
266
+ }
267
+
268
+ async function readSchedules(): Promise<ScheduledTaskRecord[]> {
269
+ try {
270
+ const raw = await fs.readFile(SCHEDULES_PATH, "utf8");
271
+ const parsed = JSON.parse(raw || "[]");
272
+ return Array.isArray(parsed) ? parsed : [];
273
+ } catch {
274
+ return [];
275
+ }
276
+ }
277
+
278
+ async function writeSchedules(items: ScheduledTaskRecord[]) {
279
+ await fs.writeFile(SCHEDULES_PATH, `${JSON.stringify(items, null, 2)}\n`, "utf8");
280
+ }
281
+
282
+ async function updateScheduleRecord(id: string, apply: (item: ScheduledTaskRecord) => ScheduledTaskRecord | null) {
283
+ const items = await readSchedules();
284
+ const next = items
285
+ .map((item) => (item.id === id ? apply(item) : item))
286
+ .filter(Boolean) as ScheduledTaskRecord[];
287
+ await writeSchedules(next);
288
+ return next.find((item) => item.id === id) || null;
289
+ }
290
+
291
+ async function setScheduleRecordState(id: string, patch: Partial<ScheduledTaskRecord>) {
292
+ const next = await updateScheduleRecord(id, (item) => ({ ...item, ...patch, updated_at: new Date().toISOString() }));
293
+ if (next) await syncHeartbeatScheduleRecord(next);
294
+ return next;
295
+ }
296
+
297
+ function shellQuote(value: string) {
298
+ return `'${String(value || "").replace(/'/g, `'\\''`)}'`;
299
+ }
300
+
301
+ async function readCrontabRaw() {
302
+ try {
303
+ const { stdout } = await execFileAsync("/bin/zsh", ["-lc", "crontab -l 2>/dev/null || true"]);
304
+ return String(stdout || "");
305
+ } catch {
306
+ return "";
307
+ }
308
+ }
309
+
310
+ async function writeCrontabRaw(content: string) {
311
+ await new Promise<void>((resolve, reject) => {
312
+ const child = spawn("crontab", ["-"], {
313
+ stdio: ["pipe", "ignore", "pipe"],
314
+ env: process.env,
315
+ });
316
+ let stderr = "";
317
+ child.stderr?.on("data", (chunk) => {
318
+ stderr += String(chunk || "");
319
+ });
320
+ child.on("error", reject);
321
+ child.on("exit", (code) => {
322
+ if ((code ?? 1) !== 0) reject(new Error(stderr.trim() || `crontab exited with code ${code}`));
323
+ else resolve();
324
+ });
325
+ child.stdin.end(content);
326
+ });
327
+ }
328
+
329
+ function scheduleCronMarker(id: string) {
330
+ return `# OPENCORE_SCHEDULE:${id}`;
331
+ }
332
+
333
+ function buildScheduleCronLine(id: string, cronExpression: string) {
334
+ const binPath = path.join(ROOT_DIR, "bin", "opencore.mjs");
335
+ const cmd =
336
+ `${shellQuote(process.execPath)} ${shellQuote(binPath)} run-scheduled ${shellQuote(id)} ` +
337
+ `>> ${shellQuote(SCHEDULER_LOG_PATH)} 2>&1`;
338
+ return `${cronExpression} ${cmd} ${scheduleCronMarker(id)}`;
339
+ }
340
+
341
+ async function upsertCronSchedule(record: ScheduledTaskRecord) {
342
+ const current = await readCrontabRaw();
343
+ const filtered = current
344
+ .split("\n")
345
+ .filter((line) => !line.includes(scheduleCronMarker(record.id)) && !line.includes(`# OPENCORE_SCHEDULE:${record.id}`))
346
+ .filter((line, idx, arr) => !(idx === arr.length - 1 && !line.trim()));
347
+ filtered.push(buildScheduleCronLine(record.id, record.cron_expression));
348
+ await writeCrontabRaw(`${filtered.join("\n").trim()}\n`);
349
+ }
350
+
351
+ async function removeCronSchedule(id: string) {
352
+ const current = await readCrontabRaw();
353
+ const filtered = current
354
+ .split("\n")
355
+ .filter((line) => !line.includes(scheduleCronMarker(id)))
356
+ .filter((line, idx, arr) => !(idx === arr.length - 1 && !line.trim()));
357
+ await writeCrontabRaw(`${filtered.join("\n").trim()}\n`);
358
+ }
359
+
360
+ async function walkFiles(dir: string, out: string[] = []) {
361
+ let entries: any[] = [];
362
+ try {
363
+ entries = await fs.readdir(dir, { withFileTypes: true });
364
+ } catch {
365
+ return out;
366
+ }
367
+ for (const entry of entries) {
368
+ const full = path.join(dir, entry.name);
369
+ if (entry.isDirectory()) {
370
+ await walkFiles(full, out);
371
+ } else {
372
+ out.push(full);
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+
378
+ async function loadInstalledSkills(maxChars = 9000) {
379
+ const files = (await walkFiles(SKILLS_DIR))
380
+ .filter((file) => /skill\.md$/i.test(path.basename(file)) || /\.md$/i.test(file))
381
+ .sort((a, b) => a.localeCompare(b))
382
+ .slice(0, 30);
383
+ const chunks: string[] = [];
384
+ for (const file of files) {
385
+ const content = await readTextFile(file, "");
386
+ if (!content.trim()) continue;
387
+ const rel = path.relative(SKILLS_DIR, file);
388
+ chunks.push(`## ${rel}\n${content.trim()}`);
389
+ if (chunks.join("\n\n").length >= maxChars) break;
390
+ }
391
+ const joined = chunks.join("\n\n");
392
+ return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n\n...[truncated]` : joined;
393
+ }
394
+
395
+ async function listInstalledSkillFiles() {
396
+ const files = (await walkFiles(SKILLS_DIR))
397
+ .filter((file) => /skill\.md$/i.test(path.basename(file)) || /\.md$/i.test(file))
398
+ .sort((a, b) => (a < b ? -1 : 1));
399
+ return files.map((file) => path.relative(SKILLS_DIR, file));
400
+ }
401
+
402
+ async function readTextFile(filePath: string, fallback = "") {
403
+ try {
404
+ return await fs.readFile(filePath, "utf8");
405
+ } catch {
406
+ return fallback;
407
+ }
408
+ }
409
+
410
+ async function appendMemory(entry: string) {
411
+ const line = `${new Date().toISOString()} ${entry}\n`;
412
+ await fs.appendFile(MEMORY_PATH, line, "utf8").catch(() => {});
413
+ }
414
+
415
+ async function appendLearnedFacts(notes: string[]) {
416
+ for (const note of notes.map((item) => String(item || "").trim()).filter(Boolean)) {
417
+ await appendMemory(`[learned] ${note}`);
418
+ }
419
+ }
420
+
421
+ async function showMacNotification(title: string, message: string) {
422
+ const safeTitle = String(title || "OpenCore").replace(/"/g, '\\"');
423
+ const safeMessage = String(message || "").replace(/"/g, '\\"');
424
+ try {
425
+ await execFileAsync("osascript", [
426
+ "-e",
427
+ `display notification "${safeMessage}" with title "${safeTitle}" subtitle "Computer control active"`,
428
+ ]);
429
+ } catch {
430
+ // Notification is best-effort only.
431
+ }
432
+ }
433
+
434
+ async function writeIndicatorState(state: {
435
+ running: boolean;
436
+ active: boolean;
437
+ message?: string;
438
+ }) {
439
+ const payload = {
440
+ running: Boolean(state.running),
441
+ active: Boolean(state.active),
442
+ message: String(state.message || "").trim(),
443
+ updated_at: new Date().toISOString(),
444
+ };
445
+ await fs.writeFile(INDICATOR_STATE_PATH, `${JSON.stringify(payload, null, 2)}\n`, "utf8").catch(() => {});
446
+ }
447
+
448
+ function startIndicatorProcess() {
449
+ try {
450
+ const child = spawn("osascript", ["-l", "JavaScript", INDICATOR_SCRIPT_PATH], {
451
+ stdio: ["ignore", "ignore", "pipe"],
452
+ env: { ...process.env, OPENCORE_INDICATOR_STATE_PATH: INDICATOR_STATE_PATH },
453
+ });
454
+ child.stderr?.on("data", (chunk: Buffer | string) => {
455
+ const text = String(chunk || "").trim();
456
+ if (text) console.warn(`[indicator] ${text}`);
457
+ });
458
+ child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
459
+ if (code !== null && code !== 0) {
460
+ console.warn(`[indicator] exited with code ${code}`);
461
+ } else if (signal) {
462
+ console.warn(`[indicator] exited with signal ${signal}`);
463
+ }
464
+ });
465
+ return child;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ function stopIndicatorProcess(indicator: any) {
472
+ if (!indicator) return;
473
+ try {
474
+ indicator.kill("SIGTERM");
475
+ } catch {}
476
+ }
477
+
478
+ async function readMemoryExcerpt(maxChars = 6000) {
479
+ const memory = await readTextFile(MEMORY_PATH, "");
480
+ if (!memory) return "";
481
+ if (memory.length <= maxChars) return memory;
482
+ return memory.slice(memory.length - maxChars);
483
+ }
484
+
485
+ function maskConfig(config: any) {
486
+ const safe = { ...(config || {}) };
487
+ if (safe.openai_api_key) safe.openai_api_key = "***masked***";
488
+ if (safe.telegram_bot_token) safe.telegram_bot_token = "***masked***";
489
+ if (safe.telegram_pairing_code) safe.telegram_pairing_code = "***masked***";
490
+ return safe;
491
+ }
492
+
493
+ function parseTelegramConfig(settings: any): TelegramConfig {
494
+ return {
495
+ enabled: Boolean(settings?.telegram_bot_token),
496
+ botToken: String(settings?.telegram_bot_token || "").trim(),
497
+ chatId: String(settings?.telegram_chat_id || "").trim(),
498
+ userId: String(settings?.telegram_user_id || "").trim(),
499
+ pairingCode: String(settings?.telegram_pairing_code || "").trim(),
500
+ paired: Boolean(settings?.telegram_paired && settings?.telegram_chat_id),
501
+ lastUpdateId: Number(settings?.telegram_last_update_id || 0) || 0,
502
+ };
503
+ }
504
+
505
+ async function writeSettingsPatch(patch: Record<string, any>) {
506
+ const current = await readSettings();
507
+ const next = { ...current, ...patch };
508
+ await fs.writeFile(SETTINGS_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
509
+ return next;
510
+ }
511
+
512
+ function formatNetworkError(error: unknown) {
513
+ if (!error) return "Unknown network error";
514
+ if (error instanceof Error) {
515
+ const cause: any = (error as any).cause;
516
+ const parts = [error.message];
517
+ if (cause?.code) parts.push(`code=${cause.code}`);
518
+ if (cause?.errno && cause?.errno !== cause?.code) parts.push(`errno=${cause.errno}`);
519
+ if (cause?.syscall) parts.push(`syscall=${cause.syscall}`);
520
+ if (cause?.hostname) parts.push(`host=${cause.hostname}`);
521
+ if (cause?.address) parts.push(`address=${cause.address}`);
522
+ if (cause?.port) parts.push(`port=${cause.port}`);
523
+ return parts.filter(Boolean).join(" | ");
524
+ }
525
+ return String(error);
526
+ }
527
+
528
+ async function telegramApi(token: string, method: string, params: Record<string, any> = {}) {
529
+ const url = new URL(`https://api.telegram.org/bot${token}/${method}`);
530
+ for (const [key, value] of Object.entries(params)) {
531
+ if (value === undefined || value === null || value === "") continue;
532
+ url.searchParams.set(key, String(value));
533
+ }
534
+ const controller = new AbortController();
535
+ const timeoutMs = 12_000;
536
+ const timeout = setTimeout(() => controller.abort(new Error(`Telegram API ${method} timed out after ${timeoutMs}ms`)), timeoutMs);
537
+ let res: Response;
538
+ try {
539
+ res = await fetch(url, { signal: controller.signal });
540
+ } catch (error) {
541
+ throw new Error(`Telegram API ${method} request failed: ${formatNetworkError(error)}`);
542
+ } finally {
543
+ clearTimeout(timeout);
544
+ }
545
+ if (!res.ok) {
546
+ throw new Error(`Telegram API ${method} failed with HTTP ${res.status}`);
547
+ }
548
+ const json = await res.json();
549
+ if (!json?.ok) {
550
+ throw new Error(String(json?.description || `Telegram API ${method} failed.`));
551
+ }
552
+ return json.result;
553
+ }
554
+
555
+ async function sendTelegramMessage(config: TelegramConfig, text: string) {
556
+ if (!config.enabled || !config.chatId) return;
557
+ const body = String(text || "").trim();
558
+ if (!body) return;
559
+ const chunks = body.match(/[\s\S]{1,3500}/g) || [];
560
+ for (const chunk of chunks) {
561
+ await telegramApi(config.botToken, "sendMessage", {
562
+ chat_id: config.chatId,
563
+ text: chunk,
564
+ });
565
+ }
566
+ }
567
+
568
+ function publicTelegramConfig(config: TelegramConfig) {
569
+ return {
570
+ enabled: config.enabled,
571
+ chat_id: config.chatId,
572
+ user_id: config.userId,
573
+ paired: config.paired,
574
+ has_bot_token: Boolean(config.botToken),
575
+ has_pairing_code: Boolean(config.pairingCode),
576
+ last_update_id: config.lastUpdateId,
577
+ };
578
+ }
579
+
580
+ async function loadPromptContext() {
581
+ const soulTemplate = await readTemplate("default-soul.md", DEFAULT_SOUL);
582
+ const guidelinesTemplate = await readTemplate("default-guidelines.md", DEFAULT_GUIDELINES);
583
+ const instructionsTemplate = await readTemplate("default-instructions.md", DEFAULT_INSTRUCTIONS);
584
+ const heartbeatTemplate = await readTemplate("default-heartbeat.md", DEFAULT_HEARTBEAT);
585
+ const soul = await readTextFile(SOUL_PATH, soulTemplate);
586
+ const heartbeat = await readTextFile(HEARTBEAT_PATH, heartbeatTemplate);
587
+ const guidelines = await readTextFile(GUIDELINES_PATH, guidelinesTemplate);
588
+ const instructions = await readTextFile(INSTRUCTIONS_PATH, instructionsTemplate);
589
+ const config = await readSettings();
590
+ const credentials = await readCredentialStore();
591
+ const memoryExcerpt = await readMemoryExcerpt(4000);
592
+ const installedSkills = await loadInstalledSkills(10000);
593
+ return {
594
+ soul,
595
+ heartbeat,
596
+ guidelines,
597
+ instructions,
598
+ installedSkills: installedSkills || "(no installed skills found)",
599
+ config: JSON.stringify(maskConfig(config), null, 2),
600
+ credentialsSummary: summarizeCredentialStoreForPrompt(credentials),
601
+ memoryExcerpt: memoryExcerpt || "(empty)",
602
+ };
603
+ }
604
+
605
+ async function buildComputerSystemPrompt() {
606
+ const ctx = await loadPromptContext();
607
+ return [
608
+ "You are controlling a macOS computer. Use only macOS UI conventions, macOS app names, macOS shortcuts, and macOS controls.",
609
+ "Do not reference or rely on Windows or Linux buttons, menus, dialogs, or keybindings.",
610
+ "You are the Computer Agent. Execute UI tasks delegated by the Manager Agent.",
611
+ "Follow OpenCore guidelines exactly:",
612
+ ctx.guidelines,
613
+ "Follow OpenCore user instructions exactly:",
614
+ ctx.instructions,
615
+ "Installed OpenCore skills (follow if relevant):",
616
+ ctx.installedSkills,
617
+ "OpenCore soul:",
618
+ ctx.soul,
619
+ "OpenCore heartbeat plan (must be followed for current task):",
620
+ ctx.heartbeat,
621
+ "OpenCore config snapshot (masked):",
622
+ ctx.config,
623
+ "Credential store summary (masked):",
624
+ ctx.credentialsSummary,
625
+ "OpenCore memory excerpt (recent):",
626
+ ctx.memoryExcerpt,
627
+ ].join("\n\n");
628
+ }
629
+
630
+ async function buildManagerSystemPrompt() {
631
+ const ctx = await loadPromptContext();
632
+ return [
633
+ "You are the OpenCore Manager Agent.",
634
+ "There are two systems: (1) Manager Agent (you), (2) Computer Agent.",
635
+ "Manager Agent handles everything except direct computer-use actions.",
636
+ "You receive user tasks first. Decide whether to answer directly, edit local markdown files, or delegate to Computer Agent.",
637
+ "You also maintain durable OpenCore state. When the user states a lasting rule, preference, permission, restriction, identity update, or computer fact, update the appropriate markdown files automatically.",
638
+ "Use guidelines.md for safety, permission, and capability restrictions. Use instructions.md for workflow preferences and operating style. Use soul.md for OpenCore identity/personality. Use memory.md for durable learned facts about the computer, apps, environment, and user preferences.",
639
+ "Delegate to Computer Agent when UI/computer control is needed.",
640
+ "Use route=local when the work can be performed locally with shell/file commands without UI control, such as file management, project inspection, text generation to files, and command-line maintenance.",
641
+ "Do direct answer when task is informational and no computer control is required.",
642
+ "Do edit_file when user asks to update any markdown/state/config file or when task requires writing files.",
643
+ "You may edit files and folders anywhere on the user's computer when needed, unless guidelines/instructions explicitly forbid it.",
644
+ "Return strict JSON only, no markdown, no explanations.",
645
+ 'JSON schema: {"route":"direct|computer|edit_file|local","direct_answer":"...","computer_task":"...","file_target":"soul|memory|heartbeat|guidelines|instructions|/absolute/or/relative/path","edit_instructions":"...","local_task":"..."}',
646
+ "Follow OpenCore guidelines exactly:",
647
+ ctx.guidelines,
648
+ "Follow OpenCore user instructions exactly:",
649
+ ctx.instructions,
650
+ "Installed OpenCore skills (follow if relevant):",
651
+ ctx.installedSkills,
652
+ "OpenCore soul:",
653
+ ctx.soul,
654
+ "OpenCore config snapshot (masked):",
655
+ ctx.config,
656
+ "Credential store summary (masked):",
657
+ ctx.credentialsSummary,
658
+ "OpenCore memory excerpt (recent):",
659
+ ctx.memoryExcerpt,
660
+ ].join("\n\n");
661
+ }
662
+
663
+ function wantsCredentialSetup(userPrompt: string) {
664
+ const text = String(userPrompt || "").toLowerCase();
665
+ return (
666
+ text.includes("credential") ||
667
+ text.includes("password") ||
668
+ text.includes("default email") ||
669
+ text.includes("login for") ||
670
+ text.includes("log in for") ||
671
+ text.includes("sign up for") ||
672
+ text.includes("save account") ||
673
+ text.includes("save login")
674
+ );
675
+ }
676
+
677
+ function extractCredentialSetupValues(userPrompt: string) {
678
+ const text = String(userPrompt || "");
679
+ const websiteMatch =
680
+ text.match(/(?:website|site|domain|for)\s*[:=]\s*([A-Za-z0-9.-]+\.[A-Za-z]{2,})/i) ||
681
+ text.match(/\b([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/);
682
+ const emailMatch =
683
+ text.match(/(?:email|login\s+email)\s*[:=]\s*([^\s,;]+@[^\s,;]+)/i) ||
684
+ text.match(/\b([^\s,;]+@[^\s,;]+\.[A-Za-z]{2,})\b/);
685
+ const passwordMatch =
686
+ text.match(/(?:password|pass)\s*[:=]\s*(.+)$/im);
687
+ const providerMatch =
688
+ text.match(/(?:email\s+provider|provider)\s*[:=]\s*([A-Za-z0-9._-]+)/i);
689
+ const autoActivationEnabled =
690
+ /\b(enable|turn on|activate)\b[\s\S]{0,40}\b(auto(?:matic)?\s+account\s+email\s+activation|email\s+activation)\b/i.test(text)
691
+ ? true
692
+ : /\b(disable|turn off)\b[\s\S]{0,40}\b(auto(?:matic)?\s+account\s+email\s+activation|email\s+activation)\b/i.test(text)
693
+ ? false
694
+ : undefined;
695
+ const defaultEmailRequested = /default\s+email/i.test(text);
696
+ return {
697
+ website: String(websiteMatch?.[1] || "").trim(),
698
+ email: String(emailMatch?.[1] || "").trim(),
699
+ password: String(passwordMatch?.[1] || "").trim(),
700
+ emailProvider: String(providerMatch?.[1] || "").trim(),
701
+ defaultEmailRequested,
702
+ autoActivationEnabled,
703
+ };
704
+ }
705
+
706
+ async function handleCredentialSetupRequest(userPrompt: string) {
707
+ if (!wantsCredentialSetup(userPrompt)) return null;
708
+ const extracted = extractCredentialSetupValues(userPrompt);
709
+ const lower = String(userPrompt || "").toLowerCase();
710
+ const store = await readCredentialStore();
711
+
712
+ const wantsDefaultsOnly =
713
+ extracted.defaultEmailRequested && !extracted.website && !/\b(save|store|add)\b[\s\S]{0,24}\bpassword\b/i.test(lower);
714
+
715
+ if (wantsDefaultsOnly || extracted.autoActivationEnabled !== undefined) {
716
+ const next = await updateCredentialDefaults({
717
+ default_email: extracted.email || store.default_email,
718
+ default_email_provider: extracted.emailProvider || store.default_email_provider,
719
+ auto_email_activation_enabled:
720
+ extracted.autoActivationEnabled === undefined
721
+ ? store.auto_email_activation_enabled
722
+ : extracted.autoActivationEnabled,
723
+ });
724
+ const warning =
725
+ extracted.autoActivationEnabled === true
726
+ ? "\nWarning: automatic account email activation lets OpenCore open your inbox and follow verification links or codes automatically. Only leave this enabled if you trust the current setup."
727
+ : "";
728
+ return {
729
+ handled: true,
730
+ answer:
731
+ `Credential defaults updated.\n` +
732
+ `Default email: ${next.default_email || "not set"}\n` +
733
+ `Default email provider: ${next.default_email_provider || "not set"}\n` +
734
+ `Automatic account email activation: ${next.auto_email_activation_enabled ? "enabled" : "disabled"}${warning}`,
735
+ };
736
+ }
737
+
738
+ if (!extracted.website) return null;
739
+ if (!extracted.email && !extracted.password) {
740
+ return {
741
+ handled: true,
742
+ answer:
743
+ `To save credentials for ${extracted.website}, send at least an email or password.\n` +
744
+ `Optional fields: email provider, notes, or default email settings.`,
745
+ };
746
+ }
747
+ const next = await upsertCredentialEntry({
748
+ website: extracted.website,
749
+ email: extracted.email,
750
+ password: extracted.password,
751
+ email_provider: extracted.emailProvider,
752
+ generated_by_ai: false,
753
+ });
754
+ const saved = next.entries.find((entry: any) => entry.website === extracted.website);
755
+ return {
756
+ handled: true,
757
+ answer:
758
+ `Saved local credentials for ${saved?.website || extracted.website}.\n` +
759
+ `Email: ${saved?.email ? "saved" : "not set"}\n` +
760
+ `Password: ${saved?.password ? "saved" : "not set"}\n` +
761
+ `Email provider: ${saved?.email_provider || "not set"}\n` +
762
+ `These credentials will be available for future login or sign-up tasks.`,
763
+ };
764
+ }
765
+
766
+ function sleep(ms: number) {
767
+ return new Promise((resolve) => setTimeout(resolve, ms));
768
+ }
769
+
770
+ function rgb(r: number, g: number, b: number) {
771
+ return `\x1b[38;2;${r};${g};${b}m`;
772
+ }
773
+
774
+ function colorizeBanner(text: string) {
775
+ const reset = "\x1b[0m";
776
+ const palette: Array<[number, number, number]> = [
777
+ [255, 176, 64],
778
+ [255, 154, 32],
779
+ [243, 128, 16],
780
+ [224, 105, 8],
781
+ [203, 86, 4],
782
+ [187, 72, 0],
783
+ ];
784
+ return text
785
+ .split("\n")
786
+ .map((line, i) => `${rgb(...palette[i % palette.length])}${line}${reset}`)
787
+ .join("\n");
788
+ }
789
+
790
+ function renderOpenCoreBanner() {
791
+ const text = figlet.textSync("OPENCORE", {
792
+ font: "ANSI Shadow",
793
+ horizontalLayout: "default",
794
+ verticalLayout: "default",
795
+ });
796
+ return colorizeBanner(text);
797
+ }
798
+
799
+ function extractToolUses(response: any) {
800
+ return (response.content || []).filter((block: any) => block.type === "tool_use" && block.name === "computer");
801
+ }
802
+
803
+ function extractAssistantText(response: any) {
804
+ const textParts = (response.content || [])
805
+ .filter((block: any) => block.type === "text" && block.text)
806
+ .map((block: any) => block.text);
807
+ return textParts.join("\n").trim();
808
+ }
809
+
810
+ function extractOpenAiText(response: any) {
811
+ const direct = String(response?.output_text || "").trim();
812
+ if (direct) return direct;
813
+ const textParts: string[] = [];
814
+ for (const item of response?.output || []) {
815
+ if (item?.type === "message") {
816
+ for (const block of item?.content || []) {
817
+ const text = String(block?.text || block?.content || "").trim();
818
+ if (text) textParts.push(text);
819
+ }
820
+ }
821
+ }
822
+ return textParts.join("\n").trim();
823
+ }
824
+
825
+ function extractOpenAiComputerCalls(response: any) {
826
+ return (response?.output || []).filter((item: any) => item?.type === "computer_call");
827
+ }
828
+
829
+ function getOpenAiCallActions(call: any) {
830
+ if (Array.isArray(call?.actions)) return call.actions;
831
+ if (call?.action) return [call.action];
832
+ return [];
833
+ }
834
+
835
+ function decodedBase64Bytes(data: string) {
836
+ return Buffer.from(String(data || ""), "base64").byteLength;
837
+ }
838
+
839
+ async function enforceApiImageLimit(base64: string, mediaType = "image/png") {
840
+ const raw = String(base64 || "").trim();
841
+ let normalizedMediaType = mediaType;
842
+ let payload = raw;
843
+ const dataUrl = /^data:([^;,]+);base64,(.+)$/i.exec(raw);
844
+ if (dataUrl) {
845
+ normalizedMediaType = dataUrl[1] || mediaType;
846
+ payload = dataUrl[2] || "";
847
+ }
848
+
849
+ let data = Buffer.from(payload, "base64");
850
+ // Always canonicalize base64 to avoid forwarding data URL prefixes or noisy encodings.
851
+ if (data.byteLength <= API_IMAGE_MAX_BYTES) {
852
+ return { data: data.toString("base64"), mediaType: normalizedMediaType };
853
+ }
854
+
855
+ const inExt = normalizedMediaType === "image/jpeg" ? "jpg" : "png";
856
+ const inFile = path.join(
857
+ os.tmpdir(),
858
+ `opencore-limit-in-${Date.now()}-${Math.random().toString(36).slice(2)}.${inExt}`,
859
+ );
860
+ const attempts: Array<{ dim: number; quality: number }> = [];
861
+ for (const dim of [1600, 1400, 1200, 1024, 900, 768, 640]) {
862
+ for (const quality of [80, 70, 60, 50, 40]) attempts.push({ dim, quality });
863
+ }
864
+
865
+ const tempFiles: string[] = [];
866
+ try {
867
+ await fs.writeFile(inFile, data);
868
+ for (const attempt of attempts) {
869
+ const outFile = path.join(
870
+ os.tmpdir(),
871
+ `opencore-limit-out-${Date.now()}-${attempt.dim}-${attempt.quality}-${Math.random().toString(36).slice(2)}.jpg`,
872
+ );
873
+ tempFiles.push(outFile);
874
+ try {
875
+ await execFileAsync("sips", [
876
+ inFile,
877
+ "--resampleHeightWidthMax",
878
+ String(attempt.dim),
879
+ "--setProperty",
880
+ "format",
881
+ "jpeg",
882
+ "--setProperty",
883
+ "formatOptions",
884
+ String(attempt.quality),
885
+ "--out",
886
+ outFile,
887
+ ]);
888
+ const candidate = await fs.readFile(outFile);
889
+ if (candidate.byteLength <= API_IMAGE_TARGET_BYTES) {
890
+ return { data: candidate.toString("base64"), mediaType: "image/jpeg" };
891
+ }
892
+ } catch {
893
+ // try next compression attempt
894
+ }
895
+ }
896
+ } finally {
897
+ fs.unlink(inFile).catch(() => {});
898
+ for (const file of tempFiles) fs.unlink(file).catch(() => {});
899
+ }
900
+
901
+ data = Buffer.from(payload, "base64");
902
+ throw new Error(
903
+ `Screenshot payload too large for computer-use API (${data.byteLength} bytes > ${API_IMAGE_MAX_BYTES} bytes) after compression attempts.`,
904
+ );
905
+ }
906
+
907
+ function isOpenAiComputerModelError(error: any) {
908
+ const message = String(error?.message || "");
909
+ return (
910
+ Number(error?.status) === 400 ||
911
+ Number(error?.status) === 404 ||
912
+ /model|tool|computer|not found|does not exist|not supported|unsupported|access/i.test(message)
913
+ );
914
+ }
915
+
916
+ async function createOpenAiResponseWithModelFallback({
917
+ client,
918
+ buildPayload,
919
+ }: {
920
+ client: any;
921
+ buildPayload: (model: string) => any;
922
+ }) {
923
+ const tried: string[] = [];
924
+ let lastError: unknown = null;
925
+
926
+ for (const model of OPENAI_COMPUTER_MODEL_CANDIDATES) {
927
+ tried.push(model);
928
+ try {
929
+ const response = await client.responses.create(buildPayload(model));
930
+ return { response, model };
931
+ } catch (error) {
932
+ if (!isOpenAiComputerModelError(error)) throw error;
933
+ lastError = error;
934
+ }
935
+ }
936
+
937
+ const wrapped = new Error(
938
+ `No accessible OpenAI computer-use model was found. Tried: ${tried.join(", ")}.`,
939
+ );
940
+ (wrapped as any).cause = lastError;
941
+ throw wrapped;
942
+ }
943
+
944
+ async function runTextModel({
945
+ runtime,
946
+ systemPrompt,
947
+ userText,
948
+ maxTokens = 1200,
949
+ }: {
950
+ runtime: ProviderRuntime;
951
+ systemPrompt: string;
952
+ userText: string;
953
+ maxTokens?: number;
954
+ }) {
955
+ const response = await runtime.openai.responses.create({
956
+ model: OPENAI_MODEL,
957
+ input: [
958
+ { role: "system", content: [{ type: "input_text", text: systemPrompt }] },
959
+ { role: "user", content: [{ type: "input_text", text: userText }] },
960
+ ],
961
+ truncation: "auto",
962
+ });
963
+ return extractOpenAiText(response);
964
+ }
965
+
966
+ function extractJsonObject(text: string) {
967
+ const start = text.indexOf("{");
968
+ const end = text.lastIndexOf("}");
969
+ if (start < 0 || end < 0 || end <= start) return null;
970
+ const maybe = text.slice(start, end + 1);
971
+ try {
972
+ return JSON.parse(maybe);
973
+ } catch {
974
+ return null;
975
+ }
976
+ }
977
+
978
+ function normalizeFileTarget(value: string | undefined) {
979
+ const target = String(value || "")
980
+ .trim()
981
+ .toLowerCase()
982
+ .replace(".md", "");
983
+ return target in EDITABLE_FILES ? target : null;
984
+ }
985
+
986
+ function resolveEditablePath(fileTarget: string | undefined) {
987
+ const mapped = normalizeFileTarget(fileTarget || "");
988
+ if (mapped) return { key: mapped, path: EDITABLE_FILES[mapped] };
989
+ const raw = String(fileTarget || "").trim();
990
+ if (!raw) return null;
991
+ const resolved = path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(os.homedir(), raw);
992
+ return { key: "custom", path: resolved };
993
+ }
994
+
995
+ async function managerDecide({
996
+ runtime,
997
+ userPrompt,
998
+ }: {
999
+ runtime: ProviderRuntime;
1000
+ userPrompt: string;
1001
+ }): Promise<ManagerDecision> {
1002
+ const systemPrompt = await buildManagerSystemPrompt();
1003
+ const text = await runTextModel({
1004
+ runtime,
1005
+ systemPrompt,
1006
+ userText: `User task:\n${userPrompt}\n\nReturn JSON only.`,
1007
+ maxTokens: 900,
1008
+ });
1009
+ const parsed = extractJsonObject(text);
1010
+ if (!parsed || !parsed.route) {
1011
+ return { route: "computer", computer_task: userPrompt };
1012
+ }
1013
+ const route = String(parsed.route) as ManagerRoute;
1014
+ if (!["direct", "computer", "edit_file", "local"].includes(route)) {
1015
+ return { route: "computer", computer_task: userPrompt };
1016
+ }
1017
+ return {
1018
+ route,
1019
+ direct_answer: String(parsed.direct_answer || ""),
1020
+ computer_task: String(parsed.computer_task || ""),
1021
+ file_target: String(parsed.file_target || ""),
1022
+ edit_instructions: String(parsed.edit_instructions || ""),
1023
+ local_task: String(parsed.local_task || ""),
1024
+ };
1025
+ }
1026
+
1027
+ async function managerDetectScheduleRequest({
1028
+ runtime,
1029
+ userPrompt,
1030
+ }: {
1031
+ runtime: ProviderRuntime;
1032
+ userPrompt: string;
1033
+ }): Promise<ScheduledTaskPlan> {
1034
+ const systemPrompt = await buildManagerSystemPrompt();
1035
+ const text = await runTextModel({
1036
+ runtime,
1037
+ systemPrompt,
1038
+ userText:
1039
+ `User message:\n${userPrompt}\n\n` +
1040
+ 'Return JSON only using schema: {"should_schedule":boolean,"cron_expression":"* * * * *","task":"...","summary":"...","schedule_kind":"one_time|recurring"}\n' +
1041
+ "Interpret whether the user is asking OpenCore to do a task in the future or on a recurring schedule.\n" +
1042
+ "Rules:\n" +
1043
+ "- should_schedule=true only when the user explicitly asks for a future-time or recurring task.\n" +
1044
+ "- task must be the action OpenCore should perform when the schedule fires, without time wording.\n" +
1045
+ "- cron_expression must be a valid 5-field cron expression in the local timezone.\n" +
1046
+ "- schedule_kind=one_time for a single future run; recurring for repeating schedules.\n" +
1047
+ "- expected_time_iso should be provided for one_time schedules when the exact future timestamp can be inferred.\n" +
1048
+ "- summary must be a concise human summary of the schedule.\n" +
1049
+ "- If the request is not a scheduling request, return should_schedule=false.",
1050
+ maxTokens: 700,
1051
+ });
1052
+ const parsed = extractJsonObject(text);
1053
+ if (!parsed || !parsed.should_schedule) return { should_schedule: false };
1054
+ return {
1055
+ should_schedule: Boolean(parsed.should_schedule),
1056
+ cron_expression: String(parsed.cron_expression || "").trim(),
1057
+ task: String(parsed.task || "").trim(),
1058
+ summary: String(parsed.summary || "").trim(),
1059
+ schedule_kind: String(parsed.schedule_kind || "one_time").trim().toLowerCase() === "recurring" ? "recurring" : "one_time",
1060
+ expected_time_iso: String(parsed.expected_time_iso || "").trim(),
1061
+ };
1062
+ }
1063
+
1064
+ async function managerRewriteFile({
1065
+ runtime,
1066
+ target,
1067
+ userPrompt,
1068
+ editInstructions,
1069
+ }: {
1070
+ runtime: ProviderRuntime;
1071
+ target: string;
1072
+ userPrompt: string;
1073
+ editInstructions: string;
1074
+ }) {
1075
+ const resolved = resolveEditablePath(target);
1076
+ if (!resolved) return null;
1077
+ const filePath = resolved.path;
1078
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1079
+ const current = await readTextFile(filePath, "");
1080
+ const systemPrompt = await buildManagerSystemPrompt();
1081
+ const text = await runTextModel({
1082
+ runtime,
1083
+ systemPrompt,
1084
+ userText:
1085
+ `You are editing file target: ${target}\n` +
1086
+ `Resolved file path: ${filePath}\n` +
1087
+ `User request: ${userPrompt}\n` +
1088
+ `Manager instructions: ${editInstructions || "(none)"}\n` +
1089
+ `Current content:\n${current}\n\n` +
1090
+ "Return only the full updated file content. No markdown fences.",
1091
+ maxTokens: 2600,
1092
+ });
1093
+ return text || current;
1094
+ }
1095
+
1096
+ async function managerBuildLocalCommand({
1097
+ runtime,
1098
+ userPrompt,
1099
+ localTask,
1100
+ }: {
1101
+ runtime: ProviderRuntime;
1102
+ userPrompt: string;
1103
+ localTask: string;
1104
+ }) {
1105
+ const systemPrompt = await buildManagerSystemPrompt();
1106
+ const text = await runTextModel({
1107
+ runtime,
1108
+ systemPrompt,
1109
+ userText:
1110
+ `User request:\n${userPrompt}\n\n` +
1111
+ `Local task summary:\n${localTask}\n\n` +
1112
+ 'Return JSON only using schema: {"command":"...","cwd":"...","summary":"..."}\n' +
1113
+ "Write one macOS-compatible shell command to complete the task locally without UI control.\n" +
1114
+ "Rules:\n" +
1115
+ "- Use zsh-compatible syntax.\n" +
1116
+ "- Prefer safe file and project commands.\n" +
1117
+ "- Use absolute cwd when useful, or omit it.\n" +
1118
+ "- Do not include explanations or markdown.",
1119
+ maxTokens: 700,
1120
+ });
1121
+ const parsed = extractJsonObject(text);
1122
+ if (!parsed || !parsed.command) return null;
1123
+ return {
1124
+ command: String(parsed.command || "").trim(),
1125
+ cwd: String(parsed.cwd || "").trim(),
1126
+ summary: String(parsed.summary || "").trim(),
1127
+ };
1128
+ }
1129
+
1130
+ function wantsTelegramSetup(userPrompt: string) {
1131
+ const text = String(userPrompt || "").toLowerCase();
1132
+ return text.includes("telegram") && (
1133
+ text.includes("set up") ||
1134
+ text.includes("setup") ||
1135
+ text.includes("connect") ||
1136
+ text.includes("configure") ||
1137
+ text.includes("reconnect")
1138
+ );
1139
+ }
1140
+
1141
+ function extractTelegramSetupValues(userPrompt: string) {
1142
+ const text = String(userPrompt || "");
1143
+ const botTokenMatch =
1144
+ text.match(/(?:telegram\s+bot\s+token|bot\s+token|token)\s*[:=]\s*([0-9]{6,}:[A-Za-z0-9_-]{20,})/i) ||
1145
+ text.match(/\b([0-9]{6,}:[A-Za-z0-9_-]{20,})\b/);
1146
+ const userIdMatch =
1147
+ text.match(/(?:telegram\s+user\s+id|user\s+id)\s*[:=]\s*([0-9]{5,})/i) ||
1148
+ text.match(/(?:telegram\s+user\s+id\s+is|user\s+id\s+is)\s+([0-9]{5,})/i);
1149
+ const pairingCodeMatch =
1150
+ text.match(/(?:pairing\s+code|pair\s+code|code)\s*[:=]\s*([A-Za-z0-9_-]{3,})/i) ||
1151
+ text.match(/pairing\s+code\s+is\s+([A-Za-z0-9_-]{3,})/i);
1152
+ return {
1153
+ botToken: String(botTokenMatch?.[1] || "").trim(),
1154
+ userId: String(userIdMatch?.[1] || "").trim(),
1155
+ pairingCode: String(pairingCodeMatch?.[1] || "").trim(),
1156
+ };
1157
+ }
1158
+
1159
+ function generateTelegramPairingCode() {
1160
+ return Math.random().toString(36).slice(2, 8).toUpperCase();
1161
+ }
1162
+
1163
+ async function handleTelegramSetupRequest(userPrompt: string) {
1164
+ if (!wantsTelegramSetup(userPrompt)) return null;
1165
+ const current = await readSettings();
1166
+ const currentTelegram = parseTelegramConfig(current);
1167
+ const extracted = extractTelegramSetupValues(userPrompt);
1168
+ const botToken = extracted.botToken || currentTelegram.botToken;
1169
+ const userId = extracted.userId || currentTelegram.userId;
1170
+ const pairingCode = extracted.pairingCode || currentTelegram.pairingCode;
1171
+ const missing: string[] = [];
1172
+ if (!botToken) missing.push("Telegram bot token");
1173
+
1174
+ if (missing.length) {
1175
+ return {
1176
+ handled: true,
1177
+ answer:
1178
+ `To finish Telegram setup, send the missing value${missing.length > 1 ? "s" : ""}: ` +
1179
+ `${missing.join(", ")}.` +
1180
+ ` After that, OpenCore will save the bot token and tell you to run /start in Telegram.`,
1181
+ updatedConfig: null as TelegramConfig | null,
1182
+ };
1183
+ }
1184
+
1185
+ await writeSettingsPatch({
1186
+ telegram_enabled: true,
1187
+ telegram_bot_token: botToken,
1188
+ telegram_chat_id: currentTelegram.chatId || "",
1189
+ telegram_user_id: userId,
1190
+ telegram_pairing_code: pairingCode,
1191
+ telegram_paired: Boolean(currentTelegram.chatId && userId && pairingCode),
1192
+ telegram_last_update_id: 0,
1193
+ });
1194
+ const next = parseTelegramConfig(await readSettings());
1195
+ if (!pairingCode) {
1196
+ return {
1197
+ handled: true,
1198
+ answer:
1199
+ "Telegram bot token saved in OpenCore config.\n" +
1200
+ "Next step: start OpenCore, send `/start` to the bot in Telegram, then give the Telegram user ID and pairing code the bot shows you back to OpenCore.",
1201
+ updatedConfig: next,
1202
+ };
1203
+ }
1204
+ if (!userId) {
1205
+ return {
1206
+ handled: true,
1207
+ answer:
1208
+ "Telegram pairing code saved, but the Telegram user ID is still missing.\n" +
1209
+ "Run `/start` in Telegram, then give both the shown user ID and pairing code back to OpenCore.",
1210
+ updatedConfig: next,
1211
+ };
1212
+ }
1213
+ return {
1214
+ handled: true,
1215
+ answer:
1216
+ next.paired
1217
+ ? "Telegram pairing code saved. Telegram is now connected and incoming bot messages will be handled like direct CLI tasks while OpenCore is running."
1218
+ : "Telegram user ID and pairing code saved, but OpenCore has not seen a `/start` chat yet. Start OpenCore, send `/start` to the bot, then save the same values again if needed.",
1219
+ updatedConfig: next,
1220
+ };
1221
+ }
1222
+
1223
+ async function runLocalCommandTask({
1224
+ runtime,
1225
+ userPrompt,
1226
+ localTask,
1227
+ }: {
1228
+ runtime: ProviderRuntime;
1229
+ userPrompt: string;
1230
+ localTask: string;
1231
+ }) {
1232
+ const built = await managerBuildLocalCommand({ runtime, userPrompt, localTask });
1233
+ if (!built || !built.command) {
1234
+ return "I could not build a valid local shell command for that task.";
1235
+ }
1236
+ const cwd = built.cwd
1237
+ ? path.isAbsolute(built.cwd)
1238
+ ? built.cwd
1239
+ : path.resolve(os.homedir(), built.cwd)
1240
+ : os.homedir();
1241
+ await appendMemory(`[manager] local_command=${built.command} cwd=${cwd}`);
1242
+ const { stdout, stderr } = await execFileAsync("/bin/zsh", ["-lc", built.command], {
1243
+ cwd,
1244
+ maxBuffer: 1024 * 1024 * 8,
1245
+ } as any);
1246
+ const output = [String(stdout || "").trim(), String(stderr || "").trim()].filter(Boolean).join("\n");
1247
+ const summary = built.summary || "Local command completed.";
1248
+ return output ? `${summary}\n\n${output}`.trim() : summary;
1249
+ }
1250
+
1251
+ function isValidCronExpression(value: string) {
1252
+ return /^(\S+\s+){4}\S+$/.test(String(value || "").trim());
1253
+ }
1254
+
1255
+ async function createScheduledTask({
1256
+ runtime,
1257
+ userPrompt,
1258
+ plan,
1259
+ }: {
1260
+ runtime: ProviderRuntime;
1261
+ userPrompt: string;
1262
+ plan: ScheduledTaskPlan;
1263
+ }) {
1264
+ if (!plan.task || !plan.cron_expression || !isValidCronExpression(plan.cron_expression)) {
1265
+ throw new Error("OpenCore could not derive a valid cron expression and task from the scheduling request.");
1266
+ }
1267
+ const id = `sched_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1268
+ const now = new Date().toISOString();
1269
+ const record: ScheduledTaskRecord = {
1270
+ id,
1271
+ task: plan.task,
1272
+ cron_expression: plan.cron_expression,
1273
+ summary: plan.summary || `Scheduled task: ${plan.task}`,
1274
+ schedule_kind: plan.schedule_kind || "one_time",
1275
+ created_at: now,
1276
+ updated_at: now,
1277
+ expected_time_iso: plan.expected_time_iso || "",
1278
+ last_status: "scheduled",
1279
+ active: true,
1280
+ source_prompt: userPrompt,
1281
+ };
1282
+ const items = await readSchedules();
1283
+ items.push(record);
1284
+ await writeSchedules(items);
1285
+ await upsertCronSchedule(record);
1286
+ await syncHeartbeatScheduleRecord(record);
1287
+ await appendMemory(
1288
+ `[manager] scheduled_task id=${record.id} kind=${record.schedule_kind} cron=${record.cron_expression} task=${record.task}`,
1289
+ );
1290
+ return record;
1291
+ }
1292
+
1293
+ async function recoverScheduledTasksIfNeeded({
1294
+ runtime,
1295
+ controller,
1296
+ dashboard,
1297
+ reason,
1298
+ }: {
1299
+ runtime: ProviderRuntime;
1300
+ controller: any;
1301
+ dashboard?: DashboardServer | null;
1302
+ reason: "startup" | "manual";
1303
+ }) {
1304
+ const schedules = await readSchedules();
1305
+ const nowMs = Date.now();
1306
+ const candidates = schedules.filter((item) => {
1307
+ const expectedMs = item.expected_time_iso ? Date.parse(item.expected_time_iso) : NaN;
1308
+ const isPastDueOneTime =
1309
+ item.schedule_kind === "one_time" &&
1310
+ item.active &&
1311
+ Number.isFinite(expectedMs) &&
1312
+ expectedMs <= nowMs &&
1313
+ !item.last_run_at;
1314
+ const hadError = item.last_status === "error";
1315
+ const stuckRunning =
1316
+ item.last_status === "running" &&
1317
+ item.last_run_at &&
1318
+ nowMs - Date.parse(item.last_run_at) > 15 * 60 * 1000;
1319
+ const missed = item.last_status === "missed";
1320
+ return isPastDueOneTime || hadError || stuckRunning || missed;
1321
+ });
1322
+
1323
+ const results: string[] = [];
1324
+ for (const item of candidates) {
1325
+ if (item.schedule_kind === "one_time" && item.active) {
1326
+ await removeCronSchedule(item.id).catch(() => {});
1327
+ await setScheduleRecordState(item.id, { active: false, last_status: "missed" });
1328
+ } else if (item.last_status === "running") {
1329
+ await setScheduleRecordState(item.id, { last_status: "missed" });
1330
+ }
1331
+ try {
1332
+ await setScheduleRecordState(item.id, {
1333
+ last_status: "running",
1334
+ last_run_at: new Date().toISOString(),
1335
+ last_error: "",
1336
+ });
1337
+ const answer = await runManagedTask({
1338
+ runtime,
1339
+ controller,
1340
+ userPrompt: item.task,
1341
+ source: "schedule",
1342
+ dashboard: dashboard || null,
1343
+ publishToConsole: reason === "startup",
1344
+ indicatorMessage: "OpenCore is recovering a scheduled task",
1345
+ });
1346
+ if (item.schedule_kind === "one_time") {
1347
+ await setScheduleRecordState(item.id, { active: false, last_status: "done", last_error: "" });
1348
+ } else {
1349
+ await setScheduleRecordState(item.id, { last_status: "done", last_error: "" });
1350
+ }
1351
+ results.push(`${item.id}: recovered successfully`);
1352
+ await appendMemory(`[schedule_recovered id=${item.id}] ${answer.slice(0, 200)}`);
1353
+ } catch (error) {
1354
+ const msg = error instanceof Error ? error.message : String(error);
1355
+ await setScheduleRecordState(item.id, { last_status: "error", last_error: msg });
1356
+ results.push(`${item.id}: recovery failed (${msg})`);
1357
+ await appendMemory(`[schedule_recovery_error id=${item.id}] ${msg}`);
1358
+ }
1359
+ }
1360
+ return results;
1361
+ }
1362
+
1363
+ async function managerInferPersistentUpdates({
1364
+ runtime,
1365
+ userPrompt,
1366
+ }: {
1367
+ runtime: ProviderRuntime;
1368
+ userPrompt: string;
1369
+ }): Promise<ManagerPersistentUpdatePlan> {
1370
+ const systemPrompt = await buildManagerSystemPrompt();
1371
+ const text = await runTextModel({
1372
+ runtime,
1373
+ systemPrompt,
1374
+ userText:
1375
+ `User message:\n${userPrompt}\n\n` +
1376
+ "Determine whether this message contains durable instructions, restrictions, preferences, identity updates, or computer facts that should be persisted for future tasks.\n" +
1377
+ 'Return JSON only using schema: {"memory_notes":["..."],"file_updates":[{"target":"guidelines|instructions|soul|memory","instructions":"..."}]}\n' +
1378
+ "Rules:\n" +
1379
+ "- Only include file_updates for durable information that should persist beyond the current task.\n" +
1380
+ "- If the user says something cannot be done on this computer or should never be done, update guidelines.\n" +
1381
+ "- If the user gives preferred behavior or workflow style, update instructions.\n" +
1382
+ "- If the user changes OpenCore identity/personality, update soul.\n" +
1383
+ "- Store important learned facts about the computer or apps in memory_notes.\n" +
1384
+ "- If nothing durable should be saved, return empty arrays.",
1385
+ maxTokens: 900,
1386
+ });
1387
+ const parsed = extractJsonObject(text);
1388
+ if (!parsed || typeof parsed !== "object") return { memory_notes: [], file_updates: [] };
1389
+ return {
1390
+ memory_notes: Array.isArray((parsed as any).memory_notes) ? (parsed as any).memory_notes : [],
1391
+ file_updates: Array.isArray((parsed as any).file_updates) ? (parsed as any).file_updates : [],
1392
+ };
1393
+ }
1394
+
1395
+ async function applyPersistentUpdatePlan({
1396
+ runtime,
1397
+ userPrompt,
1398
+ plan,
1399
+ dashboard,
1400
+ }: {
1401
+ runtime: ProviderRuntime;
1402
+ userPrompt: string;
1403
+ plan: ManagerPersistentUpdatePlan;
1404
+ dashboard: DashboardServer;
1405
+ }) {
1406
+ const appliedFiles = new Set<string>();
1407
+ const fileUpdates = Array.isArray(plan.file_updates) ? plan.file_updates : [];
1408
+ for (const item of fileUpdates) {
1409
+ const target = String(item?.target || "").trim();
1410
+ const instructions = String(item?.instructions || "").trim();
1411
+ if (!target || !instructions) continue;
1412
+ const resolved = resolveEditablePath(target);
1413
+ if (!resolved) continue;
1414
+ const updated = await managerRewriteFile({
1415
+ runtime,
1416
+ target,
1417
+ userPrompt,
1418
+ editInstructions: instructions,
1419
+ });
1420
+ if (updated === null) continue;
1421
+ await fs.writeFile(resolved.path, updated, "utf8");
1422
+ appliedFiles.add(resolved.path);
1423
+ await appendMemory(`[manager] auto_updated_file=${resolved.path}`);
1424
+ dashboard.publishEvent({ type: "file_updated", target: resolved.path });
1425
+ }
1426
+ const notes = Array.isArray(plan.memory_notes) ? plan.memory_notes : [];
1427
+ await appendLearnedFacts(notes);
1428
+ }
1429
+
1430
+ async function managerExtractLearnedFacts({
1431
+ runtime,
1432
+ userPrompt,
1433
+ answer,
1434
+ }: {
1435
+ runtime: ProviderRuntime;
1436
+ userPrompt: string;
1437
+ answer: string;
1438
+ }) {
1439
+ const systemPrompt = await buildManagerSystemPrompt();
1440
+ const text = await runTextModel({
1441
+ runtime,
1442
+ systemPrompt,
1443
+ userText:
1444
+ `User task:\n${userPrompt}\n\n` +
1445
+ `Task result:\n${answer}\n\n` +
1446
+ 'Return JSON only using schema: {"memory_notes":["..."]}\n' +
1447
+ "Extract only durable facts worth remembering for future tasks.\n" +
1448
+ "Prefer facts about the computer, installed apps, blocked permissions, UI quirks, recurring user preferences, and environment behavior.\n" +
1449
+ "Do not repeat transient step logs. If nothing durable was learned, return an empty array.",
1450
+ maxTokens: 500,
1451
+ });
1452
+ const parsed = extractJsonObject(text);
1453
+ return Array.isArray((parsed as any)?.memory_notes) ? (parsed as any).memory_notes : [];
1454
+ }
1455
+
1456
+ async function managerWriteHeartbeat({
1457
+ runtime,
1458
+ userPrompt,
1459
+ decision,
1460
+ }: {
1461
+ runtime: ProviderRuntime;
1462
+ userPrompt: string;
1463
+ decision: ManagerDecision;
1464
+ }) {
1465
+ const context = await loadPromptContext();
1466
+ const existingHeartbeat = await readTextFile(HEARTBEAT_PATH, DEFAULT_HEARTBEAT);
1467
+ const scheduleBlock = extractHeartbeatScheduleBlock(existingHeartbeat);
1468
+ const content =
1469
+ (await runTextModel({
1470
+ runtime,
1471
+ systemPrompt:
1472
+ "You are the OpenCore Manager Agent. Rewrite heartbeat.md for every new task. " +
1473
+ "Produce concise markdown with high-level actionable steps for the Computer Agent to follow.",
1474
+ userText:
1475
+ `User task: ${userPrompt}\n` +
1476
+ `Manager route: ${decision.route}\n` +
1477
+ `Delegated computer task: ${decision.computer_task || "(none)"}\n` +
1478
+ `Current guidelines:\n${context.guidelines}\n\n` +
1479
+ "Return markdown only with sections:\n" +
1480
+ "# OpenCore Heartbeat\n" +
1481
+ "## Current Task\n" +
1482
+ "## Big Steps\n" +
1483
+ "## Success Criteria\n" +
1484
+ "## Final Summary Instruction\n" +
1485
+ "Use numbered big steps and include app/site navigation instructions when relevant.",
1486
+ maxTokens: 900,
1487
+ })) || DEFAULT_HEARTBEAT;
1488
+ await fs.writeFile(HEARTBEAT_PATH, mergeHeartbeatWithSchedules(`${content.trim()}\n`, scheduleBlock), "utf8");
1489
+ await appendMemory("[manager] heartbeat_rewritten");
1490
+ return content;
1491
+ }
1492
+
1493
+ async function clearCompletedHeartbeat() {
1494
+ const heartbeatTemplate = await readTemplate("default-heartbeat.md", DEFAULT_HEARTBEAT);
1495
+ const existing = await readTextFile(HEARTBEAT_PATH, heartbeatTemplate);
1496
+ await fs.writeFile(HEARTBEAT_PATH, mergeHeartbeatWithSchedules(heartbeatTemplate, extractHeartbeatScheduleBlock(existing)), "utf8");
1497
+ await appendMemory("[manager] heartbeat_cleared");
1498
+ }
1499
+
1500
+ function extractHeartbeatScheduleBlock(content: string) {
1501
+ const text = String(content || "");
1502
+ const start = text.indexOf(HEARTBEAT_SCHEDULE_START);
1503
+ const end = text.indexOf(HEARTBEAT_SCHEDULE_END);
1504
+ if (start < 0 || end < 0 || end <= start) {
1505
+ return `${HEARTBEAT_SCHEDULE_START}\n${HEARTBEAT_SCHEDULE_END}`;
1506
+ }
1507
+ return text.slice(start, end + HEARTBEAT_SCHEDULE_END.length);
1508
+ }
1509
+
1510
+ function mergeHeartbeatWithSchedules(base: string, scheduleBlock: string) {
1511
+ const normalized = String(base || "").trimEnd();
1512
+ const block = scheduleBlock && scheduleBlock.includes(HEARTBEAT_SCHEDULE_START)
1513
+ ? scheduleBlock
1514
+ : `${HEARTBEAT_SCHEDULE_START}\n${HEARTBEAT_SCHEDULE_END}`;
1515
+ return `${normalized}\n\n## Scheduled Tasks\n${block}\n`;
1516
+ }
1517
+
1518
+ async function ensureHeartbeatScheduleBlock() {
1519
+ const current = await readTextFile(HEARTBEAT_PATH, DEFAULT_HEARTBEAT);
1520
+ if (current.includes(HEARTBEAT_SCHEDULE_START) && current.includes(HEARTBEAT_SCHEDULE_END)) return;
1521
+ await fs.writeFile(
1522
+ HEARTBEAT_PATH,
1523
+ mergeHeartbeatWithSchedules(current || DEFAULT_HEARTBEAT, `${HEARTBEAT_SCHEDULE_START}\n${HEARTBEAT_SCHEDULE_END}`),
1524
+ "utf8",
1525
+ );
1526
+ }
1527
+
1528
+ function heartbeatScheduleLine(record: ScheduledTaskRecord) {
1529
+ return `- [${record.last_status || "scheduled"}] ${record.id} | ${record.schedule_kind} | cron=${record.cron_expression} | summary=${record.summary} | task=${record.task.replace(/\s+/g, " ").trim()} | expected=${record.expected_time_iso || "n/a"} | last_run=${record.last_run_at || "n/a"} | updated=${record.updated_at}${record.last_error ? ` | error=${record.last_error.replace(/\s+/g, " ").trim()}` : ""}`;
1530
+ }
1531
+
1532
+ async function syncHeartbeatScheduleRecord(record: ScheduledTaskRecord) {
1533
+ await ensureHeartbeatScheduleBlock();
1534
+ const current = await readTextFile(HEARTBEAT_PATH, DEFAULT_HEARTBEAT);
1535
+ const block = extractHeartbeatScheduleBlock(current);
1536
+ const inner = block
1537
+ .replace(HEARTBEAT_SCHEDULE_START, "")
1538
+ .replace(HEARTBEAT_SCHEDULE_END, "")
1539
+ .split("\n")
1540
+ .map((line) => line.trimEnd())
1541
+ .filter((line) => line.trim() && !line.includes(`${record.id} |`));
1542
+ inner.push(heartbeatScheduleLine(record));
1543
+ const nextBlock = `${HEARTBEAT_SCHEDULE_START}\n${inner.join("\n")}\n${HEARTBEAT_SCHEDULE_END}`;
1544
+ const next = mergeHeartbeatWithSchedules(current, nextBlock);
1545
+ await fs.writeFile(HEARTBEAT_PATH, next, "utf8");
1546
+ }
1547
+
1548
+ function wantsHeartbeatAudit(prompt: string) {
1549
+ const text = String(prompt || "").trim().toLowerCase();
1550
+ return (
1551
+ text.includes("heartbeat") ||
1552
+ /check .*done/.test(text) ||
1553
+ /was .*done/.test(text) ||
1554
+ /check if everything was done/.test(text) ||
1555
+ /scheduled task/.test(text)
1556
+ );
1557
+ }
1558
+
1559
+ function hasActionableHeartbeat(heartbeat: string) {
1560
+ const text = String(heartbeat || "").trim();
1561
+ if (!text) return false;
1562
+ if (/^#\s*opencore heartbeat/i.test(text) && /no active task/i.test(text)) return false;
1563
+ return true;
1564
+ }
1565
+
1566
+ async function managerEvaluateHeartbeatTick({
1567
+ runtime,
1568
+ heartbeat,
1569
+ heartbeatChanged,
1570
+ }: {
1571
+ runtime: ProviderRuntime;
1572
+ heartbeat: string;
1573
+ heartbeatChanged: boolean;
1574
+ }): Promise<HeartbeatTickDecision> {
1575
+ if (!hasActionableHeartbeat(heartbeat)) {
1576
+ return { run_now: false, reason: "Heartbeat has no active task." };
1577
+ }
1578
+
1579
+ const now = new Date();
1580
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
1581
+ const text = await runTextModel({
1582
+ runtime,
1583
+ systemPrompt:
1584
+ "You are the OpenCore Manager Agent running periodic heartbeat checks. " +
1585
+ "Decide if OpenCore should immediately start an autonomous task right now. " +
1586
+ "Return JSON only.",
1587
+ userText:
1588
+ `Current time (ISO): ${now.toISOString()}\n` +
1589
+ `Current timezone: ${timezone}\n` +
1590
+ `Heartbeat file changed since last minute check: ${heartbeatChanged ? "yes" : "no"}\n\n` +
1591
+ `Heartbeat content:\n${heartbeat}\n\n` +
1592
+ 'Return JSON schema: {"run_now":boolean,"task":"string","reason":"string"}\n' +
1593
+ "Rules:\n" +
1594
+ "- run_now=true only when action is due right now, currently scheduled for now, or heartbeat was updated with a new actionable task.\n" +
1595
+ "- If run_now=true, task must be a short executable task prompt for the Computer Agent.\n" +
1596
+ "- If not due now, set run_now=false.",
1597
+ maxTokens: 500,
1598
+ });
1599
+
1600
+ const parsed = extractJsonObject(text);
1601
+ if (!parsed || typeof parsed.run_now !== "boolean") {
1602
+ return { run_now: false, reason: "Invalid manager heartbeat decision payload." };
1603
+ }
1604
+ return {
1605
+ run_now: Boolean(parsed.run_now),
1606
+ task: String(parsed.task || "").trim(),
1607
+ reason: String(parsed.reason || "").trim(),
1608
+ };
1609
+ }
1610
+
1611
+ async function executeOpenAiAction(controller: any, action: any) {
1612
+ const type = String(action?.type || "").toLowerCase();
1613
+ switch (type) {
1614
+ case "screenshot":
1615
+ return;
1616
+ case "click":
1617
+ return controller.executeAction({
1618
+ type: "click",
1619
+ x: action.x,
1620
+ y: action.y,
1621
+ button: action.button || "left",
1622
+ });
1623
+ case "double_click":
1624
+ case "double-click":
1625
+ return controller.executeAction({
1626
+ type: "double_click",
1627
+ x: action.x,
1628
+ y: action.y,
1629
+ button: action.button || "left",
1630
+ });
1631
+ case "move":
1632
+ case "mousemove":
1633
+ case "mouse_move":
1634
+ return controller.executeAction({ type: "move", x: action.x, y: action.y });
1635
+ case "drag":
1636
+ case "drag_to":
1637
+ case "dragto":
1638
+ return controller.executeAction({
1639
+ type: "drag",
1640
+ path: [
1641
+ { x: action.from_x ?? controller.cursorX, y: action.from_y ?? controller.cursorY },
1642
+ { x: action.to_x ?? action.x, y: action.to_y ?? action.y },
1643
+ ],
1644
+ });
1645
+ case "scroll":
1646
+ return controller.executeAction({
1647
+ type: "scroll",
1648
+ x: controller.cursorX,
1649
+ y: controller.cursorY,
1650
+ scroll_x: action.scroll_x ?? action.delta_x ?? action.x ?? 0,
1651
+ scroll_y: action.scroll_y ?? action.delta_y ?? action.y ?? 0,
1652
+ });
1653
+ case "type":
1654
+ case "input_text":
1655
+ return controller.executeAction({ type: "type", text: action.text || "" });
1656
+ case "keypress":
1657
+ case "key_press":
1658
+ case "press_key":
1659
+ return controller.executeAction({
1660
+ type: "keypress",
1661
+ keys: Array.isArray(action.keys) ? action.keys : [String(action.key || action.text || "")],
1662
+ });
1663
+ case "wait":
1664
+ return controller.executeAction({ type: "wait", ms: Number(action.ms || 1000) });
1665
+ default:
1666
+ throw new Error(`Unsupported OpenAI action type: ${type || "unknown"}`);
1667
+ }
1668
+ }
1669
+
1670
+ function summarizeOpenAiAction(action: any = {}) {
1671
+ const type = String(action?.type || "");
1672
+ if (type === "type") return `type(${JSON.stringify(String(action.text || "")).slice(0, 72)})`;
1673
+ if (type === "click") return `click(${action.button || "left"} @ ${action.x},${action.y})`;
1674
+ if (type === "double_click" || type === "double-click") {
1675
+ return `double_click(${action.button || "left"} @ ${action.x},${action.y})`;
1676
+ }
1677
+ return type || "unknown";
1678
+ }
1679
+
1680
+ function stripCredentialSaveFooters(text: string) {
1681
+ return String(text || "")
1682
+ .split("\n")
1683
+ .filter((line) => !/^CREDENTIAL_SAVE\s+\{.+\}$/.test(line.trim()))
1684
+ .join("\n")
1685
+ .trim();
1686
+ }
1687
+
1688
+ async function runOpenAiComputerTask({
1689
+ client,
1690
+ controller,
1691
+ prompt,
1692
+ secretTaskContext,
1693
+ onEvent,
1694
+ }: {
1695
+ client: any;
1696
+ controller: any;
1697
+ prompt: string;
1698
+ secretTaskContext?: string;
1699
+ onEvent?: (evt: any) => void;
1700
+ }) {
1701
+ const systemPrompt = await buildComputerSystemPrompt();
1702
+ let stepCount = 0;
1703
+ let finalText = "";
1704
+ let selectedModel = OPENAI_MODEL;
1705
+
1706
+ const initial = await createOpenAiResponseWithModelFallback({
1707
+ client,
1708
+ buildPayload: (model) => ({
1709
+ model,
1710
+ tools: [{ type: "computer" as const }],
1711
+ input: [
1712
+ {
1713
+ role: "system",
1714
+ content: [{ type: "input_text", text: systemPrompt }],
1715
+ },
1716
+ {
1717
+ role: "user",
1718
+ content: [
1719
+ {
1720
+ type: "input_text",
1721
+ text:
1722
+ "Execute the current task by following heartbeat.md exactly. " +
1723
+ "Use heartbeat.md big steps as the source of truth.\n\n" +
1724
+ `Task input:\n${prompt}` +
1725
+ (secretTaskContext ? `\n\nCredential/task context:\n${secretTaskContext}` : ""),
1726
+ },
1727
+ ],
1728
+ },
1729
+ ],
1730
+ truncation: "auto",
1731
+ }),
1732
+ });
1733
+ let response = initial.response;
1734
+ selectedModel = initial.model;
1735
+ console.log(`Using OpenAI computer model: ${selectedModel}`);
1736
+ await appendMemory(`[system] openai_model_selected=${selectedModel}`);
1737
+
1738
+ while (stepCount < MAX_STEPS) {
1739
+ finalText = extractOpenAiText(response) || finalText;
1740
+ const computerCalls = extractOpenAiComputerCalls(response);
1741
+ if (!computerCalls.length) break;
1742
+
1743
+ const followUpInputs: any[] = [];
1744
+ for (const call of computerCalls) {
1745
+ const actions = getOpenAiCallActions(call);
1746
+ if (!actions.length) {
1747
+ const screenshotRaw = await controller.captureScreenshotBase64();
1748
+ const screenshotMediaType =
1749
+ typeof controller.getLastScreenshotMediaType === "function"
1750
+ ? controller.getLastScreenshotMediaType()
1751
+ : "image/png";
1752
+ const screenshot = await enforceApiImageLimit(screenshotRaw, screenshotMediaType);
1753
+ const savedPath = await persistScreenshotToDisk(screenshot.data, 0, screenshot.mediaType);
1754
+ await appendMemory(`[screenshot step=0] ${savedPath}`);
1755
+ onEvent?.({ type: "screenshot", step: 0, path: savedPath });
1756
+ followUpInputs.push({
1757
+ type: "computer_call_output",
1758
+ call_id: call.call_id,
1759
+ output: {
1760
+ type: "input_image",
1761
+ image_url: `data:${screenshot.mediaType};base64,${screenshot.data}`,
1762
+ },
1763
+ });
1764
+ continue;
1765
+ }
1766
+ for (const action of actions) {
1767
+ if (stepCount >= MAX_STEPS) break;
1768
+ stepCount += 1;
1769
+ const actionSummary = summarizeOpenAiAction(action);
1770
+ console.log(` [step ${stepCount}] ${actionSummary}`);
1771
+ await appendMemory(`[action step=${stepCount}] ${actionSummary}`);
1772
+ onEvent?.({ type: "action", step: stepCount, action: actionSummary });
1773
+ try {
1774
+ await executeOpenAiAction(controller, action);
1775
+ } catch (error) {
1776
+ await appendMemory(
1777
+ `[action_error step=${stepCount}] ${error instanceof Error ? error.message : String(error)}`,
1778
+ );
1779
+ console.warn(
1780
+ ` [step ${stepCount}] action failed: ${error instanceof Error ? error.message : String(error)}`,
1781
+ );
1782
+ }
1783
+ if (ACTION_DELAY_MS) await sleep(ACTION_DELAY_MS);
1784
+ }
1785
+ const screenshotRaw = await controller.captureScreenshotBase64();
1786
+ const screenshotMediaType =
1787
+ typeof controller.getLastScreenshotMediaType === "function"
1788
+ ? controller.getLastScreenshotMediaType()
1789
+ : "image/png";
1790
+ const screenshot = await enforceApiImageLimit(screenshotRaw, screenshotMediaType);
1791
+ const savedPath = await persistScreenshotToDisk(screenshot.data, stepCount, screenshot.mediaType);
1792
+ await appendMemory(`[screenshot step=${stepCount}] ${savedPath}`);
1793
+ onEvent?.({ type: "screenshot", step: stepCount, path: savedPath });
1794
+ followUpInputs.push({
1795
+ type: "computer_call_output",
1796
+ call_id: call.call_id,
1797
+ output: {
1798
+ type: "input_image",
1799
+ image_url: `data:${screenshot.mediaType};base64,${screenshot.data}`,
1800
+ },
1801
+ });
1802
+ }
1803
+
1804
+ if (!followUpInputs.length) break;
1805
+ response = await client.responses.create({
1806
+ model: selectedModel,
1807
+ previous_response_id: response.id,
1808
+ tools: [{ type: "computer" as const }],
1809
+ input: followUpInputs,
1810
+ truncation: "auto",
1811
+ });
1812
+ }
1813
+
1814
+ if (stepCount >= MAX_STEPS) {
1815
+ console.warn(`Reached max tool steps (${MAX_STEPS}).`);
1816
+ }
1817
+ return finalText || "(No assistant message returned.)";
1818
+ }
1819
+
1820
+ async function persistScreenshotToDisk(base64: string, stepCount: number, mediaType = "image/png") {
1821
+ const ext = mediaType === "image/jpeg" ? "jpg" : "png";
1822
+ const file = path.join(
1823
+ SCREENSHOT_DIR,
1824
+ `${Date.now()}-step-${String(stepCount).padStart(4, "0")}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
1825
+ );
1826
+ await fs.writeFile(file, Buffer.from(base64, "base64"));
1827
+ return file;
1828
+ }
1829
+
1830
+ async function runManagedTask({
1831
+ runtime,
1832
+ controller,
1833
+ userPrompt,
1834
+ source,
1835
+ dashboard,
1836
+ publishToConsole = true,
1837
+ indicatorMessage = "OpenCore is controlling this Mac",
1838
+ }: {
1839
+ runtime: ProviderRuntime;
1840
+ controller: any;
1841
+ userPrompt: string;
1842
+ source: "terminal" | "dashboard" | "manager" | "schedule" | "telegram";
1843
+ dashboard?: DashboardServer | null;
1844
+ publishToConsole?: boolean;
1845
+ indicatorMessage?: string;
1846
+ }) {
1847
+ await appendMemory(`[user] ${userPrompt}`);
1848
+ await dashboard?.publishChat({ role: "user", source: source === "schedule" ? "manager" : source, text: userPrompt });
1849
+ dashboard?.publishEvent({ type: "task_started", source: source === "schedule" ? "manager" : source });
1850
+ await writeIndicatorState({ running: true, active: true, message: indicatorMessage });
1851
+ await showMacNotification("OpenCore", indicatorMessage);
1852
+ if (publishToConsole) console.log(`Running (${source})...`);
1853
+
1854
+ const persistentPlan = await managerInferPersistentUpdates({ runtime, userPrompt });
1855
+ await applyPersistentUpdatePlan({
1856
+ runtime,
1857
+ userPrompt,
1858
+ plan: persistentPlan,
1859
+ dashboard: dashboard || ({ publishEvent() {} } as any),
1860
+ });
1861
+
1862
+ const telegramSetup = await handleTelegramSetupRequest(userPrompt);
1863
+ if (telegramSetup?.handled) {
1864
+ await appendMemory("[manager] configured_telegram");
1865
+ await dashboard?.publishChat({ role: "assistant", source: "system", text: telegramSetup.answer });
1866
+ dashboard?.publishEvent({ type: "task_completed", source: source === "schedule" ? "manager" : source });
1867
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1868
+ await showMacNotification("OpenCore", "OpenCore updated Telegram settings.");
1869
+ return telegramSetup.answer;
1870
+ }
1871
+
1872
+ const credentialSetup = await handleCredentialSetupRequest(userPrompt);
1873
+ if (credentialSetup?.handled) {
1874
+ await appendMemory("[manager] configured_credentials");
1875
+ await dashboard?.publishChat({ role: "assistant", source: "system", text: credentialSetup.answer });
1876
+ dashboard?.publishEvent({ type: "task_completed", source: source === "schedule" ? "manager" : source });
1877
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1878
+ await showMacNotification("OpenCore", "OpenCore updated local credentials.");
1879
+ return credentialSetup.answer;
1880
+ }
1881
+
1882
+ if (wantsHeartbeatAudit(userPrompt)) {
1883
+ const recovered = await recoverScheduledTasksIfNeeded({
1884
+ runtime,
1885
+ controller,
1886
+ dashboard: dashboard || null,
1887
+ reason: "manual",
1888
+ });
1889
+ const answer = recovered.length
1890
+ ? `Heartbeat check complete.\n${recovered.map((item) => `- ${item}`).join("\n")}`
1891
+ : "Heartbeat check complete. No missed or errored scheduled tasks were found.";
1892
+ await appendMemory(`[assistant] ${answer}`);
1893
+ await dashboard?.publishChat({ role: "assistant", source: "system", text: answer });
1894
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1895
+ return answer;
1896
+ }
1897
+
1898
+ const schedulePlan = await managerDetectScheduleRequest({ runtime, userPrompt });
1899
+ if (schedulePlan.should_schedule) {
1900
+ const record = await createScheduledTask({ runtime, userPrompt, plan: schedulePlan });
1901
+ const scheduleAnswer =
1902
+ `Scheduled task created.\n` +
1903
+ `ID: ${record.id}\n` +
1904
+ `Schedule: ${record.summary}\n` +
1905
+ `Cron: ${record.cron_expression}\n` +
1906
+ `Task: ${record.task}`;
1907
+ await appendMemory(`[assistant] ${scheduleAnswer}`);
1908
+ await dashboard?.publishChat({ role: "assistant", source: "system", text: scheduleAnswer });
1909
+ dashboard?.publishEvent({ type: "task_completed", source: source === "schedule" ? "manager" : source });
1910
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1911
+ await showMacNotification("OpenCore", "OpenCore scheduled the requested task.");
1912
+ return scheduleAnswer;
1913
+ }
1914
+
1915
+ const decision = await managerDecide({ runtime, userPrompt });
1916
+ await appendMemory(`[manager] route=${decision.route}`);
1917
+ await managerWriteHeartbeat({ runtime, userPrompt, decision });
1918
+ let answer = "";
1919
+
1920
+ if (decision.route === "direct") {
1921
+ answer = decision.direct_answer || "Completed without computer interaction.";
1922
+ } else if (decision.route === "edit_file") {
1923
+ const resolved = resolveEditablePath(decision.file_target);
1924
+ if (!resolved) {
1925
+ answer = "I could not determine which file path to edit. Please specify a valid file target.";
1926
+ } else {
1927
+ const updated = await managerRewriteFile({
1928
+ runtime,
1929
+ target: decision.file_target || "",
1930
+ userPrompt,
1931
+ editInstructions: decision.edit_instructions || "",
1932
+ });
1933
+ if (updated === null) {
1934
+ answer = "I could not resolve a writable file target.";
1935
+ } else {
1936
+ await fs.writeFile(resolved.path, updated, "utf8");
1937
+ await appendMemory(`[manager] edited_file=${resolved.path}`);
1938
+ dashboard?.publishEvent({ type: "file_updated", target: resolved.path });
1939
+ answer = `Updated ${resolved.path} based on your request.`;
1940
+ }
1941
+ }
1942
+ } else if (decision.route === "local") {
1943
+ answer = await runLocalCommandTask({
1944
+ runtime,
1945
+ userPrompt,
1946
+ localTask: decision.local_task || userPrompt,
1947
+ });
1948
+ } else {
1949
+ const delegatedTask = decision.computer_task || userPrompt;
1950
+ const credentialTaskContext = buildCredentialExecutionContext(await readCredentialStore(), delegatedTask);
1951
+ await appendMemory(`[manager] delegated_to_computer task=${delegatedTask}`);
1952
+ answer = await runOpenAiComputerTask({
1953
+ client: runtime.openai,
1954
+ controller,
1955
+ prompt: delegatedTask,
1956
+ secretTaskContext: credentialTaskContext,
1957
+ onEvent: (evt) => dashboard?.publishEvent(evt),
1958
+ });
1959
+ }
1960
+
1961
+ for (const candidate of extractCredentialSaveCandidates(answer)) {
1962
+ await upsertCredentialEntry(candidate);
1963
+ }
1964
+ answer = stripCredentialSaveFooters(answer);
1965
+
1966
+ await appendMemory(`[assistant] ${answer}`);
1967
+ await appendLearnedFacts(await managerExtractLearnedFacts({ runtime, userPrompt, answer }));
1968
+ await clearCompletedHeartbeat();
1969
+ dashboard?.publishEvent({ type: "file_updated", target: HEARTBEAT_PATH });
1970
+ await dashboard?.publishChat({ role: "assistant", source: "system", text: answer });
1971
+ dashboard?.publishEvent({ type: "task_completed", source: source === "schedule" ? "manager" : source });
1972
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1973
+ await showMacNotification("OpenCore", source === "schedule" ? "OpenCore finished a scheduled task." : "OpenCore finished the current task.");
1974
+ return answer;
1975
+ }
1976
+
1977
+ async function main() {
1978
+ const argv = process.argv.slice(2);
1979
+ const scheduledMode = argv[0] === "--scheduled-id" ? String(argv[1] || "").trim() : "";
1980
+ await ensureOpenCoreHome();
1981
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
1982
+ const settings = await readSettings();
1983
+ const provider = "chatgpt";
1984
+ const openaiKey = String(process.env.OPENAI_API_KEY || settings.openai_api_key || "").trim();
1985
+ let runtime: ProviderRuntime;
1986
+ if (!openaiKey) {
1987
+ console.error("Missing OpenAI API key. Set it in ~/.opencore/configs/settings.json or use: opencore config set-key chatgpt");
1988
+ process.exit(1);
1989
+ }
1990
+ let OpenAIClient: any;
1991
+ try {
1992
+ OpenAIClient = require("openai").default;
1993
+ } catch {
1994
+ console.error("OpenAI SDK is not installed. Run npm install in the OpenCore project to enable ChatGPT mode.");
1995
+ process.exit(1);
1996
+ }
1997
+ runtime = { provider: "chatgpt", openai: new OpenAIClient({ apiKey: openaiKey }) };
1998
+ const controller = new MacController({
1999
+ displayWidth: process.env.COMPUTER_USE_DISPLAY_WIDTH,
2000
+ displayHeight: process.env.COMPUTER_USE_DISPLAY_HEIGHT,
2001
+ });
2002
+ await controller.init();
2003
+ let indicator: any = startIndicatorProcess();
2004
+
2005
+ if (scheduledMode) {
2006
+ const schedules = await readSchedules();
2007
+ const schedule = schedules.find((item) => item.id === scheduledMode && item.active);
2008
+ if (!schedule) {
2009
+ console.error(`Scheduled task not found or inactive: ${scheduledMode}`);
2010
+ await writeIndicatorState({ running: false, active: false, message: "OpenCore has stopped" });
2011
+ stopIndicatorProcess(indicator);
2012
+ process.exit(1);
2013
+ }
2014
+ try {
2015
+ const runStartedAt = new Date().toISOString();
2016
+ if (schedule.schedule_kind === "one_time") {
2017
+ await setScheduleRecordState(schedule.id, {
2018
+ active: false,
2019
+ last_run_at: runStartedAt,
2020
+ last_status: "running",
2021
+ last_error: "",
2022
+ });
2023
+ await removeCronSchedule(schedule.id);
2024
+ } else {
2025
+ await setScheduleRecordState(schedule.id, {
2026
+ last_run_at: runStartedAt,
2027
+ last_status: "running",
2028
+ last_error: "",
2029
+ });
2030
+ }
2031
+ const answer = await runManagedTask({
2032
+ runtime,
2033
+ controller,
2034
+ userPrompt: schedule.task,
2035
+ source: "schedule",
2036
+ dashboard: null,
2037
+ publishToConsole: true,
2038
+ indicatorMessage: "OpenCore is running a scheduled task",
2039
+ });
2040
+ const now = new Date().toISOString();
2041
+ if (schedule.schedule_kind !== "one_time") {
2042
+ await setScheduleRecordState(schedule.id, { last_run_at: now, last_status: "done", last_error: "" });
2043
+ } else {
2044
+ await setScheduleRecordState(schedule.id, { last_run_at: now, last_status: "done", last_error: "" });
2045
+ }
2046
+ console.log(answer);
2047
+ await writeIndicatorState({ running: false, active: false, message: "OpenCore has stopped" });
2048
+ stopIndicatorProcess(indicator);
2049
+ process.exit(0);
2050
+ } catch (error) {
2051
+ const msg = error instanceof Error ? error.message : String(error);
2052
+ console.error(`Scheduled task failed: ${msg}`);
2053
+ await appendMemory(`[scheduled_error id=${scheduledMode}] ${msg}`);
2054
+ await setScheduleRecordState(scheduledMode, { last_status: "error", last_error: msg });
2055
+ await writeIndicatorState({ running: false, active: false, message: "OpenCore has stopped" });
2056
+ stopIndicatorProcess(indicator);
2057
+ process.exit(1);
2058
+ }
2059
+ }
2060
+
2061
+ const queue: Array<{ text: string; source: "terminal" | "dashboard" | "manager" | "telegram" }> = [];
2062
+ let queueRunning = false;
2063
+ let exiting = false;
2064
+ let managerTickRunning = false;
2065
+ let managerTickTimer: NodeJS.Timeout | null = null;
2066
+ let telegramTickRunning = false;
2067
+ let telegramTickTimer: NodeJS.Timeout | null = null;
2068
+ let lastHeartbeatSignature = "";
2069
+ const autoTaskDedupe = new Set<string>();
2070
+ let telegramConfig = parseTelegramConfig(settings);
2071
+ const rl = readline.createInterface({ input, output, terminal: true });
2072
+ let commandPromptBusy = false;
2073
+ const dashboard = new DashboardServer({
2074
+ rootDir: ROOT_DIR,
2075
+ openCoreHome: OPENCORE_HOME,
2076
+ screenshotDir: SCREENSHOT_DIR,
2077
+ onDashboardPrompt: (text) => {
2078
+ queue.push({ text, source: "dashboard" });
2079
+ void processQueue();
2080
+ },
2081
+ getTelegramConfig: async () => publicTelegramConfig(telegramConfig),
2082
+ setTelegramConfig: async (next) => {
2083
+ const nextBotToken = String(next?.bot_token || "").trim() || telegramConfig.botToken;
2084
+ const nextChatId = String(next?.chat_id || "").trim() || telegramConfig.chatId;
2085
+ const nextUserId = String(next?.user_id || "").trim() || telegramConfig.userId;
2086
+ const nextPairingCode = String(next?.pairing_code || "").trim() || telegramConfig.pairingCode;
2087
+ const shouldEnable = Boolean(nextBotToken);
2088
+ const merged = {
2089
+ telegram_enabled: shouldEnable,
2090
+ telegram_chat_id: nextChatId,
2091
+ telegram_user_id: nextUserId,
2092
+ telegram_bot_token: nextBotToken,
2093
+ telegram_pairing_code: nextPairingCode,
2094
+ telegram_paired: Boolean(shouldEnable && nextChatId && nextUserId && nextPairingCode),
2095
+ telegram_last_update_id: Number(next?.last_update_id || telegramConfig.lastUpdateId || 0),
2096
+ };
2097
+ await writeSettingsPatch(merged);
2098
+ telegramConfig = parseTelegramConfig(await readSettings());
2099
+ refreshTelegramPolling();
2100
+ return publicTelegramConfig(telegramConfig);
2101
+ },
2102
+ disconnectTelegram: async () => {
2103
+ await writeSettingsPatch({
2104
+ telegram_enabled: false,
2105
+ telegram_bot_token: "",
2106
+ telegram_chat_id: "",
2107
+ telegram_user_id: "",
2108
+ telegram_pairing_code: "",
2109
+ telegram_paired: false,
2110
+ telegram_last_update_id: 0,
2111
+ });
2112
+ telegramConfig = parseTelegramConfig(await readSettings());
2113
+ refreshTelegramPolling();
2114
+ return publicTelegramConfig(telegramConfig);
2115
+ },
2116
+ onCredentialsUpdated: async () => {
2117
+ await appendMemory("[system] credentials_updated");
2118
+ },
2119
+ });
2120
+ await dashboard.start(DASHBOARD_PORT);
2121
+ const recoveredOnStartup = await recoverScheduledTasksIfNeeded({
2122
+ runtime,
2123
+ controller,
2124
+ dashboard,
2125
+ reason: "startup",
2126
+ });
2127
+
2128
+ console.log(`${renderOpenCoreBanner()}\n\n`);
2129
+ console.log(`OpenCore ${APP_VERSION} macOS`);
2130
+ console.log(`Provider: ${provider}`);
2131
+ console.log(`Dashboard: http://127.0.0.1:${DASHBOARD_PORT}`);
2132
+ console.log("Type a task and press Enter. Use /exit to quit.\n");
2133
+ if (recoveredOnStartup.length) {
2134
+ console.log("Recovered scheduled tasks:");
2135
+ for (const line of recoveredOnStartup) console.log(`- ${line}`);
2136
+ console.log("");
2137
+ }
2138
+
2139
+ async function shutdown(code = 0) {
2140
+ if (exiting) return;
2141
+ exiting = true;
2142
+ if (managerTickTimer) clearInterval(managerTickTimer);
2143
+ if (telegramTickTimer) clearInterval(telegramTickTimer);
2144
+ queue.length = 0;
2145
+ try {
2146
+ rl.close();
2147
+ } catch {}
2148
+ try {
2149
+ await Promise.race([
2150
+ dashboard.stop(1200),
2151
+ sleep(1400),
2152
+ ]);
2153
+ } catch {}
2154
+ await writeIndicatorState({ running: false, active: false, message: "OpenCore has stopped" });
2155
+ stopIndicatorProcess(indicator);
2156
+ indicator = null;
2157
+ process.exit(code);
2158
+ }
2159
+
2160
+ function printSkillsText(skills: string[]) {
2161
+ if (!skills.length) return "No OpenCore skills installed.";
2162
+ return `Installed OpenCore skills:\n${skills.map((skill) => `- ${skill}`).join("\n")}`;
2163
+ }
2164
+
2165
+ function printSchedulesText(active: ScheduledTaskRecord[]) {
2166
+ if (!active.length) return "No active scheduled tasks.";
2167
+ return `Active scheduled tasks:\n${active.map((item) => `- ${item.id} | ${item.schedule_kind} | ${item.cron_expression} | ${item.summary}`).join("\n")}`;
2168
+ }
2169
+
2170
+ async function handleHeartbeatCheck() {
2171
+ const recovered = await recoverScheduledTasksIfNeeded({
2172
+ runtime,
2173
+ controller,
2174
+ dashboard,
2175
+ reason: "manual",
2176
+ });
2177
+ const text = !recovered.length
2178
+ ? "Heartbeat check complete. No missed or errored scheduled tasks were found."
2179
+ : `Heartbeat recovery results:\n${recovered.map((item) => `- ${item}`).join("\n")}`;
2180
+ await appendMemory(`[system] checked_heartbeat recovered=${recovered.length}`);
2181
+ return text;
2182
+ }
2183
+
2184
+ async function disconnectTelegramRuntime() {
2185
+ if (telegramTickTimer) clearInterval(telegramTickTimer);
2186
+ telegramTickTimer = null;
2187
+ await writeSettingsPatch({
2188
+ telegram_enabled: false,
2189
+ telegram_bot_token: "",
2190
+ telegram_chat_id: "",
2191
+ telegram_user_id: "",
2192
+ telegram_pairing_code: "",
2193
+ telegram_paired: false,
2194
+ telegram_last_update_id: 0,
2195
+ });
2196
+ telegramConfig = parseTelegramConfig(await readSettings());
2197
+ refreshTelegramPolling();
2198
+ return "Telegram disconnected.";
2199
+ }
2200
+
2201
+ async function reconnectTelegramInteractive() {
2202
+ if (!input.isTTY || commandPromptBusy) {
2203
+ return "Telegram reconnect requires an interactive terminal.";
2204
+ }
2205
+ commandPromptBusy = true;
2206
+ rl.pause();
2207
+ try {
2208
+ const botToken = String(await rl.question("Telegram bot token: ")).trim();
2209
+ if (!botToken) return "Telegram reconnect cancelled: bot token is required.";
2210
+ const me = await telegramApi(botToken, "getMe");
2211
+ console.log(`Connected bot: @${me?.username || "unknown"}`);
2212
+ await writeSettingsPatch({
2213
+ telegram_enabled: true,
2214
+ telegram_bot_token: botToken,
2215
+ telegram_chat_id: "",
2216
+ telegram_user_id: "",
2217
+ telegram_pairing_code: "",
2218
+ telegram_paired: false,
2219
+ telegram_last_update_id: 0,
2220
+ });
2221
+ telegramConfig = parseTelegramConfig(await readSettings());
2222
+ refreshTelegramPolling();
2223
+ return "Telegram bot token saved. Start OpenCore, send /start to the bot, then give the Telegram user ID and pairing code from Telegram back to OpenCore.";
2224
+ } finally {
2225
+ commandPromptBusy = false;
2226
+ if (!exiting) {
2227
+ rl.resume();
2228
+ rl.prompt();
2229
+ }
2230
+ }
2231
+ }
2232
+
2233
+ async function handleControlCommand(line: string, source: "terminal" | "telegram") {
2234
+ if (line === "/skills") {
2235
+ const skills = await listInstalledSkillFiles();
2236
+ await appendMemory(`[system] listed_skills count=${skills.length}`);
2237
+ return { handled: true, text: printSkillsText(skills) };
2238
+ }
2239
+ if (line === "/schedules") {
2240
+ const schedules = await readSchedules();
2241
+ const active = schedules.filter((item) => item.active);
2242
+ await appendMemory(`[system] listed_schedules count=${active.length}`);
2243
+ return { handled: true, text: printSchedulesText(active) };
2244
+ }
2245
+ if (line === "/check-heartbeat") {
2246
+ return { handled: true, text: await handleHeartbeatCheck() };
2247
+ }
2248
+ if (source === "terminal" && line === "/telegram disconnect") {
2249
+ return { handled: true, text: await disconnectTelegramRuntime() };
2250
+ }
2251
+ if (source === "terminal" && line === "/telegram reconnect") {
2252
+ return { handled: true, text: await reconnectTelegramInteractive() };
2253
+ }
2254
+ if (source === "telegram" && line.startsWith("/")) {
2255
+ return {
2256
+ handled: true,
2257
+ text: "Unsupported Telegram command. Available commands: /skills, /schedules, /check-heartbeat",
2258
+ };
2259
+ }
2260
+ return { handled: false, text: "" };
2261
+ }
2262
+
2263
+ function startTelegramPolling() {
2264
+ if (telegramTickTimer || !telegramConfig.enabled) return;
2265
+ console.log(`Telegram: connected to chat ${telegramConfig.chatId}`);
2266
+ telegramTickTimer = setInterval(() => {
2267
+ void telegramPollTick();
2268
+ }, 4000);
2269
+ if (typeof telegramTickTimer.unref === "function") telegramTickTimer.unref();
2270
+ setTimeout(() => {
2271
+ void telegramPollTick();
2272
+ }, 1500);
2273
+ }
2274
+
2275
+ function stopTelegramPolling() {
2276
+ if (telegramTickTimer) clearInterval(telegramTickTimer);
2277
+ telegramTickTimer = null;
2278
+ }
2279
+
2280
+ function refreshTelegramPolling() {
2281
+ stopTelegramPolling();
2282
+ if (telegramConfig.enabled) startTelegramPolling();
2283
+ }
2284
+
2285
+ async function processQueue() {
2286
+ if (queueRunning) return;
2287
+ queueRunning = true;
2288
+ rl.pause();
2289
+ console.log("> [running... input locked until task completes]");
2290
+ try {
2291
+ while (queue.length) {
2292
+ const item = queue.shift();
2293
+ if (!item) continue;
2294
+ try {
2295
+ const answer = await runManagedTask({
2296
+ runtime,
2297
+ controller,
2298
+ userPrompt: item.text,
2299
+ source: item.source,
2300
+ dashboard,
2301
+ });
2302
+ console.log(`\nAssistant:\n${answer}\n`);
2303
+ if (item.source === "telegram") {
2304
+ await sendTelegramMessage(telegramConfig, answer);
2305
+ }
2306
+ } catch (error) {
2307
+ const msg = error instanceof Error ? error.message : String(error);
2308
+ console.error(`\nRequest failed: ${msg}\n`);
2309
+ await appendMemory(`[error] ${msg}`);
2310
+ await dashboard.publishChat({ role: "error", source: "system", text: `Request failed: ${msg}` });
2311
+ dashboard.publishEvent({ type: "task_failed", source: item.source });
2312
+ await writeIndicatorState({ running: true, active: false, message: "OpenCore is idle" });
2313
+ await showMacNotification("OpenCore", "OpenCore stopped because the current task failed.");
2314
+ if (item.source === "telegram") {
2315
+ await sendTelegramMessage(telegramConfig, `Request failed: ${msg}`);
2316
+ }
2317
+ }
2318
+ }
2319
+ } finally {
2320
+ queueRunning = false;
2321
+ if (!exiting) {
2322
+ rl.resume();
2323
+ rl.prompt();
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ async function managerHeartbeatTick() {
2329
+ if (exiting || queueRunning || managerTickRunning) return;
2330
+ managerTickRunning = true;
2331
+ try {
2332
+ const heartbeat = await readTextFile(HEARTBEAT_PATH, DEFAULT_HEARTBEAT);
2333
+ const stat = await fs.stat(HEARTBEAT_PATH).catch(() => null);
2334
+ const signature = `${stat?.mtimeMs || 0}:${heartbeat.length}`;
2335
+ const heartbeatChanged = signature !== lastHeartbeatSignature;
2336
+ lastHeartbeatSignature = signature;
2337
+
2338
+ const decision = await managerEvaluateHeartbeatTick({
2339
+ runtime,
2340
+ heartbeat,
2341
+ heartbeatChanged,
2342
+ });
2343
+ if (!decision.run_now) return;
2344
+
2345
+ const task = String(decision.task || "").trim();
2346
+ if (!task) return;
2347
+ const minuteKey = new Date().toISOString().slice(0, 16);
2348
+ const dedupeKey = `${minuteKey}|${signature}|${task.toLowerCase()}`;
2349
+ if (autoTaskDedupe.has(dedupeKey)) return;
2350
+ autoTaskDedupe.add(dedupeKey);
2351
+ if (autoTaskDedupe.size > 800) {
2352
+ const oldest = autoTaskDedupe.values().next().value;
2353
+ if (oldest) autoTaskDedupe.delete(oldest);
2354
+ }
2355
+
2356
+ console.log(`\n[manager heartbeat] ${decision.reason || "Autonomous run triggered."}`);
2357
+ await appendMemory(`[manager_heartbeat] trigger task=${task} reason=${decision.reason || "n/a"}`);
2358
+ queue.push({ text: task, source: "manager" });
2359
+ void processQueue();
2360
+ } catch (error) {
2361
+ const msg = error instanceof Error ? error.message : String(error);
2362
+ console.warn(`[manager heartbeat] check failed: ${msg}`);
2363
+ await appendMemory(`[manager_heartbeat_error] ${msg}`);
2364
+ } finally {
2365
+ managerTickRunning = false;
2366
+ }
2367
+ }
2368
+
2369
+ async function telegramPollTick() {
2370
+ if (exiting || queueRunning || telegramTickRunning || !telegramConfig.enabled) return;
2371
+ telegramTickRunning = true;
2372
+ try {
2373
+ const updates = await telegramApi(telegramConfig.botToken, "getUpdates", {
2374
+ offset: telegramConfig.lastUpdateId + 1,
2375
+ timeout: 1,
2376
+ limit: 10,
2377
+ });
2378
+ if (!Array.isArray(updates) || !updates.length) return;
2379
+
2380
+ let highestUpdateId = telegramConfig.lastUpdateId;
2381
+ for (const update of updates) {
2382
+ const updateId = Number(update?.update_id || 0);
2383
+ if (updateId > highestUpdateId) highestUpdateId = updateId;
2384
+ const message = update?.message;
2385
+ const chatId = String(message?.chat?.id || "");
2386
+ const userId = String(message?.from?.id || "");
2387
+ const text = String(message?.text || "").trim();
2388
+ if (!text) continue;
2389
+ if (text.startsWith("/start")) {
2390
+ if (!telegramConfig.pairingCode) {
2391
+ const generatedCode = generateTelegramPairingCode();
2392
+ await writeSettingsPatch({
2393
+ telegram_chat_id: chatId,
2394
+ telegram_user_id: userId,
2395
+ telegram_pairing_code: generatedCode,
2396
+ telegram_paired: false,
2397
+ telegram_last_update_id: updateId,
2398
+ });
2399
+ telegramConfig = parseTelegramConfig(await readSettings());
2400
+ } else if (!telegramConfig.chatId) {
2401
+ await writeSettingsPatch({
2402
+ telegram_chat_id: chatId,
2403
+ telegram_user_id: userId,
2404
+ telegram_last_update_id: updateId,
2405
+ });
2406
+ telegramConfig = parseTelegramConfig(await readSettings());
2407
+ }
2408
+ const discoveredMessage = telegramConfig.paired
2409
+ ? "OpenCore is connected. You can now send tasks while the CLI is running."
2410
+ : `Your Telegram user ID is: ${userId}\nYour OpenCore pairing code is: ${telegramConfig.pairingCode}\nGive both values to OpenCore in the terminal, dashboard, or chat so it can finish linking Telegram.`;
2411
+ await sendTelegramMessage(
2412
+ { ...telegramConfig, chatId: chatId || telegramConfig.chatId },
2413
+ discoveredMessage,
2414
+ );
2415
+ continue;
2416
+ }
2417
+ if (!telegramConfig.paired) continue;
2418
+ if (telegramConfig.userId && userId !== telegramConfig.userId) continue;
2419
+ if (telegramConfig.chatId && chatId !== telegramConfig.chatId) continue;
2420
+ const commandResult = await handleControlCommand(text, "telegram");
2421
+ if (commandResult.handled) {
2422
+ await sendTelegramMessage(telegramConfig, commandResult.text);
2423
+ continue;
2424
+ }
2425
+ queue.push({ text, source: "telegram" });
2426
+ }
2427
+
2428
+ if (highestUpdateId !== telegramConfig.lastUpdateId) {
2429
+ telegramConfig.lastUpdateId = highestUpdateId;
2430
+ await writeSettingsPatch({ telegram_last_update_id: highestUpdateId });
2431
+ }
2432
+ if (queue.length) void processQueue();
2433
+ } catch (error) {
2434
+ const msg = error instanceof Error ? error.message : String(error);
2435
+ console.warn(`[telegram] poll failed: ${msg}`);
2436
+ await appendMemory(`[telegram_error] ${msg}`);
2437
+ } finally {
2438
+ telegramTickRunning = false;
2439
+ }
2440
+ }
2441
+
2442
+ rl.setPrompt("> ");
2443
+ rl.prompt();
2444
+ managerTickTimer = setInterval(() => {
2445
+ void managerHeartbeatTick();
2446
+ }, MANAGER_HEARTBEAT_INTERVAL_MS);
2447
+ if (typeof managerTickTimer.unref === "function") managerTickTimer.unref();
2448
+ setTimeout(() => {
2449
+ void managerHeartbeatTick();
2450
+ }, 2000);
2451
+ if (telegramConfig.enabled) {
2452
+ startTelegramPolling();
2453
+ } else {
2454
+ console.log("Telegram: not configured");
2455
+ }
2456
+
2457
+ try {
2458
+ await new Promise<void>((resolve) => {
2459
+ rl.on("line", (raw) => {
2460
+ const line = raw.trim();
2461
+ if (!line) {
2462
+ if (!queueRunning && !exiting) rl.prompt();
2463
+ return;
2464
+ }
2465
+ if (line === "/exit" || line === "/quit") {
2466
+ void shutdown(0);
2467
+ resolve();
2468
+ return;
2469
+ }
2470
+ if (
2471
+ line === "/skills" ||
2472
+ line === "/schedules" ||
2473
+ line === "/check-heartbeat" ||
2474
+ line === "/telegram reconnect" ||
2475
+ line === "/telegram disconnect"
2476
+ ) {
2477
+ void (async () => {
2478
+ const result = await handleControlCommand(line, "terminal");
2479
+ if (result.handled && result.text) console.log(result.text);
2480
+ if (!queueRunning && !exiting) rl.prompt();
2481
+ })();
2482
+ return;
2483
+ }
2484
+ if (line.startsWith("/unschedule ")) {
2485
+ void (async () => {
2486
+ const scheduleId = line.slice("/unschedule ".length).trim();
2487
+ if (!scheduleId) {
2488
+ console.log("Usage: /unschedule <schedule-id>");
2489
+ if (!queueRunning && !exiting) rl.prompt();
2490
+ return;
2491
+ }
2492
+ const updated = await setScheduleRecordState(scheduleId, { active: false, last_status: "cancelled", last_error: "" });
2493
+ if (!updated) {
2494
+ console.log(`No schedule found for id: ${scheduleId}`);
2495
+ } else {
2496
+ await removeCronSchedule(scheduleId);
2497
+ console.log(`Removed schedule ${scheduleId}`);
2498
+ await appendMemory(`[system] unscheduled id=${scheduleId}`);
2499
+ }
2500
+ if (!queueRunning && !exiting) rl.prompt();
2501
+ })();
2502
+ return;
2503
+ }
2504
+ if (queueRunning) {
2505
+ console.log("OpenCore is still running. Please wait for it to finish.");
2506
+ rl.prompt();
2507
+ return;
2508
+ }
2509
+ queue.push({ text: line, source: "terminal" });
2510
+ void processQueue();
2511
+ });
2512
+ });
2513
+ } finally {
2514
+ if (!exiting) {
2515
+ await shutdown(0);
2516
+ }
2517
+ }
2518
+ }
2519
+
2520
+ main().catch((error) => {
2521
+ console.error(error);
2522
+ process.exit(1);
2523
+ });