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 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) => String(r[i]).length)),
251
+ Math.max(h.length, ...rows.map((r) => visibleLength(r[i]))),
229
252
  );
230
- const pad = (s, w) => ` ${String(s).padEnd(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
- // 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.
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 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;
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 procs = JSON.parse(out.slice(jsonStart));
274
- return procs.find((p) => p.name === LGY_PM2_NAME) || null;
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 (!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());
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
- success('Auto-start configuration saved.\n');
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
- success('Auto-start configuration removed.\n');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
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",
@@ -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})")