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,199 @@
1
+ import asyncio
2
+ import re
3
+ import time
4
+ from typing import Any
5
+
6
+ import discord # only for voice/TTS text cleanup below; messaging goes through the adapter
7
+
8
+ from config import MAX_EMBED_LEN, STREAM_RATE_LIMIT_SEC, bot_settings, logger, session_manager
9
+ from messengers.registry import get_adapter
10
+ from utils.utils import clean_ansi
11
+
12
+
13
+ def _clear_current_tool(thread_id: str):
14
+ """Removes the "current_tool" marker set by api/ui_routes.py while a
15
+ tool call is being approved - without this, the bot's presence status
16
+ stays stuck on the last tool after the turn finishes."""
17
+ session = session_manager.get_session(thread_id)
18
+ if session and "current_tool" in session:
19
+ del session["current_tool"]
20
+ session_manager.set_session(thread_id, session)
21
+
22
+
23
+ class StreamUpdater:
24
+ def __init__(self, thread: Any, context_dict: dict):
25
+ self.MAX_EMBED_LEN = MAX_EMBED_LEN
26
+ self.RATE_LIMIT_SEC = STREAM_RATE_LIMIT_SEC
27
+ self.thread = thread
28
+ self.context_dict = context_dict
29
+ self.status_msg = None
30
+ self.current_text = ""
31
+ self.last_update_time = time.time()
32
+ if self.context_dict is not None:
33
+ self.context_dict["status_msg"] = None
34
+
35
+ async def process_chunk(self, chunk: str):
36
+ self.current_text += chunk
37
+
38
+ async def split(self):
39
+ full_text = self.current_text.strip()
40
+ if full_text:
41
+ parts = [full_text[i : i + self.MAX_EMBED_LEN] for i in range(0, len(full_text), self.MAX_EMBED_LEN)]
42
+ for idx, part in enumerate(parts):
43
+ await self._update(part, force_new=(idx > 0))
44
+ self.status_msg = None
45
+ self.current_text = ""
46
+ if self.context_dict is not None:
47
+ self.context_dict["status_msg"] = None
48
+
49
+ async def flush(self, force=False):
50
+ now = time.time()
51
+ if force or now - self.last_update_time >= self.RATE_LIMIT_SEC:
52
+ display_text = self.current_text[-self.MAX_EMBED_LEN :].strip()
53
+ if display_text:
54
+ await self._update(display_text, force_new=False)
55
+ if self.context_dict is not None:
56
+ self.context_dict["final_text"] = self.current_text.strip()
57
+ self.last_update_time = now
58
+
59
+ async def _update(self, text: str, force_new: bool):
60
+ adapter = get_adapter()
61
+ try:
62
+ if self.status_msg is not None and not force_new:
63
+ if await adapter.edit_message(self.status_msg, text):
64
+ return
65
+ # Edit failed (message gone) - fall through to sending anew.
66
+ self.status_msg = None
67
+ self.status_msg = await adapter.send_message(self.thread, text)
68
+ if self.context_dict is not None:
69
+ self.context_dict["status_msg"] = self.status_msg
70
+ except asyncio.CancelledError:
71
+ logger.debug("Stream update was cancelled.")
72
+ raise
73
+ except Exception as e:
74
+ logger.exception(f"Unexpected error in stream update: {e}")
75
+
76
+
77
+ class TTSStreamManager:
78
+ def __init__(self, thread_id: str, guild_id: str, cog, is_voice: bool):
79
+ self.thread_id = str(thread_id)
80
+ self.guild_id = str(guild_id) if guild_id else None
81
+ self.cog = cog
82
+ self.is_voice = is_voice
83
+ self.tts_buffer = ""
84
+ self.settings = bot_settings
85
+
86
+ async def process_chunk(self, chunk: str):
87
+ if not self.is_voice:
88
+ return
89
+ self.tts_buffer += chunk
90
+ if re.search(r"([.?!]\s+|\n+)", self.tts_buffer):
91
+ parts = re.split(r"([.?!]\s+|\n+)", self.tts_buffer)
92
+ complete_sentences = ""
93
+ for i in range(0, len(parts) - 1, 2):
94
+ complete_sentences += parts[i] + parts[i + 1]
95
+ self.tts_buffer = parts[-1]
96
+ await self._queue_tts(complete_sentences)
97
+
98
+ async def flush_all(self):
99
+ if not self.is_voice:
100
+ return
101
+ text = self.tts_buffer.strip()
102
+ self.tts_buffer = ""
103
+ if text:
104
+ await self._queue_tts(text)
105
+
106
+ async def _queue_tts(self, text: str):
107
+ if not self.settings.get("tts_enabled", True) or not text.strip():
108
+ return
109
+
110
+ async def play_task(text_to_play, prev_task):
111
+ try:
112
+ if prev_task:
113
+ try:
114
+ await prev_task
115
+ except asyncio.CancelledError:
116
+ pass
117
+ except Exception as e:
118
+ logger.warning(f"Previous TTS task failed: {e}")
119
+
120
+ ans_clean = discord.utils.remove_markdown(text_to_play)
121
+ ans_clean = re.sub(r"●\s*[a-zA-Z0-9_]+\(.*?\)(.*?)(?=\n\n|\Z)", "", ans_clean, flags=re.DOTALL)
122
+ ans_clean = re.sub(r"AbsolutePath:.*?(?=\n|$)", "", ans_clean)
123
+ if ans_clean.strip() and self.guild_id and self.cog:
124
+ audio = await self.cog.tts(ans_clean)
125
+ if audio:
126
+ await self.cog._play_audio(self.guild_id, audio)
127
+ except asyncio.CancelledError:
128
+ logger.debug("TTS task was cancelled.")
129
+ raise
130
+ except Exception as e:
131
+ logger.exception(f"TTS Streaming Error: {e}")
132
+
133
+ prev = session_manager.get_tts_task(self.thread_id)
134
+ new_task = asyncio.create_task(play_task(text, prev))
135
+ session_manager.set_tts_task(self.thread_id, new_task)
136
+
137
+
138
+ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: asyncio.Queue):
139
+ cog = bot.get_cog("VoiceCog")
140
+ is_voice = bool(
141
+ cog
142
+ and hasattr(thread, "guild")
143
+ and str(thread.guild.id) in cog._voice_state
144
+ and cog._voice_state[str(thread.guild.id)] == thread.id
145
+ )
146
+
147
+ ui_mgr = StreamUpdater(thread, context_dict)
148
+ tts_mgr = TTSStreamManager(thread.id, thread.guild.id if hasattr(thread, "guild") else None, cog, is_voice)
149
+
150
+ try:
151
+ while True:
152
+ item = await queue.get()
153
+ if item is None:
154
+ break
155
+
156
+ if isinstance(item, tuple) and item and item[0] == "__RUN_ORDERED__":
157
+ _, send_coro_factory, done_future = item
158
+ await ui_mgr.split()
159
+ await tts_mgr.flush_all()
160
+ try:
161
+ result = await send_coro_factory()
162
+ if not done_future.done():
163
+ done_future.set_result(result)
164
+ except Exception as e:
165
+ logger.exception(f"Ordered send failed: {e}")
166
+ if not done_future.done():
167
+ done_future.set_exception(e)
168
+ continue
169
+
170
+ chunk, force_flush = (item, False) if not isinstance(item, tuple) else item
171
+
172
+ if chunk == "__END__":
173
+ await ui_mgr.flush(force=True)
174
+ if context_dict is not None:
175
+ context_dict["final_text"] = ui_mgr.current_text.strip()
176
+ session_manager.remove_queue(str(thread.id))
177
+ _clear_current_tool(str(thread.id))
178
+ await tts_mgr.flush_all()
179
+ break
180
+
181
+ if str(chunk).startswith("__CONV_ID__:"):
182
+ conv_id = chunk.split(":", 1)[1]
183
+ session_manager.update_session(str(thread.id), "conversation_id", conv_id)
184
+ continue
185
+
186
+ if chunk == "__SPLIT__":
187
+ await ui_mgr.split()
188
+ await tts_mgr.flush_all()
189
+ continue
190
+
191
+ chunk = clean_ansi(chunk)
192
+
193
+ await ui_mgr.process_chunk(chunk)
194
+ await ui_mgr.flush()
195
+ await tts_mgr.process_chunk(chunk)
196
+ except asyncio.CancelledError:
197
+ logger.debug(f"stream_thinking_latest cancelled for thread {thread.id}")
198
+ except Exception as e:
199
+ logger.exception(f"Error in stream_thinking_latest: {e}")
@@ -0,0 +1,40 @@
1
+ from config import WORKSPACE_DIR
2
+ from core.agy_runner import (
3
+ active_processes,
4
+ agy_new_conversation,
5
+ agy_send_message,
6
+ agy_start_lock,
7
+ generate_thread_title,
8
+ get_current_model,
9
+ run_agy,
10
+ )
11
+ from services.audio_service import stt, tts
12
+ from services.discord_helpers import (
13
+ build_content_with_images,
14
+ check_approval_intent,
15
+ clean_ansi,
16
+ cleanup_images,
17
+ handle_image_attachments,
18
+ )
19
+
20
+ __all__ = [
21
+ "run_agy",
22
+ "agy_new_conversation",
23
+ "agy_send_message",
24
+ "get_current_model",
25
+ "generate_thread_title",
26
+ "active_processes",
27
+ "agy_start_lock",
28
+ "tts",
29
+ "stt",
30
+ "handle_image_attachments",
31
+ "cleanup_images",
32
+ "build_content_with_images",
33
+ "clean_ansi",
34
+ "check_approval_intent",
35
+ "get_default_cwd",
36
+ ]
37
+
38
+
39
+ def get_default_cwd(folder_name="workspace"):
40
+ return str(WORKSPACE_DIR / folder_name)