arisa 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @module core/onboarding
3
+ * @role First-message onboarding: check CLIs and API keys, guide the user.
4
+ * @responsibilities
5
+ * - Detect installed CLIs (claude, codex) via Bun.which
6
+ * - Check OPENAI_API_KEY config
7
+ * - Track onboarded chats in runtime storage
8
+ * - Build platform-specific install instructions
9
+ * @dependencies shared/config
10
+ * @effects Reads/writes onboarded state in deepbase
11
+ */
12
+
13
+ import { config } from "../shared/config";
14
+ import { createLogger } from "../shared/logger";
15
+ import { getOnboardedUsers, addOnboarded as dbAddOnboarded, isOnboarded as dbIsOnboarded } from "../shared/db";
16
+
17
+ const log = createLogger("core");
18
+
19
+ let onboardedChats: Set<string> = new Set();
20
+
21
+ async function loadOnboarded() {
22
+ try {
23
+ const users = await getOnboardedUsers();
24
+ onboardedChats = new Set(users);
25
+ } catch (error) {
26
+ log.warn(`Failed to load onboarded state: ${error}`);
27
+ }
28
+ }
29
+
30
+ // Load immediately
31
+ loadOnboarded();
32
+
33
+ export interface DepsStatus {
34
+ claude: boolean;
35
+ codex: boolean;
36
+ openaiKey: boolean;
37
+ os: string;
38
+ }
39
+
40
+ export function checkDeps(): DepsStatus {
41
+ const os =
42
+ process.platform === "darwin"
43
+ ? "macOS"
44
+ : process.platform === "win32"
45
+ ? "Windows"
46
+ : "Linux";
47
+
48
+ return {
49
+ claude: Bun.which("claude") !== null,
50
+ codex: Bun.which("codex") !== null,
51
+ openaiKey: !!config.openaiApiKey,
52
+ os,
53
+ };
54
+ }
55
+
56
+ export function isOnboarded(chatId: string): boolean {
57
+ return onboardedChats.has(chatId);
58
+ }
59
+
60
+ export async function markOnboarded(chatId: string) {
61
+ onboardedChats.add(chatId);
62
+ await dbAddOnboarded(chatId);
63
+ }
64
+
65
+ /**
66
+ * Returns onboarding message for first-time users, or null if everything is set up.
67
+ * Only blocks if NO CLI is available at all.
68
+ */
69
+ export async function getOnboarding(chatId: string): Promise<{ message: string; blocking: boolean } | null> {
70
+ if (isOnboarded(chatId)) return null;
71
+
72
+ const deps = checkDeps();
73
+
74
+ // Everything set up — skip onboarding
75
+ if (deps.claude && deps.codex && deps.openaiKey) {
76
+ await markOnboarded(chatId);
77
+ return null;
78
+ }
79
+
80
+ // No CLI at all — block
81
+ if (!deps.claude && !deps.codex) {
82
+ const lines = [
83
+ "<b>Welcome to Arisa!</b>\n",
84
+ "Neither Claude CLI nor Codex CLI found. You need at least one.\n",
85
+ ];
86
+ if (deps.os === "macOS") {
87
+ lines.push("Claude: <code>brew install claude-code</code>");
88
+ } else {
89
+ lines.push("Claude: <code>npm install -g @anthropic-ai/claude-code</code>");
90
+ }
91
+ lines.push("Codex: <code>npm install -g @openai/codex</code>\n");
92
+ lines.push("Install one and message me again.");
93
+ return { message: lines.join("\n"), blocking: true };
94
+ }
95
+
96
+ // At least one CLI — inform and continue
97
+ await markOnboarded(chatId);
98
+
99
+ const using = deps.claude ? "Claude" : "Codex";
100
+ const lines = [`<b>Arisa</b> — using <b>${using}</b>`];
101
+
102
+ if (!deps.claude) {
103
+ lines.push("Claude CLI not installed. Add it with <code>npm install -g @anthropic-ai/claude-code</code>");
104
+ } else if (!deps.codex) {
105
+ lines.push("Codex CLI not installed. Add it with <code>npm install -g @openai/codex</code>");
106
+ } else {
107
+ lines.push("Use /codex or /claude to switch backend.");
108
+ }
109
+
110
+ if (!deps.openaiKey) {
111
+ lines.push("No OpenAI API key — voice and image processing disabled. Add <code>OPENAI_API_KEY</code> to <code>~/.arisa/.env</code> to enable.");
112
+ }
113
+
114
+ return { message: lines.join("\n"), blocking: false };
115
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @module core/processor
3
+ * @role Execute Claude CLI with model routing and conversation context.
4
+ * @responsibilities
5
+ * - Build claude CLI command with appropriate flags
6
+ * - Execute via async Bun.spawn (non-blocking)
7
+ * - Serialize calls through a queue (only one Claude at a time)
8
+ * - Handle errors and truncate responses
9
+ * @dependencies core/router, core/context, shared/config
10
+ * @effects Spawns claude CLI process, reads/writes conversation state
11
+ * @contract (message: string) => Promise<string>
12
+ */
13
+
14
+ import { selectModel } from "./router";
15
+ import { getRecentHistory } from "./history";
16
+ import { shouldContinue } from "./context";
17
+ import { config } from "../shared/config";
18
+ import { createLogger } from "../shared/logger";
19
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from "fs";
20
+ import { join } from "path";
21
+
22
+ const log = createLogger("core");
23
+ const ACTIVITY_LOG = join(config.logsDir, "activity.log");
24
+ const PROMPT_PREVIEW_MAX = 220;
25
+ export const CLAUDE_RATE_LIMIT_MESSAGE = "Claude is out of credits right now. Please try again in a few minutes.";
26
+
27
+ function logActivity(backend: string, model: string | null, durationMs: number, status: string) {
28
+ try {
29
+ if (!existsSync(config.logsDir)) mkdirSync(config.logsDir, { recursive: true });
30
+ const ts = new Date().toISOString();
31
+ const entry = { ts, backend, model, durationMs, status };
32
+ appendFileSync(ACTIVITY_LOG, JSON.stringify(entry) + "\n");
33
+ } catch {}
34
+ }
35
+ const SOUL_PATH = join(config.projectDir, "SOUL.md");
36
+
37
+ // Load SOUL.md once at startup — shared personality for all backends
38
+ let soulPrompt = "";
39
+ try {
40
+ if (existsSync(SOUL_PATH)) {
41
+ soulPrompt = readFileSync(SOUL_PATH, "utf8").trim();
42
+ log.info("SOUL.md loaded");
43
+ }
44
+ } catch (e) {
45
+ log.warn(`Failed to load SOUL.md: ${e}`);
46
+ }
47
+
48
+ function withSoul(message: string): string {
49
+ if (!soulPrompt) return message;
50
+ return `[System instructions]\n${soulPrompt}\n[End system instructions]\n\n${message}`;
51
+ }
52
+
53
+ function previewPrompt(input: string): string {
54
+ const compact = input.replace(/\s+/g, " ").trim();
55
+ if (!compact) return "(empty)";
56
+ return compact.length > PROMPT_PREVIEW_MAX
57
+ ? `${compact.slice(0, PROMPT_PREVIEW_MAX)}...`
58
+ : compact;
59
+ }
60
+
61
+ // Serialize Claude calls — only one at a time
62
+ // User messages have priority over task messages
63
+ type QueueSource = "user" | "task";
64
+ let processing = false;
65
+ const queue: Array<{
66
+ message: string;
67
+ chatId: string;
68
+ source: QueueSource;
69
+ resolve: (result: string) => void;
70
+ }> = [];
71
+
72
+ export async function processWithClaude(
73
+ message: string,
74
+ chatId: string,
75
+ source: QueueSource = "user",
76
+ ): Promise<string> {
77
+ return new Promise((resolve) => {
78
+ queue.push({ message, chatId, source, resolve });
79
+ processNext();
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Flush pending TASK queue items for a chat (resolve with empty string).
85
+ * Only flushes source:"task" items — never discards user messages.
86
+ */
87
+ export function flushChatQueue(chatId: string): number {
88
+ let flushed = 0;
89
+ for (let i = queue.length - 1; i >= 0; i--) {
90
+ if (queue[i].chatId === chatId && queue[i].source === "task") {
91
+ queue[i].resolve("");
92
+ queue.splice(i, 1);
93
+ flushed++;
94
+ }
95
+ }
96
+ if (flushed > 0) log.info(`Flushed ${flushed} task queue items for chat ${chatId}`);
97
+ return flushed;
98
+ }
99
+
100
+ async function processNext() {
101
+ if (processing || queue.length === 0) return;
102
+ processing = true;
103
+
104
+ // Pick user messages first, then task messages
105
+ const userIdx = queue.findIndex((q) => q.source === "user");
106
+ const idx = userIdx >= 0 ? userIdx : 0;
107
+ const [item] = queue.splice(idx, 1);
108
+ const { message, chatId, resolve } = item;
109
+
110
+ try {
111
+ const result = await runClaude(message, chatId);
112
+ resolve(result);
113
+ } catch (error) {
114
+ const msg = error instanceof Error ? error.message : String(error);
115
+ log.error(`Claude execution error: ${msg}`);
116
+ resolve(`Error: ${summarizeError(msg)}`);
117
+ } finally {
118
+ processing = false;
119
+ processNext();
120
+ }
121
+ }
122
+
123
+ async function runClaude(message: string, chatId: string): Promise<string> {
124
+ const model = selectModel(message);
125
+ const historyContext = getRecentHistory(chatId);
126
+ const start = Date.now();
127
+ const prompt = withSoul(historyContext + message);
128
+
129
+ const historyCount = historyContext ? historyContext.split("\nUser: ").length - 1 : 0;
130
+ log.info(`Model: ${model.model} (${model.reason}) | History: ${historyCount} exchanges`);
131
+
132
+ const args = ["--dangerously-skip-permissions"];
133
+
134
+ args.push("--model", model.model);
135
+ args.push("-p", prompt);
136
+
137
+ log.info(
138
+ `Claude send | promptChars: ${prompt.length} | preview: ${previewPrompt(prompt)}`
139
+ );
140
+ log.info(`Claude spawn | cmd: claude --dangerously-skip-permissions --model ${model.model} -p <prompt>`);
141
+ log.debug(`Claude prompt >>>>\n${prompt}\n<<<<`);
142
+
143
+ const proc = Bun.spawn(["claude", ...args], {
144
+ cwd: config.projectDir,
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ });
148
+
149
+ const timeout = setTimeout(() => {
150
+ log.warn(`Claude timed out after ${model.timeout}ms, killing process`);
151
+ proc.kill();
152
+ }, model.timeout);
153
+
154
+ const exitCode = await proc.exited;
155
+ clearTimeout(timeout);
156
+
157
+ const stdout = await new Response(proc.stdout).text();
158
+ const stderr = await new Response(proc.stderr).text();
159
+ const duration = Date.now() - start;
160
+
161
+ if (exitCode !== 0) {
162
+ const combined = stdout + stderr;
163
+ log.error(`Claude exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
164
+ logActivity("claude", model.model, duration, `error:${exitCode}`);
165
+ if (isRateLimit(combined)) {
166
+ return CLAUDE_RATE_LIMIT_MESSAGE;
167
+ }
168
+ return `Error (exit ${exitCode}): ${summarizeError(stderr || stdout)}`;
169
+ }
170
+
171
+ const response = stdout.trim();
172
+ logActivity("claude", model.model, duration, response ? "ok" : "empty");
173
+ log.info(`Claude recv | ${duration}ms | responseChars: ${response.length} | preview: ${previewPrompt(response)}`);
174
+ log.debug(`Claude response >>>>\n${response}\n<<<<`);
175
+
176
+ if (!response) {
177
+ log.warn("Claude returned empty response");
178
+ return "Claude returned an empty response.";
179
+ }
180
+
181
+ if (response.length > config.maxResponseLength) {
182
+ return response.substring(0, config.maxResponseLength - 100) + "\n\n[Response truncated...]";
183
+ }
184
+
185
+ return response;
186
+ }
187
+
188
+ export async function processWithCodex(message: string): Promise<string> {
189
+ const continueFlag = shouldContinue();
190
+ const start = Date.now();
191
+
192
+ log.info(`Codex | Continue: ${continueFlag}`);
193
+
194
+ const args: string[] = [];
195
+
196
+ if (continueFlag) {
197
+ args.push("exec", "resume", "--last", "--dangerously-bypass-approvals-and-sandbox");
198
+ } else {
199
+ args.push("exec", "--dangerously-bypass-approvals-and-sandbox", "-C", config.projectDir);
200
+ }
201
+
202
+ args.push(message);
203
+
204
+ log.info(
205
+ `Codex send | promptChars: ${message.length} | preview: ${previewPrompt(message)}`
206
+ );
207
+ log.info(
208
+ `Codex spawn | cmd: codex ${continueFlag
209
+ ? "exec resume --last --dangerously-bypass-approvals-and-sandbox <prompt>"
210
+ : `exec --dangerously-bypass-approvals-and-sandbox -C ${config.projectDir} <prompt>`}`
211
+ );
212
+ log.debug(`Codex prompt >>>>\n${message}\n<<<<`);
213
+
214
+ const proc = Bun.spawn(["codex", ...args], {
215
+ cwd: config.projectDir,
216
+ stdout: "pipe",
217
+ stderr: "pipe",
218
+ });
219
+
220
+ const timeout = setTimeout(() => {
221
+ log.warn("Codex timed out after 180s, killing process");
222
+ proc.kill();
223
+ }, 180_000);
224
+
225
+ const exitCode = await proc.exited;
226
+ clearTimeout(timeout);
227
+
228
+ const stdout = await new Response(proc.stdout).text();
229
+ const stderr = await new Response(proc.stderr).text();
230
+ const duration = Date.now() - start;
231
+
232
+ if (exitCode !== 0) {
233
+ log.error(`Codex exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
234
+ logActivity("codex", null, duration, `error:${exitCode}`);
235
+ return "Error processing with Codex. Please try again.";
236
+ }
237
+
238
+ const response = stdout.trim();
239
+ logActivity("codex", null, duration, response ? "ok" : "empty");
240
+ log.info(`Codex recv | ${duration}ms | responseChars: ${response.length} | preview: ${previewPrompt(response)}`);
241
+ log.debug(`Codex response >>>>\n${response}\n<<<<`);
242
+
243
+ if (!response) {
244
+ log.warn("Codex returned empty response");
245
+ return "Codex returned an empty response.";
246
+ }
247
+
248
+ if (response.length > config.maxResponseLength) {
249
+ return response.substring(0, config.maxResponseLength - 100) + "\n\n[Response truncated...]";
250
+ }
251
+
252
+ return response;
253
+ }
254
+
255
+ export function isClaudeRateLimitResponse(text: string): boolean {
256
+ return text.trim() === CLAUDE_RATE_LIMIT_MESSAGE;
257
+ }
258
+
259
+ function summarizeError(raw: string): string {
260
+ const clean = raw.replace(/\s+/g, " ").trim();
261
+ if (!clean) return "process ended without details.";
262
+ // Cap at 200 chars for Telegram readability
263
+ return clean.length > 200 ? clean.slice(0, 200) + "..." : clean;
264
+ }
265
+
266
+ function isRateLimit(output: string): boolean {
267
+ return /you'?ve hit your limit|rate limit|quota|credits.*(exceeded|exhausted)/i.test(output);
268
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @module core/router
3
+ * @role Decide which Claude model to use based on message complexity.
4
+ * @responsibilities
5
+ * - Analyze incoming message text for complexity patterns
6
+ * - Return model ID, timeout, and reason for logging
7
+ * @dependencies shared/types
8
+ * @effects None (pure function)
9
+ * @contract (message: string) => ModelConfig
10
+ */
11
+
12
+ import type { ModelConfig } from "../shared/types";
13
+
14
+ const HAIKU_PATTERNS = [
15
+ /^\s*\/reset\s*$/i,
16
+ /^\s*\S{1,12}\s*[.!]?\s*$/i, // Single short word replies (ok, yes, thanks, dale, etc.)
17
+ ];
18
+
19
+ const OPUS_PATTERNS = [
20
+ /\b(debug|fix|bug|error|refactor|deploy|architect|migration)\b/i,
21
+ /\b(code|file|function|class|module|component|endpoint|api)\b/i,
22
+ /```[\s\S]*```/,
23
+ ];
24
+
25
+ // Recency-aware state: prevent haiku downgrade during active conversations
26
+ const RECENCY_WINDOW = 5 * 60 * 1000; // 5 minutes
27
+ let lastModel: string | null = null;
28
+ let lastCallAt = 0;
29
+
30
+ export function resetRouterState(): void {
31
+ lastModel = null;
32
+ lastCallAt = 0;
33
+ }
34
+
35
+ export function selectModel(message: string): ModelConfig {
36
+ const stripped = message.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
37
+
38
+ let candidate: ModelConfig;
39
+
40
+ const isHaiku = HAIKU_PATTERNS.some((p) => p.test(stripped));
41
+ const isOpus = OPUS_PATTERNS.some((p) => p.test(stripped) || p.test(message));
42
+
43
+ if (isHaiku) {
44
+ candidate = { model: "haiku", timeout: 30_000, reason: "simple/acknowledgment" };
45
+ } else if (isOpus) {
46
+ candidate = { model: "opus", timeout: 180_000, reason: "code/complex task" };
47
+ } else {
48
+ candidate = { model: "sonnet", timeout: 120_000, reason: "general conversation" };
49
+ }
50
+
51
+ // Don't downgrade to haiku if there's a recent conversation on a higher model
52
+ if (
53
+ candidate.model === "haiku" &&
54
+ lastModel &&
55
+ lastModel !== "haiku" &&
56
+ Date.now() - lastCallAt < RECENCY_WINDOW
57
+ ) {
58
+ candidate = { model: lastModel, timeout: lastModel === "opus" ? 180_000 : 120_000, reason: "keeping context (was: simple/acknowledgment)" };
59
+ }
60
+
61
+ lastModel = candidate.model;
62
+ lastCallAt = Date.now();
63
+ return candidate;
64
+ }
@@ -0,0 +1,192 @@
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:${config.daemonPort}/send`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ chatId: task.chatId,
67
+ text: result,
68
+ }),
69
+ });
70
+ if (!response.ok) {
71
+ log.error(`Daemon returned ${response.status} for task ${task.id}`);
72
+ }
73
+ } catch (error) {
74
+ log.error(`Failed to execute task ${task.id}: ${error}`);
75
+ } finally {
76
+ inFlight.delete(task.id);
77
+ }
78
+ }
79
+
80
+ function scheduleTask(task: ScheduledTask) {
81
+ if (task.type === "once") {
82
+ if (task.status === "done") return;
83
+ const delay = (task.runAt || 0) - Date.now();
84
+ if (delay <= 0) {
85
+ executeTask(task).then(async () => {
86
+ task.status = "done";
87
+ task.lastRunAt = Date.now();
88
+ await saveTask(task);
89
+ });
90
+ return;
91
+ }
92
+ const timer = setTimeout(() => {
93
+ executeTask(task).then(async () => {
94
+ task.status = "done";
95
+ task.lastRunAt = Date.now();
96
+ activeJobs.delete(task.id);
97
+ await saveTask(task);
98
+ });
99
+ }, delay);
100
+ activeJobs.set(task.id, timer);
101
+ log.info(`Scheduled one-time task ${task.id} in ${Math.round(delay / 1000)}s`);
102
+ } else if (task.type === "cron" && task.cron) {
103
+ const job = new Cron(task.cron, async () => {
104
+ task.lastRunAt = Date.now();
105
+ await saveTask(task);
106
+ executeTask(task);
107
+ });
108
+ activeJobs.set(task.id, job);
109
+ log.info(`Scheduled cron task ${task.id}: ${task.cron}`);
110
+ }
111
+ }
112
+
113
+ export async function initScheduler() {
114
+ tasks = await loadTasks();
115
+ log.info(`Loaded ${tasks.length} tasks`);
116
+
117
+ // Clean old completed one-time tasks (> 7 days)
118
+ const retentionMs = 7 * 24 * 60 * 60 * 1000;
119
+ const now = Date.now();
120
+
121
+ const tasksToDelete: string[] = [];
122
+ tasks = tasks.filter((t) => {
123
+ if (t.type === "once" && t.status === "done") {
124
+ const shouldKeep = now - (t.lastRunAt || t.runAt || now) < retentionMs;
125
+ if (!shouldKeep) {
126
+ tasksToDelete.push(t.id);
127
+ }
128
+ return shouldKeep;
129
+ }
130
+ return true;
131
+ });
132
+
133
+ // Delete old completed tasks from db
134
+ for (const id of tasksToDelete) {
135
+ await deleteTask(id);
136
+ }
137
+
138
+ const activeCron = tasks.filter((t) => t.type === "cron");
139
+ if (activeCron.length > 0) {
140
+ log.info(`Restoring ${activeCron.length} cron tasks: ${activeCron.map((t) => `"${t.message}" (${t.cron})`).join(", ")} — send /cancel to stop`);
141
+ }
142
+
143
+ for (const task of tasks) {
144
+ scheduleTask(task);
145
+ }
146
+ }
147
+
148
+ export async function addTask(task: ScheduledTask) {
149
+ // Deduplicate: if a cron with the same message already exists for this chat, skip
150
+ if (task.type === "cron") {
151
+ const duplicate = tasks.find(
152
+ (t) => t.chatId === task.chatId && t.type === "cron" && t.message === task.message,
153
+ );
154
+ if (duplicate) {
155
+ log.info(`Skipping duplicate cron task for chat ${task.chatId}: "${task.message}"`);
156
+ return;
157
+ }
158
+ }
159
+
160
+ tasks.push(task);
161
+ scheduleTask(task);
162
+ await dbAddTask(task);
163
+ log.info(`Added task ${task.id} (${task.type})`);
164
+ }
165
+
166
+ export async function cancelAllChatTasks(chatId: string): Promise<number> {
167
+ let removed = 0;
168
+ for (let i = tasks.length - 1; i >= 0; i -= 1) {
169
+ const task = tasks[i];
170
+ if (task.chatId !== chatId) continue;
171
+ // Skip already-completed one-time tasks
172
+ if (task.type === "once" && task.status === "done") continue;
173
+
174
+ const job = activeJobs.get(task.id);
175
+ if (job && "stop" in job && typeof job.stop === "function") {
176
+ job.stop();
177
+ } else if (job) {
178
+ clearTimeout(job as ReturnType<typeof setTimeout>);
179
+ }
180
+ activeJobs.delete(task.id);
181
+ inFlight.delete(task.id);
182
+ await deleteTask(task.id);
183
+ tasks.splice(i, 1);
184
+ removed += 1;
185
+ }
186
+
187
+ // Flush any queued processWithClaude calls for this chat
188
+ flushChatQueue(chatId);
189
+
190
+ log.info(`Cancelled ${removed} tasks for chat ${chatId}`);
191
+ return removed;
192
+ }