linkgravity 1.2.0 → 1.2.2

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/README.md CHANGED
@@ -1,33 +1,32 @@
1
- # LinkGravity (lgy)
1
+ # LinkGravity
2
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.
3
+ A Discord bot interface for the Antigravity agentic AI system. It translates Antigravity CLI prompts into Discord UI components and provides voice interaction capabilities.
4
4
 
5
5
  ## Features
6
6
 
7
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.
8
+ - **Voice Interaction:** Supports voice channels with adaptive voice activity detection to segment speech and filter environmental noise, plus live "listening..." feedback while you're still talking.
9
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.
10
+ - **Approval Flow:** Command and tool-call approvals become interactive Discord buttons. Chained shell commands are approved individually, and any approval can be scoped to auto-allow that command or tool going forward - something plain `agy` doesn't do.
11
+ - **Multi-Modal Input:** Attach files for the AI to read, including audio, which gets transcribed to text automatically.
13
12
 
14
- ## Prerequisites
13
+ ## Requirements
15
14
 
16
15
  - Node.js >= 18
17
16
  - 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
17
+ - Antigravity CLI installed on this machine
18
+ - A messenger bot token, and at least one server/channel to allow it in
19
+ - **Discord** - currently the only one supported
21
20
 
22
21
  ### Creating the Discord bot
23
22
 
24
23
  In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and bot, then:
25
24
 
26
- - Under **Bot**, enable the **Message Content** privileged intent (required - the bot reads message text/attachments).
25
+ - Under **Bot**, enable the **Message Content** privileged intent - required, since the bot reads message text/attachments.
27
26
  - Under **OAuth2 → URL Generator**, select the **bot** and **applications.commands** scopes, then these bot permissions:
28
27
  - Send Messages, Send Messages in Threads, Create Public Threads
29
28
  - Read Message History, Attach Files, Embed Links, Add Reactions
30
- - Connect, Speak (for voice channel support)
29
+ - Connect, Speak - for voice channel support
31
30
  - Use the generated URL to invite the bot to your server.
32
31
 
33
32
  ## Installation
