linkgravity 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -50,6 +50,7 @@ function runPm2(args, silent = true) {
50
50
  }
51
51
 
52
52
  let hasSudoInstructions = false;
53
+ let sudoCommand = null;
53
54
  if (silent && result.stdout && (args[0] === 'startup' || args[0] === 'unstartup')) {
54
55
  const out = result.stdout.toString();
55
56
  const lines = out.split('\n');
@@ -59,11 +60,8 @@ function runPm2(args, silent = true) {
59
60
  line.trim().startsWith('sudo su -c') ||
60
61
  line.includes('sudo ')
61
62
  ) {
62
- console.log(
63
- `\n\n${color.yellow}⚠ Action Required:${color.reset} To complete setup, copy and paste this command into your terminal:\n`,
64
- );
65
- console.log(` ${color.cyan}${line.trim()}${color.reset}\n`);
66
63
  hasSudoInstructions = true;
64
+ sudoCommand = line.trim();
67
65
  }
68
66
  }
69
67
  }
@@ -74,6 +72,27 @@ function runPm2(args, silent = true) {
74
72
  }
75
73
  process.exit(result.status);
76
74
  }
75
+
76
+ return { hasSudoInstructions, sudoCommand };
77
+ }
78
+
79
+ function runSudoStepThen(sudoCommand, successMessage) {
80
+ // stdin stays inherited so sudo can still prompt for a password on the real terminal;
81
+ // stdout/stderr are captured so pm2's own noise only surfaces if this actually fails.
82
+ const result = spawnSync('sh', ['-c', sudoCommand], { stdio: ['inherit', 'pipe', 'pipe'] });
83
+ if (result.status === 0) {
84
+ success(`${successMessage}\n`);
85
+ } else {
86
+ console.log(`${color.yellow}⚠${color.reset} That didn't complete:\n`);
87
+ const output = [
88
+ (result.stdout || '').toString().trim(),
89
+ (result.stderr || '').toString().trim(),
90
+ ]
91
+ .filter(Boolean)
92
+ .join('\n');
93
+ if (output) console.log(output);
94
+ console.log(`\nRun \`lgy enable\` again to retry.\n`);
95
+ }
77
96
  }
78
97
 
79
98
  // Matches a leading timestamp from either loguru or aiohttp's access-log format; only strips the first bracket group so aiohttp's second "[INFO ]" bracket is left alone.
@@ -181,6 +200,10 @@ function verifyStartup() {
181
200
  finish(false);
182
201
  }, 30000);
183
202
 
203
+ const isBenignShutdownNoise = (str) =>
204
+ str.includes('asyncio.exceptions.CancelledError') &&
205
+ str.includes('Application.stop() complete');
206
+
184
207
  const checkLog = (data) => {
185
208
  if (settled) return;
186
209
  const str = data.toString();
@@ -191,7 +214,8 @@ function verifyStartup() {
191
214
  errorDetectionArmed &&
192
215
  (str.includes('Traceback (most recent call last):') ||
193
216
  str.includes('Error:') ||
194
- str.includes('Exception:'))
217
+ str.includes('Exception:')) &&
218
+ !isBenignShutdownNoise(str)
195
219
  ) {
196
220
  console.log(`\n\n${color.yellow}❌ Error detected during startup:${color.reset}`);
197
221
  const errorLines = str
@@ -223,11 +247,18 @@ function formatUptime(pmUptimeMs) {
223
247
  return `${days}d ${hours % 24}h`;
224
248
  }
225
249
 
250
+ function visibleLength(s) {
251
+ return String(s).replace(ANSI_ESCAPE, '').length;
252
+ }
253
+
226
254
  function renderTable(headers, rows) {
227
255
  const widths = headers.map((h, i) =>
228
- Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
256
+ Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))),
229
257
  );
230
- const pad = (s, w) => ` ${String(s).padEnd(w)} `;
258
+ const pad = (s, w) => {
259
+ const str = String(s);
260
+ return ` ${str}${' '.repeat(Math.max(0, w - visibleLength(str)))} `;
261
+ };
231
262
  const sepLine = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
232
263
  const rowLine = (cells) => '│' + cells.map((c, i) => pad(c, widths[i])).join('│') + '│';
233
264
 
@@ -237,7 +268,7 @@ function renderTable(headers, rows) {
237
268
  return lines.join('\n');
238
269
  }
