linkgravity 1.0.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. package/voice-service/package.json +24 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 linkgravity contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # LinkGravity (lgy)
2
+
3
+ A Discord bot interface for the Antigravity (agy) agentic AI system. It translates Antigravity CLI prompts into Discord UI components and provides voice interaction capabilities.
4
+
5
+ ## Features
6
+
7
+ - **Environment Sync:** Automatically syncs with the host's `~/.gemini` configuration.
8
+ - **Voice Interaction:** Supports voice channels with adaptive VAD (Voice Activity Detection) to segment speech and filter environmental noise, plus live "listening..." feedback while you're still talking.
9
+ - **Wake Word Recognition:** Uses phoneme-level similarity to detect wake words and activate voice commands.
10
+ - **CLI Prompt Interception:** Converts CLI prompts (`ask_question`, `run_command` approvals) into interactive Discord buttons.
11
+ - **Command Security:** Parses chained shell commands (`&&`, `||`, `;`) and requires individual approval for each command. Supports prefix-based scope whitelisting.
12
+ - **Multi-Modal Input:** Attach any file (not just images) for the AI to read; audio attachments (`.ogg`/`.mp3`/`.m4a`/`.wav`) are transcribed to text automatically.
13
+
14
+ ## Prerequisites
15
+
16
+ - Node.js >= 18
17
+ - Python >= 3.10
18
+ - `agy` (Antigravity CLI) installed on this machine
19
+ - `ffmpeg` on your PATH (needed for TTS/voice playback)
20
+ - A Discord bot token, and at least one server/channel to allow it in
21
+
22
+ ### Creating the Discord bot
23
+
24
+ In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and bot, then:
25
+
26
+ - Under **Bot**, enable the **Message Content** privileged intent (required - the bot reads message text/attachments).
27
+ - Under **OAuth2 → URL Generator**, select the **bot** and **applications.commands** scopes, then these bot permissions:
28
+ - Send Messages, Send Messages in Threads, Create Public Threads
29
+ - Read Message History, Attach Files, Embed Links, Add Reactions
30
+ - Connect, Speak (for voice channel support)
31
+ - Use the generated URL to invite the bot to your server.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ npm install -g linkgravity
37
+ ```
38
+
39
+ This runs a postinstall step that creates a Python virtual environment at `~/.gemini/linkgravity/venv/` (kept outside the package install location on purpose - see `npm-scripts/venv-paths.js`) and installs `requirements.txt` into it - no manual `pip install` needed.
40
+
41
+ ## Setup
42
+
43
+ Run the configuration wizard once to set your bot token, allowed servers/channels, and other settings:
44
+
45
+ ```bash
46
+ lgy setup
47
+ ```
48
+
49
+ This writes to `~/.gemini/linkgravity/lgy.json` (outside the package directory, so `npm update`/reinstall never touches it). You can re-run `lgy setup` any time to change settings later - each field keeps its current value if you leave it empty.
50
+
51
+ During setup you'll be asked for one or more Discord servers to allow, and optionally specific channels within each:
52
+
53
+ - Leave the channel list empty for a server → **the whole server** is allowed (any channel can start a session).
54
+ - List specific channel IDs for a server → **only those channels** in that server are allowed.
55
+
56
+ A new session is only ever started with the **`/new`** slash command in Discord - never just by typing a message. `/new` works both in a regular channel and from inside an existing thread.
57
+
58
+ ## Usage
59
+
60
+ ```bash
61
+ lgy start # Start the bot as a background daemon (via PM2)
62
+ lgy stop # Stop it
63
+ lgy restart # Restart it
64
+ lgy logs # View live logs (add -f to follow, --tail N for more lines)
65
+ lgy enable # Register the bot to auto-start on system boot
66
+ lgy disable # Remove it from system boot
67
+ lgy # Interactive menu (same commands, picked from a list)
68
+ ```
69
+
70
+ ## Development
71
+
72
+ This project uses [Ruff](https://docs.astral.sh/ruff/) for Python linting/formatting and [Prettier](https://prettier.io/) for the Node.js side. `npm install` sets both up automatically (installs `requirements-dev.txt` into the venv at `~/.gemini/linkgravity/venv/`, registers git hooks) - manual install is only needed if you want to run them yourself outside of a commit. The venv lives outside this checkout (see `npm-scripts/venv-paths.js` for why), so on macOS/Linux:
73
+
74
+ ```bash
75
+ ~/.gemini/linkgravity/venv/bin/ruff check src/ # lint
76
+ ~/.gemini/linkgravity/venv/bin/ruff format src/ # format
77
+
78
+ npm run format:check # check JS formatting
79
+ npm run format # format JS
80
+ ```
81
+
82
+ (On Windows, replace `venv/bin/ruff` with `venv\Scripts\ruff.exe` under the same `~/.gemini/linkgravity/` directory.)
83
+
84
+ Git hooks (via [pre-commit](https://pre-commit.com/), config in `.pre-commit-config.yaml`) run automatically once you `npm install`:
85
+
86
+ - **pre-commit**: runs `ruff` (lint + format) and `prettier` on staged files, auto-fixing what it can.
87
+ - **commit-msg**: enforces [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix: ...`, `feat: ...`, `docs: ...`) via [conventional-pre-commit](https://github.com/compilerla/conventional-pre-commit).
88
+
89
+ If a hook doesn't seem to be running, check `git config --get core.hooksPath` - it should be unset (or point at `.git/hooks`, pre-commit's default). A leftover `.husky` value from an older checkout will silently make git skip pre-commit's hooks entirely; `git config --unset core.hooksPath` fixes it.
90
+
91
+ ## Debugging
92
+
93
+ Set `LOG_LEVEL=DEBUG` in your shell before `lgy restart` for verbose logs, including agy's raw stdout for each turn (`lgy` passes your shell's environment through to the daemon on restart). Defaults to `INFO`. Use `lgy logs -t` (or `--timestamp`) to include timestamps - they're stripped by default.
94
+
95
+ ## Known Issues
96
+
97
+ **Wake word false positives on short words** (e.g. "시리", "잼민이"): Rustpotter's phoneme matching carries less signal for 1-2 syllable words, so genuine-match and unrelated-speech score distributions overlap - no single threshold cleanly separates them. Current mitigations in `voice-service/index.js`'s `getDetectorForUser` and `cogs/voice_cog.py`'s `handle_stt_input`:
98
+
99
+ - `score_mode: Max` (each of the 5 enrollment samples can cover a different natural tone/pace, instead of requiring all 5 to be delivered consistently like `Median` did)
100
+ - `min_scores: 4` (requires a candidate to keep winning across several frames, compensating for `Max` being more permissive per-frame)
101
+ - The STT-based text cross-check is tightened specifically for short wake words (similarity floor 0.55, vs. 0.35 for longer ones) - this is currently doing most of the real work of rejecting false positives
102
+
103
+ This isn't fully solved. If issues persist after real-world use, prefer these over further threshold guessing:
104
+
105
+ 1. Encourage re-enrolling with a longer/more distinctive wake word (a 1-2 syllable word is close to a hard ceiling for this approach, regardless of tuning)
106
+ 2. Log `bestWakeScore` + outcome (no raw audio) during a trial period and re-tune the constants above against that data instead of guessing
107
+
108
+ ## Security Warning
109
+
110
+ This bot gives an AI agent broad access to the machine it runs on - **that's inherent to what it does, so don't expose it publicly or run it somewhere you don't fully trust its users.**
111
+
112
+ - `allowed_user_ids` (set via `lgy setup`) is your primary access control - always set it.
113
+ - Tool calls (including shell commands) go through an approval flow in Discord by default; treat anyone in `allowed_user_ids` as having effectively full control of this machine.
114
+ - Your Discord token and other settings live in `~/.gemini/linkgravity/lgy.json`, outside this repo/package directory - never commit or share that file.
package/bin/cli.js ADDED
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, spawnSync } = require('child_process');
4
+ const path = require('path');
5
+
6
+ // Find the absolute path to the Python bot script
7
+ const botPath = path.join(__dirname, '..', 'src', 'main.py');
8
+ const { python: pythonExe, isWin } = require('../npm-scripts/venv-paths');
9
+
10
+ const cmd = process.argv[2];
11
+
12
+ const color = {
13
+ reset: '\x1b[0m',
14
+ green: '\x1b[32m',
15
+ cyan: '\x1b[36m',
16
+ yellow: '\x1b[33m',
17
+ dim: '\x1b[2m',
18
+ };
19
+
20
+ function success(msg) {
21
+ console.log(`${color.green}✔${color.reset} ${msg}`);
22
+ }
23
+
24
+ function info(msg) {
25
+ console.log(`\n${color.cyan}▶${color.reset} ${msg}`);
26
+ }
27
+
28
+ function runPm2(args, silent = true) {
29
+ const stdioOpt = silent ? 'pipe' : 'inherit';
30
+ const result = spawnSync('npx', ['-y', 'pm2', ...args], {
31
+ stdio: stdioOpt,
32
+ cwd: path.join(__dirname, '..'),
33
+ // pm2 pipes the Python process's stdout rather than giving it a
34
+ // TTY, so Python defaults to block-buffering it - occasional
35
+ // log lines (like a single WARNING) can sit in that buffer
36
+ // indefinitely instead of reaching `pm2 logs`/bot.log. This
37
+ // forces line-by-line flushing regardless of interpreter/OS.
38
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
39
+ });
40
+
41
+ if (result.error) {
42
+ console.error('Failed to execute PM2:', result.error.message);
43
+ process.exit(1);
44
+ }
45
+
46
+ let hasSudoInstructions = false;
47
+ if (silent && result.stdout && (args[0] === 'startup' || args[0] === 'unstartup')) {
48
+ const out = result.stdout.toString();
49
+ const lines = out.split('\n');
50
+ for (const line of lines) {
51
+ if (
52
+ line.trim().startsWith('sudo env PATH') ||
53
+ line.trim().startsWith('sudo su -c') ||
54
+ line.includes('sudo ')
55
+ ) {
56
+ console.log(
57
+ `\n\n${color.yellow}⚠ Action Required:${color.reset} To complete setup, copy and paste this command into your terminal:\n`,
58
+ );
59
+ console.log(` ${color.cyan}${line.trim()}${color.reset}\n`);
60
+ hasSudoInstructions = true;
61
+ }
62
+ }
63
+ }
64
+
65
+ if (result.status !== 0 && !hasSudoInstructions) {
66
+ if (silent && result.stderr) {
67
+ console.error(result.stderr.toString().trim());
68
+ }
69
+ process.exit(result.status);
70
+ }
71
+ }
72
+
73
+ // Matches a leading timestamp in either format our logs actually use:
74
+ // "2026-07-19 19:11:25 INFO ..." (loguru)
75
+ // "[2026-07-19 14:31:53] [INFO ] ..." (aiohttp access log)
76
+ // Only strips the FIRST bracket group if present, so aiohttp's second
77
+ // "[INFO ]" bracket (not a timestamp) is left alone.
78
+ const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
79
+
80
+ function runPm2LogsClean(args, showStamps = false) {
81
+ const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
82
+
83
+ const filterAndPrint = (data) => {
84
+ const lines = data.toString().split('\n');
85
+ for (const line of lines) {
86
+ if (line.trim().length === 0) continue;
87
+ if (
88
+ line.includes('In-memory PM2') ||
89
+ line.includes('pm2 update') ||
90
+ line.includes('[TAILING]') ||
91
+ line.includes('.pm2/logs/lgy') ||
92
+ line.includes('In memory PM2 version') ||
93
+ line.includes('Local PM2 version') ||
94
+ line.match(/^>+ /)
95
+ ) {
96
+ continue;
97
+ }
98
+ console.log(showStamps ? line : line.replace(TIMESTAMP_PREFIX, ''));
99
+ }
100
+ };
101
+
102
+ cp.stdout.on('data', filterAndPrint);
103
+ cp.stderr.on('data', filterAndPrint);
104
+ }
105
+
106
+ function verifyStartup() {
107
+ process.stdout.write(
108
+ `${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
109
+ );
110
+
111
+ let cp = spawn('npx', ['-y', 'pm2', 'logs', 'lgy', '--raw', '--lines', '0'], {
112
+ cwd: path.join(__dirname, '..'),
113
+ });
114
+
115
+ let timer = setTimeout(() => {
116
+ console.log(
117
+ `\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
118
+ );
119
+ cp.kill();
120
+ process.exit(1);
121
+ }, 15000);
122
+
123
+ const checkLog = (data) => {
124
+ const str = data.toString();
125
+ if (str.includes('Bot is fully online and ready!')) {
126
+ clearTimeout(timer);
127
+ console.log(
128
+ `\n${color.green}✔${color.reset} Bot successfully came online and is connected to Discord!\n`,
129
+ );
130
+ cp.kill();
131
+ process.exit(0);
132
+ } else if (
133
+ str.includes('Traceback (most recent call last):') ||
134
+ str.includes('Error:') ||
135
+ str.includes('Exception:')
136
+ ) {
137
+ clearTimeout(timer);
138
+ console.log(`\n\n${color.yellow}❌ Error detected during startup:${color.reset}`);
139
+ const errorLines = str
140
+ .split('\n')
141
+ .filter(
142
+ (l) =>
143
+ !l.includes('In-memory') && !l.includes('[TAILING]') && l.trim().length > 0,
144
+ );
145
+ console.log(errorLines.join('\n'));
146
+ cp.kill();
147
+ process.exit(1);
148
+ }
149
+ };
150
+
151
+ cp.stdout.on('data', checkLog);
152
+ cp.stderr.on('data', checkLog);
153
+ }
154
+
155
+ if (cmd === 'start') {
156
+ info('Starting LinkGravity daemon...');
157
+ runPm2(['start', botPath, '--interpreter', pythonExe, '--name', 'lgy']);
158
+ verifyStartup();
159
+ } else if (cmd === 'stop') {
160
+ info('Stopping LinkGravity daemon...');
161
+ runPm2(['stop', 'lgy']);
162
+ success('Daemon stopped successfully.\n');
163
+ } else if (cmd === 'restart') {
164
+ info('Restarting LinkGravity daemon...');
165
+ runPm2(['restart', 'lgy', '--update-env']);
166
+ verifyStartup();
167
+ } else if (cmd === 'logs') {
168
+ let args = process.argv.slice(3);
169
+ let pm2Args = ['logs', 'lgy'];
170
+ let isFollow = false;
171
+ let showStamps = false;
172
+
173
+ for (let i = 0; i < args.length; i++) {
174
+ if (args[i] === '--tail' || args[i] === '-n') {
175
+ pm2Args.push('--lines', args[i + 1] || '15');
176
+ i++;
177
+ } else if (args[i] === '-f') {
178
+ isFollow = true;
179
+ } else if (args[i] === '-t' || args[i] === '--stamp' || args[i] === '--timestamp' || args[i] === '--timestamps') {
180
+ showStamps = true;
181
+ } else {
182
+ pm2Args.push(args[i]);
183
+ }
184
+ }
185
+
186
+ if (!isFollow) {
187
+ pm2Args.push('--nostream');
188
+ }
189
+ pm2Args.push('--raw');
190
+ runPm2LogsClean(pm2Args, showStamps);
191
+ } else if (cmd === 'enable') {
192
+ if (isWin) {
193
+ console.log(
194
+ `\n${color.yellow}⚠${color.reset} pm2 doesn't support auto-start-on-boot on Windows natively. ` +
195
+ `Use a third-party tool like pm2-windows-startup (https://github.com/marklagendijk/node-pm2-windows-startup) instead.`,
196
+ );
197
+ process.exit(1);
198
+ }
199
+ info('Registering LinkGravity to start on system boot...');
200
+ runPm2(['startup']);
201
+ runPm2(['save']);
202
+ success('Auto-start configuration saved.\n');
203
+ } else if (cmd === 'disable') {
204
+ if (isWin) {
205
+ console.log(
206
+ `\n${color.yellow}⚠${color.reset} pm2 doesn't support auto-start-on-boot on Windows natively - ` +
207
+ `nothing to disable here. If you set it up via a third-party tool, remove it through that tool.`,
208
+ );
209
+ process.exit(1);
210
+ }
211
+ info('Removing LinkGravity from system boot...');
212
+ runPm2(['unstartup']);
213
+ runPm2(['save']);
214
+ success('Auto-start configuration removed.\n');
215
+ } else if (cmd === 'setup' || cmd === 'init') {
216
+ const runSetup = require('./setup');
217
+ runSetup().catch((err) => {
218
+ console.error('Setup wizard crashed:', err.message);
219
+ });
220
+ } else if (cmd === 'help') {
221
+ console.log(
222
+ [
223
+ '',
224
+ '🌌 LinkGravity (lgy / linkgravity)',
225
+ '',
226
+ 'Usage: lgy <command> [options]',
227
+ '',
228
+ 'Commands:',
229
+ ' start Start bot in the background (PM2 daemon)',
230
+ ' stop Stop the background bot',
231
+ ' restart Restart the background bot',
232
+ ' logs View bot logs (Options: --tail, -n, -f, -t/--timestamp)',
233
+ ' enable Register bot to start automatically on system boot',
234
+ ' disable Remove bot from system boot',
235
+ ' setup Run the configuration wizard (init)',
236
+ ' help Show this help message',
237
+ '',
238
+ ].join('\n'),
239
+ );
240
+ } else if (!cmd) {
241
+ const p = require('@clack/prompts');
242
+ (async () => {
243
+ console.log();
244
+ p.intro(`${color.cyan}🌌 LinkGravity Interactive Menu${color.reset}`);
245
+
246
+ const action = await p.select({
247
+ message: 'What would you like to do?',
248
+ options: [
249
+ { label: 'Start', value: 'start', hint: 'Start the bot daemon in the background' },
250
+ { label: 'Stop', value: 'stop', hint: 'Stop the running daemon' },
251
+ { label: 'Restart', value: 'restart', hint: 'Restart the running daemon' },
252
+ { label: 'Logs', value: 'logs', hint: 'View the live console logs' },
253
+ { label: 'Setup', value: 'setup', hint: 'Configure bot tokens and settings' },
254
+ {
255
+ label: 'Enable Auto-start',
256
+ value: 'enable',
257
+ hint: 'Turn ON automatic boot on system startup',
258
+ },
259
+ { label: 'Disable Auto-start', value: 'disable', hint: 'Turn OFF automatic boot' },
260
+ { label: 'Exit', value: 'exit', hint: 'Close this menu' },
261
+ ],
262
+ });
263
+
264
+ if (p.isCancel(action) || action === 'exit') {
265
+ p.cancel('Menu closed.');
266
+ process.exit(0);
267
+ }
268
+
269
+ p.outro(`Executing: ${action}`);
270
+ const { spawnSync } = require('child_process');
271
+ spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
272
+ })();
273
+ } else {
274
+ // If no valid command was provided, show help
275
+ console.log(
276
+ `\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
277
+ );
278
+ }
package/bin/setup.js ADDED
@@ -0,0 +1,260 @@
1
+ const p = require('@clack/prompts');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const color = {
8
+ reset: '\x1b[0m',
9
+ green: '\x1b[32m',
10
+ cyan: '\x1b[36m',
11
+ yellow: '\x1b[33m',
12
+ };
13
+
14
+ const workspaceDir = path.join(os.homedir(), '.gemini', 'linkgravity');
15
+ const settingsPath = path.join(workspaceDir, 'lgy.json');
16
+
17
+ if (!fs.existsSync(workspaceDir)) fs.mkdirSync(workspaceDir, { recursive: true });
18
+
19
+ function getSettings() {
20
+ if (fs.existsSync(settingsPath)) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
23
+ } catch (e) {}
24
+ }
25
+ return {};
26
+ }
27
+
28
+ function updateSettings(updates) {
29
+ const settings = getSettings();
30
+ for (const [key, value] of Object.entries(updates)) {
31
+ settings[key] = value;
32
+ }
33
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
34
+ }
35
+
36
+ function splitIds(raw) {
37
+ return raw.split(/[\s,;]+/).filter(Boolean);
38
+ }
39
+
40
+ async function collectSessionScopes(existingScopes) {
41
+ const scopes = [];
42
+ const hasExisting = existingScopes && existingScopes.length > 0;
43
+
44
+ p.note(
45
+ 'A new AI session can only be started with the /new command in Discord - never just by ' +
46
+ 'typing a message. This step controls WHERE /new is allowed to work.\n\n' +
47
+ 'For each server: leave "channels" empty to allow /new in EVERY channel of that server, ' +
48
+ 'or list specific channel IDs to restrict it to just those.',
49
+ 'Server / Channel Access',
50
+ );
51
+
52
+ let isFirst = true;
53
+ while (true) {
54
+ const promptSuffix =
55
+ isFirst && hasExisting
56
+ ? ' (leave empty to keep your current server/channel settings entirely unchanged)'
57
+ : ' (leave empty if you have no more servers to add)';
58
+
59
+ const guildId = await p.text({
60
+ message: `Server (Guild) ID to allow${promptSuffix}. Right-click the SERVER NAME (not a channel) → Copy Server ID:`,
61
+ });
62
+ if (p.isCancel(guildId)) {
63
+ p.cancel('Setup cancelled.');
64
+ process.exit(0);
65
+ }
66
+
67
+ if (!guildId) {
68
+ if (isFirst) return null; // signal: user wants to keep existing config untouched
69
+ break;
70
+ }
71
+ isFirst = false;
72
+
73
+ const channelIds = await p.text({
74
+ message:
75
+ 'Restrict to specific channel ID(s) in this server? Right-click a CHANNEL → Copy Channel ID. ' +
76
+ 'Comma-separated, or leave empty to allow the WHOLE server:',
77
+ });
78
+ if (p.isCancel(channelIds)) {
79
+ p.cancel('Setup cancelled.');
80
+ process.exit(0);
81
+ }
82
+
83
+ scopes.push({
84
+ guild_id: guildId.trim(),
85
+ channel_ids: channelIds ? splitIds(channelIds) : [],
86
+ });
87
+
88
+ const addAnother = await p.confirm({ message: 'Add another server?', initialValue: false });
89
+ if (p.isCancel(addAnother)) {
90
+ p.cancel('Setup cancelled.');
91
+ process.exit(0);
92
+ }
93
+ if (!addAnother) break;
94
+ }
95
+
96
+ return scopes;
97
+ }
98
+
99
+ async function collectUserIds(existingIds) {
100
+ const ids = [];
101
+ const hasExisting = existingIds && existingIds.length > 0;
102
+
103
+ p.note(
104
+ 'ONLY these users can use the bot (leave completely empty on first setup to allow EVERYONE). ' +
105
+ "Not related to DMs - this only gates the channel/threads configured above.",
106
+ 'Allowed Discord Users',
107
+ );
108
+
109
+ let isFirst = true;
110
+ while (true) {
111
+ const promptSuffix =
112
+ isFirst && hasExisting
113
+ ? ' (leave empty to keep your current allowed-user settings entirely unchanged)'
114
+ : ' (leave empty if you have no more users to add)';
115
+
116
+ const userId = await p.text({
117
+ message: `Discord User ID to allow${promptSuffix}:`,
118
+ });
119
+ if (p.isCancel(userId)) {
120
+ p.cancel('Setup cancelled.');
121
+ process.exit(0);
122
+ }
123
+
124
+ if (!userId) {
125
+ if (isFirst) return null; // signal: keep existing config untouched
126
+ break;
127
+ }
128
+ isFirst = false;
129
+
130
+ ids.push(userId.trim());
131
+
132
+ const addAnother = await p.confirm({ message: 'Add another user?', initialValue: false });
133
+ if (p.isCancel(addAnother)) {
134
+ p.cancel('Setup cancelled.');
135
+ process.exit(0);
136
+ }
137
+ if (!addAnother) break;
138
+ }
139
+
140
+ return ids;
141
+ }
142
+
143
+ async function runSetup() {
144
+ console.log();
145
+ p.intro(`${color.cyan}▶ LinkGravity Setup Wizard${color.reset}`);
146
+
147
+ const platform = await p.select({
148
+ message: 'Which messenger platform would you like to configure?',
149
+ options: [{ label: 'Discord', value: 'discord', hint: 'Configure Discord bot settings' }],
150
+ });
151
+ if (p.isCancel(platform)) {
152
+ p.cancel('Setup cancelled.');
153
+ process.exit(0);
154
+ }
155
+
156
+ const discordToken = await p.password({
157
+ message: 'Discord Bot Token (Leave empty to keep current):',
158
+ });
159
+ if (p.isCancel(discordToken)) {
160
+ p.cancel('Setup cancelled.');
161
+ process.exit(0);
162
+ }
163
+
164
+ const existingSettings = getSettings();
165
+ const sessionScopes = await collectSessionScopes(existingSettings.session_scopes);
166
+
167
+ const existingUserIds = existingSettings.allowed_user_ids
168
+ ? splitIds(existingSettings.allowed_user_ids)
169
+ : [];
170
+ const userIds = await collectUserIds(existingUserIds);
171
+
172
+ p.note(
173
+ 'Wake words aren\'t set here anymore - they need a voice recording to register '
174
+ + '(so only your voice triggers them), which this terminal wizard can\'t do. '
175
+ + 'Set them from Discord with `/sound wake_words:<word>` once the bot is running.',
176
+ 'Wake Words',
177
+ );
178
+
179
+ const group = await p.group(
180
+ {
181
+ tts_voice: () =>
182
+ p.select({
183
+ message:
184
+ 'TTS Voice Model (Select default voice - you can change this anytime later in Discord via /sound, which lists many more):',
185
+ options: [
186
+ {
187
+ label: 'ko-KR-SunHiNeural (Korean Female - Default)',
188
+ value: 'ko-KR-SunHiNeural',
189
+ },
190
+ { label: 'ko-KR-InJoonNeural (Korean Male)', value: 'ko-KR-InJoonNeural' },
191
+ { label: 'en-US-AriaNeural (English Female)', value: 'en-US-AriaNeural' },
192
+ { label: 'en-US-GuyNeural (English Male)', value: 'en-US-GuyNeural' },
193
+ {
194
+ label: 'en-US-AnaNeural (English Female, child-like)',
195
+ value: 'en-US-AnaNeural',
196
+ },
197
+ {
198
+ label: 'en-US-ChristopherNeural (English Male)',
199
+ value: 'en-US-ChristopherNeural',
200
+ },
201
+ {
202
+ label: 'en-GB-SoniaNeural (English Female, UK)',
203
+ value: 'en-GB-SoniaNeural',
204
+ },
205
+ { label: 'en-GB-RyanNeural (English Male, UK)', value: 'en-GB-RyanNeural' },
206
+ {
207
+ label: 'en-AU-NatashaNeural (English Female, AU)',
208
+ value: 'en-AU-NatashaNeural',
209
+ },
210
+ {
211
+ label: 'en-AU-WilliamNeural (English Male, AU)',
212
+ value: 'en-AU-WilliamNeural',
213
+ },
214
+ {
215
+ label: 'ja-JP-NanamiNeural (Japanese Female)',
216
+ value: 'ja-JP-NanamiNeural',
217
+ },
218
+ { label: 'ja-JP-KeitaNeural (Japanese Male)', value: 'ja-JP-KeitaNeural' },
219
+ {
220
+ label: 'fr-FR-DeniseNeural (French Female)',
221
+ value: 'fr-FR-DeniseNeural',
222
+ },
223
+ { label: 'de-DE-KatjaNeural (German Female)', value: 'de-DE-KatjaNeural' },
224
+ {
225
+ label: 'es-ES-ElviraNeural (Spanish Female)',
226
+ value: 'es-ES-ElviraNeural',
227
+ },
228
+ ],
229
+ }),
230
+ },
231
+ {
232
+ onCancel: () => {
233
+ p.cancel('Setup cancelled.');
234
+ process.exit(0);
235
+ },
236
+ },
237
+ );
238
+
239
+ const settingsUpdates = {};
240
+ if (discordToken) settingsUpdates.discord_token = discordToken;
241
+ if (sessionScopes !== null) settingsUpdates.session_scopes = sessionScopes;
242
+ if (userIds !== null) settingsUpdates.allowed_user_ids = userIds.join(',');
243
+ if (group.tts_voice) settingsUpdates.tts_voice = group.tts_voice;
244
+
245
+ if (Object.keys(settingsUpdates).length > 0) {
246
+ updateSettings(settingsUpdates);
247
+ }
248
+
249
+ p.note('Configuration saved to lgy.json successfully!', 'Success');
250
+
251
+ console.log(`${color.cyan}▶${color.reset} Restarting daemon to apply changes...`);
252
+ spawnSync('npx', ['-y', 'pm2', 'restart', 'lgy', '--update-env'], {
253
+ stdio: 'pipe',
254
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
255
+ });
256
+
257
+ p.outro('Daemon restarted.');
258
+ }
259
+
260
+ module.exports = runSetup;