linkgravity 1.5.1 → 1.5.3

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/bin/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { spawn, spawnSync } = require('child_process');
4
4
  const path = require('path');
5
5
  const fs = require('fs');
6
+ const os = require('os');
6
7
  const {
7
8
  PLATFORMS,
8
9
  getSettings,
@@ -236,20 +237,79 @@ function renderTable(headers, rows) {
236
237
  return lines.join('\n');
237
238
  }
238
239
 
240
+ // Mirrors src/config.py's AGY_BIN resolution (AGY_BIN_PATH env var, else ~/.local/bin/agy), plus a PATH fallback for installs that don't use the default location.
241
+ function findAgyBin() {
242
+ const envPath = process.env.AGY_BIN_PATH;
243
+ if (envPath && fs.existsSync(envPath)) return envPath;
244
+
245
+ const defaultPath = path.join(os.homedir(), '.local', 'bin', 'agy');
246
+ if (fs.existsSync(defaultPath)) return defaultPath;
247
+
248
+ const which = spawnSync(isWin ? 'where' : 'which', ['agy'], { stdio: 'pipe' });
249
+ if (which.status === 0) {
250
+ const out = which.stdout.toString().trim().split('\n')[0].trim();
251
+ if (out) return out;
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function getPm2Proc() {
257
+ const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
258
+ if (jlist.status !== 0) return null;
259
+ try {
260
+ const procs = JSON.parse(jlist.stdout.toString());
261
+ return procs.find((p) => p.name === LGY_PM2_NAME) || null;
262
+ } catch (e) {
263
+ return null;
264
+ }
265
+ }
266
+
239
267
  if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
240
268
  const pkg = require('../package.json');
241
269
  console.log(`linkgravity v${pkg.version}`);
242
270
  } else if (cmd === 'start') {
271
+ const existing = getPm2Proc();
272
+ if (existing && existing.pm2_env.status === 'online') {
273
+ console.log(
274
+ `\n${color.yellow}⚠${color.reset} LinkGravity is already running. ` +
275
+ `Use ${color.cyan}lgy restart${color.reset} to apply changes, or ${color.cyan}lgy stop${color.reset} first.\n`,
276
+ );
277
+ process.exit(1);
278
+ }
279
+
280
+ const settings = getSettings();
281
+ const anyConfigured = Object.keys(PLATFORMS).some(
282
+ (key) => platformState(key, settings).configured,
283
+ );
284
+ if (!anyConfigured) {
285
+ console.log(
286
+ `\n${color.yellow}⚠${color.reset} No messenger is configured yet - ` +
287
+ `set up at least one of Discord, Telegram, or Slack first: ${color.cyan}lgy setup${color.reset}\n`,
288
+ );
289
+ process.exit(1);
290
+ }
291
+
292
+ if (!findAgyBin()) {
293
+ console.log(
294
+ `\n${color.yellow}⚠${color.reset} Couldn't find the agy CLI ` +
295
+ `(checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). Install/configure agy first, ` +
296
+ `or set the AGY_BIN_PATH environment variable to its location.\n`,
297
+ );
298
+ process.exit(1);
299
+ }
300
+
243
301
  info('Starting LinkGravity daemon...');
244
302
  runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
245
303
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
246
304
  } else if (cmd === 'stop') {
247
305
  info('Stopping LinkGravity daemon...');
248
306
  runPm2(['stop', LGY_PM2_NAME]);
307
+ runPm2(['reset', LGY_PM2_NAME]);
249
308
  success('Daemon stopped successfully.\n');
250
309
  } else if (cmd === 'restart') {
251
310
  info('Restarting LinkGravity daemon...');
252
311
  runPm2(['restart', LGY_PM2_NAME, '--update-env']);
312
+ runPm2(['reset', LGY_PM2_NAME]);
253
313
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
254
314
  } else if (cmd === 'logs') {
255
315
  const SHORT_FLAGS = ['-f', '-n', '-t'];
@@ -292,15 +352,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
292
352
  const sessions = getSessions();
293
353
  const health = getPlatformHealth();
294
354
 
295
- let pm2Procs = [];
296
- const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
297
- if (jlist.status === 0) {
298
- try {
299
- pm2Procs = JSON.parse(jlist.stdout.toString());
300
- } catch (e) {}
301
- }
302
-
303
- const proc = pm2Procs.find((p) => p.name === LGY_PM2_NAME);
355
+ const proc = getPm2Proc();
304
356
  if (!proc) {
305
357
  console.log(`\n${color.cyan}▶${color.reset} daemon: not running\n`);
306
358
  } else {
@@ -310,9 +362,21 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
310
362
  const uptime =
311
363
  proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
312
364
  const restarts = proc.pm2_env.restart_time;
365
+ const statusColor = proc.pm2_env.status === 'online' ? color.green : color.red;
366
+
313
367
  console.log(
314
- `\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status} (uptime: ${uptime}, restarts: ${restarts}, cpu: ${cpu}, mem: ${mem})\n`,
368
+ `\n${color.cyan}▶${color.reset} daemon: ${statusColor}${proc.pm2_env.status}${color.reset}`,
315
369
  );
370
+ for (const [label, value] of [
371
+ ['uptime', uptime],
372
+ ['restarts', restarts],
373
+ ['cpu', cpu],
374
+ ['mem', mem],
375
+ ]) {
376
+ console.log(` ${label.padEnd(9)} ${value}`);
377
+ }
378
+ console.log();
379
+
316
380
  // Flag "many restarts" only alongside a short current uptime - restart_time alone is cumulative, not live.
317
381
  if (
318
382
  proc.pm2_env.status === 'online' &&
@@ -331,16 +395,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
331
395
  const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
332
396
  const h = health[key];
333
397
  let connection = '-';
398
+ let since = '-';
334
399
  if (enabled) {
335
400
  if (!h) connection = 'unknown';
336
401
  else if (h.status === 'running') connection = 'connected';
337
402
  else if (h.status === 'connecting') connection = 'connecting...';
338
403
  else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
339
404
  else if (h.status === 'stopped') connection = 'stopped';
405
+ if (h && h.at) since = formatUptime(new Date(h.at).getTime());
340
406
  }
341
- return [def.label, enabled ? 'yes' : 'no', connection, String(sessionCount)];
407
+ return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
342
408
  });
343
- console.log(renderTable(['platform', 'enabled', 'connection', 'sessions'], rows));
409
+ console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
344
410
  console.log();
345
411
  } else if (cmd === 'enable') {
346
412
  if (isWin) {
@@ -409,6 +475,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
409
475
  });
410
476
 
411
477
  if (restartResult.status === 0) {
478
+ runPm2(['reset', LGY_PM2_NAME]);
412
479
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
413
480
  } else if ((restartResult.stderr || '').toString().includes('not found')) {
414
481
  // Wasn't running before the update - start fresh instead of a false "restarted".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
@@ -40,7 +40,7 @@
40
40
  "npm-scripts",
41
41
  "hooks",
42
42
  "src",
43
- "voice-service/index.js",
43
+ "voice-service/*.js",
44
44
  "voice-service/package.json",
45
45
  "voice-service/package-lock.json",
46
46
  "requirements.txt",
@@ -242,7 +242,7 @@ class GeneralCog(commands.Cog):
242
242
  if not allowed(interaction.user.id):
243
243
  return await interaction.response.send_message("❌ Denied", ephemeral=True)
244
244
 
245
- settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
245
+ settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
246
246
  try:
247
247
  data = safe_load_json(settings_path, {}, logger=logger)
248
248
 
@@ -202,7 +202,7 @@ class VoiceCog(commands.Cog):
202
202
  await interaction.response.send_message("❌ Please join a voice channel first.", ephemeral=True)
203
203
  return
204
204
 
205
- vc_chan = interaction.user.voice.channel
205
+ voice_channel = interaction.user.voice.channel
206
206
  guild_id = interaction.guild_id
207
207
 
208
208
  wake_word_map = self.bot_settings.get("wake_words") or {}
@@ -210,23 +210,26 @@ class VoiceCog(commands.Cog):
210
210
  active_timer = self.bot_settings.get("active_timer", 60)
211
211
  required = self._wake_word_required(interaction.user.id)
212
212
 
213
- if own_word:
214
- msg = (
215
- f"🎤 Connected to `{vc_chan.name}`.\n"
216
- f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
217
- f"⚙️ You can customize settings using `/sound`."
213
+ header = f"🎤 Connected to `{voice_channel.name}`."
214
+ sound_tip = "⚙️ You can customize settings using `/sound`."
215
+
216
+ if not required:
217
+ body = "💡 Wake word is off for you - just talk, I'm listening."
218
+ elif own_word:
219
+ body = (
220
+ f"💡 Say `{own_word}` to activate me. Once awake, "
221
+ f"I'll keep listening for {active_timer} seconds after each interaction."
218
222
  )
219
- elif not required:
220
- msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
221
223
  else:
222
- msg = (
223
- f"🎤 Connected to `{vc_chan.name}`.\n"
224
- f"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
225
- f"and say your chosen word a few times to register it in your voice.\n"
226
- f"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
227
- f"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
228
- f"turning it off with `/sound require_wake_word:off` is recommended instead."
224
+ body = (
225
+ "🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
226
+ "and say your chosen word a few times to register it in your voice.\n"
227
+ "💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
228
+ "avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
229
+ "turning it off with `/sound require_wake_word:off` is recommended instead."
229
230
  )
231
+
232
+ msg = "\n".join([header, body, sound_tip])
230
233
  await interaction.response.send_message(msg)
231
234
 
232
235
  self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
@@ -239,7 +242,7 @@ class VoiceCog(commands.Cog):
239
242
  self.logger.warning(f"Failed to call /leave before /join: {e}")
240
243
 
241
244
  resp = await session.post(
242
- f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(vc_chan.id)}
245
+ f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(voice_channel.id)}
243
246
  )
244
247
  if not required:
245
248
  # Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
@@ -554,15 +557,16 @@ class VoiceCog(commands.Cog):
554
557
  self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
555
558
  self._active_turns[str(guild_id)] = asyncio.current_task()
556
559
 
557
- user = self.bot.get_user(int(user_id))
558
- if not user:
560
+ guild = self.bot.get_guild(int(guild_id))
561
+ member = guild.get_member(int(user_id)) if guild else None
562
+ if guild and not member:
559
563
  try:
560
- user = await self.bot.fetch_user(int(user_id))
564
+ member = await guild.fetch_member(int(user_id))
561
565
  except discord.NotFound:
562
566
  pass
563
567
  except discord.HTTPException as e:
564
- self.logger.warning(f"fetch_user failed for {user_id}: {e}")
565
- username = user.display_name if user else f"User {user_id}"
568
+ self.logger.warning(f"fetch_member failed for {user_id}: {e}")
569
+ username = member.display_name if member else f"User {user_id}"
566
570
  await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"🎤 **{username}**: {text}")
567
571
 
568
572
  if not text_to_ai:
@@ -3,70 +3,70 @@ import functools
3
3
  import os
4
4
  import re
5
5
  import signal
6
+ import sys
6
7
 
7
8
  from config import logger
8
9
 
9
10
  active_processes = {}
10
11
  agy_start_lock = asyncio.Lock()
11
- # Threads killed intentionally - agy's SIGTERM exit code isn't reliable enough to tell otherwise.
12
12
  _intentionally_stopped = set()
13
13
 
14
- # Forced stdout buffer size (see _find_libstdbuf) - big enough for one write, small enough not to delay polling.
15
14
  _STDOUT_BUFFER_SIZE = 65536
16
15
 
17
16
 
18
17
  @functools.lru_cache(maxsize=1)
19
- def _find_libstdbuf() -> str | None:
20
- """Find libstdbuf.so, the shared library `stdbuf` LD_PRELOADs. Linux-only
21
- (no macOS/Windows equivalent implemented) - returns None there too.
22
-
23
- agy runs commands under a PTY, so glibc line-buffers instead of
24
- fully-buffering stdout - agy's completion-detection misreads the gap
25
- between line writes as "done," truncating multi-line output to its
26
- first line despite exit code 0. LD_PRELOADing this forces full
27
- buffering instead. Returns None (no fix applied) if not found.
28
- """
29
- candidates = [
30
- "/usr/lib/x86_64-linux-gnu/coreutils/libstdbuf.so", # Debian/Ubuntu
31
- "/usr/lib/aarch64-linux-gnu/coreutils/libstdbuf.so",
32
- "/usr/libexec/coreutils/libstdbuf.so", # Fedora/RHEL
33
- "/usr/lib/coreutils/libstdbuf.so", # Arch
34
- "/usr/lib/libstdbuf.so",
35
- ]
18
+ def _find_preload_lib() -> tuple[str, str] | None:
19
+ if sys.platform == "darwin":
20
+ env_var = "DYLD_INSERT_LIBRARIES"
21
+ lib_name = "libstdbuf.dylib"
22
+ candidates = [
23
+ "/opt/homebrew/opt/coreutils/lib/libstdbuf.dylib",
24
+ "/usr/local/opt/coreutils/lib/libstdbuf.dylib",
25
+ ]
26
+ search_root = "/opt/homebrew" if os.path.isdir("/opt/homebrew") else "/usr/local"
27
+ elif os.name == "nt":
28
+ return None
29
+ else:
30
+ env_var = "LD_PRELOAD"
31
+ lib_name = "libstdbuf.so"
32
+ candidates = [
33
+ "/usr/lib/x86_64-linux-gnu/coreutils/libstdbuf.so",
34
+ "/usr/lib/aarch64-linux-gnu/coreutils/libstdbuf.so",
35
+ "/usr/libexec/coreutils/libstdbuf.so",
36
+ "/usr/lib/coreutils/libstdbuf.so",
37
+ "/usr/lib/libstdbuf.so",
38
+ ]
39
+ search_root = "/usr"
40
+
36
41
  for path in candidates:
37
42
  if os.path.isfile(path):
38
- return path
43
+ return env_var, path
39
44
 
40
- # lru_cache: only runs once per process.
41
45
  try:
42
46
  import subprocess
43
47
 
44
48
  result = subprocess.run(
45
- ["find", "/usr", "-name", "libstdbuf.so"],
49
+ ["find", search_root, "-name", lib_name],
46
50
  capture_output=True,
47
51
  text=True,
48
52
  timeout=5,
49
53
  )
50
54
  found = [line for line in result.stdout.strip().splitlines() if line]
51
55
  if found:
52
- return found[0]
56
+ return env_var, found[0]
53
57
  except Exception:
54
58
  pass
55
59
 
60
+ install_hint = "brew install coreutils" if sys.platform == "darwin" else "install coreutils"
56
61
  logger.warning(
57
- "[AGY ENV] libstdbuf.so not found - run_command output for "
58
- "multi-line commands may come back truncated. Add its path to "
59
- "_find_libstdbuf()'s candidates list if coreutils is installed "
60
- "somewhere nonstandard."
62
+ f"[AGY ENV] {lib_name} not found - run_command output for multi-line "
63
+ f"commands may come back truncated. Try `{install_hint}`, or add its path "
64
+ "to _find_preload_lib()'s candidates list if installed somewhere nonstandard."
61
65
  )
62
66
  return None
63
67
 
64
68
 
65
69
  def stop_active_process(thread_id: str) -> bool:
66
- """Kill the agy subprocess for this thread, if any. Also used when a
67
- new voice utterance interrupts a still-in-flight turn. Returns
68
- whether a process was actually found and signaled.
69
- """
70
70
  target_proc = active_processes.get(thread_id)
71
71
  if not target_proc:
72
72
  return False
@@ -100,6 +100,24 @@ async def _get_latest_conversation_id() -> str:
100
100
  return ""
101
101
 
102
102
 
103
+ def _snapshot_conversation_dirs() -> set[str]:
104
+ from pathlib import Path
105
+
106
+ history_dir = Path.home() / ".gemini/antigravity-cli/brain"
107
+ if not history_dir.exists():
108
+ return set()
109
+ return {d.name for d in history_dir.iterdir() if d.is_dir()}
110
+
111
+
112
+ async def _poll_new_conversation_id(before: set[str], attempts: int = 30, interval: float = 0.1) -> str:
113
+ for _ in range(attempts):
114
+ await asyncio.sleep(interval)
115
+ new_dirs = _snapshot_conversation_dirs() - before
116
+ if new_dirs:
117
+ return next(iter(new_dirs))
118
+ return ""
119
+
120
+
103
121
  async def run_agy(
104
122
  *args, timeout: int = 300, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
105
123
  ) -> str:
@@ -123,10 +141,11 @@ async def run_agy(
123
141
  if thread_id:
124
142
  env["LGY_THREAD_ID"] = thread_id
125
143
 
126
- libstdbuf_path = _find_libstdbuf() # works around agy's output-truncation bug
127
- if libstdbuf_path:
128
- existing_preload = env.get("LD_PRELOAD", "")
129
- env["LD_PRELOAD"] = f"{libstdbuf_path}:{existing_preload}" if existing_preload else libstdbuf_path
144
+ preload = _find_preload_lib() # works around agy's output-truncation bug
145
+ if preload:
146
+ env_var, lib_path = preload
147
+ existing_preload = env.get(env_var, "")
148
+ env[env_var] = f"{lib_path}:{existing_preload}" if existing_preload else lib_path
130
149
  env["_STDBUF_O"] = str(_STDOUT_BUFFER_SIZE)
131
150
 
132
151
  kwargs = {
@@ -149,15 +168,15 @@ async def run_agy(
149
168
  cmd = [AGY_BIN] + args_list
150
169
 
151
170
  async with agy_start_lock:
171
+ before_dirs = _snapshot_conversation_dirs()
152
172
  proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
153
173
  if thread_id:
154
174
  active_processes[thread_id] = proc
155
175
 
156
176
  if stream_queue and "--conversation" not in args:
157
- await asyncio.sleep(1.0)
158
- latest_conv_id = await _get_latest_conversation_id()
159
- if latest_conv_id:
160
- await stream_queue.put(("__CONV_ID__:" + latest_conv_id, False))
177
+ new_conv_id = await _poll_new_conversation_id(before_dirs)
178
+ if new_conv_id:
179
+ await stream_queue.put(("__CONV_ID__:" + new_conv_id, False))
161
180
 
162
181
  stdout_chunks = []
163
182
  stderr_chunks = []
@@ -267,7 +286,6 @@ async def run_agy(
267
286
  await stream_queue.put(("\n\n" + error_msg, True))
268
287
  return error_msg
269
288
 
270
- # DEBUG-only (LOG_LEVEL) raw stdout capture.
271
289
  logger.debug(f"[AGY RAW STDOUT] {text!r}")
272
290
  return text or "(Empty response)"
273
291
 
@@ -379,11 +397,6 @@ async def generate_thread_title(user_input: str, response: str) -> str:
379
397
 
380
398
 
381
399
  async def update_agy_conversation_title(conv_id: str, title: str) -> None:
382
- """Antigravity CLI's own conversation list reads its display name from
383
- conversation_summaries.db's `preview` column (the `title` column exists
384
- but is unused/always empty - confirmed by inspecting the db directly).
385
- Without this, agy shows its own auto-generated name while the Discord
386
- thread shows ours, and the two drift apart for the same conversation."""
387
400
  if not conv_id:
388
401
  return
389
402
 
package/src/main_slack.py CHANGED
@@ -139,12 +139,11 @@ async def cmd_credit(ack, body, respond, context) -> None:
139
139
  await respond("❌ Denied")
140
140
  return
141
141
 
142
- import os
143
142
  from pathlib import Path
144
143
 
145
144
  from core.atomic_io import atomic_write_json, safe_load_json
146
145
 
147
- settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
146
+ settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
148
147
  current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
149
148
 
150
149
  async def set_credit(use_credits: bool, action_body, client) -> None:
@@ -2,7 +2,6 @@
2
2
  which starts both concurrently when both platforms are enabled."""
3
3
 
4
4
  import asyncio
5
- import os
6
5
  import uuid
7
6
  from datetime import datetime
8
7
  from pathlib import Path
@@ -144,7 +143,7 @@ async def cmd_credit(update: Update, context) -> None:
144
143
  await update.message.reply_text("❌ Denied")
145
144
  return
146
145
 
147
- settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
146
+ settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
148
147
  current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
149
148
 
150
149
  async def set_credit(use_credits: bool, query) -> None:
@@ -182,6 +181,10 @@ async def cmd_credit(update: Update, context) -> None:
182
181
 
183
182
 
184
183
  async def on_message(update: Update, context) -> None:
184
+ chat_id = update.effective_chat.id
185
+ user = update.effective_user
186
+ if user and allowed(user.id, "telegram") and not session_manager.get_session(str(chat_id)):
187
+ _start_session(chat_id, user.id)
185
188
  await handle_message(None, update, context.bot_data["adapter"])
186
189
 
187
190
 
@@ -0,0 +1,31 @@
1
+ function stereoToMono(buffer) {
2
+ // Discord voice receive is 48kHz stereo; everything downstream (WAV, Rustpotter) expects mono.
3
+ const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
4
+ const mono = Buffer.alloc(samples * 2);
5
+ for (let i = 0; i < samples; i++) {
6
+ const l = buffer.readInt16LE(i * 4);
7
+ const r = buffer.readInt16LE(i * 4 + 2);
8
+ mono.writeInt16LE((l + r) >> 1, i * 2);
9
+ }
10
+ return mono;
11
+ }
12
+
13
+ function createWavHeader(dataLength, sampleRate = 48000, channels = 1, bitDepth = 16) {
14
+ const buffer = Buffer.alloc(44);
15
+ buffer.write('RIFF', 0);
16
+ buffer.writeUInt32LE(36 + dataLength, 4);
17
+ buffer.write('WAVE', 8);
18
+ buffer.write('fmt ', 12);
19
+ buffer.writeUInt32LE(16, 16);
20
+ buffer.writeUInt16LE(1, 20);
21
+ buffer.writeUInt16LE(channels, 22);
22
+ buffer.writeUInt32LE(sampleRate, 24);
23
+ buffer.writeUInt32LE(sampleRate * channels * (bitDepth / 8), 28);
24
+ buffer.writeUInt16LE(channels * (bitDepth / 8), 32);
25
+ buffer.writeUInt16LE(bitDepth, 34);
26
+ buffer.write('data', 36);
27
+ buffer.writeUInt32LE(dataLength, 40);
28
+ return buffer;
29
+ }
30
+
31
+ module.exports = { stereoToMono, createWavHeader };
@@ -0,0 +1,24 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ const aglJsonPath = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
6
+ let aglConfig = {};
7
+ try {
8
+ if (fs.existsSync(aglJsonPath)) {
9
+ aglConfig = JSON.parse(fs.readFileSync(aglJsonPath, 'utf8'));
10
+ }
11
+ } catch (e) {
12
+ console.error('Failed to load lgy.json:', e.message);
13
+ }
14
+
15
+ process.env.DISCORD_TOKEN =
16
+ aglConfig.discord_token || aglConfig.DISCORD_TOKEN || process.env.DISCORD_TOKEN;
17
+
18
+ process.env.http_proxy = '';
19
+ process.env.https_proxy = '';
20
+ process.env.HTTP_PROXY = '';
21
+ process.env.HTTPS_PROXY = '';
22
+ process.env.PYTHON_HOST = '';
23
+
24
+ module.exports = { aglConfig };
@@ -1,10 +1,21 @@
1
- require('./logger'); // side-effect: timestamps console.log/error - must load before anything else logs
1
+ require('./logger');
2
2
 
3
3
  const { Client, GatewayIntentBits, Events } = require('discord.js');
4
4
  const express = require('express');
5
5
 
6
6
  const { aglConfig } = require('./config');
7
7
  const state = require('./state');
8
+
9
+ process.on('unhandledRejection', (reason) => {
10
+ console.error('Unhandled promise rejection (voice service stays alive):', reason);
11
+ });
12
+
13
+ process.on('uncaughtException', (err) => {
14
+ // Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
15
+ console.error('Uncaught exception - voice service is exiting:', err);
16
+ process.exit(1);
17
+ });
18
+
8
19
  const { registerRoutes } = require('./routes');
9
20
 
10
21
  if (aglConfig.voice_threshold) {
@@ -24,16 +35,6 @@ client.once(Events.ClientReady, () => {
24
35
 
25
36
  registerRoutes(app, client);
26
37
 
27
- process.on('unhandledRejection', (reason) => {
28
- console.error('Unhandled promise rejection (voice service stays alive):', reason);
29
- });
30
-
31
- process.on('uncaughtException', (err) => {
32
- // Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
33
- console.error('Uncaught exception - voice service is exiting:', err);
34
- process.exit(1);
35
- });
36
-
37
38
  // Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
38
39
  function shutdownGracefully() {
39
40
  console.log(
@@ -0,0 +1,16 @@
1
+ const originalLog = console.log;
2
+ const originalError = console.error;
3
+
4
+ function getTimestamp() {
5
+ const now = new Date();
6
+ const offset = now.getTimezoneOffset() * 60000;
7
+ const localTime = new Date(now.getTime() - offset);
8
+ return localTime.toISOString().replace('T', ' ').substring(0, 19);
9
+ }
10
+
11
+ console.log = function (...args) {
12
+ originalLog(`${getTimestamp()} INFO Voice:`, ...args);
13
+ };
14
+ console.error = function (...args) {
15
+ originalError(`${getTimestamp()} ERROR Voice:`, ...args);
16
+ };