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/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.2.2",
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,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
- 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
113
121
  break
114
122
 
115
- if target_thread_id and session_manager.get_session(target_thread_id):
116
- session_manager.update_session(target_thread_id, "current_tool", tool_name)
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
- 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."})
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.send_message(target_thread, sub_cmd_formatted)
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
- 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"
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.send_message(target_thread, tool_msg_formatted))
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.send_message(target_thread, tool_msg_formatted)
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.send_message(target_thread, tool_msg_formatted)
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
- 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"
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 = get_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 = get_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)
@@ -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!**")
@@ -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?",