linkgravity 1.0.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/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/cli.js +278 -0
- package/bin/setup.js +260 -0
- package/hooks/hook.py +60 -0
- package/hooks/stop_hook.py +64 -0
- package/npm-scripts/postinstall.js +62 -0
- package/npm-scripts/prepare.js +45 -0
- package/npm-scripts/register-hook.js +182 -0
- package/npm-scripts/run-dev.js +9 -0
- package/npm-scripts/venv-paths.js +45 -0
- package/package.json +59 -0
- package/requirements.txt +13 -0
- package/src/api/server.py +48 -0
- package/src/api/ui_routes.py +340 -0
- package/src/api/voice_routes.py +94 -0
- package/src/approval/command_parser.py +62 -0
- package/src/approval/tool_formatter.py +68 -0
- package/src/cogs/general_cog.py +287 -0
- package/src/cogs/voice/__init__.py +0 -0
- package/src/cogs/voice/enrollment.py +436 -0
- package/src/cogs/voice/stt_session.py +121 -0
- package/src/cogs/voice_cog.py +573 -0
- package/src/config.py +123 -0
- package/src/core/agy_runner.py +380 -0
- package/src/core/atomic_io.py +31 -0
- package/src/core/logger.py +28 -0
- package/src/core/session_manager.py +126 -0
- package/src/handlers/message_router.py +16 -0
- package/src/handlers/thread_reply.py +165 -0
- package/src/main.py +313 -0
- package/src/messengers/base.py +105 -0
- package/src/messengers/discord_adapter.py +240 -0
- package/src/messengers/registry.py +19 -0
- package/src/services/audio_service.py +67 -0
- package/src/services/discord_helpers.py +95 -0
- package/src/services/discord_mcp.py +50 -0
- package/src/services/response.py +51 -0
- package/src/services/streaming.py +199 -0
- package/src/utils/utils.py +40 -0
- package/voice-service/index.js +1048 -0
- package/voice-service/package-lock.json +1880 -0
- package/voice-service/package.json +24 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import glob
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import discord
|
|
7
|
+
from discord import app_commands
|
|
8
|
+
from discord.ext import commands
|
|
9
|
+
|
|
10
|
+
from config import DATA_DIR, EMBED_COLOR, MODEL_CHOICES, allowed, is_allowed_session_channel, logger, session_manager
|
|
11
|
+
from core.atomic_io import atomic_write_json, safe_load_json
|
|
12
|
+
from utils.utils import get_default_cwd
|
|
13
|
+
|
|
14
|
+
MODELS_CACHE_FILE = DATA_DIR / "models.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_cached_models():
|
|
18
|
+
default_models = [
|
|
19
|
+
"Gemini 3.5 Flash (Medium)",
|
|
20
|
+
"Gemini 3.5 Flash (High)",
|
|
21
|
+
"Gemini 3.5 Flash (Low)",
|
|
22
|
+
"Gemini 3.1 Pro (Low)",
|
|
23
|
+
"Gemini 3.1 Pro (High)",
|
|
24
|
+
"Claude Sonnet 4.6 (Thinking)",
|
|
25
|
+
"Claude Opus 4.6 (Thinking)",
|
|
26
|
+
"GPT-OSS 120B (Medium)",
|
|
27
|
+
]
|
|
28
|
+
return safe_load_json(MODELS_CACHE_FILE, default_models, logger=logger)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
cached_models = load_cached_models()
|
|
32
|
+
last_models_fetch = 0
|
|
33
|
+
fetching_models = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def fetch_models_background():
|
|
37
|
+
global cached_models, last_models_fetch, fetching_models
|
|
38
|
+
import re
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from config import AGY_BIN
|
|
43
|
+
|
|
44
|
+
logger.debug(f"Starting fetch_models_background using AGY_BIN: {AGY_BIN}")
|
|
45
|
+
env = os.environ.copy()
|
|
46
|
+
env["AGY_DISCORD_BOT"] = "1"
|
|
47
|
+
p = await asyncio.create_subprocess_exec(
|
|
48
|
+
AGY_BIN,
|
|
49
|
+
"models",
|
|
50
|
+
stdout=asyncio.subprocess.PIPE,
|
|
51
|
+
stderr=asyncio.subprocess.PIPE,
|
|
52
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
53
|
+
env=env,
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=30.0)
|
|
57
|
+
except asyncio.TimeoutError:
|
|
58
|
+
p.kill()
|
|
59
|
+
logger.error("agy models timed out after 30 seconds.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
raw_text = stdout.decode("utf-8")
|
|
63
|
+
ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
64
|
+
raw_text = ansi_escape.sub("", raw_text)
|
|
65
|
+
raw_text = raw_text.replace("\r", "\n")
|
|
66
|
+
|
|
67
|
+
models = []
|
|
68
|
+
for line in raw_text.split("\n"):
|
|
69
|
+
line = line.strip()
|
|
70
|
+
line = re.sub(r"[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]", "", line).strip()
|
|
71
|
+
if line and "Fetching available models" not in line:
|
|
72
|
+
models.append(line)
|
|
73
|
+
|
|
74
|
+
if models:
|
|
75
|
+
cached_models = models
|
|
76
|
+
last_models_fetch = time.time()
|
|
77
|
+
try:
|
|
78
|
+
atomic_write_json(MODELS_CACHE_FILE, models)
|
|
79
|
+
except Exception as e:
|
|
80
|
+
logger.warning(f"Failed to save models to JSON cache: {e}")
|
|
81
|
+
logger.debug(f"Successfully fetched {len(models)} models and saved to models.json")
|
|
82
|
+
else:
|
|
83
|
+
logger.warning(f"Failed to parse any models. Raw stdout was: {raw_text[:200]}")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.warning(f"Failed to fetch models natively: {e}")
|
|
86
|
+
finally:
|
|
87
|
+
fetching_models = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GeneralCog(commands.Cog):
|
|
91
|
+
def __init__(self, bot):
|
|
92
|
+
self.bot = bot
|
|
93
|
+
asyncio.create_task(fetch_models_background())
|
|
94
|
+
|
|
95
|
+
async def cwd_autocomplete(self, interaction: discord.Interaction, current: str) -> list[app_commands.Choice[str]]:
|
|
96
|
+
if not current:
|
|
97
|
+
current = str(Path.home())
|
|
98
|
+
try:
|
|
99
|
+
paths = [d + "/" for d in glob.glob(current + "*") if os.path.isdir(d)]
|
|
100
|
+
return [app_commands.Choice(name=p, value=p) for p in paths if p and len(p) <= 100][:25]
|
|
101
|
+
except Exception:
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
async def model_autocomplete(
|
|
105
|
+
self, interaction: discord.Interaction, current: str
|
|
106
|
+
) -> list[app_commands.Choice[str]]:
|
|
107
|
+
global cached_models, last_models_fetch, fetching_models
|
|
108
|
+
import time
|
|
109
|
+
|
|
110
|
+
cached_models = load_cached_models()
|
|
111
|
+
|
|
112
|
+
if time.time() - last_models_fetch > 3600 and not fetching_models:
|
|
113
|
+
fetching_models = True
|
|
114
|
+
asyncio.create_task(fetch_models_background())
|
|
115
|
+
|
|
116
|
+
from utils.utils import get_current_model
|
|
117
|
+
|
|
118
|
+
session = session_manager.get_session(str(interaction.channel_id)) or {}
|
|
119
|
+
current_model = session.get("model") or get_current_model()
|
|
120
|
+
|
|
121
|
+
choices = []
|
|
122
|
+
if current_model:
|
|
123
|
+
choices.append(app_commands.Choice(name=f"{current_model} (current)", value=current_model))
|
|
124
|
+
|
|
125
|
+
for m in cached_models:
|
|
126
|
+
if m != current_model and current.lower() in m.lower() and len(choices) < 25:
|
|
127
|
+
choices.append(app_commands.Choice(name=m, value=m))
|
|
128
|
+
return choices
|
|
129
|
+
|
|
130
|
+
@app_commands.command(name="new", description="Start a new agy session thread")
|
|
131
|
+
@app_commands.describe(cwd="Working directory path", model="Model to use")
|
|
132
|
+
async def cmd_new(self, interaction: discord.Interaction, cwd: str = None, model: str = None):
|
|
133
|
+
if not allowed(interaction.user.id):
|
|
134
|
+
await interaction.response.send_message("❌ Permission Denied", ephemeral=True)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
target_channel = (
|
|
138
|
+
interaction.channel.parent if isinstance(interaction.channel, discord.Thread) else interaction.channel
|
|
139
|
+
)
|
|
140
|
+
if not is_allowed_session_channel(target_channel):
|
|
141
|
+
await interaction.response.send_message(
|
|
142
|
+
"❌ This channel/server isn't configured for starting sessions. Run `lgy setup` to add it.",
|
|
143
|
+
ephemeral=True,
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if model:
|
|
148
|
+
exact = next((m for m in cached_models if m.lower() == model.lower()), None)
|
|
149
|
+
partial = next((m for m in cached_models if model.lower() in m.lower()), None)
|
|
150
|
+
model = exact or partial
|
|
151
|
+
if not model:
|
|
152
|
+
await interaction.response.send_message(f"⚠️ Model matching `{model}` not found.", ephemeral=True)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
await interaction.response.defer(ephemeral=True)
|
|
156
|
+
import uuid
|
|
157
|
+
|
|
158
|
+
new_conv_id = str(uuid.uuid4())
|
|
159
|
+
thread = await target_channel.create_thread(
|
|
160
|
+
name=f"Session-{new_conv_id[:4].upper()}",
|
|
161
|
+
auto_archive_duration=1440,
|
|
162
|
+
type=discord.ChannelType.public_thread,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
session_manager.set_session(
|
|
166
|
+
str(thread.id),
|
|
167
|
+
{
|
|
168
|
+
"status": "pending",
|
|
169
|
+
"user_id": interaction.user.id,
|
|
170
|
+
"cwd": cwd or get_default_cwd(),
|
|
171
|
+
"model": model,
|
|
172
|
+
"conversation_id": None,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
await thread.send("✅ **Ready for new session!**")
|
|
176
|
+
await interaction.followup.send(f"✅ New session created: {thread.mention}", ephemeral=True)
|
|
177
|
+
|
|
178
|
+
@cmd_new.autocomplete("cwd")
|
|
179
|
+
async def cmd_new_cwd_autocomplete(self, interaction: discord.Interaction, current: str):
|
|
180
|
+
return await self.cwd_autocomplete(interaction, current)
|
|
181
|
+
|
|
182
|
+
@cmd_new.autocomplete("model")
|
|
183
|
+
async def cmd_new_model_autocomplete(self, interaction: discord.Interaction, current: str):
|
|
184
|
+
return await self.model_autocomplete(interaction, current)
|
|
185
|
+
|
|
186
|
+
@app_commands.command(name="model", description="Change AI model")
|
|
187
|
+
async def cmd_model(self, interaction: discord.Interaction, model: str):
|
|
188
|
+
if not allowed(interaction.user.id):
|
|
189
|
+
return await interaction.response.send_message("❌ Permission Denied", ephemeral=True)
|
|
190
|
+
thread_id = str(interaction.channel_id)
|
|
191
|
+
session = session_manager.get_session(thread_id)
|
|
192
|
+
if not session:
|
|
193
|
+
return await interaction.response.send_message("⚠️ Not an agy session thread.", ephemeral=True)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
exact = next((m for m in cached_models if m.lower() == model.lower()), None)
|
|
197
|
+
partial = next((m for m in cached_models if model.lower() in m.lower()), None)
|
|
198
|
+
final_model = exact or partial or model
|
|
199
|
+
session_manager.update_session(str(interaction.channel_id), "model", final_model)
|
|
200
|
+
await interaction.response.send_message(
|
|
201
|
+
embed=discord.Embed(description=f"🤖 Model changed: **{final_model}**", color=EMBED_COLOR)
|
|
202
|
+
)
|
|
203
|
+
except discord.Forbidden:
|
|
204
|
+
logger.error(f"Missing permissions to start thread in channel {interaction.channel_id}")
|
|
205
|
+
await interaction.response.send_message("❌ Missing permissions to start a thread here.", ephemeral=True)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
if not interaction.response.is_done():
|
|
208
|
+
await interaction.response.send_message(f"⚠️ An error occurred: {e}", ephemeral=True)
|
|
209
|
+
|
|
210
|
+
@cmd_model.autocomplete("model")
|
|
211
|
+
async def cmd_model_autocomplete(self, interaction: discord.Interaction, current: str):
|
|
212
|
+
return await self.model_autocomplete(interaction, current)
|
|
213
|
+
|
|
214
|
+
@app_commands.command(name="credit", description="Set whether to use AI Credits for this session (on/off)")
|
|
215
|
+
@app_commands.describe(action="Turn AI Credits ON or OFF")
|
|
216
|
+
@app_commands.choices(
|
|
217
|
+
action=[app_commands.Choice(name="on", value="on"), app_commands.Choice(name="off", value="off")]
|
|
218
|
+
)
|
|
219
|
+
async def cmd_credit(self, interaction: discord.Interaction, action: app_commands.Choice[str]):
|
|
220
|
+
if not allowed(interaction.user.id):
|
|
221
|
+
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
222
|
+
|
|
223
|
+
settings_path = Path(os.getenv("HOME", "/root")) / ".gemini/antigravity-cli/settings.json"
|
|
224
|
+
try:
|
|
225
|
+
data = safe_load_json(settings_path, {}, logger=logger)
|
|
226
|
+
|
|
227
|
+
use_credits = action.value == "on"
|
|
228
|
+
data["useG1Credits"] = use_credits
|
|
229
|
+
atomic_write_json(settings_path, data)
|
|
230
|
+
|
|
231
|
+
status_text = "🟢 **ON** (Using AI Credits)" if use_credits else "🔴 **OFF** (Using default/free model)"
|
|
232
|
+
await interaction.response.send_message(f"✅ AI Credit setting updated: {status_text}")
|
|
233
|
+
except Exception as e:
|
|
234
|
+
await interaction.response.send_message(f"⚠️ Failed to update settings: {e}", ephemeral=True)
|
|
235
|
+
|
|
236
|
+
@app_commands.command(
|
|
237
|
+
name="stop", description="Stop the currently generating response or task (Equivalent to ESC in CLI)"
|
|
238
|
+
)
|
|
239
|
+
async def cmd_stop(self, interaction: discord.Interaction):
|
|
240
|
+
if not allowed(interaction.user.id):
|
|
241
|
+
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
242
|
+
thread_id = str(interaction.channel_id)
|
|
243
|
+
|
|
244
|
+
session = session_manager.get_session(thread_id)
|
|
245
|
+
if session:
|
|
246
|
+
conv_id = session.get("conversation_id")
|
|
247
|
+
future = session_manager.get_pending_approval_by_conv(conv_id)
|
|
248
|
+
if future and not future.done():
|
|
249
|
+
future.set_result("reject")
|
|
250
|
+
|
|
251
|
+
prev_tts = session_manager.get_tts_task(thread_id)
|
|
252
|
+
if prev_tts and not prev_tts.done():
|
|
253
|
+
prev_tts.cancel()
|
|
254
|
+
session_manager.remove_tts_task(thread_id)
|
|
255
|
+
|
|
256
|
+
# A killed turn leaves the conversation in an unknown state -
|
|
257
|
+
# reusing conv_id risks silently hanging on the next message.
|
|
258
|
+
# conversation_id must be cleared too, not just status: the
|
|
259
|
+
# text path only treats a session as pending when both are unset.
|
|
260
|
+
session_manager.set_session(thread_id, {**session, "status": "pending", "conversation_id": None})
|
|
261
|
+
|
|
262
|
+
from core.agy_runner import stop_active_process
|
|
263
|
+
|
|
264
|
+
if stop_active_process(thread_id):
|
|
265
|
+
await interaction.response.send_message("🛑 Process stopped natively.", ephemeral=True)
|
|
266
|
+
else:
|
|
267
|
+
await interaction.response.send_message("🛑 Process stopped.", ephemeral=True)
|
|
268
|
+
|
|
269
|
+
@app_commands.command(name="list", description="Active session list")
|
|
270
|
+
async def cmd_sessions(self, interaction: discord.Interaction):
|
|
271
|
+
if not allowed(interaction.user.id):
|
|
272
|
+
return await interaction.response.send_message("❌ Denied", ephemeral=True)
|
|
273
|
+
all_sessions = session_manager.get_all_sessions()
|
|
274
|
+
if not all_sessions:
|
|
275
|
+
return await interaction.response.send_message("No active sessions.", ephemeral=True)
|
|
276
|
+
|
|
277
|
+
embed = discord.Embed(title="📋 Session List", color=EMBED_COLOR)
|
|
278
|
+
from utils.utils import get_current_model
|
|
279
|
+
|
|
280
|
+
for thread_id, sess in list(all_sessions.items())[-10:]:
|
|
281
|
+
ch = self.bot.get_channel(int(thread_id))
|
|
282
|
+
embed.add_field(
|
|
283
|
+
name=f"#{getattr(ch, 'name', f'ID:{thread_id}')}",
|
|
284
|
+
value=f"🤖 {MODEL_CHOICES.get(sess.get('model'), sess.get('model')) or get_current_model()}",
|
|
285
|
+
inline=False,
|
|
286
|
+
)
|
|
287
|
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
File without changes
|