arisa 3.1.4 → 3.1.8

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": "3.1.4",
3
+ "version": "3.1.8",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,10 +3,12 @@ import { unlink } from "node:fs/promises";
3
3
  import { createAgentSession, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
- import { loadProjectInstructions } from "./project-instructions.js";
7
6
  import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
+ import { withTimeout } from "./prompt-timeout.js";
8
8
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
9
9
 
10
+ const piValidationTimeoutMs = 60_000;
11
+
10
12
  function isLocalBaseUrl(value) {
11
13
  if (typeof value !== "string" || !value.trim()) return false;
12
14
  try {
@@ -21,6 +23,32 @@ function requiresProviderAuth(model) {
21
23
  return !isLocalBaseUrl(model?.baseUrl);
22
24
  }
23
25
 
26
+ function mimeMatches(pattern, mimeType = "") {
27
+ if (!pattern || !mimeType) return false;
28
+ if (pattern === mimeType) return true;
29
+ if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
30
+ return false;
31
+ }
32
+
33
+ function toolSupportsTextInput(tool) {
34
+ return (tool.input || []).some((input) => mimeMatches(input, "text/plain"));
35
+ }
36
+
37
+ function toolProducesAudio(tool) {
38
+ return (tool.output || []).some((output) => mimeMatches(output, "audio/ogg") || mimeMatches(output, "audio/*"));
39
+ }
40
+
41
+ function looksLikeTextToSpeechTool(tool) {
42
+ return /tts|text.?to.?speech|speech.?audio|speech/i.test(`${tool.name} ${tool.description || ""}`);
43
+ }
44
+
45
+ function selectMediaReplyTool(toolRegistry) {
46
+ const tools = toolRegistry.list()
47
+ .filter(toolSupportsTextInput)
48
+ .filter(toolProducesAudio);
49
+ return tools.find(looksLikeTextToSpeechTool) || tools[0] || null;
50
+ }
51
+
24
52
  export class AgentManager {
25
53
  constructor({ config, artifactStore, toolRegistry, taskStore, logger }) {
26
54
  this.config = config;
@@ -75,7 +103,10 @@ export class AgentManager {
75
103
  model,
76
104
  sessionManager: SessionManager.inMemory(),
77
105
  });
78
- await session.prompt("Reply with exactly: OK");
106
+ await withTimeout(session.prompt("Reply with exactly: OK"), {
107
+ timeoutMs: piValidationTimeoutMs,
108
+ label: "Pi validation prompt"
109
+ });
79
110
  }
80
111
 
81
112
  async getSessionContext(chatId, telegram) {
@@ -117,11 +148,8 @@ export class AgentManager {
117
148
  });
118
149
 
119
150
  if (!hasExistingSession) {
120
- const instructions = await loadProjectInstructions();
121
- const runtimeContext = buildAgentRuntimeContext();
122
- this.logger?.log("agent", `injecting project instructions for chat ${sessionKey}`);
123
- this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${runtimeContext}`);
124
- await session.prompt(`${instructions}\n\n${runtimeContext}\n\nAcknowledge with exactly: OK`);
151
+ this.logger?.log("agent", `created new session for chat ${sessionKey}`);
152
+ this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${buildAgentRuntimeContext()}`);
125
153
  }
126
154
 
127
155
  const ctx = { session, modelId: effectiveModelId };
@@ -323,7 +351,20 @@ export class AgentManager {
323
351
  }),
324
352
  execute: async (_id, params) => {
325
353
  await this.toolRegistry.load();
326
- const toolName = params.toolName || "openai-tts";
354
+ const selectedTool = params.toolName
355
+ ? this.toolRegistry.get(params.toolName)
356
+ : selectMediaReplyTool(this.toolRegistry);
357
+ if (!selectedTool) {
358
+ const result = {
359
+ ok: false,
360
+ status: "failed",
361
+ error: params.toolName
362
+ ? `Tool not found: ${params.toolName}`
363
+ : "No registered text-to-speech tool can generate an audio reply."
364
+ };
365
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
366
+ }
367
+ const toolName = selectedTool.name;
327
368
  this.logger?.log("agent", `send_media_reply via ${toolName}`);
328
369
  const result = await this.toolRegistry.run({
329
370
  name: toolName,
@@ -0,0 +1,22 @@
1
+ export class PromptTimeoutError extends Error {
2
+ constructor(label, timeoutMs) {
3
+ super(`${label} timed out after ${timeoutMs}ms`);
4
+ this.name = "PromptTimeoutError";
5
+ this.timeoutMs = timeoutMs;
6
+ }
7
+ }
8
+
9
+ export async function withTimeout(promise, { timeoutMs, label }) {
10
+ let timer = null;
11
+ const timeout = new Promise((_, reject) => {
12
+ timer = setTimeout(() => {
13
+ reject(new PromptTimeoutError(label, timeoutMs));
14
+ }, timeoutMs);
15
+ });
16
+
17
+ try {
18
+ return await Promise.race([promise, timeout]);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
@@ -0,0 +1,133 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { getToolStateDir, toolStateDir } from "../../runtime/paths.js";
6
+
7
+ export function daemonPaths(toolName) {
8
+ const root = getToolStateDir(toolName);
9
+ return {
10
+ root,
11
+ commandsDir: path.join(root, "commands"),
12
+ pidFile: path.join(root, "daemon.pid"),
13
+ metaFile: path.join(root, "daemon.meta.json"),
14
+ statusFile: path.join(root, "status.json"),
15
+ logFile: path.join(root, "daemon.log")
16
+ };
17
+ }
18
+
19
+ export async function readJson(file, fallback = {}) {
20
+ try {
21
+ return JSON.parse(await readFile(file, "utf8"));
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ export async function writeJson(file, value) {
28
+ await mkdir(path.dirname(file), { recursive: true });
29
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
30
+ }
31
+
32
+ export function isProcessAlive(pid) {
33
+ if (!pid) return false;
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ async function waitForExit(pid, timeoutMs) {
43
+ const startedAt = Date.now();
44
+ while (Date.now() - startedAt < timeoutMs) {
45
+ if (!isProcessAlive(pid)) return true;
46
+ await new Promise((resolve) => setTimeout(resolve, 100));
47
+ }
48
+ return !isProcessAlive(pid);
49
+ }
50
+
51
+ export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAfterMs = 3000 } = {}) {
52
+ const paths = daemonPaths(toolName);
53
+ const { pid } = await readJson(paths.pidFile, {});
54
+ let stopped = false;
55
+ if (isProcessAlive(pid)) {
56
+ try {
57
+ process.kill(pid, signal);
58
+ stopped = true;
59
+ } catch {}
60
+ if (signal !== "SIGKILL" && forceAfterMs > 0 && !(await waitForExit(pid, forceAfterMs))) {
61
+ try {
62
+ process.kill(pid, "SIGKILL");
63
+ } catch {}
64
+ }
65
+ }
66
+ await rm(paths.pidFile, { force: true });
67
+ return { toolName, pid: pid || null, stopped };
68
+ }
69
+
70
+ export async function startManagedDaemon({ toolName, entryPath, beforeStart = null, autoStart = true }) {
71
+ const paths = daemonPaths(toolName);
72
+ await mkdir(paths.commandsDir, { recursive: true });
73
+
74
+ const current = await readJson(paths.pidFile, {});
75
+ if (isProcessAlive(current.pid)) {
76
+ await writeJson(paths.metaFile, {
77
+ toolName,
78
+ entryPath,
79
+ autoStart,
80
+ lastStartedAt: current.startedAt || new Date().toISOString()
81
+ });
82
+ return current.pid;
83
+ }
84
+ await rm(paths.pidFile, { force: true });
85
+
86
+ await rm(paths.commandsDir, { recursive: true, force: true });
87
+ await mkdir(paths.commandsDir, { recursive: true });
88
+ if (beforeStart) await beforeStart();
89
+
90
+ const out = openSync(paths.logFile, "a");
91
+ try {
92
+ const child = spawn(process.execPath, [entryPath, "daemon"], {
93
+ detached: false,
94
+ stdio: ["ignore", out, out],
95
+ env: process.env
96
+ });
97
+ child.unref();
98
+
99
+ const startedAt = new Date().toISOString();
100
+ const record = {
101
+ pid: child.pid,
102
+ startedAt
103
+ };
104
+ await writeJson(paths.pidFile, record);
105
+ await writeJson(paths.metaFile, {
106
+ toolName,
107
+ entryPath,
108
+ autoStart,
109
+ lastStartedAt: startedAt
110
+ });
111
+ return child.pid;
112
+ } finally {
113
+ closeSync(out);
114
+ }
115
+ }
116
+
117
+ export async function listRegisteredDaemons() {
118
+ let entries = [];
119
+ try {
120
+ entries = await readdir(toolStateDir, { withFileTypes: true });
121
+ } catch {
122
+ return [];
123
+ }
124
+
125
+ const records = [];
126
+ for (const entry of entries) {
127
+ if (!entry.isDirectory()) continue;
128
+ const paths = daemonPaths(entry.name);
129
+ const meta = await readJson(paths.metaFile, null);
130
+ if (meta?.toolName && meta?.entryPath) records.push(meta);
131
+ }
132
+ return records;
133
+ }
@@ -1,34 +1,14 @@
1
1
  import crypto from "node:crypto";
2
- import { spawn } from "node:child_process";
3
- import { openSync } from "node:fs";
4
- import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, rename, unlink } from "node:fs/promises";
5
3
  import path from "node:path";
6
- import { getToolStateDir } from "../../runtime/paths.js";
7
-
8
- export function daemonPaths(toolName) {
9
- const root = getToolStateDir(toolName);
10
- return {
11
- root,
12
- commandsDir: path.join(root, "commands"),
13
- pidFile: path.join(root, "daemon.pid"),
14
- statusFile: path.join(root, "status.json"),
15
- logFile: path.join(root, "daemon.log")
16
- };
17
- }
18
-
19
- export async function readJson(file, fallback = {}) {
20
- try { return JSON.parse(await readFile(file, "utf8")); } catch { return fallback; }
21
- }
22
-
23
- export async function writeJson(file, value) {
24
- await mkdir(path.dirname(file), { recursive: true });
25
- await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
26
- }
27
-
28
- export function isProcessAlive(pid) {
29
- if (!pid) return false;
30
- try { process.kill(pid, 0); return true; } catch { return false; }
31
- }
4
+ import {
5
+ daemonPaths,
6
+ isProcessAlive,
7
+ readJson,
8
+ startManagedDaemon,
9
+ stopManagedDaemon,
10
+ writeJson
11
+ } from "./daemon-processes.js";
32
12
 
33
13
  export function createDaemonRuntime({ toolName, entryPath, beforeStart = null }) {
34
14
  const paths = daemonPaths(toolName);
@@ -54,33 +34,16 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
54
34
  await writeJson(paths.statusFile, { ...current, ...patch, updatedAt: new Date().toISOString() });
55
35
  }
56
36
 
57
- async function clearJobs() {
58
- await rm(paths.commandsDir, { recursive: true, force: true });
59
- await mkdir(paths.commandsDir, { recursive: true });
60
- }
61
-
62
37
  async function start() {
63
- await ensure();
64
- const pid = await getPid();
65
- if (isProcessAlive(pid)) return pid;
66
-
67
- await clearJobs();
68
- if (beforeStart) await beforeStart();
69
- const out = openSync(paths.logFile, "a");
70
- const child = spawn(process.execPath, [entryPath, "daemon"], {
71
- detached: true,
72
- stdio: ["ignore", out, out],
73
- env: process.env
38
+ return startManagedDaemon({
39
+ toolName,
40
+ entryPath,
41
+ beforeStart
74
42
  });
75
- child.unref();
76
- await writeJson(paths.pidFile, { pid: child.pid, startedAt: new Date().toISOString() });
77
- return child.pid;
78
43
  }
79
44
 
80
45
  async function stop() {
81
- const pid = await getPid();
82
- if (isProcessAlive(pid)) process.kill(pid, "SIGTERM");
83
- await rm(paths.pidFile, { force: true });
46
+ await stopManagedDaemon(toolName);
84
47
  }
85
48
 
86
49
  async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
package/src/index.js CHANGED
@@ -4,7 +4,7 @@ import { createServer } from "node:http";
4
4
  import { bootstrapIfNeeded } from "./runtime/bootstrap.js";
5
5
  import { createApp } from "./runtime/create-app.js";
6
6
  import { createLogger } from "./runtime/logger.js";
7
- import { getServiceStatus, registerServiceProcess, startService, stopService } from "./runtime/service-manager.js";
7
+ import { getServiceStatus, registerServiceProcess, startService, stopService, unregisterServiceProcess } from "./runtime/service-manager.js";
8
8
  import { flushArisaHome } from "./runtime/flush.js";
9
9
  import { installPiPackage, removePiPackage } from "./runtime/pi-package-manager.js";
10
10
 
@@ -17,6 +17,8 @@ const serviceRunner = Boolean(cli.flags["service-runner"]);
17
17
  const bootstrapOverrides = toBootstrapOverrides(cli.nestedFlags);
18
18
  const runtimeOverrides = toRuntimeOverrides(cli.nestedFlags);
19
19
  const logger = createLogger({ verbose });
20
+ let activeApp = null;
21
+ let shuttingDown = false;
20
22
 
21
23
  const httpPort = Number(process.env.PORT);
22
24
  let httpRequestHandler = null;
@@ -97,6 +99,35 @@ const bootstrapHttpOptions = httpPort ? { httpPort, setHttpRequestHandler } : {}
97
99
  const webhookUrl = bootstrapOverrides.webhook?.url || "";
98
100
  const appHttpOptions = httpPort ? { webhookUrl, setHttpRequestHandler } : {};
99
101
 
102
+ async function shutdown(exitCode = 0) {
103
+ if (shuttingDown) return;
104
+ shuttingDown = true;
105
+ try {
106
+ await activeApp?.stop?.();
107
+ } catch (error) {
108
+ logger.error("app", `shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
109
+ exitCode = exitCode || 1;
110
+ }
111
+ if (serviceRunner) {
112
+ await unregisterServiceProcess();
113
+ }
114
+ process.exit(exitCode);
115
+ }
116
+
117
+ process.once("SIGTERM", () => {
118
+ shutdown(0);
119
+ });
120
+
121
+ process.once("SIGINT", () => {
122
+ shutdown(0);
123
+ });
124
+
125
+ async function startRuntimeApp() {
126
+ const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
127
+ activeApp = app;
128
+ await app.start();
129
+ }
130
+
100
131
  async function runForeground() {
101
132
  const hasRuntimePiOverrides = Boolean(
102
133
  runtimeOverrides?.pi?.model
@@ -106,11 +137,12 @@ async function runForeground() {
106
137
  logger.log("app", `starting${verbose ? " in verbose mode" : ""}`);
107
138
  await bootstrapIfNeeded({ force: forceBootstrap, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
108
139
  try {
109
- const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
110
- await app.start();
140
+ await startRuntimeApp();
111
141
  } catch (error) {
112
142
  const message = error instanceof Error ? error.message : String(error);
113
143
  if (message.includes("No auth found")) {
144
+ await activeApp?.stop?.();
145
+ activeApp = null;
114
146
  console.log(`\n${message}\n`);
115
147
  if (hasRuntimePiOverrides) {
116
148
  console.log("Skipping automatic bootstrap because Pi runtime overrides were provided.");
@@ -119,8 +151,7 @@ async function runForeground() {
119
151
  }
120
152
  console.log("Reopening bootstrap so you can provide a Pi API key or switch to a provider you already authenticated with.\n");
121
153
  await bootstrapIfNeeded({ force: true, cliConfigOverrides: bootstrapOverrides, ...bootstrapHttpOptions });
122
- const app = await createApp({ logger, runtimeOverrides, ...appHttpOptions });
123
- await app.start();
154
+ await startRuntimeApp();
124
155
  return;
125
156
  }
126
157
  throw error;
@@ -202,4 +233,9 @@ async function main() {
202
233
  await runForeground();
203
234
  }
204
235
 
205
- await main();
236
+ try {
237
+ await main();
238
+ } catch (error) {
239
+ logger.error("app", error instanceof Error ? error.message : String(error));
240
+ await shutdown(1);
241
+ }
@@ -4,6 +4,7 @@ import { ToolRegistry } from "../core/tools/tool-registry.js";
4
4
  import { TaskStore } from "../core/tasks/task-store.js";
5
5
  import { AgentManager } from "../core/agent/agent-manager.js";
6
6
  import { createTelegramBot } from "../transport/telegram/bot.js";
7
+ import { createToolProcessSupervisor } from "./tool-process-supervisor.js";
7
8
 
8
9
  function normalizeString(value) {
9
10
  const text = String(value ?? "").trim();
@@ -51,6 +52,7 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
51
52
  }
52
53
 
53
54
  const artifactStore = new ArtifactStore();
55
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger });
54
56
  const toolRegistry = new ToolRegistry({ logger });
55
57
  const taskStore = new TaskStore();
56
58
  await toolRegistry.load();
@@ -63,8 +65,19 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
63
65
  async start() {
64
66
  logger?.log("app", `validating Pi model ${config.pi.provider}/${config.pi.model}`);
65
67
  await agentManager.validatePiAgent();
68
+ await toolProcessSupervisor.start();
66
69
  logger?.log("app", "starting Telegram bot");
67
- await bot.start();
70
+ try {
71
+ await bot.start();
72
+ } catch (error) {
73
+ await toolProcessSupervisor.stop();
74
+ throw error;
75
+ }
76
+ },
77
+
78
+ async stop() {
79
+ await bot.stop?.();
80
+ await toolProcessSupervisor.stop();
68
81
  }
69
82
  };
70
83
  }
@@ -74,24 +74,14 @@ export async function stopService() {
74
74
  return { ok: true, pid: status.pid };
75
75
  }
76
76
 
77
+ export async function unregisterServiceProcess() {
78
+ await rm(servicePidFile, { force: true }).catch(() => {});
79
+ }
80
+
77
81
  export async function registerServiceProcess() {
78
82
  await ensureArisaHome();
79
83
  await writeFile(servicePidFile, `${process.pid}\n`, "utf8");
80
84
 
81
- const cleanup = async () => {
82
- await rm(servicePidFile, { force: true }).catch(() => {});
83
- };
84
-
85
- process.on("SIGTERM", async () => {
86
- await cleanup();
87
- process.exit(0);
88
- });
89
-
90
- process.on("SIGINT", async () => {
91
- await cleanup();
92
- process.exit(0);
93
- });
94
-
95
85
  process.on("exit", () => {
96
86
  rm(servicePidFile, { force: true }).catch(() => {});
97
87
  });
@@ -0,0 +1,63 @@
1
+ import { access, rm } from "node:fs/promises";
2
+ import {
3
+ daemonPaths,
4
+ isProcessAlive,
5
+ listRegisteredDaemons,
6
+ readJson,
7
+ startManagedDaemon
8
+ } from "../core/tools/daemon-processes.js";
9
+ import { ensureArisaHome } from "./paths.js";
10
+
11
+ async function fileExists(file) {
12
+ try {
13
+ await access(file);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ export function createToolProcessSupervisor({ logger } = {}) {
21
+ let running = false;
22
+
23
+ async function reconcileDaemons() {
24
+ await ensureArisaHome();
25
+ for (const record of await listRegisteredDaemons()) {
26
+ if (!record.autoStart) continue;
27
+ if (!(await fileExists(record.entryPath))) {
28
+ logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
29
+ continue;
30
+ }
31
+
32
+ const paths = daemonPaths(record.toolName);
33
+ const { pid } = await readJson(paths.pidFile, {});
34
+ if (isProcessAlive(pid)) {
35
+ logger?.log("tools", `adopted managed daemon ${record.toolName} (pid ${pid})`);
36
+ continue;
37
+ }
38
+ if (pid) {
39
+ await rm(paths.pidFile, { force: true });
40
+ logger?.log("tools", `removed stale daemon pid for ${record.toolName} (${pid})`);
41
+ }
42
+
43
+ logger?.log("tools", `starting managed daemon ${record.toolName}`);
44
+ await startManagedDaemon({
45
+ toolName: record.toolName,
46
+ entryPath: record.entryPath
47
+ });
48
+ }
49
+ }
50
+
51
+ return {
52
+ async start() {
53
+ if (running) return;
54
+ running = true;
55
+ await reconcileDaemons();
56
+ },
57
+
58
+ async stop() {
59
+ if (!running) return;
60
+ running = false;
61
+ }
62
+ };
63
+ }
@@ -3,8 +3,11 @@ import path from "node:path";
3
3
  import { authorizeChat } from "./auth.js";
4
4
  import { captureIncomingArtifact } from "./media.js";
5
5
  import { renderTelegramHtml } from "./text-format.js";
6
+ import { withTimeout } from "../../core/agent/prompt-timeout.js";
6
7
  import { normalizeArtifactForReasoning, shouldNormalizeArtifactToText } from "../../core/artifacts/normalize-for-reasoning.js";
7
8
 
9
+ const promptTimeoutMs = 300_000;
10
+
8
11
  function quotedMessageSummary(message) {
9
12
  if (!message) return [];
10
13
 
@@ -154,15 +157,52 @@ async function normalizeIncomingArtifact({ artifact, toolRegistry, chatArtifactS
154
157
  return { transcript: normalizedArtifact, toolResult };
155
158
  }
156
159
 
157
- async function collectText(session, prompt) {
160
+ function sessionEventLogMessage(event) {
161
+ if (event.type === "tool_execution_start") {
162
+ return `tool ${event.toolName} started`;
163
+ }
164
+ if (event.type === "tool_execution_end") {
165
+ return `tool ${event.toolName} ${event.isError ? "failed" : "finished"}`;
166
+ }
167
+ if (event.type === "auto_retry_start") {
168
+ return `auto retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms: ${event.errorMessage}`;
169
+ }
170
+ if (event.type === "auto_retry_end") {
171
+ return event.success
172
+ ? `auto retry succeeded after ${event.attempt} attempt(s)`
173
+ : `auto retry failed after ${event.attempt} attempt(s): ${event.finalError || "unknown error"}`;
174
+ }
175
+ if (event.type === "compaction_start") {
176
+ return `compaction started (${event.reason})`;
177
+ }
178
+ if (event.type === "compaction_end") {
179
+ return `compaction ${event.aborted ? "aborted" : "finished"} (${event.reason})`;
180
+ }
181
+ if (event.type === "message_end" && event.message?.stopReason === "error") {
182
+ return `assistant message ended with error: ${event.message.errorMessage || "unknown error"}`;
183
+ }
184
+ return "";
185
+ }
186
+
187
+ async function collectText(session, prompt, { logger, chatId } = {}) {
158
188
  let text = "";
159
189
  const unsubscribe = session.subscribe((event) => {
160
190
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
161
191
  text += event.assistantMessageEvent.delta;
162
192
  }
193
+ const logMessage = sessionEventLogMessage(event);
194
+ if (logMessage) logger?.log("agent", `chat ${chatId} ${logMessage}`);
163
195
  });
164
- await session.prompt(prompt);
165
- unsubscribe();
196
+
197
+ try {
198
+ await withTimeout(session.prompt(prompt), {
199
+ timeoutMs: promptTimeoutMs,
200
+ label: `Telegram prompt for chat ${chatId}`
201
+ });
202
+ } finally {
203
+ unsubscribe();
204
+ }
205
+
166
206
  return text.trim();
167
207
  }
168
208
 
@@ -182,6 +222,7 @@ async function withTyping(ctx, work) {
182
222
  export async function createTelegramBot({ config, artifactStore, toolRegistry, taskStore, agentManager, saveConfig, updateConfig, logger, webhookUrl, setHttpRequestHandler }) {
183
223
  const bot = new Bot(config.telegram.token);
184
224
  const perChatState = new Map();
225
+ let taskTimer = null;
185
226
 
186
227
  function getIncomingChatMeta(ctx) {
187
228
  return {
@@ -252,7 +293,13 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
252
293
  async function processPromptForChat({ chatId, prompt, ctx = null }) {
253
294
  const work = async () => {
254
295
  const { session } = await agentManager.getSessionContext(chatId, createTelegramSessionBridge(chatId));
255
- const text = await collectText(session, prompt);
296
+ let text = "";
297
+ try {
298
+ text = await collectText(session, prompt, { logger, chatId });
299
+ } catch (error) {
300
+ agentManager.resetSession(chatId);
301
+ throw error;
302
+ }
256
303
  if (text) {
257
304
  await sendTextReply({
258
305
  sendText: (message, extra) => bot.api.sendMessage(chatId, message, extra),
@@ -283,12 +330,19 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
283
330
  let currentPrompt = prompt;
284
331
  let currentCtx = ctx;
285
332
 
286
- while (currentPrompt) {
287
- try {
288
- logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
289
- await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
290
- } finally {
291
- currentCtx = null;
333
+ try {
334
+ while (currentPrompt) {
335
+ try {
336
+ logger?.log("telegram", `prompt dispatch for chat ${chatId}`);
337
+ await processPromptForChat({ chatId, prompt: currentPrompt, ctx: currentCtx });
338
+ } catch (error) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ logger?.error("telegram", `${label} failed for chat ${chatId}: ${message}`);
341
+ throw error;
342
+ } finally {
343
+ currentCtx = null;
344
+ }
345
+
292
346
  if (chatState.nextPrompt) {
293
347
  currentPrompt = chatState.nextPrompt;
294
348
  chatState.nextPrompt = "";
@@ -296,9 +350,9 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
296
350
  currentPrompt = "";
297
351
  }
298
352
  }
353
+ } finally {
354
+ chatState.processing = false;
299
355
  }
300
-
301
- chatState.processing = false;
302
356
  }
303
357
 
304
358
  async function enqueueOrProcess(ctx) {
@@ -460,11 +514,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
460
514
  await bot.api.setMyCommands([
461
515
  { command: "new", description: "Start a new chat context" }
462
516
  ]);
463
- setInterval(() => {
464
- dispatchDueTasks().catch((error) => {
465
- logger?.error("tasks", `dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
466
- });
467
- }, 1000).unref();
517
+ if (!taskTimer) {
518
+ taskTimer = setInterval(() => {
519
+ dispatchDueTasks().catch((error) => {
520
+ logger?.error("tasks", `dispatch failed: ${error instanceof Error ? error.message : String(error)}`);
521
+ });
522
+ }, 1000);
523
+ taskTimer.unref();
524
+ }
468
525
  if (webhookUrl && setHttpRequestHandler) {
469
526
  const webhookPath = `/telegram-${config.telegram.token.slice(-8)}`;
470
527
  const handleUpdate = webhookCallback(bot, "http", {
@@ -485,6 +542,14 @@ export async function createTelegramBot({ config, artifactStore, toolRegistry, t
485
542
  logger?.log("telegram", "bot polling started");
486
543
  await bot.start({ drop_pending_updates: true });
487
544
  }
545
+ },
546
+
547
+ async stop() {
548
+ if (taskTimer) clearInterval(taskTimer);
549
+ taskTimer = null;
550
+ try {
551
+ bot.stop();
552
+ } catch {}
488
553
  }
489
554
  };
490
555
  }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import defaults from "./config.js";
4
4
  import { loadToolConfig } from "../../src/core/tools/tool-config.js";
5
5
  import { toolError, toolNeedsConfig, toolOk } from "../../src/core/tools/tool-result.js";
6
- import { getToolConfigPath, getToolOutDir } from "../../src/runtime/paths.js";
6
+ import { getChatToolTmpDir, getToolConfigPath, getToolTmpDir } from "../../src/runtime/paths.js";
7
7
 
8
8
  const toolName = "openai-tts";
9
9
  const config = await loadToolConfig(toolName, defaults);
@@ -49,7 +49,9 @@ async function run(requestFile) {
49
49
  return;
50
50
  }
51
51
 
52
- const outDir = getToolOutDir(toolName);
52
+ const outDir = request.chatId != null
53
+ ? getChatToolTmpDir(request.chatId, toolName)
54
+ : getToolTmpDir(toolName);
53
55
  await mkdir(outDir, { recursive: true });
54
56
  const filePath = path.join(outDir, `speech-${Date.now()}.ogg`);
55
57
  const buffer = Buffer.from(await response.arrayBuffer());
@@ -1,11 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { fileURLToPath } from "node:url";
3
-
4
- const instructionsPath = fileURLToPath(new URL("../../../AGENTS.md", import.meta.url));
5
- let cachedInstructions = null;
6
-
7
- export async function loadProjectInstructions() {
8
- if (cachedInstructions !== null) return cachedInstructions;
9
- cachedInstructions = await readFile(instructionsPath, "utf8");
10
- return cachedInstructions;
11
- }