klaude-code 1.2.6__py3-none-any.whl → 1.8.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 (205) hide show
  1. klaude_code/auth/__init__.py +24 -0
  2. klaude_code/auth/codex/__init__.py +20 -0
  3. klaude_code/auth/codex/exceptions.py +17 -0
  4. klaude_code/auth/codex/jwt_utils.py +45 -0
  5. klaude_code/auth/codex/oauth.py +229 -0
  6. klaude_code/auth/codex/token_manager.py +84 -0
  7. klaude_code/cli/auth_cmd.py +73 -0
  8. klaude_code/cli/config_cmd.py +91 -0
  9. klaude_code/cli/cost_cmd.py +338 -0
  10. klaude_code/cli/debug.py +78 -0
  11. klaude_code/cli/list_model.py +307 -0
  12. klaude_code/cli/main.py +233 -134
  13. klaude_code/cli/runtime.py +309 -117
  14. klaude_code/{version.py → cli/self_update.py} +114 -5
  15. klaude_code/cli/session_cmd.py +37 -21
  16. klaude_code/command/__init__.py +88 -27
  17. klaude_code/command/clear_cmd.py +8 -7
  18. klaude_code/command/command_abc.py +31 -31
  19. klaude_code/command/debug_cmd.py +79 -0
  20. klaude_code/command/export_cmd.py +19 -53
  21. klaude_code/command/export_online_cmd.py +154 -0
  22. klaude_code/command/fork_session_cmd.py +267 -0
  23. klaude_code/command/help_cmd.py +7 -8
  24. klaude_code/command/model_cmd.py +60 -10
  25. klaude_code/command/model_select.py +84 -0
  26. klaude_code/command/prompt-jj-describe.md +32 -0
  27. klaude_code/command/prompt_command.py +19 -11
  28. klaude_code/command/refresh_cmd.py +8 -10
  29. klaude_code/command/registry.py +139 -40
  30. klaude_code/command/release_notes_cmd.py +84 -0
  31. klaude_code/command/resume_cmd.py +111 -0
  32. klaude_code/command/status_cmd.py +104 -60
  33. klaude_code/command/terminal_setup_cmd.py +7 -9
  34. klaude_code/command/thinking_cmd.py +98 -0
  35. klaude_code/config/__init__.py +14 -6
  36. klaude_code/config/assets/__init__.py +1 -0
  37. klaude_code/config/assets/builtin_config.yaml +303 -0
  38. klaude_code/config/builtin_config.py +38 -0
  39. klaude_code/config/config.py +378 -109
  40. klaude_code/config/select_model.py +117 -53
  41. klaude_code/config/thinking.py +269 -0
  42. klaude_code/{const/__init__.py → const.py} +50 -19
  43. klaude_code/core/agent.py +20 -28
  44. klaude_code/core/executor.py +327 -112
  45. klaude_code/core/manager/__init__.py +2 -4
  46. klaude_code/core/manager/llm_clients.py +1 -15
  47. klaude_code/core/manager/llm_clients_builder.py +10 -11
  48. klaude_code/core/manager/sub_agent_manager.py +37 -6
  49. klaude_code/core/prompt.py +63 -44
  50. klaude_code/core/prompts/prompt-claude-code.md +2 -13
  51. klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +117 -0
  52. klaude_code/core/prompts/prompt-codex-gpt-5-2-codex.md +117 -0
  53. klaude_code/core/prompts/prompt-codex.md +9 -42
  54. klaude_code/core/prompts/prompt-minimal.md +12 -0
  55. klaude_code/core/prompts/{prompt-subagent-explore.md → prompt-sub-agent-explore.md} +16 -3
  56. klaude_code/core/prompts/{prompt-subagent-oracle.md → prompt-sub-agent-oracle.md} +1 -2
  57. klaude_code/core/prompts/prompt-sub-agent-web.md +51 -0
  58. klaude_code/core/reminders.py +283 -95
  59. klaude_code/core/task.py +113 -75
  60. klaude_code/core/tool/__init__.py +24 -31
  61. klaude_code/core/tool/file/_utils.py +36 -0
  62. klaude_code/core/tool/file/apply_patch.py +17 -25
  63. klaude_code/core/tool/file/apply_patch_tool.py +57 -77
  64. klaude_code/core/tool/file/diff_builder.py +151 -0
  65. klaude_code/core/tool/file/edit_tool.py +50 -63
  66. klaude_code/core/tool/file/move_tool.md +41 -0
  67. klaude_code/core/tool/file/move_tool.py +435 -0
  68. klaude_code/core/tool/file/read_tool.md +1 -1
  69. klaude_code/core/tool/file/read_tool.py +86 -86
  70. klaude_code/core/tool/file/write_tool.py +59 -69
  71. klaude_code/core/tool/report_back_tool.py +84 -0
  72. klaude_code/core/tool/shell/bash_tool.py +265 -22
  73. klaude_code/core/tool/shell/command_safety.py +3 -6
  74. klaude_code/core/tool/{memory → skill}/skill_tool.py +16 -26
  75. klaude_code/core/tool/sub_agent_tool.py +13 -2
  76. klaude_code/core/tool/todo/todo_write_tool.md +0 -157
  77. klaude_code/core/tool/todo/todo_write_tool.py +1 -1
  78. klaude_code/core/tool/todo/todo_write_tool_raw.md +182 -0
  79. klaude_code/core/tool/todo/update_plan_tool.py +1 -1
  80. klaude_code/core/tool/tool_abc.py +18 -0
  81. klaude_code/core/tool/tool_context.py +27 -12
  82. klaude_code/core/tool/tool_registry.py +7 -7
  83. klaude_code/core/tool/tool_runner.py +44 -36
  84. klaude_code/core/tool/truncation.py +29 -14
  85. klaude_code/core/tool/web/mermaid_tool.md +43 -0
  86. klaude_code/core/tool/web/mermaid_tool.py +2 -5
  87. klaude_code/core/tool/web/web_fetch_tool.md +1 -1
  88. klaude_code/core/tool/web/web_fetch_tool.py +112 -22
  89. klaude_code/core/tool/web/web_search_tool.md +23 -0
  90. klaude_code/core/tool/web/web_search_tool.py +130 -0
  91. klaude_code/core/turn.py +168 -66
  92. klaude_code/llm/__init__.py +2 -10
  93. klaude_code/llm/anthropic/client.py +190 -178
  94. klaude_code/llm/anthropic/input.py +39 -15
  95. klaude_code/llm/bedrock/__init__.py +3 -0
  96. klaude_code/llm/bedrock/client.py +60 -0
  97. klaude_code/llm/client.py +7 -21
  98. klaude_code/llm/codex/__init__.py +5 -0
  99. klaude_code/llm/codex/client.py +149 -0
  100. klaude_code/llm/google/__init__.py +3 -0
  101. klaude_code/llm/google/client.py +309 -0
  102. klaude_code/llm/google/input.py +215 -0
  103. klaude_code/llm/input_common.py +3 -9
  104. klaude_code/llm/openai_compatible/client.py +72 -164
  105. klaude_code/llm/openai_compatible/input.py +6 -4
  106. klaude_code/llm/openai_compatible/stream.py +273 -0
  107. klaude_code/llm/openai_compatible/tool_call_accumulator.py +17 -1
  108. klaude_code/llm/openrouter/client.py +89 -160
  109. klaude_code/llm/openrouter/input.py +18 -30
  110. klaude_code/llm/openrouter/reasoning.py +118 -0
  111. klaude_code/llm/registry.py +39 -7
  112. klaude_code/llm/responses/client.py +184 -171
  113. klaude_code/llm/responses/input.py +20 -1
  114. klaude_code/llm/usage.py +17 -12
  115. klaude_code/protocol/commands.py +17 -1
  116. klaude_code/protocol/events.py +31 -4
  117. klaude_code/protocol/llm_param.py +13 -10
  118. klaude_code/protocol/model.py +232 -29
  119. klaude_code/protocol/op.py +90 -1
  120. klaude_code/protocol/op_handler.py +35 -1
  121. klaude_code/protocol/sub_agent/__init__.py +117 -0
  122. klaude_code/protocol/sub_agent/explore.py +63 -0
  123. klaude_code/protocol/sub_agent/oracle.py +91 -0
  124. klaude_code/protocol/sub_agent/task.py +61 -0
  125. klaude_code/protocol/sub_agent/web.py +79 -0
  126. klaude_code/protocol/tools.py +4 -2
  127. klaude_code/session/__init__.py +2 -2
  128. klaude_code/session/codec.py +71 -0
  129. klaude_code/session/export.py +293 -86
  130. klaude_code/session/selector.py +89 -67
  131. klaude_code/session/session.py +320 -309
  132. klaude_code/session/store.py +220 -0
  133. klaude_code/session/templates/export_session.html +595 -83
  134. klaude_code/session/templates/mermaid_viewer.html +926 -0
  135. klaude_code/skill/__init__.py +27 -0
  136. klaude_code/skill/assets/deslop/SKILL.md +17 -0
  137. klaude_code/skill/assets/dev-docs/SKILL.md +108 -0
  138. klaude_code/skill/assets/handoff/SKILL.md +39 -0
  139. klaude_code/skill/assets/jj-workspace/SKILL.md +20 -0
  140. klaude_code/skill/assets/skill-creator/SKILL.md +139 -0
  141. klaude_code/{core/tool/memory/skill_loader.py → skill/loader.py} +55 -15
  142. klaude_code/skill/manager.py +70 -0
  143. klaude_code/skill/system_skills.py +192 -0
  144. klaude_code/trace/__init__.py +20 -2
  145. klaude_code/trace/log.py +150 -5
  146. klaude_code/ui/__init__.py +4 -9
  147. klaude_code/ui/core/input.py +1 -1
  148. klaude_code/ui/core/stage_manager.py +7 -7
  149. klaude_code/ui/modes/debug/display.py +2 -1
  150. klaude_code/ui/modes/repl/__init__.py +3 -48
  151. klaude_code/ui/modes/repl/clipboard.py +5 -5
  152. klaude_code/ui/modes/repl/completers.py +487 -123
  153. klaude_code/ui/modes/repl/display.py +5 -4
  154. klaude_code/ui/modes/repl/event_handler.py +370 -117
  155. klaude_code/ui/modes/repl/input_prompt_toolkit.py +552 -105
  156. klaude_code/ui/modes/repl/key_bindings.py +146 -23
  157. klaude_code/ui/modes/repl/renderer.py +189 -99
  158. klaude_code/ui/renderers/assistant.py +9 -2
  159. klaude_code/ui/renderers/bash_syntax.py +178 -0
  160. klaude_code/ui/renderers/common.py +78 -0
  161. klaude_code/ui/renderers/developer.py +104 -48
  162. klaude_code/ui/renderers/diffs.py +87 -6
  163. klaude_code/ui/renderers/errors.py +11 -6
  164. klaude_code/ui/renderers/mermaid_viewer.py +57 -0
  165. klaude_code/ui/renderers/metadata.py +112 -76
  166. klaude_code/ui/renderers/sub_agent.py +92 -7
  167. klaude_code/ui/renderers/thinking.py +40 -18
  168. klaude_code/ui/renderers/tools.py +405 -227
  169. klaude_code/ui/renderers/user_input.py +73 -13
  170. klaude_code/ui/rich/__init__.py +10 -1
  171. klaude_code/ui/rich/cjk_wrap.py +228 -0
  172. klaude_code/ui/rich/code_panel.py +131 -0
  173. klaude_code/ui/rich/live.py +17 -0
  174. klaude_code/ui/rich/markdown.py +305 -170
  175. klaude_code/ui/rich/searchable_text.py +10 -13
  176. klaude_code/ui/rich/status.py +190 -49
  177. klaude_code/ui/rich/theme.py +135 -39
  178. klaude_code/ui/terminal/__init__.py +55 -0
  179. klaude_code/ui/terminal/color.py +1 -1
  180. klaude_code/ui/terminal/control.py +13 -22
  181. klaude_code/ui/terminal/notifier.py +44 -4
  182. klaude_code/ui/terminal/selector.py +658 -0
  183. klaude_code/ui/utils/common.py +0 -18
  184. klaude_code-1.8.0.dist-info/METADATA +377 -0
  185. klaude_code-1.8.0.dist-info/RECORD +219 -0
  186. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/entry_points.txt +1 -0
  187. klaude_code/command/diff_cmd.py +0 -138
  188. klaude_code/command/prompt-dev-docs-update.md +0 -56
  189. klaude_code/command/prompt-dev-docs.md +0 -46
  190. klaude_code/config/list_model.py +0 -162
  191. klaude_code/core/manager/agent_manager.py +0 -127
  192. klaude_code/core/prompts/prompt-subagent-webfetch.md +0 -46
  193. klaude_code/core/tool/file/multi_edit_tool.md +0 -42
  194. klaude_code/core/tool/file/multi_edit_tool.py +0 -199
  195. klaude_code/core/tool/memory/memory_tool.md +0 -16
  196. klaude_code/core/tool/memory/memory_tool.py +0 -462
  197. klaude_code/llm/openrouter/reasoning_handler.py +0 -209
  198. klaude_code/protocol/sub_agent.py +0 -348
  199. klaude_code/ui/utils/debouncer.py +0 -42
  200. klaude_code-1.2.6.dist-info/METADATA +0 -178
  201. klaude_code-1.2.6.dist-info/RECORD +0 -167
  202. /klaude_code/core/prompts/{prompt-subagent.md → prompt-sub-agent.md} +0 -0
  203. /klaude_code/core/tool/{memory → skill}/__init__.py +0 -0
  204. /klaude_code/core/tool/{memory → skill}/skill_tool.md +0 -0
  205. {klaude_code-1.2.6.dist-info → klaude_code-1.8.0.dist-info}/WHEEL +0 -0
