linkgravity 1.5.5 → 1.5.7

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
@@ -15,6 +15,7 @@ const {
15
15
  } = require('./platforms');
16
16
 
17
17
  const { python: pythonExe, isWin } = require('../npm-scripts/venv-paths');
18
+ const { isEnvironmentReady } = require('../npm-scripts/ensure-env');
18
19
 
19
20
  const cmd = process.argv[2];
20
21
 
@@ -200,6 +201,10 @@ function verifyStartup() {
200
201
  finish(false);
201
202
  }, 30000);
202
203
 
204
+ const isBenignShutdownNoise = (str) =>
205
+ str.includes('asyncio.exceptions.CancelledError') &&
206
+ str.includes('Application.stop() complete');
207
+
203
208
  const checkLog = (data) => {
204
209
  if (settled) return;
205
210
  const str = data.toString();
@@ -210,7 +215,8 @@ function verifyStartup() {
210
215
  errorDetectionArmed &&
211
216
  (str.includes('Traceback (most recent call last):') ||
212
217
  str.includes('Error:') ||
213
- str.includes('Exception:'))
218
+ str.includes('Exception:')) &&
219
+ !isBenignShutdownNoise(str)
214
220
  ) {
215
221
  console.log(`\n\n${color.yellow}❌ Error detected during startup:${color.reset}`);
216
222
  const errorLines = str
@@ -373,6 +379,14 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
373
379
  process.exit(1);
374
380
  }
375
381
 
382
+ if (!isEnvironmentReady()) {
383
+ console.log(
384
+ `\n${color.yellow}⚠${color.reset} Python environment isn't set up yet - ` +
385
+ `run ${color.cyan}lgy setup${color.reset} first (it installs everything on its first run).\n`,
386
+ );
387
+ process.exit(1);
388
+ }
389
+
376
390
  info('Starting LinkGravity daemon...');
377
391
  runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
378
392
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
package/bin/setup.js CHANGED
@@ -446,6 +446,27 @@ async function runSetup() {
446
446
  console.log();
447
447
  p.intro(`${color.cyan}▶ LinkGravity Setup Wizard${color.reset}`);
448
448
 
449
+ const { ensureEnvironment, isEnvironmentReady } = require('../npm-scripts/ensure-env');
450
+ if (!isEnvironmentReady()) {
451
+ console.log();
452
+ ensureEnvironment();
453
+ console.log();
454
+ }
455
+
456
+ const registerHook = require('../npm-scripts/register-hook');
457
+ if (!registerHook.isHookRegistered()) {
458
+ const consent = await p.confirm({
459
+ message:
460
+ "Register LinkGravity's approval hook with agy? (required for tool-call approval - lets LinkGravity gate agy's actions through Discord/Telegram/Slack)",
461
+ initialValue: true,
462
+ });
463
+ if (p.isCancel(consent)) {
464
+ p.cancel('Setup cancelled.');
465
+ process.exit(0);
466
+ }
467
+ if (consent) registerHook({ allowFirstTimeCreate: true });
468
+ }
469
+
449
470
  while (true) {
450
471
  const settings = getSettings();
451
472
  const options = Object.entries(PLATFORMS).map(([key, def]) => {
package/hooks/hook.py CHANGED
@@ -4,6 +4,17 @@ import os
4
4
  import sys
5
5
  import urllib.error
6
6
  import urllib.request
7
+ from pathlib import Path
8
+
9
+ LGY_CONFIG_FILE = Path.home() / ".gemini" / "linkgravity" / "lgy.json"
10
+
11
+
12
+ def _load_approve_token():
13
+ try:
14
+ with open(LGY_CONFIG_FILE, encoding="utf-8") as f:
15
+ return json.load(f).get("approve_token", "")
16
+ except Exception:
17
+ return ""
7
18
 
8
19
 
9
20
  def main():
@@ -34,7 +45,9 @@ def main():
34
45
  ).encode("utf-8")
35
46
 
36
47
  req = urllib.request.Request(
37
- "http://localhost:18080/approve", data=payload, headers={"Content-Type": "application/json"}
48
+ "http://localhost:18080/approve",
49
+ data=payload,
50
+ headers={"Content-Type": "application/json", "X-LGY-Token": _load_approve_token()},
38
51
  )
39
52
 
40
53
  try:
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ const { execSync } = require('child_process');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const { pip: venvPip, python: venvPython, workspaceDir, repoRoot } = require('./venv-paths');
7
+
8
+ const isWin = os.platform() === 'win32';
9
+ const pyCmd = isWin ? 'python' : 'python3';
10
+
11
+ function isEnvironmentReady() {
12
+ return fs.existsSync(venvPython);
13
+ }
14
+
15
+ 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
+ });
36
+
37
+ console.log('✅ Environment ready.');
38
+ }
39
+
40
+ module.exports = { ensureEnvironment, isEnvironmentReady };
@@ -2,9 +2,8 @@
2
2
  // Runs at `prepare` time - which npm only triggers for a local `npm
