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