239
270
 
240
- // Mirrors src/config.py's AGY_BIN resolution (AGY_BIN_PATH env var, else ~/.local/bin/agy), plus a PATH fallback for installs that don't use the default location.
271
+ // Must stay in sync with config.py's AGY_BIN resolution.
241
272
  function findAgyBin() {
242
273
  const envPath = process.env.AGY_BIN_PATH;
243
274
  if (envPath && fs.existsSync(envPath)) return envPath;
@@ -257,28 +288,60 @@ function getPm2Proc() {
257
288
  const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
258
289
  if (jlist.status !== 0) return null;
259
290
 
260
- // pm2 sometimes prints a version-mismatch banner ("In-memory PM2 is
261
- // out-of-date...") before the JSON when the CLI version differs from
262
- // the already-running daemon's - skip past it instead of choking on it.
263
- const out = jlist.stdout.toString();
264
- const jsonStart = out.indexOf('[');
265
- if (jsonStart === -1) {
266
- console.error(
267
- `${color.yellow}⚠${color.reset} Couldn't read pm2 status (unexpected output, no JSON found). Raw output:\n${out.trim()}`,
268
- );
269
- return null;
291
+ // pm2 can print noise before the real JSON (version banners, ANSI escapes, daemon-spawn logs) that
292
+ // can itself contain '[' - try every '[' left-to-right and keep the first one that parses as JSON.
293
+ const out = jlist.stdout.toString().replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
294
+ for (let i = 0; i < out.length; i++) {
295
+ if (out[i] !== '[') continue;
296
+ try {
297
+ const procs = JSON.parse(out.slice(i));
298
+ if (Array.isArray(procs)) return procs.find((p) => p.name === LGY_PM2_NAME) || null;
299
+ } catch (e) {}
270
300
  }
271
301
 
302
+ console.error(
303
+ `${color.yellow}⚠${color.reset} Couldn't read pm2 status (no valid JSON found in its output). Raw output:\n${out.trim()}`,
304
+ );
305
+ return null;
306
+ }
307
+
308
+ // Best-effort: pm2 has no API for "is this registered to start on boot", so this checks the OS directly and returns null (unknown) if that check itself isn't available.
309
+ function isAutostartEnabled() {
310
+ if (isWin) return null;
272
311
  try {
273
- const procs = JSON.parse(out.slice(jsonStart));
274
- return procs.find((p) => p.name === LGY_PM2_NAME) || null;
312
+ const user = os.userInfo().username;
313
+ if (process.platform === 'linux') {
314
+ const check = spawnSync('systemctl', ['is-enabled', `pm2-${user}`], { stdio: 'pipe' });
315
+ if (check.error) return null;
316
+ const out = check.stdout.toString().trim();
317
+ if (out === 'enabled') return true;
318
+ if (out === 'disabled' || check.status !== 0) return false;
319
+ return null;
320
+ }
321
+ if (process.platform === 'darwin') {
322
+ const plistPath = path.join(
323
+ os.homedir(),
324
+ 'Library',
325
+ 'LaunchAgents',
326
+ `pm2.${user}.plist`,
327
+ );
328
+ return fs.existsSync(plistPath);
329
+ }
275
330
  } catch (e) {
276
- console.error(
277
- `${color.yellow}⚠${color.reset} Couldn't parse pm2 status output: ${e.message}. ` +
278
- `If you saw a version-mismatch warning above, try ${color.cyan}npx pm2 update${color.reset}.`,
279
- );
280
331
  return null;
281
332
  }
333
+ return null;
334
+ }
335
+
336
+ function checkLatestVersionFast(currentVersion) {
337
+ const view = spawnSync('npm', ['view', 'linkgravity', 'version'], {
338
+ stdio: 'pipe',
339
+ timeout: 3000,
340
+ });
341
+ if (view.error || view.status !== 0) return null;
342
+ const latest = view.stdout.toString().trim();
343
+ if (!latest) return null;
344
+ return { latest, upToDate: latest === currentVersion };
282
345
  }
283
346
 
284
347
  if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
@@ -407,6 +470,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
407
470
  }
408
471
  }
409
472
 