@@ -5,4 +5,4 @@ The tool automatically processes the response based on Content-Type:
5
5
  - JSON responses are formatted with indentation
6
6
  - Markdown and other text content is returned as-is
7
7
 
8
- Use this tool to retrieve web page content for analysis.
8
+ Content is always saved to a local file. The file path is included at the start of the output in a `<file_saved>` tag. For large content that gets truncated, you can read the saved file directly.
@@ -1,29 +1,89 @@
1
1
  import asyncio
2
2
  import json
3
+ import re
4
+ import time
3
5
  import urllib.error
4
6
  import urllib.request
5
7
  from http.client import HTTPResponse
6
8
  from pathlib import Path
9
+ from urllib.parse import quote, urlparse, urlunparse
7
10
 
8
11
  from pydantic import BaseModel
9
12
 
10
- from klaude_code.core.tool.tool_abc import ToolABC, load_desc
13
+ from klaude_code import const
14
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
11
15
  from klaude_code.core.tool.tool_registry import register
12
16
  from klaude_code.protocol import llm_param, model, tools
13
17
 
14
18
  DEFAULT_TIMEOUT_SEC = 30
15
19
  DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; KlaudeCode/1.0)"
20
+ WEB_FETCH_SAVE_DIR = Path(const.TOOL_OUTPUT_TRUNCATION_DIR) / "web"
16
21
 
