linkgravity 1.5.6 → 1.5.8
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 +24 -26
- package/bin/setup.js +7 -0
- package/hooks/hook.py +14 -1
- package/npm-scripts/ensure-env.js +40 -0
- package/npm-scripts/prepare.js +5 -7
- package/package.json +1 -2
- package/requirements.txt +0 -3
- package/src/api/server.py +11 -2
- package/src/api/ui_routes.py +3 -57
- package/src/cogs/voice_cog.py +45 -22
- package/src/config.py +10 -1
- package/src/handlers/thread_reply.py +1 -1
- package/src/messengers/base.py +1 -3
- package/src/messengers/discord_adapter.py +2 -2
- package/src/services/discord_helpers.py +29 -1
- package/src/services/response.py +11 -14
- package/src/services/streaming.py +2 -2
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +5 -2
- package/voice-service/receiver.js +38 -15
- package/voice-service/routes.js +19 -5
- package/voice-service/state.js +15 -2
- package/voice-service/tts.js +2 -1
- package/voice-service/wakeword.js +9 -4
- package/npm-scripts/postinstall.js +0 -48
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
|
|
|
@@ -174,7 +175,10 @@ function verifyStartup() {
|
|
|
174
175
|
`${color.cyan}▶${color.reset} Verifying startup status (waiting for bot to come online)...`,
|
|
175
176
|
);
|
|
176
177
|
|
|
177
|
-
|
|
178
|
+
// Spawned directly instead of through npx: npx wraps pm2 in "npm exec" + "sh -c", so cp.kill()
|
|
179
|
+
// reaps only the wrapper and leaves the real pm2 logs process orphaned onto init.
|
|
180
|
+
const pm2Bin = require.resolve('pm2/bin/pm2');
|
|
181
|
+
let cp = spawn(process.execPath, [pm2Bin, 'logs', LGY_PM2_NAME, '--raw', '--lines', '0'], {
|
|
178
182
|
cwd: path.join(__dirname, '..'),
|
|
179
183
|
});
|
|
180
184
|
|
|
@@ -187,12 +191,6 @@ function verifyStartup() {
|
|
|
187
191
|
resolve(ok);
|
|
188
192
|
};
|
|
189
193
|
|
|
190
|
-
// Give the --lines 20 replay burst a moment to flush before treating error text as a fresh crash, not old log noise.
|
|
191
|
-
let errorDetectionArmed = false;
|
|
192
|
-
setTimeout(() => {
|
|
193
|
-
errorDetectionArmed = true;
|
|
194
|
-
}, 1500);
|
|
195
|
-
|
|
196
194
|
let timer = setTimeout(() => {
|
|
197
195
|
console.log(
|
|
198
196
|
`\n\n${color.yellow}⏳ Startup verification timed out. Run 'lgy logs' to check status manually.${color.reset}`,
|
|
@@ -211,7 +209,6 @@ function verifyStartup() {
|
|
|
211
209
|
console.log(`\n${color.green}✔${color.reset} Bot successfully came online!\n`);
|
|
212
210
|
finish(true);
|
|
213
211
|
} else if (
|
|
214
|
-
errorDetectionArmed &&
|
|
215
212
|
(str.includes('Traceback (most recent call last):') ||
|
|
216
213
|
str.includes('Error:') ||
|
|
217
214
|
str.includes('Exception:')) &&
|
|
@@ -378,6 +375,14 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
378
375
|
process.exit(1);
|
|
379
376
|
}
|
|
380
377
|
|
|
378
|
+
if (!isEnvironmentReady()) {
|
|
379
|
+
console.log(
|
|
380
|
+
`\n${color.yellow}⚠${color.reset} Python environment isn't set up yet - ` +
|
|
381
|
+
`run ${color.cyan}lgy setup${color.reset} first (it installs everything on its first run).\n`,
|
|
382
|
+
);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
381
386
|
info('Starting LinkGravity daemon...');
|
|
382
387
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
383
388
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
@@ -584,6 +589,9 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
584
589
|
process.exit(0);
|
|
585
590
|
}
|
|
586
591
|
|
|
592
|
+
const procBeforeUpdate = getPm2Proc();
|
|
593
|
+
const wasOnline = !!procBeforeUpdate && procBeforeUpdate.pm2_env.status === 'online';
|
|
594
|
+
|
|
587
595
|
info(`Updating: v${currentVersion} -> v${latestVersion}...`);
|
|
588
596
|
const installResult = spawnSync('npm', ['install', '-g', 'linkgravity@latest'], {
|
|
589
597
|
stdio: 'inherit',
|
|
@@ -594,27 +602,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
594
602
|
}
|
|
595
603
|
success(`Installed v${latestVersion}.`);
|
|
596
604
|
|
|
597
|
-
|
|
598
|
-
const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', LGY_PM2_NAME, '--update-env'], {
|
|
599
|
-
stdio: 'pipe',
|
|
600
|
-
cwd: path.join(__dirname, '..'),
|
|
601
|
-
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
if (restartResult.status === 0) {
|
|
605
|
-
runPm2(['reset', LGY_PM2_NAME]);
|
|
606
|
-
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
607
|
-
} else if ((restartResult.stderr || '').toString().includes('not found')) {
|
|
608
|
-
// Wasn't running before the update - start fresh instead of a false "restarted".
|
|
605
|
+
if (!procBeforeUpdate) {
|
|
609
606
|
info("Daemon wasn't running - starting it fresh...");
|
|
610
607
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
611
608
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
609
|
+
} else if (wasOnline) {
|
|
610
|
+
info('Restarting daemon to apply the update...');
|
|
611
|
+
runPm2(['restart', LGY_PM2_NAME, '--update-env']);
|
|
612
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
613
|
+
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
612
614
|
} else {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
`\n${color.yellow}⚠${color.reset} Update installed, but restarting the daemon failed - run 'lgy restart' manually.`,
|
|
616
|
-
);
|
|
617
|
-
process.exit(1);
|
|
615
|
+
success(`Daemon was stopped - leaving it stopped. Run 'lgy start' when you're ready.\n`);
|
|
616
|
+
process.exit(0);
|
|
618
617
|
}
|
|
619
618
|
} else if (cmd === 'help') {
|
|
620
619
|
console.log(
|
|
@@ -684,7 +683,6 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
684
683
|
spawnSync(process.argv[0], [process.argv[1], action], { stdio: 'inherit' });
|
|
685
684
|
})();
|
|
686
685
|
} else {
|
|
687
|
-
// If no valid command was provided, show help
|
|
688
686
|
console.log(
|
|
689
687
|
`\n❌ Unknown command: ${cmd || 'none'}\n💡 Run 'lgy help' to see available commands.`,
|
|
690
688
|
);
|
package/bin/setup.js
CHANGED
|
@@ -446,6 +446,13 @@ 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
|
+
|
|
449
456
|
const registerHook = require('../npm-scripts/register-hook');
|
|
450
457
|
if (!registerHook.isHookRegistered()) {
|
|
451
458
|
const consent = await p.confirm({
|
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",
|
|
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 };
|
package/npm-scripts/prepare.js
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
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 `
|
|
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
|
|
22
|
-
// created (
|
|
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 });
|
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkgravity",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.8",
|
|
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/requirements.txt
CHANGED
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
|
package/src/api/ui_routes.py
CHANGED
|
@@ -9,6 +9,7 @@ from aiohttp import web
|
|
|
9
9
|
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
10
10
|
from messengers.base import ScopeOption
|
|
11
11
|
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
12
|
+
from utils.utils import split_message
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def is_tool_allowed(tool_name, tool_input):
|
|
@@ -57,8 +58,8 @@ def allow_response(tool_name, tool_input):
|
|
|
57
58
|
async def _send_chunked(adapter, thread, text: str) -> None:
|
|
58
59
|
if not text:
|
|
59
60
|
return
|
|
60
|
-
for
|
|
61
|
-
await adapter.send_message(thread,
|
|
61
|
+
for part in split_message(text, MAX_EMBED_LEN):
|
|
62
|
+
await adapter.send_message(thread, part)
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
def _persist_scope_if_granted(prompt_handle):
|
|
@@ -324,58 +325,3 @@ async def handle_approve_request(request):
|
|
|
324
325
|
for key in registered_approval_keys:
|
|
325
326
|
session_manager.clear_pending_approval(key)
|
|
326
327
|
return web.json_response({"decision": "allow"})
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
async def handle_mcp_ask(request):
|
|
330
|
-
try:
|
|
331
|
-
data = await request.json()
|
|
332
|
-
thread_id = data.get("thread_id")
|
|
333
|
-
|
|
334
|
-
question = _clean_inline(data.get("question", "No question provided."))
|
|
335
|
-
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
|
|
336
|
-
|
|
337
|
-
adapter = get_adapter_for_thread(thread_id)
|
|
338
|
-
thread = adapter.resolve_conversation(thread_id)
|
|
339
|
-
if not thread:
|
|
340
|
-
return web.json_response({"answer": "Thread not found"}, status=400)
|
|
341
|
-
|
|
342
|
-
future = asyncio.get_event_loop().create_future()
|
|
343
|
-
# conv_id is always None here - key just needs to be unique for cleanup.
|
|
344
|
-
approval_key = f"mcp_ask:{thread_id}:{uuid.uuid4().hex}"
|
|
345
|
-
session_manager.set_pending_approval(approval_key, future, "ask_question")
|
|
346
|
-
|
|
347
|
-
prompt = adapter.create_question_prompt(future, question, options, allow_write_in=True)
|
|
348
|
-
msg = await prompt.send(thread)
|
|
349
|
-
session_manager.pending_approval_messages[approval_key] = msg
|
|
350
|
-
|
|
351
|
-
try:
|
|
352
|
-
answer = await asyncio.wait_for(future, timeout=300)
|
|
353
|
-
return web.json_response({"answer": answer})
|
|
354
|
-
except asyncio.TimeoutError:
|
|
355
|
-
return web.json_response({"answer": "User did not respond in time."})
|
|
356
|
-
finally:
|
|
357
|
-
await prompt.finalize()
|
|
358
|
-
session_manager.pending_approval_messages.pop(approval_key, None)
|
|
359
|
-
session_manager.clear_pending_approval(approval_key)
|
|
360
|
-
except Exception as e:
|
|
361
|
-
return web.json_response({"answer": f"Error: {e}"}, status=500)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
async def handle_mcp_send_channel(request):
|
|
365
|
-
try:
|
|
366
|
-
data = await request.json()
|
|
367
|
-
channel_id = data.get("channel_id")
|
|
368
|
-
message = data.get("message", "")
|
|
369
|
-
|
|
370
|
-
adapter = get_adapter_for_thread(channel_id)
|
|
371
|
-
channel = adapter.resolve_conversation(channel_id)
|
|
372
|
-
if not channel:
|
|
373
|
-
return web.json_response({"error": "Channel not found"}, status=400)
|
|
374
|
-
|
|
375
|
-
chunks = [message[i : i + MAX_EMBED_LEN] for i in range(0, len(message), MAX_EMBED_LEN)]
|
|
376
|
-
for chunk in chunks:
|
|
377
|
-
await adapter.send_message(channel, chunk)
|
|
378
|
-
|
|
379
|
-
return web.json_response({"answer": "success"})
|
|
380
|
-
except Exception as e:
|
|
381
|
-
return web.json_response({"error": str(e)}, status=500)
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import math
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
import aiohttp
|
|
@@ -15,6 +16,18 @@ from .voice.stt_session import SttSessionTracker
|
|
|
15
16
|
NODE_VOICE_API = "http://localhost:18081"
|
|
16
17
|
# Default aiohttp timeout is 5 minutes - too long for a dead voice service.
|
|
17
18
|
NODE_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=5)
|
|
19
|
+
# Must match DEFAULT_WAKE_THRESHOLD / DEFAULT_VAD_THRESHOLD in voice-service - the two processes
|
|
20
|
+
# decide these independently, and only voice-service's values actually gate anything.
|
|
21
|
+
DEFAULT_WAKE_THRESHOLD = 0.4
|
|
22
|
+
DEFAULT_VAD_THRESHOLD = 3000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _autocomplete_query(current) -> str:
|
|
26
|
+
# discord.py hands a focused NUMBER option back as float('nan') when the input box is empty,
|
|
27
|
+
# and passes INTEGER options through unconverted - neither is guaranteed to be a str.
|
|
28
|
+
if isinstance(current, float) and math.isnan(current):
|
|
29
|
+
return ""
|
|
30
|
+
return str(current)
|
|
18
31
|
|
|
19
32
|
|
|
20
33
|
class VoiceCog(commands.Cog):
|
|
@@ -60,6 +73,12 @@ class VoiceCog(commands.Cog):
|
|
|
60
73
|
def _wake_word_required(self, user_id) -> bool:
|
|
61
74
|
return (self.bot_settings.get("wake_word_required") or {}).get(str(user_id), True)
|
|
62
75
|
|
|
76
|
+
def _wake_threshold(self, user_id) -> float:
|
|
77
|
+
return (self.bot_settings.get("wake_thresholds") or {}).get(str(user_id), DEFAULT_WAKE_THRESHOLD)
|
|
78
|
+
|
|
79
|
+
def _vad_threshold(self, user_id) -> int:
|
|
80
|
+
return (self.bot_settings.get("voice_thresholds") or {}).get(str(user_id), DEFAULT_VAD_THRESHOLD)
|
|
81
|
+
|
|
63
82
|
async def handle_voice_service_down(self):
|
|
64
83
|
await self.enrollment.handle_voice_service_down()
|
|
65
84
|
|
|
@@ -83,8 +102,9 @@ class VoiceCog(commands.Cog):
|
|
|
83
102
|
self, interaction: discord.Interaction, current: str
|
|
84
103
|
) -> list[app_commands.Choice[int]]:
|
|
85
104
|
current_val = int(self.bot_settings.get("active_timer", 60))
|
|
105
|
+
query = _autocomplete_query(current)
|
|
86
106
|
opts = []
|
|
87
|
-
if str(current_val) in
|
|
107
|
+
if str(current_val) in query or not query:
|
|
88
108
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
89
109
|
|
|
90
110
|
for v in [30, 60, 120, 300]:
|
|
@@ -95,9 +115,10 @@ class VoiceCog(commands.Cog):
|
|
|
95
115
|
async def interrupt_threshold_autocomplete(
|
|
96
116
|
self, interaction: discord.Interaction, current: str
|
|
97
117
|
) -> list[app_commands.Choice[int]]:
|
|
98
|
-
current_val =
|
|
118
|
+
current_val = self._vad_threshold(interaction.user.id)
|
|
119
|
+
query = _autocomplete_query(current)
|
|
99
120
|
opts = []
|
|
100
|
-
if str(current_val) in
|
|
121
|
+
if str(current_val) in query or not query:
|
|
101
122
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
102
123
|
|
|
103
124
|
for v in [1000, 2000, 3000, 5000]:
|
|
@@ -108,9 +129,10 @@ class VoiceCog(commands.Cog):
|
|
|
108
129
|
async def wake_sensitivity_autocomplete(
|
|
109
130
|
self, interaction: discord.Interaction, current: str
|
|
110
131
|
) -> list[app_commands.Choice[float]]:
|
|
111
|
-
current_val =
|
|
132
|
+
current_val = self._wake_threshold(interaction.user.id)
|
|
133
|
+
query = _autocomplete_query(current)
|
|
112
134
|
opts = []
|
|
113
|
-
if str(current_val) in
|
|
135
|
+
if str(current_val) in query or not query:
|
|
114
136
|
opts.append(app_commands.Choice(name=f"{current_val} (current)", value=current_val))
|
|
115
137
|
|
|
116
138
|
for v in [0.2, 0.3, 0.4, 0.5, 0.6]:
|
|
@@ -184,8 +206,9 @@ class VoiceCog(commands.Cog):
|
|
|
184
206
|
self, interaction: discord.Interaction, current: str
|
|
185
207
|
) -> list[app_commands.Choice[float]]:
|
|
186
208
|
current_val = float(self.bot_settings.get("tts_speed", 1.0))
|
|
209
|
+
query = _autocomplete_query(current)
|
|
187
210
|
opts = []
|
|
188
|
-
if str(current_val) in
|
|
211
|
+
if str(current_val) in query or not query:
|
|
189
212
|
opts.append(app_commands.Choice(name=f"{current_val}x (current)", value=current_val))
|
|
190
213
|
|
|
191
214
|
for v in [0.75, 1.0, 1.25, 1.3, 1.5, 1.75, 2.0]:
|
|
@@ -346,8 +369,8 @@ class VoiceCog(commands.Cog):
|
|
|
346
369
|
):
|
|
347
370
|
curr_wake = (self.bot_settings.get("wake_words") or {}).get(str(interaction.user.id), "None")
|
|
348
371
|
curr_timer = self.bot_settings.get("active_timer", 60)
|
|
349
|
-
curr_interrupt_thresh = self.
|
|
350
|
-
curr_wake_sens = self.
|
|
372
|
+
curr_interrupt_thresh = self._vad_threshold(interaction.user.id)
|
|
373
|
+
curr_wake_sens = self._wake_threshold(interaction.user.id)
|
|
351
374
|
curr_tts = self.bot_settings.get("tts_voice", "en-US-AriaNeural")
|
|
352
375
|
curr_tts_on = "ON" if self.bot_settings.get("tts_enabled", True) else "OFF"
|
|
353
376
|
curr_tts_speed = self.bot_settings.get("tts_speed", 1.0)
|
|
@@ -373,30 +396,30 @@ class VoiceCog(commands.Cog):
|
|
|
373
396
|
self.bot_settings["active_timer"] = active_times
|
|
374
397
|
updated.append(f"⏱️ Active Timer: `{active_times}s`")
|
|
375
398
|
if interrupt_threshold is not None:
|
|
376
|
-
self.bot_settings
|
|
399
|
+
self.bot_settings.setdefault("voice_thresholds", {})[str(interaction.user.id)] = interrupt_threshold
|
|
377
400
|
updated.append(f"🔊 Interrupt Threshold: `{interrupt_threshold}`")
|
|
378
401
|
try:
|
|
379
402
|
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
380
|
-
await session.post(
|
|
381
|
-
|
|
382
|
-
|
|
403
|
+
await session.post(
|
|
404
|
+
f"{NODE_VOICE_API}/set_vad_threshold",
|
|
405
|
+
json={"user_id": str(interaction.user.id), "threshold": interrupt_threshold},
|
|
406
|
+
)
|
|
407
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
408
|
+
self.logger.warning(f"Node.js vad-threshold sync failed for {interaction.user.id}: {e}")
|
|
383
409
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
384
|
-
except asyncio.TimeoutError:
|
|
385
|
-
self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
|
|
386
|
-
updated.append("(⚠️ Node.js Sync Timeout)")
|
|
387
410
|
if wake_sensitivity is not None:
|
|
388
411
|
clamped_wake = max(0.05, min(0.95, wake_sensitivity))
|
|
389
|
-
self.bot_settings
|
|
412
|
+
self.bot_settings.setdefault("wake_thresholds", {})[str(interaction.user.id)] = clamped_wake
|
|
390
413
|
updated.append(f"🎯 Wake Sensitivity: `{clamped_wake}`")
|
|
391
414
|
try:
|
|
392
415
|
async with aiohttp.ClientSession(timeout=NODE_REQUEST_TIMEOUT) as session:
|
|
393
|
-
await session.post(
|
|
394
|
-
|
|
395
|
-
|
|
416
|
+
await session.post(
|
|
417
|
+
f"{NODE_VOICE_API}/set_wake_threshold",
|
|
418
|
+
json={"user_id": str(interaction.user.id), "threshold": clamped_wake},
|
|
419
|
+
)
|
|
420
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
421
|
+
self.logger.warning(f"Node.js wake-threshold sync failed for {interaction.user.id}: {e}")
|
|
396
422
|
updated.append(f"(⚠️ Node.js Sync Failed: {e})")
|
|
397
|
-
except asyncio.TimeoutError:
|
|
398
|
-
self.logger.warning(f"Node.js sync timeout for {interaction.guild_id}")
|
|
399
|
-
updated.append("(⚠️ Node.js Sync Timeout)")
|
|
400
423
|
if tts_voice is not None:
|
|
401
424
|
self.bot_settings["tts_voice"] = tts_voice
|
|
402
425
|
updated.append(f"🗣️ TTS Voice: `{tts_voice}`")
|
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
|
|
@@ -24,12 +25,16 @@ DEFAULT_LGY_CONFIG = {
|
|
|
24
25
|
"allowed_user_ids": "",
|
|
25
26
|
# user_id (str) -> registered word, one per person (see EnrollmentManager._commit_enrollment).
|
|
26
27
|
"wake_words": {},
|
|
28
|
+
# user_id (str) -> wake-word match threshold; absent means voice-service's own default.
|
|
29
|
+
"wake_thresholds": {},
|
|
30
|
+
# user_id (str) -> interrupt/VAD RMS threshold; absent means voice-service's own default.
|
|
31
|
+
"voice_thresholds": {},
|
|
27
32
|
"active_timer": 60,
|
|
28
|
-
"voice_threshold": 3000,
|
|
29
33
|
"tts_voice": "ko-KR-SunHiNeural",
|
|
30
34
|
"tts_enabled": True,
|
|
31
35
|
# Sticky default for /new sessions, set whenever /model succeeds.
|
|
32
36
|
"default_model": "",
|
|
37
|
+
"approve_token": "",
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
|
|
@@ -71,6 +76,10 @@ def save_bot_settings(data):
|
|
|
71
76
|
|
|
72
77
|
bot_settings = load_bot_settings()
|
|
73
78
|
|
|
79
|
+
if not bot_settings.get("approve_token"):
|
|
80
|
+
bot_settings["approve_token"] = secrets.token_hex(24)
|
|
81
|
+
save_bot_settings(bot_settings)
|
|
82
|
+
|
|
74
83
|
from core.logger import init_logger
|
|
75
84
|
from core.session_manager import SessionManager
|
|
76
85
|
|
|
@@ -74,7 +74,7 @@ async def handle_pending_session(
|
|
|
74
74
|
await stream_task
|
|
75
75
|
|
|
76
76
|
response_text = result_text
|
|
77
|
-
if adapter.
|
|
77
|
+
if adapter.should_auto_title(thread):
|
|
78
78
|
new_title = await generate_thread_title(content, response_text)
|
|
79
79
|
await adapter.rename_conversation(thread, new_title)
|
|
80
80
|
await update_agy_conversation_title(new_conv_id, new_title)
|
package/src/messengers/base.py
CHANGED
|
@@ -96,9 +96,7 @@ class MessengerAdapter(ABC):
|
|
|
96
96
|
async def start_conversation(self, origin_ref: Any, title: str) -> Any:
|
|
97
97
|
raise NotImplementedError
|
|
98
98
|
|
|
99
|
-
def
|
|
100
|
-
"""Per-conversation version of supports_renaming - lets a single adapter answer
|
|
101
|
-
differently depending on the target (e.g. Discord threads vs. Discord DMs)."""
|
|
99
|
+
def should_auto_title(self, conversation_ref: Any) -> bool:
|
|
102
100
|
return self.supports_renaming
|
|
103
101
|
|
|
104
102
|
@abstractmethod
|
|
@@ -149,8 +149,8 @@ class DiscordAdapter(MessengerAdapter):
|
|
|
149
149
|
async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
|
|
150
150
|
return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
|
|
151
151
|
|
|
152
|
-
def
|
|
153
|
-
return isinstance(conversation_ref, discord.Thread)
|
|
152
|
+
def should_auto_title(self, conversation_ref: Any) -> bool:
|
|
153
|
+
return isinstance(conversation_ref, discord.Thread) and conversation_ref.name.startswith("Session-")
|
|
154
154
|
|
|
155
155
|
async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
|
|
156
156
|
if not isinstance(conversation_ref, discord.Thread):
|
|
@@ -74,7 +74,7 @@ def check_approval_intent(text: str) -> str:
|
|
|
74
74
|
"ㅇㅇ",
|
|
75
75
|
"ㅇㅋ",
|
|
76
76
|
"해",
|
|
77
|
-
"
|
|
77
|
+
"그래",
|
|
78
78
|
"네",
|
|
79
79
|
"sure",
|
|
80
80
|
"yeah",
|
|
@@ -92,3 +92,31 @@ def check_approval_intent(text: str) -> str:
|
|
|
92
92
|
if word in exact_allow:
|
|
93
93
|
return "allow"
|
|
94
94
|
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def split_message(text: str, limit: int) -> list[str]:
|
|
98
|
+
parts = []
|
|
99
|
+
fence = None
|
|
100
|
+
remaining = text
|
|
101
|
+
while remaining:
|
|
102
|
+
# A chunk that ends mid-code-block gets closed here and reopened at the top of the next one,
|
|
103
|
+
# otherwise the client renders the rest of the message as one runaway code block.
|
|
104
|
+
prefix = f"{fence}\n" if fence else ""
|
|
105
|
+
budget = limit - len(prefix) - len("\n```")
|
|
106
|
+
if len(remaining) <= budget:
|
|
107
|
+
body, remaining = remaining, ""
|
|
108
|
+
else:
|
|
109
|
+
window = remaining[:budget]
|
|
110
|
+
cuts = [pos + len(d) for d in ("\n\n", "\n", " ") if (pos := window.rfind(d)) > 0]
|
|
111
|
+
cut = next((c for c in cuts if c > budget // 2), max(cuts, default=0))
|
|
112
|
+
body, remaining = remaining[: cut or budget], remaining[cut or budget :]
|
|
113
|
+
chunk = prefix + body
|
|
114
|
+
fence = None
|
|
115
|
+
for match in re.finditer(r"^```(\S*)", chunk, re.MULTILINE):
|
|
116
|
+
fence = None if fence else f"```{match.group(1)}"
|
|
117
|
+
if fence:
|
|
118
|
+
chunk += "\n```"
|
|
119
|
+
if parts and not re.sub(r"^```\S*$", "", chunk, flags=re.MULTILINE).strip():
|
|
120
|
+
continue
|
|
121
|
+
parts.append(chunk)
|
|
122
|
+
return parts or [""]
|
package/src/services/response.py
CHANGED
|
@@ -3,6 +3,7 @@ from typing import Any
|
|
|
3
3
|
|
|
4
4
|
from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
|
|
5
5
|
from messengers.registry import get_adapter_for_platform
|
|
6
|
+
from services.discord_helpers import split_message
|
|
6
7
|
from utils.utils import get_current_model
|
|
7
8
|
|
|
8
9
|
|
|
@@ -17,24 +18,20 @@ async def send_agy_response(
|
|
|
17
18
|
adapter = get_adapter_for_platform(session.get("platform", "discord"))
|
|
18
19
|
session_manager.save_sessions()
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
is_last = idx == len(parts) - 1
|
|
21
|
+
session_model = session.get("model")
|
|
22
|
+
model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
parts = split_message(response_text, MAX_EMBED_LEN)
|
|
25
|
+
for idx, part in enumerate(parts):
|
|
26
|
+
if idx == len(parts) - 1:
|
|
25
27
|
if not part.strip():
|
|
26
28
|
continue
|
|
29
|
+
part = f"{part}\n-# 🤖 {model_display}"
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
status_msg = ctx.get("status_msg") if ctx else None
|
|
33
|
-
if status_msg and await adapter.edit_message(status_msg, text_to_send):
|
|
34
|
-
continue
|
|
35
|
-
await adapter.send_message(thread, text_to_send)
|
|
36
|
-
else:
|
|
37
|
-
await adapter.send_message(thread, part)
|
|
31
|
+
status_msg = ctx.get("status_msg") if ctx and idx == 0 else None
|
|
32
|
+
if status_msg and await adapter.edit_message(status_msg, part):
|
|
33
|
+
continue
|
|
34
|
+
await adapter.send_message(thread, part)
|
|
38
35
|
|
|
39
36
|
files_to_send = []
|
|
40
37
|
if conv_id and start_time:
|
|
@@ -7,7 +7,7 @@ import discord # only for voice/TTS text cleanup below; messaging goes through
|
|
|
7
7
|
|
|
8
8
|
from config import MAX_EMBED_LEN, STREAM_RATE_LIMIT_SEC, bot_settings, logger, session_manager
|
|
9
9
|
from messengers.registry import get_adapter_for_thread
|
|
10
|
-
from utils.utils import clean_ansi
|
|
10
|
+
from utils.utils import clean_ansi, split_message
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _clear_current_tool(thread_id: str):
|
|
@@ -46,7 +46,7 @@ class StreamUpdater:
|
|
|
46
46
|
async def split(self):
|
|
47
47
|
full_text = self.current_text.strip()
|
|
48
48
|
if full_text:
|
|
49
|
-
parts =
|
|
49
|
+
parts = split_message(full_text, self.MAX_EMBED_LEN)
|
|
50
50
|
for idx, part in enumerate(parts):
|
|
51
51
|
await self._update(part, force_new=(idx > 0))
|
|
52
52
|
self.status_msg = None
|
package/src/utils/utils.py
CHANGED
|
@@ -16,6 +16,7 @@ from services.discord_helpers import (
|
|
|
16
16
|
clean_ansi,
|
|
17
17
|
cleanup_images,
|
|
18
18
|
handle_image_attachments,
|
|
19
|
+
split_message,
|
|
19
20
|
)
|
|
20
21
|
|
|
21
22
|
__all__ = [
|
|
@@ -33,6 +34,7 @@ __all__ = [
|
|
|
33
34
|
"cleanup_images",
|
|
34
35
|
"build_content_with_images",
|
|
35
36
|
"clean_ansi",
|
|
37
|
+
"split_message",
|
|
36
38
|
"check_approval_intent",
|
|
37
39
|
"get_default_cwd",
|
|
38
40
|
]
|
package/voice-service/index.js
CHANGED
|
@@ -18,8 +18,11 @@ process.on('uncaughtException', (err) => {
|
|
|
18
18
|
|
|
19
19
|
const { registerRoutes } = require('./routes');
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
state.
|
|
21
|
+
for (const [userId, value] of Object.entries(aglConfig.voice_thresholds || {})) {
|
|
22
|
+
state.vadThresholds.set(userId, parseInt(value));
|
|
23
|
+
}
|
|
24
|
+
for (const [userId, value] of Object.entries(aglConfig.wake_thresholds || {})) {
|
|
25
|
+
state.wakeThresholds.set(userId, parseFloat(value));
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
const app = express();
|
|
@@ -2,11 +2,18 @@ const { EndBehaviorType } = require('@discordjs/voice');
|
|
|
2
2
|
const prism = require('prism-media');
|
|
3
3
|
const { stereoToMono, createWavHeader } = require('./audioUtils');
|
|
4
4
|
const { googleSTT } = require('./stt');
|
|
5
|
-
const { getDetectorForUser, feedPCMToDetector,
|
|
5
|
+
const { getDetectorForUser, feedPCMToDetector, wakeThresholdFor } = require('./wakeword');
|
|
6
6
|
const { interruptTTS } = require('./tts');
|
|
7
7
|
const state = require('./state');
|
|
8
|
-
const {
|
|
9
|
-
|
|
8
|
+
const { aglConfig } = require('./config');
|
|
9
|
+
const {
|
|
10
|
+
activeStreams,
|
|
11
|
+
enrollingUsers,
|
|
12
|
+
isPlaying,
|
|
13
|
+
wakeWordOptedOut,
|
|
14
|
+
vadThresholdFor,
|
|
15
|
+
isGuildActive,
|
|
16
|
+
} = state;
|
|
10
17
|
|
|
11
18
|
function setupReceiver(connection, guildId, client) {
|
|
12
19
|
const receiver = connection.receiver;
|
|
@@ -108,7 +115,10 @@ function setupReceiver(connection, guildId, client) {
|
|
|
108
115
|
partialSent = true;
|
|
109
116
|
fetch('http://127.0.0.1:18080/stt_partial', {
|
|
110
117
|
method: 'POST',
|
|
111
|
-
headers: {
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'X-LGY-Token': aglConfig.approve_token,
|
|
121
|
+
},
|
|
112
122
|
body: JSON.stringify({ guild_id: guildId, text }),
|
|
113
123
|
}).catch((err) =>
|
|
114
124
|
console.error(`[STT] Failed to send partial text to Python:`, err.message),
|
|
@@ -134,9 +144,8 @@ function setupReceiver(connection, guildId, client) {
|
|
|
134
144
|
const isBotPlaying = isPlaying.get(guildId) || false;
|
|
135
145
|
|
|
136
146
|
if (!hasInterrupted) {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
: runtime.vadThreshold;
|
|
147
|
+
const baseThreshold = vadThresholdFor(userId);
|
|
148
|
+
const dynamicThreshold = isBotPlaying ? baseThreshold * 3 : baseThreshold;
|
|
140
149
|
if (rms > dynamicThreshold) {
|
|
141
150
|
if (interruptTTS(guildId)) {
|
|
142
151
|
console.log(
|
|
@@ -222,15 +231,17 @@ function setupReceiver(connection, guildId, client) {
|
|
|
222
231
|
bestDiagScoreName = diagPaddingDetection.getName();
|
|
223
232
|
}
|
|
224
233
|
|
|
225
|
-
//
|
|
226
|
-
|
|
234
|
+
// rustpotter only emits a detection once its own per-user threshold is met, so any
|
|
235
|
+
// score reaching here is already a pass; the number below is for the log line only.
|
|
236
|
+
const threshold = wakeThresholdFor(userId);
|
|
237
|
+
wakeConfirmed = bestWakeScoreName !== null;
|
|
227
238
|
matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
|
|
228
239
|
console.log(
|
|
229
240
|
wakeConfirmed
|
|
230
241
|
? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
|
|
231
|
-
`"${bestWakeScoreName}", threshold ${
|
|
242
|
+
`"${bestWakeScoreName}", threshold ${threshold})`
|
|
232
243
|
: `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
|
|
233
|
-
`threshold ${
|
|
244
|
+
`threshold ${threshold}; diagnostic-only closeness ` +
|
|
234
245
|
`${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
|
|
235
246
|
`different scoring config, not directly comparable to the threshold)`,
|
|
236
247
|
);
|
|
@@ -248,7 +259,10 @@ function setupReceiver(connection, guildId, client) {
|
|
|
248
259
|
`http://127.0.0.1:18080/enroll_sample?user_id=${encodeURIComponent(userId)}`,
|
|
249
260
|
{
|
|
250
261
|
method: 'POST',
|
|
251
|
-
headers: {
|
|
262
|
+
headers: {
|
|
263
|
+
'Content-Type': 'application/octet-stream',
|
|
264
|
+
'X-LGY-Token': aglConfig.approve_token,
|
|
265
|
+
},
|
|
252
266
|
body: wavBuffer,
|
|
253
267
|
},
|
|
254
268
|
);
|
|
@@ -263,7 +277,10 @@ function setupReceiver(connection, guildId, client) {
|
|
|
263
277
|
if (partialSent) {
|
|
264
278
|
fetch('http://127.0.0.1:18080/stt_partial_cancel', {
|
|
265
279
|
method: 'POST',
|
|
266
|
-
headers: {
|
|
280
|
+
headers: {
|
|
281
|
+
'Content-Type': 'application/json',
|
|
282
|
+
'X-LGY-Token': aglConfig.approve_token,
|
|
283
|
+
},
|
|
267
284
|
body: JSON.stringify({ guild_id: guildId }),
|
|
268
285
|
}).catch(() => {});
|
|
269
286
|
}
|
|
@@ -277,7 +294,10 @@ function setupReceiver(connection, guildId, client) {
|
|
|
277
294
|
if (partialSent) {
|
|
278
295
|
fetch('http://127.0.0.1:18080/stt_partial_cancel', {
|
|
279
296
|
method: 'POST',
|
|
280
|
-
headers: {
|
|
297
|
+
headers: {
|
|
298
|
+
'Content-Type': 'application/json',
|
|
299
|
+
'X-LGY-Token': aglConfig.approve_token,
|
|
300
|
+
},
|
|
281
301
|
body: JSON.stringify({ guild_id: guildId }),
|
|
282
302
|
}).catch(() => {});
|
|
283
303
|
}
|
|
@@ -291,7 +311,10 @@ function setupReceiver(connection, guildId, client) {
|
|
|
291
311
|
try {
|
|
292
312
|
await fetch('http://127.0.0.1:18080/stt_input', {
|
|
293
313
|
method: 'POST',
|
|
294
|
-
headers: {
|
|
314
|
+
headers: {
|
|
315
|
+
'Content-Type': 'application/json',
|
|
316
|
+
'X-LGY-Token': aglConfig.approve_token,
|
|
317
|
+
},
|
|
295
318
|
body: JSON.stringify({
|
|
296
319
|
user_id: userId,
|
|
297
320
|
guild_id: guildId,
|
package/voice-service/routes.js
CHANGED
|
@@ -108,6 +108,19 @@ function registerRoutes(app, client) {
|
|
|
108
108
|
res.json({ success: true, was_cached: deleted });
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
app.post('/set_wake_threshold', (req, res) => {
|
|
112
|
+
const { user_id, threshold } = req.body;
|
|
113
|
+
if (!user_id || typeof threshold !== 'number') {
|
|
114
|
+
return res.status(400).json({ error: 'user_id and numeric threshold required' });
|
|
115
|
+
}
|
|
116
|
+
state.wakeThresholds.set(user_id, threshold);
|
|
117
|
+
// The threshold is baked into the rustpotter config at build time, so the cached detector
|
|
118
|
+
// has to go with it - otherwise the new value only takes effect after some unrelated reset.
|
|
119
|
+
state.detectorCache.delete(user_id);
|
|
120
|
+
console.log(`[Wake] Threshold for ${user_id} set to ${threshold}`);
|
|
121
|
+
res.json({ success: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
111
124
|
app.post('/build_wakeword', async (req, res) => {
|
|
112
125
|
// Builds a .rpw in-process via WakewordRefCreator, instead of shelling out to rustpotter-cli.
|
|
113
126
|
try {
|
|
@@ -139,12 +152,13 @@ function registerRoutes(app, client) {
|
|
|
139
152
|
}
|
|
140
153
|
});
|
|
141
154
|
|
|
142
|
-
app.post('/
|
|
143
|
-
const {
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
console.log(`[Config] Updated VAD threshold to ${state.runtime.vadThreshold}`);
|
|
155
|
+
app.post('/set_vad_threshold', (req, res) => {
|
|
156
|
+
const { user_id, threshold } = req.body;
|
|
157
|
+
if (!user_id || typeof threshold !== 'number') {
|
|
158
|
+
return res.status(400).json({ error: 'user_id and numeric threshold required' });
|
|
147
159
|
}
|
|
160
|
+
state.vadThresholds.set(user_id, threshold);
|
|
161
|
+
console.log(`[Config] VAD threshold for ${user_id} set to ${threshold}`);
|
|
148
162
|
res.json({ success: true });
|
|
149
163
|
});
|
|
150
164
|
|
package/voice-service/state.js
CHANGED
|
@@ -19,8 +19,18 @@ const suppressNotifyMap = new Map();
|
|
|
19
19
|
// userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
|
|
20
20
|
const detectorCache = new Map();
|
|
21
21
|
|
|
22
|
+
// user_id -> wake-word match threshold; absent means DEFAULT_WAKE_THRESHOLD.
|
|
23
|
+
const wakeThresholds = new Map();
|
|
24
|
+
|
|
22
25
|
// Object property, not a plain `let` - a `let` wouldn't propagate its reassignment across modules.
|
|
23
|
-
|
|
26
|
+
// user_id -> interrupt/VAD RMS threshold; absent means DEFAULT_VAD_THRESHOLD.
|
|
27
|
+
const vadThresholds = new Map();
|
|
28
|
+
|
|
29
|
+
const DEFAULT_VAD_THRESHOLD = 3000;
|
|
30
|
+
|
|
31
|
+
function vadThresholdFor(userId) {
|
|
32
|
+
return vadThresholds.get(userId) ?? DEFAULT_VAD_THRESHOLD;
|
|
33
|
+
}
|
|
24
34
|
|
|
25
35
|
function isGuildActive(guildId) {
|
|
26
36
|
return Date.now() < (activeUntil.get(guildId) || 0);
|
|
@@ -37,6 +47,9 @@ module.exports = {
|
|
|
37
47
|
wakeWordOptedOut,
|
|
38
48
|
suppressNotifyMap,
|
|
39
49
|
detectorCache,
|
|
40
|
-
|
|
50
|
+
wakeThresholds,
|
|
51
|
+
vadThresholds,
|
|
52
|
+
DEFAULT_VAD_THRESHOLD,
|
|
53
|
+
vadThresholdFor,
|
|
41
54
|
isGuildActive,
|
|
42
55
|
};
|
package/voice-service/tts.js
CHANGED
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const { detectorCache } = require('./state');
|
|
4
|
+
const { detectorCache, wakeThresholds } = require('./state');
|
|
5
5
|
|
|
6
6
|
// Rustpotter wake-word detection runs entirely in-process here, no Python round trip.
|
|
7
7
|
const WAKE_REF_DIR = path.join(os.homedir(), '.gemini', 'linkgravity', 'wake_refs');
|
|
@@ -21,7 +21,11 @@ function loadRustpotterModule() {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Wake-word confirm cutoff - must stay well above ~0.05 (rustpotter's countdown never finalizes if noise/silence clears it too); 0.4 chosen after live use kept narrowly missing genuine hits just under 0.5.
|
|
24
|
-
const
|
|
24
|
+
const DEFAULT_WAKE_THRESHOLD = 0.4;
|
|
25
|
+
|
|
26
|
+
function wakeThresholdFor(userId) {
|
|
27
|
+
return wakeThresholds.get(userId) ?? DEFAULT_WAKE_THRESHOLD;
|
|
28
|
+
}
|
|
25
29
|
|
|
26
30
|
async function getDetectorForUser(userId) {
|
|
27
31
|
if (detectorCache.has(userId)) return detectorCache.get(userId);
|
|
@@ -37,7 +41,7 @@ async function getDetectorForUser(userId) {
|
|
|
37
41
|
config.setSampleRate(48000);
|
|
38
42
|
config.setSampleFormat(mod.SampleFormat.i16);
|
|
39
43
|
config.setChannels(1);
|
|
40
|
-
config.setThreshold(
|
|
44
|
+
config.setThreshold(wakeThresholdFor(userId));
|
|
41
45
|
config.setAveragedThreshold(0);
|
|
42
46
|
// Live logs showed genuine attempts peaking above threshold but not sustaining 4 positive-scoring
|
|
43
47
|
// frames; lowered from 4. STT-side prefix-similarity check is the backstop against false wakes.
|
|
@@ -107,7 +111,8 @@ function feedPCMToDetector(entry, chunk) {
|
|
|
107
111
|
|
|
108
112
|
module.exports = {
|
|
109
113
|
WAKE_REF_DIR,
|
|
110
|
-
|
|
114
|
+
DEFAULT_WAKE_THRESHOLD,
|
|
115
|
+
wakeThresholdFor,
|
|
111
116
|
loadRustpotterModule,
|
|
112
117
|
getDetectorForUser,
|
|
113
118
|
feedPCMToDetector,
|
|
@@ -1,48 +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
|
-
// System python (not the venv's) - only used to create the venv below; every other
|
|
13
|
-
// script goes through venv-paths.js instead.
|
|
14
|
-
const isWin = os.platform() === 'win32';
|
|
15
|
-
const pyCmd = isWin ? 'python' : 'python3';
|
|
16
|
-
|
|
17
|
-
async function main() {
|
|
18
|
-
try {
|
|
19
|
-
// Fixed workspace dir, not cwd - see venv-paths.js for why.
|
|
20
|
-
fs.mkdirSync(workspaceDir, { recursive: true });
|
|
21
|
-
execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
|
|
22
|
-
|
|
23
|
-
console.log('📦 Installing Python dependencies...');
|
|
24
|
-
execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit' });
|
|
25
|
-
|
|
26
|
-
console.log('🎙️ Installing Voice Service dependencies...');
|
|
27
|
-
execSync('npm install', {
|
|
28
|
-
stdio: 'inherit',
|
|
29
|
-
cwd: path.join(__dirname, '..', 'voice-service'),
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// Own try/catch: agy may not be installed yet on a brand new machine, and that shouldn't fail the rest of the install.
|
|
33
|
-
try {
|
|
34
|
-
require('./register-hook')({ allowFirstTimeCreate: false });
|
|
35
|
-
} catch (err) {
|
|
36
|
-
console.warn(
|
|
37
|
-
`⚠️ Couldn't register the agy tool-approval hook: ${err.message.split('\n')[0]}`,
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
console.log('✅ Installation complete!');
|
|
42
|
-
} catch (error) {
|
|
43
|
-
console.error('❌ Installation failed. Please ensure Python 3.10+ is installed.');
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
main();
|