linkgravity 1.3.0 → 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 +46 -16
- package/src/api/voice_routes.py +5 -8
- package/src/cogs/general_cog.py +4 -1
- package/src/cogs/voice_cog.py +4 -7
- package/src/config.py +9 -4
- package/src/core/agy_runner.py +3 -3
- package/src/core/session_manager.py +25 -0
- package/src/handlers/message_router.py +6 -9
- package/src/handlers/thread_reply.py +62 -34
- package/src/main.py +35 -305
- 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 +12 -11
- 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,7 +113,8 @@ 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
|
|
@@ -116,6 +124,8 @@ async def handle_approve_request(request):
|
|
|
116
124
|
if target_thread_id and session_manager.get_session(target_thread_id):
|
|
117
125
|
session_manager.update_session(target_thread_id, key, tool_name)
|
|
118
126
|
|
|
127
|
+
adapter = get_adapter_for_thread(target_thread_id) if target_thread_id else get_adapter_for_platform("discord")
|
|
128
|
+
|
|
119
129
|
if "ask_question" in tool_name:
|
|
120
130
|
_set_tool_status("current_tool") # no separate approval phase here - it's waiting on the user either way
|
|
121
131
|
if not target_thread:
|
|
@@ -140,7 +150,15 @@ async def handle_approve_request(request):
|
|
|
140
150
|
)
|
|
141
151
|
await send_ordered(target_thread_id, lambda: prompt.send(target_thread))
|
|
142
152
|
|
|
143
|
-
|
|
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."})
|
|
144
162
|
session_manager.clear_pending_approval(approval_key)
|
|
145
163
|
await prompt.finalize()
|
|
146
164
|
|
|
@@ -221,13 +239,19 @@ async def handle_approve_request(request):
|
|
|
221
239
|
sub_cmd_formatted = f"```text\n{sub_cmd_display}\n```{sub_cmd_desc}"
|
|
222
240
|
|
|
223
241
|
async def _send_bash_prompt(sub_cmd_formatted=sub_cmd_formatted, prompt=prompt):
|
|
224
|
-
await adapter
|
|
242
|
+
await _send_chunked(adapter, target_thread, sub_cmd_formatted)
|
|
225
243
|
return await prompt.send(target_thread)
|
|
226
244
|
|
|
227
245
|
await send_ordered(target_thread_id, _send_bash_prompt)
|
|
228
246
|
_set_tool_status("pending_approval_tool")
|
|
229
247
|
|
|
230
|
-
|
|
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"
|
|
231
255
|
session_manager.clear_pending_approval(approval_key)
|
|
232
256
|
_persist_scope_if_granted(prompt)
|
|
233
257
|
await prompt.finalize()
|
|
@@ -238,7 +262,7 @@ async def handle_approve_request(request):
|
|
|
238
262
|
_set_tool_status("current_tool")
|
|
239
263
|
|
|
240
264
|
if target_thread and tool_msg_text and not prompted:
|
|
241
|
-
await send_ordered(target_thread_id, lambda: adapter
|
|
265
|
+
await send_ordered(target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted))
|
|
242
266
|
|
|
243
267
|
if not prompted:
|
|
244
268
|
_set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
|
|
@@ -256,7 +280,7 @@ async def handle_approve_request(request):
|
|
|
256
280
|
if is_auto_allowed:
|
|
257
281
|
if target_thread and tool_msg_text:
|
|
258
282
|
await send_ordered(
|
|
259
|
-
target_thread_id, lambda: adapter
|
|
283
|
+
target_thread_id, lambda: _send_chunked(adapter, target_thread, tool_msg_formatted)
|
|
260
284
|
)
|
|
261
285
|
_set_tool_status("current_tool") # auto-allowed - runs immediately, no approval wait
|
|
262
286
|
return allow_response(tool_name, tool_input)
|
|
@@ -272,13 +296,19 @@ async def handle_approve_request(request):
|
|
|
272
296
|
)
|
|
273
297
|
|
|
274
298
|
async def _send_prompt():
|
|
275
|
-
await adapter
|
|
299
|
+
await _send_chunked(adapter, target_thread, tool_msg_formatted)
|
|
276
300
|
return await prompt.send(target_thread)
|
|
277
301
|
|
|
278
302
|
await send_ordered(target_thread_id, _send_prompt)
|
|
279
303
|
_set_tool_status("pending_approval_tool")
|
|
280
304
|
|
|
281
|
-
|
|
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"
|
|
282
312
|
session_manager.clear_pending_approval(approval_key)
|
|
283
313
|
_persist_scope_if_granted(prompt)
|
|
284
314
|
await prompt.finalize()
|
|
@@ -304,7 +334,7 @@ async def handle_mcp_ask(request):
|
|
|
304
334
|
question = _clean_inline(data.get("question", "No question provided."))
|
|
305
335
|
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
|
|
306
336
|
|
|
307
|
-
adapter =
|
|
337
|
+
adapter = get_adapter_for_thread(thread_id)
|
|
308
338
|
thread = adapter.resolve_conversation(thread_id)
|
|
309
339
|
if not thread:
|
|
310
340
|
return web.json_response({"answer": "Thread not found"}, status=400)
|
|
@@ -337,7 +367,7 @@ async def handle_mcp_send_channel(request):
|
|
|
337
367
|
channel_id = data.get("channel_id")
|
|
338
368
|
message = data.get("message", "")
|
|
339
369
|
|
|
340
|
-
adapter =
|
|
370
|
+
adapter = get_adapter_for_thread(channel_id)
|
|
341
371
|
channel = adapter.resolve_conversation(channel_id)
|
|
342
372
|
if not channel:
|
|
343
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!**")
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -7,7 +7,7 @@ from discord import app_commands
|
|
|
7
7
|
from discord.ext import commands, tasks
|
|
8
8
|
|
|
9
9
|
from config import allowed, logger
|
|
10
|
-
from messengers.registry import
|
|
10
|
+
from messengers.registry import get_adapter_for_platform
|
|
11
11
|
|
|
12
12
|
from .voice.enrollment import EnrollmentManager
|
|
13
13
|
from .voice.stt_session import SttSessionTracker
|
|
@@ -459,10 +459,7 @@ class VoiceCog(commands.Cog):
|
|
|
459
459
|
pa = self.session_manager.get_pending_approval_by_conv(conv_id) if conv_id else None
|
|
460
460
|
has_pending_approval = bool(conv_id and pa and not pa.done())
|
|
461
461
|
|
|
462
|
-
#
|
|
463
|
-
# playback. Skipped when a tool/question approval is pending: that turn is the one
|
|
464
|
-
# waiting on this very utterance as its answer, so cancelling here would kill the
|
|
465
|
-
# agy process before the "yes"/"no" below ever reaches it.
|
|
462
|
+
# Cancel a stale in-flight turn, unless a tool/question approval is pending (this utterance may be its answer).
|
|
466
463
|
prev_task = self._active_turns.get(str(guild_id))
|
|
467
464
|
if prev_task and not prev_task.done() and not has_pending_approval:
|
|
468
465
|
prev_task.cancel()
|
|
@@ -532,7 +529,7 @@ class VoiceCog(commands.Cog):
|
|
|
532
529
|
self.session_manager.register_queue(str(thread.id), queue)
|
|
533
530
|
|
|
534
531
|
ctx = {"status_msg": None}
|
|
535
|
-
consume_task = asyncio.create_task(self.stream_thinking_latest(thread, ctx, queue))
|
|
532
|
+
consume_task = asyncio.create_task(self.stream_thinking_latest(thread, str(thread.id), ctx, queue))
|
|
536
533
|
|
|
537
534
|
try:
|
|
538
535
|
if is_new_session:
|
|
@@ -551,7 +548,7 @@ class VoiceCog(commands.Cog):
|
|
|
551
548
|
from utils.utils import generate_thread_title, update_agy_conversation_title
|
|
552
549
|
|
|
553
550
|
new_title = await generate_thread_title(text_to_ai, raw_ans)
|
|
554
|
-
await
|
|
551
|
+
await get_adapter_for_platform("discord").rename_conversation(thread, new_title)
|
|
555
552
|
await update_agy_conversation_title(new_conv_id, new_title)
|
|
556
553
|
else:
|
|
557
554
|
logger.debug("Voice: calling agy_send...")
|