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/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
- 'Allowed Discord Users',
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: `Discord User ID to allow${promptSuffix}:`,
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
- async function runSetup() {
163
- console.log();
164
- p.intro(`${color.cyan}▶ LinkGravity Setup Wizard${color.reset}`);
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
- const platform = await p.select({
167
- message: 'Which messenger platform would you like to configure?',
168
- options: [{ label: 'Discord', value: 'discord', hint: 'Configure Discord bot settings' }],
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
- if (p.isCancel(platform)) {
171
- p.cancel('Setup cancelled.');
172
- process.exit(0);
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
- const settingsUpdates = {};
259
- if (discordToken) settingsUpdates.discord_token = discordToken;
260
- if (sessionScopes !== null) settingsUpdates.session_scopes = sessionScopes;
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
- if (Object.keys(settingsUpdates).length > 0) {
265
- updateSettings(settingsUpdates);
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('Configuration saved to lgy.json successfully!', 'Success');
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
- console.log(`${color.cyan}▶${color.reset} Restarting daemon to apply changes...`);
271
- const restartResult = spawnSync('npx', ['-y', 'pm2', 'restart', 'lgy', '--update-env'], {
272
- stdio: 'pipe',
273
- env: { ...process.env, PYTHONUNBUFFERED: '1' },
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
- if (restartResult.status === 0) {
277
- p.outro('Daemon restarted.');
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
- const stderr = (restartResult.stderr || '').toString();
280
- if (stderr.includes('not found')) {
281
- // Nothing to restart yet - start it instead of a false "restarted".
282
- const botPath = path.join(__dirname, '..', 'src', 'main.py');
283
- const startResult = spawnSync(
284
- 'npx',
285
- ['-y', 'pm2', 'start', botPath, '--interpreter', pythonExe, '--name', 'lgy'],
286
- { stdio: 'pipe', env: { ...process.env, PYTHONUNBUFFERED: '1' } },
287
- );
288
- if (startResult.status === 0) {
289
- p.outro("Daemon wasn't running yet - started it fresh instead.");
290
- } else {
291
- console.error((startResult.stderr || '').toString().trim());
292
- p.outro(
293
- `${color.yellow}⚠${color.reset} Failed to start the daemon - run \`lgy start\` manually to see the full error.`,
294
- );
295
- }
296
- } else {
297
- console.error(stderr.trim());
298
- p.outro(
299
- `${color.yellow}⚠${color.reset} Failed to restart the daemon - run \`lgy restart\` manually to see the full error.`,
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("AGY_DISCORD_BOT") != "1":
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("DISCORD_THREAD_ID"),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "postinstall": "node npm-scripts/postinstall.js",
package/requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
1
  discord.py[voice]>=2.4.0
2
+ python-telegram-bot>=21.0
2
3
  PyNaCl>=1.5.0
3
4
  numpy>=1.24.0
4
5
  edge-tts
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, handle_mcp_ask, handle_mcp_send_channel
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 / MCP / STT Server started on port 18080")
46
+ logger.info("Webhook / STT Server started on port 18080")
@@ -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 get_adapter
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
- target_thread = adapter.resolve_conversation(thread_id_str)
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
- resolved_channel = adapter.resolve_conversation(payload_thread_id)
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
- target_thread = adapter.resolve_conversation(thread_id_str)
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
- chosen_opt = await future
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.send_message(target_thread, sub_cmd_formatted)
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
- decision = await future
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.send_message(target_thread, tool_msg_formatted))
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.send_message(target_thread, tool_msg_formatted)
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.send_message(target_thread, tool_msg_formatted)
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
- decision = await future
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 = get_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 = get_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)
@@ -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
- import traceback
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.error(f"tts_finished API error: {e}")
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.error(f"stt_partial API error: {e}")
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.error(f"stt_partial_cancel API error: {e}")
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.error(f"enroll_sample API error: {e}")
90
+ logger.exception(f"enroll_sample API error: {e}")
94
91
  return web.json_response({"error": str(e)}, status=500)
@@ -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["AGY_DISCORD_BOT"] = "1"
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!**")
@@ -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 get_adapter
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
- # A stale in-flight turn for this guild - cancel it, kill its agy process, stop
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 get_adapter().rename_conversation(thread, new_title)
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...")