alive-ai 0.1.8 → 0.1.10

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/README.md CHANGED
@@ -61,10 +61,11 @@ alive-ai init my-ai
61
61
  | `npx . demo` | Run a keyless animated dashboard demo. |
62
62
  | `npx . start` | Start the runtime using the configured input channel, usually Telegram. |
63
63
  | `npx . start --skip-install` | Start again without reinstalling Python dependencies. |
64
+ | `npx . stop` | Stop the running Alive-AI process for this project. |
64
65
  | `npx . update` | Refresh runtime files from the latest npm package while preserving config/data/media. |
65
66
  | `npx . uninstall` | Remove Alive-AI runtime files, config, venv, cache, data, and media from the project. |
66
67
 
67
- `start` and `chat` check npm for a newer Alive-AI version. You can update, skip once, or skip that specific version. Stop terminal chat with `/exit` or `Ctrl+C`.
68
+ `start` and `chat` check npm for a newer Alive-AI version. You can update, skip once, or skip that specific version. Stop terminal chat with `/exit` or `Ctrl+C`. Stop foreground Telegram/runtime mode with `Ctrl+C`; if a stale process is still alive, run `npx . stop` from the project root.
68
69
 
69
70
  `doctor --fix` is conservative: it prints the exact install command before running anything and asks separately for each missing tool. On macOS it uses Homebrew, on Windows it uses winget, and on Linux it supports apt, dnf, and pacman where possible. Redis is optional; doctor only checks or fixes it when `REDIS_VECTOR_MEMORY_ENABLED` is true.
70
71
 
@@ -292,7 +293,7 @@ Implemented:
292
293
  - [x] Local WebUI dashboard with live state streaming
293
294
  - [x] Optional hybrid OpenMind cloud/local semantic memory
294
295
  - [x] Optional Redis Stack vector cache with setup and doctor checks
295
- - [x] npm/npx CLI scaffold, setup, doctor, demo, chat, and start commands
296
+ - [x] npm/npx CLI scaffold, setup, doctor, demo, chat, start, stop, and uninstall commands
296
297
  - [x] Update prompt and project uninstall command
297
298
  - [x] `doctor --fix` guided system dependency installer
298
299
  - [x] Clean public repo with private personas, media, runtime data, and multi-AI orchestration removed
package/cli/index.js CHANGED
@@ -48,6 +48,7 @@ Usage:
48
48
  alive-ai demo [--port 8080] Run the animated dashboard demo
49
49
  alive-ai update [--yes] Update this project from the latest package
50
50
  alive-ai start [--skip-install] Install Python deps if needed and start runtime
51
+ alive-ai stop Stop the running project runtime
51
52
  alive-ai chat [--skip-install] Start split-pane terminal chat and logs
52
53
  alive-ai chat --plain Start raw terminal chat without the TUI
53
54
  alive-ai doctor [--fix] Check local prerequisites and optionally install missing tools
@@ -61,6 +62,7 @@ Quick start:
61
62
  npx . chat
62
63
  npx . demo
63
64
  npx . start
