arisa 2.3.16 → 2.3.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +16 -7
- package/scripts/test-secrets.ts +22 -0
- package/src/core/attachments.ts +104 -0
- package/src/core/auth.ts +58 -0
- package/src/core/context.ts +30 -0
- package/src/core/file-detector.ts +39 -0
- package/src/core/format.ts +159 -0
- package/src/core/history.ts +193 -0
- package/src/core/index.ts +464 -0
- package/src/core/intent.ts +119 -0
- package/src/core/media.ts +144 -0
- package/src/core/onboarding.ts +102 -0
- package/src/core/processor.ts +309 -0
- package/src/core/router.ts +64 -0
- package/src/core/scheduler.ts +193 -0
- package/src/daemon/agent-cli.ts +129 -0
- package/src/daemon/auto-install.ts +148 -0
- package/src/daemon/autofix.ts +116 -0
- package/src/daemon/bridge.ts +166 -0
- package/src/daemon/channels/base.ts +10 -0
- package/src/daemon/channels/telegram.ts +306 -0
- package/src/daemon/claude-login.ts +215 -0
- package/src/daemon/codex-login.ts +172 -0
- package/src/daemon/fallback.ts +49 -0
- package/src/daemon/index.ts +262 -0
- package/src/daemon/lifecycle.ts +289 -0
- package/src/daemon/setup.ts +381 -0
- package/src/shared/ai-cli.ts +115 -0
- package/src/shared/config.ts +137 -0
- package/src/shared/db.ts +304 -0
- package/src/shared/deepbase-secure.ts +39 -0
- package/src/shared/ink-shim.js +7 -0
- package/src/shared/logger.ts +42 -0
- package/src/shared/paths.ts +90 -0
- package/src/shared/ports.ts +116 -0
- package/src/shared/secrets.ts +136 -0
- package/src/shared/types.ts +103 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,464 @@
|
|
|
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 { addExchange, getForeignContext, clearHistory, getLastBackend } from "./history";
|
|
31
|
+
import { getOnboarding, checkDeps } from "./onboarding";
|
|
32
|
+
import { initScheduler, addTask, cancelAllChatTasks } from "./scheduler";
|
|
33
|
+
import { detectScheduleIntent } from "./intent";
|
|
34
|
+
import { initAuth, isAuthorized, tryAuthorize } from "./auth";
|
|
35
|
+
import { initAttachments, saveAttachment } from "./attachments";
|
|
36
|
+
import { saveMessageRecord, getMessageRecord } from "../shared/db";
|
|
37
|
+
|
|
38
|
+
const log = createLogger("core");
|
|
39
|
+
|
|
40
|
+
// Kill previous Core if still running, write our PID
|
|
41
|
+
claimProcess("core");
|
|
42
|
+
|
|
43
|
+
// Per-chat backend state — default based on what's installed (claude > codex)
|
|
44
|
+
const backendState = new Map<string, "claude" | "codex">();
|
|
45
|
+
|
|
46
|
+
function defaultBackend(): "claude" | "codex" {
|
|
47
|
+
const deps = checkDeps();
|
|
48
|
+
return deps.claude ? "claude" : "codex";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getBackend(chatId: string): "claude" | "codex" {
|
|
52
|
+
const deps = checkDeps();
|
|
53
|
+
|
|
54
|
+
const preferInstalled = (candidate: "claude" | "codex"): "claude" | "codex" => {
|
|
55
|
+
if (candidate === "claude" && !deps.claude && deps.codex) return "codex";
|
|
56
|
+
if (candidate === "codex" && !deps.codex && deps.claude) return "claude";
|
|
57
|
+
return candidate;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const current = backendState.get(chatId);
|
|
61
|
+
if (current) return preferInstalled(current);
|
|
62
|
+
|
|
63
|
+
const fromHistory = getLastBackend(chatId);
|
|
64
|
+
if (fromHistory) {
|
|
65
|
+
const resolved = preferInstalled(fromHistory);
|
|
66
|
+
backendState.set(chatId, resolved);
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return preferInstalled(defaultBackend());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Initialize auth + scheduler + attachments
|
|
74
|
+
await initAuth();
|
|
75
|
+
await initScheduler();
|
|
76
|
+
await initAttachments();
|
|
77
|
+
|
|
78
|
+
const server = await serveWithRetry({
|
|
79
|
+
unix: config.coreSocket,
|
|
80
|
+
async fetch(req) {
|
|
81
|
+
const url = new URL(req.url);
|
|
82
|
+
|
|
83
|
+
if (url.pathname === "/health" && req.method === "GET") {
|
|
84
|
+
return Response.json({ status: "ok", timestamp: Date.now() });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (url.pathname === "/message" && req.method === "POST") {
|
|
88
|
+
try {
|
|
89
|
+
const body = await req.json();
|
|
90
|
+
const msg: IncomingMessage = body.message;
|
|
91
|
+
|
|
92
|
+
if (!msg) {
|
|
93
|
+
return Response.json({ error: "Missing message" }, { status: 400 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
log.debug(`Inbound message | chatId=${msg.chatId} | sender=${msg.sender} | type=${msg.text ? "text" : "media"}`);
|
|
97
|
+
|
|
98
|
+
// Auth gate: require token before anything else
|
|
99
|
+
if (!isAuthorized(msg.chatId)) {
|
|
100
|
+
if (msg.text && await tryAuthorize(msg.chatId, msg.text)) {
|
|
101
|
+
return Response.json({ text: "Authorized. Welcome to Arisa!" } as CoreResponse);
|
|
102
|
+
}
|
|
103
|
+
return Response.json({ text: "Send the auth token to start. Check the server console." } as CoreResponse);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Onboarding: first message from this chat
|
|
107
|
+
const onboarding = await getOnboarding(msg.chatId);
|
|
108
|
+
if (onboarding?.blocking) {
|
|
109
|
+
return Response.json({ text: onboarding.message } as CoreResponse);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Initialize message text
|
|
113
|
+
let messageText = msg.text || "";
|
|
114
|
+
|
|
115
|
+
// Prepend reply context if message quotes another message
|
|
116
|
+
if (msg.replyTo) {
|
|
117
|
+
let quotedText = msg.replyTo.text || "";
|
|
118
|
+
let quotedSender = msg.replyTo.sender;
|
|
119
|
+
let quotedDate = new Date(msg.replyTo.timestamp).toLocaleString("es-AR");
|
|
120
|
+
let attachmentInfo = "";
|
|
121
|
+
|
|
122
|
+
// Try ledger lookup for richer context
|
|
123
|
+
if (msg.replyTo.messageId) {
|
|
124
|
+
const ledger = await getMessageRecord(msg.chatId, msg.replyTo.messageId);
|
|
125
|
+
if (ledger) {
|
|
126
|
+
quotedText = ledger.text || quotedText;
|
|
127
|
+
quotedSender = ledger.sender;
|
|
128
|
+
quotedDate = new Date(ledger.timestamp).toLocaleString("es-AR");
|
|
129
|
+
if (ledger.mediaDescription) {
|
|
130
|
+
attachmentInfo += `\nMedia description: ${ledger.mediaDescription}`;
|
|
131
|
+
}
|
|
132
|
+
if (ledger.attachmentPath) {
|
|
133
|
+
attachmentInfo += `\nAttachment: ${ledger.attachmentPath}`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!quotedText && !attachmentInfo) {
|
|
139
|
+
quotedText = "[media or unknown content]";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
messageText = `━━━ QUOTED MESSAGE ━━━
|
|
143
|
+
From: ${quotedSender}
|
|
144
|
+
Date: ${quotedDate}
|
|
145
|
+
Content: "${quotedText}"${attachmentInfo}
|
|
146
|
+
━━━━━━━━━━━━━━━━━━━━
|
|
147
|
+
|
|
148
|
+
${messageText}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Handle /reset command
|
|
152
|
+
if (msg.command === "/reset") {
|
|
153
|
+
const { writeFileSync } = await import("fs");
|
|
154
|
+
writeFileSync(config.resetFlagPath, "reset");
|
|
155
|
+
clearHistory(msg.chatId);
|
|
156
|
+
const { resetRouterState } = await import("./router");
|
|
157
|
+
resetRouterState();
|
|
158
|
+
const response: CoreResponse = { text: "Conversation reset! Next message will start a fresh conversation." };
|
|
159
|
+
return Response.json(response);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Handle /cancel command — stop all scheduled tasks
|
|
163
|
+
if (msg.command === "/cancel") {
|
|
164
|
+
const removed = await cancelAllChatTasks(msg.chatId);
|
|
165
|
+
const text = removed > 0
|
|
166
|
+
? `Cancelled ${removed} task${removed > 1 ? "s" : ""}.`
|
|
167
|
+
: "No active tasks to cancel.";
|
|
168
|
+
return Response.json({ text } as CoreResponse);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Handle /codex command — switch to codex backend
|
|
172
|
+
if (msg.command === "/codex") {
|
|
173
|
+
const deps = checkDeps();
|
|
174
|
+
if (!deps.codex) {
|
|
175
|
+
const hint = deps.os === "macOS"
|
|
176
|
+
? "<code>bun add -g @openai/codex</code>"
|
|
177
|
+
: "<code>bun add -g @openai/codex</code>";
|
|
178
|
+
return Response.json({ text: `Codex CLI is not installed.\n${hint}` } as CoreResponse);
|
|
179
|
+
}
|
|
180
|
+
backendState.set(msg.chatId, "codex");
|
|
181
|
+
log.info(`Backend switched to codex for chat ${msg.chatId}`);
|
|
182
|
+
const response: CoreResponse = { text: "Codex mode activated. Use /claude to switch back." };
|
|
183
|
+
return Response.json(response);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle /claude command — switch to claude backend
|
|
187
|
+
if (msg.command === "/claude") {
|
|
188
|
+
const deps = checkDeps();
|
|
189
|
+
if (!deps.claude) {
|
|
190
|
+
const hint = "<code>bun add -g @anthropic-ai/claude-code</code>";
|
|
191
|
+
return Response.json({ text: `Claude CLI is not installed.\n${hint}` } as CoreResponse);
|
|
192
|
+
}
|
|
193
|
+
backendState.set(msg.chatId, "claude");
|
|
194
|
+
log.info(`Backend switched to claude for chat ${msg.chatId}`);
|
|
195
|
+
const response: CoreResponse = { text: "Claude mode activated. Use /codex to switch back." };
|
|
196
|
+
return Response.json(response);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Handle /speak command — generate speech via ElevenLabs
|
|
200
|
+
if (msg.command === "/speak") {
|
|
201
|
+
if (!config.elevenlabsApiKey) {
|
|
202
|
+
return Response.json({ text: "ELEVENLABS_API_KEY not configured. Add it to ~/.arisa/.env" } as CoreResponse);
|
|
203
|
+
}
|
|
204
|
+
const textToSpeak = messageText.replace(/^\/speak\s*/, "").trim();
|
|
205
|
+
if (!textToSpeak) {
|
|
206
|
+
return Response.json({ text: "Usage: /speak <text to convert to speech>" } as CoreResponse);
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const audioPath = await generateSpeech(textToSpeak);
|
|
210
|
+
const response: CoreResponse = {
|
|
211
|
+
text: "",
|
|
212
|
+
audio: audioPath,
|
|
213
|
+
};
|
|
214
|
+
return Response.json(response);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
log.error(`Speech generation failed: ${error}`);
|
|
217
|
+
return Response.json({ text: "Failed to generate speech. Check logs for details." } as CoreResponse);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Process media first — track metadata for message ledger
|
|
222
|
+
let ledgerMediaType: "image" | "audio" | "document" | undefined;
|
|
223
|
+
let ledgerAttachmentPath: string | undefined;
|
|
224
|
+
let ledgerMediaDescription: string | undefined;
|
|
225
|
+
|
|
226
|
+
if (msg.audio) {
|
|
227
|
+
const audioPath = await saveAttachment(msg.chatId, "audio", msg.audio.base64, msg.audio.filename);
|
|
228
|
+
ledgerMediaType = "audio";
|
|
229
|
+
ledgerAttachmentPath = audioPath;
|
|
230
|
+
if (isMediaConfigured()) {
|
|
231
|
+
try {
|
|
232
|
+
const transcription = await transcribeAudio(msg.audio.base64, msg.audio.filename);
|
|
233
|
+
if (transcription.trim()) {
|
|
234
|
+
ledgerMediaDescription = transcription;
|
|
235
|
+
messageText = `[Audio saved to ${audioPath}]\n[Voice message transcription]: ${transcription}`;
|
|
236
|
+
} else {
|
|
237
|
+
messageText = `[Audio saved to ${audioPath}]\n[Transcription returned empty. Ask the user to try again or send text.]`;
|
|
238
|
+
}
|
|
239
|
+
} catch (error) {
|
|
240
|
+
log.error(`Transcription failed: ${error}`);
|
|
241
|
+
messageText = `[Audio saved to ${audioPath}]\n[Transcription failed. The audio file is still accessible at the path above.]`;
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
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.]`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (msg.image) {
|
|
249
|
+
const caption = msg.image.caption || "";
|
|
250
|
+
const imgPath = await saveAttachment(msg.chatId, "image", msg.image.base64);
|
|
251
|
+
ledgerMediaType = "image";
|
|
252
|
+
ledgerAttachmentPath = imgPath;
|
|
253
|
+
|
|
254
|
+
if (caption && isMediaConfigured()) {
|
|
255
|
+
// User sent text with the image → describe it via Vision
|
|
256
|
+
try {
|
|
257
|
+
const description = await describeImage(msg.image.base64, caption);
|
|
258
|
+
if (description.trim()) {
|
|
259
|
+
ledgerMediaDescription = description;
|
|
260
|
+
messageText = `[Image saved to ${imgPath}]\n[Image description: ${description}]\n${caption}`;
|
|
261
|
+
} else {
|
|
262
|
+
messageText = `[Image saved to ${imgPath}]\n[Image content could not be interpreted]\n${caption}`;
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
log.error(`Image analysis failed: ${error}`);
|
|
266
|
+
messageText = `[Image saved to ${imgPath}]\n[Error analyzing the image]\n${caption}`;
|
|
267
|
+
}
|
|
268
|
+
} else if (caption) {
|
|
269
|
+
// Has caption but no OpenAI key
|
|
270
|
+
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}`;
|
|
271
|
+
} else {
|
|
272
|
+
// No caption → just save, no GPT call
|
|
273
|
+
messageText = `[Image saved to ${imgPath}]`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (msg.document) {
|
|
278
|
+
const docPath = await saveAttachment(msg.chatId, "document", msg.document.base64, msg.document.filename, msg.document.mimeType);
|
|
279
|
+
ledgerMediaType = "document";
|
|
280
|
+
ledgerAttachmentPath = docPath;
|
|
281
|
+
const caption = msg.document.caption || "";
|
|
282
|
+
messageText = caption
|
|
283
|
+
? `[Document saved to ${docPath}] (${msg.document.mimeType})\n${caption}`
|
|
284
|
+
: `[Document saved to ${docPath}] (${msg.document.mimeType})`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!messageText) {
|
|
288
|
+
const response: CoreResponse = { text: "Empty message received." };
|
|
289
|
+
return Response.json(response);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Save incoming message to ledger (after media processing so we have descriptions)
|
|
293
|
+
if (msg.messageId) {
|
|
294
|
+
saveMessageRecord({
|
|
295
|
+
id: `${msg.chatId}_${msg.messageId}`,
|
|
296
|
+
chatId: msg.chatId,
|
|
297
|
+
messageId: msg.messageId,
|
|
298
|
+
direction: "in",
|
|
299
|
+
sender: msg.sender,
|
|
300
|
+
timestamp: msg.timestamp,
|
|
301
|
+
text: messageText,
|
|
302
|
+
mediaType: ledgerMediaType,
|
|
303
|
+
attachmentPath: ledgerAttachmentPath,
|
|
304
|
+
mediaDescription: ledgerMediaDescription,
|
|
305
|
+
}).catch((e) => log.error(`Failed to save incoming message record: ${e}`));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Detect scheduling intent via haiku (language-agnostic)
|
|
309
|
+
const scheduleIntent = await detectScheduleIntent(messageText);
|
|
310
|
+
if (scheduleIntent) {
|
|
311
|
+
if (scheduleIntent.type === "cancel") {
|
|
312
|
+
const removed = await cancelAllChatTasks(msg.chatId);
|
|
313
|
+
const text = removed > 0
|
|
314
|
+
? scheduleIntent.confirmation
|
|
315
|
+
: "No active tasks to cancel.";
|
|
316
|
+
return Response.json({ text } as CoreResponse);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const taskId = `${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
320
|
+
const task: ScheduledTask = {
|
|
321
|
+
id: taskId,
|
|
322
|
+
chatId: msg.chatId,
|
|
323
|
+
sender: msg.sender,
|
|
324
|
+
senderId: msg.senderId,
|
|
325
|
+
type: scheduleIntent.type,
|
|
326
|
+
message: scheduleIntent.message,
|
|
327
|
+
originalMessage: messageText,
|
|
328
|
+
createdAt: Date.now(),
|
|
329
|
+
...(scheduleIntent.type === "once" && scheduleIntent.delaySeconds
|
|
330
|
+
? { runAt: Date.now() + scheduleIntent.delaySeconds * 1000 }
|
|
331
|
+
: {}),
|
|
332
|
+
...(scheduleIntent.type === "cron" && scheduleIntent.cron
|
|
333
|
+
? { cron: scheduleIntent.cron }
|
|
334
|
+
: {}),
|
|
335
|
+
};
|
|
336
|
+
await addTask(task);
|
|
337
|
+
const response: CoreResponse = { text: scheduleIntent.confirmation };
|
|
338
|
+
return Response.json(response);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const deps = checkDeps();
|
|
342
|
+
if (!deps.claude && !deps.codex) {
|
|
343
|
+
return Response.json({
|
|
344
|
+
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>",
|
|
345
|
+
} as CoreResponse);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Route based on current backend state
|
|
349
|
+
const backend = getBackend(msg.chatId);
|
|
350
|
+
const canFallback = backend === "codex" ? deps.claude : deps.codex;
|
|
351
|
+
let agentResponse: string;
|
|
352
|
+
let historyResponse: string | null = null;
|
|
353
|
+
let usedBackend: "claude" | "codex" = backend;
|
|
354
|
+
|
|
355
|
+
// Inject cross-backend context if switching
|
|
356
|
+
const foreignCtx = getForeignContext(msg.chatId, backend);
|
|
357
|
+
const enrichedMessage = foreignCtx ? foreignCtx + messageText : messageText;
|
|
358
|
+
|
|
359
|
+
log.info(`Routing | backend: ${backend} | foreignCtx: ${!!foreignCtx} | enrichedChars: ${enrichedMessage.length}`);
|
|
360
|
+
|
|
361
|
+
if (backend === "codex") {
|
|
362
|
+
try {
|
|
363
|
+
agentResponse = await processWithCodex(enrichedMessage);
|
|
364
|
+
if (agentResponse.startsWith("Error processing with Codex") && canFallback) {
|
|
365
|
+
log.warn("Codex failed, falling back to Claude");
|
|
366
|
+
agentResponse = await processWithClaude(enrichedMessage, msg.chatId);
|
|
367
|
+
usedBackend = "claude";
|
|
368
|
+
}
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (canFallback) {
|
|
371
|
+
log.warn(`Codex threw, falling back to Claude: ${error}`);
|
|
372
|
+
agentResponse = await processWithClaude(enrichedMessage, msg.chatId);
|
|
373
|
+
usedBackend = "claude";
|
|
374
|
+
} else {
|
|
375
|
+
agentResponse = "Error processing with Codex. Please try again.";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
try {
|
|
380
|
+
agentResponse = await processWithClaude(enrichedMessage, msg.chatId);
|
|
381
|
+
if (agentResponse.startsWith("Error:") && canFallback) {
|
|
382
|
+
log.warn("Claude failed, falling back to Codex");
|
|
383
|
+
agentResponse = await processWithCodex(enrichedMessage);
|
|
384
|
+
usedBackend = "codex";
|
|
385
|
+
}
|
|
386
|
+
if (isClaudeRateLimitResponse(agentResponse) && canFallback) {
|
|
387
|
+
log.warn("Claude credits exhausted, falling back to Codex");
|
|
388
|
+
const codexResponse = await processWithCodex(enrichedMessage);
|
|
389
|
+
if (isCodexAuthRequiredResponse(codexResponse)) {
|
|
390
|
+
agentResponse = `${agentResponse}\n---CHUNK---\n${codexResponse}`;
|
|
391
|
+
} else {
|
|
392
|
+
agentResponse = `Claude is out of credits right now, so I switched this reply to Codex.\n---CHUNK---\n${codexResponse}`;
|
|
393
|
+
historyResponse = codexResponse;
|
|
394
|
+
usedBackend = "codex";
|
|
395
|
+
// Persist the switch so subsequent messages don't keep re-injecting
|
|
396
|
+
// cross-backend context while Claude has no credits.
|
|
397
|
+
backendState.set(msg.chatId, "codex");
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
402
|
+
if (canFallback) {
|
|
403
|
+
log.warn(`Claude threw, falling back to Codex: ${errMsg}`);
|
|
404
|
+
agentResponse = await processWithCodex(enrichedMessage);
|
|
405
|
+
usedBackend = "codex";
|
|
406
|
+
} else {
|
|
407
|
+
agentResponse = `Claude error: ${errMsg.slice(0, 200)}`;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Log exchange for shared history
|
|
413
|
+
addExchange(msg.chatId, messageText, historyResponse ?? agentResponse, usedBackend);
|
|
414
|
+
|
|
415
|
+
log.info(`Response | backend: ${usedBackend} | responseChars: ${agentResponse.length}`);
|
|
416
|
+
log.debug(`Response raw >>>>\n${agentResponse}\n<<<<`);
|
|
417
|
+
|
|
418
|
+
// Detect [VOICE]...[/VOICE] tags — generate speech via ElevenLabs
|
|
419
|
+
let audioPath: string | undefined;
|
|
420
|
+
let textResponse = agentResponse;
|
|
421
|
+
|
|
422
|
+
const voiceMatch = agentResponse.match(/\[VOICE\]([\s\S]*?)\[\/VOICE\]/);
|
|
423
|
+
if (voiceMatch && isSpeechConfigured()) {
|
|
424
|
+
const speechText = voiceMatch[1].trim();
|
|
425
|
+
textResponse = agentResponse.replace(/\[VOICE\][\s\S]*?\[\/VOICE\]/, "").trim();
|
|
426
|
+
try {
|
|
427
|
+
audioPath = await generateSpeech(speechText, config.elevenlabsVoiceId);
|
|
428
|
+
log.info(`Speech generated for ${speechText.length} chars`);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
log.error(`Speech generation failed: ${error}`);
|
|
431
|
+
// Fallback: send the voice text as regular text so the message isn't empty
|
|
432
|
+
if (!textResponse) {
|
|
433
|
+
textResponse = speechText;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Prepend onboarding info if first message (non-blocking)
|
|
439
|
+
const fullResponse = onboarding
|
|
440
|
+
? onboarding.message + "\n\n" + textResponse
|
|
441
|
+
: textResponse;
|
|
442
|
+
|
|
443
|
+
const files = detectFiles(textResponse);
|
|
444
|
+
|
|
445
|
+
const response: CoreResponse = {
|
|
446
|
+
text: fullResponse,
|
|
447
|
+
files: files.length > 0 ? files : undefined,
|
|
448
|
+
audio: audioPath,
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
return Response.json(response);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
454
|
+
log.error(`Request processing error: ${errMsg}`);
|
|
455
|
+
const summary = errMsg.length > 200 ? errMsg.slice(0, 200) + "..." : errMsg;
|
|
456
|
+
return Response.json({ text: `Internal error: ${summary}` } as CoreResponse);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
log.info(`Core server listening on ${config.coreSocket}`);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/intent
|
|
3
|
+
* @role Use a fast model to detect scheduling intents from any language.
|
|
4
|
+
* @responsibilities
|
|
5
|
+
* - Classify messages as schedule requests or regular messages
|
|
6
|
+
* - Extract schedule type (once/cron), timing, and reminder text
|
|
7
|
+
* - Works with whatever CLI is available (claude or codex)
|
|
8
|
+
* @dependencies shared/config
|
|
9
|
+
* @effects Spawns claude or codex CLI
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { config } from "../shared/config";
|
|
13
|
+
import { createLogger } from "../shared/logger";
|
|
14
|
+
import { buildBunWrappedAgentCliCommand, resolveAgentCliPath } from "../shared/ai-cli";
|
|
15
|
+
|
|
16
|
+
const log = createLogger("core");
|
|
17
|
+
|
|
18
|
+
export interface ScheduleIntent {
|
|
19
|
+
type: "once" | "cron" | "cancel";
|
|
20
|
+
delaySeconds?: number;
|
|
21
|
+
cron?: string;
|
|
22
|
+
message: string;
|
|
23
|
+
confirmation: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const INTENT_PROMPT = `You are a scheduling intent detector. Analyze the user message and determine if they want to schedule a reminder, recurring notification, or cancel/stop existing tasks.
|
|
27
|
+
|
|
28
|
+
If it IS a scheduling request, respond with ONLY this JSON (no markdown, no explanation):
|
|
29
|
+
For one-time reminders:
|
|
30
|
+
{"type":"once","delaySeconds":300,"message":"the reminder text","confirmation":"I'll remind you in 5 minutes"}
|
|
31
|
+
|
|
32
|
+
For recurring reminders:
|
|
33
|
+
{"type":"cron","cron":"*/5 * * * *","message":"the reminder text","confirmation":"I'll remind you every 5 minutes"}
|
|
34
|
+
|
|
35
|
+
For cancelling/stopping tasks:
|
|
36
|
+
{"type":"cancel","message":"","confirmation":"All tasks cancelled."}
|
|
37
|
+
|
|
38
|
+
If it is NOT a scheduling or cancellation request, respond with ONLY:
|
|
39
|
+
{"type":"none"}
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
- One-time: "in X seconds/minutes/hours" or equivalent in any language → once
|
|
43
|
+
- Recurring: "every X seconds/minutes/hours" or equivalent in any language → cron
|
|
44
|
+
- Cancel: "stop/cancel/remove all tasks/reminders" or equivalent in any language → cancel
|
|
45
|
+
- For seconds-based cron, use 6-field format: */N * * * * *
|
|
46
|
+
- For minutes-based cron: */N * * * *
|
|
47
|
+
- For hours-based cron: 0 */N * * *
|
|
48
|
+
- Extract the actual reminder content, not the scheduling instruction
|
|
49
|
+
- Write the confirmation in the same language as the user's message
|
|
50
|
+
- Support any language
|
|
51
|
+
- Only detect clear scheduling intent, not vague mentions of time`;
|
|
52
|
+
|
|
53
|
+
function buildCmd(cli: "claude" | "codex", prompt: string): string[] {
|
|
54
|
+
if (cli === "claude") {
|
|
55
|
+
return buildBunWrappedAgentCliCommand(
|
|
56
|
+
"claude",
|
|
57
|
+
["--dangerously-skip-permissions", "--model", "haiku", "-p", prompt],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return buildBunWrappedAgentCliCommand(
|
|
61
|
+
"codex",
|
|
62
|
+
["exec", "--dangerously-bypass-approvals-and-sandbox", "-C", config.projectDir, prompt],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Track which CLI actually works (not just Bun.which, which can find broken shims)
|
|
67
|
+
let verifiedCli: "claude" | "codex" | null = null;
|
|
68
|
+
|
|
69
|
+
async function trySpawn(prompt: string, cli: "claude" | "codex"): Promise<string | null> {
|
|
70
|
+
const cmd = buildCmd(cli, prompt);
|
|
71
|
+
const proc = Bun.spawn(cmd, { cwd: config.projectDir, stdout: "pipe", stderr: "pipe" });
|
|
72
|
+
|
|
73
|
+
const timeout = setTimeout(() => proc.kill(), 15_000);
|
|
74
|
+
const exitCode = await proc.exited;
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
|
|
77
|
+
if (exitCode !== 0) return null;
|
|
78
|
+
|
|
79
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getCliOrder(): Array<"claude" | "codex"> {
|
|
83
|
+
if (verifiedCli) return [verifiedCli];
|
|
84
|
+
const order: Array<"claude" | "codex"> = [];
|
|
85
|
+
if (resolveAgentCliPath("claude") !== null) order.push("claude");
|
|
86
|
+
if (resolveAgentCliPath("codex") !== null) order.push("codex");
|
|
87
|
+
return order;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function detectScheduleIntent(message: string): Promise<ScheduleIntent | null> {
|
|
91
|
+
const clis = getCliOrder();
|
|
92
|
+
if (clis.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const fullPrompt = `${INTENT_PROMPT}\n\nUser message: ${message}`;
|
|
95
|
+
|
|
96
|
+
for (const cli of clis) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = await trySpawn(fullPrompt, cli);
|
|
99
|
+
if (raw === null) continue;
|
|
100
|
+
|
|
101
|
+
// This CLI works — remember it
|
|
102
|
+
verifiedCli = cli;
|
|
103
|
+
|
|
104
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
105
|
+
if (!jsonMatch) return null;
|
|
106
|
+
|
|
107
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
108
|
+
if (parsed.type === "none") return null;
|
|
109
|
+
if (parsed.type !== "once" && parsed.type !== "cron" && parsed.type !== "cancel") return null;
|
|
110
|
+
|
|
111
|
+
return parsed as ScheduleIntent;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
log.warn(`Intent detection with ${cli} failed: ${e}`);
|
|
114
|
+
// Try next CLI
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|