linkgravity 1.5.0 → 1.5.2

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
@@ -246,10 +246,12 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
246
246
  } else if (cmd === 'stop') {
247
247
  info('Stopping LinkGravity daemon...');
248
248
  runPm2(['stop', LGY_PM2_NAME]);
249
+ runPm2(['reset', LGY_PM2_NAME]);
249
250
  success('Daemon stopped successfully.\n');
250
251
  } else if (cmd === 'restart') {
251
252
  info('Restarting LinkGravity daemon...');
252
253
  runPm2(['restart', LGY_PM2_NAME, '--update-env']);
254
+ runPm2(['reset', LGY_PM2_NAME]);
253
255
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
254
256
  } else if (cmd === 'logs') {
255
257
  const SHORT_FLAGS = ['-f', '-n', '-t'];
@@ -310,9 +312,21 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
310
312
  const uptime =
311
313
  proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
312
314
  const restarts = proc.pm2_env.restart_time;
315
+ const statusColor = proc.pm2_env.status === 'online' ? color.green : color.red;
316
+
313
317
  console.log(
314
- `\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status} (uptime: ${uptime}, restarts: ${restarts}, cpu: ${cpu}, mem: ${mem})\n`,
318
+ `\n${color.cyan}▶${color.reset} daemon: ${statusColor}${proc.pm2_env.status}${color.reset}`,
315
319
  );
320
+ for (const [label, value] of [
321
+ ['uptime', uptime],
322
+ ['restarts', restarts],
323
+ ['cpu', cpu],
324
+ ['mem', mem],
325
+ ]) {
326
+ console.log(` ${label.padEnd(9)} ${value}`);
327
+ }
328
+ console.log();
329
+
316
330
  // Flag "many restarts" only alongside a short current uptime - restart_time alone is cumulative, not live.
317
331
  if (
318
332
  proc.pm2_env.status === 'online' &&
@@ -331,16 +345,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
331
345
  const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
332
346
  const h = health[key];
333
347
  let connection = '-';
348
+ let since = '-';
334
349
  if (enabled) {
335
350
  if (!h) connection = 'unknown';
336
351
  else if (h.status === 'running') connection = 'connected';
337
352
  else if (h.status === 'connecting') connection = 'connecting...';
338
353
  else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
339
354
  else if (h.status === 'stopped') connection = 'stopped';
355
+ if (h && h.at) since = formatUptime(new Date(h.at).getTime());
340
356
  }
341
- return [def.label, enabled ? 'yes' : 'no', connection, String(sessionCount)];
357
+ return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
342
358
  });
343
- console.log(renderTable(['platform', 'enabled', 'connection', 'sessions'], rows));
359
+ console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
344
360
  console.log();
345
361
  } else if (cmd === 'enable') {
346
362
  if (isWin) {
@@ -409,6 +425,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
409
425
  });
410
426
 
411
427
  if (restartResult.status === 0) {
428
+ runPm2(['reset', LGY_PM2_NAME]);
412
429
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
413
430
  } else if ((restartResult.stderr || '').toString().includes('not found')) {
414
431
  // 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.0",
3
+ "version": "1.5.2",
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",
@@ -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
 
@@ -153,6 +153,33 @@ class VoiceCog(commands.Cog):
153
153
  opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
154
154
  return opts
155
155
 
156
+ async def require_wake_word_autocomplete(
157
+ self, interaction: discord.Interaction, current: str
158
+ ) -> list[app_commands.Choice[str]]:
159
+ is_on = self._wake_word_required(interaction.user.id)
160
+ current_val = "ON" if is_on else "OFF"
161
+ other_val = "OFF" if is_on else "ON"
162
+
163
+ opts = []
164
+ if current.lower() in current_val.lower() or not current:
165
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val.lower()))
166
+ if current.lower() in other_val.lower():
167
+ opts.append(app_commands.Choice(name=other_val, value=other_val.lower()))
168
+ return opts
169
+
170
+ async def tts_speed_autocomplete(
171
+ self, interaction: discord.Interaction, current: str
172
+ ) -> list[app_commands.Choice[float]]:
173
+ current_val = float(self.bot_settings.get("tts_speed", 1.0))
174
+ opts = []
175
+ if str(current_val) in current or not current:
176
+ opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
177
+
178
+ for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
179
+ if v != current_val and len(opts) < 25:
180
+ opts.append(app_commands.Choice(name=f"{v}x", value=v))
181
+ return opts
182
+
156
183
  @app_commands.command(name="join", description="Summon the bot to your current voice channel")
