linkgravity 1.5.1 → 1.5.3
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 +79 -12
- package/package.json +2 -2
- package/src/cogs/general_cog.py +1 -1
- package/src/cogs/voice_cog.py +25 -21
- package/src/core/agy_runner.py +58 -45
- package/src/main_slack.py +1 -2
- package/src/main_telegram.py +5 -2
- package/voice-service/audioUtils.js +31 -0
- package/voice-service/config.js +24 -0
- package/voice-service/index.js +12 -11
- package/voice-service/logger.js +16 -0
- package/voice-service/receiver.js +310 -0
- package/voice-service/routes.js +182 -0
- package/voice-service/state.js +42 -0
- package/voice-service/stt.js +71 -0
- package/voice-service/tts.js +81 -0
- package/voice-service/wakeword.js +114 -0
package/bin/cli.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const { spawn, spawnSync } = require('child_process');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
6
7
|
const {
|
|
7
8
|
PLATFORMS,
|
|
8
9
|
getSettings,
|
|
@@ -236,20 +237,79 @@ function renderTable(headers, rows) {
|
|
|
236
237
|
return lines.join('\n');
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// Mirrors src/config.py's AGY_BIN resolution (AGY_BIN_PATH env var, else ~/.local/bin/agy), plus a PATH fallback for installs that don't use the default location.
|
|
241
|
+
function findAgyBin() {
|
|
242
|
+
const envPath = process.env.AGY_BIN_PATH;
|
|
243
|
+
if (envPath && fs.existsSync(envPath)) return envPath;
|
|
244
|
+
|
|
245
|
+
const defaultPath = path.join(os.homedir(), '.local', 'bin', 'agy');
|
|
246
|
+
if (fs.existsSync(defaultPath)) return defaultPath;
|
|
247
|
+
|
|
248
|
+
const which = spawnSync(isWin ? 'where' : 'which', ['agy'], { stdio: 'pipe' });
|
|
249
|
+
if (which.status === 0) {
|
|
250
|
+
const out = which.stdout.toString().trim().split('\n')[0].trim();
|
|
251
|
+
if (out) return out;
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getPm2Proc() {
|
|
257
|
+
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
258
|
+
if (jlist.status !== 0) return null;
|
|
259
|
+
try {
|
|
260
|
+
const procs = JSON.parse(jlist.stdout.toString());
|
|
261
|
+
return procs.find((p) => p.name === LGY_PM2_NAME) || null;
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
239
267
|
if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
240
268
|
const pkg = require('../package.json');
|
|
241
269
|
console.log(`linkgravity v${pkg.version}`);
|
|
242
270
|
} else if (cmd === 'start') {
|
|
271
|
+
const existing = getPm2Proc();
|
|
272
|
+
if (existing && existing.pm2_env.status === 'online') {
|
|
273
|
+
console.log(
|
|
274
|
+
`\n${color.yellow}⚠${color.reset} LinkGravity is already running. ` +
|
|
275
|
+
`Use ${color.cyan}lgy restart${color.reset} to apply changes, or ${color.cyan}lgy stop${color.reset} first.\n`,
|
|
276
|
+
);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const settings = getSettings();
|
|
281
|
+
const anyConfigured = Object.keys(PLATFORMS).some(
|
|
282
|
+
(key) => platformState(key, settings).configured,
|
|
283
|
+
);
|
|
284
|
+
if (!anyConfigured) {
|
|
285
|
+
console.log(
|
|
286
|
+
`\n${color.yellow}⚠${color.reset} No messenger is configured yet - ` +
|
|
287
|
+
`set up at least one of Discord, Telegram, or Slack first: ${color.cyan}lgy setup${color.reset}\n`,
|
|
288
|
+
);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!findAgyBin()) {
|
|
293
|
+
console.log(
|
|
294
|
+
`\n${color.yellow}⚠${color.reset} Couldn't find the agy CLI ` +
|
|
295
|
+
`(checked $AGY_BIN_PATH, ~/.local/bin/agy, and PATH). Install/configure agy first, ` +
|
|
296
|
+
`or set the AGY_BIN_PATH environment variable to its location.\n`,
|
|
297
|
+
);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
|
|
243
301
|
info('Starting LinkGravity daemon...');
|
|
244
302
|
runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
|
|
245
303
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
246
304
|
} else if (cmd === 'stop') {
|
|
247
305
|
info('Stopping LinkGravity daemon...');
|
|
248
306
|
runPm2(['stop', LGY_PM2_NAME]);
|
|
307
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
249
308
|
success('Daemon stopped successfully.\n');
|
|
250
309
|
} else if (cmd === 'restart') {
|
|
251
310
|
info('Restarting LinkGravity daemon...');
|
|
252
311
|
runPm2(['restart', LGY_PM2_NAME, '--update-env']);
|
|
312
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
253
313
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
254
314
|
} else if (cmd === 'logs') {
|
|
255
315
|
const SHORT_FLAGS = ['-f', '-n', '-t'];
|
|
@@ -292,15 +352,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
292
352
|
const sessions = getSessions();
|
|
293
353
|
const health = getPlatformHealth();
|
|
294
354
|
|
|
295
|
-
|
|
296
|
-
const jlist = spawnSync('npx', ['-y', 'pm2', 'jlist'], { stdio: 'pipe' });
|
|
297
|
-
if (jlist.status === 0) {
|
|
298
|
-
try {
|
|
299
|
-
pm2Procs = JSON.parse(jlist.stdout.toString());
|
|
300
|
-
} catch (e) {}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const proc = pm2Procs.find((p) => p.name === LGY_PM2_NAME);
|
|
355
|
+
const proc = getPm2Proc();
|
|
304
356
|
if (!proc) {
|
|
305
357
|
console.log(`\n${color.cyan}▶${color.reset} daemon: not running\n`);
|
|
306
358
|
} else {
|
|
@@ -310,9 +362,21 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
310
362
|
const uptime =
|
|
311
363
|
proc.pm2_env.status === 'online' ? formatUptime(proc.pm2_env.pm_uptime) : '-';
|
|
312
364
|
const restarts = proc.pm2_env.restart_time;
|
|
365
|
+
const statusColor = proc.pm2_env.status === 'online' ? color.green : color.red;
|
|
366
|
+
|
|
313
367
|
console.log(
|
|
314
|
-
`\n${color.cyan}▶${color.reset} daemon: ${proc.pm2_env.status}
|
|
368
|
+
`\n${color.cyan}▶${color.reset} daemon: ${statusColor}${proc.pm2_env.status}${color.reset}`,
|
|
315
369
|
);
|
|
370
|
+
for (const [label, value] of [
|
|
371
|
+
['uptime', uptime],
|
|
372
|
+
['restarts', restarts],
|
|
373
|
+
['cpu', cpu],
|
|
374
|
+
['mem', mem],
|
|
375
|
+
]) {
|
|
376
|
+
console.log(` ${label.padEnd(9)} ${value}`);
|
|
377
|
+
}
|
|
378
|
+
console.log();
|
|
379
|
+
|
|
316
380
|
// Flag "many restarts" only alongside a short current uptime - restart_time alone is cumulative, not live.
|
|
317
381
|
if (
|
|
318
382
|
proc.pm2_env.status === 'online' &&
|
|
@@ -331,16 +395,18 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
331
395
|
const sessionCount = Object.values(sessions).filter((s) => s.platform === key).length;
|
|
332
396
|
const h = health[key];
|
|
333
397
|
let connection = '-';
|
|
398
|
+
let since = '-';
|
|
334
399
|
if (enabled) {
|
|
335
400
|
if (!h) connection = 'unknown';
|
|
336
401
|
else if (h.status === 'running') connection = 'connected';
|
|
337
402
|
else if (h.status === 'connecting') connection = 'connecting...';
|
|
338
403
|
else if (h.status === 'error') connection = `error: ${h.detail || '?'}`;
|
|
339
404
|
else if (h.status === 'stopped') connection = 'stopped';
|
|
405
|
+
if (h && h.at) since = formatUptime(new Date(h.at).getTime());
|
|
340
406
|
}
|
|
341
|
-
return [def.label, enabled ? 'yes' : 'no', connection, String(sessionCount)];
|
|
407
|
+
return [def.label, enabled ? 'yes' : 'no', connection, since, String(sessionCount)];
|
|
342
408
|
});
|
|
343
|
-
console.log(renderTable(['platform', 'enabled', 'connection', 'sessions'], rows));
|
|
409
|
+
console.log(renderTable(['platform', 'enabled', 'connection', 'since', 'sessions'], rows));
|
|
344
410
|
console.log();
|
|
345
411
|
} else if (cmd === 'enable') {
|
|
346
412
|
if (isWin) {
|
|
@@ -409,6 +475,7 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
|
|
|
409
475
|
});
|
|
410
476
|
|
|
411
477
|
if (restartResult.status === 0) {
|
|
478
|
+
runPm2(['reset', LGY_PM2_NAME]);
|
|
412
479
|
verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
|
|
413
480
|
} else if ((restartResult.stderr || '').toString().includes('not found')) {
|
|
414
481
|
// Wasn't running before the update - start fresh instead of a false "restarted".
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkgravity",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.3",
|
|
4
4
|
"description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"postinstall": "node npm-scripts/postinstall.js",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"npm-scripts",
|
|
41
41
|
"hooks",
|
|
42
42
|
"src",
|
|
43
|
-
"voice-service
|
|
43
|
+
"voice-service/*.js",
|
|
44
44
|
"voice-service/package.json",
|
|
45
45
|
"voice-service/package-lock.json",
|
|
46
46
|
"requirements.txt",
|
package/src/cogs/general_cog.py
CHANGED
|
@@ -242,7 +242,7 @@ class GeneralCog(commands.Cog):
|
|
|
242
242
|
if not allowed(interaction.user.id):
|
|
243
243
|
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
244
244
|
|
|
245
|
-
settings_path = Path
|
|
245
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
246
246
|
try:
|
|
247
247
|
data = safe_load_json(settings_path, {}, logger=logger)
|
|
248
248
|
|
package/src/cogs/voice_cog.py
CHANGED
|
@@ -202,7 +202,7 @@ class VoiceCog(commands.Cog):
|
|
|
202
202
|
await interaction.response.send_message("❌ Please join a voice channel first.", ephemeral=True)
|
|
203
203
|
return
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
voice_channel = interaction.user.voice.channel
|
|
206
206
|
guild_id = interaction.guild_id
|
|
207
207
|
|
|
208
208
|
wake_word_map = self.bot_settings.get("wake_words") or {}
|
|
@@ -210,23 +210,26 @@ class VoiceCog(commands.Cog):
|
|
|
210
210
|
active_timer = self.bot_settings.get("active_timer", 60)
|
|
211
211
|
required = self._wake_word_required(interaction.user.id)
|
|
212
212
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
header = f"🎤 Connected to `{voice_channel.name}`."
|
|
214
|
+
sound_tip = "⚙️ You can customize settings using `/sound`."
|
|
215
|
+
|
|
216
|
+
if not required:
|
|
217
|
+
body = "💡 Wake word is off for you - just talk, I'm listening."
|
|
218
|
+
elif own_word:
|
|
219
|
+
body = (
|
|
220
|
+
f"💡 Say `{own_word}` to activate me. Once awake, "
|
|
221
|
+
f"I'll keep listening for {active_timer} seconds after each interaction."
|
|
218
222
|
)
|
|
219
|
-
elif not required:
|
|
220
|
-
msg = f"🎤 Connected to `{vc_chan.name}`.\n💡 Wake word is off for you - just talk, I'm listening."
|
|
221
223
|
else:
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
f"turning it off with `/sound require_wake_word:off` is recommended instead."
|
|
224
|
+
body = (
|
|
225
|
+
"🎙️ You haven't set up a wake word yet, so I can't hear you - run `/sound wake_word:<word>` "
|
|
226
|
+
"and say your chosen word a few times to register it in your voice.\n"
|
|
227
|
+
"💡 A wake word keeps everyone else's side conversation from triggering me by accident, and "
|
|
228
|
+
"avoids running speech recognition on audio that isn't meant for me. If you use push-to-talk, "
|
|
229
|
+
"turning it off with `/sound require_wake_word:off` is recommended instead."
|
|
229
230
|
)
|
|
231
|
+
|
|
232
|
+
msg = "\n".join([header, body, sound_tip])
|
|
230
233
|
await interaction.response.send_message(msg)
|
|
231
234
|
|
|
232
235
|
self.stt_session.clear_active_window(str(guild_id)) # don't inherit a window left open by a prior /join
|
|
@@ -239,7 +242,7 @@ class VoiceCog(commands.Cog):
|
|
|
239
242
|
self.logger.warning(f"Failed to call /leave before /join: {e}")
|
|
240
243
|
|
|
241
244
|
resp = await session.post(
|
|
242
|
-
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(
|
|
245
|
+
f"{NODE_VOICE_API}/join", json={"guild_id": str(guild_id), "channel_id": str(voice_channel.id)}
|
|
243
246
|
)
|
|
244
247
|
if not required:
|
|
245
248
|
# Node's opt-out set is in-memory and won't survive a Node restart, unlike our own bot_settings.
|
|
@@ -554,15 +557,16 @@ class VoiceCog(commands.Cog):
|
|
|
554
557
|
self.logger.warning(f"Failed to interrupt playback for guild {guild_id}: {e}")
|
|
555
558
|
self._active_turns[str(guild_id)] = asyncio.current_task()
|
|
556
559
|
|
|
557
|
-
|
|
558
|
-
if
|
|
560
|
+
guild = self.bot.get_guild(int(guild_id))
|
|
561
|
+
member = guild.get_member(int(user_id)) if guild else None
|
|
562
|
+
if guild and not member:
|
|
559
563
|
try:
|
|
560
|
-
|
|
564
|
+
member = await guild.fetch_member(int(user_id))
|
|
561
565
|
except discord.NotFound:
|
|
562
566
|
pass
|
|
563
567
|
except discord.HTTPException as e:
|
|
564
|
-
self.logger.warning(f"
|
|
565
|
-
username =
|
|
568
|
+
self.logger.warning(f"fetch_member failed for {user_id}: {e}")
|
|
569
|
+
username = member.display_name if member else f"User {user_id}"
|
|
566
570
|
await self.stt_session.finalize_partial_msg(str(guild_id), thread, f"🎤 **{username}**: {text}")
|
|
567
571
|
|
|
568
572
|
if not text_to_ai:
|
package/src/core/agy_runner.py
CHANGED
|
@@ -3,70 +3,70 @@ import functools
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
5
|
import signal
|
|
6
|
+
import sys
|
|
6
7
|
|
|
7
8
|
from config import logger
|
|
8
9
|
|
|
9
10
|
active_processes = {}
|
|
10
11
|
agy_start_lock = asyncio.Lock()
|
|
11
|
-
# Threads killed intentionally - agy's SIGTERM exit code isn't reliable enough to tell otherwise.
|
|
12
12
|
_intentionally_stopped = set()
|
|
13
13
|
|
|
14
|
-
# Forced stdout buffer size (see _find_libstdbuf) - big enough for one write, small enough not to delay polling.
|
|
15
14
|
_STDOUT_BUFFER_SIZE = 65536
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
@functools.lru_cache(maxsize=1)
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
18
|
+
def _find_preload_lib() -> tuple[str, str] | None:
|
|
19
|
+
if sys.platform == "darwin":
|
|
20
|
+
env_var = "DYLD_INSERT_LIBRARIES"
|
|
21
|
+
lib_name = "libstdbuf.dylib"
|
|
22
|
+
candidates = [
|
|
23
|
+
"/opt/homebrew/opt/coreutils/lib/libstdbuf.dylib",
|
|
24
|
+
"/usr/local/opt/coreutils/lib/libstdbuf.dylib",
|
|
25
|
+
]
|
|
26
|
+
search_root = "/opt/homebrew" if os.path.isdir("/opt/homebrew") else "/usr/local"
|
|
27
|
+
elif os.name == "nt":
|
|
28
|
+
return None
|
|
29
|
+
else:
|
|
30
|
+
env_var = "LD_PRELOAD"
|
|
31
|
+
lib_name = "libstdbuf.so"
|
|
32
|
+
candidates = [
|
|
33
|
+
"/usr/lib/x86_64-linux-gnu/coreutils/libstdbuf.so",
|
|
34
|
+
"/usr/lib/aarch64-linux-gnu/coreutils/libstdbuf.so",
|
|
35
|
+
"/usr/libexec/coreutils/libstdbuf.so",
|
|
36
|
+
"/usr/lib/coreutils/libstdbuf.so",
|
|
37
|
+
"/usr/lib/libstdbuf.so",
|
|
38
|
+
]
|
|
39
|
+
search_root = "/usr"
|
|
40
|
+
|
|
36
41
|
for path in candidates:
|
|
37
42
|
if os.path.isfile(path):
|
|
38
|
-
return path
|
|
43
|
+
return env_var, path
|
|
39
44
|
|
|
40
|
-
# lru_cache: only runs once per process.
|
|
41
45
|
try:
|
|
42
46
|
import subprocess
|
|
43
47
|
|
|
44
48
|
result = subprocess.run(
|
|
45
|
-
["find",
|
|
49
|
+
["find", search_root, "-name", lib_name],
|
|
46
50
|
capture_output=True,
|
|
47
51
|
text=True,
|
|
48
52
|
timeout=5,
|
|
49
53
|
)
|
|
50
54
|
found = [line for line in result.stdout.strip().splitlines() if line]
|
|
51
55
|
if found:
|
|
52
|
-
return found[0]
|
|
56
|
+
return env_var, found[0]
|
|
53
57
|
except Exception:
|
|
54
58
|
pass
|
|
55
59
|
|
|
60
|
+
install_hint = "brew install coreutils" if sys.platform == "darwin" else "install coreutils"
|
|
56
61
|
logger.warning(
|
|
57
|
-
"[AGY ENV]
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"somewhere nonstandard."
|
|
62
|
+
f"[AGY ENV] {lib_name} not found - run_command output for multi-line "
|
|
63
|
+
f"commands may come back truncated. Try `{install_hint}`, or add its path "
|
|
64
|
+
"to _find_preload_lib()'s candidates list if installed somewhere nonstandard."
|
|
61
65
|
)
|
|
62
66
|
return None
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
def stop_active_process(thread_id: str) -> bool:
|
|
66
|
-
"""Kill the agy subprocess for this thread, if any. Also used when a
|
|
67
|
-
new voice utterance interrupts a still-in-flight turn. Returns
|
|
68
|
-
whether a process was actually found and signaled.
|
|
69
|
-
"""
|
|
70
70
|
target_proc = active_processes.get(thread_id)
|
|
71
71
|
if not target_proc:
|
|
72
72
|
return False
|
|
@@ -100,6 +100,24 @@ async def _get_latest_conversation_id() -> str:
|
|
|
100
100
|
return ""
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _snapshot_conversation_dirs() -> set[str]:
|
|
104
|
+
from pathlib import Path
|
|
105
|
+
|
|
106
|
+
history_dir = Path.home() / ".gemini/antigravity-cli/brain"
|
|
107
|
+
if not history_dir.exists():
|
|
108
|
+
return set()
|
|
109
|
+
return {d.name for d in history_dir.iterdir() if d.is_dir()}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _poll_new_conversation_id(before: set[str], attempts: int = 30, interval: float = 0.1) -> str:
|
|
113
|
+
for _ in range(attempts):
|
|
114
|
+
await asyncio.sleep(interval)
|
|
115
|
+
new_dirs = _snapshot_conversation_dirs() - before
|
|
116
|
+
if new_dirs:
|
|
117
|
+
return next(iter(new_dirs))
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
103
121
|
async def run_agy(
|
|
104
122
|
*args, timeout: int = 300, stream_queue: asyncio.Queue = None, thread_id: str = None, cwd: str = None
|
|
105
123
|
) -> str:
|
|
@@ -123,10 +141,11 @@ async def run_agy(
|
|
|
123
141
|
if thread_id:
|
|
124
142
|
env["LGY_THREAD_ID"] = thread_id
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
if
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
preload = _find_preload_lib() # works around agy's output-truncation bug
|
|
145
|
+
if preload:
|
|
146
|
+
env_var, lib_path = preload
|
|
147
|
+
existing_preload = env.get(env_var, "")
|
|
148
|
+
env[env_var] = f"{lib_path}:{existing_preload}" if existing_preload else lib_path
|
|
130
149
|
env["_STDBUF_O"] = str(_STDOUT_BUFFER_SIZE)
|
|
131
150
|
|
|
132
151
|
kwargs = {
|
|
@@ -149,15 +168,15 @@ async def run_agy(
|
|
|
149
168
|
cmd = [AGY_BIN] + args_list
|
|
150
169
|
|
|
151
170
|
async with agy_start_lock:
|
|
171
|
+
before_dirs = _snapshot_conversation_dirs()
|
|
152
172
|
proc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
|
|
153
173
|
if thread_id:
|
|
154
174
|
active_processes[thread_id] = proc
|
|
155
175
|
|
|
156
176
|
if stream_queue and "--conversation" not in args:
|
|
157
|
-
await
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await stream_queue.put(("__CONV_ID__:" + latest_conv_id, False))
|
|
177
|
+
new_conv_id = await _poll_new_conversation_id(before_dirs)
|
|
178
|
+
if new_conv_id:
|
|
179
|
+
await stream_queue.put(("__CONV_ID__:" + new_conv_id, False))
|
|
161
180
|
|
|
162
181
|
stdout_chunks = []
|
|
163
182
|
stderr_chunks = []
|
|
@@ -267,7 +286,6 @@ async def run_agy(
|
|
|
267
286
|
await stream_queue.put(("\n\n" + error_msg, True))
|
|
268
287
|
return error_msg
|
|
269
288
|
|
|
270
|
-
# DEBUG-only (LOG_LEVEL) raw stdout capture.
|
|
271
289
|
logger.debug(f"[AGY RAW STDOUT] {text!r}")
|
|
272
290
|
return text or "(Empty response)"
|
|
273
291
|
|
|
@@ -379,11 +397,6 @@ async def generate_thread_title(user_input: str, response: str) -> str:
|
|
|
379
397
|
|
|
380
398
|
|
|
381
399
|
async def update_agy_conversation_title(conv_id: str, title: str) -> None:
|
|
382
|
-
"""Antigravity CLI's own conversation list reads its display name from
|
|
383
|
-
conversation_summaries.db's `preview` column (the `title` column exists
|
|
384
|
-
but is unused/always empty - confirmed by inspecting the db directly).
|
|
385
|
-
Without this, agy shows its own auto-generated name while the Discord
|
|
386
|
-
thread shows ours, and the two drift apart for the same conversation."""
|
|
387
400
|
if not conv_id:
|
|
388
401
|
return
|
|
389
402
|
|
package/src/main_slack.py
CHANGED
|
@@ -139,12 +139,11 @@ async def cmd_credit(ack, body, respond, context) -> None:
|
|
|
139
139
|
await respond("❌ Denied")
|
|
140
140
|
return
|
|
141
141
|
|
|
142
|
-
import os
|
|
143
142
|
from pathlib import Path
|
|
144
143
|
|
|
145
144
|
from core.atomic_io import atomic_write_json, safe_load_json
|
|
146
145
|
|
|
147
|
-
settings_path = Path
|
|
146
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
148
147
|
current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
|
|
149
148
|
|
|
150
149
|
async def set_credit(use_credits: bool, action_body, client) -> None:
|
package/src/main_telegram.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
which starts both concurrently when both platforms are enabled."""
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
|
-
import os
|
|
6
5
|
import uuid
|
|
7
6
|
from datetime import datetime
|
|
8
7
|
from pathlib import Path
|
|
@@ -144,7 +143,7 @@ async def cmd_credit(update: Update, context) -> None:
|
|
|
144
143
|
await update.message.reply_text("❌ Denied")
|
|
145
144
|
return
|
|
146
145
|
|
|
147
|
-
settings_path = Path
|
|
146
|
+
settings_path = Path.home() / ".gemini/antigravity-cli/settings.json"
|
|
148
147
|
current = bool(safe_load_json(settings_path, {}, logger=logger).get("useG1Credits", False))
|
|
149
148
|
|
|
150
149
|
async def set_credit(use_credits: bool, query) -> None:
|
|
@@ -182,6 +181,10 @@ async def cmd_credit(update: Update, context) -> None:
|
|
|
182
181
|
|
|
183
182
|
|
|
184
183
|
async def on_message(update: Update, context) -> None:
|
|
184
|
+
chat_id = update.effective_chat.id
|
|
185
|
+
user = update.effective_user
|
|
186
|
+
if user and allowed(user.id, "telegram") and not session_manager.get_session(str(chat_id)):
|
|
187
|
+
_start_session(chat_id, user.id)
|
|
185
188
|
await handle_message(None, update, context.bot_data["adapter"])
|
|
186
189
|
|
|
187
190
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function stereoToMono(buffer) {
|
|
2
|
+
// Discord voice receive is 48kHz stereo; everything downstream (WAV, Rustpotter) expects mono.
|
|
3
|
+
const samples = buffer.length >> 2; // 2 bytes/sample * 2 channels
|
|
4
|
+
const mono = Buffer.alloc(samples * 2);
|
|
5
|
+
for (let i = 0; i < samples; i++) {
|
|
6
|
+
const l = buffer.readInt16LE(i * 4);
|
|
7
|
+
const r = buffer.readInt16LE(i * 4 + 2);
|
|
8
|
+
mono.writeInt16LE((l + r) >> 1, i * 2);
|
|
9
|
+
}
|
|
10
|
+
return mono;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createWavHeader(dataLength, sampleRate = 48000, channels = 1, bitDepth = 16) {
|
|
14
|
+
const buffer = Buffer.alloc(44);
|
|
15
|
+
buffer.write('RIFF', 0);
|
|
16
|
+
buffer.writeUInt32LE(36 + dataLength, 4);
|
|
17
|
+
buffer.write('WAVE', 8);
|
|
18
|
+
buffer.write('fmt ', 12);
|
|
19
|
+
buffer.writeUInt32LE(16, 16);
|
|
20
|
+
buffer.writeUInt16LE(1, 20);
|
|
21
|
+
buffer.writeUInt16LE(channels, 22);
|
|
22
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
23
|
+
buffer.writeUInt32LE(sampleRate * channels * (bitDepth / 8), 28);
|
|
24
|
+
buffer.writeUInt16LE(channels * (bitDepth / 8), 32);
|
|
25
|
+
buffer.writeUInt16LE(bitDepth, 34);
|
|
26
|
+
buffer.write('data', 36);
|
|
27
|
+
buffer.writeUInt32LE(dataLength, 40);
|
|
28
|
+
return buffer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { stereoToMono, createWavHeader };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const aglJsonPath = path.join(os.homedir(), '.gemini', 'linkgravity', 'lgy.json');
|
|
6
|
+
let aglConfig = {};
|
|
7
|
+
try {
|
|
8
|
+
if (fs.existsSync(aglJsonPath)) {
|
|
9
|
+
aglConfig = JSON.parse(fs.readFileSync(aglJsonPath, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error('Failed to load lgy.json:', e.message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
process.env.DISCORD_TOKEN =
|
|
16
|
+
aglConfig.discord_token || aglConfig.DISCORD_TOKEN || process.env.DISCORD_TOKEN;
|
|
17
|
+
|
|
18
|
+
process.env.http_proxy = '';
|
|
19
|
+
process.env.https_proxy = '';
|
|
20
|
+
process.env.HTTP_PROXY = '';
|
|
21
|
+
process.env.HTTPS_PROXY = '';
|
|
22
|
+
process.env.PYTHON_HOST = '';
|
|
23
|
+
|
|
24
|
+
module.exports = { aglConfig };
|
package/voice-service/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
require('./logger');
|
|
1
|
+
require('./logger');
|
|
2
2
|
|
|
3
3
|
const { Client, GatewayIntentBits, Events } = require('discord.js');
|
|
4
4
|
const express = require('express');
|
|
5
5
|
|
|
6
6
|
const { aglConfig } = require('./config');
|
|
7
7
|
const state = require('./state');
|
|
8
|
+
|
|
9
|
+
process.on('unhandledRejection', (reason) => {
|
|
10
|
+
console.error('Unhandled promise rejection (voice service stays alive):', reason);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
process.on('uncaughtException', (err) => {
|
|
14
|
+
// Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
|
|
15
|
+
console.error('Uncaught exception - voice service is exiting:', err);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
});
|
|
18
|
+
|
|
8
19
|
const { registerRoutes } = require('./routes');
|
|
9
20
|
|
|
10
21
|
if (aglConfig.voice_threshold) {
|
|
@@ -24,16 +35,6 @@ client.once(Events.ClientReady, () => {
|
|
|
24
35
|
|
|
25
36
|
registerRoutes(app, client);
|
|
26
37
|
|
|
27
|
-
process.on('unhandledRejection', (reason) => {
|
|
28
|
-
console.error('Unhandled promise rejection (voice service stays alive):', reason);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
process.on('uncaughtException', (err) => {
|
|
32
|
-
// Logs the cause before exiting - an unhandled sync error used to kill the process with no trace.
|
|
33
|
-
console.error('Uncaught exception - voice service is exiting:', err);
|
|
34
|
-
process.exit(1);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
38
|
// Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
|
|
38
39
|
function shutdownGracefully() {
|
|
39
40
|
console.log(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const originalLog = console.log;
|
|
2
|
+
const originalError = console.error;
|
|
3
|
+
|
|
4
|
+
function getTimestamp() {
|
|
5
|
+
const now = new Date();
|
|
6
|
+
const offset = now.getTimezoneOffset() * 60000;
|
|
7
|
+
const localTime = new Date(now.getTime() - offset);
|
|
8
|
+
return localTime.toISOString().replace('T', ' ').substring(0, 19);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
console.log = function (...args) {
|
|
12
|
+
originalLog(`${getTimestamp()} INFO Voice:`, ...args);
|
|
13
|
+
};
|
|
14
|
+
console.error = function (...args) {
|
|
15
|
+
originalError(`${getTimestamp()} ERROR Voice:`, ...args);
|
|
16
|
+
};
|