arisa 2.3.55 → 3.0.1

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.
Files changed (62) hide show
  1. package/AGENTS.md +102 -0
  2. package/README.md +120 -165
  3. package/bin/arisa.js +2 -643
  4. package/cli/openai-transcribe/index.js +51 -0
  5. package/cli/openai-transcribe/package.json +6 -0
  6. package/cli/openai-transcribe/tool.manifest.json +15 -0
  7. package/cli/openai-tts/index.js +58 -0
  8. package/cli/openai-tts/package.json +6 -0
  9. package/cli/openai-tts/tool.manifest.json +20 -0
  10. package/cli/web-browser/index.js +146 -0
  11. package/cli/web-browser/package.json +6 -0
  12. package/cli/web-browser/tool.manifest.json +8 -0
  13. package/package.json +26 -44
  14. package/src/core/agent/agent-manager.js +218 -0
  15. package/src/core/artifacts/artifact-store.js +102 -0
  16. package/src/core/config/config-store.js +20 -0
  17. package/src/core/tools/tool-registry.js +117 -0
  18. package/src/index.js +27 -0
  19. package/src/runtime/bootstrap.js +213 -0
  20. package/src/runtime/create-app.js +22 -0
  21. package/src/transport/telegram/auth.js +13 -0
  22. package/src/transport/telegram/bot.js +214 -0
  23. package/src/transport/telegram/media.js +75 -0
  24. package/CLAUDE.md +0 -191
  25. package/SOUL.md +0 -36
  26. package/scripts/dump-commands.ts +0 -26
  27. package/scripts/test-secrets.ts +0 -22
  28. package/src/core/attachments.ts +0 -104
  29. package/src/core/auth.ts +0 -58
  30. package/src/core/context.ts +0 -30
  31. package/src/core/file-detector.ts +0 -39
  32. package/src/core/format.ts +0 -159
  33. package/src/core/index.ts +0 -456
  34. package/src/core/intent.ts +0 -119
  35. package/src/core/media.ts +0 -144
  36. package/src/core/onboarding.ts +0 -102
  37. package/src/core/processor.ts +0 -305
  38. package/src/core/router.ts +0 -64
  39. package/src/core/scheduler.ts +0 -193
  40. package/src/daemon/agent-cli.ts +0 -130
  41. package/src/daemon/auto-install.ts +0 -158
  42. package/src/daemon/autofix.ts +0 -116
  43. package/src/daemon/bridge.ts +0 -166
  44. package/src/daemon/channels/base.ts +0 -10
  45. package/src/daemon/channels/telegram.ts +0 -306
  46. package/src/daemon/claude-login.ts +0 -218
  47. package/src/daemon/codex-login.ts +0 -172
  48. package/src/daemon/fallback.ts +0 -73
  49. package/src/daemon/index.ts +0 -272
  50. package/src/daemon/lifecycle.ts +0 -313
  51. package/src/daemon/setup.ts +0 -329
  52. package/src/shared/ai-cli.ts +0 -165
  53. package/src/shared/config.ts +0 -137
  54. package/src/shared/db.ts +0 -304
  55. package/src/shared/deepbase-secure.ts +0 -39
  56. package/src/shared/ink-shim.js +0 -14
  57. package/src/shared/logger.ts +0 -42
  58. package/src/shared/paths.ts +0 -90
  59. package/src/shared/ports.ts +0 -120
  60. package/src/shared/secrets.ts +0 -136
  61. package/src/shared/types.ts +0 -103
  62. package/tsconfig.json +0 -19