473
+ const daemonAlive = !!proc && proc.pm2_env.status === 'online';
474
+
410
475
  const rows = Object.entries(PLATFORMS).map(([key, def]) => {
411
476
  const { enabled } = platformState(key, settings);
412
477
  const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
@@ -414,17 +479,54 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
414
479
  let connection = '-';
415
480
  let since = '-';
416
481
  if (enabled) {
417
- if (!h) connection = 'unknown';
418
- else if (h.status === 'running') connection = 'connected';
419
- else if (h.status === 'connecting') connection = 'connecting...';
420
- else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
421
- else if (h.status === 'stopped') connection = 'stopped';
422
- if (h && h.at) since = formatUptime(new Date(h.at).getTime());
482
+ if (!daemonAlive) {
483
+ // health.json freezes at its last value if the process was killed outright (kill -9, OOM, reboot) instead of exiting cleanly, so don't trust it once pm2 confirms the daemon isn't actually running.
484
+ connection = `${color.red}down${color.reset}`;
485
+ since = h && h.at ? `last seen ${formatUptime(new Date(h.at).getTime())} ago` : '-';
486
+ } else if (!h) {
487
+ connection = 'unknown';
488
+ } else {
489
+ if (h.status === 'running') connection = `${color.green}connected${color.reset}`;
490
+ else if (h.status === 'connecting') connection = 'connecting...';
491
+ else if (h.status === 'error')
492
+ connection = `${color.red}error: ${h.detail || '?'}${color.reset}`;
493
+ else if (h.status === 'stopped') connection = 'stopped';
494
+ if (h.at) since = formatUptime(new Date(h.at).getTime());
495
+ }
423
496
  }
424
497
  return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
425
498
  });
426
499
  console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
427
500
  console.log();
