aicortexpy 0.1.0__py3-none-any.whl

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 (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AICortex Python local agent runtime daemon."""
2
+
3
+ __version__ = "0.1.0"
aicortexpy/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m aicortexpy` invocation."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,559 @@
1
+ """ACP JSON-RPC 2.0 client over newline-delimited stdin/stdout.
2
+
3
+ Port of the shared hermesClient used by Cursor/Kiro/Hermes ACP backends.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+ import threading
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Callable, TextIO
14
+
15
+ log = logging.getLogger("aicortexpy.acp")
16
+
17
+ MessageCB = Callable[[dict[str, Any]], None]
18
+ PromptDoneCB = Callable[["PromptResult"], None]
19
+ AcceptCB = Callable[[str], bool]
20
+
21
+
22
+ @dataclass
23
+ class TokenUsage:
24
+ input_tokens: int = 0
25
+ output_tokens: int = 0
26
+ cache_read_tokens: int = 0
27
+
28
+
29
+ @dataclass
30
+ class PromptResult:
31
+ stop_reason: str = ""
32
+ usage: TokenUsage = field(default_factory=TokenUsage)
33
+
34
+
35
+ @dataclass
36
+ class _PendingRPC:
37
+ event: threading.Event
38
+ method: str
39
+ result: Any = None
40
+ error: Exception | None = None
41
+
42
+
43
+ @dataclass
44
+ class _PendingTool:
45
+ tool_name: str = ""
46
+ input: dict[str, Any] | None = None
47
+ args_text: str = ""
48
+ emitted: bool = False
49
+
50
+
51
+ class ACPClient:
52
+ """Thread-safe JSON-RPC client for one ACP child process."""
53
+
54
+ def __init__(self, stdin: TextIO, *, tool_name_fn: Callable[[str, str], str] | None = None) -> None:
55
+ self._stdin = stdin
56
+ self._write_lock = threading.Lock()
57
+ self._mu = threading.Lock()
58
+ self._next_id = 1
59
+ self._pending: dict[int, _PendingRPC] = {}
60
+ self._tool_mu = threading.Lock()
61
+ self._pending_tools: dict[str, _PendingTool] = {}
62
+ self.tool_name_fn = tool_name_fn or default_tool_name
63
+ self.session_id = ""
64
+ self.on_message: MessageCB | None = None
65
+ self.on_prompt_done: PromptDoneCB | None = None
66
+ self.accept_notification: AcceptCB | None = None
67
+ self.usage = TokenUsage()
68
+ self._usage_lock = threading.Lock()
69
+
70
+ def write_line(self, data: bytes) -> None:
71
+ with self._write_lock:
72
+ self._stdin.buffer.write(data)
73
+ self._stdin.buffer.flush()
74
+
75
+ def request(self, method: str, params: Any, *, timeout: float) -> Any:
76
+ with self._mu:
77
+ req_id = self._next_id
78
+ self._next_id += 1
79
+ pending = _PendingRPC(event=threading.Event(), method=method)
80
+ self._pending[req_id] = pending
81
+ payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
82
+ raw = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
83
+ try:
84
+ self.write_line(raw)
85
+ except OSError as e:
86
+ with self._mu:
87
+ self._pending.pop(req_id, None)
88
+ raise RuntimeError(f"write {method}: {e}") from e
89
+ if not pending.event.wait(timeout):
90
+ with self._mu:
91
+ self._pending.pop(req_id, None)
92
+ raise TimeoutError(f"{method} timed out after {timeout:.0f}s")
93
+ if pending.error:
94
+ raise pending.error
95
+ return pending.result
96
+
97
+ def close_all_pending(self, err: Exception) -> None:
98
+ with self._mu:
99
+ items = list(self._pending.items())
100
+ self._pending.clear()
101
+ for _, pr in items:
102
+ pr.error = err
103
+ pr.event.set()
104
+
105
+ def handle_line(self, line: str) -> None:
106
+ line = line.strip()
107
+ if not line:
108
+ return
109
+ try:
110
+ raw = json.loads(line)
111
+ except json.JSONDecodeError:
112
+ return
113
+ if not isinstance(raw, dict):
114
+ return
115
+ if "id" in raw:
116
+ if "result" in raw or "error" in raw:
117
+ self._handle_response(raw)
118
+ return
119
+ if "method" in raw:
120
+ self._handle_agent_request(raw)
121
+ return
122
+ if "method" in raw:
123
+ self._handle_notification(raw)
124
+
125
+ def _handle_response(self, raw: dict[str, Any]) -> None:
126
+ rid = raw.get("id")
127
+ try:
128
+ req_id = int(rid)
129
+ except (TypeError, ValueError):
130
+ return
131
+ with self._mu:
132
+ pr = self._pending.pop(req_id, None)
133
+ if pr is None:
134
+ return
135
+ if "error" in raw:
136
+ err = raw["error"] if isinstance(raw["error"], dict) else {}
137
+ msg = str(err.get("message") or "rpc error")
138
+ code = err.get("code")
139
+ data = err.get("data")
140
+ detail = ""
141
+ if data is not None and data != "null":
142
+ detail = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
143
+ text = f"{pr.method}: {msg} (code={code})"
144
+ if detail:
145
+ text += f", data={detail}"
146
+ pr.error = RuntimeError(text)
147
+ else:
148
+ result = raw.get("result")
149
+ if pr.method == "session/prompt":
150
+ self._extract_prompt_result(result)
151
+ pr.result = result
152
+ pr.event.set()
153
+
154
+ def _handle_agent_request(self, raw: dict[str, Any]) -> None:
155
+ method = str(raw.get("method") or "")
156
+ req_id = raw.get("id")
157
+ if method == "session/request_permission":
158
+ option_id = _select_permission_option_id(raw.get("params"))
159
+ result = {"outcome": {"outcome": "selected", "optionId": option_id}}
160
+ log.debug("auto-approved permission option_id=%s", option_id)
161
+ elif method == "cursor/ask_question":
162
+ result = {
163
+ "outcome": {
164
+ "outcome": "skipped",
165
+ "reason": "headless daemon — no interactive UI",
166
+ }
167
+ }
168
+ elif method == "cursor/create_plan":
169
+ result = {"outcome": {"outcome": "accepted"}}
170
+ elif method == "cursor/task":
171
+ result = {
172
+ "outcome": {
173
+ "outcome": "rejected",
174
+ "reason": (
175
+ "headless daemon — subagent delegation unavailable; "
176
+ "use main session tools"
177
+ ),
178
+ }
179
+ }
180
+ else:
181
+ resp = {
182
+ "jsonrpc": "2.0",
183
+ "id": req_id,
184
+ "error": {"code": -32601, "message": f"method not found: {method}"},
185
+ }
186
+ try:
187
+ self.write_line((json.dumps(resp) + "\n").encode("utf-8"))
188
+ except OSError:
189
+ pass
190
+ return
191
+ resp = {"jsonrpc": "2.0", "id": req_id, "result": result}
192
+ try:
193
+ self.write_line((json.dumps(resp, ensure_ascii=False) + "\n").encode("utf-8"))
194
+ except OSError as e:
195
+ log.warning("write agent-request response failed: %s", e)
196
+
197
+ def _handle_notification(self, raw: dict[str, Any]) -> None:
198
+ method = str(raw.get("method") or "")
199
+ if method not in ("session/update", "session/notification"):
200
+ return
201
+ params = raw.get("params") if isinstance(raw.get("params"), dict) else {}
202
+ update = params.get("update")
203
+ if update is None:
204
+ return
205
+ update_type, update_data = _normalize_update(update)
206
+ if self.accept_notification and not self.accept_notification(update_type):
207
+ return
208
+ if update_type == "agent_message_chunk":
209
+ text = _content_text(update_data)
210
+ if text and self.on_message:
211
+ self.on_message({"type": "text", "content": text})
212
+ elif update_type == "agent_thought_chunk":
213
+ text = _content_text(update_data)
214
+ if text and self.on_message:
215
+ self.on_message({"type": "thinking", "content": text})
216
+ elif update_type == "tool_call":
217
+ self._handle_tool_call_start(update_data)
218
+ elif update_type == "tool_call_update":
219
+ self._handle_tool_call_update(update_data)
220
+ elif update_type == "usage_update":
221
+ self._handle_usage_update(update_data)
222
+ elif update_type == "turn_end":
223
+ self._extract_prompt_result(update_data)
224
+
225
+ def _handle_tool_call_start(self, data: Any) -> None:
226
+ if not isinstance(data, dict):
227
+ return
228
+ call_id = str(data.get("toolCallId") or "")
229
+ title = str(data.get("title") or "")
230
+ kind = str(data.get("kind") or "")
231
+ name = str(data.get("name") or "")
232
+ tool_name = self.tool_name_fn(title, kind) or name
233
+ raw_input = data.get("rawInput") or data.get("input") or data.get("parameters")
234
+ if isinstance(raw_input, dict):
235
+ self._track_tool(call_id, _PendingTool(tool_name=tool_name, input=raw_input, emitted=True))
236
+ if self.on_message:
237
+ self.on_message(
238
+ {
239
+ "type": "tool-use",
240
+ "tool": tool_name,
241
+ "call_id": call_id,
242
+ "input": raw_input,
243
+ }
244
+ )
245
+ return
246
+ content = data.get("content") if isinstance(data.get("content"), list) else []
247
+ self._track_tool(
248
+ call_id,
249
+ _PendingTool(
250
+ tool_name=tool_name,
251
+ args_text=_extract_tool_call_text(content),
252
+ emitted=False,
253
+ ),
254
+ )
255
+
256
+ def _handle_tool_call_update(self, data: Any) -> None:
257
+ if not isinstance(data, dict):
258
+ return
259
+ call_id = str(data.get("toolCallId") or "")
260
+ status = str(data.get("status") or "")
261
+ content = data.get("content") if isinstance(data.get("content"), list) else []
262
+ if status not in ("completed", "failed"):
263
+ pending = self._get_pending_tool(call_id)
264
+ if pending is not None and not pending.emitted:
265
+ text = _extract_tool_call_text(content)
266
+ if text:
267
+ pending.args_text = text
268
+ return
269
+ pending = self._take_pending_tool(call_id)
270
+ self._emit_deferred_tool_use(pending, call_id, data)
271
+ output = str(data.get("rawOutput") or data.get("output") or "")
272
+ if not output:
273
+ output = _extract_tool_call_text(content)
274
+ if self.on_message:
275
+ self.on_message({"type": "tool-result", "call_id": call_id, "output": output})
276
+
277
+ def _emit_deferred_tool_use(self, pending: _PendingTool | None, call_id: str, data: dict) -> None:
278
+ if pending is not None and pending.emitted:
279
+ return
280
+ title = str(data.get("title") or data.get("name") or "")
281
+ kind = str(data.get("kind") or "")
282
+ raw_input = data.get("rawInput") or data.get("input") or data.get("parameters")
283
+ tool_name = ""
284
+ inp: dict[str, Any] | None = None
285
+ if pending is not None:
286
+ tool_name = pending.tool_name
287
+ if pending.input is not None:
288
+ inp = pending.input
289
+ elif pending.args_text:
290
+ try:
291
+ parsed = json.loads(pending.args_text)
292
+ inp = parsed if isinstance(parsed, dict) else {"text": pending.args_text}
293
+ except json.JSONDecodeError:
294
+ inp = {"text": pending.args_text}
295
+ if not tool_name:
296
+ tool_name = self.tool_name_fn(title, kind) or str(data.get("name") or "tool")
297
+ if inp is None and isinstance(raw_input, dict):
298
+ inp = raw_input
299
+ if inp is None:
300
+ inp = {}
301
+ if self.on_message:
302
+ self.on_message(
303
+ {"type": "tool-use", "tool": tool_name, "call_id": call_id, "input": inp}
304
+ )
305
+
306
+ def _track_tool(self, call_id: str, p: _PendingTool) -> None:
307
+ if not call_id:
308
+ return
309
+ with self._tool_mu:
310
+ self._pending_tools[call_id] = p
311
+
312
+ def _get_pending_tool(self, call_id: str) -> _PendingTool | None:
313
+ with self._tool_mu:
314
+ return self._pending_tools.get(call_id)
315
+
316
+ def _take_pending_tool(self, call_id: str) -> _PendingTool | None:
317
+ with self._tool_mu:
318
+ return self._pending_tools.pop(call_id, None)
319
+
320
+ def _extract_prompt_result(self, data: Any) -> None:
321
+ if not isinstance(data, dict):
322
+ return
323
+ pr = PromptResult(stop_reason=str(data.get("stopReason") or ""))
324
+ usage = data.get("usage")
325
+ if isinstance(usage, dict):
326
+ pr.usage = TokenUsage(
327
+ input_tokens=int(usage.get("inputTokens") or 0),
328
+ output_tokens=int(usage.get("outputTokens") or 0),
329
+ cache_read_tokens=int(usage.get("cachedReadTokens") or 0),
330
+ )
331
+ if self.on_prompt_done:
332
+ self.on_prompt_done(pr)
333
+
334
+ def _handle_usage_update(self, data: Any) -> None:
335
+ if not isinstance(data, dict):
336
+ return
337
+ usage = data.get("usage") if isinstance(data.get("usage"), dict) else data
338
+ if not isinstance(usage, dict):
339
+ return
340
+ with self._usage_lock:
341
+ self.usage.input_tokens = int(
342
+ usage.get("inputTokens") or usage.get("input_tokens") or self.usage.input_tokens
343
+ )
344
+ self.usage.output_tokens = int(
345
+ usage.get("outputTokens") or usage.get("output_tokens") or self.usage.output_tokens
346
+ )
347
+ self.usage.cache_read_tokens = int(
348
+ usage.get("cachedReadTokens")
349
+ or usage.get("cache_read_tokens")
350
+ or self.usage.cache_read_tokens
351
+ )
352
+
353
+
354
+ def extract_session_id(result: Any) -> str:
355
+ if not isinstance(result, dict):
356
+ return ""
357
+ for key in ("sessionId", "session_id", "id"):
358
+ v = result.get(key)
359
+ if isinstance(v, str) and v.strip():
360
+ return v.strip()
361
+ session = result.get("session")
362
+ if isinstance(session, dict):
363
+ return extract_session_id(session)
364
+ return ""
365
+
366
+
367
+ def resolve_resumed_session_id(requested: str, response: Any) -> tuple[str, bool]:
368
+ actual = extract_session_id(response) or requested
369
+ return actual, bool(actual and requested and actual != requested)
370
+
371
+
372
+ def _select_permission_option_id(params: Any) -> str:
373
+ fallback = "approve_for_session"
374
+ if not isinstance(params, dict):
375
+ return fallback
376
+ options = params.get("options")
377
+ if not isinstance(options, list) or not options:
378
+ return fallback
379
+ prefer = ("allow-always", "approve_for_session", "allow-once", "approve")
380
+ by_id = {
381
+ str(o.get("optionId") or ""): o
382
+ for o in options
383
+ if isinstance(o, dict)
384
+ }
385
+ for want in prefer:
386
+ if want in by_id:
387
+ return want
388
+ for o in options:
389
+ if isinstance(o, dict) and o.get("kind") == "allow_always":
390
+ return str(o.get("optionId") or fallback)
391
+ for o in options:
392
+ if isinstance(o, dict) and o.get("kind") == "allow_once":
393
+ return str(o.get("optionId") or fallback)
394
+ first = options[0]
395
+ if isinstance(first, dict):
396
+ return str(first.get("optionId") or fallback)
397
+ return fallback
398
+
399
+
400
+ def _normalize_update(data: Any) -> tuple[str, Any]:
401
+ if not isinstance(data, dict):
402
+ return "", data
403
+ session_update = str(data.get("sessionUpdate") or "")
404
+ typ = str(data.get("type") or "")
405
+ if session_update:
406
+ return _normalize_type(session_update), data
407
+ if typ:
408
+ return _normalize_type(typ), data
409
+ if len(data) == 1:
410
+ k, v = next(iter(data.items()))
411
+ return _normalize_type(str(k)), v
412
+ return "", data
413
+
414
+
415
+ def _normalize_type(t: str) -> str:
416
+ key = re.sub(r"[\s_\-]", "", t.strip().lower())
417
+ mapping = {
418
+ "agentmessagechunk": "agent_message_chunk",
419
+ "agentthoughtchunk": "agent_thought_chunk",
420
+ "toolcall": "tool_call",
421
+ "toolcallupdate": "tool_call_update",
422
+ "usageupdate": "usage_update",
423
+ "turnend": "turn_end",
424
+ "endturn": "turn_end",
425
+ }
426
+ return mapping.get(key, "")
427
+
428
+
429
+ def _content_text(data: Any) -> str:
430
+ if not isinstance(data, dict):
431
+ return ""
432
+ content = data.get("content")
433
+ if isinstance(content, dict):
434
+ return str(content.get("text") or "")
435
+ return str(data.get("text") or "")
436
+
437
+
438
+ def _extract_tool_call_text(blocks: Any) -> str:
439
+ if not isinstance(blocks, list):
440
+ return ""
441
+ parts: list[str] = []
442
+ for raw in blocks:
443
+ if not isinstance(raw, dict):
444
+ continue
445
+ typ = str(raw.get("type") or "")
446
+ if typ == "content":
447
+ inner = raw.get("content")
448
+ if isinstance(inner, dict) and str(inner.get("type") or "") == "text":
449
+ text = str(inner.get("text") or "")
450
+ if text:
451
+ parts.append(text)
452
+ elif typ == "diff":
453
+ path = str(raw.get("path") or "")
454
+ if not path:
455
+ continue
456
+ old = str(raw.get("oldText") or "")
457
+ new = str(raw.get("newText") or "")
458
+ if not old:
459
+ parts.append(f"--- {path}\n+++ {path}\n(new file, {len(new)} bytes)")
460
+ else:
461
+ parts.append(
462
+ f"--- {path}\n+++ {path}\n(edited: {len(old)} → {len(new)} bytes)"
463
+ )
464
+ return "\n".join(parts)
465
+
466
+
467
+ def default_tool_name(title: str, kind: str = "") -> str:
468
+ return hermes_tool_name_from_title(title, kind)
469
+
470
+
471
+ def hermes_tool_name_from_title(title: str, kind: str = "") -> str:
472
+ if title == "execute code":
473
+ return "execute_code"
474
+ if ":" in title:
475
+ name = title.split(":", 1)[0].strip()
476
+ if name == "terminal":
477
+ return "terminal"
478
+ if name == "read":
479
+ return "read_file"
480
+ if name == "write":
481
+ return "write_file"
482
+ if name.startswith("patch"):
483
+ return "patch"
484
+ if name == "search":
485
+ return "search_files"
486
+ if name == "web search":
487
+ return "web_search"
488
+ if name == "extract":
489
+ return "web_extract"
490
+ if name == "delegate":
491
+ return "delegate_task"
492
+ if name == "analyze image":
493
+ return "vision_analyze"
494
+ return name
495
+ kind_map = {
496
+ "read": "read_file",
497
+ "edit": "write_file",
498
+ "execute": "terminal",
499
+ "search": "search_files",
500
+ "fetch": "web_search",
501
+ "think": "thinking",
502
+ }
503
+ if kind in kind_map:
504
+ return kind_map[kind]
505
+ return title or kind
506
+
507
+
508
+ def normalize_title_tool(title: str, mapping: dict[str, str] | None = None) -> str:
509
+ t = (title or "").strip()
510
+ if not t:
511
+ return ""
512
+ if ":" in t:
513
+ t = t.split(":", 1)[0].strip()
514
+ lower = t.lower()
515
+ base = {
516
+ "read": "read_file",
517
+ "read file": "read_file",
518
+ "write": "write_file",
519
+ "write file": "write_file",
520
+ "edit": "edit_file",
521
+ "patch": "edit_file",
522
+ "shell": "terminal",
523
+ "bash": "terminal",
524
+ "terminal": "terminal",
525
+ "run command": "terminal",
526
+ "run shell command": "terminal",
527
+ "execute": "terminal",
528
+ "grep": "search_files",
529
+ "search": "search_files",
530
+ "find": "search_files",
531
+ "glob": "glob",
532
+ "code": "code",
533
+ "web search": "web_search",
534
+ "fetch": "web_fetch",
535
+ "web fetch": "web_fetch",
536
+ "todo": "todo_write",
537
+ "todo write": "todo_write",
538
+ "todo list": "todo_write",
539
+ "todo_list": "todo_write",
540
+ }
541
+ if mapping:
542
+ base = {**base, **mapping}
543
+ if lower in base:
544
+ return base[lower]
545
+ return lower.replace(" ", "_")
546
+
547
+
548
+ def kiro_tool_name(title: str, kind: str = "") -> str:
549
+ return normalize_title_tool(title) or hermes_tool_name_from_title(title, kind)
550
+
551
+
552
+ def kimi_tool_name(title: str, kind: str = "") -> str:
553
+ # Upstream hermes mapping first, then kimi capitalised titles.
554
+ mapped = hermes_tool_name_from_title(title, kind)
555
+ return normalize_title_tool(mapped) or normalize_title_tool(title)
556
+
557
+
558
+ def dsh_tool_name(title: str, kind: str = "") -> str:
559
+ return normalize_title_tool(title) or hermes_tool_name_from_title(title, kind)