linkgravity 1.2.1 → 1.3.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/README.md CHANGED
@@ -1,33 +1,32 @@
1
- # LinkGravity (lgy)
1
+ # LinkGravity
2
2
 
3
- A Discord bot interface for the Antigravity (agy) agentic AI system. It translates Antigravity CLI prompts into Discord UI components and provides voice interaction capabilities.
3
+ A Discord bot interface for the Antigravity agentic AI system. It translates Antigravity CLI prompts into Discord UI components and provides voice interaction capabilities.
4
4
 
5
5
  ## Features
6
6
 
7
7
  - **Environment Sync:** Automatically syncs with the host's `~/.gemini` configuration.
8
- - **Voice Interaction:** Supports voice channels with adaptive VAD (Voice Activity Detection) to segment speech and filter environmental noise, plus live "listening..." feedback while you're still talking.
8
+ - **Voice Interaction:** Supports voice channels with adaptive voice activity detection to segment speech and filter environmental noise, plus live "listening..." feedback while you're still talking.
9
9
  - **Wake Word Recognition:** Uses phoneme-level similarity to detect wake words and activate voice commands.
10
- - **CLI Prompt Interception:** Converts CLI prompts (`ask_question`, `run_command` approvals) into interactive Discord buttons.
11
- - **Command Security:** Parses chained shell commands (`&&`, `||`, `;`) and requires individual approval for each command. Supports prefix-based scope whitelisting.
12
- - **Multi-Modal Input:** Attach any file (not just images) for the AI to read; audio attachments (`.ogg`/`.mp3`/`.m4a`/`.wav`) are transcribed to text automatically.
10
+ - **Approval Flow:** Command and tool-call approvals become interactive Discord buttons. Chained shell commands are approved individually, and any approval can be scoped to auto-allow that command or tool going forward - something plain `agy` doesn't do.
11
+ - **Multi-Modal Input:** Attach files for the AI to read, including audio, which gets transcribed to text automatically.
13
12
 
14
- ## Prerequisites
13
+ ## Requirements
15
14
 
16
15
  - Node.js >= 18
17
16
  - Python >= 3.10
18
- - `agy` (Antigravity CLI) installed on this machine
19
- - `ffmpeg` on your PATH (needed for TTS/voice playback)
20
- - A Discord bot token, and at least one server/channel to allow it in
17
+ - Antigravity CLI installed on this machine
18
+ - A messenger bot token, and at least one server/channel to allow it in
19
+ - **Discord** - currently the only one supported
21
20
 
22
21
  ### Creating the Discord bot
23
22
 
24
23
  In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and bot, then:
25
24
 
26
- - Under **Bot**, enable the **Message Content** privileged intent (required - the bot reads message text/attachments).
25
+ - Under **Bot**, enable the **Message Content** privileged intent - required, since the bot reads message text/attachments.
27
26
  - Under **OAuth2 → URL Generator**, select the **bot** and **applications.commands** scopes, then these bot permissions:
28
27
  - Send Messages, Send Messages in Threads, Create Public Threads
29
28
  - Read Message History, Attach Files, Embed Links, Add Reactions
30
- - Connect, Speak (for voice channel support)
29
+ - Connect, Speak - for voice channel support
31
30
  - Use the generated URL to invite the bot to your server.
32
31
 
33
32
  ## Installation