17
22
 
18
- def _extract_content_type(response: HTTPResponse) -> str:
19
- """Extract the base content type without charset parameters."""
20
- content_type = response.getheader("Content-Type", "")
21
- return content_type.split(";")[0].strip().lower()
23
+ def _encode_url(url: str) -> str:
24
+ """Encode non-ASCII characters in URL to make it safe for HTTP requests."""
25
+ parsed = urlparse(url)
26
+ encoded_path = quote(parsed.path, safe="/-_.~")
27
+ encoded_query = quote(parsed.query, safe="=&-_.~")
28
+ # Handle IDN (Internationalized Domain Names) by encoding to punycode
29
+ try:
30
+ netloc = parsed.netloc.encode("idna").decode("ascii")
31
+ except UnicodeError:
32
+ netloc = parsed.netloc
33
+ return urlunparse((parsed.scheme, netloc, encoded_path, parsed.params, encoded_query, parsed.fragment))
34
+
35
+
36
+ def _extract_content_type_and_charset(response: HTTPResponse) -> tuple[str, str | None]:
37
+ """Extract the base content type and charset from Content-Type header."""
38
+ content_type_header = response.getheader("Content-Type", "")
39
+ parts = content_type_header.split(";")
40
+ content_type = parts[0].strip().lower()
41
+
42
+ charset = None
43
+ for part in parts[1:]:
44
+ part = part.strip()
45
+ if part.lower().startswith("charset="):
46
+ charset = part[8:].strip().strip("\"'")
47
+ break
48
+
49
+ return content_type, charset
50
+
51
+
52
+ def _detect_encoding(data: bytes, declared_charset: str | None) -> str:
53
+ """Detect the encoding of the data."""
54
+ # 1. Use declared charset from HTTP header if available
55
+ if declared_charset:
56
+ return declared_charset
22
57
 
