arisa 3.1.6 → 3.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.1.6",
3
+ "version": "3.2.0",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,7 +1,9 @@
1
1
  function mimeMatches(pattern, mimeType = "") {
2
- if (!pattern || !mimeType) return false;
3
- if (pattern === mimeType) return true;
4
- if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
2
+ const normalizedMimeType = mimeType.split(";")[0].trim().toLowerCase();
3
+ const normalizedPattern = pattern?.trim().toLowerCase();
4
+ if (!normalizedPattern || !normalizedMimeType) return false;
5
+ if (normalizedPattern === normalizedMimeType) return true;
6
+ if (normalizedPattern.endsWith("/*")) return normalizedMimeType.startsWith(`${normalizedPattern.slice(0, -2)}/`);
5
7
  return false;
6
8
  }
7
9
 
@@ -20,8 +22,9 @@ function looksLikeAudioTranscriptionTool(tool) {
20
22
  }
21
23
 
22
24
  export function shouldNormalizeArtifactToText(artifact, desiredMimeType = "text/plain") {
25
+ const mimeType = artifact?.mimeType?.split(";")[0].trim().toLowerCase();
23
26
  return desiredMimeType === "text/plain"
24
- && (artifact?.mimeType?.startsWith("audio/") || artifact?.mimeType?.startsWith("video/"));
27
+ && (mimeType?.startsWith("audio/") || mimeType?.startsWith("video/"));
25
28
  }
26
29
 