@@ -36,7 +35,7 @@ In the [Discord Developer Portal](https://discord.com/developers/applications),
36
35
  npm install -g linkgravity
37
36
  ```
38
37
 
39
- This runs a postinstall step that creates a Python virtual environment at `~/.gemini/linkgravity/venv/` (kept outside the package install location on purpose - see `npm-scripts/venv-paths.js`) and installs `requirements.txt` into it - no manual `pip install` needed.
38
+ Sets up its own Python environment automatically - no manual `pip install` needed.
40
39
 
41
40
  ## Setup
42
41
 
@@ -46,11 +45,11 @@ Run the configuration wizard once to set your bot token, allowed servers/channel
46
45
  lgy setup
47
46
  ```
48
47
 
49
- This writes to `~/.gemini/linkgravity/lgy.json` (outside the package directory, so `npm update`/reinstall never touches it). You can re-run `lgy setup` any time to change settings later - each field keeps its current value if you leave it empty.
48
+ This writes to `~/.gemini/linkgravity/lgy.json`, outside the package directory, so `npm update`/reinstall never touches it. You can re-run `lgy setup` any time to change settings later - each field keeps its current value if you leave it empty.
50
49
 
51
50
  During setup you'll be asked for one or more Discord servers to allow, and optionally specific channels within each:
52
51
 
53
- - Leave the channel list empty for a server → **the whole server** is allowed (any channel can start a session).
52
+ - Leave the channel list empty for a server → **the whole server** is allowed - any channel can start a session.
54
53
  - List specific channel IDs for a server → **only those channels** in that server are allowed.
55
54
 
56
55
  A new session is only ever started with the **`/new`** slash command in Discord - never just by typing a message. `/new` works both in a regular channel and from inside an existing thread.
@@ -58,57 +57,35 @@ A new session is only ever started with the **`/new`** slash command in Discord
58
57
  ## Usage
59
58
 
60
59
  ```bash
61
- lgy start # Start the bot as a background daemon (via PM2)
60
+ lgy start # Start the bot as a background daemon via PM2
62
61
  lgy stop # Stop it
63
62
  lgy restart # Restart it
64
- lgy logs # View live logs (add -f to follow, --tail N for more lines)
63
+ lgy logs # View live logs - add -f to follow, --tail N for more lines
65
64
  lgy enable # Register the bot to auto-start on system boot
66
65
  lgy disable # Remove it from system boot
67
- lgy # Interactive menu (same commands, picked from a list)
66
+ lgy # Interactive menu - same commands, picked from a list
68
67
  ```
69
68
 
70
69
  ## Development
71
70
 
72
- This project uses [Ruff](https://docs.astral.sh/ruff/) for Python linting/formatting and [Prettier](https://prettier.io/) for the Node.js side. `npm install` sets both up automatically (installs `requirements-dev.txt` into the venv at `~/.gemini/linkgravity/venv/`, registers git hooks) - manual install is only needed if you want to run them yourself outside of a commit. The venv lives outside this checkout (see `npm-scripts/venv-paths.js` for why), so on macOS/Linux:
73
-
74
71
  ```bash
75
- ~/.gemini/linkgravity/venv/bin/ruff check src/ # lint
76
- ~/.gemini/linkgravity/venv/bin/ruff format src/ # format
77
-
78
- npm run format:check # check JS formatting
79
- npm run format # format JS
72
+ npm i
80
73
  ```
81
74
 
82
- (On Windows, replace `venv/bin/ruff` with `venv\Scripts\ruff.exe` under the same `~/.gemini/linkgravity/` directory.)
83
-
84
- Git hooks (via [pre-commit](https://pre-commit.com/), config in `.pre-commit-config.yaml`) run automatically once you `npm install`:
85
-
86
- - **pre-commit**: runs `ruff` (lint + format) and `prettier` on staged files, auto-fixing what it can.
87
- - **commit-msg**: enforces [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix: ...`, `feat: ...`, `docs: ...`) via [conventional-pre-commit](https://github.com/compilerla/conventional-pre-commit).
88
-
89
- If a hook doesn't seem to be running, check `git config --get core.hooksPath` - it should be unset (or point at `.git/hooks`, pre-commit's default). A leftover `.husky` value from an older checkout will silently make git skip pre-commit's hooks entirely; `git config --unset core.hooksPath` fixes it.
75
+ That's it - it wires up [Ruff](https://docs.astral.sh/ruff/) for Python and [Prettier](https://prettier.io/) for Node, plus git hooks that lint/format on commit and enforce [Conventional Commits](https://www.conventionalcommits.org/) commit messages.
90
76
 
91
77
  ## Debugging
92
78
 
93
- Set `LOG_LEVEL=DEBUG` in your shell before `lgy restart` for verbose logs, including agy's raw stdout for each turn (`lgy` passes your shell's environment through to the daemon on restart). Defaults to `INFO`. Use `lgy logs -t` (or `--timestamp`) to include timestamps - they're stripped by default.
94
-
95
- ## Known Issues
96
-
97
- **Wake word false positives on short words** (e.g. "시리", "잼민이"): Rustpotter's phoneme matching carries less signal for 1-2 syllable words, so genuine-match and unrelated-speech score distributions overlap - no single threshold cleanly separates them. Current mitigations in `voice-service/index.js`'s `getDetectorForUser` and `cogs/voice_cog.py`'s `handle_stt_input`:
98
-
99
- - `score_mode: Max` (each of the 5 enrollment samples can cover a different natural tone/pace, instead of requiring all 5 to be delivered consistently like `Median` did)
100
- - `min_scores: 4` (requires a candidate to keep winning across several frames, compensating for `Max` being more permissive per-frame)
101
- - The STT-based text cross-check is tightened specifically for short wake words (similarity floor 0.55, vs. 0.35 for longer ones) - this is currently doing most of the real work of rejecting false positives
102
-
103
- This isn't fully solved. If issues persist after real-world use, prefer these over further threshold guessing:
79
+ ```bash
80
+ LOG_LEVEL=DEBUG lgy start
81
+ ```
104
82
 
105
- 1. Encourage re-enrolling with a longer/more distinctive wake word (a 1-2 syllable word is close to a hard ceiling for this approach, regardless of tuning)
106
- 2. Log `bestWakeScore` + outcome (no raw audio) during a trial period and re-tune the constants above against that data instead of guessing
83
+ Use `lgy logs -t` to include timestamps.
107
84
 
108
85
  ## Security Warning
109
86
 
110
87
  This bot gives an AI agent broad access to the machine it runs on - **that's inherent to what it does, so don't expose it publicly or run it somewhere you don't fully trust its users.**
111
88
 
112
- - `allowed_user_ids` (set via `lgy setup`) is your primary access control - always set it.
113
- - Tool calls (including shell commands) go through an approval flow in Discord by default; treat anyone in `allowed_user_ids` as having effectively full control of this machine.
89
+ - `allowed_user_ids`, set via `lgy setup`, is your primary access control - always set it.
90
+ - Tool calls, including shell commands, go through an approval flow in Discord by default; treat anyone in `allowed_user_ids` as having effectively full control of this machine.
114
91
  - Your Discord token and other settings live in `~/.gemini/linkgravity/lgy.json`, outside this repo/package directory - never commit or share that file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
@@ -112,10 +112,12 @@ async def handle_approve_request(request):
112
112
  target_thread_id = thread_id_str
113
113
  break
114
114
 
115
- if target_thread_id and session_manager.get_session(target_thread_id):
116
- session_manager.update_session(target_thread_id, "current_tool", tool_name)
115
+ def _set_tool_status(key: str):
116
+ if target_thread_id and session_manager.get_session(target_thread_id):
117
+ session_manager.update_session(target_thread_id, key, tool_name)
117
118
 
118
119
  if "ask_question" in tool_name:
120
+ _set_tool_status("current_tool") # no separate approval phase here - it's waiting on the user either way
119
121
  if not target_thread:
120
122
  return web.json_response({"decision": "deny", "reason": "No target thread found."})
121
123
 
@@ -223,6 +225,7 @@ async def handle_approve_request(request):
223
225
  return await prompt.send(target_thread)
224
226
 
225
227
  await send_ordered(target_thread_id, _send_bash_prompt)
228
+ _set_tool_status("pending_approval_tool")
226
229
 
227
230
  decision = await future
228
231
  session_manager.clear_pending_approval(approval_key)
@@ -232,9 +235,14 @@ async def handle_approve_request(request):
232
235
  if decision == "reject":
233
236
  return web.json_response({"decision": "reject"})
234
237
 
238
+ _set_tool_status("current_tool")
239
+
235
240
  if target_thread and tool_msg_text and not prompted:
236
241
  await send_ordered(target_thread_id, lambda: adapter.send_message(target_thread, tool_msg_formatted))
237
242
 
243
+ if not prompted:
244
+ _set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
245
+
238
246
  return allow_response(tool_name, tool_input)
239
247
 
240
248
  else:
@@ -250,6 +258,7 @@ async def handle_approve_request(request):
250
258
  await send_ordered(
251
259
  target_thread_id, lambda: adapter.send_message(target_thread, tool_msg_formatted)
252
260
  )
261
+ _set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
253
262
  return allow_response(tool_name, tool_input)
254
263
 
255
264
  approval_key = f"{conv_id}:{uuid.uuid4().hex}"
@@ -267,6 +276,7 @@ async def handle_approve_request(request):
267
276
  return await prompt.send(target_thread)
268
277
 
269
278
  await send_ordered(target_thread_id, _send_prompt)
279
+ _set_tool_status("pending_approval_tool")
270
280
 
271
281
  decision = await future
272
282
  session_manager.clear_pending_approval(approval_key)
@@ -276,6 +286,7 @@ async def handle_approve_request(request):
276
286
  if decision == "reject":
277
287
  return web.json_response({"decision": "reject"})
278
288
 
289
+ _set_tool_status("current_tool")
279
290
  return allow_response(tool_name, tool_input)
280
291
 
281
292
  except Exception as e:
@@ -263,7 +263,7 @@ class EnrollmentManager:
263
263
  session["pending_sample"] = audio_bytes
264
264
  session["awaiting_confirmation"] = True
265
265
 
266
- await self._play_audio(session["guild_id"], audio_bytes, suppress_active_window=True)
266
+ await self._play_audio(session["guild_id"], self._trim_silence_wav(audio_bytes), suppress_active_window=True)
267
267
  await self._update_status(
268
268
  session,
269
269
  f"🔊 **{step}/{session['needed']}** captured - keep it, or re-record if it's noisy?",
@@ -337,7 +337,7 @@ class EnrollmentManager:
337
337
  except aiohttp.ClientError as e:
338
338
  self.logger.warning(f"Failed to invalidate cached detector for {user_id}: {e}")
339
339
 
340
- self.bot_settings["wake_words"] = session["word"]
340
+ self.bot_settings.setdefault("wake_words", {})[user_id] = session["word"]
341
341
  self.save_bot_settings(self.bot_settings)
342
342
 
343
343
  del self._enrollment[user_id]
@@ -7,6 +7,7 @@ from discord import app_commands
7
7
  from discord.ext import commands, tasks
8
8
 
9
9
  from config import allowed, logger
10
+ from messengers.registry import get_adapter
10
11
 
11
12
  from .voice.enrollment import EnrollmentManager
12
13
  from .voice.stt_session import SttSessionTracker
@@ -174,22 +175,20 @@ class VoiceCog(commands.Cog):
174
175
  vc_chan = interaction.user.voice.channel
175
176
  guild_id = interaction.guild_id
176
177
 
177
- has_wake_word = bool(self.bot_settings.get("wake_words"))
178
+ wake_word_map = self.bot_settings.get("wake_words") or {}
179
+ own_word = wake_word_map.get(str(interaction.user.id))
178
180
  active_timer = self.bot_settings.get("active_timer", 60)
179
181
 
180
- if has_wake_word:
181
- wake_words_raw = self.bot_settings["wake_words"]
182
- ww_list = [f"`{w.strip()}`" for w in wake_words_raw.split(",") if w.strip()]
183
- ww_str = ", ".join(ww_list[:-1]) + f", or {ww_list[-1]}" if len(ww_list) > 1 else ww_list[0]
182
+ if own_word:
184
183
  msg = (
185
184
  f"🎤 Connected to `{vc_chan.name}`.\n"
186
- f"💡 Say {ww_str} to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
185
+ f"💡 Say `{own_word}` to activate me. Once awake, I'll keep listening for {active_timer} seconds after each interaction.\n"
187
186
  f"⚙️ You can customize settings using `/sound`."
188
187
  )
189
188
  else:
190
189
  msg = (
191
190
  f"🎤 Connected to `{vc_chan.name}`.\n"
192
- f"🎙️ No wake word is set up yet, so I can't hear you yet - run `/sound wake_word:<word>` "
191
+ f"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
193
192
  f"and say your chosen word a few times to register it in your voice."
194
193
  )
195
194
  await interaction.response.send_message(msg)
@@ -210,17 +209,17 @@ class VoiceCog(commands.Cog):
210
209
  if data.get("success"):
211
210
  self._voice_state[str(guild_id)] = interaction.channel_id
212
211
 
213
- if has_wake_word:
212
+ if own_word:
214
213
  if self.bot_settings.get("tts_enabled", True):
215
- welcome_audio = await self.tts("Yes, I am listening.")
214
+ welcome_audio = await self.tts("Voice connected.")
216
215
  if welcome_audio:
217
- await self._play_audio(str(guild_id), welcome_audio)
216
+ await self._play_audio(str(guild_id), welcome_audio, suppress_active_window=True)
218
217
  elif self.bot_settings.get("tts_enabled", True):
219
218
  prompt_audio = await self.tts(
220
219
  "No wake word is set up yet. Please use the sound command to set one."
221
220
  )
222
221
  if prompt_audio:
223
- await self._play_audio(str(guild_id), prompt_audio)
222
+ await self._play_audio(str(guild_id), prompt_audio, suppress_active_window=True)
224
223
  else:
225
224
  await interaction.channel.send(f"⚠️ Node.js integration failed: {data.get('error')}")
226
225
  except aiohttp.ClientConnectorError:
@@ -274,7 +273,7 @@ class VoiceCog(commands.Cog):
274
273
  and tts_voice is None
275
274
  and tts_enabled is None
276
275
  ):
277
- curr_wake = self.bot_settings.get("wake_words", "None")
276
+ curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
278
277
  curr_timer = self.bot_settings.get("active_timer", 60)
279
278
  curr_thresh = self.bot_settings.get("voice_threshold", 3000)
280
279
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
@@ -451,9 +450,21 @@ class VoiceCog(commands.Cog):
451
450
 
452
451
  self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
453
452
 
454
- # A stale in-flight turn for this guild - cancel it, kill its agy process, stop playback.
453
+ sess = self.session_manager.get_session(str(thread_id))
454
+ if not sess:
455
+ sess = {"status": "pending", "user_id": str(user_id)}
456
+ self.session_manager.set_session(str(thread_id), sess)
457
+
458
+ conv_id = sess.get("conversation_id")
459
+ pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
460
+ has_pending_approval = bool(conv_id and pa and not pa.done())
461
+
462
+ # A stale in-flight turn for this guild - cancel it, kill its agy process, stop
463
+ # playback. Skipped when a tool/question approval is pending: that turn is the one
464
+ # waiting on this very utterance as its answer, so cancelling here would kill the
465
+ # agy process before the "yes"/"no" below ever reaches it.
455
466
  prev_task = self._active_turns.get(str(guild_id))
456
- if prev_task and not prev_task.done():
467
+ if prev_task and not prev_task.done() and not has_pending_approval:
457
468
  prev_task.cancel()
458
469
 
459
470
  from core.agy_runner import stop_active_process
@@ -492,15 +503,7 @@ class VoiceCog(commands.Cog):
492
503
  await self._play_audio(str(guild_id), audio_reply)
493
504
  return
494
505
 
495
- sess = self.session_manager.get_session(str(thread_id))
496
- if not sess:
497
- sess = {"status": "pending", "user_id": str(user_id)}
498
- self.session_manager.set_session(str(thread_id), sess)
499
-
500
- conv_id = sess.get("conversation_id")
501
- pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
502
-
503
- if conv_id and pa and not pa.done():
506
+ if has_pending_approval:
504
507
  app_type = self.session_manager.get_pending_approval_type_by_conv(conv_id)
505
508
  if app_type == "ask_question":
506
509
  pa.set_result(text)
@@ -544,6 +547,12 @@ class VoiceCog(commands.Cog):
544
547
  sess["status"] = "active"
545
548
  self.session_manager.set_session(str(thread_id), sess)
546
549
  conv_id = new_conv_id
550
+
551
+ from utils.utils import generate_thread_title, update_agy_conversation_title
552
+
553
+ new_title = await generate_thread_title(text_to_ai, raw_ans)
554
+ await get_adapter().rename_conversation(thread, new_title)
555
+ await update_agy_conversation_title(new_conv_id, new_title)
547
556
  else:
548
557
  logger.debug("Voice: calling agy_send...")
549
558
  raw_ans = await self.agy_send(
package/src/config.py CHANGED
@@ -7,6 +7,10 @@ WORKSPACE_DIR = Path.home() / ".gemini" / "linkgravity"
7
7
  WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
8
8
  DATA_DIR = WORKSPACE_DIR / "data"
9
9
  DATA_DIR.mkdir(parents=True, exist_ok=True)
10
+ # Per-user wake-word recordings + built .rpw reference (see EnrollmentManager).
11
+ # Defined early so load_bot_settings' migration below can read it.
12
+ WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
13
+ WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
10
14
 
11
15
  LGY_CONFIG_FILE = WORKSPACE_DIR / "lgy.json"
12
16
 
@@ -14,7 +18,8 @@ DEFAULT_LGY_CONFIG = {
14
18
  "discord_token": "",
15
19
  "session_scopes": [],
16
20
  "allowed_user_ids": "",
17
- "wake_words": "Jarvis",
21
+ # user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
22
+ "wake_words": {},
18
23
  "active_timer": 60,
19
24
  "voice_threshold": 3000,
20
25
  "tts_voice": "ko-KR-SunHiNeural",
@@ -33,10 +38,26 @@ class _PrintLogger:
33
38
  print(f"[config] {msg}")
34
39
 
35
40
 
41
+ def _migrate_legacy_wake_words():
42
+ """Pre-1.3, wake_words was one global string shared by everyone and
43
+ overwritten by each /sound call. The .rpw files were always saved per
44
+ user_id though, so rebuild the real per-user mapping from those."""
45
+ migrated = {}
46
+ for user_dir in WAKE_REF_DIR.iterdir():
47
+ if not user_dir.is_dir():
48
+ continue
49
+ rpw = next(user_dir.glob("*.rpw"), None)
50
+ if rpw:
51
+ migrated[user_dir.name] = rpw.stem.replace("_", " ")
52
+ return migrated
53
+
54
+
36
55
  def load_bot_settings():
37
56
  data = safe_load_json(LGY_CONFIG_FILE, DEFAULT_LGY_CONFIG.copy(), logger=_PrintLogger())
38
57
  for k, v in DEFAULT_LGY_CONFIG.items():
39
58
  data.setdefault(k, v)
59
+ if isinstance(data.get("wake_words"), str):
60
+ data["wake_words"] = _migrate_legacy_wake_words()
40
61
  return data
41
62
 
42
63
 
@@ -93,12 +114,9 @@ def is_allowed_session_channel(channel) -> bool:
93
114
 
94
115
  TMP_FILE_DIR = WORKSPACE_DIR / "tmp-files"
95
116
  TMP_VOICE_DIR = WORKSPACE_DIR / "tmp-voice"
96
- # Per-user wake-word recordings + built .rpw reference (see EnrollmentManager).
97
- WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
98
117
 
99
118
  TMP_FILE_DIR.mkdir(parents=True, exist_ok=True)
100
119
  TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
101
- WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
102
120
 
103
121
  MAX_EMBED_LEN = 1900
104
122
  STREAM_RATE_LIMIT_SEC = 0.5
@@ -376,3 +376,36 @@ async def generate_thread_title(user_input: str, response: str) -> str:
376
376
  except Exception as e:
377
377
  logger.warning(f"AI thread-title generation failed, falling back to raw input: {e}")
378
378
  return fallback
379
+
380
+
381
+ 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
+ if not conv_id:
388
+ return
389
+
390
+ import sqlite3
391
+ from pathlib import Path
392
+
393
+ db_path = Path.home() / ".gemini/antigravity-cli/conversation_summaries.db"
394
+ if not db_path.exists():
395
+ return
396
+
397
+ def _update():
398
+ conn = sqlite3.connect(str(db_path), timeout=5)
399
+ try:
400
+ conn.execute(
401
+ "UPDATE conversation_summaries SET preview = ? WHERE conversation_id = ?",
402
+ (title, conv_id),
403
+ )
404
+ conn.commit()
405
+ finally:
406
+ conn.close()
407
+
408
+ try:
409
+ await asyncio.get_running_loop().run_in_executor(None, _update)
410
+ except Exception as e:
411
+ logger.warning(f"Failed to sync title into agy's conversation_summaries.db for {conv_id}: {e}")
@@ -16,6 +16,7 @@ from utils.utils import (
16
16
  generate_thread_title,
17
17
  handle_image_attachments,
18
18
  stt,
19
+ update_agy_conversation_title,
19
20
  )
20
21
 
21
22
 
@@ -68,6 +69,7 @@ async def handle_pending_session(
68
69
  response_text = result_text
69
70
  new_title = await generate_thread_title(content, response_text)
70
71
  await adapter.rename_conversation(thread, new_title)
72
+ await update_agy_conversation_title(new_conv_id, new_title)
71
73
 
72
74
  response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
73
75
 
package/src/main.py CHANGED
@@ -71,6 +71,19 @@ def _status_text_for_tool(tool_name: str) -> str:
71
71
  return f"⚙️ Running {tool_name}..."
72
72
 
73
73
 
74
+ def _voice_status_text() -> str | None:
75
+ """None if no guild is connected to voice right now. Otherwise reflects
76
+ whether any connected guild is in its post-wake-word "awake" window -
77
+ filling the gap between Idle and an active text session, since being
78
+ connected to voice and waiting for a wake word isn't really "Idle"."""
79
+ voice_cog = bot.get_cog("VoiceCog")
80
+ if not voice_cog or not voice_cog._voice_state:
81
+ return None
82
+ if any(voice_cog.stt_session.is_active(guild_id) for guild_id in voice_cog._voice_state):
83
+ return "👂 Awake"
84
+ return "💤 Asleep"
85
+
86
+
74
87
  intents = discord.Intents.default()
75
88
  intents.message_content = True
76
89
  intents.voice_states = True
@@ -87,12 +100,18 @@ async def status_updater_task():
87
100
 
88
101
  full_status = ""
89
102
  if not session_manager.has_active_queues():
90
- full_status = "🟢 Idle"
103
+ full_status = _voice_status_text() or "🟢 Idle"
91
104
  else:
92
105
  first_t_id = session_manager.get_active_queue_keys()[0]
93
106
  sess = session_manager.get_session(first_t_id) or {}
107
+ pending_tool = sess.get("pending_approval_tool")
94
108
  tool_name = sess.get("current_tool")
95
- full_status = _status_text_for_tool(tool_name) if tool_name else "🧠 Thinking..."
109
+ if pending_tool:
110
+ full_status = "⏳ Waiting for approval..."
111
+ elif tool_name:
112
+ full_status = _status_text_for_tool(tool_name)
113
+ else:
114
+ full_status = "🧠 Thinking..."
96
115
 
97
116
  if full_status != last_status:
98
117
  logger.debug(f"Status updating to: {full_status}")
@@ -177,6 +196,18 @@ async def on_ready():
177
196
  logger.info(f"✅ Bot is fully online and ready! Logged in as {bot.user}")
178
197
 
179
198
 
199
+ def _terminate_voice_process():
200
+ global _voice_shutting_down
201
+ _voice_shutting_down = True
202
+ if voice_process and voice_process.poll() is None:
203
+ logger.debug("Terminating child Node.js voice process...")
204
+ voice_process.terminate() # Node now catches this and disconnects any active voice channel cleanly
205
+ try:
206
+ voice_process.wait(timeout=3)
207
+ except subprocess.TimeoutExpired:
208
+ voice_process.kill()
209
+
210
+
180
211
  @bot.event
181
212
  async def setup_hook():
182
213
  await bot.tree.sync()
@@ -201,18 +232,7 @@ async def setup_hook():
201
232
  asyncio.create_task(_wait_for_voice_service_ready())
202
233
  asyncio.create_task(_supervise_voice_process(voice_dir))
203
234
 
204
- def cleanup_voice():
205
- global _voice_shutting_down
206
- _voice_shutting_down = True
207
- if voice_process and voice_process.poll() is None:
208
- logger.debug("Zombie prevention: Terminating child Node.js process as Python exits...")
209
- voice_process.terminate()
210
- try:
211
- voice_process.wait(timeout=3)
212
- except subprocess.TimeoutExpired:
213
- voice_process.kill()
214
-
215
- atexit.register(cleanup_voice)
235
+ atexit.register(_terminate_voice_process)
216
236
  else:
217
237
  logger.warning("Voice service (index.js) not found. Skipping auto-start.")
218
238
  except Exception as e:
@@ -275,6 +295,18 @@ async def main():
275
295
 
276
296
  discord.utils.setup_logging()
277
297
 
298
+ def _handle_sigterm():
299
+ logger.info("Received SIGTERM (lgy stop/restart) - disconnecting voice before exit...")
300
+ _terminate_voice_process()
301
+ asyncio.create_task(bot.close())
302
+
303
+ try:
304
+ import signal
305
+
306
+ asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, _handle_sigterm)
307
+ except NotImplementedError:
308
+ pass # add_signal_handler isn't supported on this platform (e.g. Windows)
309
+
278
310
  from messengers.discord_adapter import DiscordAdapter
279
311
  from messengers.registry import set_adapter
280
312
 
@@ -11,12 +11,19 @@ from utils.utils import clean_ansi
11
11
 
12
12
 
13
13
  def _clear_current_tool(thread_id: str):
14
- """Removes the "current_tool" marker set by api/ui_routes.py while a
15
- tool call is being approved - without this, the bot's presence status
16
- stays stuck on the last tool after the turn finishes."""
14
+ """Removes the "current_tool"/"pending_approval_tool" markers set by
15
+ api/ui_routes.py while a tool call is being approved/run - without
16
+ this, the bot's presence status stays stuck on the last tool after
17
+ the turn finishes."""
17
18
  session = session_manager.get_session(thread_id)
18
- if session and "current_tool" in session:
19
- del session["current_tool"]
19
+ if not session:
20
+ return
21
+ changed = False
22
+ for key in ("current_tool", "pending_approval_tool"):
23
+ if key in session:
24
+ del session[key]
25
+ changed = True
26
+ if changed:
20
27
  session_manager.set_session(thread_id, session)
21
28
 
22
29
 
@@ -7,6 +7,7 @@ from core.agy_runner import (
7
7
  generate_thread_title,
8
8
  get_current_model,
9
9
  run_agy,
10
+ update_agy_conversation_title,
10
11
  )
11
12
  from services.audio_service import stt, tts
12
13
  from services.discord_helpers import (
@@ -23,6 +24,7 @@ __all__ = [
23
24
  "agy_send_message",
24
25
  "get_current_model",
25
26
  "generate_thread_title",
27
+ "update_agy_conversation_title",
26
28
  "active_processes",
27
29
  "agy_start_lock",
28
30
  "tts",
@@ -271,19 +271,8 @@ function loadRustpotterModule() {
271
271
  // userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
272
272
  const detectorCache = new Map();
273
273
 
274
- // The real pass/fail cutoff for a wake-word match. This has to be a
275
- // genuine, meaningful threshold, not a tuning knob to set near-zero:
276
- // rustpotter's confirmation logic re-arms its countdown EVERY time a
277
- // new "candidate" clears the threshold (detector.rs's run_detection:
278
- // `self.detection_countdown = self.max_mfcc_frames / 2` runs again on
279
- // every qualifying frame). With this set to 0.05 during earlier
280
- // debugging, silence and noise cleared it just as easily as real
281
- // speech, so the countdown never ran out and nothing was ever
282
- // confirmed no matter how much silence padding was fed afterward.
283
- // Verified against a native Rust reproduction of this exact detector
284
- // before settling on 0.5 (matches rustpotter's own default, and what
285
- // rustpotter-cli scored real captured audio at: 0.55-0.73).
286
- const WAKE_MATCH_THRESHOLD = 0.5;
274
+ // Wake-word confirm cutoff - must stay well above ~0.05 (rustpotter's countdown never finalizes if noise/silence clears it too); 0.4 chosen after live use kept narrowly missing genuine hits just under 0.5.
275
+ const WAKE_MATCH_THRESHOLD = 0.4;
287
276
 
288
277
  async function getDetectorForUser(userId) {
289
278
  if (detectorCache.has(userId)) return detectorCache.get(userId);
@@ -661,20 +650,17 @@ function setupReceiver(connection, guildId) {
661
650
  bestDiagScoreName = diagPaddingDetection.getName();
662
651
  }
663
652
 
664
- // The real pass/fail decision - see WAKE_MATCH_THRESHOLD's
665
- // comment for why this has to be a meaningful cutoff.
666
- // bestDiagScore comes from the separate diagnostic
667
- // instance above (see its comment in getDetectorForUser)
668
- // so a "no match" line still shows the real closest
669
- // score instead of a meaningless flat 0.000.
653
+ // Real pass/fail uses bestWakeScore; bestDiagScore is a separate, much looser detector shown only for "how close" - not on the same scale, not comparable to WAKE_MATCH_THRESHOLD.
670
654
  wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
671
655
  matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
672
656
  console.log(
673
657
  wakeConfirmed
674
658
  ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
675
659
  `"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
676
- : `[Wake] ${userId}: no match (closest score ${bestDiagScore.toFixed(3)} for ` +
677
- `"${bestDiagScoreName ?? 'n/a'}", needed ${WAKE_MATCH_THRESHOLD})`,
660
+ : `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
661
+ `threshold ${WAKE_MATCH_THRESHOLD}; diagnostic-only closeness ` +
662
+ `${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
663
+ `different scoring config, not directly comparable to the threshold)`,
678
664
  );
679
665
  }
680
666
 
@@ -702,7 +688,8 @@ function setupReceiver(connection, guildId) {
702
688
  return;
703
689
  }
704
690
 
705
- if (pcmBuffer.length < 24000) {
691
+ // Was 24000 (250ms) - cut off short Korean replies ("네"/"어"/"응"); noise is filtered upstream by isSpeaking's RMS/sustain check, not by duration.
692
+ if (pcmBuffer.length < 9600) {
706
693
  if (partialSent) {
707
694
  fetch('http://127.0.0.1:18080/stt_partial_cancel', {
708
695
  method: 'POST',
@@ -1036,6 +1023,22 @@ process.on('uncaughtException', (err) => {
1036
1023
  process.exit(1);
1037
1024
  });
1038
1025
 
1026
+ // Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
1027
+ function shutdownGracefully() {
1028
+ console.log(`[Shutdown] Disconnecting from ${connections.size} active voice connection(s)...`);
1029
+ for (const connection of connections.values()) {
1030
+ try {
1031
+ connection.destroy();
1032
+ } catch (e) {
1033
+ // already destroyed/disconnected - fine
1034
+ }
1035
+ }
1036
+ process.exit(0);
1037
+ }
1038
+
1039
+ process.on('SIGTERM', shutdownGracefully);
1040
+ process.on('SIGINT', shutdownGracefully);
1041
+
1039
1042
  const PORT = 18081;
1040
1043
  app.listen(PORT, '0.0.0.0', () => {
1041
1044
  console.log(`Node.js Voice API listening on port ${PORT}`);