3
3
  // install` inside this repo (i.e. a git clone / contributor checkout),
4
4
  // never for `npm install -g linkgravity` end users installing the published
5
- // package from the registry. That's exactly why dev-only setup (git
6
- // hooks, lint tooling) lives here instead of postinstall.js, which runs
7
- // for everyone, including end users who don't need any of this.
5
+ // package from the registry. That's why dev-only setup (git hooks, lint
6
+ // tooling) lives here rather than running for every end user.
8
7
  'use strict';
9
8
  const { execSync } = require('child_process');
10
9
  const fs = require('fs');
@@ -13,14 +12,13 @@ const { repoRoot, pip, preCommit } = require('./venv-paths');
13
12
  if (!fs.existsSync(pip)) {
14
13
  console.warn(
15
14
  '⚠️ No venv found yet - skipping dev tooling install and git hook setup. ' +
16
- 'Run `npm install` again once the venv exists, or set it up manually.',
15
+ 'Run `node bin/cli.js setup` to create it, then `npm install` again.',
17
16
  );
18
17
  return;
19
18
  }
20
19
 
21
- // 1. Dev-only Python tooling into the same venv postinstall.js already
22
- // created (prepare always runs after postinstall in npm's lifecycle
23
- // order): ruff (editor/manual use) and pre-commit itself, which is
20
+ // 1. Dev-only Python tooling into the same venv `lgy setup` already
21
+ // created: ruff (editor/manual use) and pre-commit itself, which is
24
22
  // what actually runs the hooks declared in .pre-commit-config.yaml.
