linkgravity 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +160 -80
- package/bin/platforms.js +63 -0
- package/bin/setup.js +172 -76
- package/hooks/hook.py +3 -4
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/api/server.py +2 -4
- package/src/api/ui_routes.py +59 -18
- package/src/api/voice_routes.py +5 -8
- package/src/cogs/general_cog.py +4 -1
- package/src/cogs/voice/enrollment.py +1 -1
- package/src/cogs/voice_cog.py +28 -23
- package/src/config.py +9 -4
- package/src/core/agy_runner.py +36 -3
- package/src/core/session_manager.py +25 -0
- package/src/handlers/message_router.py +6 -9
- package/src/handlers/thread_reply.py +63 -33
- package/src/main.py +40 -278
- package/src/main_discord.py +311 -0
- package/src/main_telegram.py +202 -0
- package/src/messengers/base.py +46 -7
- package/src/messengers/discord_adapter.py +72 -5
- package/src/messengers/registry.py +28 -12
- package/src/messengers/telegram_adapter.py +372 -0
- package/src/services/audio_service.py +2 -2
- package/src/services/discord_helpers.py +3 -4
- package/src/services/response.py +2 -2
- package/src/services/streaming.py +24 -16
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +25 -22
- package/src/services/discord_mcp.py +0 -50
package/bin/setup.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
const p = require('@clack/prompts');
|
|
2
|
-
const fs = require('fs');
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const os = require('os');
|
|
5
2
|
const { spawnSync } = require('child_process');
|
|
6
3
|
const { python: pythonExe } = require('../npm-scripts/venv-paths');
|
|
4
|
+
const {
|
|
5
|
+
getSettings,
|
|
6
|
+
updateSettings,
|
|
7
|
+
PLATFORMS,
|
|
8
|
+
platformState,
|
|
9
|
+
LGY_PM2_NAME,
|
|
10
|
+
LGY_SCRIPT_PATH,
|
|
11
|
+
} = require('./platforms');
|
|
7
12
|
|
|
8
13
|
const color = {
|
|
9
14
|
reset: '\x1b[0m',
|
|
@@ -12,28 +17,6 @@ const color = {
|
|
|
12
17
|
yellow: '\x1b[33m',
|
|
13
18
|
};
|
|
14
19
|
|
|
15
|
-
const workspaceDir = path.join(os.homedir(), '.gemini', 'linkgravity');
|
|
16
|
-
const settingsPath = path.join(workspaceDir, 'lgy.json');
|
|
17
|
-
|
|
18
|
-
if (!fs.existsSync(workspaceDir)) fs.mkdirSync(workspaceDir, { recursive: true });
|
|
19
|
-
|
|
20
|
-
function getSettings() {
|
|
21
|
-
if (fs.existsSync(settingsPath)) {
|
|
22
|
-
try {
|
|
23
|
-
return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
24
|
-
} catch (e) {}
|
|
25
|
-
}
|
|
26
|
-
return {};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function updateSettings(updates) {
|
|
30
|
-
const settings = getSettings();
|
|
31
|
-
for (const [key, value] of Object.entries(updates)) {
|
|
32
|
-
settings[key] = value;
|
|
33
|
-
}
|
|
34
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 4));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
20
|
function splitIds(raw) {
|
|
38
21
|
return raw.split(/[\s,;]+/).filter(Boolean);
|
|
39
22
|
}
|
|
@@ -115,14 +98,14 @@ async function collectSessionScopes(existingScopes) {
|
|
|
115
98
|
return scopes;
|
|
116
99
|
}
|
|
117
100
|
|
|
118
|
-
async function collectUserIds(existingIds) {
|
|
101
|
+
async function collectUserIds(existingIds, platformLabel) {
|
|
119
102
|
const ids = [];
|
|
120
103
|
const hasExisting = existingIds && existingIds.length > 0;
|
|
121
104
|
|
|
122
105
|
p.note(
|
|
123
106
|
'ONLY these users can use the bot (leave completely empty on first setup to allow EVERYONE). ' +
|
|
124
107
|
'Not related to DMs - this only gates the channel/threads configured above.',
|
|
125
|
-
|
|
108
|
+
`Allowed ${platformLabel} Users`,
|
|
126
109
|
);
|
|
127
110
|
|
|
128
111
|
let isFirst = true;
|
|
@@ -133,7 +116,7 @@ async function collectUserIds(existingIds) {
|
|
|
133
116
|
: ' (leave empty if you have no more users to add)';
|
|
134
117
|
|
|
135
118
|
const userId = await p.text({
|
|
136
|
-
message:
|
|
119
|
+
message: `${platformLabel} User ID to allow${promptSuffix}:`,
|
|
137
120
|
});
|
|
138
121
|
if (p.isCancel(userId)) {
|
|
139
122
|
p.cancel('Setup cancelled.');
|
|
@@ -159,19 +142,64 @@ async function collectUserIds(existingIds) {
|
|
|
159
142
|
return ids;
|
|
160
143
|
}
|
|
161
144
|
|
|
162
|
-
|
|
163
|
-
console.log();
|
|
164
|
-
|
|
145
|
+
function stopDaemon(pm2Name, label) {
|
|
146
|
+
console.log(`${color.cyan}▶${color.reset} Stopping ${label} daemon...`);
|
|
147
|
+
spawnSync('npx', ['-y', 'pm2', 'delete', pm2Name], { stdio: 'pipe' });
|
|
148
|
+
|
|
149
|
+
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
150
|
+
let stillRunning = false;
|
|
151
|
+
if (jlist.status === 0) {
|
|
152
|
+
try {
|
|
153
|
+
stillRunning = JSON.parse(jlist.stdout.toString()).some((p) => p.name === pm2Name);
|
|
154
|
+
} catch (e) {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!stillRunning) {
|
|
158
|
+
p.outro(`${label} daemon stopped.`);
|
|
159
|
+
} else {
|
|
160
|
+
p.outro(
|
|
161
|
+
`${color.yellow}⚠${color.reset} ${label} daemon is still running - run \`npx pm2 delete ${pm2Name}\` manually and check \`npx pm2 list\`.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
function startOrRestartDaemon(pm2Name, scriptPath, label) {
|
|
167
|
+
console.log(`${color.cyan}▶${color.reset} Restarting ${label} daemon to apply changes...`);
|
|
168
|
+
const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', pm2Name, '--update-env'], {
|
|
169
|
+
stdio: 'pipe',
|
|
170
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
169
171
|
});
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
|
|
173
|
+
if (restartResult.status === 0) {
|
|
174
|
+
p.outro(`${label} daemon restarted.`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const stderr = (restartResult.stderr || '').toString();
|
|
179
|
+
if (stderr.includes('not found')) {
|
|
180
|
+
// Nothing to restart yet - start it instead of a false "restarted".
|
|
181
|
+
const startResult = spawnSync(
|
|
182
|
+
'npx',
|
|
183
|
+
['-y', 'pm2', 'start', scriptPath, '--interpreter', pythonExe, '--name', pm2Name],
|
|
184
|
+
{ stdio: 'pipe', env: { ...process.env, PYTHONUNBUFFERED: '1' } },
|
|
185
|
+
);
|
|
186
|
+
if (startResult.status === 0) {
|
|
187
|
+
p.outro(`${label} daemon wasn't running yet - started it fresh instead.`);
|
|
188
|
+
} else {
|
|
189
|
+
console.error((startResult.stderr || '').toString().trim());
|
|
190
|
+
p.outro(
|
|
191
|
+
`${color.yellow}⚠${color.reset} Failed to start the ${label} daemon - run \`lgy start\` manually to see the full error.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
console.error(stderr.trim());
|
|
196
|
+
p.outro(
|
|
197
|
+
`${color.yellow}⚠${color.reset} Failed to restart the ${label} daemon - run \`lgy restart\` manually to see the full error.`,
|
|
198
|
+
);
|
|
173
199
|
}
|
|
200
|
+
}
|
|
174
201
|
|
|
202
|
+
async function configureDiscord(existingSettings) {
|
|
175
203
|
const discordToken = await p.password({
|
|
176
204
|
message: 'Discord Bot Token (Leave empty to keep current):',
|
|
177
205
|
});
|
|
@@ -180,13 +208,17 @@ async function runSetup() {
|
|
|
180
208
|
process.exit(0);
|
|
181
209
|
}
|
|
182
210
|
|
|
183
|
-
const existingSettings = getSettings();
|
|
184
211
|
const sessionScopes = await collectSessionScopes(existingSettings.session_scopes);
|
|
185
212
|
|
|
186
213
|
const existingUserIds = existingSettings.allowed_user_ids
|
|
187
214
|
? splitIds(existingSettings.allowed_user_ids)
|
|
188
215
|
: [];
|
|
189
|
-
const userIds = await collectUserIds(existingUserIds);
|
|
216
|
+
const userIds = await collectUserIds(existingUserIds, 'Discord');
|
|
217
|
+
|
|
218
|
+
const updates = {};
|
|
219
|
+
if (discordToken) updates.discord_token = discordToken;
|
|
220
|
+
if (sessionScopes !== null) updates.session_scopes = sessionScopes;
|
|
221
|
+
if (userIds !== null) updates.allowed_user_ids = userIds.join(',');
|
|
190
222
|
|
|
191
223
|
p.note(
|
|
192
224
|
"Wake words aren't set here anymore - they need a voice recording to register " +
|
|
@@ -255,51 +287,115 @@ async function runSetup() {
|
|
|
255
287
|
},
|
|
256
288
|
);
|
|
257
289
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (userIds !== null) settingsUpdates.allowed_user_ids = userIds.join(',');
|
|
262
|
-
if (group.tts_voice) settingsUpdates.tts_voice = group.tts_voice;
|
|
290
|
+
if (group.tts_voice) updates.tts_voice = group.tts_voice;
|
|
291
|
+
return updates;
|
|
292
|
+
}
|
|
263
293
|
|
|
264
|
-
|
|
265
|
-
|
|
294
|
+
async function configureTelegram(existingSettings) {
|
|
295
|
+
const telegramToken = await p.password({
|
|
296
|
+
message: 'Telegram Bot Token (from @BotFather, leave empty to keep current):',
|
|
297
|
+
});
|
|
298
|
+
if (p.isCancel(telegramToken)) {
|
|
299
|
+
p.cancel('Setup cancelled.');
|
|
300
|
+
process.exit(0);
|
|
266
301
|
}
|
|
267
302
|
|
|
268
|
-
p.note(
|
|
303
|
+
p.note(
|
|
304
|
+
'Telegram has no channel/server gating yet - every chat you DM (or add) the bot to becomes ' +
|
|
305
|
+
'its own session, and there is no voice support yet.',
|
|
306
|
+
'Telegram Access',
|
|
307
|
+
);
|
|
269
308
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
});
|
|
309
|
+
const existingTelegramUserIds = existingSettings.telegram_allowed_user_ids
|
|
310
|
+
? splitIds(existingSettings.telegram_allowed_user_ids)
|
|
311
|
+
: [];
|
|
312
|
+
const telegramUserIds = await collectUserIds(existingTelegramUserIds, 'Telegram');
|
|
275
313
|
|
|
276
|
-
|
|
277
|
-
|
|
314
|
+
const updates = {};
|
|
315
|
+
if (telegramToken) updates.telegram_token = telegramToken;
|
|
316
|
+
if (telegramUserIds !== null) updates.telegram_allowed_user_ids = telegramUserIds.join(',');
|
|
317
|
+
return updates;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const CONFIGURERS = {
|
|
321
|
+
discord: configureDiscord,
|
|
322
|
+
telegram: configureTelegram,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
function applyDaemonState() {
|
|
326
|
+
const settings = getSettings();
|
|
327
|
+
const anyEnabled = Object.keys(PLATFORMS).some((k) => platformState(k, settings).enabled);
|
|
328
|
+
if (anyEnabled) {
|
|
329
|
+
startOrRestartDaemon(LGY_PM2_NAME, LGY_SCRIPT_PATH, 'LinkGravity');
|
|
278
330
|
} else {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
331
|
+
stopDaemon(LGY_PM2_NAME, 'LinkGravity');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function platformMenu(key) {
|
|
336
|
+
const def = PLATFORMS[key];
|
|
337
|
+
|
|
338
|
+
while (true) {
|
|
339
|
+
const settings = getSettings();
|
|
340
|
+
const { configured, enabled } = platformState(key, settings);
|
|
341
|
+
|
|
342
|
+
const options = [];
|
|
343
|
+
options.push(
|
|
344
|
+
enabled
|
|
345
|
+
? { value: 'off', label: 'Turn OFF' }
|
|
346
|
+
: { value: 'on', label: configured ? 'Turn ON' : 'Configure & turn ON' },
|
|
347
|
+
);
|
|
348
|
+
if (configured)
|
|
349
|
+
options.push({ value: 'edit', label: 'Edit settings (token, access, etc.)' });
|
|
350
|
+
options.push({ value: 'back', label: '← Back' });
|
|
351
|
+
|
|
352
|
+
const action = await p.select({
|
|
353
|
+
message: `${def.label} — currently ${enabled ? 'ON' : 'OFF'}${configured ? '' : ' (not configured)'}`,
|
|
354
|
+
options,
|
|
355
|
+
});
|
|
356
|
+
if (p.isCancel(action) || action === 'back') return;
|
|
357
|
+
|
|
358
|
+
if (action === 'off') {
|
|
359
|
+
updateSettings({ [`${key}_enabled`]: false });
|
|
360
|
+
applyDaemonState();
|
|
361
|
+
continue;
|
|
301
362
|
}
|
|
363
|
+
|
|
364
|
+
if (action === 'on' && configured) {
|
|
365
|
+
updateSettings({ [`${key}_enabled`]: true });
|
|
366
|
+
applyDaemonState();
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// action === 'edit', or first-time 'on' (not configured yet) - both need the full wizard.
|
|
371
|
+
const updates = await CONFIGURERS[key](settings);
|
|
372
|
+
updates[`${key}_enabled`] = true;
|
|
373
|
+
updateSettings(updates);
|
|
374
|
+
applyDaemonState();
|
|
302
375
|
}
|
|
303
376
|
}
|
|
304
377
|
|
|
378
|
+
async function runSetup() {
|
|
379
|
+
console.log();
|
|
380
|
+
p.intro(`${color.cyan}▶ LinkGravity Setup Wizard${color.reset}`);
|
|
381
|
+
|
|
382
|
+
while (true) {
|
|
383
|
+
const settings = getSettings();
|
|
384
|
+
const options = Object.entries(PLATFORMS).map(([key, def]) => {
|
|
385
|
+
const { configured, enabled } = platformState(key, settings);
|
|
386
|
+
return {
|
|
387
|
+
value: key,
|
|
388
|
+
label: `${def.label} — ${enabled ? 'ON' : 'OFF'}`,
|
|
389
|
+
hint: configured ? undefined : 'not configured yet',
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
const choice = await p.select({ message: 'LinkGravity Setup (Esc to finish)', options });
|
|
393
|
+
if (p.isCancel(choice)) break;
|
|
394
|
+
|
|
395
|
+
await platformMenu(choice);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
p.outro('Setup complete.');
|
|
399
|
+
}
|
|
400
|
+
|
|
305
401
|
module.exports = runSetup;
|
package/hooks/hook.py
CHANGED
|
@@ -7,7 +7,7 @@ import urllib.request
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def main():
|
|
10
|
-
if os.environ.get("
|
|
10
|
+
if os.environ.get("LGY_APPROVAL_HOOK") != "1":
|
|
11
11
|
print(json.dumps({"decision": "allow"}))
|
|
12
12
|
return
|
|
13
13
|
|
|
@@ -29,7 +29,7 @@ def main():
|
|
|
29
29
|
"conversation_id": conv_id,
|
|
30
30
|
"tool_name": tool_name,
|
|
31
31
|
"tool_input": tool_input_data,
|
|
32
|
-
"thread_id": os.environ.get("
|
|
32
|
+
"thread_id": os.environ.get("LGY_THREAD_ID"),
|
|
33
33
|
}
|
|
34
34
|
).encode("utf-8")
|
|
35
35
|
|
|
@@ -43,8 +43,7 @@ def main():
|
|
|
43
43
|
decision = res_data.get("decision", "allow")
|
|
44
44
|
if decision == "allow":
|
|
45
45
|
out = {"decision": "allow"}
|
|
46
|
-
# Print mode requires a matching allow rule even when this
|
|
47
|
-
# hook says "allow", or it soft-denies the tool call anyway.
|
|
46
|
+
# Print mode requires a matching allow rule even when this hook says "allow", or it soft-denies anyway.
|
|
48
47
|
if res_data.get("permissionOverrides"):
|
|
49
48
|
out["permissionOverrides"] = res_data["permissionOverrides"]
|
|
50
49
|
print(json.dumps(out))
|
package/package.json
CHANGED
package/requirements.txt
CHANGED
package/src/api/server.py
CHANGED
|
@@ -23,7 +23,7 @@ async def setup_webhook_server(bot):
|
|
|
23
23
|
app = web.Application(client_max_size=50 * 1024 * 1024)
|
|
24
24
|
app["bot"] = bot
|
|
25
25
|
|
|
26
|
-
from api.ui_routes import handle_approve_request
|
|
26
|
+
from api.ui_routes import handle_approve_request
|
|
27
27
|
from api.voice_routes import (
|
|
28
28
|
handle_enroll_sample,
|
|
29
29
|
handle_stt_input,
|
|
@@ -33,8 +33,6 @@ async def setup_webhook_server(bot):
|
|
|
33
33
|
)
|
|
34
34
|
|
|
35
35
|
app.router.add_post("/approve", handle_approve_request)
|
|
36
|
-
app.router.add_post("/mcp_ask", handle_mcp_ask)
|
|
37
|
-
app.router.add_post("/mcp_send_channel", handle_mcp_send_channel)
|
|
38
36
|
app.router.add_post("/stt_input", handle_stt_input)
|
|
39
37
|
app.router.add_post("/tts_finished", handle_tts_finished)
|
|
40
38
|
app.router.add_post("/stt_partial", handle_stt_partial)
|
|
@@ -45,4 +43,4 @@ async def setup_webhook_server(bot):
|
|
|
45
43
|
await runner.setup()
|
|
46
44
|
site = web.TCPSite(runner, "0.0.0.0", 18080)
|
|
47
45
|
await site.start()
|
|
48
|
-
logger.info("Webhook /
|
|
46
|
+
logger.info("Webhook / STT Server started on port 18080")
|
package/src/api/ui_routes.py
CHANGED
|
@@ -6,9 +6,9 @@ import uuid
|
|
|
6
6
|
|
|
7
7
|
from aiohttp import web
|
|
8
8
|
|
|
9
|
-
from config import MAX_EMBED_LEN, logger, session_manager
|
|
9
|
+
from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
|
|
10
10
|
from messengers.base import ScopeOption
|
|
11
|
-
from messengers.registry import
|
|
11
|
+
from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def is_tool_allowed(tool_name, tool_input):
|
|
@@ -54,6 +54,13 @@ def allow_response(tool_name, tool_input):
|
|
|
54
54
|
return web.json_response(body)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
async def _send_chunked(adapter, thread, text: str) -> None:
|
|
58
|
+
if not text:
|
|
59
|
+
return
|
|
60
|
+
for i in range(0, len(text), MAX_EMBED_LEN):
|
|
61
|
+
await adapter.send_message(thread, text[i : i + MAX_EMBED_LEN])
|
|
62
|
+
|
|
63
|
+
|
|
57
64
|
def _persist_scope_if_granted(prompt_handle):
|
|
58
65
|
"""If the prompt was resolved via a persistent-allow button, records
|
|
59
66
|
that scope. Scope persistence is business logic, so it lives here
|
|
@@ -84,18 +91,18 @@ async def handle_approve_request(request):
|
|
|
84
91
|
# DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
|
|
85
92
|
logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
|
|
86
93
|
|
|
87
|
-
adapter = get_adapter()
|
|
88
|
-
|
|
89
94
|
target_thread = None
|
|
90
95
|
target_thread_id = None
|
|
91
96
|
for thread_id_str, sess in session_manager.get_all_sessions().items():
|
|
92
97
|
if sess.get("conversation_id") == conv_id:
|
|
93
|
-
|
|
98
|
+
candidate_adapter = get_adapter_for_platform(sess.get("platform", "discord"))
|
|
99
|
+
target_thread = candidate_adapter.resolve_conversation(thread_id_str)
|
|
94
100
|
target_thread_id = thread_id_str
|
|
95
101
|
break
|
|
96
102
|
|
|
97
103
|
if not target_thread and payload_thread_id:
|
|
98
|
-
|
|
104
|
+
candidate_adapter = get_adapter_for_thread(payload_thread_id)
|
|
105
|
+
resolved_channel = candidate_adapter.resolve_conversation(payload_thread_id)
|
|
99
106
|
if resolved_channel:
|
|
100
107
|
target_thread = resolved_channel
|
|
101
108
|
target_thread_id = payload_thread_id
|
|
@@ -106,16 +113,21 @@ async def handle_approve_request(request):
|
|
|
106
113
|
if not target_thread:
|
|
107
114
|
for thread_id_str, sess in reversed(list(session_manager.get_all_sessions().items())):
|
|
108
115
|
if sess.get("status") == "pending":
|
|
109
|
-
|
|
116
|
+
candidate_adapter = get_adapter_for_platform(sess.get("platform", "discord"))
|
|
117
|
+
target_thread = candidate_adapter.resolve_conversation(thread_id_str)
|
|
110
118
|
session_manager.update_session(thread_id_str, "conversation_id", conv_id)
|
|
111
119
|
session_manager.update_session(thread_id_str, "status", "active")
|
|
112
120
|
target_thread_id = thread_id_str
|
|
113
121
|
break
|
|
114
122
|
|
|
115
|
-
|
|
116
|
-
session_manager.
|
|
123
|
+
def _set_tool_status(key: str):
|
|
124
|
+
if target_thread_id and session_manager.get_session(target_thread_id):
|
|
125
|
+
session_manager.update_session(target_thread_id, key, tool_name)
|
|
126
|
+
|
|
127
|
+
adapter = get_adapter_for_thread(target_thread_id) if target_thread_id else get_adapter_for_platform("discord")
|
|
117
128
|
|
|
118
129
|
if "ask_question" in tool_name:
|
|
130
|
+
_set_tool_status("current_tool") # no separate approval phase here - it's waiting on the user either way
|
|
119
131
|
if not target_thread:
|
|
120
132
|
return web.json_response({"decision": "deny", "reason": "No target thread found."})
|
|
121
133
|
|
|
@@ -138,7 +150,15 @@ async def handle_approve_request(request):
|
|
|
138
150
|
)
|
|
139
151
|
await send_ordered(target_thread_id, lambda: prompt.send(target_thread))
|
|
140
152
|
|
|
141
|
-
|
|
153
|
+
try:
|
|
154
|
+
chosen_opt = await asyncio.wait_for(future, timeout=APPROVAL_TIMEOUT_SEC)
|
|
155
|
+
except asyncio.TimeoutError:
|
|
156
|
+
logger.warning(
|
|
157
|
+
f"Question prompt timed out after {APPROVAL_TIMEOUT_SEC}s with no answer (conv_id={conv_id!r})"
|
|
158
|
+
)
|
|
159
|
+
session_manager.clear_pending_approval(approval_key)
|
|
160
|
+
await prompt.finalize()
|
|
161
|
+
return web.json_response({"decision": "deny", "reason": "User did not answer in time."})
|
|
142
162
|
session_manager.clear_pending_approval(approval_key)
|
|
143
163
|
await prompt.finalize()
|
|
144
164
|
|
|
@@ -219,12 +239,19 @@ async def handle_approve_request(request):
|
|
|
219
239
|
sub_cmd_formatted = f"```text\n{sub_cmd_display}\n```{sub_cmd_desc}"
|
|
220
240
|
|
|
221
241
|
async def _send_bash_prompt(sub_cmd_formatted=sub_cmd_formatted, prompt=prompt):
|
|
222
|
-
await adapter
|
|
242
|
+
await _send_chunked(adapter, target_thread, sub_cmd_formatted)
|
|
223
243
|
return await prompt.send(target_thread)
|
|
224
244
|
|
|
225
245
|
await send_ordered(target_thread_id, _send_bash_prompt)
|
|
246
|
+
_set_tool_status("pending_approval_tool")
|
|
226
247
|
|
|
227
|
-
|
|
248
|
+
try:
|
|
249
|
+
decision = await asyncio.wait_for(future, timeout=APPROVAL_TIMEOUT_SEC)
|
|
250
|
+
except asyncio.TimeoutError:
|
|
251
|
+
logger.warning(
|
|
252
|
+
f"Tool approval timed out after {APPROVAL_TIMEOUT_SEC}s with no response (conv_id={conv_id!r}, tool={tool_name!r})"
|
|
253
|
+
)
|
|
254
|
+
decision = "reject"
|
|
228
255
|
session_manager.clear_pending_approval(approval_key)
|
|
229
256
|
_persist_scope_if_granted(prompt)
|
|
230
257
|
await prompt.finalize()
|
|
@@ -232,8 +259,13 @@ async def handle_approve_request(request):
|
|
|
232
259
|
if decision == "reject":
|
|
233
260
|
return web.json_response({"decision": "reject"})
|
|
234
261
|
|
|
262
|
+
_set_tool_status("current_tool")
|
|
263
|
+
|
|
235
264
|
if target_thread and tool_msg_text and not prompted:
|
|
236
|
-
await send_ordered(target_thread_id, lambda: adapter
|
|
265
|
+
await send_ordered(target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted))
|
|
266
|
+
|
|
267
|
+
if not prompted:
|
|
268
|
+
_set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
|
|
237
269
|
|
|
238
270
|
return allow_response(tool_name, tool_input)
|
|
239
271
|
|
|
@@ -248,8 +280,9 @@ async def handle_approve_request(request):
|
|
|
248
280
|
if is_auto_allowed:
|
|
249
281
|
if target_thread and tool_msg_text:
|
|
250
282
|
await send_ordered(
|
|
251
|
-
target_thread_id, lambda: adapter
|
|
283
|
+
target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
|
|
252
284
|
)
|
|
285
|
+
_set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
|
|
253
286
|
return allow_response(tool_name, tool_input)
|
|
254
287
|
|
|
255
288
|
approval_key = f"{conv_id}:{uuid.uuid4().hex}"
|
|
@@ -263,12 +296,19 @@ async def handle_approve_request(request):
|
|
|
263
296
|
)
|
|
264
297
|
|
|
265
298
|
async def _send_prompt():
|
|
266
|
-
await adapter
|
|
299
|
+
await _send_chunked(adapter, target_thread, tool_msg_formatted)
|
|
267
300
|
return await prompt.send(target_thread)
|
|
268
301
|
|
|
269
302
|
await send_ordered(target_thread_id, _send_prompt)
|
|
303
|
+
_set_tool_status("pending_approval_tool")
|
|
270
304
|
|
|
271
|
-
|
|
305
|
+
try:
|
|
306
|
+
decision = await asyncio.wait_for(future, timeout=APPROVAL_TIMEOUT_SEC)
|
|
307
|
+
except asyncio.TimeoutError:
|
|
308
|
+
logger.warning(
|
|
309
|
+
f"Tool approval timed out after {APPROVAL_TIMEOUT_SEC}s with no response (conv_id={conv_id!r}, tool={tool_name!r})"
|
|
310
|
+
)
|
|
311
|
+
decision = "reject"
|
|
272
312
|
session_manager.clear_pending_approval(approval_key)
|
|
273
313
|
_persist_scope_if_granted(prompt)
|
|
274
314
|
await prompt.finalize()
|
|
@@ -276,6 +316,7 @@ async def handle_approve_request(request):
|
|
|
276
316
|
if decision == "reject":
|
|
277
317
|
return web.json_response({"decision": "reject"})
|
|
278
318
|
|
|
319
|
+
_set_tool_status("current_tool")
|
|
279
320
|
return allow_response(tool_name, tool_input)
|
|
280
321
|
|
|
281
322
|
except Exception as e:
|
|
@@ -293,7 +334,7 @@ async def handle_mcp_ask(request):
|
|
|
293
334
|
question = _clean_inline(data.get("question", "No question provided."))
|
|
294
335
|
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
|
|
295
336
|
|
|
296
|
-
adapter =
|
|
337
|
+
adapter = get_adapter_for_thread(thread_id)
|
|
297
338
|
thread = adapter.resolve_conversation(thread_id)
|
|
298
339
|
if not thread:
|
|
299
340
|
return web.json_response({"answer": "Thread not found"}, status=400)
|
|
@@ -326,7 +367,7 @@ async def handle_mcp_send_channel(request):
|
|
|
326
367
|
channel_id = data.get("channel_id")
|
|
327
368
|
message = data.get("message", "")
|
|
328
369
|
|
|
329
|
-
adapter =
|
|
370
|
+
adapter = get_adapter_for_thread(channel_id)
|
|
330
371
|
channel = adapter.resolve_conversation(channel_id)
|
|
331
372
|
if not channel:
|
|
332
373
|
return web.json_response({"error": "Channel not found"}, status=400)
|
package/src/api/voice_routes.py
CHANGED
|
@@ -14,10 +14,7 @@ async def handle_stt_input(request):
|
|
|
14
14
|
asyncio.create_task(cog.handle_stt_input(data))
|
|
15
15
|
return web.json_response({"success": True})
|
|
16
16
|
except Exception as e:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
logger.error(f"STT API error: {e}")
|
|
20
|
-
traceback.print_exc()
|
|
17
|
+
logger.exception(f"STT API error: {e}")
|
|
21
18
|
return web.json_response({"error": str(e)}, status=500)
|
|
22
19
|
|
|
23
20
|
|
|
@@ -36,7 +33,7 @@ async def handle_tts_finished(request):
|
|
|
36
33
|
cog.mark_tts_finished(str(guild_id))
|
|
37
34
|
return web.json_response({"success": True})
|
|
38
35
|
except Exception as e:
|
|
39
|
-
logger.
|
|
36
|
+
logger.exception(f"tts_finished API error: {e}")
|
|
40
37
|
return web.json_response({"error": str(e)}, status=500)
|
|
41
38
|
|
|
42
39
|
|
|
@@ -53,7 +50,7 @@ async def handle_stt_partial(request):
|
|
|
53
50
|
asyncio.create_task(cog.handle_stt_partial(data))
|
|
54
51
|
return web.json_response({"success": True})
|
|
55
52
|
except Exception as e:
|
|
56
|
-
logger.
|
|
53
|
+
logger.exception(f"stt_partial API error: {e}")
|
|
57
54
|
return web.json_response({"error": str(e)}, status=500)
|
|
58
55
|
|
|
59
56
|
|
|
@@ -71,7 +68,7 @@ async def handle_stt_partial_cancel(request):
|
|
|
71
68
|
asyncio.create_task(cog.cancel_stt_partial(str(guild_id)))
|
|
72
69
|
return web.json_response({"success": True})
|
|
73
70
|
except Exception as e:
|
|
74
|
-
logger.
|
|
71
|
+
logger.exception(f"stt_partial_cancel API error: {e}")
|
|
75
72
|
return web.json_response({"error": str(e)}, status=500)
|
|
76
73
|
|
|
77
74
|
|
|
@@ -90,5 +87,5 @@ async def handle_enroll_sample(request):
|
|
|
90
87
|
asyncio.create_task(cog.handle_enroll_sample(user_id, audio_bytes))
|
|
91
88
|
return web.json_response({"success": True})
|
|
92
89
|
except Exception as e:
|
|
93
|
-
logger.
|
|
90
|
+
logger.exception(f"enroll_sample API error: {e}")
|
|
94
91
|
return web.json_response({"error": str(e)}, status=500)
|
package/src/cogs/general_cog.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import glob
|
|
3
3
|
import os
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
import discord
|
|
@@ -53,7 +54,7 @@ async def fetch_models_background():
|
|
|
53
54
|
|
|
54
55
|
logger.debug(f"Starting fetch_models_background using AGY_BIN: {AGY_BIN}")
|
|
55
56
|
env = os.environ.copy()
|
|
56
|
-
env["
|
|
57
|
+
env["LGY_APPROVAL_HOOK"] = "1"
|
|
57
58
|
p = await asyncio.create_subprocess_exec(
|
|
58
59
|
AGY_BIN,
|
|
59
60
|
"models",
|
|
@@ -178,10 +179,12 @@ class GeneralCog(commands.Cog):
|
|
|
178
179
|
str(thread.id),
|
|
179
180
|
{
|
|
180
181
|
"status": "pending",
|
|
182
|
+
"platform": "discord",
|
|
181
183
|
"user_id": interaction.user.id,
|
|
182
184
|
"cwd": cwd or get_default_cwd(),
|
|
183
185
|
"model": model,
|
|
184
186
|
"conversation_id": None,
|
|
187
|
+
"created_at": datetime.now().isoformat(),
|
|
185
188
|
},
|
|
186
189
|
)
|
|
187
190
|
await thread.send("✅ **Ready for new session!**")
|
|
@@ -263,7 +263,7 @@ class EnrollmentManager:
|
|
|
263
263
|
session["pending_sample"] = audio_bytes
|
|
264
264
|
session["awaiting_confirmation"] = True
|
|
265
265
|
|
|
266
|
-
await self._play_audio(session["guild_id"], audio_bytes, suppress_active_window=True)
|
|
266
|
+
await self._play_audio(session["guild_id"], self._trim_silence_wav(audio_bytes), suppress_active_window=True)
|
|
267
267
|
await self._update_status(
|
|
268
268
|
session,
|
|
269
269
|
f"🔊 **{step}/{session['needed']}** captured - keep it, or re-record if it's noisy?",
|