arisa 2.3.55 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +102 -0
  2. package/README.md +120 -165
  3. package/cli/openai-transcribe/index.js +51 -0
  4. package/cli/openai-transcribe/package.json +6 -0
  5. package/cli/openai-transcribe/tool.manifest.json +15 -0
  6. package/cli/openai-tts/index.js +58 -0
  7. package/cli/openai-tts/package.json +6 -0
  8. package/cli/openai-tts/tool.manifest.json +20 -0
  9. package/cli/web-browser/index.js +146 -0
  10. package/cli/web-browser/package.json +6 -0
  11. package/cli/web-browser/tool.manifest.json +8 -0
  12. package/package.json +26 -44
  13. package/src/core/agent/agent-manager.js +218 -0
  14. package/src/core/artifacts/artifact-store.js +102 -0
  15. package/src/core/config/config-store.js +20 -0
  16. package/src/core/tools/tool-registry.js +117 -0
  17. package/src/index.js +27 -0
  18. package/src/runtime/bootstrap.js +213 -0
  19. package/src/runtime/create-app.js +22 -0
  20. package/src/transport/telegram/auth.js +13 -0
  21. package/src/transport/telegram/bot.js +214 -0
  22. package/src/transport/telegram/media.js +75 -0
  23. package/CLAUDE.md +0 -191
  24. package/SOUL.md +0 -36
  25. package/bin/arisa.js +0 -644
  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
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}`);
@@ -1,119 +0,0 @@
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
- }