65
+ npx . stop
64
66
  npx . uninstall`);
65
67
  }
66
68
 
@@ -934,6 +936,116 @@ function ensurePythonEnv(skipInstall) {
934
936
  return pythonBin;
935
937
  }
936
938
 
939
+ function isChildRunning(child) {
940
+ return child && child.exitCode === null && child.signalCode === null;
941
+ }
942
+
943
+ function signalExitCode(signal) {
944
+ if (signal === "SIGINT") return 130;
945
+ if (signal === "SIGTERM") return 143;
946
+ if (signal === "SIGHUP") return 129;
947
+ if (signal === "SIGKILL") return 137;
948
+ return 1;
949
+ }
950
+
951
+ function runtimeInfoPath() {
952
+ return path.join(process.cwd(), ".alive-ai", "runtime.json");
953
+ }
954
+
955
+ function legacyRuntimePidPath() {
956
+ return path.join(process.cwd(), ".alive-ai", "runtime.pid");
957
+ }
958
+
959
+ function readRuntimeInfo() {
960
+ try {
961
+ const info = readJson(runtimeInfoPath());
962
+ const pid = Number(info.pid);
963
+ return Number.isInteger(pid) && pid > 0 ? { ...info, pid } : null;
964
+ } catch {}
965
+
966
+ try {
967
+ const pid = Number(fs.readFileSync(legacyRuntimePidPath(), "utf8").trim());
968
+ return Number.isInteger(pid) && pid > 0 ? { pid } : null;
969
+ } catch {
970
+ return null;
971
+ }
972
+ }
973
+
974
+ function writeRuntimeInfo(pid, details = {}) {
975
+ if (!Number.isInteger(pid) || pid <= 0) return;
976
+ writeJson(runtimeInfoPath(), {
977
+ pid,
978
+ cwd: process.cwd(),
979
+ startedAt: new Date().toISOString(),
980
+ ...details,
981
+ });
982
+ }
983
+
984
+ function clearRuntimeInfo(pid) {
985
+ const info = readRuntimeInfo();
986
+ if (info && pid && info.pid !== pid) return;
987
+ for (const filePath of [runtimeInfoPath(), legacyRuntimePidPath()]) {
988
+ try {
989
+ fs.rmSync(filePath, { force: true });
990
+ } catch {}
991
+ }
992
+ }
993
+
994
+ function processIsAlive(pid) {
995
+ if (!Number.isInteger(pid) || pid <= 0) return false;
996
+ try {
997
+ process.kill(pid, 0);
998
+ return true;
999
+ } catch (error) {
1000
+ return error && error.code === "EPERM";
1001
+ }
1002
+ }
1003
+
1004
+ function waitForProcessExit(pid, timeoutMs) {
1005
+ const deadline = Date.now() + timeoutMs;
1006
+ return new Promise((resolve) => {
1007
+ const tick = () => {
1008
+ if (!processIsAlive(pid)) return resolve(true);
1009
+ if (Date.now() >= deadline) return resolve(false);
1010
+ setTimeout(tick, 150);
1011
+ };
1012
+ tick();
1013
+ });
1014
+ }
1015
+
1016
+ async function stopProjectRuntime() {
1017
+ const info = readRuntimeInfo();
1018
+ if (!info || !info.pid) {
1019
+ console.log("No Alive-AI runtime pid found for this project.");
1020
+ return;
1021
+ }
1022
+
1023
+ if (!processIsAlive(info.pid)) {
1024
+ clearRuntimeInfo(info.pid);
1025
+ console.log("Removed stale Alive-AI runtime pid file.");
1026
+ return;
1027
+ }
1028
+
1029
+ console.log(`Stopping Alive-AI runtime pid ${info.pid}...`);
1030
+ try {
1031
+ process.kill(info.pid, "SIGTERM");
1032
+ } catch (error) {
1033
+ console.error(`Could not stop pid ${info.pid}: ${error.message}`);
1034
+ process.exit(1);
1035
+ }
1036
+
1037
+ if (!(await waitForProcessExit(info.pid, 5000))) {
1038
+ console.error("Alive-AI did not stop cleanly; forcing shutdown.");
1039
+ try {
1040
+ process.kill(info.pid, "SIGKILL");
1041
+ } catch {}
1042
+ await waitForProcessExit(info.pid, 2000);
1043
+ }
1044
+
1045
+ clearRuntimeInfo(info.pid);
1046
+ console.log("Alive-AI stopped.");
1047
+ }
1048
+
937
1049
  async function startRuntime(args, options = {}) {
938
1050
  if (!fs.existsSync(path.join(process.cwd(), "config", "settings.json"))) {
939
1051
  console.log("Missing config/settings.json. Starting onboarding first.");
@@ -969,25 +1081,105 @@ async function startRuntime(args, options = {}) {
969
1081
  };
970
1082
  if (options.tui) env.ALIVE_AI_TUI = "1";
971
1083
 
1084
+ const existingRuntime = readRuntimeInfo();
1085
+ if (existingRuntime && processIsAlive(existingRuntime.pid)) {
1086
+ console.error(`Alive-AI already appears to be running for this project (pid ${existingRuntime.pid}).`);
1087
+ console.error("Run `npx . stop` before starting a new session.");
1088
+ process.exit(1);
1089
+ }
1090
+ if (existingRuntime) clearRuntimeInfo(existingRuntime.pid);
1091
+
972
1092
  const child = spawn(pythonBin, ["main.py", ...extraArgs], {
973
1093
  stdio: options.tui ? ["pipe", "pipe", "pipe"] : "inherit",
974
1094
  cwd: process.cwd(),
975
1095
  env,
976
1096
  });
1097
+ writeRuntimeInfo(child.pid, { inputChannel: effectiveInputChannel });
977
1098
 
978
1099
  if (options.tui) {
979
1100
  const code = await runRuntimeTui(child, {
980
1101
  dashboard: `http://127.0.0.1:${readProjectSettings().WEBUI_PORT || DEFAULT_PORT}`,
