linkgravity 1.7.3 → 1.7.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
@@ -14,7 +14,7 @@ const {
14
14
  LGY_SCRIPT_PATH,
15
15
  } = require('./platforms');
16
16
 
17
- const { python: pythonExe, isWin } = require('../npm-scripts/venv-paths');
17
+ const { daemonPython, isWin } = require('../npm-scripts/venv-paths');
18
18
  const { isEnvironmentReady } = require('../npm-scripts/ensure-env');
19
19
 
20
20
  const cmd = process.argv[2];
@@ -203,15 +203,34 @@ function verifyStartup() {
203
203
  cwd: PM2_CWD,
204
204
  });
205
205
 
206
+ const baseline = getPm2Proc()?.pm2_env?.restart_time ?? 0;
207
+
206
208
  let settled = false;
207
209
  const finish = (ok) => {
208
210
  if (settled) return;
209
211
  settled = true;
210
212
  clearTimeout(timer);
213
+ clearInterval(watchdog);
211
214
  cp.kill();
212
215
  resolve(ok);
213
216
  };
214
217
 
218
+ // A process that exits on startup never logs anything for the stream below to match, so
219
+ // pm2's own counters are the only signal that it is dying and being restarted.
220
+ const watchdog = setInterval(() => {
221
+ const proc = getPm2Proc();
222
+ if (!proc) return;
223
+ const { status, restart_time: restarts = 0 } = proc.pm2_env;
224
+ if (status === 'errored' || restarts > baseline) {
225
+ console.log(
226
+ `\n\n${color.yellow}❌ The daemon keeps exiting - pm2 has restarted it ` +
227
+ `${restarts - baseline} time(s) (status: ${status}).${color.reset}`,
228
+ );
229
+ console.log(` Run ${color.cyan}lgy logs${color.reset} to see why.\n`);
230
+ finish(false);
231
+ }
232
+ }, 2000);
233
+
215
234
  let timer = setTimeout(() => {
216
235
  console.log(
217
236
  `\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
@@ -302,7 +321,31 @@ function findAgyBin() {
302
321
  return null;
303
322
  }
304
323
 
305
- function getPm2Proc() {
324
+ // Returns null when the daemon can be started, or the reason it can't.
325
+ function launchBlocker() {
326
+ const settings = getSettings();
327
+ const anyConfigured = Object.keys(PLATFORMS).some(
328
+ (key) => platformState(key, settings).configured,
329
+ );
330
+ if (!anyConfigured) {
331
+ return (
332
+ 'No messenger is configured yet - set up at least one of Discord, Telegram, or ' +
333
+ `Slack first: ${color.cyan}lgy setup${color.reset}`
334
+ );
335
+ }
336
+ if (!findAgyBin()) {
337
+ return (
338
+ "Couldn't find the agy CLI (checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). " +
339
+ 'Install/configure agy first, or set the AGY_BIN_PATH environment variable to its location.'
340
+ );
341
+ }
342
+ return null;
343
+ }
344
+
345
+ // pm2 keys an app by name AND script path, so an install that moved (node version switch, npm prefix
346
+ // change, npm link) registers a second app under the same name instead of replacing the first, and
347
+ // every name-based pm2 command then acts on both at once.
348
+ function getPm2Procs() {
306
349
  const jlist = spawnSync(process.execPath, [PM2_BIN, 'jlist'], { stdio: 'pipe' });
307
350
  if (jlist.status !== 0) return null;
308
351
 
@@ -313,7 +356,7 @@ function getPm2Proc() {
313
356
  if (out[i] !== '[') continue;
314
357
  try {
315
358
  const procs = JSON.parse(out.slice(i));
316
- if (Array.isArray(procs)) return procs.find((p) => p.name === LGY_PM2_NAME) || null;
359
+ if (Array.isArray(procs)) return procs.filter((p) => p.name === LGY_PM2_NAME);
317
360
  } catch (e) {}
318
361
  }
319
362
 
@@ -323,6 +366,71 @@ function getPm2Proc() {
323
366
  return null;
324
367
  }
325
368
 
369
+ function isOurRegistration(proc) {
370
+ const registered = proc.pm2_env.pm_exec_path || '';
371
+ if (!isWin) return registered === LGY_SCRIPT_PATH;
372
+ return registered.toLowerCase() === LGY_SCRIPT_PATH.toLowerCase();
373
+ }
374
+
375
+ function printRegistrations(procs) {
376
+ for (const proc of procs) {
377
+ const mine = isOurRegistration(proc) ? ' <- this install' : '';
378
+ console.log(
379
+ ` ${String(proc.pm2_env.status).padEnd(8)} ${proc.pm2_env.pm_exec_path}${mine}`,
380
+ );
381
+ }
382
+ }
383
+
384
+ let duplicatesReported = false;
385
+ function pickPm2Proc(procs) {
386
+ if (!procs || procs.length === 0) return null;
387
+
388
+ if (procs.length > 1 && !duplicatesReported) {
389
+ duplicatesReported = true;
390
+ console.log(
391
+ `\n${color.yellow}⚠${color.reset} pm2 has ${procs.length} apps registered as '${LGY_PM2_NAME}' - only this install's should be:`,
392
+ );
393
+ printRegistrations(procs);
394
+ console.log(
395
+ ` Everything below reports on one of them. Run ${color.cyan}lgy start${color.reset} to drop the stale ones.\n`,
396
+ );
397
+ }
398
+
399
+ const isOnline = (proc) => proc.pm2_env.status === 'online';
400
+ return (
401
+ procs.find((proc) => isOnline(proc) && isOurRegistration(proc)) ||
402
+ procs.find(isOnline) ||
403
+ procs.find(isOurRegistration) ||
404
+ procs[0]
405
+ );
406
+ }
407
+
408
+ function getPm2Proc() {
409
+ return pickPm2Proc(getPm2Procs());
410
+ }
411
+
412
+ function startDaemon() {
413
+ runPm2([
414
+ 'start',
415
+ LGY_SCRIPT_PATH,
416
+ '--interpreter',
417
+ daemonPython,
418
+ '--name',
419
+ LGY_PM2_NAME,
420
+ '--update-env',
421
+ ]);
422
+ }
423
+
424
+ // The saved autostart list keeps the removed paths until pm2 save runs, so a reboot restores them.
425
+ function clearRegistrations(procs) {
426
+ console.log(
427
+ `\n${color.yellow}⚠${color.reset} pm2 has ${procs.length} app(s) registered as '${LGY_PM2_NAME}', not all from this install:`,
428
+ );
429
+ printRegistrations(procs);
430
+ console.log(' Removing all of them and registering this install alone.\n');
431
+ runPm2(['delete', LGY_PM2_NAME]);
432
+ }
433
+
326
434
  // 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.
