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,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)
|