arisa 2.0.9 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "2.0.9",
3
+ "version": "2.1.1",
4
4
  "description": "Arisa - dynamic agent runtime with daemon/core architecture that evolves through user interaction",
5
5
  "preferGlobal": true,
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "core": "bun src/core/index.ts"
28
28
  },
29
29
  "dependencies": {
30
+ "@inquirer/prompts": "^8.2.0",
30
31
  "croner": "^9.0.0",
31
32
  "crypto-js": "^4.2.0",
32
33
  "deepbase": "^3.4.9",
package/src/core/index.ts CHANGED
@@ -76,7 +76,7 @@ await initScheduler();
76
76
  await initAttachments();
77
77
 
78
78
  const server = await serveWithRetry({
79
- port: config.corePort,
79
+ unix: config.coreSocket,
80
80
  async fetch(req) {
81
81
  const url = new URL(req.url);
82
82
 
@@ -461,4 +461,4 @@ ${messageText}`;
461
461
  },
462
462
  });
463
463
 
464
- log.info(`Core server listening on port ${config.corePort}`);
464
+ log.info(`Core server listening on ${config.coreSocket}`);
@@ -59,14 +59,15 @@ async function executeTask(task: ScheduledTask) {
59
59
  if (!tasks.includes(task) || !result) return;
60
60
 
61
61
  // Send the processed result to Telegram via Daemon
62
- const response = await fetch(`http://localhost:${config.daemonPort}/send`, {
62
+ const response = await fetch("http://localhost/daemon/send", {
63
63
  method: "POST",
64
64
  headers: { "Content-Type": "application/json" },
65
65
  body: JSON.stringify({
66
66
  chatId: task.chatId,
67
67
  text: result,
68
68
  }),
69
- });
69
+ unix: config.daemonSocket,
70
+ } as any);
70
71
  if (!response.ok) {
71
72
  log.error(`Daemon returned ${response.status} for task ${task.id}`);
72
73
  }
@@ -2,7 +2,7 @@
2
2
  * @module daemon/bridge
3
3
  * @role HTTP client from Daemon to Core with smart fallback to local AI CLI.
4
4
  * @responsibilities
5
- * - POST messages to Core at :51777/message
5
+ * - POST messages to Core via Unix socket
6
6
  * - Respect Core lifecycle state (starting/up/down)
7
7
  * - Wait for Core during startup, fallback only when truly down
8
8
  * - Serialize fallback calls (one CLI process at a time)
@@ -18,7 +18,7 @@ import { getCoreState, getCoreError, waitForCoreReady } from "./lifecycle";
18
18
 
19
19
  const log = createLogger("daemon");
20
20
 
21
- const CORE_URL = `http://localhost:${config.corePort}`;
21
+ const CORE_URL = "http://localhost/core";
22
22
  const STARTUP_WAIT_MS = 15_000;
23
23
  const RETRY_DELAY = 3000;
24
24
 
@@ -135,7 +135,8 @@ async function postToCore(message: IncomingMessage): Promise<CoreResponse> {
135
135
  headers: { "Content-Type": "application/json" },
136
136
  body: JSON.stringify({ message }),
137
137
  signal: AbortSignal.timeout(config.claudeTimeout + 5000),
138
- });
138
+ unix: config.coreSocket,
139
+ } as any);
139
140
 
