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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. 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