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 +144 -34
- package/bin/setup.js +14 -0
- package/npm-scripts/postinstall.js +5 -19
- package/npm-scripts/register-hook.js +32 -23
- package/package.json +1 -1
- package/src/api/server.py +1 -1
- package/src/cogs/voice_cog.py +44 -12
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) =>
|
|
256
|
+
Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))),
|
|
229
257
|
);
|
|
230
|
-
const pad = (s, 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
|
-
//
|
|
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
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
274
|
-
|
|
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 (!
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
else if (h
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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:
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
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, "
|
|
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")
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
|
357
|
-
self.bot_settings["voice_threshold"] =
|
|
358
|
-
updated.append(f"🔊 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={"
|
|
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})")
|