@@ -1,159 +0,0 @@
1
- /**
2
- * @module core/format
3
- * @role Format responses for Telegram (HTML) and chunk long messages.
4
- * @responsibilities
5
- * - Split text into chunks respecting Telegram's 4096 char limit
6
- * - Safe HTML sending with plain-text fallback marker
7
- * @dependencies None
8
- * @effects None (pure functions)
9
- * @contract chunkMessage(text) => string[]
10
- */
11
-
12
- const MAX_TELEGRAM_LENGTH = 4096;
13
-
14
- function escapeHtml(s: string): string {
15
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
16
- }
17
-
18
- /** Decode HTML entities so we don't double-encode them when escapeHtml runs. */
19
- function unescapeHtml(s: string): string {
20
- return s
21
- .replace(/&amp;/g, "&")
22
- .replace(/&lt;/g, "<")
23
- .replace(/&gt;/g, ">")
24
- .replace(/&quot;/g, '"')
25
- .replace(/&#39;/g, "'");
26
- }
27
-
28
- function protectTelegramHtmlTags(text: string): { text: string; tags: string[] } {
29
- const tags: string[] = [];
30
- const tagPattern = /<\/?(?:b|i|u|s|code|pre|blockquote)>|<a\s+href="[^"]+">|<\/a>/gi;
31
- const protectedText = text.replace(tagPattern, (tag) => {
32
- tags.push(tag);
33
- return `\x00HTMLTAG${tags.length - 1}\x00`;
34
- });
35
- return { text: protectedText, tags };
36
- }
37
-
38
- /**
39
- * Convert Markdown (from Claude CLI) to Telegram-safe HTML.
40
- * Telegram supports: <b>, <i>, <code>, <pre>, <a>, <s>, <u>, <blockquote>
41
- * Must also escape HTML entities in non-tag content.
42
- */
43
- export function markdownToTelegramHtml(text: string): string {
44
- // Step 0: Decode any pre-escaped HTML entities to avoid double-encoding
45
- // (e.g. &lt;code&gt; → <code> so protectTelegramHtmlTags can detect them)
46
- text = unescapeHtml(text);
47
-
48
- // Step 1: Extract code blocks and links before escaping to protect them
49
- const codeBlocks: string[] = [];
50
- const inlineCodes: string[] = [];
51
- const links: string[] = [];
52
-
53
- // Preserve already-valid Telegram HTML tags instead of escaping them.
54
- const protectedHtml = protectTelegramHtmlTags(text);
55
-
56
- // Protect fenced code blocks: ```lang\n...\n``` or ```...```
57
- let result = protectedHtml.text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
58
- codeBlocks.push(code.trimEnd());
59
- return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
60
- });
61
-
62
- // Protect inline code: `...`
63
- result = result.replace(/`([^`\n]+)`/g, (_match, code) => {
64
- inlineCodes.push(code);
65
- return `\x00INLINE${inlineCodes.length - 1}\x00`;
66
- });
67
-
68
- // Protect links: [text](url)
69
- result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, linkText, url) => {
70
- links.push(`<a href="${url}">${escapeHtml(linkText)}</a>`);
71
- return `\x00LINK${links.length - 1}\x00`;
72
- });
73
-
74
- // Step 2: Now escape HTML entities in the remaining text
75
- result = escapeHtml(result);
76
-
77
- // Step 3: Convert markdown patterns to HTML tags
78
-
79
- // Bold+Italic: ***text*** or ___text___
80
- result = result.replace(/\*{3}(.+?)\*{3}/g, "<b><i>$1</i></b>");
81
- result = result.replace(/_{3}(.+?)_{3}/g, "<b><i>$1</i></b>");
82
-
83
- // Bold: **text** or __text__
84
- result = result.replace(/\*{2}(.+?)\*{2}/g, "<b>$1</b>");
85
- result = result.replace(/_{2}(.+?)_{2}/g, "<b>$1</b>");
86
-
87
- // Italic: *text* or _text_ (not inside words for underscore)
88
- result = result.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, "<i>$1</i>");
89
- result = result.replace(/(?<!\w)_([^_\n]+)_(?!\w)/g, "<i>$1</i>");
90
-
91
- // Strikethrough: ~~text~~
92
- result = result.replace(/~~(.+?)~~/g, "<s>$1</s>");
93
-
94
- // Step 4: Restore protected elements
95
- result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m, i) => `<pre>${escapeHtml(codeBlocks[+i])}</pre>`);
96
- result = result.replace(/\x00INLINE(\d+)\x00/g, (_m, i) => `<code>${escapeHtml(inlineCodes[+i])}</code>`);
97
- result = result.replace(/\x00LINK(\d+)\x00/g, (_m, i) => links[+i]);
98
- result = result.replace(/\x00HTMLTAG(\d+)\x00/g, (_m, i) => protectedHtml.tags[+i]);
99
-
100
- return result;
101
- }
102
-
103
- export function chunkMessage(text: string): string[] {
104
- if (text.length <= MAX_TELEGRAM_LENGTH) {
105
- return [text];
106
- }
107
-
108
- const chunks: string[] = [];
109
- let remaining = text;
110
-
111
- // Tags that Telegram supports and we use
112
- const tagNames = ["pre", "code", "b", "i", "s", "a"];
113
-
114
- while (remaining.length > 0) {
115
- if (remaining.length <= MAX_TELEGRAM_LENGTH) {
116
- chunks.push(remaining);
117
- break;
118
- }
119
-
120
- let splitAt = remaining.lastIndexOf("\n", MAX_TELEGRAM_LENGTH);
121
- if (splitAt === -1 || splitAt < MAX_TELEGRAM_LENGTH * 0.5) {
122
- splitAt = MAX_TELEGRAM_LENGTH;
123
- }
124
-
125
- // Check if splitting here would break an HTML tag
126
- let candidate = remaining.substring(0, splitAt);
127
-
128
- // Find unclosed tags in the candidate chunk
129
- const openTags: string[] = [];
130
- const tagRegex = /<\/?([a-z]+)(?:\s[^>]*)?\/?>/gi;
131
- let match: RegExpExecArray | null;
132
- while ((match = tagRegex.exec(candidate)) !== null) {
133
- const fullTag = match[0];
134
- const tagName = match[1].toLowerCase();
135
- if (!tagNames.includes(tagName)) continue;
136
- if (fullTag.startsWith("</")) {
137
- // Closing tag - pop if matching
138
- const idx = openTags.lastIndexOf(tagName);
139
- if (idx !== -1) openTags.splice(idx, 1);
140
- } else if (!fullTag.endsWith("/>")) {
141
- openTags.push(tagName);
142
- }
143
- }
144
-
145
- // If there are unclosed tags, close them at end of this chunk and reopen in next
146
- if (openTags.length > 0) {
147
- const closingTags = [...openTags].reverse().map(t => `</${t}>`).join("");
148
- const openingTags = openTags.map(t => `<${t}>`).join("");
149
- candidate = candidate + closingTags;
150
- remaining = openingTags + remaining.substring(splitAt).trimStart();
151
- } else {
152
- remaining = remaining.substring(splitAt).trimStart();
153
- }
154
-
155
- chunks.push(candidate);
156
- }
157
-
158
- return chunks;
159
- }
package/src/core/index.ts DELETED
@@ -1,456 +0,0 @@
1
- /**
2
- * @module core/index
3
- * @role HTTP server entry point for Core process.
4
- * @responsibilities
5
- * - Listen on :7777 for messages from Daemon
6
- * - Route /message requests through media → processor → file-detector → format
7
- * - Expose /health endpoint for Daemon health checks
8
- * - Handle /reset, scheduler parsing, and command dispatch
9
- * - Initialize scheduler on startup
10
- * @dependencies All core/* modules, shared/*
11
- * @effects Network (HTTP server), spawns Claude CLI, disk I/O
12
- */
13
-
14
- import { config } from "../shared/config";
15
-
16
- // Initialize encrypted secrets
17
- await config.secrets.initialize();
18
- import { createLogger } from "../shared/logger";
19
- import { serveWithRetry, claimProcess } from "../shared/ports";
20
- import type { IncomingMessage, CoreResponse, ScheduledTask } from "../shared/types";
21
- import {
22
- processWithClaude,
23
- processWithCodex,
24
- isClaudeRateLimitResponse,
25
- isCodexAuthRequiredResponse,
26
- } from "./processor";
27
- import { transcribeAudio, describeImage, generateSpeech, isMediaConfigured, isSpeechConfigured } from "./media";
28
- import { detectFiles } from "./file-detector";
29
-
30
- import { getOnboarding, checkDeps } from "./onboarding";
31
- import { initScheduler, addTask, cancelAllChatTasks } from "./scheduler";
32
- import { detectScheduleIntent } from "./intent";
33
- import { initAuth, isAuthorized, tryAuthorize } from "./auth";
34
- import { initAttachments, saveAttachment } from "./attachments";
35
- import { saveMessageRecord, getMessageRecord } from "../shared/db";
36
-
37
- const log = createLogger("core");
38
-
39
- // Kill previous Core if still running, write our PID
40
- claimProcess("core");
41
-
42
- // Per-chat backend state — default based on what's installed (claude > codex)
43
- const backendState = new Map<string, "claude" | "codex">();
44
-
45
- function defaultBackend(): "claude" | "codex" {
46
- const deps = checkDeps();
47
- return deps.claude ? "claude" : "codex";
48
- }
49
-
50
- function getBackend(chatId: string): "claude" | "codex" {
51
- const deps = checkDeps();
52
-
53
- const preferInstalled = (candidate: "claude" | "codex"): "claude" | "codex" => {
54
- if (candidate === "claude" && !deps.claude && deps.codex) return "codex";
55
- if (candidate === "codex" && !deps.codex && deps.claude) return "claude";
56
- return candidate;
57
- };
58
-
59
- const current = backendState.get(chatId);
60
- if (current) return preferInstalled(current);
61
-
62
- return preferInstalled(defaultBackend());
63
- }
64
-
65
- // Initialize auth + scheduler + attachments
66
- await initAuth();
67
- await initScheduler();
68
- await initAttachments();
69
-
70
- const server = await serveWithRetry({
71
- unix: config.coreSocket,
72
- async fetch(req) {
73
- const url = new URL(req.url);
74
-
75
- if (url.pathname === "/health" && req.method === "GET") {
76
- return Response.json({ status: "ok", timestamp: Date.now() });
77
- }
78
-
79
- // Save outgoing message records (called by Daemon after sending to Telegram)
80
- if (url.pathname === "/record" && req.method === "POST") {
81
- try {
82
- const record = await req.json();
83
- await saveMessageRecord(record);
84
- return Response.json({ ok: true });
85
- } catch (error) {
86
- log.error(`Record save error: ${error}`);
87
- return Response.json({ error: "Save failed" }, { status: 500 });
88
- }
89
- }
90
-
91
- if (url.pathname === "/message" && req.method === "POST") {
92
- try {
93
- const body = await req.json();
94
- const msg: IncomingMessage = body.message;
95
-
96
- if (!msg) {
97
- return Response.json({ error: "Missing message" }, { status: 400 });
98
- }
99
-
100
- log.debug(`Inbound message | chatId=${msg.chatId} | sender=${msg.sender} | type=${msg.text ? "text" : "media"}`);
101
-
102
- // Auth gate: require token before anything else
103
- if (!isAuthorized(msg.chatId)) {
104
- if (msg.text && await tryAuthorize(msg.chatId, msg.text)) {
105
- return Response.json({ text: "Authorized. Welcome to Arisa!" } as CoreResponse);
106
- }
107
- return Response.json({ text: "Send the auth token to start. Check the server console." } as CoreResponse);
108
- }
109
-
110
- // Onboarding: first message from this chat
111
- const onboarding = await getOnboarding(msg.chatId);
112
- if (onboarding?.blocking) {
113
- return Response.json({ text: onboarding.message } as CoreResponse);
114
- }
115
-
116
- // Initialize message text
117
- let messageText = msg.text || "";
118
-
119
- // Prepend reply context if message quotes another message
120
- if (msg.replyTo) {
121
- let quotedText = msg.replyTo.text || "";
122
- let quotedSender = msg.replyTo.sender;
123
- let quotedDate = new Date(msg.replyTo.timestamp).toLocaleString("es-AR");
124
- let attachmentInfo = "";
125
-
126
- // Try ledger lookup for richer context
127
- if (msg.replyTo.messageId) {
128
- const ledger = await getMessageRecord(msg.chatId, msg.replyTo.messageId);
129
- if (ledger) {
130
- quotedText = ledger.text || quotedText;
131
- quotedSender = ledger.sender;
132
- quotedDate = new Date(ledger.timestamp).toLocaleString("es-AR");
133
- if (ledger.mediaDescription) {
134
- attachmentInfo += `\nMedia description: ${ledger.mediaDescription}`;
135
- }
136
- if (ledger.attachmentPath) {
137
- attachmentInfo += `\nAttachment: ${ledger.attachmentPath}`;
138
- }
139
- }
140
- }
141
-
142
- if (!quotedText && !attachmentInfo) {
143
- quotedText = "[media or unknown content]";
144
- }
145
-
146
- messageText = `━━━ QUOTED MESSAGE ━━━
147
- From: ${quotedSender}
148
- Date: ${quotedDate}
149
- Content: "${quotedText}"${attachmentInfo}
150
- ━━━━━━━━━━━━━━━━━━━━
151
-
152
- ${messageText}`;
153
- }
154
-
155
- // Handle /reset command
156
- if (msg.command === "/reset") {
157
- const { writeFileSync } = await import("fs");
158
- writeFileSync(config.resetFlagPath, "reset");
159
- const { resetRouterState } = await import("./router");
160
- resetRouterState();
161
- const response: CoreResponse = { text: "Conversation reset! Next message will start a fresh conversation." };
162
- return Response.json(response);
163
- }
164
-
165
- // Handle /cancel command — stop all scheduled tasks
166
- if (msg.command === "/cancel") {
167
- const removed = await cancelAllChatTasks(msg.chatId);
168
- const text = removed > 0
169
- ? `Cancelled ${removed} task${removed > 1 ? "s" : ""}.`
170
- : "No active tasks to cancel.";
171
- return Response.json({ text } as CoreResponse);
172
- }
173
-
174
- // Handle /codex command — switch to codex backend
175
- if (msg.command === "/codex") {
176
- const deps = checkDeps();
177
- if (!deps.codex) {
178
- const hint = deps.os === "macOS"
179
- ? "<code>bun add -g @openai/codex</code>"
180
- : "<code>bun add -g @openai/codex</code>";
181
- return Response.json({ text: `Codex CLI is not installed.\n${hint}` } as CoreResponse);
182
- }
183
- backendState.set(msg.chatId, "codex");
184
- log.info(`Backend switched to codex for chat ${msg.chatId}`);
185
- const response: CoreResponse = { text: "Codex mode activated. Use /claude to switch back." };
186
- return Response.json(response);
187
- }
188
-
189
- // Handle /claude command — switch to claude backend
190
- if (msg.command === "/claude") {
191
- const deps = checkDeps();
192
- if (!deps.claude) {
193
- const hint = "<code>bun add -g @anthropic-ai/claude-code</code>";
194
- return Response.json({ text: `Claude CLI is not installed.\n${hint}` } as CoreResponse);
195
- }
196
- backendState.set(msg.chatId, "claude");
197
- log.info(`Backend switched to claude for chat ${msg.chatId}`);
198
- const response: CoreResponse = { text: "Claude mode activated. Use /codex to switch back." };
199
- return Response.json(response);
200
- }
201
-
202
- // Handle /speak command — generate speech via ElevenLabs
203
- if (msg.command === "/speak") {
204
- if (!config.elevenlabsApiKey) {
205
- return Response.json({ text: "ELEVENLABS_API_KEY not configured. Add it to ~/.arisa/.env" } as CoreResponse);
206
- }
207
- const textToSpeak = messageText.replace(/^\/speak\s*/, "").trim();
208
- if (!textToSpeak) {
209
- return Response.json({ text: "Usage: /speak <text to convert to speech>" } as CoreResponse);
210
- }
211
- try {
212
- const audioPath = await generateSpeech(textToSpeak);
213
- const response: CoreResponse = {
214
- text: "",
215
- audio: audioPath,
216
- };
217
- return Response.json(response);
218
- } catch (error) {
219
- log.error(`Speech generation failed: ${error}`);
220
- return Response.json({ text: "Failed to generate speech. Check logs for details." } as CoreResponse);
221
- }
222
- }
223
-
224
- // Process media first — track metadata for message ledger
225
- let ledgerMediaType: "image" | "audio" | "document" | undefined;
226
- let ledgerAttachmentPath: string | undefined;
227
- let ledgerMediaDescription: string | undefined;
228
-
229
- if (msg.audio) {
230
- const audioPath = await saveAttachment(msg.chatId, "audio", msg.audio.base64, msg.audio.filename);
231
- ledgerMediaType = "audio";
232
- ledgerAttachmentPath = audioPath;
233
- if (isMediaConfigured()) {
234
- try {
235
- const transcription = await transcribeAudio(msg.audio.base64, msg.audio.filename);
236
- if (transcription.trim()) {
237
- ledgerMediaDescription = transcription;
238
- messageText = `[Audio saved to ${audioPath}]\n[Voice message transcription]: ${transcription}`;
239
- } else {
240
- messageText = `[Audio saved to ${audioPath}]\n[Transcription returned empty. Ask the user to try again or send text.]`;
241
- }
242
- } catch (error) {
243
- log.error(`Transcription failed: ${error}`);
244
- messageText = `[Audio saved to ${audioPath}]\n[Transcription failed. The audio file is still accessible at the path above.]`;
245
- }
246
- } else {
247
- messageText = `[Audio saved to ${audioPath}]\n[Cannot transcribe because OPENAI_API_KEY is not configured. The audio file is still accessible at the path above.]`;
248
- }
249
- }
250
-
251
- if (msg.image) {
252
- const caption = msg.image.caption || "";
253
- const imgPath = await saveAttachment(msg.chatId, "image", msg.image.base64);
254
- ledgerMediaType = "image";
255
- ledgerAttachmentPath = imgPath;
256
-
257
- if (caption && isMediaConfigured()) {
258
- // User sent text with the image → describe it via Vision
259
- try {
260
- const description = await describeImage(msg.image.base64, caption);
261
- if (description.trim()) {
262
- ledgerMediaDescription = description;
263
- messageText = `[Image saved to ${imgPath}]\n[Image description: ${description}]\n${caption}`;
264
- } else {
265
- messageText = `[Image saved to ${imgPath}]\n[Image content could not be interpreted]\n${caption}`;
266
- }
267
- } catch (error) {
268
- log.error(`Image analysis failed: ${error}`);
269
- messageText = `[Image saved to ${imgPath}]\n[Error analyzing the image]\n${caption}`;
270
- }
271
- } else if (caption) {
272
- // Has caption but no OpenAI key
273
- messageText = `[Image saved to ${imgPath}]\n[Cannot describe image — OPENAI_API_KEY not configured. The image file is accessible at the path above.]\n${caption}`;
274
- } else {
275
- // No caption → just save, no GPT call
276
- messageText = `[Image saved to ${imgPath}]`;
277
- }
278
- }
279
-
280
- if (msg.document) {
281
- const docPath = await saveAttachment(msg.chatId, "document", msg.document.base64, msg.document.filename, msg.document.mimeType);
282
- ledgerMediaType = "document";
283
- ledgerAttachmentPath = docPath;
284
- const caption = msg.document.caption || "";
285
- messageText = caption
286
- ? `[Document saved to ${docPath}] (${msg.document.mimeType})\n${caption}`
287
- : `[Document saved to ${docPath}] (${msg.document.mimeType})`;
288
- }
289
-
290
- if (!messageText) {
291
- const response: CoreResponse = { text: "Empty message received." };
292
- return Response.json(response);
293
- }
294
-
295
- // Save incoming message to ledger (after media processing so we have descriptions)
296
- if (msg.messageId) {
297
- saveMessageRecord({
298
- id: `${msg.chatId}_${msg.messageId}`,
299
- chatId: msg.chatId,
300
- messageId: msg.messageId,
301
- direction: "in",
302
- sender: msg.sender,
303
- timestamp: msg.timestamp,
304
- text: messageText,
305
- mediaType: ledgerMediaType,
306
- attachmentPath: ledgerAttachmentPath,
307
- mediaDescription: ledgerMediaDescription,
308
- }).catch((e) => log.error(`Failed to save incoming message record: ${e}`));
309
- }
310
-
311
- // Detect scheduling intent via haiku (language-agnostic)
312
- const scheduleIntent = await detectScheduleIntent(messageText);
313
- if (scheduleIntent) {
314
- if (scheduleIntent.type === "cancel") {
315
- const removed = await cancelAllChatTasks(msg.chatId);
316
- const text = removed > 0
317
- ? scheduleIntent.confirmation
318
- : "No active tasks to cancel.";
319
- return Response.json({ text } as CoreResponse);
320
- }
321
-
322
- const taskId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
323
- const task: ScheduledTask = {
324
- id: taskId,
325
- chatId: msg.chatId,
326
- sender: msg.sender,
327
- senderId: msg.senderId,
328
- type: scheduleIntent.type,
329
- message: scheduleIntent.message,
330
- originalMessage: messageText,
331
- createdAt: Date.now(),
332
- ...(scheduleIntent.type === "once" && scheduleIntent.delaySeconds
333
- ? { runAt: Date.now() + scheduleIntent.delaySeconds * 1000 }
334
- : {}),
335
- ...(scheduleIntent.type === "cron" && scheduleIntent.cron
336
- ? { cron: scheduleIntent.cron }
337
- : {}),
338
- };
339
- await addTask(task);
340
- const response: CoreResponse = { text: scheduleIntent.confirmation };
341
- return Response.json(response);
342
- }
343
-
344
- const deps = checkDeps();
345
- if (!deps.claude && !deps.codex) {
346
- return Response.json({
347
- text: "No AI CLI is installed. Install at least one:\n<code>bun add -g @anthropic-ai/claude-code</code>\n<code>bun add -g @openai/codex</code>",
348
- } as CoreResponse);
349
- }
350
-
351
- // Route based on current backend state
352
- const backend = getBackend(msg.chatId);
353
- const canFallback = backend === "codex" ? deps.claude : deps.codex;
354
- let agentResponse: string;
355
- let usedBackend: "claude" | "codex" = backend;
356
-
357
- log.info(`Routing | backend: ${backend} | messageChars: ${messageText.length}`);
358
-
359
- if (backend === "codex") {
360
- try {
361
- agentResponse = await processWithCodex(messageText);
362
- if (agentResponse.startsWith("Error processing with Codex") && canFallback) {
363
- log.warn("Codex failed, falling back to Claude");
364
- agentResponse = await processWithClaude(messageText, msg.chatId);
365
- usedBackend = "claude";
366
- }
367
- } catch (error) {
368
- if (canFallback) {
369
- log.warn(`Codex threw, falling back to Claude: ${error}`);
370
- agentResponse = await processWithClaude(messageText, msg.chatId);
371
- usedBackend = "claude";
372
- } else {
373
- agentResponse = "Error processing with Codex. Please try again.";
374
- }
375
- }
376
- } else {
377
- try {
378
- agentResponse = await processWithClaude(messageText, msg.chatId);
379
- if (agentResponse.startsWith("Error:") && canFallback) {
380
- log.warn("Claude failed, falling back to Codex");
381
- agentResponse = await processWithCodex(messageText);
382
- usedBackend = "codex";
383
- }
384
- if (isClaudeRateLimitResponse(agentResponse) && canFallback) {
385
- log.warn("Claude credits exhausted, falling back to Codex");
386
- const codexResponse = await processWithCodex(messageText);
387
- if (isCodexAuthRequiredResponse(codexResponse)) {
388
- agentResponse = `${agentResponse}\n---CHUNK---\n${codexResponse}`;
389
- } else {
390
- agentResponse = `Claude is out of credits right now, so I switched this reply to Codex.\n---CHUNK---\n${codexResponse}`;
391
- usedBackend = "codex";
392
- backendState.set(msg.chatId, "codex");
393
- }
394
- }
395
- } catch (error) {
396
- const errMsg = error instanceof Error ? error.message : String(error);
397
- if (canFallback) {
398
- log.warn(`Claude threw, falling back to Codex: ${errMsg}`);
399
- agentResponse = await processWithCodex(messageText);
400
- usedBackend = "codex";
401
- } else {
402
- agentResponse = `Claude error: ${errMsg.slice(0, 200)}`;
403
- }
404
- }
405
- }
406
-
407
- log.info(`Response | backend: ${usedBackend} | responseChars: ${agentResponse.length}`);
408
- log.debug(`Response raw >>>>\n${agentResponse}\n<<<<`);
409
-
410
- // Detect [VOICE]...[/VOICE] tags — generate speech via ElevenLabs
411
- let audioPath: string | undefined;
412
- let textResponse = agentResponse;
413
-
414
- const voiceMatch = agentResponse.match(/\[VOICE\]([\s\S]*?)\[\/VOICE\]/);
415
- if (voiceMatch && isSpeechConfigured()) {
416
- const speechText = voiceMatch[1].trim();
417
- textResponse = agentResponse.replace(/\[VOICE\][\s\S]*?\[\/VOICE\]/, "").trim();
418
- try {
419
- audioPath = await generateSpeech(speechText, config.elevenlabsVoiceId);
420
- log.info(`Speech generated for ${speechText.length} chars`);
421
- } catch (error) {
422
- log.error(`Speech generation failed: ${error}`);
423
- // Fallback: send the voice text as regular text so the message isn't empty
424
- if (!textResponse) {
425
- textResponse = speechText;
426
- }
427
- }
428
- }
429
-
430
- // Prepend onboarding info if first message (non-blocking)
431
- const fullResponse = onboarding
432
- ? onboarding.message + "\n\n" + textResponse
433
- : textResponse;
434
-
435
- const files = detectFiles(textResponse);
436
-
437
- const response: CoreResponse = {
438
- text: fullResponse,
439
- files: files.length > 0 ? files : undefined,
440
- audio: audioPath,
441
- };
442
-
443
- return Response.json(response);
444
- } catch (error) {
445
- const errMsg = error instanceof Error ? error.message : String(error);
446
- log.error(`Request processing error: ${errMsg}`);
447
- const summary = errMsg.length > 200 ? errMsg.slice(0, 200) + "..." : errMsg;
448
- return Response.json({ text: `Internal error: ${summary}` } as CoreResponse);
449
- }
450
- }
451
-
452
- return Response.json({ error: "Not found" }, { status: 404 });
453
- },
454
- });
455
-
456
- log.info(`Core server listening on ${config.coreSocket}`);