157
184
  async def cmd_join(self, interaction: discord.Interaction):
158
185
  if not allowed(interaction.user.id):
@@ -175,7 +202,7 @@ class VoiceCog(commands.Cog):
175
202
  await interaction.response.send_message("❌ Please join a voice channel first.", ephemeral=True)
176
203
  return
177
204
 
178
- vc_chan = interaction.user.voice.channel
205
+ voice_channel = interaction.user.voice.channel
179
206
  guild_id = interaction.guild_id
180
207
 
181
208
  wake_word_map = self.bot_settings.get("wake_words") or {}
@@ -183,23 +210,26 @@ class VoiceCog(commands.Cog):
183
210
  active_timer = self.bot_settings.get("active_timer", 60)
184
211
  required = self._wake_word_required(interaction.user.id)
185
212
 
186
- if own_word:
187
- msg = (
188
- f"🎤 Connected to `{vc_chan.name}`.\n"
189
- f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
190
- 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."
191
222
  )
192
- elif not required:
193
- msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
194
223
  else:
195
- msg = (
196
- f"🎤 Connected to `{vc_chan.name}`.\n"
197
- f"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
198
- f"and say your chosen word a few times to register it in your voice.\n"
199
- f"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
200
- f"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
201
- 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."
202
230
  )
231
+
232
+ msg = "\n".join([header, body, sound_tip])
203
233
  await interaction.response.send_message(msg)
204
234
 
205
235
  self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
@@ -212,7 +242,7 @@ class VoiceCog(commands.Cog):
212
242
  self.logger.warning(f"Failed to call /leave before /join: {e}")
213
243
 
214
244
  resp = await session.post(
215
- 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)}
216
246
  )
217
247
  if not required:
218
248
  # Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
@@ -268,6 +298,8 @@ class VoiceCog(commands.Cog):
268
298
  threshold=threshold_autocomplete,
269
299
  tts_voice=tts_voice_autocomplete,
270
300
  tts_enabled=tts_enabled_autocomplete,
301
+ tts_speed=tts_speed_autocomplete,
302
+ require_wake_word=require_wake_word_autocomplete,
271
303
  )
272
304
  async def cmd_voice(
273
305
  self,
@@ -525,15 +557,16 @@ class VoiceCog(commands.Cog):
525
557
  self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
526
558
  self._active_turns[str(guild_id)] = asyncio.current_task()
527
559
 
528
- user = self.bot.get_user(int(user_id))
529
- 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:
530
563
  try:
531
- user = await self.bot.fetch_user(int(user_id))
564
+ member = await guild.fetch_member(int(user_id))
532
565
  except discord.NotFound:
533
566
  pass
534
567
  except discord.HTTPException as e:
535
- self.logger.warning(f"fetch_user failed for {user_id}: {e}")
536
- 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}"
537
570
  await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"🎤 **{username}**: {text}")
538
571
 
539
572
  if not text_to_ai:
@@ -591,9 +624,10 @@ class VoiceCog(commands.Cog):
591
624
 
592
625
  from utils.utils import generate_thread_title, update_agy_conversation_title
593
626
 
594
- new_title = await generate_thread_title(text_to_ai, raw_ans)
595
- await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
596
- await update_agy_conversation_title(new_conv_id, new_title)
627
+ if thread.name.startswith("Session-"):
628
+ new_title = await generate_thread_title(text_to_ai, raw_ans)
629
+ await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
630
+ await update_agy_conversation_title(new_conv_id, new_title)
597
631
  else:
598
632
  logger.debug("Voice: calling agy_send...")
599
633
  raw_ans = await self.agy_send(
@@ -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
 
@@ -74,7 +74,7 @@ async def handle_pending_session(
74
74
  await stream_task
75
75
 
76
76
  response_text = result_text
77
- if adapter.can_rename(thread):
77
+ if adapter.can_rename(thread) and thread.name.startswith("Session-"):
78
78
  new_title = await generate_thread_title(content, response_text)
79
79
  await adapter.rename_conversation(thread, new_title)
80
80
  await update_agy_conversation_title(new_conv_id, new_title)
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