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 +25 -48
- package/bin/cli.js +68 -21
- package/bin/setup.js +1 -3
- package/package.json +1 -1
- package/src/cogs/general_cog.py +1 -4
- package/src/cogs/voice/enrollment.py +1 -1
- package/src/cogs/voice_cog.py +11 -19
- package/src/config.py +23 -9
package/README.md
CHANGED
|
@@ -1,33 +1,32 @@
|
|
|
1
|
-
# LinkGravity
|
|
1
|
+
# LinkGravity
|
|
2
2
|
|
|
3
|
-
A Discord bot interface for the Antigravity
|
|
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
|
|
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
|
-
- **
|
|
11
|
-
- **
|
|
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
|
-
##
|
|
13
|
+
## Requirements
|
|
15
14
|
|
|
16
15
|
- Node.js >= 18
|
|
17
16
|
- Python >= 3.10
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
- Tool calls
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
package/src/cogs/general_cog.py
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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]
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
ww_list = [f"`{w.strip()}`" for w in
|
|
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
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|