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.
- package/AGENTS.md +102 -0
- package/README.md +120 -165
- package/bin/arisa.js +2 -643
- package/cli/openai-transcribe/index.js +51 -0
- package/cli/openai-transcribe/package.json +6 -0
- package/cli/openai-transcribe/tool.manifest.json +15 -0
- package/cli/openai-tts/index.js +58 -0
- package/cli/openai-tts/package.json +6 -0
- package/cli/openai-tts/tool.manifest.json +20 -0
- package/cli/web-browser/index.js +146 -0
- package/cli/web-browser/package.json +6 -0
- package/cli/web-browser/tool.manifest.json +8 -0
- package/package.json +26 -44
- package/src/core/agent/agent-manager.js +218 -0
- package/src/core/artifacts/artifact-store.js +102 -0
- package/src/core/config/config-store.js +20 -0
- package/src/core/tools/tool-registry.js +117 -0
- package/src/index.js +27 -0
- package/src/runtime/bootstrap.js +213 -0
- package/src/runtime/create-app.js +22 -0
- package/src/transport/telegram/auth.js +13 -0
- package/src/transport/telegram/bot.js +214 -0
- package/src/transport/telegram/media.js +75 -0
- package/CLAUDE.md +0 -191
- package/SOUL.md +0 -36
- package/scripts/dump-commands.ts +0 -26
- package/scripts/test-secrets.ts +0 -22
- package/src/core/attachments.ts +0 -104
- package/src/core/auth.ts +0 -58
- package/src/core/context.ts +0 -30
- package/src/core/file-detector.ts +0 -39
- package/src/core/format.ts +0 -159
- package/src/core/index.ts +0 -456
- package/src/core/intent.ts +0 -119
- package/src/core/media.ts +0 -144
- package/src/core/onboarding.ts +0 -102
- package/src/core/processor.ts +0 -305
- package/src/core/router.ts +0 -64
- package/src/core/scheduler.ts +0 -193
- package/src/daemon/agent-cli.ts +0 -130
- package/src/daemon/auto-install.ts +0 -158
- package/src/daemon/autofix.ts +0 -116
- package/src/daemon/bridge.ts +0 -166
- package/src/daemon/channels/base.ts +0 -10
- package/src/daemon/channels/telegram.ts +0 -306
- package/src/daemon/claude-login.ts +0 -218
- package/src/daemon/codex-login.ts +0 -172
- package/src/daemon/fallback.ts +0 -73
- package/src/daemon/index.ts +0 -272
- package/src/daemon/lifecycle.ts +0 -313
- package/src/daemon/setup.ts +0 -329
- package/src/shared/ai-cli.ts +0 -165
- package/src/shared/config.ts +0 -137
- package/src/shared/db.ts +0 -304
- package/src/shared/deepbase-secure.ts +0 -39
- package/src/shared/ink-shim.js +0 -14
- package/src/shared/logger.ts +0 -42
- package/src/shared/paths.ts +0 -90
- package/src/shared/ports.ts +0 -120
- package/src/shared/secrets.ts +0 -136
- package/src/shared/types.ts +0 -103
- package/tsconfig.json +0 -19
package/src/core/format.ts
DELETED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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(/&/g, "&")
|
|
22
|
-
.replace(/</g, "<")
|
|
23
|
-
.replace(/>/g, ">")
|
|
24
|
-
.replace(/"/g, '"')
|
|
25
|
-
.replace(/'/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. <code> → <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}`);
|