327
435
  function isAutostartEnabled() {
328
436
  if (isWin) return null;
@@ -363,8 +471,11 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
363
471
  const pkg = require('../package.json');
364
472
  console.log(`linkgravity v${pkg.version}`);
365
473
  } else if (cmd === 'start') {
366
- const existing = getPm2Proc();
367
- if (existing && existing.pm2_env.status === 'online') {
474
+ const registered = getPm2Procs() || [];
475
+ const stale = registered.filter((proc) => !isOurRegistration(proc));
476
+ const existing = registered.find(isOurRegistration);
477
+
478
+ if (!stale.length && existing && existing.pm2_env.status === 'online') {
368
479
  console.log(
369
480
  `\n${color.yellow}⚠${color.reset} LinkGravity is already running. ` +
370
481
  `Use ${color.cyan}lgy restart${color.reset} to apply changes, or ${color.cyan}lgy stop${color.reset} first.\n`,
@@ -372,24 +483,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
372
483
  process.exit(1);
373
484
  }
374
485
 
375
- const settings = getSettings();
376
- const anyConfigured = Object.keys(PLATFORMS).some(
377
- (key) => platformState(key, settings).configured,
378
- );
379
- if (!anyConfigured) {
380
- console.log(
381
- `\n${color.yellow}⚠${color.reset} No messenger is configured yet - ` +
382
- `set up at least one of Discord, Telegram, or Slack first: ${color.cyan}lgy setup${color.reset}\n`,
383
- );
384
- process.exit(1);
385
- }
386
-
387
- if (!findAgyBin()) {
388
- console.log(
389
- `\n${color.yellow}⚠${color.reset} Couldn't find the agy CLI ` +
390
- `(checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). Install/configure agy first, ` +
391
- `or set the AGY_BIN_PATH environment variable to its location.\n`,
392
- );
486
+ const blocker = launchBlocker();
487
+ if (blocker) {
488
+ console.log(`\n${color.yellow}⚠${color.reset} ${blocker}\n`);
393
489
  process.exit(1);
394
490
  }
395
491
 
@@ -400,16 +496,11 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
400
496
 
401
497
  repairHookRegistration();
402
498
 
499
+ if (stale.length) clearRegistrations(registered);
500
+
403
501
  info('Starting LinkGravity daemon...');
404
- runPm2([
405
- 'start',
406
- LGY_SCRIPT_PATH,
407
- '--interpreter',
408
- pythonExe,
409
- '--name',
410
- LGY_PM2_NAME,
411
- '--update-env',
412
- ]);
502
+ startDaemon();
503
+ if (stale.length) runPm2(['save']);
413
504
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
414
505
  } else if (cmd === 'stop') {
415
506
  info('Stopping LinkGravity daemon...');
@@ -417,6 +508,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
417
508
  runPm2(['reset', LGY_PM2_NAME]);
418
509
  success('Daemon stopped successfully.\n');
419
510
  } else if (cmd === 'restart') {
511
+ const registered = getPm2Procs() || [];
512
+ if (registered.some((proc) => !isOurRegistration(proc))) {
513
+ console.log(
514
+ `\n${color.yellow}⚠${color.reset} pm2 has ${registered.length} app(s) registered as '${LGY_PM2_NAME}', and a restart would start every one of them:`,
515
+ );
516
+ printRegistrations(registered);
517
+ console.log(
518
+ ` Run ${color.cyan}lgy start${color.reset} instead - it drops the stale ones first.\n`,
519
+ );
520
+ process.exit(1);
521
+ }
522
+
420
523
  info('Restarting LinkGravity daemon...');
421
524
  runPm2(['restart', LGY_PM2_NAME, '--update-env']);
422
525
  runPm2(['reset', LGY_PM2_NAME]);
@@ -615,8 +718,10 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
615
718
  process.exit(0);
616
719
  }
617
720
 
618
- const procBeforeUpdate = getPm2Proc();
721
+ const registeredBeforeUpdate = getPm2Procs() || [];
722
+ const procBeforeUpdate = pickPm2Proc(registeredBeforeUpdate);
619
723
  const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
724
+ const hadStale = registeredBeforeUpdate.some((proc) => !isOurRegistration(proc));
620
725
 
621
726
  info(`Updating: v${currentVersion} -> v${latestVersion}...`);
622
727
  const installResult = runNpm(['install', '-g', 'linkgravity@latest'], { stdio: 'inherit' });
@@ -634,16 +739,27 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
634
739
  repairHookRegistration({ fresh: true });
635
740
 
636
741
  if (!procBeforeUpdate) {
742
+ const blocker = launchBlocker();
743
+ if (blocker) {
744
+ console.log(`\n${color.yellow}⚠${color.reset} ${blocker}\n`);
745
+ success('Update finished - the daemon was left stopped.\n');
746
+ process.exit(0);
747
+ }
637
748
  info("Daemon wasn't running - starting it fresh...");
638
- runPm2([
639
- 'start',
640
- LGY_SCRIPT_PATH,
641
- '--interpreter',
642
- pythonExe,
643
- '--name',
644
- LGY_PM2_NAME,
645
- '--update-env',
646
- ]);
749
+ startDaemon();
750
+ verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
751
+ } else if (hadStale) {
752
+ clearRegistrations(registeredBeforeUpdate);
753
+ if (!wasOnline) {
754
+ runPm2(['save']);
755
+ success(
756
+ `Daemon was stopped - leaving it stopped. Run 'lgy start' when you're ready.\n`,
757
+ );
758
+ process.exit(0);
759
+ }
760
+ info('Starting the daemon from this install...');
761
+ startDaemon();
762
+ runPm2(['save']);
647
763
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
648
764
  } else if (wasOnline) {
649
765
  info('Restarting daemon to apply the update...');
@@ -722,7 +838,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
722
838
  spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
723
839
  })();
724
840
  } else {
725
- console.log(
841
+ // Not stdout: an eval "$(lgy ...)" line in a shell rc would run this message as commands.
842
+ console.error(
726
843
  `\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
727
844
  );
845
+ process.exit(1);
728
846
  }
package/bin/pm2.js CHANGED
@@ -10,6 +10,9 @@ function pm2Env() {
10
10
  ...process.env,
11
11
  // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
12
12
  PYTHONUNBUFFERED: '1',
13
+ // Without this Python inherits the console codepage (cp949 on Korean Windows) and loguru
14
+ // drops every line it can't encode - including the one verifyStartup waits for.
15
+ PYTHONIOENCODING: 'utf-8',
13
16
  // pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
14
17
  // survives unless a value is passed every time.
15
18
  LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
package/bin/setup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const p = require('@clack/prompts');
2
2
  const { spawnSync } = require('child_process');
3
- const { python: pythonExe } = require('../npm-scripts/venv-paths');
3
+ const { daemonPython } = require('../npm-scripts/venv-paths');
4
4
  const { PM2_BIN, PM2_CWD, pm2Env } = require('./pm2');
5
5
  const {
6
6
  getSettings,
@@ -224,7 +224,7 @@ function startOrRestartDaemon(pm2Name, scriptPath, label) {
224
224
  'start',
225
225
  scriptPath,
226
226
  '--interpreter',
227
- pythonExe,
227
+ daemonPython,
228
228
  '--name',
229
229
  pm2Name,
230
230
  ]);
@@ -40,6 +40,10 @@ module.exports = {
40
40
  isWin,
41
41
  venvBinDir,
42
42
  python: venvBin('python'),
43
+ // Only for pm2's --interpreter: pm2 spawns with detached:true, which on Windows forces a
44
+ // console window and ignores its own windowsHide option. pythonw is GUI-subsystem so no
45
+ // console is ever allocated, and pm2 pipes stdio anyway so no output is lost.
46
+ daemonPython: venvBin(isWin ? 'pythonw' : 'python'),
43
47
  pip: venvBin('pip'),
44
48
  preCommit: venvBin('pre-commit'),
45
49
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
@@ -51,7 +51,7 @@ async def fetch_models_background():
51
51
  import time
52
52
 
53
53
  try:
54
- from config import AGY_BIN
54
+ from config import AGY_BIN, CREATION_FLAGS
55
55
 
56
56
  logger.debug(f"Starting fetch_models_background using AGY_BIN: {AGY_BIN}")
57
57
  env = os.environ.copy()
@@ -63,6 +63,7 @@ async def fetch_models_background():
63
63
  stderr=asyncio.subprocess.PIPE,
64
64
  stdin=asyncio.subprocess.DEVNULL,
65
65
  env=env,
66
+ creationflags=CREATION_FLAGS,
66
67
  )
67
68
  try:
68
69
  stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=30.0)
@@ -583,7 +583,7 @@ class VoiceCog(commands.Cog):
583
583
  wake_syllables = len(re.sub(r"[^\w가-힣]", "", matched_wake_word or ""))
584
584
  min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
585
585
  if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
586
- self.logger.info(
586
+ self.logger.debug(
587
587
  f"STT: ignoring wake - '{text}' doesn't resemble '{matched_wake_word}' "
588
588
  f"(prefix similarity {prefix_similarity:.2f}, needed {min_prefix_similarity:.2f})"
589
589
  )
package/src/config.py CHANGED
@@ -1,5 +1,7 @@
1
1
  import os
2
2
  import secrets
3
+ import shutil
4
+ import subprocess
3
5
  from pathlib import Path
4
6
 
5
7
  from core.atomic_io import atomic_write_json, safe_load_json
@@ -158,7 +160,22 @@ MODEL_CHOICES = {
158
160
  "pro": "Gemini 3.1 Pro",
159
161
  }
160
162
 
161
- AGY_BIN = os.getenv("AGY_BIN_PATH", str(Path.home() / ".local/bin/agy"))
163
+
164
+ def _resolve_agy_bin() -> str:
165
+ fallback = str(Path.home() / ".local/bin/agy")
166
+ for candidate in (os.getenv("AGY_BIN_PATH"), fallback):
167
+ if candidate and Path(candidate).is_file():
168
+ return candidate
169
+ # Windows installs agy under AppData and adds it to PATH instead.
170
+ return shutil.which("agy") or fallback
171
+
172
+
173
+ # Must stay in sync with findAgyBin() in bin/cli.js.
174
+ AGY_BIN = _resolve_agy_bin()
175
+
176
+ # pm2 spawns the daemon with detached:true, so on Windows it owns no console; without this
177
+ # flag every child it launches gets a console window of its own.
178
+ CREATION_FLAGS = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
162
179
 
163
180
  session_manager = SessionManager(DATA_DIR)
164
181
 
@@ -163,7 +163,7 @@ async def run_agy(
163
163
  if os.name == "nt":
164
164
  import subprocess
165
165
 
166
- kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
166
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
167
167
  elif hasattr(os, "setsid"):
168
168
  kwargs["preexec_fn"] = os.setsid
169
169
 
@@ -11,7 +11,7 @@ from functools import partial
11
11
  import discord
12
12
  from discord.ext import commands
13
13
 
14
- from config import DISCORD_TOKEN, logger, session_manager
14
+ from config import CREATION_FLAGS, DISCORD_TOKEN, logger, session_manager
15
15
  from handlers.message_router import handle_message
16
16
  from messengers.discord_adapter import DiscordAdapter
17
17
  from messengers.registry import register_adapter
@@ -128,7 +128,7 @@ async def status_updater_task():
128
128
 
129
129
 
130
130
  def _spawn_voice_process(voice_dir: str) -> subprocess.Popen:
131
- return subprocess.Popen(["node", "index.js"], cwd=voice_dir)
131
+ return subprocess.Popen(["node", "index.js"], cwd=voice_dir, creationflags=CREATION_FLAGS)
132
132
 
133
133
 
134
134
  async def _supervise_voice_process(voice_dir: str):
package/src/main_slack.py CHANGED
@@ -258,7 +258,7 @@ async def run_slack(stop_event: asyncio.Event) -> None:
258
258
 
259
259
  app, adapter = build_app()
260
260
  try:
261
- await adapter.resolve_bot_user_id()
261
+ bot_user_id = await adapter.resolve_bot_user_id()
262
262
  except SlackApiError as e:
263
263
  logger.critical(f"Slack auth_test failed - check slack_bot_token: {e}")
264
264
  return
@@ -266,6 +266,9 @@ async def run_slack(stop_event: asyncio.Event) -> None:
266
266
  handler = AsyncSocketModeHandler(app, SLACK_APP_TOKEN)
267
267
  logger.info("✅ Slack bot starting (Socket Mode)...")
268
268
  await handler.connect_async()
269
+ # cli.js's verifyStartup() waits for this exact sentence - without it a Slack-only install
270
+ # never reports a successful startup and every lgy start/restart/update times out.
271
+ logger.info(f"✅ Bot is fully online and ready! Logged in as {bot_user_id}")
269
272
  platform_health.set_status("slack", "running")
270
273
  try:
271
274
  await stop_event.wait()
@@ -1,7 +1,7 @@
1
1
  from pathlib import Path
2
2
  from typing import Any
3
3
 
4
- from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
4
+ from config import MAX_EMBED_LEN, bot_settings, session_manager
5
5
  from messengers.registry import get_adapter_for_platform
6
6
  from services.discord_helpers import split_message
7
7
  from utils.utils import get_current_model
@@ -18,8 +18,8 @@ async def send_agy_response(
18
18
  adapter = get_adapter_for_platform(session.get("platform", "discord"))
19
19
  session_manager.save_sessions()
20
20
 
21
- session_model = session.get("model")
22
- model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
21
+ # Same precedence as the /model autocomplete in general_cog.
22
+ model_display = session.get("model") or bot_settings.get("default_model") or get_current_model()
23
23
 
24
24
  parts = split_message(response_text, MAX_EMBED_LEN)
25
25
  for idx, part in enumerate(parts):