linkgravity 1.5.9 → 1.5.11

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
@@ -32,13 +32,17 @@ function success(msg) {
32
32
  console.log(`${color.green}✔${color.reset} ${msg}`);
33
33
  }
34
34
 
35
+ // Resolved rather than run through npx: pm2 is a direct dependency, and npx wraps it in
36
+ // "npm exec" + "sh -c", which leaves the real process orphaned when we try to kill it.
37
+ const PM2_BIN = require.resolve('pm2/bin/pm2');
38
+
35
39
  function info(msg) {
36
40
  console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
37
41
  }
38
42
 
39
43
  function runPm2(args, silent = true) {
40
44
  const stdioOpt = silent ? 'pipe' : 'inherit';
41
- const result = spawnSync('npx', ['-y', 'pm2', ...args], {
45
+ const result = spawnSync(process.execPath, [PM2_BIN, ...args], {
42
46
  stdio: stdioOpt,
43
47
  cwd: path.join(__dirname, '..'),
44
48
  // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
@@ -119,7 +123,7 @@ function colorizeLevel(line) {
119
123
  }
120
124
 
121
125
  function runPm2LogsStream(args, printLine) {
122
- const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
126
+ const cp = spawn(process.execPath, [PM2_BIN, ...args], { cwd: path.join(__dirname, '..') });
123
127
 
124
128
  const isNoise = (line) =>
125
129
  line.trim().length === 0 ||
@@ -160,6 +164,8 @@ function runPm2LogsStream(args, printLine) {
160
164
  stdoutHandler.flush();
161
165
  stderrHandler.flush();
162
166
  });
167
+ // Ctrl-C reaches pm2 too (same process group), but an unhandled parent exit would leave it tailing.
168
+ process.on('exit', () => cp.kill());
163
169
  }
164
170
 
165
171
  function runPm2LogsClean(args, showStamps = false) {
@@ -175,10 +181,7 @@ function verifyStartup() {
175
181
  `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
176
182
  );
177
183
 
178
- // Spawned directly instead of through npx: npx wraps pm2 in "npm exec" + "sh -c", so cp.kill()
179
- // reaps only the wrapper and leaves the real pm2 logs process orphaned onto init.
180
- const pm2Bin = require.resolve('pm2/bin/pm2');
181
- let cp = spawn(process.execPath, [pm2Bin, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
184
+ let cp = spawn(process.execPath, [PM2_BIN, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
182
185
  cwd: path.join(__dirname, '..'),
183
186
  });
184
187
 
@@ -282,7 +285,7 @@ function findAgyBin() {
282
285
  }
283
286
 
284
287
  function getPm2Proc() {
285
- const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
288
+ const jlist = spawnSync(process.execPath, [PM2_BIN, 'jlist'], { stdio: 'pipe' });
286
289
  if (jlist.status !== 0) return null;
287
290
 
288
291
  // pm2 can print noise before the real JSON (version banners, ANSI escapes, daemon-spawn logs) that
@@ -376,11 +379,8 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
376
379
  }
377
380
 
378
381
  if (!isEnvironmentReady()) {
379
- console.log(
380
- `\n${color.yellow}⚠${color.reset} Python environment isn't set up yet - ` +
381
- `run ${color.cyan}lgy setup${color.reset} first (it installs everything on its first run).\n`,
382
- );
383
- process.exit(1);
382
+ info('Some dependencies are missing - installing them first...');
383
+ require('../npm-scripts/ensure-env').ensureEnvironment();
384
384
  }
385
385
 
386
386
  info('Starting LinkGravity daemon...');
@@ -602,6 +602,12 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
602
602
  }
603
603
  success(`Installed v${latestVersion}.`);
604
604
 
605
+ // The new install ships without voice-service/node_modules, so restore anything the
606
+ // directory swap dropped. Loaded here rather than at the top of the file: by now npm has
607
+ // replaced this package on disk, and the copy required at startup is the pre-update one.
608
+ delete require.cache[require.resolve('../npm-scripts/ensure-env')];
609
+ require('../npm-scripts/ensure-env').ensureEnvironment();
610
+
605
611
  if (!procBeforeUpdate) {
606
612
  info("Daemon wasn't running - starting it fresh...");
607
613
  runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
@@ -8,31 +8,40 @@ const { pip: venvPip, python: venvPython, workspaceDir, repoRoot } = require('./
8
8
  const isWin = os.platform() === 'win32';
9
9
  const pyCmd = isWin ? 'python' : 'python3';
10
10
 
11
- function isEnvironmentReady() {
11
+ const voiceServiceDir = path.join(repoRoot, 'voice-service');
12
+
13
+ function isVenvReady() {
12
14
  return fs.existsSync(venvPython);
13
15
  }
14
16
 
17
+ function isVoiceServiceReady() {
18
+ return fs.existsSync(path.join(voiceServiceDir, 'node_modules'));
19
+ }
20
+
21
+ function isEnvironmentReady() {
22
+ return isVenvReady() && isVoiceServiceReady();
23
+ }
24
+
15
25
  function ensureEnvironment() {
16
- if (isEnvironmentReady()) return;
17
-
18
- console.log('⚙️ Setting up Python Virtual Environment...');
19
- console.log(
20
- ` (in ${path.join(workspaceDir, 'venv')} - not inside this install, so it survives`,
21
- );
22
- console.log(' package updates/reinstalls and works the same whether this is a global');
23
- console.log(' `npm install -g linkgravity` or a local dev clone.)');
24
-
25
- fs.mkdirSync(workspaceDir, { recursive: true });
26
- execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
27
-
28
- console.log('📦 Installing Python dependencies...');
29
- execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit', cwd: repoRoot });
30
-
31
- console.log('🎙️ Installing Voice Service dependencies...');
32
- execSync('npm install', {
33
- stdio: 'inherit',
34
- cwd: path.join(repoRoot, 'voice-service'),
35
- });
26
+ if (!isVenvReady()) {
27
+ console.log('⚙️ Setting up Python Virtual Environment...');
28
+ console.log(
29
+ ` (in ${path.join(workspaceDir, 'venv')} - not inside this install, so it survives`,
30
+ );
31
+ console.log(' package updates/reinstalls and works the same whether this is a global');
32
+ console.log(' `npm install -g linkgravity` or a local dev clone.)');
33
+
34
+ fs.mkdirSync(workspaceDir, { recursive: true });
35
+ execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
36
+
37
+ console.log('📦 Installing Python dependencies...');
38
+ execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit', cwd: repoRoot });
39
+ }
40
+
41
+ if (!isVoiceServiceReady()) {
42
+ console.log('🎙️ Installing Voice Service dependencies...');
43
+ execSync('npm install', { stdio: 'inherit', cwd: voiceServiceDir });
44
+ }
36
45
 
37
46
  console.log('✅ Environment ready.');
38
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.9",
3
+ "version": "1.5.11",
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",
@@ -13,6 +13,10 @@ _intentionally_stopped = set()
13
13
 
14
14
  _STDOUT_BUFFER_SIZE = 65536
15
15
 
16
+ # Returned as ordinary output, not raised - callers that need to distinguish failure from a
17
+ # real answer have to compare against this.
18
+ TIMEOUT_MESSAGE = "🛑 AI Task timed out."
19
+
16
20
 
17
21
  @functools.lru_cache(maxsize=1)
18
22
  def _find_preload_lib() -> tuple[str, str] | None:
@@ -315,7 +319,7 @@ async def run_agy(
315
319
  logger.warning("[AGY RETRY] Global timeout. Retrying...")
316
320
  await asyncio.sleep(2.0)
317
321
  continue
318
- msg = "🛑 AI Task timed out."
322
+ msg = TIMEOUT_MESSAGE
319
323
  if stream_queue is not None:
320
324
  await stream_queue.put(("\n\n" + msg, True))
321
325
  return msg
@@ -388,7 +392,7 @@ async def generate_thread_title(user_input: str, response: str) -> str:
388
392
  )
389
393
  title = await run_agy("--print", prompt, timeout=30)
390
394
  title = title.strip().strip('"').strip("'")
391
- if not title or len(title) > 80:
395
+ if not title or len(title) > 80 or title == TIMEOUT_MESSAGE:
392
396
  return fallback
393
397
  return title
394
398
  except Exception as e:
@@ -73,13 +73,9 @@ async def handle_pending_session(
73
73
  await queue.put(("__END__", True))
74
74
  await stream_task
75
75
 
76
- response_text = result_text
77
- if adapter.should_auto_title(thread):
78
- new_title = await generate_thread_title(content, response_text)
79
- await adapter.rename_conversation(thread, new_title)
80
- await update_agy_conversation_title(new_conv_id, new_title)
76
+ needs_title = adapter.should_auto_title(thread)
81
77
 
82
- response_text = await render_thought_process(new_conv_id, ctx, response_text, thread)
78
+ response_text = await render_thought_process(new_conv_id, ctx, result_text, thread)
83
79
 
84
80
  session["conversation_id"] = new_conv_id
85
81
  session["created_at"] = datetime.now().isoformat()
@@ -87,6 +83,13 @@ async def handle_pending_session(
87
83
  session_manager.save_sessions()
88
84
 
89
85
  await send_agy_response(thread, response_text, session, ctx, start_time, new_conv_id)
86
+
87
+ # Outside the typing indicator: the answer is already on screen, and leaving "typing"
88
+ # up during the extra title call reads as if more output is still coming.
89
+ if needs_title:
90
+ new_title = await generate_thread_title(content, result_text)
91
+ await adapter.rename_conversation(thread, new_title)
92
+ await update_agy_conversation_title(new_conv_id, new_title)
90
93
  finally:
91
94
  cleanup_images(image_paths)
92
95