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,340 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from aiohttp import web
|
|
8
|
+
|
|
9
|
+
from config import MAX_EMBED_LEN, logger, session_manager
|
|
10
|
+
from messengers.base import ScopeOption
|
|
11
|
+
from messengers.registry import get_adapter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_tool_allowed(tool_name, tool_input):
|
|
15
|
+
from api.server import is_tool_allowed as is_tool_allowed_orig
|
|
16
|
+
|
|
17
|
+
return is_tool_allowed_orig(tool_name, tool_input)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def send_ordered(target_thread_id, send_coro_factory):
|
|
21
|
+
"""Routes through the same per-conversation stream queue as the answer
|
|
22
|
+
text, so tool-call messages can't arrive out of order. Falls back to
|
|
23
|
+
a direct call if no stream is registered."""
|
|
24
|
+
q = session_manager.get_queue(target_thread_id) if target_thread_id else None
|
|
25
|
+
if not q:
|
|
26
|
+
return await send_coro_factory()
|
|
27
|
+
|
|
28
|
+
from config import STREAM_RATE_LIMIT_SEC
|
|
29
|
+
|
|
30
|
+
await asyncio.sleep(STREAM_RATE_LIMIT_SEC)
|
|
31
|
+
|
|
32
|
+
done = asyncio.get_running_loop().create_future()
|
|
33
|
+
q.put_nowait(("__RUN_ORDERED__", send_coro_factory, done))
|
|
34
|
+
return await done
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_permission_overrides(tool_name, tool_input):
|
|
38
|
+
"""Builds the PreToolUse `permissionOverrides` output field for an
|
|
39
|
+
approved call. Print mode soft-denies a tool unless a matching allow
|
|
40
|
+
rule exists even when the hook says "allow"; returning
|
|
41
|
+
"command(<CommandLine>)" supplies that rule for this one call."""
|
|
42
|
+
if tool_name == "run_command" and isinstance(tool_input, dict):
|
|
43
|
+
command_line = tool_input.get("CommandLine")
|
|
44
|
+
if command_line:
|
|
45
|
+
return [f"command({command_line})"]
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def allow_response(tool_name, tool_input):
|
|
50
|
+
body = {"decision": "allow"}
|
|
51
|
+
overrides = build_permission_overrides(tool_name, tool_input)
|
|
52
|
+
if overrides:
|
|
53
|
+
body["permissionOverrides"] = overrides
|
|
54
|
+
return web.json_response(body)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _persist_scope_if_granted(prompt_handle):
|
|
58
|
+
"""If the prompt was resolved via a persistent-allow button, records
|
|
59
|
+
that scope. Scope persistence is business logic, so it lives here
|
|
60
|
+
rather than inside the adapter's button callback - a future resolved
|
|
61
|
+
by a non-UI path (typed reply, voice) simply has no outcome/scope."""
|
|
62
|
+
outcome = prompt_handle.outcome
|
|
63
|
+
if outcome and outcome.decision == "allow" and outcome.scope:
|
|
64
|
+
kind, scope = outcome.scope.kind, outcome.scope.scope
|
|
65
|
+
if scope not in session_manager.persistent_allowed[kind]:
|
|
66
|
+
session_manager.persistent_allowed[kind].append(scope)
|
|
67
|
+
session_manager.save_persistent()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _clean_inline(text: str) -> str:
|
|
71
|
+
return re.sub(r"[*#_`]", "", text).replace("\n", " ").strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def handle_approve_request(request):
|
|
75
|
+
# Tracks approval_keys registered this request so the exception handler can clean them up.
|
|
76
|
+
registered_approval_keys = []
|
|
77
|
+
try:
|
|
78
|
+
data = await request.json()
|
|
79
|
+
conv_id = data.get("conversation_id")
|
|
80
|
+
tool_name = data.get("tool_name")
|
|
81
|
+
tool_input = data.get("tool_input")
|
|
82
|
+
payload_thread_id = data.get("thread_id")
|
|
83
|
+
|
|
84
|
+
# DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
|
|
85
|
+
logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
|
|
86
|
+
|
|
87
|
+
adapter = get_adapter()
|
|
88
|
+
|
|
89
|
+
target_thread = None
|
|
90
|
+
target_thread_id = None
|
|
91
|
+
for thread_id_str, sess in session_manager.get_all_sessions().items():
|
|
92
|
+
if sess.get("conversation_id") == conv_id:
|
|
93
|
+
target_thread = adapter.resolve_conversation(thread_id_str)
|
|
94
|
+
target_thread_id = thread_id_str
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
if not target_thread and payload_thread_id:
|
|
98
|
+
resolved_channel = adapter.resolve_conversation(payload_thread_id)
|
|
99
|
+
if resolved_channel:
|
|
100
|
+
target_thread = resolved_channel
|
|
101
|
+
target_thread_id = payload_thread_id
|
|
102
|
+
if session_manager.get_session(payload_thread_id):
|
|
103
|
+
session_manager.update_session(payload_thread_id, "conversation_id", conv_id)
|
|
104
|
+
session_manager.update_session(payload_thread_id, "status", "active")
|
|
105
|
+
|
|
106
|
+
if not target_thread:
|
|
107
|
+
for thread_id_str, sess in reversed(list(session_manager.get_all_sessions().items())):
|
|
108
|
+
if sess.get("status") == "pending":
|
|
109
|
+
target_thread = adapter.resolve_conversation(thread_id_str)
|
|
110
|
+
session_manager.update_session(thread_id_str, "conversation_id", conv_id)
|
|
111
|
+
session_manager.update_session(thread_id_str, "status", "active")
|
|
112
|
+
target_thread_id = thread_id_str
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
if target_thread_id and session_manager.get_session(target_thread_id):
|
|
116
|
+
session_manager.update_session(target_thread_id, "current_tool", tool_name)
|
|
117
|
+
|
|
118
|
+
if "ask_question" in tool_name:
|
|
119
|
+
if not target_thread:
|
|
120
|
+
return web.json_response({"decision": "deny", "reason": "No target thread found."})
|
|
121
|
+
|
|
122
|
+
questions = tool_input.get("questions", [])
|
|
123
|
+
if not questions:
|
|
124
|
+
return web.json_response({"decision": "deny", "reason": "No questions provided."})
|
|
125
|
+
|
|
126
|
+
q_data = questions[0]
|
|
127
|
+
question_text = _clean_inline(q_data.get("question", "No question provided."))
|
|
128
|
+
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in q_data.get("options", [])]
|
|
129
|
+
is_multi_select = q_data.get("is_multi_select", False)
|
|
130
|
+
|
|
131
|
+
future = asyncio.get_running_loop().create_future()
|
|
132
|
+
approval_key = f"{conv_id}:{uuid.uuid4().hex}"
|
|
133
|
+
session_manager.set_pending_approval(approval_key, future, "ask_question", conv_id=conv_id)
|
|
134
|
+
registered_approval_keys.append(approval_key)
|
|
135
|
+
|
|
136
|
+
prompt = adapter.create_question_prompt(
|
|
137
|
+
future, question_text, options, multi_select=is_multi_select, allow_write_in=True
|
|
138
|
+
)
|
|
139
|
+
await send_ordered(target_thread_id, lambda: prompt.send(target_thread))
|
|
140
|
+
|
|
141
|
+
chosen_opt = await future
|
|
142
|
+
session_manager.clear_pending_approval(approval_key)
|
|
143
|
+
await prompt.finalize()
|
|
144
|
+
|
|
145
|
+
return web.json_response(
|
|
146
|
+
{"decision": "deny", "reason": f"User selected via Discord button: [{chosen_opt}]"}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
from approval.command_parser import parse_shell_commands
|
|
150
|
+
from approval.tool_formatter import format_bash_display, format_tool_display
|
|
151
|
+
|
|
152
|
+
if "run_command" in tool_name:
|
|
153
|
+
cmd = tool_input.get("CommandLine", "")
|
|
154
|
+
sub_cmds = parse_shell_commands(cmd)
|
|
155
|
+
tool_msg_text, desc_json, _ = format_bash_display(sub_cmds[0] if sub_cmds else cmd)
|
|
156
|
+
tool_msg_formatted = f"```text\n{tool_msg_text}\n```{desc_json}"
|
|
157
|
+
else:
|
|
158
|
+
sub_cmds = [None]
|
|
159
|
+
tool_msg_text, desc_json, view_tool_input = format_tool_display(tool_name, tool_input)
|
|
160
|
+
tool_msg_formatted = f"```text\n{tool_msg_text}\n```{desc_json}"
|
|
161
|
+
|
|
162
|
+
if "run_command" in tool_name:
|
|
163
|
+
prompted = False
|
|
164
|
+
for sub_cmd in sub_cmds:
|
|
165
|
+
if not sub_cmd:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
is_auto_allowed = False
|
|
169
|
+
if "\n" not in sub_cmd and "|" not in sub_cmd:
|
|
170
|
+
try:
|
|
171
|
+
tokens = shlex.split(sub_cmd)
|
|
172
|
+
for scope in session_manager.persistent_allowed.get("commands", []):
|
|
173
|
+
scope_tokens = shlex.split(scope)
|
|
174
|
+
if len(scope_tokens) <= len(tokens) and tokens[: len(scope_tokens)] == scope_tokens:
|
|
175
|
+
is_auto_allowed = True
|
|
176
|
+
break
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
elif is_tool_allowed(tool_name, {"CommandLine": sub_cmd}) or (
|
|
180
|
+
conv_id in session_manager.session_allowed_tools
|
|
181
|
+
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
182
|
+
):
|
|
183
|
+
is_auto_allowed = True
|
|
184
|
+
|
|
185
|
+
if is_auto_allowed:
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
prompted = True
|
|
189
|
+
|
|
190
|
+
prompt_desc = f"**🎯 Requesting permission for:**\n```bash\n{sub_cmd}\n```\n"
|
|
191
|
+
if sub_cmd.strip() != cmd.strip():
|
|
192
|
+
prompt_desc += f"**📜 Full command context:**\n```bash\n{cmd}\n```"
|
|
193
|
+
prompt_desc += (
|
|
194
|
+
f"\n**🔧 Tool Execution Detail:**\n```json\n"
|
|
195
|
+
f"{json.dumps(tool_input, indent=2, ensure_ascii=False)[:1000]}\n```"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Persistent-allow options: expanding prefixes of the sub-command's tokens.
|
|
199
|
+
scope_options = []
|
|
200
|
+
try:
|
|
201
|
+
tokens = shlex.split(sub_cmd)
|
|
202
|
+
except ValueError:
|
|
203
|
+
tokens = [sub_cmd]
|
|
204
|
+
current_prefix = []
|
|
205
|
+
for t in tokens[:3]:
|
|
206
|
+
current_prefix.append(t)
|
|
207
|
+
scope_options.append(ScopeOption(kind="commands", scope=" ".join(current_prefix)))
|
|
208
|
+
|
|
209
|
+
approval_key = f"{conv_id}:{uuid.uuid4().hex}"
|
|
210
|
+
future = asyncio.get_running_loop().create_future()
|
|
211
|
+
session_manager.set_pending_approval(approval_key, future, conv_id=conv_id)
|
|
212
|
+
registered_approval_keys.append(approval_key)
|
|
213
|
+
|
|
214
|
+
prompt = adapter.create_tool_approval_prompt(
|
|
215
|
+
future, "⚠️ Tool Execution Approval Required", prompt_desc, scope_options
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
sub_cmd_display, sub_cmd_desc, _ = format_bash_display(sub_cmd)
|
|
219
|
+
sub_cmd_formatted = f"```text\n{sub_cmd_display}\n```{sub_cmd_desc}"
|
|
220
|
+
|
|
221
|
+
async def _send_bash_prompt(sub_cmd_formatted=sub_cmd_formatted, prompt=prompt):
|
|
222
|
+
await adapter.send_message(target_thread, sub_cmd_formatted)
|
|
223
|
+
return await prompt.send(target_thread)
|
|
224
|
+
|
|
225
|
+
await send_ordered(target_thread_id, _send_bash_prompt)
|
|
226
|
+
|
|
227
|
+
decision = await future
|
|
228
|
+
session_manager.clear_pending_approval(approval_key)
|
|
229
|
+
_persist_scope_if_granted(prompt)
|
|
230
|
+
await prompt.finalize()
|
|
231
|
+
|
|
232
|
+
if decision == "reject":
|
|
233
|
+
return web.json_response({"decision": "reject"})
|
|
234
|
+
|
|
235
|
+
if target_thread and tool_msg_text and not prompted:
|
|
236
|
+
await send_ordered(target_thread_id, lambda: adapter.send_message(target_thread, tool_msg_formatted))
|
|
237
|
+
|
|
238
|
+
return allow_response(tool_name, tool_input)
|
|
239
|
+
|
|
240
|
+
else:
|
|
241
|
+
is_auto_allowed = False
|
|
242
|
+
if is_tool_allowed(tool_name, tool_input) or (
|
|
243
|
+
conv_id in session_manager.session_allowed_tools
|
|
244
|
+
and tool_name in session_manager.session_allowed_tools[conv_id]
|
|
245
|
+
):
|
|
246
|
+
is_auto_allowed = True
|
|
247
|
+
|
|
248
|
+
if is_auto_allowed:
|
|
249
|
+
if target_thread and tool_msg_text:
|
|
250
|
+
await send_ordered(
|
|
251
|
+
target_thread_id, lambda: adapter.send_message(target_thread, tool_msg_formatted)
|
|
252
|
+
)
|
|
253
|
+
return allow_response(tool_name, tool_input)
|
|
254
|
+
|
|
255
|
+
approval_key = f"{conv_id}:{uuid.uuid4().hex}"
|
|
256
|
+
future = asyncio.get_running_loop().create_future()
|
|
257
|
+
session_manager.set_pending_approval(approval_key, future, conv_id=conv_id)
|
|
258
|
+
registered_approval_keys.append(approval_key)
|
|
259
|
+
|
|
260
|
+
scope_options = [ScopeOption(kind="tools", scope=tool_name)]
|
|
261
|
+
prompt = adapter.create_tool_approval_prompt(
|
|
262
|
+
future, "⚠️ Tool Execution Approval Required", tool_msg_formatted, scope_options
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
async def _send_prompt():
|
|
266
|
+
await adapter.send_message(target_thread, tool_msg_formatted)
|
|
267
|
+
return await prompt.send(target_thread)
|
|
268
|
+
|
|
269
|
+
await send_ordered(target_thread_id, _send_prompt)
|
|
270
|
+
|
|
271
|
+
decision = await future
|
|
272
|
+
session_manager.clear_pending_approval(approval_key)
|
|
273
|
+
_persist_scope_if_granted(prompt)
|
|
274
|
+
await prompt.finalize()
|
|
275
|
+
|
|
276
|
+
if decision == "reject":
|
|
277
|
+
return web.json_response({"decision": "reject"})
|
|
278
|
+
|
|
279
|
+
return allow_response(tool_name, tool_input)
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.exception(f"Error in handle_approve_request: {e}")
|
|
283
|
+
for key in registered_approval_keys:
|
|
284
|
+
session_manager.clear_pending_approval(key)
|
|
285
|
+
return web.json_response({"decision": "allow"})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def handle_mcp_ask(request):
|
|
289
|
+
try:
|
|
290
|
+
data = await request.json()
|
|
291
|
+
thread_id = data.get("thread_id")
|
|
292
|
+
|
|
293
|
+
question = _clean_inline(data.get("question", "No question provided."))
|
|
294
|
+
options = [(_clean_inline(str(opt)) or "Option")[:80] for opt in data.get("options", [])]
|
|
295
|
+
|
|
296
|
+
adapter = get_adapter()
|
|
297
|
+
thread = adapter.resolve_conversation(thread_id)
|
|
298
|
+
if not thread:
|
|
299
|
+
return web.json_response({"answer": "Thread not found"}, status=400)
|
|
300
|
+
|
|
301
|
+
future = asyncio.get_event_loop().create_future()
|
|
302
|
+
# conv_id is always None here - key just needs to be unique for cleanup.
|
|
303
|
+
approval_key = f"mcp_ask:{thread_id}:{uuid.uuid4().hex}"
|
|
304
|
+
session_manager.set_pending_approval(approval_key, future, "ask_question")
|
|
305
|
+
|
|
306
|
+
prompt = adapter.create_question_prompt(future, question, options, allow_write_in=True)
|
|
307
|
+
msg = await prompt.send(thread)
|
|
308
|
+
session_manager.pending_approval_messages[approval_key] = msg
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
answer = await asyncio.wait_for(future, timeout=300)
|
|
312
|
+
return web.json_response({"answer": answer})
|
|
313
|
+
except asyncio.TimeoutError:
|
|
314
|
+
return web.json_response({"answer": "User did not respond in time."})
|
|
315
|
+
finally:
|
|
316
|
+
await prompt.finalize()
|
|
317
|
+
session_manager.pending_approval_messages.pop(approval_key, None)
|
|
318
|
+
session_manager.clear_pending_approval(approval_key)
|
|
319
|
+
except Exception as e:
|
|
320
|
+
return web.json_response({"answer": f"Error: {e}"}, status=500)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def handle_mcp_send_channel(request):
|
|
324
|
+
try:
|
|
325
|
+
data = await request.json()
|
|
326
|
+
channel_id = data.get("channel_id")
|
|
327
|
+
message = data.get("message", "")
|
|
328
|
+
|
|
329
|
+
adapter = get_adapter()
|
|
330
|
+
channel = adapter.resolve_conversation(channel_id)
|
|
331
|
+
if not channel:
|
|
332
|
+
return web.json_response({"error": "Channel not found"}, status=400)
|
|
333
|
+
|
|
334
|
+
chunks = [message[i : i + MAX_EMBED_LEN] for i in range(0, len(message), MAX_EMBED_LEN)]
|
|
335
|
+
for chunk in chunks:
|
|
336
|
+
await adapter.send_message(channel, chunk)
|
|
337
|
+
|
|
338
|
+
return web.json_response({"answer": "success"})
|
|
339
|
+
except Exception as e:
|
|
340
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from aiohttp import web
|
|
4
|
+
|
|
5
|
+
from config import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def handle_stt_input(request):
|
|
9
|
+
try:
|
|
10
|
+
data = await request.json()
|
|
11
|
+
bot = request.app["bot"]
|
|
12
|
+
cog = bot.get_cog("VoiceCog")
|
|
13
|
+
if cog:
|
|
14
|
+
asyncio.create_task(cog.handle_stt_input(data))
|
|
15
|
+
return web.json_response({"success": True})
|
|
16
|
+
except Exception as e:
|
|
17
|
+
import traceback
|
|
18
|
+
|
|
19
|
+
logger.error(f"STT API error: {e}")
|
|
20
|
+
traceback.print_exc()
|
|
21
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def handle_tts_finished(request):
|
|
25
|
+
"""Called by voice-service/index.js the moment the bot's spoken reply
|
|
26
|
+
actually finishes playing (its audio queue drains). This is what
|
|
27
|
+
should start the "keep listening" countdown - not the moment the
|
|
28
|
+
user's speech was recognized, which is well before the bot has even
|
|
29
|
+
started replying."""
|
|
30
|
+
try:
|
|
31
|
+
data = await request.json()
|
|
32
|
+
guild_id = data.get("guild_id")
|
|
33
|
+
bot = request.app["bot"]
|
|
34
|
+
cog = bot.get_cog("VoiceCog")
|
|
35
|
+
if cog and guild_id:
|
|
36
|
+
cog.mark_tts_finished(str(guild_id))
|
|
37
|
+
return web.json_response({"success": True})
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error(f"tts_finished API error: {e}")
|
|
40
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def handle_stt_partial(request):
|
|
44
|
+
"""Called repeatedly by voice-service/index.js while the user is
|
|
45
|
+
still speaking, only during the post-wake active window (bounded,
|
|
46
|
+
default 60s - not always-on). Body is already-recognized {"text":
|
|
47
|
+
...}, not audio; this just updates a live "listening..." message."""
|
|
48
|
+
try:
|
|
49
|
+
data = await request.json()
|
|
50
|
+
bot = request.app["bot"]
|
|
51
|
+
cog = bot.get_cog("VoiceCog")
|
|
52
|
+
if cog:
|
|
53
|
+
asyncio.create_task(cog.handle_stt_partial(data))
|
|
54
|
+
return web.json_response({"success": True})
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.error(f"stt_partial API error: {e}")
|
|
57
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def handle_stt_partial_cancel(request):
|
|
61
|
+
"""Called when an utterance that had a live partial message showing
|
|
62
|
+
turned out too short to actually process, or got dropped because the
|
|
63
|
+
active window lapsed mid-utterance - cleans up that placeholder
|
|
64
|
+
instead of leaving "🎤 (listening...)" stuck forever."""
|
|
65
|
+
try:
|
|
66
|
+
data = await request.json()
|
|
67
|
+
guild_id = data.get("guild_id")
|
|
68
|
+
bot = request.app["bot"]
|
|
69
|
+
cog = bot.get_cog("VoiceCog")
|
|
70
|
+
if cog and guild_id:
|
|
71
|
+
asyncio.create_task(cog.cancel_stt_partial(str(guild_id)))
|
|
72
|
+
return web.json_response({"success": True})
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"stt_partial_cancel API error: {e}")
|
|
75
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def handle_enroll_sample(request):
|
|
79
|
+
"""Called by voice-service/index.js for each utterance a user makes
|
|
80
|
+
WHILE enrolling a wake word (see its `enrollingUsers` set) - raw
|
|
81
|
+
PCM/WAV bytes, same as /wake_check. Nothing gets matched against
|
|
82
|
+
this; it's just collected as a reference sample. See
|
|
83
|
+
VoiceCog.handle_enroll_sample for where samples actually get saved
|
|
84
|
+
(only once all of them are in)."""
|
|
85
|
+
try:
|
|
86
|
+
user_id = request.query.get("user_id")
|
|
87
|
+
audio_bytes = await request.read()
|
|
88
|
+
cog = request.app["bot"].get_cog("VoiceCog")
|
|
89
|
+
if cog and user_id and audio_bytes:
|
|
90
|
+
asyncio.create_task(cog.handle_enroll_sample(user_id, audio_bytes))
|
|
91
|
+
return web.json_response({"success": True})
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"enroll_sample API error: {e}")
|
|
94
|
+
return web.json_response({"error": str(e)}, status=500)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
def parse_shell_commands(cmd: str) -> list[str]:
|
|
2
|
+
"""
|
|
3
|
+
Parses a shell command string into individual sub-commands
|
|
4
|
+
separated by &&, ||, ;, or \n, respecting single and double quotes.
|
|
5
|
+
"""
|
|
6
|
+
sub_cmds = []
|
|
7
|
+
current = []
|
|
8
|
+
in_single = False
|
|
9
|
+
in_double = False
|
|
10
|
+
|
|
11
|
+
i = 0
|
|
12
|
+
while i < len(cmd):
|
|
13
|
+
c = cmd[i]
|
|
14
|
+
|
|
15
|
+
if c == "'" and not in_double:
|
|
16
|
+
in_single = not in_single
|
|
17
|
+
current.append(c)
|
|
18
|
+
elif c == '"' and not in_single:
|
|
19
|
+
if i > 0 and cmd[i - 1] == "\\":
|
|
20
|
+
current.append(c)
|
|
21
|
+
else:
|
|
22
|
+
in_double = not in_double
|
|
23
|
+
current.append(c)
|
|
24
|
+
elif not in_single and not in_double:
|
|
25
|
+
if c == "\\":
|
|
26
|
+
current.append(c)
|
|
27
|
+
if i + 1 < len(cmd):
|
|
28
|
+
current.append(cmd[i + 1])
|
|
29
|
+
i += 1
|
|
30
|
+
elif c == "\n":
|
|
31
|
+
if current:
|
|
32
|
+
sub_cmds.append("".join(current))
|
|
33
|
+
current = []
|
|
34
|
+
elif c == ";":
|
|
35
|
+
if current:
|
|
36
|
+
sub_cmds.append("".join(current))
|
|
37
|
+
current = []
|
|
38
|
+
elif c == "&" and i + 1 < len(cmd) and cmd[i + 1] == "&":
|
|
39
|
+
if current:
|
|
40
|
+
sub_cmds.append("".join(current))
|
|
41
|
+
current = []
|
|
42
|
+
i += 1
|
|
43
|
+
elif c == "|":
|
|
44
|
+
if i + 1 < len(cmd) and cmd[i + 1] == "|":
|
|
45
|
+
if current:
|
|
46
|
+
sub_cmds.append("".join(current))
|
|
47
|
+
current = []
|
|
48
|
+
i += 1
|
|
49
|
+
else:
|
|
50
|
+
if current:
|
|
51
|
+
sub_cmds.append("".join(current))
|
|
52
|
+
current = []
|
|
53
|
+
else:
|
|
54
|
+
current.append(c)
|
|
55
|
+
else:
|
|
56
|
+
current.append(c)
|
|
57
|
+
i += 1
|
|
58
|
+
|
|
59
|
+
if current:
|
|
60
|
+
sub_cmds.append("".join(current))
|
|
61
|
+
|
|
62
|
+
return [c.strip() for c in sub_cmds if c.strip()]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
TOOL_DISPLAY_NAMES = {
|
|
5
|
+
"view_file": "Read",
|
|
6
|
+
"write_to_file": "Write",
|
|
7
|
+
"replace_file_content": "Edit",
|
|
8
|
+
"multi_replace_file_content": "Edit",
|
|
9
|
+
"grep_search": "Grep",
|
|
10
|
+
"list_dir": "List",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
PATH_ARG_KEYS = ["AbsolutePath", "TargetFile", "DirectoryPath"]
|
|
14
|
+
|
|
15
|
+
DETAIL_FIELD_KEYS = {"Description", "Instruction", "TargetFile", "AbsolutePath", "DirectoryPath", "Query", "SearchPath"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_tool_display(tool_name: str, tool_input: dict) -> tuple[str, str, dict]:
|
|
19
|
+
"""
|
|
20
|
+
Formats the tool name and input into user-friendly display text.
|
|
21
|
+
Returns: (tool_msg_text, desc_json, view_tool_input)
|
|
22
|
+
"""
|
|
23
|
+
display_name = TOOL_DISPLAY_NAMES.get(tool_name, tool_name)
|
|
24
|
+
|
|
25
|
+
args_str = None
|
|
26
|
+
for key in PATH_ARG_KEYS:
|
|
27
|
+
if key in tool_input:
|
|
28
|
+
args_str = os.path.basename(tool_input[key])
|
|
29
|
+
break
|
|
30
|
+
if args_str is None:
|
|
31
|
+
args_str = ", ".join(f"{k}={v}" for k, v in tool_input.items() if len(str(v)) < 50)
|
|
32
|
+
|
|
33
|
+
tool_msg_text = f"● {display_name}({args_str})"
|
|
34
|
+
view_tool_input = tool_input
|
|
35
|
+
|
|
36
|
+
fields_text = ""
|
|
37
|
+
for k, v in tool_input.items():
|
|
38
|
+
if k in DETAIL_FIELD_KEYS:
|
|
39
|
+
fields_text += f"**{k}**: {v}\n"
|
|
40
|
+
|
|
41
|
+
code_text = ""
|
|
42
|
+
if "CodeContent" in tool_input:
|
|
43
|
+
code_text = f"\n**Code Content:**\n```python\n{tool_input['CodeContent'][:1000]}\n```"
|
|
44
|
+
elif "ReplacementChunks" in tool_input:
|
|
45
|
+
for i, chunk in enumerate(tool_input["ReplacementChunks"]):
|
|
46
|
+
code_text += f"\n**Replacement Chunk #{i + 1} (Lines {chunk.get('StartLine')}-{chunk.get('EndLine')}):**\n"
|
|
47
|
+
code_text += f"```python\n{chunk.get('ReplacementContent')[:500]}\n```"
|
|
48
|
+
elif "ReplacementContent" in tool_input:
|
|
49
|
+
code_text += f"\n**Replacement Content:**\n```python\n{tool_input['ReplacementContent'][:1000]}\n```"
|
|
50
|
+
|
|
51
|
+
if fields_text or code_text:
|
|
52
|
+
desc_json = f"\n{fields_text}{code_text}"
|
|
53
|
+
else:
|
|
54
|
+
desc_json = f"\n```json\n{json.dumps(tool_input, indent=2, ensure_ascii=False)[:1000]}\n```"
|
|
55
|
+
|
|
56
|
+
return tool_msg_text, desc_json, view_tool_input
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_bash_display(sub_cmd: str) -> tuple[str, str, dict]:
|
|
60
|
+
"""
|
|
61
|
+
Formats a single bash sub-command for display.
|
|
62
|
+
"""
|
|
63
|
+
is_long = "\n" in sub_cmd or len(sub_cmd) > 50
|
|
64
|
+
display_cmd = sub_cmd.split("\n")[0][:50] + "..." if is_long else sub_cmd
|
|
65
|
+
tool_msg_text = f"● Bash({display_cmd})"
|
|
66
|
+
view_tool_input = {"CommandLine": sub_cmd}
|
|
67
|
+
desc_json = f"\n```bash\n{sub_cmd}\n```" if is_long else ""
|
|
68
|
+
return tool_msg_text, desc_json, view_tool_input
|