58
+ # 2. Try to detect from HTML meta tags (check first 2KB)
59
+ head = data[:2048].lower()
60
+ # <meta charset="xxx">
61
+ if match := re.search(rb'<meta[^>]+charset=["\']?([^"\'\s>]+)', head):
62
+ return match.group(1).decode("ascii", errors="ignore")
63
+ # <meta http-equiv="Content-Type" content="text/html; charset=xxx">
64
+ if match := re.search(rb'content=["\'][^"\']*charset=([^"\'\s;]+)', head):
65
+ return match.group(1).decode("ascii", errors="ignore")
23
66
 
24
- def _validate_utf8(data: bytes) -> str:
25
- """Validate and decode bytes as UTF-8."""
26
- return data.decode("utf-8")
67
+ # 3. Use chardet for automatic detection
68
+ import chardet
69
+
70
+ result = chardet.detect(data)
71
+ if result["encoding"] and result["confidence"] and result["confidence"] > 0.7:
72
+ return result["encoding"]
73
+
74
+ # 4. Default to UTF-8
75
+ return "utf-8"
76
+
77
+
78
+ def _decode_content(data: bytes, declared_charset: str | None) -> str:
79
+ """Decode bytes to string with automatic encoding detection."""
80
+ encoding = _detect_encoding(data, declared_charset)
81
+
82
+ try:
83
+ return data.decode(encoding)
84
+ except (UnicodeDecodeError, LookupError):
85
+ # Fallback: try UTF-8 with replacement for invalid chars
86
+ return data.decode("utf-8", errors="replace")
27
87
 