25
23
  try {
26
24
  execSync(`"${pip}" install -r requirements-dev.txt`, { stdio: 'inherit', cwd: repoRoot });
@@ -1,11 +1,5 @@
1
1
  'use strict';
2
- // Registers/fixes this project's agy hooks in ~/.gemini/config/hooks.json
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: tool-approval gate, matcher-wrapped per schema.
17
- // Stop: fires when agy is about to end a turn; if fullyIdle is false
18
- // (an async run_command is still in flight), stop_hook.py tells agy to
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
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
- "postinstall": "node npm-scripts/postinstall.js",
7
6
  "start": "node npm-scripts/run-dev.js",
8
7
  "dev": "node npm-scripts/run-dev.js",
9
8
  "format": "prettier --write \"bin/**/*.js\" \"npm-scripts/**/*.js\" \"voice-service/*.js\"",
package/src/api/server.py CHANGED
@@ -1,6 +1,15 @@
1
1
  from aiohttp import web
2
2
 
3
- from config import logger, session_manager
3
+ from config import bot_settings, logger, session_manager
4
+
5
+ LGY_TOKEN_HEADER = "X-LGY-Token"
6
+
7
+
8
+ @web.middleware
9
+ async def auth_middleware(request, handler):
10
+ if request.headers.get(LGY_TOKEN_HEADER) != bot_settings.get("approve_token"):
11
+ return web.json_response({"error": "unauthorized"}, status=403)
12
+ return await handler(request)
4
13
 
5
14
 
6
15
  def is_tool_allowed(tool_name, tool_input):
@@ -20,7 +29,7 @@ def is_tool_allowed(tool_name, tool_input):
20
29
 
21
30
 
22
31
  async def setup_webhook_server(bot):
23
- app = web.Application(client_max_size=50 * 1024 * 1024)
32
+ app = web.Application(client_max_size=50 * 1024 * 1024, middlewares=[auth_middleware])
24
33
  app["bot"] = bot
25
34
 
26
35
  from api.ui_routes import handle_approve_request
@@ -41,6 +50,6 @@ async def setup_webhook_server(bot):
41
50
 
42
51
  runner = web.AppRunner(app)
43
52
  await runner.setup()
44
- site = web.TCPSite(runner, "0.0.0.0", 18080)
53
+ site = web.TCPSite(runner, "127.0.0.1", 18080)
45
54
  await site.start()
46
55
  logger.info("Webhook / STT Server started on port 18080")
package/src/config.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import secrets
2
3
  from pathlib import Path
3
4
 
4
5
  from core.atomic_io import atomic_write_json, safe_load_json
@@ -30,6 +31,7 @@ DEFAULT_LGY_CONFIG = {
30
31
  "tts_enabled": True,
31
32
  # Sticky default for /new sessions, set whenever /model succeeds.
32
33
  "default_model": "",
34
+ "approve_token": "",
33
35
  }
34
36
 
35
37
 
@@ -71,6 +73,10 @@ def save_bot_settings(data):
71
73
 
72
74
  bot_settings = load_bot_settings()
73
75
 
76
+ if not bot_settings.get("approve_token"):
77
+ bot_settings["approve_token"] = secrets.token_hex(24)
78
+ save_bot_settings(bot_settings)
79
+
74
80
  from core.logger import init_logger
75
81
  from core.session_manager import SessionManager
76
82
 
@@ -5,6 +5,7 @@ const { googleSTT } = require('./stt');
5
5
  const { getDetectorForUser, feedPCMToDetector, WAKE_MATCH_THRESHOLD } = require('./wakeword');
6
6
  const { interruptTTS } = require('./tts');
7
7
  const state = require('./state');
8
+ const { aglConfig } = require('./config');
8
9
  const { activeStreams, enrollingUsers, isPlaying, wakeWordOptedOut, runtime, isGuildActive } =
9
10
  state;
10
11
 
@@ -108,7 +109,10 @@ function setupReceiver(connection, guildId, client) {
108
109
  partialSent = true;
109
110
  fetch('http://127.0.0.1:18080/stt_partial', {
110
111
  method: 'POST',
111
- headers: { 'Content-Type': 'application/json' },
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ 'X-LGY-Token': aglConfig.approve_token,
115
+ },
112
116
  body: JSON.stringify({ guild_id: guildId, text }),
113
117
  }).catch((err) =>
114
118
  console.error(`[STT] Failed to send partial text to Python:`, err.message),
@@ -248,7 +252,10 @@ function setupReceiver(connection, guildId, client) {
248
252
  `http://127.0.0.1:18080/enroll_sample?user_id=${encodeURIComponent(userId)}`,
249
253
  {
250
254
  method: 'POST',
251
- headers: { 'Content-Type': 'application/octet-stream' },
255
+ headers: {
256
+ 'Content-Type': 'application/octet-stream',
257
+ 'X-LGY-Token': aglConfig.approve_token,
258
+ },
252
259
  body: wavBuffer,
253
260
  },
254
261
  );
@@ -263,7 +270,10 @@ function setupReceiver(connection, guildId, client) {
263
270
  if (partialSent) {
264
271
  fetch('http://127.0.0.1:18080/stt_partial_cancel', {
265
272
  method: 'POST',
266
- headers: { 'Content-Type': 'application/json' },
273
+ headers: {
274
+ 'Content-Type': 'application/json',
275
+ 'X-LGY-Token': aglConfig.approve_token,
276
+ },
267
277
  body: JSON.stringify({ guild_id: guildId }),
268
278
  }).catch(() => {});
269
279
  }
@@ -277,7 +287,10 @@ function setupReceiver(connection, guildId, client) {
277
287
  if (partialSent) {
278
288
  fetch('http://127.0.0.1:18080/stt_partial_cancel', {
279
289
  method: 'POST',
280
- headers: { 'Content-Type': 'application/json' },
290
+ headers: {
291
+ 'Content-Type': 'application/json',
292
+ 'X-LGY-Token': aglConfig.approve_token,
293
+ },
281
294
  body: JSON.stringify({ guild_id: guildId }),
282
295
  }).catch(() => {});
283
296
  }
@@ -291,7 +304,10 @@ function setupReceiver(connection, guildId, client) {
291
304
  try {
292
305
  await fetch('http://127.0.0.1:18080/stt_input', {
293
306
  method: 'POST',
294
- headers: { 'Content-Type': 'application/json' },
307
+ headers: {
308
+ 'Content-Type': 'application/json',
309
+ 'X-LGY-Token': aglConfig.approve_token,
310
+ },
295
311
  body: JSON.stringify({
296
312
  user_id: userId,
297
313
  guild_id: guildId,
@@ -1,6 +1,7 @@
1
1
  const { Readable } = require('stream');
2
2
  const { createAudioPlayer, createAudioResource, AudioPlayerStatus } = require('@discordjs/voice');
3
3
  const { players, audioQueues, isPlaying, connections, suppressNotifyMap } = require('./state');
4
+ const { aglConfig } = require('./config');
4
5
 
5
6
  function interruptTTS(guildId) {
6
7
  const player = players.get(guildId);
@@ -25,7 +26,7 @@ async function notifyTtsFinished(guild_id) {
25
26
  try {
26
27
  await fetch('http://127.0.0.1:18080/tts_finished', {
27
28
  method: 'POST',
28
- headers: { 'Content-Type': 'application/json' },
29
+ headers: { 'Content-Type': 'application/json', 'X-LGY-Token': aglConfig.approve_token },
29
30
  body: JSON.stringify({ guild_id }),
30
31
  });
31
32
  } catch (err) {
@@ -1,62 +0,0 @@
1
- const { execSync } = require('child_process');
2
- const os = require('os');
3
- const path = require('path');
4
- const fs = require('fs');
5
- const { pip: venvPip, workspaceDir } = require('./venv-paths');
6
-
7
- console.log('⚙️ Setting up Python Virtual Environment...');
8
- console.log(` (in ${path.join(workspaceDir, 'venv')} - not inside this install, so it survives`);
9
- console.log(' package updates/reinstalls and works the same whether this is a global');
10
- console.log(' `npm install -g linkgravity` or a local dev clone.)');
11
-
12
- // Use python on Windows, python3 on Mac/Linux - this is the *system*
13
- // python used only to create the venv below; once it exists, every
14
- // other script (this one included) goes through venv-paths.js instead.
15
- const isWin = os.platform() === 'win32';
16
- const pyCmd = isWin ? 'python' : 'python3';
17
-
18
- async function main() {
19
- try {
20
- // 1. Create Python virtual environment (venv) in the fixed
21
- // workspace dir, not in cwd - see venv-paths.js for why.
22
- fs.mkdirSync(workspaceDir, { recursive: true });
23
- execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
24
-
25
- // 2. Install Python packages
26
- console.log('📦 Installing Python dependencies...');
27
- execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit' });
28
-
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
- console.log('🎙️ Installing Voice Service dependencies...');
34
- execSync('npm install', {
35
- stdio: 'inherit',
36
- cwd: path.join(__dirname, '..', 'voice-service'),
37
- });
38
-
39
- // 4. Register this install's location with agy as its
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).
47
- try {
48
- require('./register-hook')();
49
- } catch (err) {
50
- console.warn(
51
- `⚠️ Couldn't register the agy tool-approval hook: ${err.message.split('\n')[0]}`,
52
- );
53
- }
54
-
55
- console.log('✅ Installation complete!');
56
- } catch (error) {
57
- console.error('❌ Installation failed. Please ensure Python 3.10+ is installed.');
58
- process.exit(1);
59
- }
60
- }
61
-
62
- main();