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 +25 -48
- package/package.json +1 -1
- package/src/api/ui_routes.py +13 -2
- package/src/cogs/voice/enrollment.py +2 -2
- package/src/cogs/voice_cog.py +32 -23
- package/src/config.py +22 -4
- package/src/core/agy_runner.py +33 -0
- package/src/handlers/thread_reply.py +2 -0
- package/src/main.py +46 -14
- package/src/services/streaming.py +12 -5
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +25 -22
package/README.md
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
|
-
# LinkGravity
|
|
1
|
+
# LinkGravity
|
|
2
2
|
|
|
3
|
-
A Discord bot interface for the Antigravity
|
|
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
|
|
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
|
-
- **
|
|
11
|
-
- **
|
|
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
|
-
##
|
|
13
|
+
## Requirements
|
|
15
14
|
|
|
16
15
|
- Node.js >= 18
|
|
17
16
|
- Python >= 3.10
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
- Tool calls
|
|
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
package/src/api/ui_routes.py
CHANGED
|
@@ -112,10 +112,12 @@ async def handle_approve_request(request):
|
|
|
112
112
|
target_thread_id = thread_id_str
|
|
113
113
|
break
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
session_manager.
|
|
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
|
|
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]
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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"🎙️
|
|
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
|
|
212
|
+
if own_word:
|
|
214
213
|
if self.bot_settings.get("tts_enabled", True):
|
|
215
|
-
welcome_audio = await self.tts("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/core/agy_runner.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
|
15
|
-
tool call is being approved - without
|
|
16
|
-
stays stuck on the last tool after
|
|
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
|
|
19
|
-
|
|
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
|
|
package/src/utils/utils.py
CHANGED
|
@@ -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",
|
package/voice-service/index.js
CHANGED
|
@@ -271,19 +271,8 @@ function loadRustpotterModule() {
|
|
|
271
271
|
// userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
|
|
272
272
|
const detectorCache = new Map();
|
|
273
273
|
|
|
274
|
-
//
|
|
275
|
-
|
|
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
|
-
//
|
|
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 (
|
|
677
|
-
`
|
|
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
|
-
|
|
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}`);
|