28
88
 
29
89
  def _convert_html_to_markdown(html: str) -> str:
@@ -43,6 +103,30 @@ def _format_json(text: str) -> str:
43
103
  return text
44
104
 
45
105
 
106
+ def _extract_url_filename(url: str) -> str:
107
+ """Extract a safe filename from a URL."""
108
+ parsed = urlparse(url)
109
+ host = parsed.netloc.replace(".", "_").replace(":", "_")
110
+ path = parsed.path.strip("/").replace("/", "_")
111
+ name = f"{host}_{path}" if path else host
112
+ name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
113
+ return name[:80] if len(name) > 80 else name
114
+
115
+
116
+ def _save_web_content(url: str, content: str) -> str | None:
117
+ """Save web content to file. Returns file path or None on failure."""
118
+ try:
119
+ WEB_FETCH_SAVE_DIR.mkdir(parents=True, exist_ok=True)
120
+ timestamp = int(time.time())
121
+ identifier = _extract_url_filename(url)
122
+ filename = f"{identifier}-{timestamp}.md"
123
+ file_path = WEB_FETCH_SAVE_DIR / filename
124
+ file_path.write_text(content, encoding="utf-8")
125
+ return str(file_path)
126
+ except OSError:
127
+ return None
128
+
129
+
46
130
  def _process_content(content_type: str, text: str) -> str:
47
131
  """Process content based on Content-Type header."""
48
132
  if content_type == "text/html":
@@ -69,17 +153,22 @@ def _fetch_url(url: str, timeout: int = DEFAULT_TIMEOUT_SEC) -> tuple[str, str]:
69
153
  "Accept": "text/markdown, */*",
70
154
  "User-Agent": DEFAULT_USER_AGENT,
71
155
  }
72
- request = urllib.request.Request(url, headers=headers)
156
+ encoded_url = _encode_url(url)
157
+ request = urllib.request.Request(encoded_url, headers=headers)
73
158
 
74
159
  with urllib.request.urlopen(request, timeout=timeout) as response:
75
- content_type = _extract_content_type(response)
160
+ content_type, charset = _extract_content_type_and_charset(response)
76
161
  data = response.read()
77
- text = _validate_utf8(data)
162
+ text = _decode_content(data, charset)
78
163
  return content_type, text
79
164
 
80
165
 
81
166
  @register(tools.WEB_FETCH)
82
167
  class WebFetchTool(ToolABC):
168
+ @classmethod
169
+ def metadata(cls) -> ToolMetadata:
170
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=True)
171
+
83
172
  @classmethod
84
173
  def schema(cls) -> llm_param.ToolSchema:
85
174
  return llm_param.ToolSchema(
@@ -120,40 +209,41 @@ class WebFetchTool(ToolABC):
120
209
  if not url.startswith(("http://", "https://")):
121
210
  return model.ToolResultItem(
122
211
  status="error",
123
- output="Invalid URL: must start with http:// or https://",
212
+ output=f"Invalid URL: must start with http:// or https:// (url={url})",
124
213
  )
125
214
 
126
215
  try:
127
216
  content_type, text = await asyncio.to_thread(_fetch_url, url)
128
217
  processed = _process_content(content_type, text)
129
218
 
219
+ # Always save content to file
220
+ saved_path = _save_web_content(url, processed)
221
+
222
+ # Build output with file path info
223
+ output = f"<file_saved>{saved_path}</file_saved>\n\n{processed}" if saved_path else processed
224
+
130
225
  return model.ToolResultItem(
131
226
  status="success",
132
- output=processed,
227
+ output=output,
133
228
  )
134
229
 
135
230
  except urllib.error.HTTPError as e:
136
231
  return model.ToolResultItem(
137
232
  status="error",
138
- output=f"HTTP error {e.code}: {e.reason}",
233
+ output=f"HTTP error {e.code}: {e.reason} (url={url})",
139
234
  )
140
235
  except urllib.error.URLError as e:
141
236
  return model.ToolResultItem(
142
237
  status="error",
143
- output=f"URL error: {e.reason}",
144
- )
145
- except UnicodeDecodeError as e:
146
- return model.ToolResultItem(
147
- status="error",
148
- output=f"Content is not valid UTF-8: {e}",
238
+ output=f"URL error: {e.reason} (url={url})",
149
239
  )
150
240
  except TimeoutError:
151
241
  return model.ToolResultItem(
152
242
  status="error",
153
- output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds",
243
+ output=f"Request timed out after {DEFAULT_TIMEOUT_SEC} seconds (url={url})",
154
244
  )
155
245
  except Exception as e:
156
246
  return model.ToolResultItem(
157
247
  status="error",
158
- output=f"Failed to fetch URL: {e}",
248
+ output=f"Failed to fetch URL: {e} (url={url})",
159
249
  )
@@ -0,0 +1,23 @@
1
+ - Search the web and use the results to inform responses
2
+ - Provides up-to-date information for current events and recent data
3
+ - Returns search result information formatted as search result blocks, including links as markdown hyperlinks
4
+ - Use this tool for accessing information beyond your knowledge cutoff
5
+ - Searches are performed automatically within a single API call
6
+
7
+ CRITICAL REQUIREMENT - You MUST follow this:
8
+ - After answering the user's question, you MUST include a "Sources:" section at the end of your response
9
+ - In the Sources section, list all relevant URLs from the search results as markdown hyperlinks: [Title](URL)
10
+ - This is MANDATORY - never skip including sources in your response
11
+ - Example format:
12
+
13
+ [Your answer here]
14
+
15
+ Sources:
16
+ - [Source Title 1](https://example.com/1)
17
+ - [Source Title 2](https://example.com/2)
18
+
19
+ Usage notes:
20
+ - Domain filtering is supported to include or block specific websites
21
+ - Web search is only available in the US
22
+ - Account for "Today's date" in <env>. For example, if <env> says "Today's date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.
23
+
@@ -0,0 +1,130 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from klaude_code.core.tool.tool_abc import ToolABC, ToolConcurrencyPolicy, ToolMetadata, load_desc
8
+ from klaude_code.core.tool.tool_registry import register
9
+ from klaude_code.protocol import llm_param, model, tools
10
+
11
+ DEFAULT_MAX_RESULTS = 10
12
+ MAX_RESULTS_LIMIT = 20
13
+
14
+
15
+ @dataclass
16
+ class SearchResult:
17
+ """A single search result from DuckDuckGo."""
18
+
19
+ title: str
20
+ url: str
21
+ snippet: str
22
+ position: int
23
+
24
+
25
+ def _search_duckduckgo(query: str, max_results: int) -> list[SearchResult]:
26
+ """Perform a web search using ddgs library."""
27
+ from ddgs import DDGS # type: ignore
28
+
29
+ results: list[SearchResult] = []
30
+
31
+ with DDGS() as ddgs:
32
+ for i, r in enumerate(ddgs.text(query, max_results=max_results)):
33
+ results.append(
34
+ SearchResult(
35
+ title=r.get("title", ""),
36
+ url=r.get("href", ""),
37
+ snippet=r.get("body", ""),
38
+ position=i + 1,
39
+ )
40
+ )
41
+
42
+ return results
43
+
44
+
45
+ def _format_results(results: list[SearchResult]) -> str:
46
+ """Format search results for LLM consumption."""
47
+ if not results:
48
+ return (
49
+ "No results were found for your search query. "
50
+ "Please try rephrasing your search or using different keywords."
51
+ )
52
+
53
+ lines = [f"Found {len(results)} search results:\n"]
54
+
55
+ for result in results:
56
+ lines.append(f"{result.position}. {result.title}")
57
+ lines.append(f" URL: {result.url}")
58
+ lines.append(f" Summary: {result.snippet}\n")
59
+
60
+ return "\n".join(lines)
61
+
62
+
63
+ @register(tools.WEB_SEARCH)
64
+ class WebSearchTool(ToolABC):
65
+ @classmethod
66
+ def metadata(cls) -> ToolMetadata:
67
+ return ToolMetadata(concurrency_policy=ToolConcurrencyPolicy.CONCURRENT, has_side_effects=False)
68
+
69
+ @classmethod
70
+ def schema(cls) -> llm_param.ToolSchema:
71
+ return llm_param.ToolSchema(
72
+ name=tools.WEB_SEARCH,
73
+ type="function",
74
+ description=load_desc(Path(__file__).parent / "web_search_tool.md"),
75
+ parameters={
76
+ "type": "object",
77
+ "properties": {
78
+ "query": {
79
+ "type": "string",
80
+ "description": "The search query to use",
81
+ },
82
+ "max_results": {
83
+ "type": "integer",
84
+ "description": f"Maximum number of results to return (default: {DEFAULT_MAX_RESULTS}, max: {MAX_RESULTS_LIMIT})",
85
+ },
86
+ },
87
+ "required": ["query"],
88
+ },
89
+ )
90
+
91
+ class WebSearchArguments(BaseModel):
92
+ query: str
93
+ max_results: int = DEFAULT_MAX_RESULTS
94
+
95
+ @classmethod
96
+ async def call(cls, arguments: str) -> model.ToolResultItem:
97
+ try:
98
+ args = WebSearchTool.WebSearchArguments.model_validate_json(arguments)
99
+ except ValueError as e:
100
+ return model.ToolResultItem(
101
+ status="error",
102
+ output=f"Invalid arguments: {e}",
103
+ )
104
+ return await cls.call_with_args(args)
105
+
106
+ @classmethod
107
+ async def call_with_args(cls, args: WebSearchArguments) -> model.ToolResultItem:
108
+ query = args.query.strip()
109
+ if not query:
110
+ return model.ToolResultItem(
111
+ status="error",
112
+ output="Query cannot be empty",
113
+ )
114
+
115
+ max_results = min(max(args.max_results, 1), MAX_RESULTS_LIMIT)
116
+
117
+ try:
118
+ results = await asyncio.to_thread(_search_duckduckgo, query, max_results)
119
+ formatted = _format_results(results)
120
+
121
+ return model.ToolResultItem(
122
+ status="success",
123
+ output=formatted,
124
+ )
125
+
126
+ except Exception as e:
127
+ return model.ToolResultItem(
128
+ status="error",
129
+ output=f"Search failed: {e}",
130
+ )