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,240 @@
1
+ """Discord implementation of MessengerAdapter. All discord.py UI code
2
+ (buttons, modals, embeds) for the main messaging path lives here."""
3
+
4
+ import asyncio
5
+ from contextlib import asynccontextmanager
6
+ from typing import Any
7
+
8
+ import discord
9
+
10
+ from config import logger
11
+ from messengers.base import (
12
+ MessengerAdapter,
13
+ PromptHandle,
14
+ ScopeOption,
15
+ ToolApprovalOutcome,
16
+ VoiceCapable,
17
+ )
18
+
19
+
20
+ class _DiscordPromptHandle(PromptHandle):
21
+ def __init__(self, embed: discord.Embed, view: discord.ui.View):
22
+ self.embed = embed
23
+ self.view = view
24
+ self.message: discord.Message | None = None
25
+ self.outcome: ToolApprovalOutcome | None = None
26
+
27
+ async def send(self, conversation_ref: discord.abc.Messageable) -> discord.Message:
28
+ self.message = await conversation_ref.send(embed=self.embed, view=self.view)
29
+ return self.message
30
+
31
+ async def finalize(self) -> None:
32
+ for child in self.view.children:
33
+ child.disabled = True
34
+ if self.message is None:
35
+ return
36
+ try:
37
+ await self.message.edit(embed=self.embed, view=self.view)
38
+ except discord.NotFound:
39
+ pass
40
+ except discord.HTTPException as e:
41
+ logger.warning(f"Failed to finalize prompt message: {e}")
42
+
43
+
44
+ class DiscordAdapter(MessengerAdapter):
45
+ platform_name = "discord"
46
+
47
+ def __init__(self, bot: discord.Client):
48
+ self.bot = bot
49
+
50
+ # -- plain messaging ---------------------------------------------------
51
+
52
+ async def send_message(self, conversation_ref: discord.abc.Messageable, text: str) -> discord.Message:
53
+ return await conversation_ref.send(text)
54
+
55
+ async def edit_message(self, message_ref: discord.Message, text: str) -> bool:
56
+ try:
57
+ await message_ref.edit(content=text, embed=None)
58
+ return True
59
+ except discord.NotFound:
60
+ logger.warning("Discord message not found (likely deleted).")
61
+ return False
62
+ except discord.Forbidden:
63
+ logger.error("Forbidden to edit Discord message (missing permissions).")
64
+ return False
65
+ except discord.HTTPException as e:
66
+ logger.warning(f"HTTPException editing Discord message: {e}")
67
+ return False
68
+
69
+ async def send_files(self, conversation_ref: discord.abc.Messageable, file_paths: list[str]) -> None:
70
+ if not file_paths:
71
+ return
72
+ await conversation_ref.send(files=[discord.File(p) for p in file_paths])
73
+
74
+ def resolve_conversation(self, conversation_id: str) -> Any:
75
+ try:
76
+ return self.bot.get_channel(int(conversation_id))
77
+ except (TypeError, ValueError):
78
+ return None
79
+
80
+ async def start_conversation(self, origin_ref: discord.Message, title: str) -> discord.Thread:
81
+ return await origin_ref.create_thread(name=title[:100], auto_archive_duration=1440)
82
+
83
+ async def rename_conversation(self, conversation_ref: discord.Thread, title: str) -> None:
84
+ await conversation_ref.edit(name=title[:100])
85
+
86
+ @asynccontextmanager
87
+ async def typing(self, conversation_ref: discord.abc.Messageable):
88
+ async with conversation_ref.typing():
89
+ yield
90
+
91
+ # -- interactive prompts ----------------------------------------------
92
+
93
+ def create_tool_approval_prompt(
94
+ self,
95
+ decision_future: asyncio.Future,
96
+ title: str,
97
+ body: str,
98
+ scope_options: list[ScopeOption],
99
+ ) -> PromptHandle:
100
+ embed = discord.Embed(title=title, description=body, color=discord.Color.orange())
101
+ view = discord.ui.View(timeout=None)
102
+ handle = _DiscordPromptHandle(embed, view)
103
+
104
+ def make_callback(decision: str, scope: ScopeOption | None):
105
+ async def callback(interaction: discord.Interaction):
106
+ handle.outcome = ToolApprovalOutcome(decision=decision, scope=scope)
107
+ if not decision_future.done():
108
+ decision_future.set_result(decision)
109
+
110
+ for child in view.children:
111
+ child.disabled = True
112
+ if decision == "allow" and scope:
113
+ embed.color = discord.Color.green()
114
+ embed.title = f"✅ Approved & Auto-Allowed ({scope.scope})"
115
+ elif decision == "allow":
116
+ embed.color = discord.Color.green()
117
+ embed.title = "✅ Tool Execution Approved"
118
+ else:
119
+ embed.color = discord.Color.red()
120
+ embed.title = "❌ Tool Execution Rejected"
121
+ await interaction.response.edit_message(embed=embed, view=view)
122
+
123
+ return callback
124
+
125
+ btn_once = discord.ui.Button(label="✅ Approve once", style=discord.ButtonStyle.green)
126
+ btn_once.callback = make_callback("allow", None)
127
+ view.add_item(btn_once)
128
+
129
+ for opt in scope_options:
130
+ suffix = " tool" if opt.kind == "tools" else ""
131
+ label = f"♾️ Allow [{opt.scope}]{suffix}"
132
+ if len(label) > 80:
133
+ label = f"♾️ Allow […{opt.scope[-68:]}]{suffix}"[:80]
134
+ btn = discord.ui.Button(label=label, style=discord.ButtonStyle.gray)
135
+ btn.callback = make_callback("allow", opt)
136
+ view.add_item(btn)
137
+
138
+ btn_reject = discord.ui.Button(label="❌ Reject", style=discord.ButtonStyle.red)
139
+ btn_reject.callback = make_callback("reject", None)
140
+ view.add_item(btn_reject)
141
+
142
+ return handle
143
+
144
+ def create_question_prompt(
145
+ self,
146
+ answer_future: asyncio.Future,
147
+ question: str,
148
+ options: list[str],
149
+ multi_select: bool = False,
150
+ allow_write_in: bool = True,
151
+ ) -> PromptHandle:
152
+ embed = discord.Embed(
153
+ title="❓ Question from AI",
154
+ description=f"**{question}**\n\nPlease select an answer below.",
155
+ color=discord.Color.blue(),
156
+ )
157
+ view = discord.ui.View(timeout=None)
158
+ handle = _DiscordPromptHandle(embed, view)
159
+
160
+ async def _resolve(interaction: discord.Interaction, text: str, note: str):
161
+ await interaction.response.send_message(f"✅ {note}: **{text}**")
162
+ if not answer_future.done():
163
+ answer_future.set_result(text)
164
+ for child in view.children:
165
+ child.disabled = True
166
+ if interaction.message:
167
+ try:
168
+ await interaction.message.edit(view=view)
169
+ except discord.HTTPException:
170
+ pass
171
+
172
+ if multi_select and options:
173
+ select = discord.ui.Select(
174
+ placeholder="Select multiple options...",
175
+ min_values=1,
176
+ max_values=min(len(options), 25),
177
+ options=[discord.SelectOption(label=opt[:100]) for opt in options[:25]],
178
+ )
179
+ view.add_item(select)
180
+
181
+ async def submit_callback(interaction: discord.Interaction):
182
+ if not select.values:
183
+ return
184
+ await _resolve(interaction, ", ".join(select.values), "Selected")
185
+
186
+ submit_btn = discord.ui.Button(label="Submit", style=discord.ButtonStyle.primary)
187
+ submit_btn.callback = submit_callback
188
+ view.add_item(submit_btn)
189
+ else:
190
+ def make_option_callback(opt_text: str):
191
+ async def callback(interaction: discord.Interaction):
192
+ await _resolve(interaction, opt_text, "Selected")
193
+
194
+ return callback
195
+
196
+ # 25 components per view; leave room for the write-in button.
197
+ for opt in options[:24]:
198
+ btn = discord.ui.Button(label=opt[:80], style=discord.ButtonStyle.primary)
199
+ btn.callback = make_option_callback(opt)
200
+ view.add_item(btn)
201
+
202
+ if allow_write_in:
203
+ class WriteInModal(discord.ui.Modal, title="Write in"):
204
+ answer = discord.ui.TextInput(
205
+ label="Enter your response",
206
+ style=discord.TextStyle.paragraph,
207
+ placeholder="Type your response here...",
208
+ required=True,
209
+ max_length=2000,
210
+ )
211
+
212
+ async def on_submit(modal_self, interaction: discord.Interaction):
213
+ await _resolve(interaction, modal_self.answer.value, "Selected (Write in)")
214
+
215
+ async def write_in_callback(interaction: discord.Interaction):
216
+ await interaction.response.send_modal(WriteInModal())
217
+
218
+ write_in_btn = discord.ui.Button(label="✍️ Write in", style=discord.ButtonStyle.secondary)
219
+ write_in_btn.callback = write_in_callback
220
+ view.add_item(write_in_btn)
221
+
222
+ return handle
223
+
224
+
225
+ class DiscordVoiceAdapter(DiscordAdapter, VoiceCapable):
226
+ """Discord adapter with voice wired in. Separate class so it's
227
+ visible at the type level which code paths need voice."""
228
+
229
+ def __init__(self, bot: discord.Client, voice_cog):
230
+ super().__init__(bot)
231
+ self.voice_cog = voice_cog
232
+
233
+ async def join_voice(self, guild_ref: Any, channel_ref: Any) -> None:
234
+ raise NotImplementedError("Wire to VoiceCog's /join when migrating voice_cog.py")
235
+
236
+ async def leave_voice(self, guild_ref: Any) -> None:
237
+ raise NotImplementedError("Wire to VoiceCog's /leave when migrating voice_cog.py")
238
+
239
+ async def play_tts(self, guild_ref: Any, audio_bytes: bytes) -> None:
240
+ await self.voice_cog._play_audio(str(guild_ref), audio_bytes)
@@ -0,0 +1,19 @@
1
+ """Process-wide messenger adapter instance, set once at startup by
2
+ main.py. Matches the codebase's existing singleton pattern (see
3
+ config.session_manager) so deep call sites don't need the adapter
4
+ threaded through every signature."""
5
+
6
+ from messengers.base import MessengerAdapter
7
+
8
+ _adapter: MessengerAdapter | None = None
9
+
10
+
11
+ def set_adapter(adapter: MessengerAdapter) -> None:
12
+ global _adapter
13
+ _adapter = adapter
14
+
15
+
16
+ def get_adapter() -> MessengerAdapter:
17
+ if _adapter is None:
18
+ raise RuntimeError("Messenger adapter not initialized - main.py must call set_adapter() at startup.")
19
+ return _adapter
@@ -0,0 +1,67 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from config import TTS_VOICE, bot_settings, logger
8
+
9
+
10
+ async def tts(text: str, voice: str = None) -> bytes | None:
11
+ voice = voice or bot_settings.get("tts_voice", TTS_VOICE)
12
+ try:
13
+ import edge_tts
14
+
15
+ clean = re.sub(r"[`*#_\[\]()]", "", text)
16
+ clean = re.sub(r"https?://\S+", "URL", clean)
17
+ clean = re.sub(r"\n+", ". ", clean).strip()[:800]
18
+ if not clean:
19
+ return None
20
+ hangul_chars = len(re.findall(r"[가-힣]", clean))
21
+ alpha_chars = len(re.findall(r"[a-zA-Z]", clean))
22
+ total_letters = hangul_chars + alpha_chars
23
+ ko_voice = os.getenv("TTS_VOICE_KO", "ko-KR-SunHiNeural")
24
+ en_voice = os.getenv("TTS_VOICE_EN", "en-US-AriaNeural")
25
+
26
+ if total_letters == 0:
27
+ active_voice = TTS_VOICE
28
+ elif (hangul_chars / total_letters) >= 0.3:
29
+ active_voice = ko_voice
30
+ else:
31
+ active_voice = en_voice
32
+ communicate = edge_tts.Communicate(clean, active_voice)
33
+ with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
34
+ tmp = f.name
35
+ await communicate.save(tmp)
36
+ data = Path(tmp).read_bytes()
37
+ os.unlink(tmp)
38
+ return data
39
+ except Exception as e:
40
+ logger.error(f"TTS error: {e}")
41
+ return None
42
+
43
+
44
+ async def stt(audio_bytes: bytes) -> str | None:
45
+ try:
46
+
47
+ def _transcribe():
48
+ import io
49
+
50
+ import speech_recognition as sr
51
+
52
+ r = sr.Recognizer()
53
+ with sr.AudioFile(io.BytesIO(audio_bytes)) as source:
54
+ audio = r.record(source)
55
+ try:
56
+ return r.recognize_google(audio, language="ko-KR")
57
+ except sr.UnknownValueError:
58
+ return None
59
+ except sr.RequestError as e:
60
+ logger.error(f"STT API error: {e}")
61
+ return None
62
+
63
+ result = await asyncio.to_thread(_transcribe)
64
+ return result.strip() if result else None
65
+ except Exception as e:
66
+ logger.error(f"STT internal error: {e}")
67
+ return None
@@ -0,0 +1,95 @@
1
+ import re
2
+ import string
3
+ import uuid
4
+ from pathlib import Path
5
+
6
+ import discord
7
+
8
+ from config import TMP_FILE_DIR, logger
9
+
10
+
11
+ async def handle_image_attachments(message: discord.Message) -> list[str]:
12
+ saved_paths = []
13
+ for att in message.attachments:
14
+ ext = Path(att.filename).suffix.lower()
15
+ filename = f"{uuid.uuid4().hex}{ext or '.tmp'}"
16
+ dest = TMP_FILE_DIR / filename
17
+ try:
18
+ data = await att.read()
19
+ dest.write_bytes(data)
20
+ saved_paths.append(str(dest))
21
+ except Exception as e:
22
+ logger.error(f"Image save failed: {e}")
23
+ return saved_paths
24
+
25
+
26
+ def cleanup_images(image_paths: list[str]):
27
+ for path in image_paths:
28
+ try:
29
+ Path(path).unlink(missing_ok=True)
30
+ except Exception as e:
31
+ logger.warning(f"Failed to delete temp image {path}: {e}")
32
+
33
+
34
+ def build_content_with_images(content: str, image_paths: list[str]) -> str:
35
+ parts = []
36
+ parts.append(content)
37
+
38
+ if not image_paths:
39
+ return "\n".join(parts)
40
+
41
+ parts.append("\n\n[Attached file path (use `view_file` tool to analyze)]")
42
+ for path in image_paths:
43
+ parts.append(f"- {path}")
44
+ return "\n".join(parts)
45
+
46
+
47
+ def clean_ansi(text: str) -> str:
48
+ return re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])").sub("", text)
49
+
50
+
51
+ def check_approval_intent(text: str) -> str:
52
+ lower_c = text.strip().lower()
53
+ neg_kr_substr = ["아니", "거절", "취소", "하지마", "안돼", "싫어", "멈춰", "그만", "안해", "노노"]
54
+ for neg in neg_kr_substr:
55
+ if neg in lower_c:
56
+ return "reject"
57
+ pos_kr_substr = ["진행", "승인", "하라고", "해봐", "해라", "실행", "그래", "알았어", "좋아", "수락", "오케이"]
58
+ for pos in pos_kr_substr:
59
+ if pos in lower_c:
60
+ return "allow"
61
+
62
+ clean_text = lower_c.translate(str.maketrans("", "", string.punctuation))
63
+ words = clean_text.split()
64
+ exact_reject = ["no", "cancel", "stop", "reject", "deny", "n", "ㄴㄴ", "nope", "abort", "quit", "never"]
65
+ exact_allow = [
66
+ "yes",
67
+ "ok",
68
+ "okay",
69
+ "go",
70
+ "approve",
71
+ "allow",
72
+ "y",
73
+ "응",
74
+ "어",
75
+ "ㅇㅇ",
76
+ "ㅇㅋ",
77
+ "해",
78
+ "콜",
79
+ "네",
80
+ "sure",
81
+ "yeah",
82
+ "yep",
83
+ "yup",
84
+ "proceed",
85
+ "fine",
86
+ "alright",
87
+ "고",
88
+ ]
89
+ for word in words:
90
+ if word in exact_reject:
91
+ return "reject"
92
+ for word in words:
93
+ if word in exact_allow:
94
+ return "allow"
95
+ return None
@@ -0,0 +1,50 @@
1
+ import os
2
+
3
+ import requests
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ mcp = FastMCP("DiscordButtons")
7
+
8
+
9
+ @mcp.tool()
10
+ def ask_discord_user(question: str, options: list[str]) -> str:
11
+ """
12
+ Ask a multiple-choice question to the user in Discord using interactive buttons.
13
+ You MUST use this tool instead of the default ask_question tool when running in Discord.
14
+ """
15
+ thread_id = os.environ.get("DISCORD_THREAD_ID")
16
+ if not thread_id:
17
+ return "Error: DISCORD_THREAD_ID not set. Are you running in Discord?"
18
+
19
+ try:
20
+ resp = requests.post(
21
+ "http://127.0.0.1:18080/mcp_ask",
22
+ json={"thread_id": thread_id, "question": question, "options": options},
23
+ timeout=300,
24
+ )
25
+ if resp.status_code == 200:
26
+ return resp.json().get("answer", "No answer")
27
+ return f"Error: HTTP {resp.status_code}"
28
+ except Exception as e:
29
+ return f"Error: {e}"
30
+
31
+
32
+ @mcp.tool()
33
+ def send_discord_message(channel_id: str, message: str) -> str:
34
+ """
35
+ Sends a text message to a specific Discord channel by its ID.
36
+ You can use this tool when the user asks you to send a message to a different channel.
37
+ """
38
+ try:
39
+ resp = requests.post(
40
+ "http://127.0.0.1:18080/mcp_send_channel", json={"channel_id": channel_id, "message": message}, timeout=10
41
+ )
42
+ if resp.status_code == 200:
43
+ return "Message sent successfully"
44
+ return f"Error: HTTP {resp.status_code} - {resp.text}"
45
+ except Exception as e:
46
+ return f"Error: {e}"
47
+
48
+
49
+ if __name__ == "__main__":
50
+ mcp.run()
@@ -0,0 +1,51 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
5
+ from messengers.registry import get_adapter
6
+ from utils.utils import get_current_model
7
+
8
+
9
+ async def send_agy_response(
10
+ thread: Any,
11
+ response_text: str,
12
+ session: dict,
13
+ ctx: dict = None,
14
+ start_time: float = 0,
15
+ conv_id: str = None,
16
+ ):
17
+ adapter = get_adapter()
18
+ session_manager.save_sessions()
19
+
20
+ parts = [response_text[i : i + MAX_EMBED_LEN] for i in range(0, max(len(response_text), 1), MAX_EMBED_LEN)]
21
+ for idx, part in enumerate(parts):
22
+ is_last = idx == len(parts) - 1
23
+
24
+ if is_last:
25
+ if not part.strip():
26
+ continue
27
+
28
+ session_model = session.get("model")
29
+ model_display = MODEL_CHOICES.get(session_model, session_model) if session_model else get_current_model()
30
+ text_to_send = f"{part}\n-# 🤖 {model_display}"
31
+
32
+ status_msg = ctx.get("status_msg") if ctx else None
33
+ if status_msg and await adapter.edit_message(status_msg, text_to_send):
34
+ continue
35
+ await adapter.send_message(thread, text_to_send)
36
+ else:
37
+ await adapter.send_message(thread, part)
38
+
39
+ files_to_send = []
40
+ if conv_id and start_time:
41
+ brain_dir = Path.home() / f".gemini/antigravity-cli/brain/{conv_id}"
42
+ if brain_dir.exists():
43
+ for md_file in brain_dir.glob("*.md"):
44
+ if md_file.stat().st_mtime >= start_time:
45
+ files_to_send.append(str(md_file))
46
+ if files_to_send:
47
+ await adapter.send_files(thread, files_to_send)
48
+
49
+
50
+ async def render_thought_process(conv_id: str, ctx: dict, response_text: str, thread: Any) -> str:
51
+ return ctx.get("final_text", response_text)