arisa 2.3.16 → 2.3.18

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,144 @@
1
+ /**
2
+ * @module core/media
3
+ * @role Handle voice transcription (Whisper), image analysis (Vision), and speech synthesis (ElevenLabs).
4
+ * @responsibilities
5
+ * - Transcribe audio buffers via OpenAI Whisper API
6
+ * - Describe images via OpenAI Vision API
7
+ * - Generate speech from text via ElevenLabs API
8
+ * - Manage temp files for audio processing
9
+ * @dependencies shared/config
10
+ * @effects Network calls to OpenAI API and ElevenLabs API, temp file I/O in runtime voice_temp/
11
+ * @contract transcribeAudio(base64, filename) => Promise<string>
12
+ * @contract describeImage(base64, caption?) => Promise<string>
13
+ * @contract generateSpeech(text, voice?) => Promise<string>
14
+ */
15
+
16
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync } from "fs";
17
+ import { join } from "path";
18
+ import OpenAI from "openai";
19
+ import { ElevenLabsClient } from "elevenlabs";
20
+ import { config } from "../shared/config";
21
+ import { createLogger } from "../shared/logger";
22
+
23
+ const log = createLogger("core");
24
+
25
+ let openai: OpenAI | null = null;
26
+ let elevenlabs: ElevenLabsClient | null = null;
27
+
28
+ function getClient(): OpenAI {
29
+ if (!openai) {
30
+ if (!config.openaiApiKey) {
31
+ throw new Error("OPENAI_API_KEY not configured");
32
+ }
33
+ openai = new OpenAI({ apiKey: config.openaiApiKey });
34
+ }
35
+ return openai;
36
+ }
37
+
38
+ function getElevenLabsClient(): ElevenLabsClient {
39
+ if (!elevenlabs) {
40
+ if (!config.elevenlabsApiKey) {
41
+ throw new Error("ELEVENLABS_API_KEY not configured");
42
+ }
43
+ elevenlabs = new ElevenLabsClient({ apiKey: config.elevenlabsApiKey });
44
+ }
45
+ return elevenlabs;
46
+ }
47
+
48
+ export async function transcribeAudio(base64: string, filename: string): Promise<string> {
49
+ const client = getClient();
50
+
51
+ if (!existsSync(config.voiceTempDir)) {
52
+ mkdirSync(config.voiceTempDir, { recursive: true });
53
+ }
54
+
55
+ const tempPath = join(config.voiceTempDir, filename);
56
+ const buffer = Buffer.from(base64, "base64");
57
+ writeFileSync(tempPath, buffer);
58
+
59
+ try {
60
+ const file = Bun.file(tempPath);
61
+ const transcription = await client.audio.transcriptions.create({
62
+ file: file,
63
+ model: "gpt-4o-mini-transcribe",
64
+ });
65
+ log.info(`Transcribed audio: "${transcription.text.substring(0, 80)}..."`);
66
+ return transcription.text;
67
+ } finally {
68
+ try { unlinkSync(tempPath); } catch { /* ignore */ }
69
+ }
70
+ }
71
+
72
+ export async function describeImage(base64: string, caption?: string): Promise<string> {
73
+ const client = getClient();
74
+
75
+ const prompt = caption
76
+ ? `The user sent this image with the text: "${caption}". Describe in detail what you see and respond considering the attached text.`
77
+ : "Describe in detail what you see in this image.";
78
+
79
+ const response = await client.chat.completions.create({
80
+ model: "gpt-5.2",
81
+ messages: [
82
+ {
83
+ role: "user",
84
+ content: [
85
+ { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64}` } },
86
+ { type: "text", text: prompt },
87
+ ],
88
+ },
89
+ ],
90
+ response_format: { type: "text" },
91
+ verbosity: "low",
92
+ reasoning_effort: "none",
93
+ store: false,
94
+ });
95
+
96
+ const description = response.choices[0]?.message?.content || "";
97
+ log.info(`Image described (gpt-5.2): "${description.substring(0, 80)}..."`);
98
+ return description;
99
+ }
100
+
101
+ export async function generateSpeech(text: string, voiceId: string = config.elevenlabsVoiceId): Promise<string> {
102
+ const client = getElevenLabsClient();
103
+
104
+ if (!existsSync(config.voiceTempDir)) {
105
+ mkdirSync(config.voiceTempDir, { recursive: true });
106
+ }
107
+
108
+ const outputPath = join(config.voiceTempDir, `speech_${Date.now()}.mp3`);
109
+
110
+ try {
111
+ const audio = await client.textToSpeech.convert(voiceId, {
112
+ text,
113
+ model_id: "eleven_turbo_v2_5",
114
+ });
115
+
116
+ const chunks: Uint8Array[] = [];
117
+ for await (const chunk of audio) {
118
+ chunks.push(chunk);
119
+ }
120
+
121
+ const buffer = Buffer.concat(chunks);
122
+ writeFileSync(outputPath, buffer);
123
+
124
+ log.info(`Generated speech: ${text.substring(0, 80)}... (voice: ${voiceId})`);
125
+ return outputPath;
126
+ } catch (error) {
127
+ // Invalidate cached client on auth errors so a new key takes effect without restart
128
+ const errStr = String(error);
129
+ if (errStr.includes("401") || errStr.includes("403") || errStr.includes("Unauthorized")) {
130
+ elevenlabs = null;
131
+ log.warn("ElevenLabs client invalidated due to auth error — update ELEVENLABS_API_KEY in .env");
132
+ }
133
+ log.error(`Failed to generate speech: ${error}`);
134
+ throw error;
135
+ }
136
+ }
137
+
138
+ export function isMediaConfigured(): boolean {
139
+ return !!config.openaiApiKey;
140
+ }
141
+
142
+ export function isSpeechConfigured(): boolean {
143
+ return !!config.elevenlabsApiKey;
144
+ }
@@ -0,0 +1,102 @@
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
+ import { isAgentCliInstalled } from "../shared/ai-cli";
17
+
18
+ const log = createLogger("core");
19
+
20
+ let onboardedChats: Set<string> = new Set();
21
+
22
+ async function loadOnboarded() {
23
+ try {
24
+ const users = await getOnboardedUsers();
25
+ onboardedChats = new Set(users);
26
+ } catch (error) {
27
+ log.warn(`Failed to load onboarded state: ${error}`);
28
+ }
29
+ }
30
+
31
+ // Load immediately
32
+ loadOnboarded();
33
+
34
+ export interface DepsStatus {
35
+ claude: boolean;
36
+ codex: boolean;
37
+ openaiKey: boolean;
38
+ os: string;
39
+ }
40
+
41
+ export function checkDeps(): DepsStatus {
42
+ const os =
43
+ process.platform === "darwin"
44
+ ? "macOS"
45
+ : process.platform === "win32"
46
+ ? "Windows"
47
+ : "Linux";
48
+
49
+ return {
50
+ claude: isAgentCliInstalled("claude"),
51
+ codex: isAgentCliInstalled("codex"),
52
+ openaiKey: !!config.openaiApiKey,
53
+ os,
54
+ };
55
+ }
56
+
57
+ export function isOnboarded(chatId: string): boolean {
58
+ return onboardedChats.has(chatId);
59
+ }
60
+
61
+ export async function markOnboarded(chatId: string) {
62
+ onboardedChats.add(chatId);
63
+ await dbAddOnboarded(chatId);
64
+ }
65
+
66
+ /**
67
+ * Returns onboarding message for first-time users, or null if everything is set up.
68
+ * Only blocks if NO CLI is available at all.
69
+ */
70
+ export async function getOnboarding(chatId: string): Promise<{ message: string; blocking: boolean } | null> {
71
+ if (isOnboarded(chatId)) return null;
72
+
73
+ const deps = checkDeps();
74
+
75
+ // No CLI at all — block
76
+ if (!deps.claude && !deps.codex) {
77
+ const lines = [
78
+ "<b>Welcome to Arisa!</b>\n",
79
+ "Neither Claude CLI nor Codex CLI found. You need at least one.\n",
80
+ ];
81
+ if (deps.os === "macOS") {
82
+ lines.push("Claude: <code>brew install claude-code</code>");
83
+ } else {
84
+ lines.push("Claude: <code>bun add -g @anthropic-ai/claude-code</code>");
85
+ }
86
+ lines.push("Codex: <code>bun add -g @openai/codex</code>\n");
87
+ lines.push("Install one and message me again.");
88
+ return { message: lines.join("\n"), blocking: true };
89
+ }
90
+
91
+ // At least one CLI — minimal greeting and continue
92
+ await markOnboarded(chatId);
93
+
94
+ const using = deps.claude ? "Claude" : "Codex";
95
+ const lines = [`<b>Arisa</b> — using <b>${using}</b>`];
96
+
97
+ if (deps.claude && deps.codex) {
98
+ lines.push("Use /codex or /claude to switch backend.");
99
+ }
100
+
101
+ return { message: lines.join("\n"), blocking: false };
102
+ }
@@ -0,0 +1,309 @@
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 { buildBunWrappedAgentCliCommand } from "../shared/ai-cli";
20
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from "fs";
21
+ import { join } from "path";
22
+
23
+ const log = createLogger("core");
24
+ const ACTIVITY_LOG = join(config.logsDir, "activity.log");
25
+ const PROMPT_PREVIEW_MAX = 220;
26
+ export const CLAUDE_RATE_LIMIT_MESSAGE = "Claude is out of credits right now. Please try again in a few minutes.";
27
+ export const CODEX_AUTH_REQUIRED_MESSAGE = [
28
+ "Codex login is required.",
29
+ "Check the Arisa daemon logs now and complete the device-auth steps shown there."
30
+ ].join("\n");
31
+
32
+ function logActivity(backend: string, model: string | null, durationMs: number, status: string) {
33
+ try {
34
+ if (!existsSync(config.logsDir)) mkdirSync(config.logsDir, { recursive: true });
35
+ const ts = new Date().toISOString();
36
+ const entry = { ts, backend, model, durationMs, status };
37
+ appendFileSync(ACTIVITY_LOG, JSON.stringify(entry) + "\n");
38
+ } catch {}
39
+ }
40
+ const SOUL_PATH = join(config.projectDir, "SOUL.md");
41
+
42
+ // Load SOUL.md once at startup — shared personality for all backends
43
+ let soulPrompt = "";
44
+ try {
45
+ if (existsSync(SOUL_PATH)) {
46
+ soulPrompt = readFileSync(SOUL_PATH, "utf8").trim();
47
+ log.info("SOUL.md loaded");
48
+ }
49
+ } catch (e) {
50
+ log.warn(`Failed to load SOUL.md: ${e}`);
51
+ }
52
+
53
+ function withSoul(message: string): string {
54
+ if (!soulPrompt) return message;
55
+ return `[System instructions]\n${soulPrompt}\n[End system instructions]\n\n${message}`;
56
+ }
57
+
58
+ function previewPrompt(input: string): string {
59
+ const compact = input.replace(/\s+/g, " ").trim();
60
+ if (!compact) return "(empty)";
61
+ return compact.length > PROMPT_PREVIEW_MAX
62
+ ? `${compact.slice(0, PROMPT_PREVIEW_MAX)}...`
63
+ : compact;
64
+ }
65
+
66
+ // Serialize Claude calls — only one at a time
67
+ // User messages have priority over task messages
68
+ type QueueSource = "user" | "task";
69
+ let processing = false;
70
+ const queue: Array<{
71
+ message: string;
72
+ chatId: string;
73
+ source: QueueSource;
74
+ resolve: (result: string) => void;
75
+ }> = [];
76
+
77
+ export async function processWithClaude(
78
+ message: string,
79
+ chatId: string,
80
+ source: QueueSource = "user",
81
+ ): Promise<string> {
82
+ return new Promise((resolve) => {
83
+ queue.push({ message, chatId, source, resolve });
84
+ processNext();
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Flush pending TASK queue items for a chat (resolve with empty string).
90
+ * Only flushes source:"task" items — never discards user messages.
91
+ */
92
+ export function flushChatQueue(chatId: string): number {
93
+ let flushed = 0;
94
+ for (let i = queue.length - 1; i >= 0; i--) {
95
+ if (queue[i].chatId === chatId && queue[i].source === "task") {
96
+ queue[i].resolve("");
97
+ queue.splice(i, 1);
98
+ flushed++;
99
+ }
100
+ }
101
+ if (flushed > 0) log.info(`Flushed ${flushed} task queue items for chat ${chatId}`);
102
+ return flushed;
103
+ }
104
+
105
+ async function processNext() {
106
+ if (processing || queue.length === 0) return;
107
+ processing = true;
108
+
109
+ // Pick user messages first, then task messages
110
+ const userIdx = queue.findIndex((q) => q.source === "user");
111
+ const idx = userIdx >= 0 ? userIdx : 0;
112
+ const [item] = queue.splice(idx, 1);
113
+ const { message, chatId, resolve } = item;
114
+
115
+ try {
116
+ const result = await runClaude(message, chatId);
117
+ resolve(result);
118
+ } catch (error) {
119
+ const msg = error instanceof Error ? error.message : String(error);
120
+ log.error(`Claude execution error: ${msg}`);
121
+ resolve(`Error: ${summarizeError(msg)}`);
122
+ } finally {
123
+ processing = false;
124
+ processNext();
125
+ }
126
+ }
127
+
128
+ async function runClaude(message: string, chatId: string): Promise<string> {
129
+ const model = selectModel(message);
130
+ const historyContext = getRecentHistory(chatId);
131
+ const start = Date.now();
132
+ const prompt = withSoul(historyContext + message);
133
+
134
+ const historyCount = historyContext ? historyContext.split("\nUser: ").length - 1 : 0;
135
+ log.info(`Model: ${model.model} (${model.reason}) | History: ${historyCount} exchanges`);
136
+
137
+ const args = ["--dangerously-skip-permissions", "--output-format", "text"];
138
+
139
+ args.push("--model", model.model);
140
+ args.push("-p", prompt);
141
+
142
+ log.info(
143
+ `Claude send | promptChars: ${prompt.length} | preview: ${previewPrompt(prompt)}`
144
+ );
145
+ log.info(`Claude spawn | cmd: claude --dangerously-skip-permissions --model ${model.model} -p <prompt>`);
146
+ log.debug(`Claude prompt >>>>\n${prompt}\n<<<<`);
147
+
148
+ const proc = Bun.spawn(buildBunWrappedAgentCliCommand("claude", args), {
149
+ cwd: config.projectDir,
150
+ stdin: "pipe",
151
+ stdout: "pipe",
152
+ stderr: "pipe",
153
+ env: { ...process.env },
154
+ });
155
+ proc.stdin.end();
156
+
157
+ const timeout = setTimeout(() => {
158
+ log.warn(`Claude timed out after ${model.timeout}ms, killing process`);
159
+ proc.kill();
160
+ }, model.timeout);
161
+
162
+ const exitCode = await proc.exited;
163
+ clearTimeout(timeout);
164
+
165
+ const stdout = await new Response(proc.stdout).text();
166
+ const stderr = await new Response(proc.stderr).text();
167
+ const duration = Date.now() - start;
168
+
169
+ if (exitCode !== 0) {
170
+ const combined = stdout + stderr;
171
+ log.error(`Claude exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
172
+ logActivity("claude", model.model, duration, `error:${exitCode}`);
173
+ if (isRateLimit(combined)) {
174
+ return CLAUDE_RATE_LIMIT_MESSAGE;
175
+ }
176
+ return `Error (exit ${exitCode}): ${summarizeError(stderr || stdout)}`;
177
+ }
178
+
179
+ const response = stdout.trim();
180
+ logActivity("claude", model.model, duration, response ? "ok" : "empty");
181
+ log.info(`Claude recv | ${duration}ms | responseChars: ${response.length} | preview: ${previewPrompt(response)}`);
182
+ log.debug(`Claude response >>>>\n${response}\n<<<<`);
183
+
184
+ if (!response) {
185
+ log.warn("Claude returned empty response");
186
+ return "Claude returned an empty response.";
187
+ }
188
+
189
+ if (response.length > config.maxResponseLength) {
190
+ return response.substring(0, config.maxResponseLength - 100) + "\n\n[Response truncated...]";
191
+ }
192
+
193
+ return response;
194
+ }
195
+
196
+ export async function processWithCodex(message: string): Promise<string> {
197
+ const continueFlag = shouldContinue();
198
+ const start = Date.now();
199
+
200
+ log.info(`Codex | Continue: ${continueFlag}`);
201
+
202
+ const args: string[] = [];
203
+
204
+ if (continueFlag) {
205
+ args.push("exec", "resume", "--last", "--dangerously-bypass-approvals-and-sandbox");
206
+ } else {
207
+ args.push("exec", "--dangerously-bypass-approvals-and-sandbox", "-C", config.projectDir);
208
+ }
209
+
210
+ args.push(message);
211
+
212
+ log.info(
213
+ `Codex send | promptChars: ${message.length} | preview: ${previewPrompt(message)}`
214
+ );
215
+ log.info(
216
+ `Codex spawn | cmd: codex ${continueFlag
217
+ ? "exec resume --last --dangerously-bypass-approvals-and-sandbox <prompt>"
218
+ : `exec --dangerously-bypass-approvals-and-sandbox -C ${config.projectDir} <prompt>`}`
219
+ );
220
+ log.debug(`Codex prompt >>>>\n${message}\n<<<<`);
221
+
222
+ const proc = Bun.spawn(buildBunWrappedAgentCliCommand("codex", args), {
223
+ cwd: config.projectDir,
224
+ stdout: "pipe",
225
+ stderr: "pipe",
226
+ });
227
+
228
+ const timeout = setTimeout(() => {
229
+ log.warn("Codex timed out after 180s, killing process");
230
+ proc.kill();
231
+ }, 180_000);
232
+
233
+ const exitCode = await proc.exited;
234
+ clearTimeout(timeout);
235
+
236
+ const stdout = await new Response(proc.stdout).text();
237
+ const stderr = await new Response(proc.stderr).text();
238
+ const duration = Date.now() - start;
239
+
240
+ if (exitCode !== 0) {
241
+ const combined = `${stdout}\n${stderr}`;
242
+ log.error(`Codex exited with code ${exitCode}: ${stderr.substring(0, 200)}`);
243
+ logActivity("codex", null, duration, `error:${exitCode}`);
244
+ if (isCodexAuthError(combined)) {
245
+ return CODEX_AUTH_REQUIRED_MESSAGE;
246
+ }
247
+ return "Error processing with Codex. Please try again.";
248
+ }
249
+
250
+ const response = stdout.trim();
251
+ logActivity("codex", null, duration, response ? "ok" : "empty");
252
+ log.info(`Codex recv | ${duration}ms | responseChars: ${response.length} | preview: ${previewPrompt(response)}`);
253
+ log.debug(`Codex response >>>>\n${response}\n<<<<`);
254
+
255
+ if (!response) {
256
+ log.warn("Codex returned empty response");
257
+ return "Codex returned an empty response.";
258
+ }
259
+
260
+ if (response.length > config.maxResponseLength) {
261
+ return response.substring(0, config.maxResponseLength - 100) + "\n\n[Response truncated...]";
262
+ }
263
+
264
+ return response;
265
+ }
266
+
267
+ export function isClaudeRateLimitResponse(text: string): boolean {
268
+ return text.trim() === CLAUDE_RATE_LIMIT_MESSAGE;
269
+ }
270
+
271
+ export function isCodexAuthRequiredResponse(text: string): boolean {
272
+ return text.trim() === CODEX_AUTH_REQUIRED_MESSAGE;
273
+ }
274
+
275
+ function summarizeError(raw: string): string {
276
+ if (!raw.trim()) return "process ended without details.";
277
+
278
+ const lines = raw.split("\n");
279
+
280
+ // Filter out Bun stack-trace source code lines (e.g. "3 | import{createRequire...")
281
+ // and caret pointer lines (e.g. " ^")
282
+ const meaningful = lines.filter(
283
+ (l) => !/^\s*\d+\s*\|/.test(l) && !/^\s*\^+\s*$/.test(l)
284
+ );
285
+
286
+ // Look for explicit error lines first (e.g. "error: ...", "TypeError: ...")
287
+ const errorLine = meaningful.find((l) =>
288
+ /^\s*(error|Error|TypeError|ReferenceError|SyntaxError|RangeError|ENOENT|EACCES|fatal)[:]/i.test(l.trim())
289
+ );
290
+
291
+ const summary = errorLine?.trim()
292
+ || meaningful.filter((l) => l.trim()).join(" ").trim()
293
+ || "process ended without details.";
294
+
295
+ const clean = summary.replace(/\s+/g, " ");
296
+ return clean.length > 200 ? clean.slice(0, 200) + "..." : clean;
297
+ }
298
+
299
+ function isRateLimit(output: string): boolean {
300
+ return /you'?ve hit your limit|rate limit|quota|credits.*(exceeded|exhausted)/i.test(output);
301
+ }
302
+
303
+ function isCodexAuthError(output: string): boolean {
304
+ return (
305
+ /missing bearer authentication in header/i.test(output)
306
+ || (/401\s+Unauthorized/i.test(output) && /bearer/i.test(output))
307
+ || (/failed to refresh available models/i.test(output) && /unauthorized/i.test(output))
308
+ );
309
+ }
@@ -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
+ }