linkgravity 1.5.4 → 1.5.5
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 +138 -33
- package/package.json +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.
|
|
@@ -223,11 +242,18 @@ function formatUptime(pmUptimeMs) {
|
|
|
223
242
|
return `${days}d ${hours % 24}h`;
|
|
224
243
|
}
|
|
225
244
|
|
|
245
|
+
function visibleLength(s) {
|
|
246
|
+
return String(s).replace(ANSI_ESCAPE, '').length;
|
|
247
|
+
}
|
|
248
|
+
|
|
226
249
|
function renderTable(headers, rows) {
|
|
227
250
|
const widths = headers.map((h, i) =>
|
|
228
|
-
Math.max(h.length, ...rows.map((r) =>
|
|
251
|
+
Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))),
|
|
229
252
|
);
|
|
230
|
-
const pad = (s, w) =>
|
|
253
|
+
const pad = (s, w) => {
|
|
254
|
+
const str = String(s);
|
|
255
|
+
return ` ${str}${' '.repeat(Math.max(0, w - visibleLength(str)))} `;
|
|
256
|
+
};
|
|
231
257
|
const sepLine = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r;
|
|
232
258
|
const rowLine = (cells) => '│' + cells.map((c, i) => pad(c, widths[i])).join('│') + '│';
|
|
233
259
|
|
|
@@ -237,7 +263,7 @@ function renderTable(headers, rows) {
|
|
|
237
263
|
return lines.join('\n');
|
|
238
264
|
}
|
|
239
265
|
|
|
240
|
-
//
|
|
266
|
+
// Must stay in sync with config.py's AGY_BIN resolution.
|
|
241
267
|
function findAgyBin() {
|
|
242
268
|
const envPath = process.env.AGY_BIN_PATH;
|
|
243
269
|
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
@@ -257,28 +283,60 @@ function getPm2Proc() {
|
|
|
257
283
|
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
258
284
|
if (jlist.status !== 0) return null;
|
|
259
285
|
|
|
260
|
-
// pm2
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
)
|
|
269
|
-
return null;
|
|
286
|
+
// pm2 can print noise before the real JSON (version banners, ANSI escapes, daemon-spawn logs) that
|
|
287
|
+
// can itself contain '[' - try every '[' left-to-right and keep the first one that parses as JSON.
|
|
288
|
+
const out = jlist.stdout.toString().replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
|
|
289
|
+
for (let i = 0; i < out.length; i++) {
|
|
290
|
+
if (out[i] !== '[') continue;
|
|
291
|
+
try {
|
|
292
|
+
const procs = JSON.parse(out.slice(i));
|
|
293
|
+
if (Array.isArray(procs)) return procs.find((p) => p.name === LGY_PM2_NAME) || null;
|
|
294
|
+
} catch (e) {}
|
|
270
295
|
}
|
|
271
296
|
|
|
297
|
+
console.error(
|
|
298
|
+
`${color.yellow}⚠${color.reset} Couldn't read pm2 status (no valid JSON found in its output). Raw output:\n${out.trim()}`,
|
|
299
|
+
);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 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.
|
|
304
|
+
function isAutostartEnabled() {
|
|
305
|
+
if (isWin) return null;
|
|
272
306
|
try {
|
|
273
|
-
const
|
|
274
|
-
|
|
307
|
+
const user = os.userInfo().username;
|
|
308
|
+
if (process.platform === 'linux') {
|
|
309
|
+
const check = spawnSync('systemctl', ['is-enabled', `pm2-${user}`], { stdio: 'pipe' });
|
|
310
|
+
if (check.error) return null;
|
|
311
|
+
const out = check.stdout.toString().trim();
|
|
312
|
+
if (out === 'enabled') return true;
|
|
313
|
+
if (out === 'disabled' || check.status !== 0) return false;
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
if (process.platform === 'darwin') {
|
|
317
|
+
const plistPath = path.join(
|
|
318
|
+
os.homedir(),
|
|
319
|
+
'Library',
|
|
320
|
+
'LaunchAgents',
|
|
321
|
+
`pm2.${user}.plist`,
|
|
322
|
+
);
|
|
323
|
+
return fs.existsSync(plistPath);
|
|
324
|
+
}
|
|
275
325
|
} 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
326
|
return null;
|
|
281
327
|
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function checkLatestVersionFast(currentVersion) {
|
|
332
|
+
const view = spawnSync('npm', ['view', 'linkgravity', 'version'], {
|
|
333
|
+
stdio: 'pipe',
|
|
334
|
+
timeout: 3000,
|
|
335
|
+
});
|
|
336
|
+
if (view.error || view.status !== 0) return null;
|
|
337
|
+
const latest = view.stdout.toString().trim();
|
|
338
|
+
if (!latest) return null;
|
|
339
|
+
return { latest, upToDate: latest === currentVersion };
|
|
282
340
|
}
|
|
283
341
|
|
|
284
342
|
if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
@@ -407,6 +465,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
407
465
|
}
|
|
408
466
|
}
|
|
409
467
|
|
|
468
|
+
const daemonAlive = !!proc && proc.pm2_env.status === 'online';
|
|
469
|
+
|
|
410
470
|
const rows = Object.entries(PLATFORMS).map(([key, def]) => {
|
|
411
471
|
const { enabled } = platformState(key, settings);
|
|
412
472
|
const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
|
|
@@ -414,17 +474,54 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
414
474
|
let connection = '-';
|
|
415
475
|
let since = '-';
|
|
416
476
|
if (enabled) {
|
|
417
|
-
if (!
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
else if (h
|
|
422
|
-
|
|
477
|
+
if (!daemonAlive) {
|
|
478
|
+
// 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.
|
|
479
|
+
connection = `${color.red}down${color.reset}`;
|
|
480
|
+
since = h && h.at ? `last seen ${formatUptime(new Date(h.at).getTime())} ago` : '-';
|
|
481
|
+
} else if (!h) {
|
|
482
|
+
connection = 'unknown';
|
|
483
|
+
} else {
|
|
484
|
+
if (h.status === 'running') connection = `${color.green}connected${color.reset}`;
|
|
485
|
+
else if (h.status === 'connecting') connection = 'connecting...';
|
|
486
|
+
else if (h.status === 'error')
|
|
487
|
+
connection = `${color.red}error: ${h.detail || '?'}${color.reset}`;
|
|
488
|
+
else if (h.status === 'stopped') connection = 'stopped';
|
|
489
|
+
if (h.at) since = formatUptime(new Date(h.at).getTime());
|
|
490
|
+
}
|
|
423
491
|
}
|
|
424
492
|
return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
|
|
425
493
|
});
|
|
426
494
|
console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
|
|
427
495
|
console.log();
|
|
496
|
+
|
|
497
|
+
const pkg = require('../package.json');
|
|
498
|
+
const versionCheck = checkLatestVersionFast(pkg.version);
|
|
499
|
+
const versionLine =
|
|
500
|
+
versionCheck === null
|
|
501
|
+
? pkg.version
|
|
502
|
+
: versionCheck.upToDate
|
|
503
|
+
? `${pkg.version} (up to date)`
|
|
504
|
+
: `${pkg.version} ${color.yellow}(v${versionCheck.latest} available - run \`lgy update\`)${color.reset}`;
|
|
505
|
+
|
|
506
|
+
const agyPath = findAgyBin();
|
|
507
|
+
const agyLine = agyPath ? `found (${agyPath})` : `${color.yellow}not found${color.reset}`;
|
|
508
|
+
|
|
509
|
+
const autostart = isAutostartEnabled();
|
|
510
|
+
const autostartLine =
|
|
511
|
+
autostart === null
|
|
512
|
+
? 'unknown'
|
|
513
|
+
: autostart
|
|
514
|
+
? `${color.green}enabled${color.reset}`
|
|
515
|
+
: 'disabled';
|
|
516
|
+
|
|
517
|
+
for (const [label, value] of [
|
|
518
|
+
['version', versionLine],
|
|
519
|
+
['agy', agyLine],
|
|
520
|
+
['autostart', autostartLine],
|
|
521
|
+
]) {
|
|
522
|
+
console.log(`${label.padEnd(10)} ${value}`);
|
|
523
|
+
}
|
|
524
|
+
console.log();
|
|
428
525
|
} else if (cmd === 'enable') {
|
|
429
526
|
if (isWin) {
|
|
430
527
|
console.log(
|
|
@@ -434,9 +531,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
434
531
|
process.exit(1);
|
|
435
532
|
}
|
|
436
533
|
info('Registering LinkGravity to start on system boot...');
|
|
437
|
-
runPm2(['startup']);
|
|
534
|
+
const { hasSudoInstructions, sudoCommand } = runPm2(['startup']);
|
|
438
535
|
runPm2(['save']);
|
|
439
|
-
|
|
536
|
+
if (hasSudoInstructions && sudoCommand) {
|
|
537
|
+
runSudoStepThen(sudoCommand, 'Auto-start configuration saved.');
|
|
538
|
+
} else {
|
|
539
|
+
success('Auto-start configuration saved.\n');
|
|
540
|
+
}
|
|
440
541
|
} else if (cmd === 'disable') {
|
|
441
542
|
if (isWin) {
|
|
442
543
|
console.log(
|
|
@@ -446,9 +547,13 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
446
547
|
process.exit(1);
|
|
447
548
|
}
|
|
448
549
|
info('Removing LinkGravity from system boot...');
|
|
449
|
-
runPm2(['unstartup']);
|
|
550
|
+
const { hasSudoInstructions, sudoCommand } = runPm2(['unstartup']);
|
|
450
551
|
runPm2(['save']);
|
|
451
|
-
|
|
552
|
+
if (hasSudoInstructions && sudoCommand) {
|
|
553
|
+
runSudoStepThen(sudoCommand, 'Auto-start configuration removed.');
|
|
554
|
+
} else {
|
|
555
|
+
success('Auto-start configuration removed.\n');
|
|
556
|
+
}
|
|
452
557
|
} else if (cmd === 'setup' || cmd === 'init') {
|
|
453
558
|
const runSetup = require('./setup');
|
|
454
559
|
runSetup().catch((err) => {
|
package/package.json
CHANGED
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})")
|