140
141
  if (!response.ok) {
141
142
  throw new Error(`Core returned ${response.status}`);
@@ -154,7 +155,10 @@ function sleep(ms: number): Promise<void> {
154
155
 
155
156
  export async function isCoreHealthy(): Promise<boolean> {
156
157
  try {
157
- const response = await fetch(`${CORE_URL}/health`, { signal: AbortSignal.timeout(2000) });
158
+ const response = await fetch(`${CORE_URL}/health`, {
159
+ signal: AbortSignal.timeout(2000),
160
+ unix: config.coreSocket,
161
+ } as any);
158
162
  return response.ok;
159
163
  } catch {
160
164
  return false;
@@ -16,6 +16,8 @@ import { buildBunWrappedAgentCliCommand } from "../shared/ai-cli";
16
16
  const log = createLogger("daemon");
17
17
 
18
18
  const AUTH_HINT_PATTERNS = [
19
+ /not logged in/i,
20
+ /please run \/login/i,
19
21
  /invalid.*api.?key/i,
20
22
  /authentication.*failed/i,
21
23
  /not authenticated/i,
@@ -23,7 +23,7 @@ const { config } = await import("../shared/config");
23
23
  // Initialize encrypted secrets
24
24
  await config.secrets.initialize();
25
25
  const { createLogger } = await import("../shared/logger");
26
- const { serveWithRetry, claimProcess, releaseProcess } = await import("../shared/ports");
26
+ const { serveWithRetry, claimProcess, releaseProcess, cleanupSocket } = await import("../shared/ports");
27
27
  const { TelegramChannel } = await import("./channels/telegram");
28
28
  const { sendToCore } = await import("./bridge");
29
29
  const { startCore, stopCore, setLifecycleNotify } = await import("./lifecycle");
@@ -175,7 +175,7 @@ telegram.onMessage(async (msg) => {
175
175
 
176
176
  // --- HTTP server for Core → Daemon pushes (scheduler) ---
177
177
  const pushServer = await serveWithRetry({
178
- port: config.daemonPort,
178
+ unix: config.daemonSocket,
179
179
  async fetch(req) {
180
180
  const url = new URL(req.url);
181
181
 
@@ -220,7 +220,7 @@ const pushServer = await serveWithRetry({
220
220
  },
221
221
  });
222
222
 
223
- log.info(`Daemon push server listening on port ${config.daemonPort}`);
223
+ log.info(`Daemon push server listening on ${config.daemonSocket}`);
224
224
 
225
225
  // --- Auto-install missing CLIs (non-blocking) ---
226
226
  void autoInstallMissingClis();
@@ -238,6 +238,8 @@ telegram.connect().catch((error) => {
238
238
  function shutdown() {
239
239
  log.info("Shutting down Daemon...");
240
240
  stopCore();
241
+ cleanupSocket(config.daemonSocket);
242
+ cleanupSocket(config.coreSocket);
241
243
  releaseProcess("daemon");
242
244
  process.exit(0);
243
245
  }
@@ -91,9 +91,10 @@ function startHealthCheck() {
91
91
  return;
92
92
  }
93
93
  try {
94
- const res = await fetch(`http://localhost:${config.corePort}/health`, {
94
+ const res = await fetch("http://localhost/core/health", {
95
95
  signal: AbortSignal.timeout(2000),
96
- });
96
+ unix: config.coreSocket,
97
+ } as any);
97
98
  if (res.ok) {
98
99
  coreState = "up";
99
100
  log.info("Core is ready (health check passed)");
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * @module daemon/setup
3
- * @role Interactive first-run setup. Prompts for missing config via stdin.
3
+ * @role Interactive first-run setup with inquirer prompts.
4
4
  * @responsibilities
5
5
  * - Check required config (TELEGRAM_BOT_TOKEN)
6
6
  * - Check optional config (OPENAI_API_KEY)
7
- * - Prompt user interactively and save to runtime .env
8
- * @dependencies shared/paths (avoids importing config to prevent module caching issues)
9
- * @effects Reads stdin, writes runtime .env
7
+ * - Detect / install missing CLIs (Claude, Codex)
8
+ * - Run interactive login flows for installed CLIs
9
+ * - Persist tokens to both .env and encrypted DB
10
+ * @dependencies shared/paths, shared/secrets, shared/ai-cli
11
+ * @effects Reads stdin, writes runtime .env, spawns install/login processes
10
12
  */
11
13
 
12
14
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
13
15
  import { dirname, join } from "path";
14
16
  import { dataDir } from "../shared/paths";
15
17
  import { secrets, setSecret } from "../shared/secrets";
18
+ import { isAgentCliInstalled, buildBunWrappedAgentCliCommand, type AgentCliName } from "../shared/ai-cli";
16
19
 
17
20
  const ENV_PATH = join(dataDir, ".env");
18
21
  const SETUP_DONE_KEY = "ARISA_SETUP_COMPLETE";
19
22
 
23
+ const CLI_PACKAGES: Record<AgentCliName, string> = {
24
+ claude: "@anthropic-ai/claude-code",
25
+ codex: "@openai/codex",
26
+ };
27
+
20
28
  function loadExistingEnv(): Record<string, string> {
21
29
  if (!existsSync(ENV_PATH)) return {};
22
30
  const vars: Record<string, string> = {};
@@ -36,7 +44,8 @@ function saveEnv(vars: Record<string, string>) {
36
44
  writeFileSync(ENV_PATH, content);
37
45
  }
38
46
 
39
- async function prompt(question: string): Promise<string> {
47
+ // Fallback readline for non-TTY environments
48
+ async function readLine(question: string): Promise<string> {
40
49
  process.stdout.write(question);
41
50
  for await (const line of console) {
42
51
  return line.trim();
@@ -50,18 +59,44 @@ export async function runSetup(): Promise<boolean> {
50
59
  const openaiSecret = await secrets.openai();
51
60
  let changed = false;
52
61
  const setupDone = vars[SETUP_DONE_KEY] === "1" || process.env[SETUP_DONE_KEY] === "1";
62
+ const isFirstRun = !setupDone;
63
+
64
+ // Try to load inquirer for interactive mode
65
+ let inq: typeof import("@inquirer/prompts") | null = null;
66
+ if (process.stdin.isTTY) {
67
+ try {
68
+ inq = await import("@inquirer/prompts");
69
+ } catch {
70
+ // Fall back to basic prompts
71
+ }
72
+ }
73
+
74
+ // ─── Phase 1: Tokens ────────────────────────────────────────────
75
+
76
+ const hasTelegram = !!(vars.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || telegramSecret);
77
+ const hasOpenAI = !!(vars.OPENAI_API_KEY || process.env.OPENAI_API_KEY || openaiSecret);
78
+
79
+ if (!hasTelegram) {
80
+ if (isFirstRun) console.log("\n🔧 Arisa Setup\n");
53
81
 
54
- // Required: TELEGRAM_BOT_TOKEN
55
- if (!vars.TELEGRAM_BOT_TOKEN && !process.env.TELEGRAM_BOT_TOKEN && !telegramSecret) {
56
- console.log("\n🔧 Arisa Setup\n");
57
- console.log("Telegram Bot Token required. Get one from @BotFather on Telegram.");
58
- const token = await prompt("TELEGRAM_BOT_TOKEN: ");
59
- if (!token) {
82
+ let token: string;
83
+ if (inq) {
84
+ token = await inq.input({
85
+ message: "Telegram Bot Token (from @BotFather):",
86
+ validate: (v) => (v.trim() ? true : "Token is required"),
87
+ });
88
+ } else {
89
+ console.log("Telegram Bot Token required. Get one from @BotFather on Telegram.");
90
+ token = await readLine("TELEGRAM_BOT_TOKEN: ");
91
+ }
92
+
93
+ if (!token.trim()) {
60
94
  console.log("No token provided. Cannot start without Telegram Bot Token.");
61
95
  return false;
62
96
  }
63
- vars.TELEGRAM_BOT_TOKEN = token;
64
- await setSecret("TELEGRAM_BOT_TOKEN", token).catch((e) =>
97
+
98
+ vars.TELEGRAM_BOT_TOKEN = token.trim();
99
+ await setSecret("TELEGRAM_BOT_TOKEN", token.trim()).catch((e) =>
65
100
  console.warn(`[setup] Could not persist TELEGRAM_BOT_TOKEN to encrypted DB: ${e}`)
66
101
  );
67
102
  console.log("[setup] TELEGRAM_BOT_TOKEN saved to .env + encrypted DB");
@@ -71,33 +106,168 @@ export async function runSetup(): Promise<boolean> {
71
106
  console.log(`[setup] TELEGRAM_BOT_TOKEN found in ${src}`);
72
107
  }
73
108
 
74
- // Optional: OPENAI_API_KEY
75
- if (!vars.OPENAI_API_KEY && !process.env.OPENAI_API_KEY && !openaiSecret && !setupDone) {
76
- if (!changed) console.log("\n🔧 Arisa Setup\n");
77
- console.log("\nOpenAI API Key (optional — enables voice transcription + image analysis).");
78
- const key = await prompt("OPENAI_API_KEY (enter to skip): ");
79
- if (key) {
80
- vars.OPENAI_API_KEY = key;
81
- await setSecret("OPENAI_API_KEY", key).catch((e) =>
109
+ if (!hasOpenAI && isFirstRun) {
110
+ let key: string;
111
+ if (inq) {
112
+ key = await inq.input({
113
+ message: "OpenAI API Key (optional — voice + image, enter to skip):",
114
+ });
115
+ } else {
116
+ console.log("\nOpenAI API Key (optional — enables voice transcription + image analysis).");
117
+ key = await readLine("OPENAI_API_KEY (enter to skip): ");
118
+ }
119
+
120
+ if (key.trim()) {
121
+ vars.OPENAI_API_KEY = key.trim();
122
+ await setSecret("OPENAI_API_KEY", key.trim()).catch((e) =>
82
123
  console.warn(`[setup] Could not persist OPENAI_API_KEY to encrypted DB: ${e}`)
83
124
  );
84
125
  console.log("[setup] OPENAI_API_KEY saved to .env + encrypted DB");
85
126
  changed = true;
86
127
  }
87
- } else if (vars.OPENAI_API_KEY || process.env.OPENAI_API_KEY || openaiSecret) {
128
+ } else if (hasOpenAI) {
88
129
  const src = openaiSecret ? "encrypted DB" : vars.OPENAI_API_KEY ? ".env" : "env var";
89
130
  console.log(`[setup] OPENAI_API_KEY found in ${src}`);
90
131
  }
91
132
 
133
+ // Save tokens
92
134
  if (!setupDone) {
93
135
  vars[SETUP_DONE_KEY] = "1";
94
136
  changed = true;
95
137
  }
96
-
97
138
  if (changed) {
98
139
  saveEnv(vars);
99
- console.log(`\nConfig saved to ${ENV_PATH}\n`);
140
+ console.log(`\nConfig saved to ${ENV_PATH}`);
141
+ }
142
+
143
+ // ─── Phase 2: CLI Installation (first run, interactive) ─────────
144
+
145
+ if (isFirstRun && process.stdin.isTTY) {
146
+ await setupClis(inq);
100
147
  }
101
148
 
102
149
  return true;
103
150
  }
151
+
152
+ async function setupClis(inq: typeof import("@inquirer/prompts") | null) {
153
+ let claudeInstalled = isAgentCliInstalled("claude");
154
+ let codexInstalled = isAgentCliInstalled("codex");
155
+
156
+ console.log("\nCLI Status:");
157
+ console.log(` ${claudeInstalled ? "✓" : "✗"} Claude${claudeInstalled ? "" : " — not installed"}`);
158
+ console.log(` ${codexInstalled ? "✓" : "✗"} Codex${codexInstalled ? "" : " — not installed"}`);
159
+
160
+ // Install missing CLIs
161
+ const missing: AgentCliName[] = [];
162
+ if (!claudeInstalled) missing.push("claude");
163
+ if (!codexInstalled) missing.push("codex");
164
+
165
+ if (missing.length > 0) {
166
+ let toInstall: AgentCliName[] = [];
167
+
168
+ if (inq) {
169
+ toInstall = await inq.checkbox({
170
+ message: "Install missing CLIs? (space to select, enter to confirm)",
171
+ choices: missing.map((cli) => ({
172
+ name: `${cli === "claude" ? "Claude" : "Codex"} (${CLI_PACKAGES[cli]})`,
173
+ value: cli as AgentCliName,
174
+ checked: true,
175
+ })),
176
+ });
177
+ } else {
178
+ // Non-inquirer fallback: install all
179
+ const answer = await readLine("\nInstall missing CLIs? (Y/n): ");
180
+ if (answer.toLowerCase() !== "n") toInstall = missing;
181
+ }
182
+
183
+ for (const cli of toInstall) {
184
+ console.log(`\nInstalling ${cli}...`);
185
+ const ok = await installCli(cli);
186
+ console.log(ok ? ` ✓ ${cli} installed` : ` ✗ ${cli} install failed`);
187
+ }
188
+
189
+ // Refresh status
190
+ claudeInstalled = isAgentCliInstalled("claude");
191
+ codexInstalled = isAgentCliInstalled("codex");
192
+ }
193
+
194
+ // Login CLIs
195
+ if (claudeInstalled) {
196
+ let doLogin = true;
197
+ if (inq) {
198
+ doLogin = await inq.confirm({ message: "Log in to Claude?", default: true });
199
+ } else {
200
+ const answer = await readLine("\nLog in to Claude? (Y/n): ");
201
+ doLogin = answer.toLowerCase() !== "n";
202
+ }
203
+ if (doLogin) {
204
+ console.log();
205
+ await runInteractiveLogin("claude");
206
+ }
207
+ }
208
+
209
+ if (codexInstalled) {
210
+ let doLogin = true;
211
+ if (inq) {
212
+ doLogin = await inq.confirm({ message: "Log in to Codex?", default: true });
213
+ } else {
214
+ const answer = await readLine("\nLog in to Codex? (Y/n): ");
215
+ doLogin = answer.toLowerCase() !== "n";
216
+ }
217
+ if (doLogin) {
218
+ console.log();
219
+ await runInteractiveLogin("codex");
220
+ }
221
+ }
222
+
223
+ if (!claudeInstalled && !codexInstalled) {
224
+ console.log("\n⚠ No CLIs installed. Arisa needs at least one to work.");
225
+ console.log(" The daemon will auto-install them in the background.\n");
226
+ } else {
227
+ console.log("\n✓ Setup complete!\n");
228
+ }
229
+ }
230
+
231
+ async function installCli(cli: AgentCliName): Promise<boolean> {
232
+ try {
233
+ const proc = Bun.spawn(["bun", "add", "-g", CLI_PACKAGES[cli]], {
234
+ stdout: "inherit",
235
+ stderr: "inherit",
236
+ });
237
+ const timeout = setTimeout(() => proc.kill(), 120_000);
238
+ const exitCode = await proc.exited;
239
+ clearTimeout(timeout);
240
+ return exitCode === 0;
241
+ } catch (e) {
242
+ console.error(` Install error: ${e}`);
243
+ return false;
244
+ }
245
+ }
246
+
247
+ async function runInteractiveLogin(cli: AgentCliName): Promise<boolean> {
248
+ const args = cli === "claude"
249
+ ? ["setup-token"]
250
+ : ["login", "--device-auth"];
251
+
252
+ console.log(`Starting ${cli} login...`);
253
+
254
+ try {
255
+ const proc = Bun.spawn(buildBunWrappedAgentCliCommand(cli, args), {
256
+ stdin: "inherit",
257
+ stdout: "inherit",
258
+ stderr: "inherit",
259
+ });
260
+ const exitCode = await proc.exited;
261
+
262
+ if (exitCode === 0) {
263
+ console.log(` ✓ ${cli} login successful`);
264
+ return true;
265
+ } else {
266
+ console.log(` ✗ ${cli} login failed (exit ${exitCode})`);
267
+ return false;
268
+ }
269
+ } catch (e) {
270
+ console.error(` Login error: ${e}`);
271
+ return false;
272
+ }
273
+ }
@@ -107,6 +107,8 @@ export const config = {
107
107
 
108
108
  corePort: 51777,
109
109
  daemonPort: 51778,
110
+ coreSocket: join(dataDir, "core.sock"),
111
+ daemonSocket: join(dataDir, "daemon.sock"),
110
112
 
111
113
  // API keys - use async getters for first load
112
114
  get telegramBotToken() { return secureConfig.telegramBotToken; },
@@ -78,19 +78,37 @@ export function releaseProcess(name: string): void {
78
78
  }
79
79
 
80
80
  /**
81
- * Bun.serve() with retry if port is busy, waits for previous process to die.
81
+ * Remove a Unix socket file if it exists (stale leftover from crash).
82
+ */
83
+ export function cleanupSocket(socketPath: string): void {
84
+ try { unlinkSync(socketPath); } catch {}
85
+ }
86
+
87
+ /**
88
+ * Bun.serve() with retry — handles both TCP ports and Unix sockets.
89
+ * For Unix sockets, cleans up stale socket file before first attempt.
82
90
  */
83
91
  export async function serveWithRetry(
84
92
  options: Parameters<typeof Bun.serve>[0],
85
93
  retries = 5,
86
94
  ): Promise<ReturnType<typeof Bun.serve>> {
95
+ const socketPath = (options as any).unix as string | undefined;
96
+
97
+ // Pre-clean stale Unix socket from a previous crash
98
+ if (socketPath) cleanupSocket(socketPath);
99
+
87
100
  for (let i = 0; i < retries; i++) {
88
101
  try {
89
102
  return Bun.serve(options);
90
103
  } catch (e: any) {
91
104
  if (e?.code !== "EADDRINUSE" || i === retries - 1) throw e;
92
- const port = (options as any).port ?? "?";
93
- console.log(`[ports] Port ${port} busy, retrying (${i + 1}/${retries})...`);
105
+ if (socketPath) {
106
+ console.log(`[ports] Socket ${socketPath} busy, cleaning up and retrying (${i + 1}/${retries})...`);
107
+ cleanupSocket(socketPath);
108
+ } else {
109
+ const port = (options as any).port ?? "?";
110
+ console.log(`[ports] Port ${port} busy, retrying (${i + 1}/${retries})...`);
111
+ }
94
112
  await new Promise((r) => setTimeout(r, 1000));
95
113
  }
96
114
  }