linkgravity 1.5.7 → 1.5.9

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
@@ -2,13 +2,15 @@
2
2
 
3
3
  A Discord, Telegram, and Slack bot interface for the Antigravity agentic AI system. It translates Antigravity CLI prompts into chat UI components and provides voice interaction capabilities.
4
4
 
5
+ ![Tool approval buttons and session threads in Discord](https://raw.githubusercontent.com/dev-sseul/linkgravity/main/docs/images/approval-flow.png)
6
+
5
7
  ## Features
6
8
 
7
- - **Environment Sync:** Automatically syncs with the host's `~/.gemini` configuration.
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
- - **Wake Word Recognition:** Uses phoneme-level similarity to detect wake words and activate voice commands.
9
+ - **Sessions as Threads:** Each `agy` conversation lives in its own thread, so several can run side by side and stay readable later. Threads get named from what you actually talked about instead of staying `Session-XXXX`.
10
10
  - **Approval Flow:** Command and tool-call approvals become interactive chat 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
+ - **Voice Interaction:** Talk to the agent from a Discord voice channel and hear its replies. Say your wake word to get its attention, so side conversation in the channel doesn't set it off.
11
12
  - **Multi-Modal Input:** Attach files for the AI to read, including audio, which gets transcribed to text automatically.
13
+ - **Same Agent as Your Terminal:** Reads the `agy` setup already on the machine, so sessions started from chat use the same models and settings you use locally.
12
14
 
13
15
  ## Supported Platforms
14
16
 
@@ -58,7 +60,7 @@ Slack has more moving parts than the others - two separate tokens, and a few set
58
60
  - It's easy to grab the wrong token here - the page also shows a **User OAuth Token** (`xoxp-...`) further down, which is a different thing and won't work for this bot.
59
61
  5. **App Home** (left sidebar) > under **Show Tabs**, turn on **Messages Tab**, then check **Allow users to send Slash commands and messages from the messages tab** - this is what lets you DM the bot at all. (If this section looks greyed out, it's because step 3 hasn't been saved/installed yet - go back and do that first.)
60
62
  6. **Event Subscriptions** (left sidebar) > toggle **Enable Events** on > under **Subscribe to bot events**, add `message.channels`, `message.groups`, `message.im`, and `message.mpim` > **Save Changes**.
61
- 7. **Slash Commands** (left sidebar) > **Create New Command**, three times, for `/new`, `/model`, and `/credit` (any description/hint text is fine - only the command name matters).
63
+ 7. **Slash Commands** (left sidebar) > **Create New Command**, four times, for `/new`, `/model`, `/credit`, and `/permissions` (any description/hint text is fine - only the command name matters).
62
64
  8. Back on **OAuth & Permissions**, since scopes/events changed after the initial install, click **Reinstall to Workspace** to push those changes live. Any time you change scopes or events later, you'll need to repeat this step.
63
65
  9. In Slack itself, for any **channel** (not DM) you want the bot usable in, run `/invite @<your bot's name>` there first - the bot can't post in a channel it hasn't been added to.
64
66
 
package/bin/cli.js CHANGED
@@ -175,7 +175,10 @@ function verifyStartup() {
175
175
  `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
176
176
  );
177
177
 
178
- let cp = spawn('npx', ['-y', 'pm2', 'logs', LGY_PM2_NAME, '--raw', '--lines', '20'], {
178
+ // Spawned directly instead of through npx: npx wraps pm2 in "npm exec" + "sh -c", so cp.kill()
179
+ // reaps only the wrapper and leaves the real pm2 logs process orphaned onto init.
180
+ const pm2Bin = require.resolve('pm2/bin/pm2');
181
+ let cp = spawn(process.execPath, [pm2Bin, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
179
182
  cwd: path.join(__dirname, '..'),
180
183
  });
181
184
 
@@ -188,12 +191,6 @@ function verifyStartup() {
188
191
  resolve(ok);
189
192
  };
190
193
 
191
- // Give the --lines 20 replay burst a moment to flush before treating error text as a fresh crash, not old log noise.
192
- let errorDetectionArmed = false;
193
- setTimeout(() => {
194
- errorDetectionArmed = true;
195
- }, 1500);
196
-
197
194
  let timer = setTimeout(() => {
198
195
  console.log(
199
196
  `\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
@@ -212,7 +209,6 @@ function verifyStartup() {
212
209
  console.log(`\n${color.green}✔${color.reset} Bot successfully came online!\n`);
213
210
  finish(true);
214
211
  } else if (
215
- errorDetectionArmed &&
216
212
  (str.includes('Traceback (most recent call last):') ||
217
213
  str.includes('Error:') ||
218
214
  str.includes('Exception:')) &&
@@ -593,6 +589,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
593
589
  process.exit(0);
594
590
  }
595
591
 
592
+ const procBeforeUpdate = getPm2Proc();
593
+ const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
594
+
596
595
  info(`Updating: v${currentVersion} -> v${latestVersion}...`);
597
596
  const installResult = spawnSync('npm', ['install', '-g', 'linkgravity@latest'], {
598
597
  stdio: 'inherit',
@@ -603,27 +602,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
603
602
  }
604
603
  success(`Installed v${latestVersion}.`);
605
604
 
606
- info('Restarting daemon to apply the update...');
607
- const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', LGY_PM2_NAME, '--update-env'], {
608
- stdio: 'pipe',
609
- cwd: path.join(__dirname, '..'),
610
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
611
- });
612
-
613
- if (restartResult.status === 0) {
614
- runPm2(['reset', LGY_PM2_NAME]);
615
- verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
616
- } else if ((restartResult.stderr || '').toString().includes('not found')) {
617
- // Wasn't running before the update - start fresh instead of a false "restarted".
605
+ if (!procBeforeUpdate) {
618
606
  info("Daemon wasn't running - starting it fresh...");
619
607
  runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
620
608
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
609
+ } else if (wasOnline) {
610
+ info('Restarting daemon to apply the update...');
611
+ runPm2(['restart', LGY_PM2_NAME, '--update-env']);
612
+ runPm2(['reset', LGY_PM2_NAME]);
613
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
621
614
  } else {
622
- console.error((restartResult.stderr || '').toString().trim());
623
- console.error(
624
- `\n${color.yellow}⚠${color.reset} Update installed, but restarting the daemon failed - run 'lgy restart' manually.`,
625
- );
626
- process.exit(1);
615
+ success(`Daemon was stopped - leaving it stopped. Run 'lgy start' when you're ready.\n`);
616
+ process.exit(0);
627
617
  }
628
618
  } else if (cmd === 'help') {
629
619
  console.log(
@@ -693,7 +683,6 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
693
683
  spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
694
684
  })();
695
685
  } else {
696
- // If no valid command was provided, show help
697
686
  console.log(
698
687
  `\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
699
688
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.7",
3
+ "version": "1.5.9",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
package/requirements.txt CHANGED
@@ -4,12 +4,9 @@ slack-bolt>=1.20.0
4
4
  PyNaCl>=1.5.0
5
5
  numpy>=1.24.0
6
6
  edge-tts
7
- google-genai>=1.0.0
8
7
  aiohttp==3.10.3
9
8
  aiofiles==24.1.0
10
9
  SpeechRecognition>=3.17.0
11
- schedule==1.2.1
12
10
  jamo>=0.4.1
13
11
  loguru>=0.7.0
14
- mcp>=1.0.0
15
12
  requests>=2.31.0
@@ -9,6 +9,7 @@ from aiohttp import web
9
9
  from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
10
10
  from messengers.base import ScopeOption
11
11
  from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
12
+ from utils.utils import split_message
12
13
 
13
14
 
14
15
  def is_tool_allowed(tool_name, tool_input):
@@ -57,8 +58,8 @@ def allow_response(tool_name, tool_input):
57
58
  async def _send_chunked(adapter, thread, text: str) -> None:
58
59
  if not text:
59
60
  return
60
- for i in range(0, len(text), MAX_EMBED_LEN):
61
- await adapter.send_message(thread, text[i : i + MAX_EMBED_LEN])
61
+ for part in split_message(text, MAX_EMBED_LEN):
62
+ await adapter.send_message(thread, part)
62
63
 
63
64
 
64
65
  def _persist_scope_if_granted(prompt_handle):
@@ -196,10 +197,7 @@ async def handle_approve_request(request):
196
197
  break
197
198
  except ValueError:
198
199
  pass
199
- elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd}) or (
200
- conv_id in session_manager.session_allowed_tools
201
- and tool_name in session_manager.session_allowed_tools[conv_id]
202
- ):
200
+ elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd}):
203
201
  is_auto_allowed = True
204
202
 
205
203
  if is_auto_allowed:
@@ -270,14 +268,7 @@ async def handle_approve_request(request):
270
268
  return allow_response(tool_name, tool_input)
271
269
 
272
270
  else:
273
- is_auto_allowed = False
274
- if is_tool_allowed(tool_name, tool_input) or (
275
- conv_id in session_manager.session_allowed_tools
276
- and tool_name in session_manager.session_allowed_tools[conv_id]
277
- ):
278
- is_auto_allowed = True
279
-
280
- if is_auto_allowed:
271
+ if is_tool_allowed(tool_name, tool_input):
281
272
  if target_thread and tool_msg_text:
282
273
  await send_ordered(
283
274
  target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
@@ -324,58 +315,3 @@ async def handle_approve_request(request):
324
315
  for key in registered_approval_keys:
325
316
  session_manager.clear_pending_approval(key)
326
317
  return web.json_response({"decision": "allow"})
327
-
328
-
329
- async def handle_mcp_ask(request):
330
- try:
331
- data = await request.json()
332
- thread_id = data.get("thread_id")
333
-
334
- question = _clean_inline(data.get("question", "No question provided."))
335
- options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
336
-
337
- adapter = get_adapter_for_thread(thread_id)
338
- thread = adapter.resolve_conversation(thread_id)
339
- if not thread:
340
- return web.json_response({"answer": "Thread not found"}, status=400)
341
-
342
- future = asyncio.get_event_loop().create_future()
343
- # conv_id is always None here - key just needs to be unique for cleanup.
344
- approval_key = f"mcp_ask:{thread_id}:{uuid.uuid4().hex}"
345
- session_manager.set_pending_approval(approval_key, future, "ask_question")
346
-
347
- prompt = adapter.create_question_prompt(future, question, options, allow_write_in=True)
348
- msg = await prompt.send(thread)
349
- session_manager.pending_approval_messages[approval_key] = msg
350
-
351
- try:
352
- answer = await asyncio.wait_for(future, timeout=300)
353
- return web.json_response({"answer": answer})
354
- except asyncio.TimeoutError:
355
- return web.json_response({"answer": "User did not respond in time."})
356
- finally:
357
- await prompt.finalize()
358
- session_manager.pending_approval_messages.pop(approval_key, None)
359
- session_manager.clear_pending_approval(approval_key)
360
- except Exception as e:
361
- return web.json_response({"answer": f"Error: {e}"}, status=500)
362
-
363
-
364
- async def handle_mcp_send_channel(request):
365
- try:
366
- data = await request.json()
367
- channel_id = data.get("channel_id")
368
- message = data.get("message", "")
369
-
370
- adapter = get_adapter_for_thread(channel_id)
371
- channel = adapter.resolve_conversation(channel_id)
372
- if not channel:
373
- return web.json_response({"error": "Channel not found"}, status=400)
374
-
375
- chunks = [message[i : i + MAX_EMBED_LEN] for i in range(0, len(message), MAX_EMBED_LEN)]
376
- for chunk in chunks:
377
- await adapter.send_message(channel, chunk)
378
-
379
- return web.json_response({"answer": "success"})
380
- except Exception as e:
381
- return web.json_response({"error": str(e)}, status=500)
@@ -255,6 +255,23 @@ class GeneralCog(commands.Cog):
255
255
  except Exception as e:
256
256
  await interaction.response.send_message(f"⚠️ Failed to update settings: {e}", ephemeral=True)
257
257
 
258
+ @app_commands.command(name="permissions", description="View and remove previously allowed tools and commands")
259
+ async def cmd_permissions(self, interaction: discord.Interaction):
260
+ if not allowed(interaction.user.id):
261
+ return await interaction.response.send_message("❌ Denied", ephemeral=True)
262
+
263
+ from messengers.registry import get_adapter_for_platform
264
+ from services import permissions
265
+
266
+ adapter = get_adapter_for_platform("discord")
267
+
268
+ async def on_revoke(entry):
269
+ permissions.revoke(entry)
270
+
271
+ handle = adapter.create_permission_list(on_revoke)
272
+ await handle.send(interaction.channel)
273
+ await interaction.response.send_message("🔐 Permission list posted above.", ephemeral=True)
274
+
258
275
  @app_commands.command(
259
276
  name="stop", description="Stop the currently generating response or task (Equivalent to ESC in CLI)"
260
277
  )
@@ -278,9 +295,21 @@ class GeneralCog(commands.Cog):
278
295
  # Must clear conversation_id too, not just status - pending requires both unset.
279
296
  session_manager.set_session(thread_id, {**session, "status": "pending", "conversation_id": None})
280
297
 
298
+ # The typing indicator only clears when the handler leaves its `async with adapter.typing()`
299
+ # block, and that block outlives the agy process (title generation, final send), so killing
300
+ # the process alone can leave the thread stuck showing "is thinking...".
301
+ handler = session_manager.get_handler_task(thread_id)
302
+ cancelled = bool(handler and handler is not asyncio.current_task() and not handler.done())
303
+ if cancelled:
304
+ handler.cancel()
305
+ session_manager.remove_handler_task(thread_id)
306
+
281
307
  from core.agy_runner import stop_active_process
282
308
 
283
309
  if stop_active_process(thread_id):
284
- await interaction.response.send_message("🛑 Process stopped natively.", ephemeral=True)
310
+ msg = "🛑 Process stopped natively."
311
+ elif cancelled:
312
+ msg = "🛑 Stopped."
285
313
  else:
286
- await interaction.response.send_message("🛑 Process stopped.", ephemeral=True)
314
+ msg = "🛑 Nothing was running."
315
+ await interaction.response.send_message(msg, ephemeral=True)
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import math
2
3
  import time
3
4
 
4
5
  import aiohttp
@@ -15,6 +16,18 @@ from .voice.stt_session import SttSessionTracker
15
16
  NODE_VOICE_API = "http://localhost:18081"
16
17
  # Default aiohttp timeout is 5 minutes - too long for a dead voice service.
17
18
  NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
19
+ # Must match DEFAULT_WAKE_THRESHOLD / DEFAULT_VAD_THRESHOLD in voice-service - the two processes
20
+ # decide these independently, and only voice-service's values actually gate anything.
21
+ DEFAULT_WAKE_THRESHOLD = 0.4
22
+ DEFAULT_VAD_THRESHOLD = 3000
23
+
24
+
25
+ def _autocomplete_query(current) -> str:
26
+ # discord.py hands a focused NUMBER option back as float('nan') when the input box is empty,
27
+ # and passes INTEGER options through unconverted - neither is guaranteed to be a str.
28
+ if isinstance(current, float) and math.isnan(current):
29
+ return ""
30
+ return str(current)
18
31
 
19
32
 
20
33
  class VoiceCog(commands.Cog):
@@ -60,6 +73,12 @@ class VoiceCog(commands.Cog):
60
73
  def _wake_word_required(self, user_id) -> bool:
61
74
  return (self.bot_settings.get("wake_word_required") or {}).get(str(user_id), True)
62
75
 
76
+ def _wake_threshold(self, user_id) -> float:
77
+ return (self.bot_settings.get("wake_thresholds") or {}).get(str(user_id), DEFAULT_WAKE_THRESHOLD)
78
+
79
+ def _vad_threshold(self, user_id) -> int:
80
+ return (self.bot_settings.get("voice_thresholds") or {}).get(str(user_id), DEFAULT_VAD_THRESHOLD)
81
+
63
82
  async def handle_voice_service_down(self):
64
83
  await self.enrollment.handle_voice_service_down()
65
84
 
@@ -83,8 +102,9 @@ class VoiceCog(commands.Cog):
83
102
  self, interaction: discord.Interaction, current: str
84
103
  ) -> list[app_commands.Choice[int]]:
85
104
  current_val = int(self.bot_settings.get("active_timer", 60))
105
+ query = _autocomplete_query(current)
86
106
  opts = []
87
- if str(current_val) in current or not current:
107
+ if str(current_val) in query or not query:
88
108
  opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
89
109
 
90
110
  for v in [30, 60, 120, 300]:
@@ -95,9 +115,10 @@ class VoiceCog(commands.Cog):
95
115
  async def interrupt_threshold_autocomplete(
96
116
  self, interaction: discord.Interaction, current: str
97
117
  ) -> list[app_commands.Choice[int]]:
98
- current_val = int(self.bot_settings.get("voice_threshold", 3000))
118
+ current_val = self._vad_threshold(interaction.user.id)
119
+ query = _autocomplete_query(current)
99
120
  opts = []
100
- if str(current_val) in current or not current:
121
+ if str(current_val) in query or not query:
101
122
  opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
102
123
 
103
124
  for v in [1000, 2000, 3000, 5000]:
@@ -108,9 +129,10 @@ class VoiceCog(commands.Cog):
108
129
  async def wake_sensitivity_autocomplete(
109
130
  self, interaction: discord.Interaction, current: str
110
131
  ) -> list[app_commands.Choice[float]]:
111
- current_val = float(self.bot_settings.get("wake_threshold", 0.4))
132
+ current_val = self._wake_threshold(interaction.user.id)
133
+ query = _autocomplete_query(current)
112
134
  opts = []
113
- if str(current_val) in current or not current:
135
+ if str(current_val) in query or not query:
114
136
  opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
115
137
 
116
138
  for v in [0.2, 0.3, 0.4, 0.5, 0.6]:
@@ -184,8 +206,9 @@ class VoiceCog(commands.Cog):
184
206
  self, interaction: discord.Interaction, current: str
185
207
  ) -> list[app_commands.Choice[float]]:
186
208
  current_val = float(self.bot_settings.get("tts_speed", 1.0))
209
+ query = _autocomplete_query(current)
187
210
  opts = []
188
- if str(current_val) in current or not current:
211
+ if str(current_val) in query or not query:
189
212
  opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
190
213
 
191
214
  for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
@@ -346,8 +369,8 @@ class VoiceCog(commands.Cog):
346
369
  ):
347
370
  curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
348
371
  curr_timer = self.bot_settings.get("active_timer", 60)
349
- curr_interrupt_thresh = self.bot_settings.get("voice_threshold", 3000)
350
- curr_wake_sens = self.bot_settings.get("wake_threshold", 0.4)
372
+ curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
373
+ curr_wake_sens = self._wake_threshold(interaction.user.id)
351
374
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
352
375
  curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
353
376
  curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
@@ -373,30 +396,30 @@ class VoiceCog(commands.Cog):
373
396
  self.bot_settings["active_timer"] = active_times
374
397
  updated.append(f"⏱️ Active Timer: `{active_times}s`")
375
398
  if interrupt_threshold is not None:
376
- self.bot_settings["voice_threshold"] = interrupt_threshold
399
+ self.bot_settings.setdefault("voice_thresholds", {})[str(interaction.user.id)] = interrupt_threshold
377
400
  updated.append(f"🔊 Interrupt Threshold: `{interrupt_threshold}`")
378
401
  try:
379
402
  async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
380
- await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": interrupt_threshold})
381
- except aiohttp.ClientError as e:
382
- self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
403
+ await session.post(
404
+ f"{NODE_VOICE_API}/set_vad_threshold",
405
+ json={"user_id": str(interaction.user.id), "threshold": interrupt_threshold},
406
+ )
407
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
408
+ self.logger.warning(f"Node.js vad-threshold sync failed for {interaction.user.id}: {e}")
383
409
  updated.append(f"(⚠️ Node.js Sync Failed: {e})")
384
- except asyncio.TimeoutError:
385
- self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
386
- updated.append("(⚠️ Node.js Sync Timeout)")
387
410
  if wake_sensitivity is not None:
388
411
  clamped_wake = max(0.05, min(0.95, wake_sensitivity))
389
- self.bot_settings["wake_threshold"] = clamped_wake
412
+ self.bot_settings.setdefault("wake_thresholds", {})[str(interaction.user.id)] = clamped_wake
390
413
  updated.append(f"🎯 Wake Sensitivity: `{clamped_wake}`")
391
414
  try:
392
415
  async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
393
- await session.post(f"{NODE_VOICE_API}/set_config", json={"wake_threshold": clamped_wake})
394
- except aiohttp.ClientError as e:
395
- self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
416
+ await session.post(
417
+ f"{NODE_VOICE_API}/set_wake_threshold",
418
+ json={"user_id": str(interaction.user.id), "threshold": clamped_wake},
419
+ )
420
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
421
+ self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
396
422
  updated.append(f"(⚠️ Node.js Sync Failed: {e})")
397
- except asyncio.TimeoutError:
398
- self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
399
- updated.append("(⚠️ Node.js Sync Timeout)")
400
423
  if tts_voice is not None:
401
424
  self.bot_settings["tts_voice"] = tts_voice
402
425
  updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
package/src/config.py CHANGED
@@ -25,8 +25,11 @@ DEFAULT_LGY_CONFIG = {
25
25
  "allowed_user_ids": "",
26
26
  # user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
27
27
  "wake_words": {},
28
+ # user_id (str) -> wake-word match threshold; absent means voice-service's own default.
29
+ "wake_thresholds": {},
30
+ # user_id (str) -> interrupt/VAD RMS threshold; absent means voice-service's own default.
31
+ "voice_thresholds": {},
28
32
  "active_timer": 60,
29
- "voice_threshold": 3000,
30
33
  "tts_voice": "ko-KR-SunHiNeural",
31
34
  "tts_enabled": True,
32
35
  # Sticky default for /new sessions, set whenever /model succeeds.
@@ -24,7 +24,6 @@ class SessionManager:
24
24
  self.active_approval_by_conv: dict[str, str] = {}
25
25
 
26
26
  self.persistent_allowed: dict = self._load_persistent()
27
- self.session_allowed_tools: dict[str, set] = {}
28
27
 
29
28
  def _load_sessions(self) -> dict:
30
29
  from config import logger
@@ -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) and thread.name.startswith("Session-"):
77
+ if adapter.should_auto_title(thread):
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
@@ -14,7 +14,12 @@ from config import SLACK_APP_TOKEN, SLACK_BOT_TOKEN, allowed, bot_settings, logg
14
14
  from core import platform_health
15
15
  from handlers.message_router import handle_message
16
16
  from messengers.registry import register_adapter
17
- from messengers.slack_adapter import SlackAdapter, encode_conversation_id, latest_channel_session
17
+ from messengers.slack_adapter import (
18
+ SlackAdapter,
19
+ SlackConversationRef,
20
+ encode_conversation_id,
21
+ latest_channel_session,
22
+ )
18
23
  from utils.utils import get_default_cwd
19
24
 
20
25
 
@@ -129,6 +134,25 @@ async def cmd_model(ack, body, respond, context) -> None:
129
134
  await respond(f"🤖 Model changed: *{final_model}*\n💾 Also set as the default for new sessions.")
130
135
 
131
136
 
137
+ async def cmd_permissions(ack, body, respond, context) -> None:
138
+ await ack()
139
+ adapter: SlackAdapter = context["adapter"]
140
+ user_id = body["user_id"]
141
+ channel = body["channel_id"]
142
+
143
+ if not allowed(user_id, "slack"):
144
+ await respond("❌ Denied")
145
+ return
146
+
147
+ from services import permissions
148
+
149
+ async def on_revoke(entry):
150
+ permissions.revoke(entry)
151
+
152
+ handle = adapter.create_permission_list(on_revoke)
153
+ await handle.send(SlackConversationRef(channel=channel, thread_ts=None))
154
+
155
+
132
156
  async def cmd_credit(ack, body, respond, context) -> None:
133
157
  await ack()
134
158
  adapter: SlackAdapter = context["adapter"]
@@ -212,6 +236,7 @@ def build_app() -> tuple[AsyncApp, SlackAdapter]:
212
236
  app.command("/new")(cmd_new)
213
237
  app.command("/model")(cmd_model)
214
238
  app.command("/credit")(cmd_credit)
239
+ app.command("/permissions")(cmd_permissions)
215
240
  app.event("message")(on_message)
216
241
  app.action(re.compile(".*"))(on_action)
217
242
  app.view(re.compile(".*"))(on_view_submission)
@@ -135,6 +135,23 @@ async def cmd_model(update: Update, context) -> None:
135
135
  )
136
136
 
137
137
 
138
+ async def cmd_permissions(update: Update, context) -> None:
139
+ user = update.effective_user
140
+ adapter: TelegramAdapter = context.bot_data["adapter"]
141
+
142
+ if not allowed(user.id, "telegram"):
143
+ await update.message.reply_text("❌ Denied")
144
+ return
145
+
146
+ from services import permissions
147
+
148
+ async def on_revoke(entry):
149
+ permissions.revoke(entry)
150
+
151
+ handle = adapter.create_permission_list(on_revoke)
152
+ await handle.send(update.effective_chat.id)
153
+
154
+
138
155
  async def cmd_credit(update: Update, context) -> None:
139
156
  user = update.effective_user
140
157
  adapter: TelegramAdapter = context.bot_data["adapter"]
@@ -207,6 +224,7 @@ async def on_ready(app: Application) -> None:
207
224
  BotCommand("new", "Start a new session (or /start)"),
208
225
  BotCommand("model", "Change the AI model for this session"),
209
226
  BotCommand("credit", "Turn AI Credits on/off"),
227
+ BotCommand("permissions", "View and remove allowed tools and commands"),
210
228
  ]
211
229
  )
212
230
  logger.info(f"✅ Bot is fully online and ready! Logged in as @{app.bot.username}")
@@ -221,6 +239,7 @@ def build_application() -> Application:
221
239
  app.add_handler(CommandHandler(["new", "start"], cmd_new))
222
240
  app.add_handler(CommandHandler("model", cmd_model))
223
241
  app.add_handler(CommandHandler("credit", cmd_credit))
242
+ app.add_handler(CommandHandler("permissions", cmd_permissions))
224
243
  app.add_handler(CallbackQueryHandler(adapter.handle_callback_query))
225
244
  app.add_handler(MessageHandler(filters.ALL & ~filters.COMMAND, on_message))
226
245
  app.add_error_handler(on_error)
@@ -54,6 +54,18 @@ class ToolApprovalOutcome:
54
54
  scope: ScopeOption | None = None
55
55
 
56
56
 
57
+ @dataclass
58
+ class PermissionEntry:
59
+ kind: str
60
+ scope: str
61
+
62
+
63
+ class PermissionListHandle(ABC):
64
+ @abstractmethod
65
+ async def send(self, conversation_ref: Any) -> Any:
66
+ raise NotImplementedError
67
+
68
+
57
69
  class PromptHandle(ABC):
58
70
  outcome: ToolApprovalOutcome | None = None
59
71
 
@@ -96,9 +108,7 @@ class MessengerAdapter(ABC):
96
108
  async def start_conversation(self, origin_ref: Any, title: str) -> Any:
97
109
  raise NotImplementedError
98
110
 
99
- def can_rename(self, conversation_ref: Any) -> bool:
100
- """Per-conversation version of supports_renaming - lets a single adapter answer
101
- differently depending on the target (e.g. Discord threads vs. Discord DMs)."""
111
+ def should_auto_title(self, conversation_ref: Any) -> bool:
102
112
  return self.supports_renaming
103
113
 
104
114
  @abstractmethod
@@ -119,6 +129,10 @@ class MessengerAdapter(ABC):
119
129
  ) -> PromptHandle:
120
130
  raise NotImplementedError
121
131
 
132
+ @abstractmethod
133
+ def create_permission_list(self, on_revoke: Callable[[PermissionEntry], Awaitable[None]]) -> PermissionListHandle:
134
+ raise NotImplementedError
135
+
122
136
  @abstractmethod
123
137
  def create_question_prompt(
124
138
  self,
@@ -12,6 +12,7 @@ from messengers.base import (
12
12
  IncomingAttachment,
13
13
  IncomingMessage,
14
14
  MessengerAdapter,
15
+ PermissionListHandle,
15
16
  PromptHandle,
16
17
  ScopeOption,
17
18
  ToolApprovalOutcome,
@@ -32,6 +33,58 @@ class _ErrorLoggingView(discord.ui.View):
32
33
  pass
33
34
 
34
35
 
36
+ class _DiscordPermissionList(PermissionListHandle):
37
+ def __init__(self, on_revoke):
38
+ self.on_revoke = on_revoke
39
+ self.page = 0
40
+ self.message: discord.Message | None = None
41
+
42
+ def _build(self):
43
+ from services import permissions
44
+
45
+ entries = permissions.list_entries()
46
+ page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
47
+ embed = discord.Embed(
48
+ title="🔐 Allowed Permissions",
49
+ description=permissions.render_body(page_entries, self.page, total_pages),
50
+ color=discord.Color.blurple(),
51
+ )
52
+ view = _ErrorLoggingView(timeout=None)
53
+
54
+ for entry in page_entries:
55
+ label = f"🗑️ {permissions.entry_label(entry)}"
56
+ button = discord.ui.Button(label=label[:80], style=discord.ButtonStyle.gray)
57
+
58
+ async def revoke_callback(interaction: discord.Interaction, entry=entry):
59
+ await self.on_revoke(entry)
60
+ await self._refresh(interaction)
61
+
62
+ button.callback = revoke_callback
63
+ view.add_item(button)
64
+
65
+ if total_pages > 1:
66
+ for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
67
+ nav = discord.ui.Button(label=label, style=discord.ButtonStyle.blurple)
68
+
69
+ async def nav_callback(interaction: discord.Interaction, delta=delta):
70
+ self.page += delta
71
+ await self._refresh(interaction)
72
+
73
+ nav.callback = nav_callback
74
+ view.add_item(nav)
75
+
76
+ return embed, view
77
+
78
+ async def _refresh(self, interaction: discord.Interaction) -> None:
79
+ embed, view = self._build()
80
+ await interaction.response.edit_message(embed=embed, view=view)
81
+
82
+ async def send(self, conversation_ref: discord.abc.Messageable) -> discord.Message:
83
+ embed, view = self._build()
84
+ self.message = await conversation_ref.send(embed=embed, view=view)
85
+ return self.message
86
+
87
+
35
88
  class _DiscordPromptHandle(PromptHandle):
36
89
  def __init__(self, embed: discord.Embed, view: discord.ui.View):
37
90
  self.embed = embed
@@ -149,8 +202,8 @@ class DiscordAdapter(MessengerAdapter):
149
202
  async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
150
203
  return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
151
204
 
152
- def can_rename(self, conversation_ref: Any) -> bool:
153
- return isinstance(conversation_ref, discord.Thread)
205
+ def should_auto_title(self, conversation_ref: Any) -> bool:
206
+ return isinstance(conversation_ref, discord.Thread) and conversation_ref.name.startswith("Session-")
154
207
 
155
208
  async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
156
209
  if not isinstance(conversation_ref, discord.Thread):
@@ -215,6 +268,9 @@ class DiscordAdapter(MessengerAdapter):
215
268
 
216
269
  return handle
217
270
 
271
+ def create_permission_list(self, on_revoke) -> PermissionListHandle:
272
+ return _DiscordPermissionList(on_revoke)
273
+
218
274
  def create_question_prompt(
219
275
  self,
220
276
  answer_future: asyncio.Future,
@@ -17,6 +17,7 @@ from messengers.base import (
17
17
  IncomingAttachment,
18
18
  IncomingMessage,
19
19
  MessengerAdapter,
20
+ PermissionListHandle,
20
21
  PromptHandle,
21
22
  ScopeOption,
22
23
  ToolApprovalOutcome,
@@ -85,6 +86,77 @@ class SlackMessageRef:
85
86
  self.ts = ts
86
87
 
87
88
 
89
+ class _SlackPermissionList(PermissionListHandle):
90
+ def __init__(self, client: AsyncWebClient, callbacks: dict, on_revoke):
91
+ self.client = client
92
+ self._callbacks = callbacks
93
+ self.on_revoke = on_revoke
94
+ self.page = 0
95
+ self.list_id = uuid.uuid4().hex[:12]
96
+ self._keys: list[str] = []
97
+ self.channel: str | None = None
98
+ self.ts: str | None = None
99
+
100
+ def _build(self):
101
+ from services import permissions
102
+
103
+ for key in self._keys:
104
+ self._callbacks.pop(key, None)
105
+ self._keys = []
106
+
107
+ entries = permissions.list_entries()
108
+ page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
109
+ body = permissions.render_body(page_entries, self.page, total_pages)
110
+ text = f"*🔐 Allowed Permissions*\n\n{body}"
111
+ blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": text[:2990]}}]
112
+ elements = []
113
+
114
+ for i, entry in enumerate(page_entries):
115
+ key = f"{self.list_id}:revoke:{i}"
116
+ self._callbacks[key] = lambda b, c, entry=entry: self._revoke(entry)
117
+ self._keys.append(key)
118
+ label = f"🗑️ {permissions.entry_label(entry)}"
119
+ elements.append({"type": "button", "text": {"type": "plain_text", "text": label[:75]}, "action_id": key})
120
+
121
+ if total_pages > 1:
122
+ for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
123
+ key = f"{self.list_id}:page:{delta}"
124
+ self._callbacks[key] = lambda b, c, delta=delta: self._turn(delta)
125
+ self._keys.append(key)
126
+ elements.append({"type": "button", "text": {"type": "plain_text", "text": label}, "action_id": key})
127
+
128
+ # 25 is Slack's hard per-block limit on action elements.
129
+ for chunk_start in range(0, len(elements), 25):
130
+ blocks.append({"type": "actions", "elements": elements[chunk_start : chunk_start + 25]})
131
+
132
+ return text, blocks
133
+
134
+ async def _redraw(self) -> None:
135
+ text, blocks = self._build()
136
+ if self.channel and self.ts:
137
+ await self.client.chat_update(channel=self.channel, ts=self.ts, text=text, blocks=blocks)
138
+
139
+ async def _revoke(self, entry) -> None:
140
+ await self.on_revoke(entry)
141
+ await self._redraw()
142
+
143
+ async def _turn(self, delta: int) -> None:
144
+ self.page += delta
145
+ await self._redraw()
146
+
147
+ async def send(self, conversation_ref: SlackConversationRef) -> dict:
148
+ text, blocks = self._build()
149
+ resp = await self.client.chat_postMessage(
150
+ channel=conversation_ref.channel,
151
+ thread_ts=conversation_ref.api_thread_ts,
152
+ text=text,
153
+ blocks=blocks,
154
+ )
155
+ self.channel = resp["channel"]
156
+ self.ts = resp["ts"]
157
+ return resp
158
+
159
+
88
160
  class _SlackPromptHandle(PromptHandle):
89
161
  def __init__(
90
162
  self, client: AsyncWebClient, text: str, blocks: list[dict], cleanup: Callable[[], None] | None = None
@@ -358,6 +430,9 @@ class SlackAdapter(MessengerAdapter):
358
430
  )
359
431
  return handle
360
432
 
433
+ def create_permission_list(self, on_revoke) -> PermissionListHandle:
434
+ return _SlackPermissionList(self.client, self._callbacks, on_revoke)
435
+
361
436
  def create_question_prompt(
362
437
  self,
363
438
  answer_future: asyncio.Future,
@@ -19,6 +19,7 @@ from messengers.base import (
19
19
  IncomingAttachment,
20
20
  IncomingMessage,
21
21
  MessengerAdapter,
22
+ PermissionListHandle,
22
23
  PromptHandle,
23
24
  ScopeOption,
24
25
  ToolApprovalOutcome,
@@ -52,6 +53,63 @@ async def safe_query_edit(query, **kwargs) -> None:
52
53
  raise
53
54
 
54
55
 
56
+ class _TelegramPermissionList(PermissionListHandle):
57
+ def __init__(self, bot, callbacks: dict, on_revoke):
58
+ self.bot = bot
59
+ self._callbacks = callbacks
60
+ self.on_revoke = on_revoke
61
+ self.page = 0
62
+ self.list_id = uuid.uuid4().hex[:12]
63
+ self._keys: list[str] = []
64
+
65
+ def _build(self):
66
+ from services import permissions
67
+
68
+ for key in self._keys:
69
+ self._callbacks.pop(key, None)
70
+ self._keys = []
71
+
72
+ entries = permissions.list_entries()
73
+ page_entries, self.page, total_pages = permissions.page_of(entries, self.page)
74
+ body = permissions.render_body(page_entries, self.page, total_pages)
75
+ text = f"<b>🔐 Allowed Permissions</b>\n\n{html.escape(body)}"
76
+ keyboard: list[list[InlineKeyboardButton]] = []
77
+
78
+ for i, entry in enumerate(page_entries):
79
+ key = f"{self.list_id}:revoke:{i}"
80
+ self._callbacks[key] = lambda query, entry=entry: self._revoke(entry, query)
81
+ self._keys.append(key)
82
+ keyboard.append([InlineKeyboardButton(f"🗑️ {permissions.entry_label(entry)}"[:64], callback_data=key)])
83
+
84
+ if total_pages > 1:
85
+ row = []
86
+ for label, delta in (("◀ Prev", -1), ("Next ▶", 1)):
87
+ key = f"{self.list_id}:page:{delta}"
88
+ self._callbacks[key] = lambda query, delta=delta: self._turn(delta, query)
89
+ self._keys.append(key)
90
+ row.append(InlineKeyboardButton(label, callback_data=key))
91
+ keyboard.append(row)
92
+
93
+ return text, InlineKeyboardMarkup(keyboard)
94
+
95
+ async def _redraw(self, query) -> None:
96
+ text, markup = self._build()
97
+ await query.answer()
98
+ await safe_query_edit(query, text=text, parse_mode="HTML", reply_markup=markup)
99
+
100
+ async def _revoke(self, entry, query) -> None:
101
+ await self.on_revoke(entry)
102
+ await self._redraw(query)
103
+
104
+ async def _turn(self, delta: int, query) -> None:
105
+ self.page += delta
106
+ await self._redraw(query)
107
+
108
+ async def send(self, conversation_ref: int) -> Message:
109
+ text, markup = self._build()
110
+ return await self.bot.send_message(chat_id=conversation_ref, text=text, reply_markup=markup, parse_mode="HTML")
111
+
112
+
55
113
  class _TelegramPromptHandle(PromptHandle):
56
114
  def __init__(self, bot, text: str, reply_markup, cleanup: Callable[[], None] | None = None):
57
115
  self.bot = bot
@@ -285,6 +343,9 @@ class TelegramAdapter(MessengerAdapter):
285
343
  )
286
344
  return handle
287
345
 
346
+ def create_permission_list(self, on_revoke) -> PermissionListHandle:
347
+ return _TelegramPermissionList(self.bot, self._callbacks, on_revoke)
348
+
288
349
  def create_question_prompt(
289
350
  self,
290
351
  answer_future: asyncio.Future,
@@ -74,7 +74,7 @@ def check_approval_intent(text: str) -> str:
74
74
  "ㅇㅇ",
75
75
  "ㅇㅋ",
76
76
  "해",
77
- "",
77
+ "그래",
78
78
  "네",
79
79
  "sure",
80
80
  "yeah",
@@ -92,3 +92,31 @@ def check_approval_intent(text: str) -> str:
92
92
  if word in exact_allow:
93
93
  return "allow"
94
94
  return None
95
+
96
+
97
+ def split_message(text: str, limit: int) -> list[str]:
98
+ parts = []
99
+ fence = None
100
+ remaining = text
101
+ while remaining:
102
+ # A chunk that ends mid-code-block gets closed here and reopened at the top of the next one,
103
+ # otherwise the client renders the rest of the message as one runaway code block.
104
+ prefix = f"{fence}\n" if fence else ""
105
+ budget = limit - len(prefix) - len("\n```")
106
+ if len(remaining) <= budget:
107
+ body, remaining = remaining, ""
108
+ else:
109
+ window = remaining[:budget]
110
+ cuts = [pos + len(d) for d in ("\n\n", "\n", " ") if (pos := window.rfind(d)) > 0]
111
+ cut = next((c for c in cuts if c > budget // 2), max(cuts, default=0))
112
+ body, remaining = remaining[: cut or budget], remaining[cut or budget :]
113
+ chunk = prefix + body
114
+ fence = None
115
+ for match in re.finditer(r"^```(\S*)", chunk, re.MULTILINE):
116
+ fence = None if fence else f"```{match.group(1)}"
117
+ if fence:
118
+ chunk += "\n```"
119
+ if parts and not re.sub(r"^```\S*$", "", chunk, flags=re.MULTILINE).strip():
120
+ continue
121
+ parts.append(chunk)
122
+ return parts or [""]
@@ -0,0 +1,42 @@
1
+ from config import session_manager
2
+ from messengers.base import PermissionEntry
3
+
4
+ # Discord allows 25 components per message; the rest is headroom for paging buttons.
5
+ PAGE_SIZE = 20
6
+
7
+
8
+ def list_entries() -> list[PermissionEntry]:
9
+ allowed = session_manager.persistent_allowed
10
+ return [
11
+ PermissionEntry(kind=kind, scope=scope) for kind in ("tools", "commands") for scope in allowed.get(kind) or []
12
+ ]
13
+
14
+
15
+ def revoke(entry: PermissionEntry) -> bool:
16
+ bucket = session_manager.persistent_allowed.get(entry.kind) or []
17
+ if entry.scope not in bucket:
18
+ return False
19
+ bucket.remove(entry.scope)
20
+ session_manager.save_persistent()
21
+ return True
22
+
23
+
24
+ def page_of(entries: list[PermissionEntry], page: int) -> tuple[list[PermissionEntry], int, int]:
25
+ total_pages = max(1, -(-len(entries) // PAGE_SIZE))
26
+ page = max(0, min(page, total_pages - 1))
27
+ start = page * PAGE_SIZE
28
+ return entries[start : start + PAGE_SIZE], page, total_pages
29
+
30
+
31
+ def entry_label(entry: PermissionEntry) -> str:
32
+ suffix = " (tool)" if entry.kind == "tools" else ""
33
+ return f"{entry.scope}{suffix}"
34
+
35
+
36
+ def render_body(entries: list[PermissionEntry], page: int, total_pages: int) -> str:
37
+ if not entries:
38
+ return "No permissions have been allowed yet."
39
+ lines = [f"• {entry_label(e)}" for e in entries]
40
+ if total_pages > 1:
41
+ lines.append(f"\nPage {page + 1} of {total_pages}")
42
+ return "\n".join(lines)
@@ -3,6 +3,7 @@ from typing import Any
3
3
 
4
4
  from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
5
5
  from messengers.registry import get_adapter_for_platform
6
+ from services.discord_helpers import split_message
6
7
  from utils.utils import get_current_model
7
8
 
8
9
 
@@ -17,24 +18,20 @@ async def send_agy_response(
17
18
  adapter = get_adapter_for_platform(session.get("platform", "discord"))
18
19
  session_manager.save_sessions()
19
20
 
20
- parts = [response_text[i : i + MAX_EMBED_LEN] for i in range(0, max(len(response_text), 1), MAX_EMBED_LEN)]
21
- for idx, part in enumerate(parts):
22
- is_last = idx == len(parts) - 1
21
+ session_model = session.get("model")
22
+ model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
23
23
 
24
- if is_last:
24
+ parts = split_message(response_text, MAX_EMBED_LEN)
25
+ for idx, part in enumerate(parts):
26
+ if idx == len(parts) - 1:
25
27
  if not part.strip():
26
28
  continue
29
+ part = f"{part}\n-# 🤖 {model_display}"
27
30
 
28
- session_model = session.get("model")
29
- model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
30
- text_to_send = f"{part}\n-# 🤖 {model_display}"
31
-
32
- status_msg = ctx.get("status_msg") if ctx else None
33
- if status_msg and await adapter.edit_message(status_msg, text_to_send):
34
- continue
35
- await adapter.send_message(thread, text_to_send)
36
- else:
37
- await adapter.send_message(thread, part)
31
+ status_msg = ctx.get("status_msg") if ctx and idx == 0 else None
32
+ if status_msg and await adapter.edit_message(status_msg, part):
33
+ continue
34
+ await adapter.send_message(thread, part)
38
35
 
39
36
  files_to_send = []
40
37
  if conv_id and start_time:
@@ -7,7 +7,7 @@ import discord # only for voice/TTS text cleanup below; messaging goes through
7
7
 
8
8
  from config import MAX_EMBED_LEN, STREAM_RATE_LIMIT_SEC, bot_settings, logger, session_manager
9
9
  from messengers.registry import get_adapter_for_thread
10
- from utils.utils import clean_ansi
10
+ from utils.utils import clean_ansi, split_message
11
11
 
12
12
 
13
13
  def _clear_current_tool(thread_id: str):
@@ -46,7 +46,7 @@ class StreamUpdater:
46
46
  async def split(self):
47
47
  full_text = self.current_text.strip()
48
48
  if full_text:
49
- parts = [full_text[i : i + self.MAX_EMBED_LEN] for i in range(0, len(full_text), self.MAX_EMBED_LEN)]
49
+ parts = split_message(full_text, self.MAX_EMBED_LEN)
50
50
  for idx, part in enumerate(parts):
51
51
  await self._update(part, force_new=(idx > 0))
52
52
  self.status_msg = None
@@ -16,6 +16,7 @@ from services.discord_helpers import (
16
16
  clean_ansi,
17
17
  cleanup_images,
18
18
  handle_image_attachments,
19
+ split_message,
19
20
  )
20
21
 
21
22
  __all__ = [
@@ -33,6 +34,7 @@ __all__ = [
33
34
  "cleanup_images",
34
35
  "build_content_with_images",
35
36
  "clean_ansi",
37
+ "split_message",
36
38
  "check_approval_intent",
37
39
  "get_default_cwd",
38
40
  ]
@@ -18,8 +18,11 @@ process.on('uncaughtException', (err) => {
18
18
 
19
19
  const { registerRoutes } = require('./routes');
20
20
 
21
- if (aglConfig.voice_threshold) {
22
- state.runtime.vadThreshold = parseInt(aglConfig.voice_threshold) || 3000;
21
+ for (const [userId, value] of Object.entries(aglConfig.voice_thresholds || {})) {
22
+ state.vadThresholds.set(userId, parseInt(value));
23
+ }
24
+ for (const [userId, value] of Object.entries(aglConfig.wake_thresholds || {})) {
25
+ state.wakeThresholds.set(userId, parseFloat(value));
23
26
  }
24
27
 
25
28
  const app = express();
@@ -2,12 +2,18 @@ const { EndBehaviorType } = require('@discordjs/voice');
2
2
  const prism = require('prism-media');
3
3
  const { stereoToMono, createWavHeader } = require('./audioUtils');
4
4
  const { googleSTT } = require('./stt');
5
- const { getDetectorForUser, feedPCMToDetector, WAKE_MATCH_THRESHOLD } = require('./wakeword');
5
+ const { getDetectorForUser, feedPCMToDetector, wakeThresholdFor } = require('./wakeword');
6
6
  const { interruptTTS } = require('./tts');
7
7
  const state = require('./state');
8
8
  const { aglConfig } = require('./config');
9
- const { activeStreams, enrollingUsers, isPlaying, wakeWordOptedOut, runtime, isGuildActive } =
10
- state;
9
+ const {
10
+ activeStreams,
11
+ enrollingUsers,
12
+ isPlaying,
13
+ wakeWordOptedOut,
14
+ vadThresholdFor,
15
+ isGuildActive,
16
+ } = state;
11
17
 
12
18
  function setupReceiver(connection, guildId, client) {
13
19
  const receiver = connection.receiver;
@@ -138,9 +144,8 @@ function setupReceiver(connection, guildId, client) {
138
144
  const isBotPlaying = isPlaying.get(guildId) || false;
139
145
 
140
146
  if (!hasInterrupted) {
141
- const dynamicThreshold = isBotPlaying
142
- ? runtime.vadThreshold * 3
143
- : runtime.vadThreshold;
147
+ const baseThreshold = vadThresholdFor(userId);
148
+ const dynamicThreshold = isBotPlaying ? baseThreshold * 3 : baseThreshold;
144
149
  if (rms > dynamicThreshold) {
145
150
  if (interruptTTS(guildId)) {
146
151
  console.log(
@@ -226,15 +231,17 @@ function setupReceiver(connection, guildId, client) {
226
231
  bestDiagScoreName = diagPaddingDetection.getName();
227
232
  }
228
233
 
229
- // 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.
230
- wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
234
+ // rustpotter only emits a detection once its own per-user threshold is met, so any
235
+ // score reaching here is already a pass; the number below is for the log line only.
236
+ const threshold = wakeThresholdFor(userId);
237
+ wakeConfirmed = bestWakeScoreName !== null;
231
238
  matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
232
239
  console.log(
233
240
  wakeConfirmed
234
241
  ? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
235
- `"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
242
+ `"${bestWakeScoreName}", threshold ${threshold})`
236
243
  : `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
237
- `threshold ${WAKE_MATCH_THRESHOLD}; diagnostic-only closeness ` +
244
+ `threshold ${threshold}; diagnostic-only closeness ` +
238
245
  `${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
239
246
  `different scoring config, not directly comparable to the threshold)`,
240
247
  );
@@ -108,6 +108,19 @@ function registerRoutes(app, client) {
108
108
  res.json({ success: true, was_cached: deleted });
109
109
  });
110
110
 
111
+ app.post('/set_wake_threshold', (req, res) => {
112
+ const { user_id, threshold } = req.body;
113
+ if (!user_id || typeof threshold !== 'number') {
114
+ return res.status(400).json({ error: 'user_id and numeric threshold required' });
115
+ }
116
+ state.wakeThresholds.set(user_id, threshold);
117
+ // The threshold is baked into the rustpotter config at build time, so the cached detector
118
+ // has to go with it - otherwise the new value only takes effect after some unrelated reset.
119
+ state.detectorCache.delete(user_id);
120
+ console.log(`[Wake] Threshold for ${user_id} set to ${threshold}`);
121
+ res.json({ success: true });
122
+ });
123
+
111
124
  app.post('/build_wakeword', async (req, res) => {
112
125
  // Builds a .rpw in-process via WakewordRefCreator, instead of shelling out to rustpotter-cli.
113
126
  try {
@@ -139,12 +152,13 @@ function registerRoutes(app, client) {
139
152
  }
140
153
  });
141
154
 
142
- app.post('/set_config', (req, res) => {
143
- const { voice_threshold } = req.body;
144
- if (voice_threshold) {
145
- state.runtime.vadThreshold = voice_threshold;
146
- console.log(`[Config] Updated VAD threshold to ${state.runtime.vadThreshold}`);
155
+ app.post('/set_vad_threshold', (req, res) => {
156
+ const { user_id, threshold } = req.body;
157
+ if (!user_id || typeof threshold !== 'number') {
158
+ return res.status(400).json({ error: 'user_id and numeric threshold required' });
147
159
  }
160
+ state.vadThresholds.set(user_id, threshold);
161
+ console.log(`[Config] VAD threshold for ${user_id} set to ${threshold}`);
148
162
  res.json({ success: true });
149
163
  });
150
164
 
@@ -19,8 +19,18 @@ const suppressNotifyMap = new Map();
19
19
  // userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
20
20
  const detectorCache = new Map();
21
21
 
22
+ // user_id -> wake-word match threshold; absent means DEFAULT_WAKE_THRESHOLD.
23
+ const wakeThresholds = new Map();
24
+
22
25
  // Object property, not a plain `let` - a `let` wouldn't propagate its reassignment across modules.
23
- const runtime = { vadThreshold: 3000 };
26
+ // user_id -> interrupt/VAD RMS threshold; absent means DEFAULT_VAD_THRESHOLD.
27
+ const vadThresholds = new Map();
28
+
29
+ const DEFAULT_VAD_THRESHOLD = 3000;
30
+
31
+ function vadThresholdFor(userId) {
32
+ return vadThresholds.get(userId) ?? DEFAULT_VAD_THRESHOLD;
33
+ }
24
34
 
25
35
  function isGuildActive(guildId) {
26
36
  return Date.now() < (activeUntil.get(guildId) || 0);
@@ -37,6 +47,9 @@ module.exports = {
37
47
  wakeWordOptedOut,
38
48
  suppressNotifyMap,
39
49
  detectorCache,
40
- runtime,
50
+ wakeThresholds,
51
+ vadThresholds,
52
+ DEFAULT_VAD_THRESHOLD,
53
+ vadThresholdFor,
41
54
  isGuildActive,
42
55
  };
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
- const { detectorCache } = require('./state');
4
+ const { detectorCache, wakeThresholds } = require('./state');
5
5
 
6
6
  // Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
7
7
  const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
@@ -21,7 +21,11 @@ function loadRustpotterModule() {
21
21
  }
22
22
 
23
23
  // 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.
24
- const WAKE_MATCH_THRESHOLD = 0.4;
24
+ const DEFAULT_WAKE_THRESHOLD = 0.4;
25
+
26
+ function wakeThresholdFor(userId) {
27
+ return wakeThresholds.get(userId) ?? DEFAULT_WAKE_THRESHOLD;
28
+ }
25
29
 
26
30
  async function getDetectorForUser(userId) {
27
31
  if (detectorCache.has(userId)) return detectorCache.get(userId);
@@ -37,7 +41,7 @@ async function getDetectorForUser(userId) {
37
41
  config.setSampleRate(48000);
38
42
  config.setSampleFormat(mod.SampleFormat.i16);
39
43
  config.setChannels(1);
40
- config.setThreshold(WAKE_MATCH_THRESHOLD);
44
+ config.setThreshold(wakeThresholdFor(userId));
41
45
  config.setAveragedThreshold(0);
42
46
  // Live logs showed genuine attempts peaking above threshold but not sustaining 4 positive-scoring
43
47
  // frames; lowered from 4. STT-side prefix-similarity check is the backstop against false wakes.
@@ -107,7 +111,8 @@ function feedPCMToDetector(entry, chunk) {
107
111
 
108
112
  module.exports = {
109
113
  WAKE_REF_DIR,
110
- WAKE_MATCH_THRESHOLD,
114
+ DEFAULT_WAKE_THRESHOLD,
115
+ wakeThresholdFor,
111
116
  loadRustpotterModule,
112
117
  getDetectorForUser,
113
118
  feedPCMToDetector,