arisa 2.3.16 → 2.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @module core/scheduler
3
+ * @role Manage cron and one-time scheduled tasks using croner.
4
+ * @responsibilities
5
+ * - Persist tasks to tasks.json, restore on startup
6
+ * - Execute tasks by processing through Claude, then sending result via Daemon
7
+ * - Schedule one-time (setTimeout) and recurring (croner) tasks
8
+ * @dependencies croner, shared/config, shared/types
9
+ * @effects Disk I/O (tasks.json), network (POST to Daemon), timers
10
+ */
11
+
12
+ import { Cron } from "croner";
13
+ import { config } from "../shared/config";
14
+ import { createLogger } from "../shared/logger";
15
+ import { getTasks, updateTask, deleteTask, addTask as dbAddTask } from "../shared/db";
16
+ import { processWithClaude, flushChatQueue } from "./processor";
17
+ import type { ScheduledTask } from "../shared/types";
18
+
19
+ const log = createLogger("scheduler");
20
+
21
+ let tasks: ScheduledTask[] = [];
22
+ const activeJobs = new Map<string, Cron | ReturnType<typeof setTimeout>>();
23
+ const inFlight = new Set<string>(); // task IDs currently executing
24
+
25
+ async function loadTasks(): Promise<ScheduledTask[]> {
26
+ try {
27
+ return await getTasks();
28
+ } catch (error) {
29
+ log.error(`Failed to load tasks from db: ${error}`);
30
+ return [];
31
+ }
32
+ }
33
+
34
+ async function saveTask(task: ScheduledTask) {
35
+ try {
36
+ await updateTask(task.id, task);
37
+ } catch (error) {
38
+ log.error(`Failed to save task ${task.id}: ${error}`);
39
+ }
40
+ }
41
+
42
+ async function executeTask(task: ScheduledTask) {
43
+ // Skip if previous execution is still in-flight (prevents queue buildup)
44
+ if (inFlight.has(task.id)) {
45
+ log.info(`Skipping task ${task.id}: previous execution still in-flight`);
46
+ return;
47
+ }
48
+
49
+ // Check task is still active (may have been cancelled)
50
+ if (!tasks.includes(task)) return;
51
+
52
+ log.info(`Executing task ${task.id}: ${task.message.substring(0, 60)}`);
53
+ inFlight.add(task.id);
54
+ try {
55
+ // Process through Claude to get a real response (source:"task" = low priority)
56
+ const result = await processWithClaude(task.message, task.chatId, "task");
57
+
58
+ // Re-check: task may have been cancelled while Claude was processing
59
+ if (!tasks.includes(task) || !result) return;
60
+
61
+ // Send the processed result to Telegram via Daemon
62
+ const response = await fetch("http://localhost/send", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ chatId: task.chatId,
67
+ text: result,
68
+ }),
69
+ unix: config.daemonSocket,
70
+ } as any);
71
+ if (!response.ok) {
72
+ log.error(`Daemon returned ${response.status} for task ${task.id}`);
73
+ }
74
+ } catch (error) {
75
+ log.error(`Failed to execute task ${task.id}: ${error}`);
76
+ } finally {
77
+ inFlight.delete(task.id);
78
+ }
79
+ }
80
+
81
+ function scheduleTask(task: ScheduledTask) {
82
+ if (task.type === "once") {
83
+ if (task.status === "done") return;
84
+ const delay = (task.runAt || 0) - Date.now();
85
+ if (delay <= 0) {
86
+ executeTask(task).then(async () => {
87
+ task.status = "done";
88
+ task.lastRunAt = Date.now();
89
+ await saveTask(task);
90
+ });
91
+ return;
92
+ }
93
+ const timer = setTimeout(() => {
94
+ executeTask(task).then(async () => {
95
+ task.status = "done";
96
+ task.lastRunAt = Date.now();
97
+ activeJobs.delete(task.id);
98
+ await saveTask(task);
99
+ });
100
+ }, delay);
101
+ activeJobs.set(task.id, timer);
102
+ log.info(`Scheduled one-time task ${task.id} in ${Math.round(delay / 1000)}s`);
103
+ } else if (task.type === "cron" && task.cron) {
104
+ const job = new Cron(task.cron, async () => {
105
+ task.lastRunAt = Date.now();
106
+ await saveTask(task);
107
+ executeTask(task);
108
+ });
109
+ activeJobs.set(task.id, job);
110
+ log.info(`Scheduled cron task ${task.id}: ${task.cron}`);
111
+ }
112
+ }
113
+
114
+ export async function initScheduler() {
115
+ tasks = await loadTasks();
116
+ log.info(`Loaded ${tasks.length} tasks`);
117
+
118
+ // Clean old completed one-time tasks (> 7 days)
119
+ const retentionMs = 7 * 24 * 60 * 60 * 1000;
120
+ const now = Date.now();
121
+
122
+ const tasksToDelete: string[] = [];
123
+ tasks = tasks.filter((t) => {
124
+ if (t.type === "once" && t.status === "done") {
125
+ const shouldKeep = now - (t.lastRunAt || t.runAt || now) < retentionMs;
126
+ if (!shouldKeep) {
127
+ tasksToDelete.push(t.id);
128
+ }
129
+ return shouldKeep;
130
+ }
131
+ return true;
132
+ });
133
+
134
+ // Delete old completed tasks from db
135
+ for (const id of tasksToDelete) {
136
+ await deleteTask(id);
137
+ }
138
+
139
+ const activeCron = tasks.filter((t) => t.type === "cron");
140
+ if (activeCron.length > 0) {
141
+ log.info(`Restoring ${activeCron.length} cron tasks: ${activeCron.map((t) => `"${t.message}" (${t.cron})`).join(", ")} — send /cancel to stop`);
142
+ }
143
+
144
+ for (const task of tasks) {
145
+ scheduleTask(task);
146
+ }
147
+ }
148
+
149
+ export async function addTask(task: ScheduledTask) {
150
+ // Deduplicate: if a cron with the same message already exists for this chat, skip
151
+ if (task.type === "cron") {
152
+ const duplicate = tasks.find(
153
+ (t) => t.chatId === task.chatId && t.type === "cron" && t.message === task.message,
154
+ );
155
+ if (duplicate) {
156
+ log.info(`Skipping duplicate cron task for chat ${task.chatId}: "${task.message}"`);
157
+ return;
158
+ }
159
+ }
160
+
161
+ tasks.push(task);
162
+ scheduleTask(task);
163
+ await dbAddTask(task);
164
+ log.info(`Added task ${task.id} (${task.type})`);
165
+ }
166
+
167
+ export async function cancelAllChatTasks(chatId: string): Promise<number> {
168
+ let removed = 0;
169
+ for (let i = tasks.length - 1; i >= 0; i -= 1) {
170
+ const task = tasks[i];
171
+ if (task.chatId !== chatId) continue;
172
+ // Skip already-completed one-time tasks
173
+ if (task.type === "once" && task.status === "done") continue;
174
+
175
+ const job = activeJobs.get(task.id);
176
+ if (job && "stop" in job && typeof job.stop === "function") {
177
+ job.stop();
178
+ } else if (job) {
179
+ clearTimeout(job as ReturnType<typeof setTimeout>);
180
+ }
181
+ activeJobs.delete(task.id);
182
+ inFlight.delete(task.id);
183
+ await deleteTask(task.id);
184
+ tasks.splice(i, 1);
185
+ removed += 1;
186
+ }
187
+
188
+ // Flush any queued processWithClaude calls for this chat
189
+ flushChatQueue(chatId);
190
+
191
+ log.info(`Cancelled ${removed} tasks for chat ${chatId}`);
192
+ return removed;
193
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @module daemon/agent-cli
3
+ * @role Run AI CLI commands directly from Daemon with local fallback order.
4
+ * @responsibilities
5
+ * - Detect available CLIs (Claude, Codex)
6
+ * - Execute prompt with timeout
7
+ * - Fallback from Claude to Codex when needed
8
+ * @dependencies shared/config
9
+ * @effects Spawns external CLI processes
10
+ */
11
+
12
+ import { config } from "../shared/config";
13
+ import { createLogger } from "../shared/logger";
14
+ import {
15
+ buildBunWrappedAgentCliCommand,
16
+ resolveAgentCliPath,
17
+ type AgentCliName,
18
+ } from "../shared/ai-cli";
19
+
20
+ const log = createLogger("daemon");
21
+
22
+ export type AgentCli = AgentCliName;
23
+
24
+ export interface CliExecutionResult {
25
+ cli: AgentCli;
26
+ output: string;
27
+ stderr: string;
28
+ exitCode: number;
29
+ partial: boolean;
30
+ }
31
+
32
+ export interface CliFallbackOutcome {
33
+ result: CliExecutionResult | null;
34
+ attempted: AgentCli[];
35
+ failures: string[];
36
+ }
37
+
38
+ export function getAvailableAgentCli(): AgentCli[] {
39
+ const order: AgentCli[] = [];
40
+ if (resolveAgentCliPath("claude") !== null) order.push("claude");
41
+ if (resolveAgentCliPath("codex") !== null) order.push("codex");
42
+ return order;
43
+ }
44
+
45
+ export function getAgentCliLabel(cli: AgentCli): string {
46
+ return cli === "claude" ? "Claude" : "Codex";
47
+ }
48
+
49
+ function buildCommand(cli: AgentCli, prompt: string): string[] {
50
+ if (cli === "claude") {
51
+ return buildBunWrappedAgentCliCommand(
52
+ "claude",
53
+ ["--dangerously-skip-permissions", "--model", "sonnet", "-p", prompt],
54
+ );
55
+ }
56
+ return buildBunWrappedAgentCliCommand(
57
+ "codex",
58
+ ["exec", "--dangerously-bypass-approvals-and-sandbox", "-C", config.projectDir, prompt],
59
+ );
60
+ }
61
+
62
+ async function runSingleCli(
63
+ cli: AgentCli,
64
+ prompt: string,
65
+ timeoutMs: number,
66
+ ): Promise<Omit<CliExecutionResult, "partial">> {
67
+ const cmd = buildCommand(cli, prompt);
68
+ log.info(`Daemon AI: trying ${getAgentCliLabel(cli)} CLI`);
69
+
70
+ const proc = Bun.spawn(cmd, {
71
+ cwd: config.projectDir,
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ env: { ...process.env },
75
+ });
76
+
77
+ const timeout = setTimeout(() => proc.kill(), timeoutMs);
78
+ const stdoutPromise = new Response(proc.stdout).text();
79
+ const stderrPromise = new Response(proc.stderr).text();
80
+ const exitCode = await proc.exited;
81
+ clearTimeout(timeout);
82
+
83
+ const [output, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
84
+ return { cli, output, stderr, exitCode };
85
+ }
86
+
87
+ export async function runWithCliFallback(prompt: string, timeoutMs: number): Promise<CliFallbackOutcome> {
88
+ const candidates = getAvailableAgentCli();
89
+ const attempted: AgentCli[] = [];
90
+ const failures: string[] = [];
91
+ let partial: CliExecutionResult | null = null;
92
+
93
+ for (const cli of candidates) {
94
+ attempted.push(cli);
95
+
96
+ try {
97
+ const result = await runSingleCli(cli, prompt, timeoutMs);
98
+ const output = result.output.trim();
99
+
100
+ if (result.exitCode === 0 && output) {
101
+ return {
102
+ result: { ...result, output, partial: false },
103
+ attempted,
104
+ failures,
105
+ };
106
+ }
107
+
108
+ if (result.exitCode !== 0 && output && partial === null) {
109
+ partial = { ...result, output, partial: true };
110
+ }
111
+
112
+ const reason = result.exitCode === 0
113
+ ? "empty output"
114
+ : `exit=${result.exitCode}: ${summarizeError(result.stderr || result.output)}`;
115
+ failures.push(`${getAgentCliLabel(cli)} ${reason}`);
116
+ } catch (error) {
117
+ const msg = error instanceof Error ? error.message : String(error);
118
+ failures.push(`${getAgentCliLabel(cli)} error: ${summarizeError(msg)}`);
119
+ }
120
+ }
121
+
122
+ return { result: partial, attempted, failures };
123
+ }
124
+
125
+ function summarizeError(raw: string): string {
126
+ const clean = raw.replace(/\s+/g, " ").trim();
127
+ if (!clean) return "no details";
128
+ return clean.length > 200 ? `${clean.slice(0, 200)}...` : clean;
129
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @module daemon/auto-install
3
+ * @role Auto-install missing AI CLIs at daemon startup.
4
+ * @responsibilities
5
+ * - Check which CLIs (claude, codex) are missing
6
+ * - Attempt `bun add -g <package>` for each missing CLI
7
+ * - Log results, notify chats on success
8
+ * @dependencies shared/ai-cli
9
+ * @effects Spawns bun install processes, modifies global packages
10
+ */
11
+
12
+ import { createLogger } from "../shared/logger";
13
+ import { isAgentCliInstalled, buildBunWrappedAgentCliCommand, type AgentCliName } from "../shared/ai-cli";
14
+
15
+ const log = createLogger("daemon");
16
+
17
+ const CLI_PACKAGES: Record<AgentCliName, string> = {
18
+ claude: "@anthropic-ai/claude-code",
19
+ codex: "@openai/codex",
20
+ };
21
+
22
+ const INSTALL_TIMEOUT = 120_000; // 2min
23
+
24
+ type NotifyFn = (text: string) => Promise<void>;
25
+ let notifyFn: NotifyFn | null = null;
26
+
27
+ export function setAutoInstallNotify(fn: NotifyFn) {
28
+ notifyFn = fn;
29
+ }
30
+
31
+ async function installCli(cli: AgentCliName): Promise<boolean> {
32
+ const pkg = CLI_PACKAGES[cli];
33
+ log.info(`Auto-install: installing ${cli} (${pkg})...`);
34
+
35
+ try {
36
+ const proc = Bun.spawn(["bun", "add", "-g", pkg], {
37
+ stdout: "pipe",
38
+ stderr: "pipe",
39
+ env: { ...process.env },
40
+ });
41
+
42
+ const timeout = setTimeout(() => proc.kill(), INSTALL_TIMEOUT);
43
+ const exitCode = await proc.exited;
44
+ clearTimeout(timeout);
45
+
46
+ if (exitCode === 0) {
47
+ log.info(`Auto-install: ${cli} installed successfully`);
48
+ return true;
49
+ } else {
50
+ const stderr = await new Response(proc.stderr).text();
51
+ log.error(`Auto-install: ${cli} install failed (exit ${exitCode}): ${stderr.slice(0, 300)}`);
52
+ return false;
53
+ }
54
+ } catch (error) {
55
+ log.error(`Auto-install: ${cli} install error: ${error}`);
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export async function autoInstallMissingClis(): Promise<void> {
61
+ const missing: AgentCliName[] = [];
62
+
63
+ for (const cli of Object.keys(CLI_PACKAGES) as AgentCliName[]) {
64
+ if (!isAgentCliInstalled(cli)) {
65
+ missing.push(cli);
66
+ }
67
+ }
68
+
69
+ if (missing.length === 0) {
70
+ log.info("Auto-install: all CLIs already installed");
71
+ return;
72
+ }
73
+
74
+ log.info(`Auto-install: missing CLIs: ${missing.join(", ")}`);
75
+
76
+ const installed: string[] = [];
77
+ for (const cli of missing) {
78
+ const ok = await installCli(cli);
79
+ if (ok) installed.push(cli);
80
+ }
81
+
82
+ if (installed.length > 0) {
83
+ const msg = `Auto-installed: <b>${installed.join(", ")}</b>`;
84
+ log.info(msg);
85
+ await notifyFn?.(msg).catch((e) => log.error(`Auto-install notify failed: ${e}`));
86
+ }
87
+
88
+ // After install, probe auth for all installed CLIs
89
+ await probeCliAuth();
90
+ }
91
+
92
+ type AuthProbeFn = (cli: AgentCliName, errorText: string) => void;
93
+ let authProbeFn: AuthProbeFn | null = null;
94
+
95
+ export function setAuthProbeCallback(fn: AuthProbeFn) {
96
+ authProbeFn = fn;
97
+ }
98
+
99
+ const PROBE_TIMEOUT = 15_000;
100
+
101
+ /**
102
+ * Run a minimal command with each installed CLI to detect auth errors early.
103
+ * Uses a real API call (cheap haiku / short exec) so auth errors surface.
104
+ * When an auth error is found, the callback triggers the appropriate login flow.
105
+ */
106
+ export async function probeCliAuth(): Promise<void> {
107
+ for (const cli of ["claude", "codex"] as AgentCliName[]) {
108
+ if (!isAgentCliInstalled(cli)) continue;
109
+
110
+ log.info(`Auth probe: testing ${cli}...`);
111
+ if (cli === "claude") {
112
+ const hasToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
113
+ const tokenPreview = hasToken ? `${process.env.CLAUDE_CODE_OAUTH_TOKEN!.slice(0, 15)}...` : "NOT SET";
114
+ log.info(`Auth probe: CLAUDE_CODE_OAUTH_TOKEN=${tokenPreview}`);
115
+ }
116
+ try {
117
+ const args = cli === "claude"
118
+ ? ["-p", "say ok", "--model", "haiku", "--output-format", "text", "--dangerously-skip-permissions"]
119
+ : ["exec", "--dangerously-bypass-approvals-and-sandbox", "echo ok"];
120
+
121
+ const cmd = buildBunWrappedAgentCliCommand(cli, args);
122
+ log.info(`Auth probe cmd: ${cmd.map(c => c.length > 80 ? c.slice(0, 80) + "..." : c).join(" ")}`);
123
+
124
+ const proc = Bun.spawn(cmd, {
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ env: { ...process.env },
128
+ });
129
+
130
+ const timeout = setTimeout(() => proc.kill(), PROBE_TIMEOUT);
131
+ const exitCode = await proc.exited;
132
+ clearTimeout(timeout);
133
+
134
+ const stdout = await new Response(proc.stdout).text();
135
+ const stderr = await new Response(proc.stderr).text();
136
+
137
+ if (exitCode === 0) {
138
+ log.info(`Auth probe: ${cli} authenticated OK`);
139
+ } else {
140
+ const combined = stdout + "\n" + stderr;
141
+ log.warn(`Auth probe: ${cli} failed (exit ${exitCode}): ${combined.slice(0, 200)}`);
142
+ authProbeFn?.(cli, combined);
143
+ }
144
+ } catch (e) {
145
+ log.error(`Auth probe: ${cli} error: ${e}`);
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @module daemon/autofix
3
+ * @role Auto-diagnose and fix Core crashes using available AI CLI.
4
+ * @responsibilities
5
+ * - Spawn Claude/Codex CLI to analyze crash errors and edit code
6
+ * - Rate-limit attempts (cooldown + max attempts)
7
+ * - Notify via callback (Telegram)
8
+ * @dependencies shared/config
9
+ * @effects Spawns AI CLI process which may edit project files
10
+ */
11
+
12
+ import { config } from "../shared/config";
13
+ import { createLogger } from "../shared/logger";
14
+ import { getAgentCliLabel, runWithCliFallback } from "./agent-cli";
15
+
16
+ const log = createLogger("daemon");
17
+
18
+ let lastAttemptAt = 0;
19
+ let attemptCount = 0;
20
+
21
+ const COOLDOWN_MS = 120_000; // 2min between batches
22
+ const MAX_ATTEMPTS = 3;
23
+ const AUTOFIX_TIMEOUT = 180_000; // 3min for autofix
24
+
25
+ type NotifyFn = (text: string) => Promise<void>;
26
+ let notifyFn: NotifyFn | null = null;
27
+
28
+ export function setAutoFixNotify(fn: NotifyFn) {
29
+ notifyFn = fn;
30
+ }
31
+
32
+ /**
33
+ * Attempt to auto-fix a Core crash. Returns true if any CLI produced a usable result.
34
+ */
35
+ export async function attemptAutoFix(error: string): Promise<boolean> {
36
+ const now = Date.now();
37
+
38
+ // Reset attempts after cooldown
39
+ if (now - lastAttemptAt > COOLDOWN_MS) {
40
+ attemptCount = 0;
41
+ }
42
+
43
+ if (attemptCount >= MAX_ATTEMPTS) {
44
+ log.warn(`Auto-fix: max attempts (${MAX_ATTEMPTS}) reached, waiting for cooldown`);
45
+ return false;
46
+ }
47
+
48
+ attemptCount++;
49
+ lastAttemptAt = now;
50
+
51
+ log.info(`Auto-fix: attempt ${attemptCount}/${MAX_ATTEMPTS}`);
52
+ await notifyFn?.(`Auto-fix: intento ${attemptCount}/${MAX_ATTEMPTS}. Analizando error...`);
53
+
54
+ // Extract file paths from the error to help the fallback model focus
55
+ const projectPathPattern = new RegExp(`${escapeRegExp(config.projectDir)}[^\\s:)]+`, "g");
56
+ const fileRefs = error.match(projectPathPattern) || [];
57
+ const uniqueFiles = [...new Set(fileRefs)].slice(0, 5);
58
+ const fileHint = uniqueFiles.length > 0
59
+ ? `\nKey files from the stack trace: ${uniqueFiles.join(", ")}`
60
+ : "";
61
+
62
+ const prompt = `Arisa Core error on startup. Fix it.
63
+
64
+ Error:
65
+ \`\`\`
66
+ ${error.slice(-1500)}
67
+ \`\`\`
68
+ ${fileHint}
69
+
70
+ Rules:
71
+ - If it's a corrupted JSON/data file: delete or recreate it
72
+ - If it's a bad import: fix the import
73
+ - If it's a code bug: fix the minimal code
74
+ - Do NOT refactor, improve, or change anything beyond the fix
75
+ - Be fast — read only the files mentioned in the error`;
76
+
77
+ try {
78
+ const outcome = await runWithCliFallback(prompt, AUTOFIX_TIMEOUT);
79
+ const result = outcome.result;
80
+
81
+ if (!result) {
82
+ if (outcome.attempted.length === 0) {
83
+ log.error("Auto-fix: no AI CLI available (claude/codex)");
84
+ await notifyFn?.("Auto-fix: no hay CLI disponible (Claude/Codex).");
85
+ } else {
86
+ const detail = outcome.failures.join(" | ").slice(0, 400);
87
+ log.error(`Auto-fix: all CLIs failed: ${detail}`);
88
+ await notifyFn?.("Auto-fix: both Claude and Codex failed. Check the logs.");
89
+ }
90
+ return false;
91
+ }
92
+
93
+ const cli = getAgentCliLabel(result.cli);
94
+ const summary = result.output.slice(0, 300);
95
+ if (result.partial) {
96
+ log.warn(`Auto-fix: ${cli} produced output but exited with code ${result.exitCode}`);
97
+ } else {
98
+ log.info(`Auto-fix: ${cli} completed successfully`);
99
+ }
100
+
101
+ await notifyFn?.(`Auto-fix applied with ${cli}. Core restarting...\n<pre>${escapeHtml(summary)}</pre>`);
102
+ return true;
103
+ } catch (err) {
104
+ log.error(`Auto-fix: error: ${err}`);
105
+ await notifyFn?.("Auto-fix: internal error. Check the logs.");
106
+ return false;
107
+ }
108
+ }
109
+
110
+ function escapeHtml(s: string): string {
111
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
112
+ }
113
+
114
+ function escapeRegExp(s: string): string {
115
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
116
+ }