981
1102
  });
1103
+ clearRuntimeInfo(child.pid);
982
1104
  process.exitCode = code;
983
1105
  return;
984
1106
  }
985
1107
 
986
1108
  await new Promise((resolve) => {
987
- child.on("exit", (code) => {
988
- process.exitCode = code || 0;
1109
+ let stopRequested = false;
1110
+ let stopSignal = null;
1111
+ let finished = false;
1112
+ const timers = [];
1113
+
1114
+ function cleanup() {
1115
+ for (const timer of timers) clearTimeout(timer);
1116
+ process.off("SIGINT", onSignal);
1117
+ process.off("SIGTERM", onSignal);
1118
+ process.off("SIGHUP", onSignal);
1119
+ clearRuntimeInfo(child.pid);
1120
+ }
1121
+
1122
+ function requestStop(signal) {
1123
+ stopSignal = stopSignal || signal;
1124
+ if (stopRequested) {
1125
+ if (isChildRunning(child)) {
1126
+ console.error("\nAlive-AI is still stopping; forcing shutdown.");
1127
+ child.kill("SIGKILL");
1128
+ }
1129
+ return;
1130
+ }
1131
+
1132
+ stopRequested = true;
1133
+ process.exitCode = signalExitCode(signal);
1134
+ console.log(`\nStopping Alive-AI (${signal})...`);
1135
+
1136
+ try {
1137
+ child.kill(signal === "SIGHUP" ? "SIGTERM" : signal);
1138
+ } catch {}
1139
+
1140
+ timers.push(setTimeout(() => {
1141
+ if (isChildRunning(child)) child.kill("SIGTERM");
1142
+ }, 1500));
1143
+
1144
+ timers.push(setTimeout(() => {
1145
+ if (isChildRunning(child)) {
1146
+ console.error("Alive-AI did not stop cleanly; forcing shutdown.");
1147
+ child.kill("SIGKILL");
1148
+ }
1149
+ }, 5000));
1150
+
1151
+ for (const timer of timers) timer.unref();
1152
+ }
1153
+
1154
+ function onSignal(signal) {
1155
+ requestStop(signal);
1156
+ }
1157
+
1158
+ child.on("error", (error) => {
1159
+ if (finished) return;
1160
+ finished = true;
1161
+ cleanup();
1162
+ console.error(`Alive-AI failed to start: ${error.message}`);
1163
+ process.exitCode = 1;
989
1164
  resolve();
990
1165
  });
1166
+ child.on("close", (code, signal) => {
1167
+ if (finished) return;
1168
+ finished = true;
1169
+ cleanup();
1170
+ if (stopRequested) {
1171
+ process.exitCode = code && code !== 0 ? code : signalExitCode(signal || stopSignal);
1172
+ } else if (typeof code === "number") {
1173
+ process.exitCode = code;
1174
+ } else {
1175
+ process.exitCode = signalExitCode(signal || stopSignal);
1176
+ }
1177
+ resolve();
1178
+ });
1179
+
1180
+ process.on("SIGINT", onSignal);
1181
+ process.on("SIGTERM", onSignal);
1182
+ process.on("SIGHUP", onSignal);
991
1183
  });
992
1184
  }
993
1185
 
@@ -1082,6 +1274,7 @@ async function main() {
1082
1274
  if (command === "update") return updateProject(args);
1083
1275
  if (command === "demo") return startDemo(args);
1084
1276
  if (command === "start") return startRuntime(args);
1277
+ if (command === "stop") return stopProjectRuntime(args);
1085
1278
  if (command === "chat") return startTerminalChat(args);
1086
1279
  if (command === "doctor") return doctor(args);
1087
1280
  if (command === "uninstall") return uninstallProject(args);
package/cli/tui.js CHANGED
@@ -65,6 +65,10 @@ function runRuntimeTui(child, options = {}) {
65
65
  let resolved = false;
66
66
  const isTty = process.stdin.isTTY && process.stdout.isTTY;
67
67
 
68
+ function childRunning() {
69
+ return child.exitCode === null && child.signalCode === null;
70
+ }
71
+
68
72
  function addChat(role, text) {
69
73
  state.chat.push({ role, text: String(text || "") });
70
74
  if (state.chat.length > 200) state.chat = state.chat.slice(-200);
@@ -173,10 +177,10 @@ function runRuntimeTui(child, options = {}) {
173
177
  if (child.stdin.writable) child.stdin.end();
174
178
  } catch {}
175
179
  setTimeout(() => {
176
- if (!child.killed && child.exitCode === null) child.kill("SIGTERM");
180
+ if (childRunning()) child.kill("SIGTERM");
177
181
  }, 1200).unref();
178
182
  setTimeout(() => {
179
- if (!child.killed && child.exitCode === null) child.kill("SIGKILL");
183
+ if (childRunning()) child.kill("SIGKILL");
180
184
  }, 3500).unref();
181
185
  }
182
186
 
@@ -1102,7 +1102,12 @@ async def _send_response(self, response, emotion, chat_id, text, user_id="defaul
1102
1102
  await self.nervous.emit("chat_action_voice", {})
1103
1103
  vp = await self._voice.generate(response, mood=mood)
1104
1104
  if vp:
1105
- await self.nervous.emit("send_voice_file", {"file_path": vp, "chat_id": chat_id})
1105
+ await self.nervous.emit("send_voice_file", {
1106
+ "file_path": vp,
1107
+ "chat_id": chat_id,
1108
+ "fallback_text": response,
1109
+ "mood": mood,
1110
+ })
1106
1111
  return
1107
1112
  await self.nervous.emit("send_text", {"text": response, "mood": mood, "chat_id": chat_id})
1108
1113
 
package/docs/index.html CHANGED
@@ -291,9 +291,9 @@ npx . chat</code></pre>
291
291
  <p>`npx . setup` asks for the minimum required configuration and lets optional systems be skipped. Use `local` for Ollama, `skip` for optional keys, OpenMind cloud or local for shared long-term semantic memory, and leave Redis off unless you specifically want a local Redis Stack vector cache.</p>
292
292
  <div class="steps">
293
293
  <div class="step"><strong>Start local terminal chat</strong><span>`npx . chat` starts the same runtime with chat on the left, logs on the right, and the WebUI at `http://127.0.0.1:8080`.</span></div>
294
- <div class="step"><strong>Start Telegram/runtime mode</strong><span>`npx . start` starts the configured input channel and validates Telegram before polling. Stop foreground runs with `Ctrl+C`.</span></div>
294
+ <div class="step"><strong>Start Telegram/runtime mode</strong><span>`npx . start` starts the configured input channel and validates Telegram before polling. Stop foreground runs with `Ctrl+C`; use `npx . stop` to clear a stale project runtime.</span></div>
295
295
  <div class="step"><strong>Install missing tools</strong><span>`npx . doctor --fix` asks `y/N` for each missing tool. Redis is checked only when the Redis vector cache is enabled.</span></div>
296
- <div class="step"><strong>Keep it updated</strong><span>`start` and `chat` check npm for newer versions. Use `npx . update` manually or `npx . uninstall` to remove local runtime files.</span></div>
296
+ <div class="step"><strong>Keep it updated</strong><span>`start` and `chat` check npm for newer versions. Use `npx . update` manually, `npx . stop` for a stuck session, or `npx . uninstall` to remove local runtime files.</span></div>
297
297
  <div class="step"><strong>Use OpenMind</strong><span>Choose `openmind-cloud` for `https://theopenmind.pro` or `openmind-local` for `http://127.0.0.1:3333`.</span></div>
298
298
  <div class="step"><strong>Preview the dashboard</strong><span>`npx . demo` is keyless. The real runtime dashboard streams local state over SSE.</span></div>
299
299
  </div>
@@ -4,6 +4,7 @@ Listen for Telegram messages and send reactions, voice, images
4
4
  """
5
5
 
6
6
  import asyncio
7
+ import contextlib
7
8
  import os
8
9
  from pathlib import Path
9
10
  from telegram import Update, InputFile, ReactionTypeEmoji
@@ -30,6 +31,8 @@ class TelegramListener:
30
31
  self.chat_id = None
31
32
  self.user_id = None
32
33
  self.last_message_id = None
34
+ self._stop_event = None
35
+ self._stopping = False
33
36
 
34
37
  # Command handler (initialized later with dependencies)
35
38
  self.commands = None
@@ -63,6 +66,8 @@ class TelegramListener:
63
66
 
64
67
  async def start(self):
65
68
  """Start listening - blocks forever"""
69
+ self._stop_event = asyncio.Event()
70
+
66
71
  # First check environment variable (from secrets.env), then settings
67
72
  token = os.environ.get("TELEGRAM_TOKEN") or self.config.settings.get("telegram_token")
68
73
 
@@ -178,20 +183,34 @@ class TelegramListener:
178
183
  ) from None
179
184
 
180
185
  # Block forever - keep the bot alive
181
- await asyncio.Event().wait()
186
+ try:
187
+ await self._stop_event.wait()
188
+ except asyncio.CancelledError:
189
+ await self.stop()
190
+ raise
191
+ finally:
192
+ await self.stop()
182
193
 
183
194
  async def stop(self):
184
195
  """Stop Telegram cleanly if it was started."""
185
- if not self.app:
196
+ if self._stop_event and not self._stop_event.is_set():
197
+ self._stop_event.set()
198
+ if self._stopping or not self.app:
186
199
  return
200
+ self._stopping = True
201
+ app = self.app
202
+ self.app = None
187
203
  try:
188
- if self.app.updater and self.app.updater.running:
189
- await self.app.updater.stop()
190
- if self.app.running:
191
- await self.app.stop()
192
- await self.app.shutdown()
204
+ if app.updater and app.updater.running:
205
+ with contextlib.suppress(Exception):
206
+ await app.updater.stop()
207
+ if app.running:
208
+ with contextlib.suppress(Exception):
209
+ await app.stop()
210
+ with contextlib.suppress(Exception):
211
+ await app.shutdown()
193
212
  finally:
194
- self.app = None
213
+ self._stopping = False
195
214
 
196
215
  async def _on_message(self, update: Update, context):
197
216
  """Handle incoming message"""
@@ -359,12 +378,13 @@ class TelegramListener:
359
378
  print(f"[Telegram] Send text error: {e}")
360
379
 
361
380
  async def _send_voice_file(self, data: dict):
362
- """Send voice file (OGG format for Telegram)"""
381
+ """Send voice file, falling back to audio/text if Telegram rejects it."""
363
382
  if not self.app:
364
383
  return
365
384
 
366
385
  chat_id = data.get("chat_id", self.chat_id)
367
386
  file_path = data.get("file_path", "")
387
+ fallback_text = data.get("fallback_text", "")
368
388
 
369
389
  if not chat_id or not file_path:
370
390
  return
@@ -372,18 +392,32 @@ class TelegramListener:
372
392
  path = Path(file_path)
373
393
  if not path.exists():
374
394
  print(f"[Telegram] Voice file not found: {file_path}")
395
+ if fallback_text:
396
+ await self._send_text({"chat_id": chat_id, "text": fallback_text})
375
397
  return
376
398
 
377
399
  try:
378
- with open(path, "rb") as voice_file:
379
- await self.app.bot.send_voice(
400
+ if path.suffix.lower() == ".ogg":
401
+ with open(path, "rb") as voice_file:
402
+ await self.app.bot.send_voice(
403
+ chat_id=chat_id,
404
+ voice=voice_file,
405
+ caption=data.get("caption", "")
406
+ )
407
+ print(f"[Telegram] Sent voice: {file_path}")
408
+ return
409
+
410
+ with open(path, "rb") as audio_file:
411
+ await self.app.bot.send_audio(
380
412
  chat_id=chat_id,
381
- voice=voice_file,
413
+ audio=audio_file,
382
414
  caption=data.get("caption", "")
383
415
  )
384
- print(f"[Telegram] Sent voice: {file_path}")
416
+ print(f"[Telegram] Sent audio fallback: {file_path}")
385
417
  except Exception as e:
386
- print(f"[Telegram] Send voice error: {e}")
418
+ print(f"[Telegram] Send voice/audio error: {e}")
419
+ if fallback_text:
420
+ await self._send_text({"chat_id": chat_id, "text": fallback_text})
387
421
 
388
422
  async def _send_image(self, data: dict):
389
423
  """Send image file"""
package/main.py CHANGED
@@ -12,6 +12,7 @@ import argparse
12
12
  import contextlib
13
13
  import json
14
14
  import os
15
+ import signal
15
16
  import sys
16
17
  import traceback
17
18
  from pathlib import Path
@@ -91,6 +92,28 @@ async def main() -> None:
91
92
 
92
93
  ai = Self(ROOT)
93
94
  webui_task = None
95
+ runtime_task = None
96
+ stop_task = None
97
+ stop_event = asyncio.Event()
98
+ loop = asyncio.get_running_loop()
99
+
100
+ def request_stop(signame: str) -> None:
101
+ if stop_event.is_set():
102
+ return
103
+ print(f"\n[Alive-AI] Stopping ({signame})...")
104
+ stop_event.set()
105
+
106
+ signal_handlers = []
107
+ for signame in ("SIGINT", "SIGTERM"):
108
+ signum = getattr(signal, signame, None)
109
+ if signum is None:
110
+ continue
111
+ try:
112
+ loop.add_signal_handler(signum, request_stop, signame)
113
+ signal_handlers.append(signum)
114
+ except (NotImplementedError, RuntimeError, ValueError):
115
+ pass
116
+
94
117
  webui_enabled = str(settings.get("WEBUI_ENABLED", os.environ.get("WEBUI_ENABLED", "true"))).lower() != "false"
95
118
  webui_port = int(settings.get("WEBUI_PORT", os.environ.get("WEBUI_PORT", "8080")))
96
119
 
@@ -108,7 +131,25 @@ async def main() -> None:
108
131
  print(f"[Alive-AI] WebUI unavailable: {exc}")
109
132
 
110
133
  input_channel = args.input or os.environ.get("ALIVE_AI_INPUT_CHANNEL") or settings.get("INPUT_CHANNEL", "telegram")
111
- await ai.start(input_channel=input_channel)
134
+ runtime_task = asyncio.create_task(ai.start(input_channel=input_channel))
135
+ stop_task = asyncio.create_task(stop_event.wait())
136
+ done, _pending = await asyncio.wait(
137
+ {runtime_task, stop_task},
138
+ return_when=asyncio.FIRST_COMPLETED,
139
+ )
140
+
141
+ if stop_task in done:
142
+ if runtime_task and not runtime_task.done():
143
+ runtime_task.cancel()
144
+ with contextlib.suppress(asyncio.CancelledError):
145
+ await runtime_task
146
+ return
147
+
148
+ if stop_task and not stop_task.done():
149
+ stop_task.cancel()
150
+ with contextlib.suppress(asyncio.CancelledError):
151
+ await stop_task
152
+ await runtime_task
112
153
  except KeyboardInterrupt:
113
154
  print("\n[Alive-AI] Stopping...")
114
155
  except Exception as exc:
@@ -120,6 +161,17 @@ async def main() -> None:
120
161
  print("[Alive-AI] Telegram could not start. Check the bot token/network, or run `npx . chat` for terminal mode.")
121
162
  sys.exit(1)
122
163
  finally:
164
+ for signum in signal_handlers:
165
+ with contextlib.suppress(Exception):
166
+ loop.remove_signal_handler(signum)
167
+ if stop_task and not stop_task.done():
168
+ stop_task.cancel()
169
+ with contextlib.suppress(asyncio.CancelledError):
170
+ await stop_task
171
+ if runtime_task and not runtime_task.done():
172
+ runtime_task.cancel()
173
+ with contextlib.suppress(asyncio.CancelledError):
174
+ await runtime_task
123
175
  with contextlib.suppress(Exception):
124
176
  await ai.stop()
125
177
  if webui_task:
@@ -8,6 +8,7 @@ import asyncio
8
8
  import re
9
9
  import tempfile
10
10
  import subprocess
11
+ import warnings
11
12
  from pathlib import Path
12
13
  from typing import Optional
13
14
  from concurrent.futures import ThreadPoolExecutor
@@ -113,17 +114,29 @@ class GTTS:
113
114
  tts.write_to_fp(mp3_buffer)
114
115
  mp3_buffer.seek(0)
115
116
 
116
- # Convert MP3 to OGG for Telegram
117
- # If pydub is available, convert to OGG
117
+ # Convert MP3 to OGG Opus for Telegram voice notes.
118
+ # pydub's default OGG codec can resolve to Vorbis, which Telegram
119
+ # does not want for voice notes and some ffmpeg builds do not ship.
118
120
  try:
121
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module=r"pydub\..*")
119
122
  from pydub import AudioSegment
120
123
  audio = AudioSegment.from_mp3(mp3_buffer)
121
124
  ogg_buffer = io.BytesIO()
122
- audio.export(ogg_buffer, format="ogg")
125
+ audio.export(
126
+ ogg_buffer,
127
+ format="ogg",
128
+ codec="libopus",
129
+ bitrate="32k",
130
+ parameters=["-application", "voip"],
131
+ )
123
132
  ogg_buffer.seek(0)
124
133
  return ogg_buffer.read()
125
134
  except ImportError:
126
- # No pydub - return MP3, Telegram accepts it too
135
+ # No pydub - return MP3 and let the sender use send_audio.
136
+ mp3_buffer.seek(0)
137
+ return mp3_buffer.read()
138
+ except Exception as exc:
139
+ print(f"[GTTS] OGG Opus conversion failed, falling back to MP3: {exc}")
127
140
  mp3_buffer.seek(0)
128
141
  return mp3_buffer.read()
129
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alive-ai",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Local-first emotional AI runtime with memory, impulses, and a live dashboard.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://vindepemarte.github.io/alive-ai/",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alive-ai-runtime"
3
- version = "0.1.8"
3
+ version = "0.1.9"
4
4
  description = "Local-first emotional AI runtime with memory, impulses, and a live dashboard."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"