501
+
502
+ const pkg = require('../package.json');
503
+ const versionCheck = checkLatestVersionFast(pkg.version);
504
+ const versionLine =
505
+ versionCheck === null
506
+ ? pkg.version
507
+ : versionCheck.upToDate
508
+ ? `${pkg.version} (up to date)`
509
+ : `${pkg.version} ${color.yellow}(v${versionCheck.latest} available - run \`lgy update\`)${color.reset}`;
510
+
511
+ const agyPath = findAgyBin();
512
+ const agyLine = agyPath ? `found (${agyPath})` : `${color.yellow}not found${color.reset}`;
513
+
514
+ const autostart = isAutostartEnabled();
515
+ const autostartLine =
516
+ autostart === null
517
+ ? 'unknown'
518
+ : autostart
519
+ ? `${color.green}enabled${color.reset}`
520
+ : 'disabled';
521
+
522
+ for (const [label, value] of [
523
+ ['version', versionLine],
524
+ ['agy', agyLine],
525
+ ['autostart', autostartLine],
526
+ ]) {
527
+ console.log(`${label.padEnd(10)} ${value}`);
528
+ }
529
+ console.log();
428
530
  } else if (cmd === 'enable') {
429
531
  if (isWin) {
430
532
  console.log(
@@ -434,9 +536,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
434
536
  process.exit(1);
435
537
  }
436
538
  info('Registering LinkGravity to start on system boot...');
437
- runPm2(['startup']);
539
+ const { hasSudoInstructions, sudoCommand } = runPm2(['startup']);
438
540
  runPm2(['save']);
439
- success('Auto-start configuration saved.\n');
541
+ if (hasSudoInstructions && sudoCommand) {
542
+ runSudoStepThen(sudoCommand, 'Auto-start configuration saved.');
543
+ } else {
544
+ success('Auto-start configuration saved.\n');
545
+ }
440
546
  } else if (cmd === 'disable') {
441
547
  if (isWin) {
442
548
  console.log(
@@ -446,9 +552,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
446
552
  process.exit(1);
447
553
  }
448
554
  info('Removing LinkGravity from system boot...');
449
- runPm2(['unstartup']);
555
+ const { hasSudoInstructions, sudoCommand } = runPm2(['unstartup']);
450
556
  runPm2(['save']);
451
- success('Auto-start configuration removed.\n');
557
+ if (hasSudoInstructions && sudoCommand) {
558
+ runSudoStepThen(sudoCommand, 'Auto-start configuration removed.');
559
+ } else {
560
+ success('Auto-start configuration removed.\n');
561
+ }
452
562
  } else if (cmd === 'setup' || cmd === 'init') {
453
563
  const runSetup = require('./setup');
454
564
  runSetup().catch((err) => {
package/bin/setup.js CHANGED
@@ -446,6 +446,20 @@ async function runSetup() {
446
446
  console.log();
447
447
  p.intro(`${color.cyan}▶ LinkGravity Setup Wizard${color.reset}`);
448
448
 
449
+ const registerHook = require('../npm-scripts/register-hook');
450
+ if (!registerHook.isHookRegistered()) {
451
+ const consent = await p.confirm({
452
+ message:
453
+ "Register LinkGravity's approval hook with agy? (required for tool-call approval - lets LinkGravity gate agy's actions through Discord/Telegram/Slack)",
454
+ initialValue: true,
455
+ });
456
+ if (p.isCancel(consent)) {
457
+ p.cancel('Setup cancelled.');
458
+ process.exit(0);
459
+ }
460
+ if (consent) registerHook({ allowFirstTimeCreate: true });
461
+ }
462
+
449
463
  while (true) {
450
464
  const settings = getSettings();
451
465
  const options = Object.entries(PLATFORMS).map(([key, def]) => {
@@ -9,43 +9,29 @@ console.log(` (in ${path.join(workspaceDir, 'venv')} - not inside this install
9
9
  console.log(' package updates/reinstalls and works the same whether this is a global');
10
10
  console.log(' `npm install -g linkgravity` or a local dev clone.)');
11
11
 
12
- // Use python on Windows, python3 on Mac/Linux - this is the *system*
13
- // python used only to create the venv below; once it exists, every
14
- // other script (this one included) goes through venv-paths.js instead.
12
+ // System python (not the venv's) - only used to create the venv below; every other
13
+ // script goes through venv-paths.js instead.
15
14
  const isWin = os.platform() === 'win32';
16
15
  const pyCmd = isWin ? 'python' : 'python3';
17
16
 
18
17
  async function main() {
19
18
  try {
20
- // 1. Create Python virtual environment (venv) in the fixed
21
- // workspace dir, not in cwd - see venv-paths.js for why.
19
+ // Fixed workspace dir, not cwd - see venv-paths.js for why.
22
20
  fs.mkdirSync(workspaceDir, { recursive: true });
23
21
  execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
24
22
 
25
- // 2. Install Python packages
26
23
  console.log('📦 Installing Python dependencies...');
27
24
  execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit' });
28
25
 
29
- // 3. Install Node.js voice service packages (includes
30
- // rustpotter-web, which handles both wake-word detection AND
31
- // building .rpw reference files in-process - no separate
32
- // binary download needed for either).
33
26
  console.log('🎙️ Installing Voice Service dependencies...');
34
27
  execSync('npm install', {
35
28
  stdio: 'inherit',
36
29
  cwd: path.join(__dirname, '..', 'voice-service'),
37
30
  });
38
31
 
39
- // 4. Register this install's location with agy as its
40
- // tool-approval hook (~/.gemini/config/hooks.json) - always
41
- // re-run so the registered path self-heals if this checkout
42
- // gets moved/renamed later, instead of silently going stale.
43
- // Guarded on its own: agy may not be installed/configured yet
44
- // on a brand new machine, and that shouldn't fail the rest of
45
- // the install - just means the hook needs registering once agy
46
- // itself is set up (re-running `npm install` after does it).
32
+ // Own try/catch: agy may not be installed yet on a brand new machine, and that shouldn't fail the rest of the install.
47
33
  try {
48
- require('./register-hook')();
34
+ require('./register-hook')({ allowFirstTimeCreate: false });
49
35
  } catch (err) {
50
36
  console.warn(
51
37
  `⚠️ Couldn't register the agy tool-approval hook: ${err.message.split('\n')[0]}`,
@@ -1,11 +1,5 @@
1
1
  'use strict';
2
- // Registers/fixes this project's agy hooks in ~/.gemini/config/hooks.json
3
- // (schema: https://antigravity.google/docs/hooks). Runs on every `npm
4
- // install` so paths stay correct if this checkout moves, identifying its
5
- // own entries by `name` (not command string) so a stale path gets fixed
6
- // in place rather than duplicated, leaving any other configured hooks
7
- // untouched. Only ever overwrites `command` - never `type`/`timeout`,
8
- // which the user may have customized - logging old/new values on change.
2
+ // Only ever overwrites `command` on an existing entry - never `type`/`timeout`, which the user may have customized.
9
3
  const fs = require('fs');
10
4
  const path = require('path');
11
5
  const os = require('os');
@@ -13,11 +7,9 @@ const { repoRoot, python: venvPython } = require('./venv-paths');
13
7
 
14
8
  const hooksJsonPath = path.join(os.homedir(), '.gemini', 'config', 'hooks.json');
15
9
 
16
- // PreToolUse: tool-approval gate, matcher-wrapped per schema.
17
- // Stop: fires when agy is about to end a turn; if fullyIdle is false
18
- // (an async run_command is still in flight), stop_hook.py tells agy to
19
- // keep going instead of ending the turn with that result lost. Flat
20
- // array per schema (no matcher - nothing to match tool names against).
10
+ // PreToolUse/Stop meanings are agy's own hook contract: Stop fires when agy is about to
11
+ // end a turn, and if fullyIdle is false (an async run_command still in flight), stop_hook.py
12
+ // tells agy to keep going instead of losing that result.
21
13
  const HOOK_REGISTRATIONS = [
22
14
  {
23
15
  eventType: 'PreToolUse',
@@ -35,9 +27,6 @@ const HOOK_REGISTRATIONS = [
35
27
  },
36
28
  ];
37
29
 
38
- // Hooks retired from HOOK_REGISTRATIONS but listed here so an existing
39
- // install actually gets the stale entry removed from hooks.json, instead
40
- // of a zombie entry pointing at a script that no longer exists.
41
30
  const RETIRED_HOOKS = [{ eventType: 'PreInvocation', name: 'wait-ms-before-async-reminder' }];
42
31
 
43
32
  function loadHooksConfig() {
@@ -47,9 +36,6 @@ function loadHooksConfig() {
47
36
  try {
48
37
  return JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
49
38
  } catch (err) {
50
- // Back up rather than silently clobbering whatever was there -
51
- // it may have other hooks configured that have nothing to do
52
- // with this project.
53
39
  const backupPath = `${hooksJsonPath}.corrupted-${Date.now()}`;
54
40
  fs.copyFileSync(hooksJsonPath, backupPath);
55
41
  console.warn(
@@ -76,7 +62,6 @@ function findHookEntry(config, eventType, name, wrapInMatcher) {
76
62
  }
77
63
  return hookEntry;
78
64
  }
79
- // Flat array (Stop/PreInvocation/PostInvocation) - no matcher wrapper.
80
65
  let hookEntry = config.hooks[eventType].find((h) => h.name === name);
81
66
  if (!hookEntry) {
82
67
  hookEntry = { name };
@@ -94,8 +79,6 @@ function removeRetiredHooks(config) {
94
79
  const nextArr = [];
95
80
  for (const entry of arr) {
96
81
  if (Array.isArray(entry.hooks)) {
97
- // Matcher-wrapped shape - drop the matcher block too if
98
- // nothing's left in it.
99
82
  const beforeLen = entry.hooks.length;
100
83
  entry.hooks = entry.hooks.filter((h) => h.name !== retired.name);
101
84
  if (entry.hooks.length !== beforeLen) {
@@ -106,7 +89,6 @@ function removeRetiredHooks(config) {
106
89
  }
107
90
  if (entry.hooks.length > 0) nextArr.push(entry);
108
91
  } else {
109
- // Flat shape.
110
92
  if (entry.name === retired.name) {
111
93
  removedAny = true;
112
94
  console.log(
@@ -122,9 +104,24 @@ function removeRetiredHooks(config) {
122
104
  return removedAny;
123
105
  }
124
106
 
125
- function registerHook() {
107
+ function registerHook({ allowFirstTimeCreate = true } = {}) {
126
108
  const config = loadHooksConfig();
127
109
  config.hooks = config.hooks || {};
110
+
111
+ const isFirstTime = HOOK_REGISTRATIONS.some((reg) => {
112
+ const arr = config.hooks[reg.eventType] || [];
113
+ return reg.wrapInMatcher
114
+ ? !arr.some((m) => (m.hooks || []).some((h) => h.name === reg.name))
115
+ : !arr.some((h) => h.name === reg.name);
116
+ });
117
+
118
+ if (isFirstTime && !allowFirstTimeCreate) {
119
+ console.log(
120
+ "ℹ️ LinkGravity's Discord/Telegram/Slack approval hook isn't registered with agy yet - run `lgy setup` to enable it.",
121
+ );
122
+ return;
123
+ }
124
+
128
125
  let wroteChange = false;
129
126
  let backedUp = false;
130
127
 
@@ -175,7 +172,19 @@ function registerHook() {
175
172
  }
176
173
  }
177
174
 
175
+ function isHookRegistered() {
176
+ const config = loadHooksConfig();
177
+ config.hooks = config.hooks || {};
178
+ return HOOK_REGISTRATIONS.every((reg) => {
179
+ const arr = config.hooks[reg.eventType] || [];
180
+ return reg.wrapInMatcher
181
+ ? arr.some((m) => (m.hooks || []).some((h) => h.name === reg.name))
182
+ : arr.some((h) => h.name === reg.name);
183
+ });
184
+ }
185
+
178
186
  module.exports = registerHook;
187
+ module.exports.isHookRegistered = isHookRegistered;
179
188
 
180
189
  if (require.main === module) {
181
190
  registerHook();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
package/src/api/server.py CHANGED
@@ -41,6 +41,6 @@ async def setup_webhook_server(bot):
41
41
 
42
42
  runner = web.AppRunner(app)
43
43
  await runner.setup()
44
- site = web.TCPSite(runner, "0.0.0.0", 18080)
44
+ site = web.TCPSite(runner, "127.0.0.1", 18080)
45
45
  await site.start()
46
46
  logger.info("Webhook / STT Server started on port 18080")
@@ -92,7 +92,7 @@ class VoiceCog(commands.Cog):
92
92
  opts.append(app_commands.Choice(name=str(v), value=v))
93
93
  return opts
94
94
 
95
- async def threshold_autocomplete(
95
+ async def interrupt_threshold_autocomplete(
96
96
  self, interaction: discord.Interaction, current: str
97
97
  ) -> list[app_commands.Choice[int]]:
98
98
  current_val = int(self.bot_settings.get("voice_threshold", 3000))
@@ -105,6 +105,19 @@ class VoiceCog(commands.Cog):
105
105
  opts.append(app_commands.Choice(name=str(v), value=v))
106
106
  return opts
107
107
 
108
+ async def wake_sensitivity_autocomplete(
109
+ self, interaction: discord.Interaction, current: str
110
+ ) -> list[app_commands.Choice[float]]:
111
+ current_val = float(self.bot_settings.get("wake_threshold", 0.4))
112
+ opts = []
113
+ if str(current_val) in current or not current:
114
+ opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
115
+
116
+ for v in [0.2, 0.3, 0.4, 0.5, 0.6]:
117
+ if v != current_val and len(opts) < 25:
118
+ opts.append(app_commands.Choice(name=str(v), value=v))
119
+ return opts
120
+
108
121
  async def tts_voice_autocomplete(
109
122
  self, interaction: discord.Interaction, current: str
110
123
  ) -> list[app_commands.Choice[str]]:
@@ -282,12 +295,13 @@ class VoiceCog(commands.Cog):
282
295
 
283
296
  @app_commands.command(
284
297
  name="sound",
285
- description="Configure voice settings (Wake word, active time, threshold, TTS voice/speed, TTS on/off)",
298
+ description="Configure voice settings (Wake word, active time, thresholds, TTS voice/speed, TTS on/off)",
286
299
  )
287
300
  @app_commands.describe(
288
301
  wake_word="The single word/phrase that wakes the bot (recorded in your voice)",
289
302
  active_times="Duration in seconds the bot stays awake",
290
- threshold="Voice volume sensitivity (1000~10000)",
303
+ interrupt_threshold="Mic volume that interrupts (barges into) TTS playback (1000~10000)",
304
+ wake_sensitivity="Wake word match sensitivity (0.1~0.9, lower = easier to trigger but more false wakes)",
291
305
  tts_voice="Select the AI TTS voice",
292
306
  tts_enabled="Turn Text-to-Speech ON or OFF",
293
307
  tts_speed="TTS playback speed multiplier, e.g. 1.3 for 1.3x (0.5~2.0)",
@@ -295,7 +309,8 @@ class VoiceCog(commands.Cog):
295
309
  )
296
310
  @app_commands.autocomplete(
297
311
  active_times=active_times_autocomplete,
298
- threshold=threshold_autocomplete,
312
+ interrupt_threshold=interrupt_threshold_autocomplete,
313
+ wake_sensitivity=wake_sensitivity_autocomplete,
299
314
  tts_voice=tts_voice_autocomplete,
300
315
  tts_enabled=tts_enabled_autocomplete,
301
316
  tts_speed=tts_speed_autocomplete,
@@ -306,7 +321,8 @@ class VoiceCog(commands.Cog):
306
321
  interaction: discord.Interaction,
307
322
  wake_word: str = None,
308
323
  active_times: int = None,
309
- threshold: int = None,
324
+ interrupt_threshold: int = None,
325
+ wake_sensitivity: float = None,
310
326
  tts_voice: str = None,
311
327
  tts_enabled: str = None,
312
328
  tts_speed: float = None,
@@ -321,7 +337,8 @@ class VoiceCog(commands.Cog):
321
337
  if (
322
338
  wake_word is None
323
339
  and active_times is None
324
- and threshold is None
340
+ and interrupt_threshold is None
341
+ and wake_sensitivity is None
325
342
  and tts_voice is None
326
343
  and tts_enabled is None
327
344
  and tts_speed is None
@@ -329,7 +346,8 @@ class VoiceCog(commands.Cog):
329
346
  ):
330
347
  curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
331
348
  curr_timer = self.bot_settings.get("active_timer", 60)
332
- curr_thresh = self.bot_settings.get("voice_threshold", 3000)
349
+ curr_interrupt_thresh = self.bot_settings.get("voice_threshold", 3000)
350
+ curr_wake_sens = self.bot_settings.get("wake_threshold", 0.4)
333
351
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
334
352
  curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
335
353
  curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
@@ -339,7 +357,8 @@ class VoiceCog(commands.Cog):
339
357
  embed.add_field(name="🎙️ Wake Word", value=f"`{curr_wake}`", inline=False)
340
358
  embed.add_field(name="🔒 Wake Word Required", value=f"`{'ON' if curr_required else 'OFF'}`", inline=False)
341
359
  embed.add_field(name="⏱️ Active Time", value=f"`{curr_timer}s`", inline=False)
342
- embed.add_field(name="🔊 Threshold", value=f"`{curr_thresh}`", inline=False)
360
+ embed.add_field(name="🎯 Wake Sensitivity", value=f"`{curr_wake_sens}`", inline=False)
361
+ embed.add_field(name="🔊 Interrupt Threshold", value=f"`{curr_interrupt_thresh}`", inline=False)
343
362
  embed.add_field(name="🗣️ TTS Voice", value=f"`{curr_tts}`", inline=False)
344
363
  embed.add_field(name="🔊 TTS Enabled", value=f"`{curr_tts_on}`", inline=False)
345
364
  embed.add_field(name="⏩ TTS Speed", value=f"`{curr_tts_speed}x`", inline=False)
@@ -353,12 +372,25 @@ class VoiceCog(commands.Cog):
353
372
  if active_times is not None:
354
373
  self.bot_settings["active_timer"] = active_times
355
374
  updated.append(f"⏱️ Active Timer: `{active_times}s`")
356
- if threshold is not None:
357
- self.bot_settings["voice_threshold"] = threshold
358
- updated.append(f"🔊 Threshold: `{threshold}`")
375
+ if interrupt_threshold is not None:
376
+ self.bot_settings["voice_threshold"] = interrupt_threshold
377
+ updated.append(f"🔊 Interrupt Threshold: `{interrupt_threshold}`")
378
+ try:
379
+ 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}")
383
+ 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
+ if wake_sensitivity is not None:
388
+ clamped_wake = max(0.05, min(0.95, wake_sensitivity))
389
+ self.bot_settings["wake_threshold"] = clamped_wake
390
+ updated.append(f"🎯 Wake Sensitivity: `{clamped_wake}`")
359
391
  try:
360
392
  async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
361
- await session.post(f"{NODE_VOICE_API}/set_config", json={"voice_threshold": threshold})
393
+ await session.post(f"{NODE_VOICE_API}/set_config", json={"wake_threshold": clamped_wake})
362
394
  except aiohttp.ClientError as e:
363
395
  self.logger.warning(f"Node.js sync failed for {interaction.guild_id}: {e}")
364
396
  updated.append(f"(⚠️ Node.js Sync Failed: {e})")