27
30
  export function selectPipeTool({ toolRegistry, artifact, desiredMimeType }) {
@@ -4,9 +4,6 @@ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { getToolStateDir, toolStateDir } from "../../runtime/paths.js";
6
6
 
7
- export const OWNER_HEARTBEAT_INTERVAL_MS = 2000;
8
- export const OWNER_HEARTBEAT_TTL_MS = 10000;
9
-
10
7
  export function daemonPaths(toolName) {
11
8
  const root = getToolStateDir(toolName);
12
9
  return {
@@ -54,9 +51,11 @@ async function waitForExit(pid, timeoutMs) {
54
51
  export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAfterMs = 3000 } = {}) {
55
52
  const paths = daemonPaths(toolName);
56
53
  const { pid } = await readJson(paths.pidFile, {});
54
+ let stopped = false;
57
55
  if (isProcessAlive(pid)) {
58
56
  try {
59
57
  process.kill(pid, signal);
58
+ stopped = true;
60
59
  } catch {}
61
60
  if (signal !== "SIGKILL" && forceAfterMs > 0 && !(await waitForExit(pid, forceAfterMs))) {
62
61
  try {
@@ -65,19 +64,24 @@ export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAft
65
64
  }
66
65
  }
67
66
  await rm(paths.pidFile, { force: true });
67
+ return { toolName, pid: pid || null, stopped };
68
68
  }
69
69
 
70
- export async function startManagedDaemon({ toolName, entryPath, beforeStart = null, ownerEnv = {} }) {
70
+ export async function startManagedDaemon({ toolName, entryPath, beforeStart = null, autoStart = true }) {
71
71
  const paths = daemonPaths(toolName);
72
72
  await mkdir(paths.commandsDir, { recursive: true });
73
73
 
74
74
  const current = await readJson(paths.pidFile, {});
75
75
  if (isProcessAlive(current.pid)) {
76
- const sameOwner = !ownerEnv.ARISA_TOOL_OWNER_TOKEN
77
- || current.ownerToken === ownerEnv.ARISA_TOOL_OWNER_TOKEN;
78
- if (sameOwner) return current.pid;
79
- await stopManagedDaemon(toolName);
76
+ await writeJson(paths.metaFile, {
77
+ toolName,
78
+ entryPath,
79
+ autoStart,
80
+ lastStartedAt: current.startedAt || new Date().toISOString()
81
+ });
82
+ return current.pid;
80
83
  }
84
+ await rm(paths.pidFile, { force: true });
81
85
 
82
86
  await rm(paths.commandsDir, { recursive: true, force: true });
83
87
  await mkdir(paths.commandsDir, { recursive: true });
@@ -88,26 +92,21 @@ export async function startManagedDaemon({ toolName, entryPath, beforeStart = nu
88
92
  const child = spawn(process.execPath, [entryPath, "daemon"], {
89
93
  detached: false,
90
94
  stdio: ["ignore", out, out],
91
- env: { ...process.env, ...ownerEnv }
95
+ env: process.env
92
96
  });
93
97
  child.unref();
94
98
 
95
99
  const startedAt = new Date().toISOString();
96
100
  const record = {
97
101
  pid: child.pid,
98
- startedAt,
99
- ownerPid: Number.parseInt(ownerEnv.ARISA_OWNER_PID || "", 10) || null,
100
- ownerToken: ownerEnv.ARISA_TOOL_OWNER_TOKEN || ""
102
+ startedAt
101
103
  };
102
104
  await writeJson(paths.pidFile, record);
103
105
  await writeJson(paths.metaFile, {
104
106
  toolName,
105
107
  entryPath,
106
- autoStart: true,
107
- lastStartedAt: startedAt,
108
- ownerFile: ownerEnv.ARISA_TOOL_OWNER_FILE || "",
109
- ownerPid: record.ownerPid,
110
- ownerToken: record.ownerToken
108
+ autoStart,
109
+ lastStartedAt: startedAt
111
110
  });
112
111
  return child.pid;
113
112
  } finally {
@@ -2,8 +2,6 @@ import crypto from "node:crypto";
2
2
  import { mkdir, readdir, rename, unlink } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import {
5
- OWNER_HEARTBEAT_INTERVAL_MS,
6
- OWNER_HEARTBEAT_TTL_MS,
7
5
  daemonPaths,
8
6
  isProcessAlive,
9
7
  readJson,
@@ -40,12 +38,7 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
40
38
  return startManagedDaemon({
41
39
  toolName,
42
40
  entryPath,
43
- beforeStart,
44
- ownerEnv: {
45
- ARISA_OWNER_PID: process.env.ARISA_OWNER_PID,
46
- ARISA_TOOL_OWNER_FILE: process.env.ARISA_TOOL_OWNER_FILE,
47
- ARISA_TOOL_OWNER_TOKEN: process.env.ARISA_TOOL_OWNER_TOKEN
48
- }
41
+ beforeStart
49
42
  });
50
43
  }
51
44
 
@@ -53,39 +46,6 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
53
46
  await stopManagedDaemon(toolName);
54
47
  }
55
48
 
56
- function installOwnerWatch() {
57
- const ownerFile = process.env.ARISA_TOOL_OWNER_FILE;
58
- const ownerToken = process.env.ARISA_TOOL_OWNER_TOKEN;
59
- if (!ownerFile || !ownerToken) return;
60
-
61
- let exiting = false;
62
- async function exitIfOrphaned(message) {
63
- if (exiting) return;
64
- exiting = true;
65
- await writeStatus({ state: "stopped", message });
66
- process.exit(0);
67
- }
68
-
69
- const timer = setInterval(async () => {
70
- const owner = await readJson(ownerFile, null);
71
- if (!owner || owner.token !== ownerToken) {
72
- await exitIfOrphaned("Arisa owner stopped");
73
- return;
74
- }
75
-
76
- const heartbeatAt = Date.parse(owner.heartbeatAt || "");
77
- if (!Number.isFinite(heartbeatAt) || Date.now() - heartbeatAt > OWNER_HEARTBEAT_TTL_MS) {
78
- await exitIfOrphaned("Arisa owner heartbeat expired");
79
- return;
80
- }
81
-
82
- if (!isProcessAlive(owner.pid)) {
83
- await exitIfOrphaned("Arisa owner process exited");
84
- }
85
- }, OWNER_HEARTBEAT_INTERVAL_MS);
86
- timer.unref();
87
- }
88
-
89
49
  async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
90
50
  const startTime = Date.now();
91
51
  while (Date.now() - startTime < timeoutMs) {
@@ -143,7 +103,6 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
143
103
  }
144
104
 
145
105
  async function workLoop({ processJob, idleTimeoutMs = 0, intervalMs = 250 }) {
146
- installOwnerWatch();
147
106
  let lastActivity = Date.now();
148
107
  setInterval(async () => {
149
108
  try {
@@ -25,9 +25,8 @@ function runProcess(command, args, options = {}) {
25
25
  }
26
26
 
27
27
  export class ToolRegistry {
28
- constructor({ logger, processOwnerEnv = {} } = {}) {
28
+ constructor({ logger } = {}) {
29
29
  this.logger = logger;
30
- this.processOwnerEnv = processOwnerEnv;
31
30
  this.tools = new Map();
32
31
  this.skillRegistry = new SkillRegistry();
33
32
  }
@@ -154,7 +153,7 @@ export class ToolRegistry {
154
153
  await writeFile(requestFile, `${JSON.stringify(enrichedRequest, null, 2)}\n`, "utf8");
155
154
  const result = await runProcess("node", [tool.entry, "run", "--request-file", requestFile], {
156
155
  cwd: tool.dir,
157
- env: { ...process.env, ...this.processOwnerEnv }
156
+ env: process.env
158
157
  });
159
158
  await unlink(requestFile).catch(() => {});
160
159
  await rmdir(tmpDir).catch(() => {});
@@ -53,7 +53,7 @@ export async function createApp({ logger, runtimeOverrides, webhookUrl, setHttpR
53
53
 
54
54
  const artifactStore = new ArtifactStore();
55
55
  const toolProcessSupervisor = createToolProcessSupervisor({ logger });
56
- const toolRegistry = new ToolRegistry({ logger, processOwnerEnv: toolProcessSupervisor.env() });
56
+ const toolRegistry = new ToolRegistry({ logger });
57
57
  const taskStore = new TaskStore();
58
58
  await toolRegistry.load();
59
59
  logger?.log("app", `loaded ${toolRegistry.list().length} tools`);
@@ -7,7 +7,6 @@ export const stateDir = path.join(arisaHomeDir, "state");
7
7
  export const configFile = path.join(stateDir, "config.json");
8
8
  export const servicePidFile = path.join(stateDir, "arisa.pid");
9
9
  export const serviceLogFile = path.join(stateDir, "arisa.log");
10
- export const toolDaemonOwnerFile = path.join(stateDir, "tool-daemon-owner.json");
11
10
  export const tasksFile = path.join(stateDir, "tasks.json");
12
11
  export const toolsDir = path.join(arisaHomeDir, "tools");
13
12
  export const chatsDir = path.join(arisaHomeDir, "chats");
@@ -1,14 +1,12 @@
1
- import crypto from "node:crypto";
2
1
  import { access, rm } from "node:fs/promises";
3
2
  import {
4
3
  daemonPaths,
4
+ isProcessAlive,
5
5
  listRegisteredDaemons,
6
6
  readJson,
7
- startManagedDaemon,
8
- stopManagedDaemon,
9
- writeJson
7
+ startManagedDaemon
10
8
  } from "../core/tools/daemon-processes.js";
11
- import { ensureArisaHome, toolDaemonOwnerFile } from "./paths.js";
9
+ import { ensureArisaHome } from "./paths.js";
12
10
 
13
11
  async function fileExists(file) {
14
12
  try {
@@ -20,36 +18,10 @@ async function fileExists(file) {
20
18
  }
21
19
 
22
20
  export function createToolProcessSupervisor({ logger } = {}) {
23
- const token = crypto.randomUUID();
24
- let heartbeatTimer = null;
25
21
  let running = false;
26
22
 
27
- const ownerEnv = {
28
- ARISA_OWNER_PID: String(process.pid),
29
- ARISA_TOOL_OWNER_FILE: toolDaemonOwnerFile,
30
- ARISA_TOOL_OWNER_TOKEN: token
31
- };
32
-
33
- async function writeHeartbeat() {
34
- await writeJson(toolDaemonOwnerFile, {
35
- pid: process.pid,
36
- token,
37
- heartbeatAt: new Date().toISOString()
38
- });
39
- }
40
-
41
- async function startHeartbeat() {
23
+ async function reconcileDaemons() {
42
24
  await ensureArisaHome();
43
- await writeHeartbeat();
44
- heartbeatTimer = setInterval(() => {
45
- writeHeartbeat().catch((error) => {
46
- logger?.error("tools", `tool daemon heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
47
- });
48
- }, 2000);
49
- heartbeatTimer.unref();
50
- }
51
-
52
- async function restartRegisteredDaemons() {
53
25
  for (const record of await listRegisteredDaemons()) {
54
26
  if (!record.autoStart) continue;
55
27
  if (!(await fileExists(record.entryPath))) {
@@ -59,52 +31,33 @@ export function createToolProcessSupervisor({ logger } = {}) {
59
31
 
60
32
  const paths = daemonPaths(record.toolName);
61
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
+ }
62
38
  if (pid) {
63
- await stopManagedDaemon(record.toolName);
39
+ await rm(paths.pidFile, { force: true });
40
+ logger?.log("tools", `removed stale daemon pid for ${record.toolName} (${pid})`);
64
41
  }
65
42
 
66
43
  logger?.log("tools", `starting managed daemon ${record.toolName}`);
67
44
  await startManagedDaemon({
68
45
  toolName: record.toolName,
69
- entryPath: record.entryPath,
70
- ownerEnv
46
+ entryPath: record.entryPath
71
47
  });
72
48
  }
73
49
  }
74
50
 
75
- async function stopRegisteredDaemons() {
76
- for (const record of await listRegisteredDaemons()) {
77
- logger?.log("tools", `stopping managed daemon ${record.toolName}`);
78
- await stopManagedDaemon(record.toolName);
79
- }
80
- }
81
-
82
- async function removeOwnerFile() {
83
- const current = await readJson(toolDaemonOwnerFile, null);
84
- if (current?.token === token) {
85
- await rm(toolDaemonOwnerFile, { force: true });
86
- }
87
- }
88
-
89
51
  return {
90
- env() {
91
- return ownerEnv;
92
- },
93
-
94
52
  async start() {
95
53
  if (running) return;
96
54
  running = true;
97
- await startHeartbeat();
98
- await restartRegisteredDaemons();
55
+ await reconcileDaemons();
99
56
  },
100
57
 
101
58
  async stop() {
102
59
  if (!running) return;
103
60
  running = false;
104
- if (heartbeatTimer) clearInterval(heartbeatTimer);
105
- heartbeatTimer = null;
106
- await stopRegisteredDaemons();
107
- await removeOwnerFile();
108
61
  }
109
62
  };
110
63
  }
@@ -6,6 +6,28 @@ async function downloadToBuffer(ctx, fileId) {
6
6
  return Buffer.from(await response.arrayBuffer());
7
7
  }
8
8
 
9
+ function mimeTypeFromAudioFileName(fileName = "") {
10
+ const extension = fileName.toLowerCase().split(".").pop();
11
+ return {
12
+ flac: "audio/flac",
13
+ m4a: "audio/mp4",
14
+ mp3: "audio/mpeg",
15
+ mp4: "audio/mp4",
16
+ mpeg: "audio/mpeg",
17
+ mpga: "audio/mpga",
18
+ ogg: "audio/ogg",
19
+ opus: "audio/ogg",
20
+ wav: "audio/wav",
21
+ webm: "audio/webm"
22
+ }[extension] || "";
23
+ }
24
+
25
+ function normalizeDocumentMimeType(document) {
26
+ const mimeType = document.mime_type || "";
27
+ if (mimeType && mimeType !== "application/octet-stream") return mimeType;
28
+ return mimeTypeFromAudioFileName(document.file_name) || mimeType || "application/octet-stream";
29
+ }
30
+
9
31
  function incomingCaptionMetadata(ctx) {
10
32
  return ctx.message?.caption ? { caption: ctx.message.caption } : {};
11
33
  }
@@ -33,6 +55,26 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
33
55
  });
34
56
  }
35
57
 
58
+ if (ctx.message?.audio) {
59
+ const audio = ctx.message.audio;
60
+ const fileName = audio.file_name || `${chatId}-${ctx.msg.message_id}`;
61
+ const content = await downloadToBuffer(ctx, audio.file_id);
62
+ return store.createGeneratedFile({
63
+ fileName,
64
+ content,
65
+ kind: "audio",
66
+ mimeType: audio.mime_type || "audio/mpeg",
67
+ source: baseSource,
68
+ metadata: {
69
+ duration: audio.duration,
70
+ performer: audio.performer,
71
+ title: audio.title,
72
+ fileSize: audio.file_size,
73
+ ...incomingCaptionMetadata(ctx)
74
+ }
75
+ });
76
+ }
77
+
36
78
  if (ctx.message?.video) {
37
79
  const video = ctx.message.video;
38
80
  const fileName = video.file_name || `${chatId}-${ctx.msg.message_id}.mp4`;
@@ -54,13 +96,15 @@ export async function captureIncomingArtifact(ctx, artifactStore) {
54
96
  }
55
97
 
56
98
  if (ctx.message?.document) {
57
- const fileName = ctx.message.document.file_name || `${chatId}-${ctx.msg.message_id}`;
58
- const content = await downloadToBuffer(ctx, ctx.message.document.file_id);
99
+ const document = ctx.message.document;
100
+ const fileName = document.file_name || `${chatId}-${ctx.msg.message_id}`;
101
+ const mimeType = normalizeDocumentMimeType(document);
102
+ const content = await downloadToBuffer(ctx, document.file_id);
59
103
  return store.createGeneratedFile({
60
104
  fileName,
61
105
  content,
62
- kind: "document",
63
- mimeType: ctx.message.document.mime_type || "application/octet-stream",
106
+ kind: mimeType.startsWith("audio/") ? "audio" : "document",
107
+ mimeType,
64
108
  source: baseSource,
65
109
  metadata: incomingCaptionMetadata(ctx)
66
110
  });
@@ -8,8 +8,55 @@ import { getToolConfigPath } from "../../src/runtime/paths.js";
8
8
  const toolName = "openai-transcribe";
9
9
  const config = await loadToolConfig(toolName, defaults);
10
10
 
11
+ const supportedUploadExtensions = new Set([
12
+ ".flac",
13
+ ".m4a",
14
+ ".mp3",
15
+ ".mp4",
16
+ ".mpeg",
17
+ ".mpga",
18
+ ".ogg",
19
+ ".wav",
20
+ ".webm"
21
+ ]);
22
+
23
+ const mimeUploadExtensions = new Map([
24
+ ["audio/aac", ".m4a"],
25
+ ["audio/flac", ".flac"],
26
+ ["audio/m4a", ".m4a"],
27
+ ["audio/mp3", ".mp3"],
28
+ ["audio/mp4", ".m4a"],
29
+ ["audio/mpeg", ".mp3"],
30
+ ["audio/mpga", ".mpga"],
31
+ ["audio/ogg", ".ogg"],
32
+ ["audio/opus", ".ogg"],
33
+ ["audio/wav", ".wav"],
34
+ ["audio/wave", ".wav"],
35
+ ["audio/webm", ".webm"],
36
+ ["audio/x-m4a", ".m4a"],
37
+ ["audio/x-wav", ".wav"],
38
+ ["video/mp4", ".mp4"],
39
+ ["video/webm", ".webm"]
40
+ ]);
41
+
42
+ function baseMimeType(mimeType = "") {
43
+ return mimeType.split(";")[0].trim().toLowerCase();
44
+ }
45
+
46
+ function uploadFileNameForArtifact(artifact) {
47
+ const currentName = path.basename(artifact.path);
48
+ const currentExtension = path.extname(currentName).toLowerCase();
49
+ if (supportedUploadExtensions.has(currentExtension)) return currentName;
50
+
51
+ const extension = mimeUploadExtensions.get(baseMimeType(artifact.mimeType));
52
+ if (!extension) return currentName;
53
+
54
+ const parsed = path.parse(currentName);
55
+ return `${parsed.name || "audio"}${extension}`;
56
+ }
57
+
11
58
  function printHelp() {
12
- console.log(`openai-transcribe\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n "artifact": { "path": "/abs/media.ogg", "mimeType": "audio/ogg" },\n "args": {}\n }\n\nConfig at ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n`);
59
+ console.log(`openai-transcribe\n\nUsage:\n node index.js --help\n node index.js run --request-file <json>\n\nExpected input:\n {\n "artifact": { "path": "/abs/media.ogg", "mimeType": "audio/ogg" },\n "args": {}\n }\n\nSupported upload formats include flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, and webm.\n\nConfig at ${getToolConfigPath(toolName)}:\n OPENAI_API_KEY\n MODEL\n`);
13
60
  }
14
61
 
15
62
  async function run(requestFile) {
@@ -32,7 +79,7 @@ async function run(requestFile) {
32
79
  await stat(artifact.path);
33
80
  const form = new FormData();
34
81
  const data = await readFile(artifact.path);
35
- form.append("file", new Blob([data]), path.basename(artifact.path));
82
+ form.append("file", new Blob([data], { type: baseMimeType(artifact.mimeType) || "application/octet-stream" }), uploadFileNameForArtifact(artifact));
36
83
  form.append("model", config.MODEL);
37
84
 
38
85
  const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
@@ -2,7 +2,24 @@
2
2
  "name": "openai-transcribe",
3
3
  "description": "Transcribe audio files and video audio tracks with OpenAI audio transcription API.",
4
4
  "entry": "index.js",
5
- "input": ["audio/ogg", "audio/mpeg", "audio/wav", "audio/mp4", "video/mp4"],
5
+ "input": [
6
+ "audio/aac",
7
+ "audio/flac",
8
+ "audio/m4a",
9
+ "audio/mp3",
10
+ "audio/mp4",
11
+ "audio/mpeg",
12
+ "audio/mpga",
13
+ "audio/ogg",
14
+ "audio/opus",
15
+ "audio/wav",
16
+ "audio/wave",
17
+ "audio/webm",
18
+ "audio/x-m4a",
19
+ "audio/x-wav",
20
+ "video/mp4",
21
+ "video/webm"
22
+ ],
6
23
  "output": ["text/plain"],
7
24
  "configSchema": {
8
25
  "OPENAI_API_KEY": {