@@ -36,7 +35,7 @@ In the [Discord Developer Portal](https://discord.com/developers/applications),
36
35
  npm install -g linkgravity
37
36
  ```
38
37
 
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.
38
+ Sets up its own Python environment automatically - no manual `pip install` needed.
40
39
 
41
40
  ## Setup
42
41
 
@@ -46,11 +45,11 @@ Run the configuration wizard once to set your bot token, allowed servers/channel
46
45
  lgy setup
47
46
  ```
48
47
 
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.
48
+ 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
49
 
51
50
  During setup you'll be asked for one or more Discord servers to allow, and optionally specific channels within each:
52
51
 
53
- - Leave the channel list empty for a server → **the whole server** is allowed (any channel can start a session).
52
+ - Leave the channel list empty for a server → **the whole server** is allowed - any channel can start a session.
54
53
  - List specific channel IDs for a server → **only those channels** in that server are allowed.
55
54
 
56
55
  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.
@@ -58,57 +57,35 @@ A new session is only ever started with the **`/new`** slash command in Discord
58
57
  ## Usage
59
58
 
60
59
  ```bash
61
- lgy start # Start the bot as a background daemon (via PM2)
60
+ lgy start # Start the bot as a background daemon via PM2
62
61
  lgy stop # Stop it
63
62
  lgy restart # Restart it
64
- lgy logs # View live logs (add -f to follow, --tail N for more lines)
63
+ lgy logs # View live logs - add -f to follow, --tail N for more lines
65
64
  lgy enable # Register the bot to auto-start on system boot
66
65
  lgy disable # Remove it from system boot
67
- lgy # Interactive menu (same commands, picked from a list)
66
+ lgy # Interactive menu - same commands, picked from a list
68
67
  ```
69
68
 
70
69
  ## Development
71
70
 
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
71
  ```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
72
+ npm i
80
73
  ```
81
74
 
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.
75
+ That's it - it wires up [Ruff](https://docs.astral.sh/ruff/) for Python and [Prettier](https://prettier.io/) for Node, plus git hooks that lint/format on commit and enforce [Conventional Commits](https://www.conventionalcommits.org/) commit messages.
90
76
 
91
77
  ## Debugging
92
78
 
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:
79
+ ```bash
80
+ LOG_LEVEL=DEBUG lgy start
81
+ ```
104
82
 
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
83
+ Use `lgy logs -t` to include timestamps.
107
84
 
108
85
  ## Security Warning
109
86
 
110
87
  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
88
 
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.
89
+ - `allowed_user_ids`, set via `lgy setup`, is your primary access control - always set it.
90
+ - 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
91
  - 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 CHANGED
@@ -14,6 +14,7 @@ const color = {
14
14
  green: '\x1b[32m',
15
15
  cyan: '\x1b[36m',
16
16
  yellow: '\x1b[33m',
17
+ red: '\x1b[31m',
17
18
  dim: '\x1b[2m',
18
19
  };
19
20
 
@@ -76,31 +77,71 @@ function runPm2(args, silent = true) {
76
77
  // Only strips the FIRST bracket group if present, so aiohttp's second
77
78
  // "[INFO ]" bracket (not a timestamp) is left alone.
78
79
  const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
80
+ // loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
81
+ // eslint-disable-next-line no-control-regex
82
+ const ANSI_ESCAPE = /\x1b\[[0-9;]*m/g;
83
+
84
+ const LEVEL_COLOR = {
85
+ DEBUG: color.dim,
86
+ INFO: color.cyan,
87
+ WARNING: color.yellow,
88
+ ERROR: color.red,
89
+ CRITICAL: color.red,
90
+ };
91
+
92
+ // Recolors the level word ourselves so Python (loguru) and Node (plain console.log) lines match.
93
+ function colorizeLevel(line) {
94
+ return line.replace(/\b(DEBUG|INFO|WARNING|ERROR|CRITICAL)\b/, (match) => {
95
+ const c = LEVEL_COLOR[match];
96
+ return c ? `${c}${match}${color.reset}` : match;
97
+ });
98
+ }
79
99
 
80
100
  function runPm2LogsClean(args, showStamps = false) {
81
101
  const cp = spawn('npx', ['-y', 'pm2', ...args], { cwd: path.join(__dirname, '..') });
82
102
 
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, ''));
103
+ const printLine = (line) => {
104
+ if (line.trim().length === 0) return;
105
+ if (
106
+ line.includes('In-memory PM2') ||
107
+ line.includes('pm2 update') ||
108
+ line.includes('[TAILING]') ||
109
+ line.includes('.pm2/logs/lgy') ||
110
+ line.includes('In memory PM2 version') ||
111
+ line.includes('Local PM2 version') ||
112
+ line.match(/^>+ /)
113
+ ) {
114
+ return;
99
115
  }
116
+ const clean = line.replace(ANSI_ESCAPE, '');
117
+ const displayLine = colorizeLevel(showStamps ? clean : clean.replace(TIMESTAMP_PREFIX, ''));
118
+ console.log(displayLine);
100
119
  };
101
120
 
102
- cp.stdout.on('data', filterAndPrint);
103
- cp.stderr.on('data', filterAndPrint);
121
+ // A line can arrive split across two 'data' events, so buffer until '\n' is seen.
122
+ function makeChunkHandler() {
123
+ let buffer = '';
124
+ const handler = (data) => {
125
+ buffer += data.toString();
126
+ const lines = buffer.split('\n');
127
+ buffer = lines.pop(); // last element: '' if buffer ended in '\n', else the incomplete tail
128
+ for (const line of lines) printLine(line);
129
+ };
130
+ handler.flush = () => {
131
+ if (buffer) printLine(buffer);
132
+ buffer = '';
133
+ };
134
+ return handler;
135
+ }
136
+
137
+ const stdoutHandler = makeChunkHandler();
138
+ const stderrHandler = makeChunkHandler();
139
+ cp.stdout.on('data', stdoutHandler);
140
+ cp.stderr.on('data', stderrHandler);
141
+ cp.on('close', () => {
142
+ stdoutHandler.flush();
143
+ stderrHandler.flush();
144
+ });
104
145
  }
105
146
 
106
147
  function verifyStartup() {
@@ -168,7 +209,14 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
168
209
  runPm2(['restart', 'lgy', '--update-env']);
169
210
  verifyStartup();
170
211
  } else if (cmd === 'logs') {
171
- let args = process.argv.slice(3);
212
+ const SHORT_FLAGS = ['-f', '-n', '-t'];
213
+ let args = process.argv.slice(3).flatMap((arg) => {
214
+ // Only split bare combined short flags (e.g. "-fn" -> "-f", "-n"), not "--long" flags.
215
+ if (!/^-[a-z]{2,}$/.test(arg)) return [arg];
216
+ const chars = arg.slice(1).split('');
217
+ if (!chars.every((c) => SHORT_FLAGS.includes(`-${c}`))) return [arg];
218
+ return chars.map((c) => `-${c}`);
219
+ });
172
220
  let pm2Args = ['logs', 'lgy'];
173
221
  let isFollow = false;
174
222
  let showStamps = false;
@@ -265,8 +313,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
265
313
  if (restartResult.status === 0) {
266
314
  verifyStartup();
267
315
  } else if ((restartResult.stderr || '').toString().includes('not found')) {
268
- // Daemon wasn't running before the update - start it fresh instead
269
- // of reporting a restart that never had anything to restart.
316
+ // Wasn't running before the update - start fresh instead of a false "restarted".
270
317
  info("Daemon wasn't running - starting it fresh...");
271
318
  runPm2(['start', botPath, '--interpreter', pythonExe, '--name', 'lgy']);
272
319
  verifyStartup();
package/bin/setup.js CHANGED
@@ -278,9 +278,7 @@ async function runSetup() {
278
278
  } else {
279
279
  const stderr = (restartResult.stderr || '').toString();
280
280
  if (stderr.includes('not found')) {
281
- // Nothing to restart yet (first-ever setup, or pm2's process list was
282
- // reset e.g. after a reboot without `pm2 save`) - start it instead of
283
- // reporting a false "restarted" success.
281
+ // Nothing to restart yet - start it instead of a false "restarted".
284
282
  const botPath = path.join(__dirname, '..', 'src', 'main.py');
285
283
  const startResult = spawnSync(
286
284
  'npx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
@@ -273,10 +273,7 @@ class GeneralCog(commands.Cog):
273
273
  prev_tts.cancel()
274
274
  session_manager.remove_tts_task(thread_id)
275
275
 
276
- # A killed turn leaves the conversation in an unknown state -
277
- # reusing conv_id risks silently hanging on the next message.
278
- # conversation_id must be cleared too, not just status: the
279
- # text path only treats a session as pending when both are unset.
276
+ # Must clear conversation_id too, not just status - pending requires both unset.
280
277
  session_manager.set_session(thread_id, {**session, "status": "pending", "conversation_id": None})
281
278
 
282
279
  from core.agy_runner import stop_active_process
@@ -337,7 +337,7 @@ class EnrollmentManager:
337
337
  except aiohttp.ClientError as e:
338
338
  self.logger.warning(f"Failed to invalidate cached detector for {user_id}: {e}")
339
339
 
340
- self.bot_settings["wake_words"] = session["word"]
340
+ self.bot_settings.setdefault("wake_words", {})[user_id] = session["word"]
341
341
  self.save_bot_settings(self.bot_settings)
342
342
 
343
343
  del self._enrollment[user_id]
@@ -44,8 +44,7 @@ class VoiceCog(commands.Cog):
44
44
  self.save_bot_settings = save_bot_settings
45
45
  self.logger = logger
46
46
  self._voice_state = {}
47
- # guild_id -> in-flight handle_stt_input task, if any. A new
48
- # utterance cancels the previous one so replies can't race out of order.
47
+ # guild_id -> in-flight handle_stt_input task; a new utterance cancels the previous one.
49
48
  self._active_turns = {}
50
49
  self.enrollment = EnrollmentManager(
51
50
  bot, self._voice_state, self._play_audio, self.bot_settings, self.save_bot_settings, self.logger
@@ -175,12 +174,13 @@ class VoiceCog(commands.Cog):
175
174
  vc_chan = interaction.user.voice.channel
176
175
  guild_id = interaction.guild_id
177
176
 
178
- has_wake_word = bool(self.bot_settings.get("wake_words"))
177
+ wake_word_map = self.bot_settings.get("wake_words") or {}
178
+ has_wake_word = bool(wake_word_map)
179
179
  active_timer = self.bot_settings.get("active_timer", 60)
180
180
 
181
181
  if has_wake_word:
182
- wake_words_raw = self.bot_settings["wake_words"]
183
- ww_list = [f"`{w.strip()}`" for w in wake_words_raw.split(",") if w.strip()]
182
+ # dict.fromkeys dedupes while keeping first-registered order (each user has their own word).
183
+ ww_list = [f"`{w.strip()}`" for w in dict.fromkeys(wake_word_map.values()) if w.strip()]
184
184
  ww_str = ", ".join(ww_list[:-1]) + f", or {ww_list[-1]}" if len(ww_list) > 1 else ww_list[0]
185
185
  msg = (
186
186
  f"🎤 Connected to `{vc_chan.name}`.\n"
@@ -275,7 +275,7 @@ class VoiceCog(commands.Cog):
275
275
  and tts_voice is None
276
276
  and tts_enabled is None
277
277
  ):
278
- curr_wake = self.bot_settings.get("wake_words", "None")
278
+ curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
279
279
  curr_timer = self.bot_settings.get("active_timer", 60)
280
280
  curr_thresh = self.bot_settings.get("voice_threshold", 3000)
281
281
  curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
@@ -292,8 +292,7 @@ class VoiceCog(commands.Cog):
292
292
  updated = []
293
293
  wake_word_pending = False
294
294
  if wake_word is not None:
295
- # Deferred: wake_words only gets set once 5 samples are
296
- # recorded and confirmed (see EnrollmentManager), not here.
295
+ # Deferred - wake_words is set later once 5 samples are recorded (see EnrollmentManager).
297
296
  wake_word_pending = True
298
297
  if active_times is not None:
299
298
  self.bot_settings["active_timer"] = active_times
@@ -383,8 +382,7 @@ class VoiceCog(commands.Cog):
383
382
  await self.stt_session.clear_partial_msg(str(guild_id))
384
383
  return
385
384
 
386
- # Node runs STT itself before calling this endpoint, so this is
387
- # already-recognized text, not raw audio - keeps STT off every VAD hit.
385
+ # Node runs STT itself before this call, so 'text' is already-recognized, not raw audio.
388
386
  text = data.get("text")
389
387
 
390
388
  thread_id = self._voice_state.get(str(guild_id))
@@ -407,10 +405,7 @@ class VoiceCog(commands.Cog):
407
405
  import difflib
408
406
  import re
409
407
 
410
- # Wake detection is Node's audio-based Rustpotter detector's job.
411
- # No text-similarity fallback: an unenrolled user (or a failed
412
- # .rpw build - see EnrollmentManager._build_rustpotter_reference)
413
- # just can't wake the bot until that's fixed.
408
+ # Wake detection is Node's Rustpotter detector's job - no text-similarity fallback.
414
409
  is_waking_up = bool(data.get("wake_confirmed"))
415
410
  matched_wake_word = data.get("matched_wake_word")
416
411
  is_active = self.stt_session.is_active(str(guild_id))
@@ -444,8 +439,7 @@ class VoiceCog(commands.Cog):
444
439
  if prefix_similarity >= 0.6:
445
440
  text_to_ai = " ".join(words[wake_word_count:]).strip()
446
441
 
447
- # No text resemblance at all + short wake word (less acoustic
448
- # signal, more false-wakes) -> require a closer match to trust it.
442
+ # Short wake words have less acoustic signal (more false-wakes) -> need a closer match.
449
443
  wake_syllables = len(re.sub(r"[^\w가-힣]", "", matched_wake_word or ""))
450
444
  min_prefix_similarity = 0.55 if wake_syllables <= 2 else 0.35
451
445
  if is_waking_up and prefix_similarity is not None and prefix_similarity < min_prefix_similarity:
@@ -458,9 +452,7 @@ class VoiceCog(commands.Cog):
458
452
 
459
453
  self.logger.debug(f"STT recognized: {text} -> AI: {text_to_ai}")
460
454
 
461
- # A previous in-flight turn for this guild is now stale - cancel
462
- # it, kill its agy process (like /stop), and stop its playback,
463
- # rather than letting two turns race to completion.
455
+ # A stale in-flight turn for this guild - cancel it, kill its agy process, stop playback.
464
456
  prev_task = self._active_turns.get(str(guild_id))
465
457
  if prev_task and not prev_task.done():
466
458
  prev_task.cancel()
package/src/config.py CHANGED
@@ -7,6 +7,10 @@ WORKSPACE_DIR = Path.home() / ".gemini" / "linkgravity"
7
7
  WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
8
8
  DATA_DIR = WORKSPACE_DIR / "data"
9
9
  DATA_DIR.mkdir(parents=True, exist_ok=True)
10
+ # Per-user wake-word recordings + built .rpw reference (see EnrollmentManager).
11
+ # Defined early so load_bot_settings' migration below can read it.
12
+ WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
13
+ WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
10
14
 
11
15
  LGY_CONFIG_FILE = WORKSPACE_DIR / "lgy.json"
12
16
 
@@ -14,13 +18,13 @@ DEFAULT_LGY_CONFIG = {
14
18
  "discord_token": "",
15
19
  "session_scopes": [],
16
20
  "allowed_user_ids": "",
17
- "wake_words": "Jarvis",
21
+ # user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
22
+ "wake_words": {},
18
23
  "active_timer": 60,
19
24
  "voice_threshold": 3000,
20
25
  "tts_voice": "ko-KR-SunHiNeural",
21
26
  "tts_enabled": True,
22
- # Sticky default for new /new sessions - set whenever /model succeeds,
23
- # so a new thread doesn't fall back to agy's own settings.json model.
27
+ # Sticky default for /new sessions, set whenever /model succeeds.
24
28
  "default_model": "",
25
29
  }
26
30
 
@@ -34,10 +38,26 @@ class _PrintLogger:
34
38
  print(f"[config] {msg}")
35
39
 
36
40
 
41
+ def _migrate_legacy_wake_words():
42
+ """Pre-1.3, wake_words was one global string shared by everyone and
43
+ overwritten by each /sound call. The .rpw files were always saved per
44
+ user_id though, so rebuild the real per-user mapping from those."""
45
+ migrated = {}
46
+ for user_dir in WAKE_REF_DIR.iterdir():
47
+ if not user_dir.is_dir():
48
+ continue
49
+ rpw = next(user_dir.glob("*.rpw"), None)
50
+ if rpw:
51
+ migrated[user_dir.name] = rpw.stem.replace("_", " ")
52
+ return migrated
53
+
54
+
37
55
  def load_bot_settings():
38
56
  data = safe_load_json(LGY_CONFIG_FILE, DEFAULT_LGY_CONFIG.copy(), logger=_PrintLogger())
39
57
  for k, v in DEFAULT_LGY_CONFIG.items():
40
58
  data.setdefault(k, v)
59
+ if isinstance(data.get("wake_words"), str):
60
+ data["wake_words"] = _migrate_legacy_wake_words()
41
61
  return data
42
62
 
43
63
 
@@ -94,15 +114,9 @@ def is_allowed_session_channel(channel) -> bool:
94
114
 
95
115
  TMP_FILE_DIR = WORKSPACE_DIR / "tmp-files"
96
116
  TMP_VOICE_DIR = WORKSPACE_DIR / "tmp-voice"
97
- # Per-user wake-word recordings + built .rpw reference (see
98
- # EnrollmentManager in cogs/voice/enrollment.py). No folder/.rpw yet means
99
- # "not enrolled" - falls back to transcribing everything and matching
100
- # bot_settings["wake_words"] as text.
101
- WAKE_REF_DIR = WORKSPACE_DIR / "wake_refs"
102
117
 
103
118
  TMP_FILE_DIR.mkdir(parents=True, exist_ok=True)
104
119
  TMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
105
- WAKE_REF_DIR.mkdir(parents=True, exist_ok=True)
106
120
 
107
121
  MAX_EMBED_LEN = 1900
108
122
  STREAM_RATE_LIMIT_SEC = 0.5