localcode 0.4.0__py3-none-macosx_13_0_arm64.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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
localcode/toolkit.py ADDED
@@ -0,0 +1,1241 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import fnmatch
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any, Callable
11
+
12
+ import httpx
13
+ try:
14
+ from ddgs import DDGS
15
+ except ImportError:
16
+ from duckduckgo_search import DDGS
17
+
18
+ from .config import AppConfig
19
+ from .context import IGNORE_DIRS, list_repo_files
20
+ from .indexer import build_index, search_index
21
+ # MCP tools are lazily wired in via ensure_mcp_tools() (the `mcp` module is
22
+ # imported there, on demand, so a missing/broken MCP setup never affects
23
+ # toolkit import). See ensure_mcp_tools() below.
24
+ from .shell import run_shell
25
+ from .undo import ChangeLog
26
+
27
+ ToolHandler = Callable[[dict[str, Any]], str]
28
+
29
+
30
+ @dataclass
31
+ class LocalCodeTool:
32
+ name: str
33
+ description: str
34
+ parameters: dict[str, Any]
35
+ handler: ToolHandler
36
+
37
+ def as_schema(self) -> dict[str, Any]:
38
+ return {
39
+ "type": "function",
40
+ "function": {
41
+ "name": self.name,
42
+ "description": self.description,
43
+ "parameters": self.parameters,
44
+ },
45
+ }
46
+
47
+
48
+ class LocalCodeToolkit:
49
+ # Exit codes that are NOT errors for specific commands
50
+ COMMAND_EXIT_SEMANTICS: dict[str, dict[int, str]] = {
51
+ "grep": {1: "no matches (not an error)"},
52
+ "rg": {1: "no matches (not an error)"},
53
+ "diff": {1: "files differ (not an error)"},
54
+ "test": {1: "condition false (not an error)"},
55
+ "find": {1: "no matches (not an error)"},
56
+ }
57
+
58
+ def __init__(self, repo_root: Path, config: AppConfig, app: Any = None) -> None:
59
+ self.repo_root = repo_root
60
+ self.config = config
61
+ self.app = app
62
+ self.tools: dict[str, LocalCodeTool] = {}
63
+ self.plugin_errors: list[str] = []
64
+ self.changes = ChangeLog(repo_root)
65
+ # MCP wiring was deferred out during the T0.9 purge, but close()
66
+ # and diagnostics() still reference these — initialize them so
67
+ # those methods don't AttributeError. close() runs on every
68
+ # session teardown (app.py), so the crash was guaranteed.
69
+ self.mcp_clients: dict[str, Any] = {}
70
+ self._mcp_loaded: bool = False
71
+ self.mcp_errors: list[str] = []
72
+ # File state tracking (agent pattern: pre-read validation + staleness)
73
+ self._read_state: dict[str, float] = {} # path → timestamp when last read
74
+ self._register_builtin_tools()
75
+ self._register_plugin_tools()
76
+
77
+ def _register(self, tool: LocalCodeTool) -> None:
78
+ self.tools[tool.name] = tool
79
+
80
+ # ── Built-in tools ───────────────────────────────────────────────────
81
+
82
+ def _register_builtin_tools(self) -> None:
83
+ # -- File operations --
84
+ self._register(LocalCodeTool(
85
+ name="read_file",
86
+ description=(
87
+ "Read a file from the repository. Returns file content with line numbers. "
88
+ "Use offset and limit to read specific portions of large files."
89
+ ),
90
+ parameters={
91
+ "type": "object",
92
+ "properties": {
93
+ "path": {"type": "string", "description": "Relative path to the file"},
94
+ "offset": {"type": "integer", "description": "Line number to start from (0-based). Optional."},
95
+ "limit": {"type": "integer", "description": "Max number of lines to return. Optional, default 500."},
96
+ },
97
+ "required": ["path"],
98
+ },
99
+ handler=lambda args: self._read_file(
100
+ str(args["path"]),
101
+ offset=int(args.get("offset", 0)),
102
+ limit=int(args.get("limit", 500)),
103
+ ),
104
+ ))
105
+
106
+ self._register(LocalCodeTool(
107
+ name="write_file",
108
+ description="Create a new file or completely overwrite an existing file with new content.",
109
+ parameters={
110
+ "type": "object",
111
+ "properties": {
112
+ "path": {"type": "string", "description": "Relative path to the file"},
113
+ "content": {"type": "string", "description": "Complete file content to write"},
114
+ },
115
+ "required": ["path", "content"],
116
+ },
117
+ handler=lambda args: self._write_file(str(args["path"]), str(args["content"])),
118
+ ))
119
+
120
+ self._register(LocalCodeTool(
121
+ name="edit_file",
122
+ description=(
123
+ "Make a surgical edit to a file by replacing an exact string match. "
124
+ "Provide old_string (the exact text to find) and new_string (replacement). "
125
+ "old_string must uniquely match one location in the file. "
126
+ "For creating new files, use write_file instead."
127
+ ),
128
+ parameters={
129
+ "type": "object",
130
+ "properties": {
131
+ "path": {"type": "string", "description": "Relative path to the file"},
132
+ "old_string": {"type": "string", "description": "Exact text to find and replace (must be unique in file)"},
133
+ "new_string": {"type": "string", "description": "Replacement text"},
134
+ },
135
+ "required": ["path", "old_string", "new_string"],
136
+ },
137
+ handler=lambda args: (
138
+ self._edit_file(str(args["path"]), str(args["old_string"]), str(args["new_string"]))
139
+ if "old_string" in args and "new_string" in args
140
+ else f"Error: edit_file requires 'path', 'old_string', and 'new_string'. You provided: {list(args.keys())}. Use read_file first to see the exact text, then provide old_string (exact text to find) and new_string (replacement)."
141
+ ),
142
+ ))
143
+
144
+ self._register(LocalCodeTool(
145
+ name="multi_edit",
146
+ description=(
147
+ "Apply multiple edits to a single file atomically. Each edit is an {old_string, new_string} pair. "
148
+ "All old_strings must be unique. Edits are applied in order."
149
+ ),
150
+ parameters={
151
+ "type": "object",
152
+ "properties": {
153
+ "path": {"type": "string", "description": "Relative path to the file"},
154
+ "edits": {
155
+ "type": "array",
156
+ "items": {
157
+ "type": "object",
158
+ "properties": {
159
+ "old_string": {"type": "string"},
160
+ "new_string": {"type": "string"},
161
+ },
162
+ "required": ["old_string", "new_string"],
163
+ },
164
+ "description": "List of {old_string, new_string} pairs to apply",
165
+ },
166
+ },
167
+ "required": ["path", "edits"],
168
+ },
169
+ handler=lambda args: self._multi_edit(str(args["path"]), args["edits"]),
170
+ ))
171
+
172
+ # -- Search and navigation --
173
+ self._register(LocalCodeTool(
174
+ name="grep",
175
+ description=(
176
+ "Search file contents using regex. Returns matching lines with file paths and line numbers. "
177
+ "Fast local search across the entire repository or a specific path."
178
+ ),
179
+ parameters={
180
+ "type": "object",
181
+ "properties": {
182
+ "pattern": {"type": "string", "description": "Regex pattern to search for"},
183
+ "path": {"type": "string", "description": "File or directory to search in (relative). Default: repo root."},
184
+ "include": {"type": "string", "description": "Glob pattern to filter files, e.g. '*.py', '*.ts'. Optional."},
185
+ "max_results": {"type": "integer", "description": "Max matches to return. Default 50."},
186
+ "case_insensitive": {"type": "boolean", "description": "Case-insensitive search. Default false."},
187
+ },
188
+ "required": ["pattern"],
189
+ },
190
+ handler=lambda args: self._grep(
191
+ str(args["pattern"]),
192
+ path=str(args.get("path", "")),
193
+ include=str(args.get("include", "")),
194
+ max_results=int(args.get("max_results", 50)),
195
+ case_insensitive=bool(args.get("case_insensitive", False)),
196
+ ),
197
+ ))
198
+
199
+ self._register(LocalCodeTool(
200
+ name="glob",
201
+ description=(
202
+ "Find files matching a glob pattern. Returns file paths sorted by modification time. "
203
+ "Supports patterns like '**/*.py', 'src/**/*.ts', '*.json'."
204
+ ),
205
+ parameters={
206
+ "type": "object",
207
+ "properties": {
208
+ "pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.py' or 'src/*.ts'"},
209
+ "path": {"type": "string", "description": "Directory to search in (relative). Default: repo root."},
210
+ "max_results": {"type": "integer", "description": "Max files to return. Default 100."},
211
+ },
212
+ "required": ["pattern"],
213
+ },
214
+ handler=lambda args: self._glob(
215
+ str(args["pattern"]),
216
+ path=str(args.get("path", "")),
217
+ max_results=int(args.get("max_results", 100)),
218
+ ),
219
+ ))
220
+
221
+ self._register(LocalCodeTool(
222
+ name="list_files",
223
+ description="List repository files, optionally filtered by a substring pattern.",
224
+ parameters={
225
+ "type": "object",
226
+ "properties": {
227
+ "pattern": {"type": "string", "description": "Substring to filter file paths. Optional."},
228
+ },
229
+ },
230
+ handler=lambda args: "\n".join(
231
+ list_repo_files(self.repo_root, args.get("pattern"), limit=300)
232
+ ) or "No files matched.",
233
+ ))
234
+
235
+ self._register(LocalCodeTool(
236
+ name="search_code",
237
+ description="Search the local code index for semantically relevant code chunks.",
238
+ parameters={
239
+ "type": "object",
240
+ "properties": {"query": {"type": "string"}},
241
+ "required": ["query"],
242
+ },
243
+ handler=lambda args: self._search_code(str(args["query"])),
244
+ ))
245
+
246
+ # -- Shell execution --
247
+ self._register(LocalCodeTool(
248
+ name="bash",
249
+ description=(
250
+ "Execute a shell command and return its output. "
251
+ "Use for running tests, builds, git commands, installing packages, etc. "
252
+ "Commands run in the repository root with a 120s timeout. "
253
+ "Prefer dedicated tools (read_file, edit_file, grep) over shell equivalents."
254
+ ),
255
+ parameters={
256
+ "type": "object",
257
+ "properties": {
258
+ "command": {"type": "string", "description": "The shell command to execute"},
259
+ "timeout": {"type": "integer", "description": "Timeout in seconds. Default 120."},
260
+ },
261
+ "required": ["command"],
262
+ },
263
+ handler=lambda args: self._bash(
264
+ str(args["command"]),
265
+ timeout=int(args.get("timeout", 120)),
266
+ ),
267
+ ))
268
+
269
+ # -- Tool search (deferred tool loading, like agent) --
270
+ self._register(LocalCodeTool(
271
+ name="tool_search",
272
+ description=(
273
+ "Search for additional tools not in your current set. "
274
+ "Use when you need a capability not listed in your tools. "
275
+ f"Available deferred tools: {', '.join(sorted(set(self.tools.keys()) - self.CORE_TOOLS))}"
276
+ ),
277
+ parameters={
278
+ "type": "object",
279
+ "properties": {
280
+ "query": {"type": "string", "description": "Tool name or keyword to search for"},
281
+ },
282
+ "required": ["query"],
283
+ },
284
+ handler=lambda args: self._tool_search(str(args["query"])),
285
+ ))
286
+
287
+ # -- Security review --
288
+ self._register(LocalCodeTool(
289
+ name="security_scan",
290
+ description=(
291
+ "Scan code for security vulnerabilities: hardcoded secrets, SQL injection, "
292
+ "command injection, XSS, insecure crypto, exposed credentials."
293
+ ),
294
+ parameters={
295
+ "type": "object",
296
+ "properties": {
297
+ "path": {"type": "string", "description": "File or directory to scan. Default: whole repo."},
298
+ },
299
+ },
300
+ handler=lambda args: self._security_scan(str(args.get("path", ""))),
301
+ ))
302
+
303
+ # Sub-agent delegation tool removed — the handler was a stub that
304
+ # only returned "delegation has been removed", so exposing it just
305
+ # spent context tokens on a tool the model could never use.
306
+
307
+ # -- Codemod (regex replace across files) --
308
+ self._register(LocalCodeTool(
309
+ name="codemod",
310
+ description=(
311
+ "Find and replace a regex pattern across all matching files. "
312
+ "Returns a preview of changes. Useful for renames and refactoring."
313
+ ),
314
+ parameters={
315
+ "type": "object",
316
+ "properties": {
317
+ "pattern": {"type": "string", "description": "Regex pattern to find"},
318
+ "replacement": {"type": "string", "description": "Replacement string ($1 for groups)"},
319
+ "include": {"type": "string", "description": "Glob to filter files, e.g. '*.py'. Default: all files."},
320
+ },
321
+ "required": ["pattern", "replacement"],
322
+ },
323
+ handler=lambda args: self._codemod(
324
+ str(args["pattern"]),
325
+ str(args["replacement"]),
326
+ include=str(args.get("include", "")),
327
+ ),
328
+ ))
329
+
330
+ # -- Test runner --
331
+ self._register(LocalCodeTool(
332
+ name="run_tests",
333
+ description=(
334
+ "Detect and run the project's test suite. Returns pass/fail results. "
335
+ "Auto-detects pytest, npm test, cargo test, etc."
336
+ ),
337
+ parameters={
338
+ "type": "object",
339
+ "properties": {
340
+ "command": {"type": "string", "description": "Override test command. Leave empty to auto-detect."},
341
+ },
342
+ },
343
+ handler=lambda args: self._run_tests(command=str(args.get("command", ""))),
344
+ ))
345
+
346
+ # -- REPL (execute code snippets) --
347
+ self._register(LocalCodeTool(
348
+ name="repl",
349
+ description=(
350
+ "Execute a code snippet and return the output. "
351
+ "Supports python and shell. Use to test ideas without creating files."
352
+ ),
353
+ parameters={
354
+ "type": "object",
355
+ "properties": {
356
+ "code": {"type": "string", "description": "Code to execute"},
357
+ "language": {"type": "string", "description": "python or shell. Default python."},
358
+ },
359
+ "required": ["code"],
360
+ },
361
+ handler=lambda args: self._repl(
362
+ str(args["code"]),
363
+ lang=str(args.get("language", "python")),
364
+ ),
365
+ ))
366
+
367
+ # -- System info --
368
+ self._register(LocalCodeTool(
369
+ name="current_datetime",
370
+ description="Get the current date and time. Use this for any time/date questions.",
371
+ parameters={"type": "object", "properties": {}},
372
+ handler=lambda args: self._current_datetime(),
373
+ ))
374
+
375
+ # -- Git operations --
376
+ self._register(LocalCodeTool(
377
+ name="git_status",
378
+ description="Show the current git status (modified, staged, untracked files).",
379
+ parameters={"type": "object", "properties": {}},
380
+ handler=lambda args: self._git_command(["git", "status", "--short"]),
381
+ ))
382
+
383
+ self._register(LocalCodeTool(
384
+ name="git_diff",
385
+ description="Show the current working tree diff, or diff for specific files.",
386
+ parameters={
387
+ "type": "object",
388
+ "properties": {
389
+ "path": {"type": "string", "description": "Specific file path to diff. Optional."},
390
+ "staged": {"type": "boolean", "description": "Show staged changes. Default false."},
391
+ },
392
+ },
393
+ handler=lambda args: self._git_diff_tool(
394
+ path=str(args.get("path", "")),
395
+ staged=bool(args.get("staged", False)),
396
+ ),
397
+ ))
398
+
399
+ self._register(LocalCodeTool(
400
+ name="git_log",
401
+ description="Show recent git commit history.",
402
+ parameters={
403
+ "type": "object",
404
+ "properties": {
405
+ "count": {"type": "integer", "description": "Number of commits. Default 20."},
406
+ "path": {"type": "string", "description": "Show history for a specific file. Optional."},
407
+ "oneline": {"type": "boolean", "description": "Compact one-line format. Default true."},
408
+ },
409
+ },
410
+ handler=lambda args: self._git_log(
411
+ count=int(args.get("count", 20)),
412
+ path=str(args.get("path", "")),
413
+ oneline=bool(args.get("oneline", True)),
414
+ ),
415
+ ))
416
+
417
+ self._register(LocalCodeTool(
418
+ name="git_commit",
419
+ description="Stage files and create a git commit.",
420
+ parameters={
421
+ "type": "object",
422
+ "properties": {
423
+ "message": {"type": "string", "description": "Commit message"},
424
+ "files": {
425
+ "type": "array",
426
+ "items": {"type": "string"},
427
+ "description": "Files to stage. If empty, commits currently staged files.",
428
+ },
429
+ },
430
+ "required": ["message"],
431
+ },
432
+ handler=lambda args: self._git_commit(
433
+ str(args["message"]),
434
+ files=list(args.get("files", [])),
435
+ ),
436
+ ))
437
+
438
+ # -- Web search --
439
+ self._register(LocalCodeTool(
440
+ name="web_search",
441
+ description="Search the web for current information. Returns titles, snippets, and URLs.",
442
+ parameters={
443
+ "type": "object",
444
+ "properties": {
445
+ "query": {"type": "string", "description": "Search query"},
446
+ "max_results": {"type": "integer", "description": "Max results. Default 5."},
447
+ },
448
+ "required": ["query"],
449
+ },
450
+ handler=lambda args: self._web_search(
451
+ str(args["query"]),
452
+ max_results=int(args.get("max_results", 5)),
453
+ ),
454
+ ))
455
+
456
+ self._register(LocalCodeTool(
457
+ name="web_fetch",
458
+ description="Fetch the text content of a web page URL. Useful for reading documentation.",
459
+ parameters={
460
+ "type": "object",
461
+ "properties": {
462
+ "url": {"type": "string", "description": "URL to fetch"},
463
+ "max_chars": {"type": "integer", "description": "Max chars to return. Default 12000."},
464
+ },
465
+ "required": ["url"],
466
+ },
467
+ handler=lambda args: self._web_fetch(
468
+ str(args["url"]),
469
+ max_chars=int(args.get("max_chars", 12000)),
470
+ ),
471
+ ))
472
+
473
+ # ── Tool implementations ─────────────────────────────────────────────
474
+
475
+ MAX_FILE_SIZE = 1024 * 1024 # 1MB limit (agent uses 1GB, we're conservative for local)
476
+
477
+ def _read_file(self, relative_path: str, offset: int = 0, limit: int = 500) -> str:
478
+ path = (self.repo_root / relative_path).resolve()
479
+ if not path.is_file():
480
+ return f"File not found: {relative_path}"
481
+ # Size check
482
+ try:
483
+ size = path.stat().st_size
484
+ if size > self.MAX_FILE_SIZE:
485
+ return f"File too large ({size // 1024}KB). Max is {self.MAX_FILE_SIZE // 1024}KB."
486
+ except Exception:
487
+ pass
488
+ # Track that this file was read (for pre-edit validation)
489
+ import time as _time
490
+ self._read_state[relative_path] = _time.time()
491
+ try:
492
+ lines = path.read_text(errors="replace").splitlines(keepends=True)
493
+ except Exception as exc:
494
+ return f"Error reading {relative_path}: {exc}"
495
+ total = len(lines)
496
+ if offset >= total:
497
+ return f"{relative_path}: {total} lines total, offset {offset} is past end of file."
498
+ end = min(offset + limit, total)
499
+ selected = lines[offset:end]
500
+ numbered = []
501
+ for i, line in enumerate(selected, start=offset + 1):
502
+ numbered.append(f"{i:>6}\t{line.rstrip()}")
503
+ result = "\n".join(numbered)
504
+ header = f"{relative_path} ({total} lines)"
505
+ if offset > 0 or end < total:
506
+ header += f" [showing lines {offset + 1}-{end}]"
507
+ return f"{header}\n{result}"
508
+
509
+ def _write_file(self, relative_path: str, content: str) -> str:
510
+ path = (self.repo_root / relative_path).resolve()
511
+ if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
512
+ return "Error: refusing to write outside the repo root"
513
+ self.changes.snapshot_before(relative_path, "write_file")
514
+ before = path.read_text(errors="replace") if path.exists() else ""
515
+ path.parent.mkdir(parents=True, exist_ok=True)
516
+ path.write_text(content)
517
+ if not before:
518
+ return f"Created {relative_path} ({len(content)} chars)"
519
+ diff = "\n".join(
520
+ difflib.unified_diff(
521
+ before.splitlines(), content.splitlines(),
522
+ fromfile=f"a/{relative_path}", tofile=f"b/{relative_path}", lineterm="",
523
+ )
524
+ )
525
+ return f"Wrote {relative_path}\n\n{diff[:4000] or '(file overwritten)'}"
526
+
527
+ @staticmethod
528
+ def _normalize_quotes(s: str) -> str:
529
+ """Normalize curly/smart quotes to straight quotes."""
530
+ return s.replace("\u2018", "'").replace("\u2019", "'").replace("\u201c", '"').replace("\u201d", '"')
531
+
532
+ def _edit_file(self, relative_path: str, old_string: str, new_string: str) -> str:
533
+ path = (self.repo_root / relative_path).resolve()
534
+ if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
535
+ return "Error: refusing to edit outside the repo root"
536
+
537
+ # Pre-read validation: model must read the file before editing
538
+ if relative_path not in self._read_state:
539
+ return f"Error: you must read_file('{relative_path}') before editing it. Read first, then edit."
540
+
541
+ # Staleness check: file changed since last read?
542
+ if path.is_file():
543
+ current_mtime = path.stat().st_mtime
544
+ read_time = self._read_state.get(relative_path, 0)
545
+ if current_mtime > read_time + 1: # 1s tolerance
546
+ self._read_state.pop(relative_path, None)
547
+ return f"Error: {relative_path} has been modified since you read it. Read it again first."
548
+
549
+ self.changes.snapshot_before(relative_path, "edit_file")
550
+ if not path.is_file():
551
+ return f"File not found: {relative_path}"
552
+ text = path.read_text(errors="replace")
553
+ count = text.count(old_string)
554
+
555
+ # Quote normalization fallback: try with normalized quotes
556
+ if count == 0:
557
+ normalized_old = self._normalize_quotes(old_string)
558
+ normalized_text = self._normalize_quotes(text)
559
+ if normalized_text.count(normalized_old) == 1:
560
+ # Find the actual substring in the original text
561
+ idx = normalized_text.index(normalized_old)
562
+ actual_old = text[idx:idx + len(old_string)]
563
+ old_string = actual_old
564
+ count = 1
565
+
566
+ if count == 0:
567
+ lines = text.splitlines()
568
+ first_line = old_string.split("\n")[0].strip() if old_string else ""
569
+ nearby = []
570
+ for i, line in enumerate(lines):
571
+ if first_line and first_line in line:
572
+ start = max(0, i - 2)
573
+ end = min(len(lines), i + 5)
574
+ nearby = [f"{j + 1:>6}\t{lines[j]}" for j in range(start, end)]
575
+ break
576
+ hint = ""
577
+ if nearby:
578
+ hint = "\n\nNearest match context:\n" + "\n".join(nearby)
579
+ return f"Error: old_string not found in {relative_path}{hint}"
580
+ if count > 1:
581
+ return f"Error: old_string matches {count} locations in {relative_path}. Provide more context to make it unique."
582
+ updated = text.replace(old_string, new_string, 1)
583
+ path.write_text(updated)
584
+ # Show a compact diff of what changed
585
+ old_lines = old_string.splitlines()
586
+ new_lines = new_string.splitlines()
587
+ diff = "\n".join(difflib.unified_diff(
588
+ old_lines, new_lines,
589
+ fromfile=f"a/{relative_path}", tofile=f"b/{relative_path}", lineterm="",
590
+ ))
591
+ return f"Edited {relative_path}\n{diff[:3000]}"
592
+
593
+ def _multi_edit(self, relative_path: str, edits: list[dict[str, str]]) -> str:
594
+ path = (self.repo_root / relative_path).resolve()
595
+ if self.repo_root.resolve() not in path.parents and path != self.repo_root.resolve():
596
+ return "Error: refusing to edit outside the repo root"
597
+ self.changes.snapshot_before(relative_path, "multi_edit")
598
+ if not path.is_file():
599
+ return f"File not found: {relative_path}"
600
+ text = path.read_text(errors="replace")
601
+ original = text
602
+ results = []
603
+ for i, edit in enumerate(edits):
604
+ old = str(edit.get("old_string", ""))
605
+ new = str(edit.get("new_string", ""))
606
+ count = text.count(old)
607
+ if count == 0:
608
+ results.append(f" edit {i + 1}: old_string not found, skipped")
609
+ continue
610
+ if count > 1:
611
+ results.append(f" edit {i + 1}: old_string matches {count} locations, skipped")
612
+ continue
613
+ text = text.replace(old, new, 1)
614
+ results.append(f" edit {i + 1}: applied")
615
+ if text != original:
616
+ path.write_text(text)
617
+ applied = sum(1 for r in results if "applied" in r)
618
+ return f"Multi-edit {relative_path}: {applied}/{len(edits)} applied\n" + "\n".join(results)
619
+
620
+ def _grep(
621
+ self,
622
+ pattern: str,
623
+ path: str = "",
624
+ include: str = "",
625
+ max_results: int = 50,
626
+ case_insensitive: bool = False,
627
+ ) -> str:
628
+ search_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
629
+ if not search_root.exists():
630
+ return f"Path not found: {path}"
631
+
632
+ flags = re.IGNORECASE if case_insensitive else 0
633
+ try:
634
+ regex = re.compile(pattern, flags)
635
+ except re.error as exc:
636
+ return f"Invalid regex: {exc}"
637
+
638
+ matches: list[str] = []
639
+ files_searched = 0
640
+
641
+ def _search_file(file_path: Path) -> None:
642
+ nonlocal files_searched
643
+ if include and not fnmatch.fnmatch(file_path.name, include):
644
+ return
645
+ try:
646
+ content = file_path.read_text(errors="replace")
647
+ except Exception:
648
+ return
649
+ files_searched += 1
650
+ rel = str(file_path.relative_to(self.repo_root))
651
+ for line_num, line in enumerate(content.splitlines(), 1):
652
+ if regex.search(line):
653
+ matches.append(f"{rel}:{line_num}: {line.rstrip()[:200]}")
654
+ if len(matches) >= max_results:
655
+ return
656
+
657
+ if search_root.is_file():
658
+ _search_file(search_root)
659
+ else:
660
+ for file_path in search_root.rglob("*"):
661
+ if not file_path.is_file():
662
+ continue
663
+ if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
664
+ continue
665
+ # skip binary files by extension
666
+ if file_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar", ".gz", ".bin", ".exe", ".dll", ".so", ".pyc", ".pyo"}:
667
+ continue
668
+ _search_file(file_path)
669
+ if len(matches) >= max_results:
670
+ break
671
+
672
+ if not matches:
673
+ return f"No matches for /{pattern}/ in {files_searched} files."
674
+ header = f"{len(matches)} matches in {files_searched} files"
675
+ if len(matches) >= max_results:
676
+ header += f" (limited to {max_results})"
677
+ return f"{header}\n" + "\n".join(matches)
678
+
679
+ def _glob(self, pattern: str, path: str = "", max_results: int = 100) -> str:
680
+ search_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
681
+ if not search_root.exists():
682
+ return f"Path not found: {path}"
683
+
684
+ results: list[tuple[float, str]] = []
685
+ for file_path in search_root.rglob(pattern):
686
+ if not file_path.is_file():
687
+ continue
688
+ if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
689
+ continue
690
+ try:
691
+ mtime = file_path.stat().st_mtime
692
+ except Exception:
693
+ mtime = 0
694
+ results.append((mtime, str(file_path.relative_to(self.repo_root))))
695
+ if len(results) >= max_results * 2: # collect extra for sorting
696
+ break
697
+
698
+ results.sort(key=lambda x: x[0], reverse=True)
699
+ paths = [r[1] for r in results[:max_results]]
700
+
701
+ if not paths:
702
+ return f"No files matching '{pattern}'"
703
+ header = f"{len(paths)} files"
704
+ if len(results) > max_results:
705
+ header += f" (showing {max_results} most recent)"
706
+ return f"{header}\n" + "\n".join(paths)
707
+
708
+ def _bash(self, command: str, timeout: int = 120) -> str:
709
+ # Safety: block obviously destructive commands
710
+ stripped = command.strip().lower()
711
+ blocked = ["rm -rf /", "rm -rf /*", "mkfs", "dd if=/dev", ":(){:|:&};:"]
712
+ for b in blocked:
713
+ if stripped.startswith(b):
714
+ return f"Blocked: dangerous command '{command}'"
715
+
716
+ result = run_shell(command, str(self.repo_root), timeout=min(timeout, 300))
717
+ output = result.output
718
+ if len(output) > 50000:
719
+ output = output[:25000] + "\n...[middle truncated]...\n" + output[-25000:]
720
+ # Exit code semantics: some non-zero codes aren't errors
721
+ cmd_name = command.split()[0] if command.split() else ""
722
+ semantics = self.COMMAND_EXIT_SEMANTICS.get(cmd_name, {})
723
+ if result.returncode in semantics:
724
+ status = f"exit code: {result.returncode} ({semantics[result.returncode]})"
725
+ elif result.returncode == 0:
726
+ status = "exit code: 0 (success)"
727
+ elif result.timed_out:
728
+ status = f"exit code: {result.returncode} (timed out)"
729
+ else:
730
+ status = f"exit code: {result.returncode} (error)"
731
+ return f"$ {command}\n{output}\n[{status}]"
732
+
733
+ def _git_command(self, cmd: list[str], max_chars: int = 20000) -> str:
734
+ try:
735
+ result = subprocess.run(
736
+ cmd, cwd=self.repo_root,
737
+ capture_output=True, text=True, check=False, timeout=30,
738
+ )
739
+ output = (result.stdout + result.stderr).strip()
740
+ if len(output) > max_chars:
741
+ output = output[:max_chars] + "\n...[truncated]"
742
+ return output or "(no output)"
743
+ except subprocess.TimeoutExpired:
744
+ return "Git command timed out"
745
+ except Exception as exc:
746
+ return f"Git error: {exc}"
747
+
748
+ def _git_diff_tool(self, path: str = "", staged: bool = False) -> str:
749
+ cmd = ["git", "diff"]
750
+ if staged:
751
+ cmd.append("--cached")
752
+ cmd.append("--")
753
+ if path:
754
+ cmd.append(path)
755
+ else:
756
+ cmd.append(".")
757
+ return self._git_command(cmd)
758
+
759
+ def _git_log(self, count: int = 20, path: str = "", oneline: bool = True) -> str:
760
+ cmd = ["git", "log", f"-{min(count, 100)}"]
761
+ if oneline:
762
+ cmd.append("--oneline")
763
+ else:
764
+ cmd.extend(["--format=%h %an %ad %s", "--date=short"])
765
+ if path:
766
+ cmd.extend(["--", path])
767
+ return self._git_command(cmd)
768
+
769
+ def _git_commit(self, message: str, files: list[str] | None = None) -> str:
770
+ if files:
771
+ for f in files:
772
+ add_result = subprocess.run(
773
+ ["git", "add", f], cwd=self.repo_root,
774
+ capture_output=True, text=True, check=False, timeout=15,
775
+ )
776
+ if add_result.returncode != 0:
777
+ return f"git add failed for {f}: {add_result.stderr.strip()}"
778
+
779
+ result = subprocess.run(
780
+ ["git", "commit", "-m", message],
781
+ cwd=self.repo_root,
782
+ capture_output=True, text=True, check=False, timeout=30,
783
+ )
784
+ output = (result.stdout + result.stderr).strip()
785
+ if result.returncode != 0:
786
+ return f"Commit failed: {output}"
787
+ return f"Committed: {output}"
788
+
789
+ def _tool_search(self, query: str) -> str:
790
+ """Search for deferred tools and return their schemas."""
791
+ query_lower = query.lower()
792
+ matches = []
793
+ for name, tool in self.tools.items():
794
+ if query_lower in name.lower() or query_lower in tool.description.lower():
795
+ matches.append(f" {name}: {tool.description[:100]}")
796
+ if not matches:
797
+ return f"No tools matching '{query}'. Available: {', '.join(sorted(self.tools.keys()))}"
798
+ # Add matched tools to the active set for this session
799
+ return f"Found {len(matches)} matching tools:\n" + "\n".join(matches[:10])
800
+
801
+ def _security_scan(self, path: str = "") -> str:
802
+ """Scan for common security vulnerabilities using regex patterns."""
803
+ import re as _re
804
+ PATTERNS = [
805
+ ("Hardcoded secret/key", r'(?:api[_-]?key|secret|password|token|credential)\s*[=:]\s*["\'][^"\']{8,}["\']'),
806
+ ("SQL injection risk", r'(?:execute|cursor\.execute|query)\s*\(\s*[f"\'].*\{'),
807
+ ("Command injection", r'(?:os\.system|subprocess\.call|subprocess\.run)\s*\(\s*[f"\']'),
808
+ ("Hardcoded password", r'(?:password|passwd|pwd)\s*=\s*["\'][^"\']+["\']'),
809
+ ("Insecure HTTP", r'http://(?!localhost|127\.0\.0\.1)'),
810
+ ("Eval usage", r'\beval\s*\('),
811
+ ("Pickle load", r'pickle\.loads?\('),
812
+ (".env file reference", r'\.env\b'),
813
+ ("Private key", r'-----BEGIN (?:RSA |EC )?PRIVATE KEY-----'),
814
+ ("AWS key pattern", r'AKIA[0-9A-Z]{16}'),
815
+ ]
816
+ scan_root = (self.repo_root / path).resolve() if path else self.repo_root.resolve()
817
+ findings: list[str] = []
818
+ files_scanned = 0
819
+
820
+ for file_path in scan_root.rglob("*"):
821
+ if not file_path.is_file():
822
+ continue
823
+ if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
824
+ continue
825
+ if file_path.suffix.lower() in {".png", ".jpg", ".zip", ".bin", ".pyc", ".woff"}:
826
+ continue
827
+ try:
828
+ content = file_path.read_text(errors="replace")
829
+ except Exception:
830
+ continue
831
+ files_scanned += 1
832
+ rel = str(file_path.relative_to(self.repo_root))
833
+ for label, pattern in PATTERNS:
834
+ for match in _re.finditer(pattern, content, _re.IGNORECASE):
835
+ line_num = content[:match.start()].count("\n") + 1
836
+ snippet = match.group(0)[:60]
837
+ findings.append(f" [{label}] {rel}:{line_num} — {snippet}")
838
+ if len(findings) >= 50:
839
+ break
840
+ if len(findings) >= 50:
841
+ break
842
+
843
+ if not findings:
844
+ return f"No security issues found in {files_scanned} files."
845
+ return f"Found {len(findings)} potential issues in {files_scanned} files:\n" + "\n".join(findings)
846
+
847
+ def _codemod(self, pattern: str, replacement: str, include: str = "") -> str:
848
+ """Regex find-and-replace across all matching files."""
849
+ import re as _re
850
+ try:
851
+ regex = _re.compile(pattern)
852
+ except _re.error as exc:
853
+ return f"Invalid regex: {exc}"
854
+
855
+ modified_files: list[str] = []
856
+ total_replacements = 0
857
+ preview_lines: list[str] = []
858
+
859
+ for file_path in self.repo_root.rglob("*"):
860
+ if not file_path.is_file():
861
+ continue
862
+ if any(part in IGNORE_DIRS for part in file_path.relative_to(self.repo_root).parts):
863
+ continue
864
+ if file_path.suffix.lower() in {".png", ".jpg", ".gif", ".zip", ".bin", ".pyc"}:
865
+ continue
866
+ if include and not fnmatch.fnmatch(file_path.name, include):
867
+ continue
868
+ try:
869
+ content = file_path.read_text(errors="replace")
870
+ except Exception:
871
+ continue
872
+ new_content, count = regex.subn(replacement, content)
873
+ if count > 0:
874
+ rel = str(file_path.relative_to(self.repo_root))
875
+ self.changes.snapshot_before(rel, "codemod")
876
+ file_path.write_text(new_content)
877
+ modified_files.append(rel)
878
+ total_replacements += count
879
+ preview_lines.append(f" {rel}: {count} replacement(s)")
880
+
881
+ if not modified_files:
882
+ return f"No matches for /{pattern}/ in {'*' if not include else include}"
883
+ header = f"Modified {len(modified_files)} file(s), {total_replacements} total replacements"
884
+ return f"{header}\n" + "\n".join(preview_lines[:20])
885
+
886
+ def _run_tests(self, command: str = "") -> str:
887
+ """Run the project's test suite."""
888
+ from .verification import run_verification
889
+ output, code = run_verification(self.repo_root, command=command or None)
890
+ status = "PASSED" if code == 0 else "FAILED"
891
+ return f"Tests {status} (exit {code})\n{output}"
892
+
893
+ def _repl(self, code: str, lang: str = "python") -> str:
894
+ """Execute a code snippet in a subprocess."""
895
+ import tempfile
896
+ if lang in ("shell", "bash", "sh"):
897
+ result = run_shell(code, str(self.repo_root), timeout=30)
898
+ return f"$ {code}\n{result.output}\n[exit {result.returncode}]"
899
+ # Python
900
+ tmp = tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False, dir=str(self.repo_root))
901
+ try:
902
+ tmp.write(code)
903
+ tmp.close()
904
+ result = run_shell(f"python3 {tmp.name}", str(self.repo_root), timeout=30)
905
+ output = result.output
906
+ if len(output) > 5000:
907
+ output = output[:2500] + "\n...[truncated]...\n" + output[-2500:]
908
+ return f"{output}\n[exit {result.returncode}]"
909
+ finally:
910
+ import os
911
+ try:
912
+ os.unlink(tmp.name)
913
+ except Exception:
914
+ pass
915
+
916
+ def _current_datetime(self) -> str:
917
+ import datetime
918
+ now = datetime.datetime.now()
919
+ utc = datetime.datetime.now(datetime.timezone.utc)
920
+ try:
921
+ tz_name = datetime.datetime.now().astimezone().tzname()
922
+ except Exception:
923
+ tz_name = "local"
924
+ return (
925
+ f"Current local time: {now.strftime('%Y-%m-%d %H:%M:%S')} ({tz_name})\n"
926
+ f"UTC time: {utc.strftime('%Y-%m-%d %H:%M:%S')} UTC\n"
927
+ f"Day: {now.strftime('%A')}"
928
+ )
929
+
930
+ def _search_code(self, query: str) -> str:
931
+ results = search_index(self.repo_root, query)
932
+ if not results:
933
+ count, path = build_index(self.repo_root)
934
+ results = search_index(self.repo_root, query)
935
+ if not results:
936
+ return f"No code index matches. Built index for {count} files."
937
+ lines = [f"{item['path']}#chunk{item['chunk_id']}\n{item['preview']}" for item in results]
938
+ return "\n\n".join(lines)
939
+
940
+ # ── Web search implementations ───────────────────────────────────────
941
+
942
+ def _web_search(self, query: str, max_results: int = 5) -> str:
943
+ # Try DuckDuckGo, fall back to scraping if needed.
944
+ result = self._duckduckgo_search(query, max_results)
945
+ if result == "No results found." or "Search error" in result:
946
+ # Fallback: try bash curl to get a quick answer
947
+ try:
948
+ from .shell import run_shell
949
+ sr = run_shell(f'curl -s "https://lite.duckduckgo.com/lite/?q={query.replace(" ", "+")}" 2>&1 | head -100', str(self.repo_root), timeout=10)
950
+ if sr.output.strip():
951
+ return f"Web results for '{query}':\n{sr.output[:2000]}"
952
+ except Exception:
953
+ pass
954
+ return result
955
+
956
+ def _web_fetch(self, url: str, max_chars: int = 12000) -> str:
957
+ try:
958
+ response = httpx.get(
959
+ url, timeout=20.0, follow_redirects=True,
960
+ headers={"User-Agent": "LocalCode/0.2 (coding-assistant)"},
961
+ )
962
+ response.raise_for_status()
963
+ content_type = response.headers.get("content-type", "")
964
+ if "text" not in content_type and "json" not in content_type and "xml" not in content_type:
965
+ return f"Non-text content type: {content_type}"
966
+ text = response.text
967
+ # Strip HTML tags for readability
968
+ if "html" in content_type:
969
+ text = self._strip_html(text)
970
+ if len(text) > max_chars:
971
+ text = text[:max_chars] + "\n...[truncated]"
972
+ return text
973
+ except Exception as exc:
974
+ return f"Fetch failed: {exc}"
975
+
976
+ @staticmethod
977
+ def _strip_html(html: str) -> str:
978
+ # Remove script and style tags entirely
979
+ html = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
980
+ # Remove HTML tags
981
+ text = re.sub(r"<[^>]+>", " ", html)
982
+ # Collapse whitespace
983
+ text = re.sub(r"\s+", " ", text)
984
+ # Collapse multiple newlines
985
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
986
+ return "\n".join(lines)
987
+
988
+ def _duckduckgo_search(self, query: str, max_results: int = 5) -> str:
989
+ results: list[str] = []
990
+ try:
991
+ with DDGS() as ddgs:
992
+ # Try instant answer first (for time, weather, calculations, etc.)
993
+ try:
994
+ answers = list(ddgs.answers(query))
995
+ if answers:
996
+ for ans in answers[:2]:
997
+ text = ans.get("text", "")
998
+ url = ans.get("url", "")
999
+ if text:
1000
+ results.append(f"[Instant Answer] {text}\n{url}")
1001
+ except Exception:
1002
+ pass
1003
+ # Then regular search results
1004
+ for item in ddgs.text(query, max_results=max_results):
1005
+ title = item.get("title", "")
1006
+ body = item.get("body", "")
1007
+ href = item.get("href", "")
1008
+ results.append(f"{title}\n{body}\n{href}")
1009
+ except Exception as exc:
1010
+ return f"Search error: {exc}"
1011
+ return "\n\n".join(results) if results else "No results found."
1012
+
1013
+ # ── Plugin & MCP ─────────────────────────────────────────────────────
1014
+
1015
+ def _register_plugin_tools(self) -> None:
1016
+ """Plugin system removed — no-op."""
1017
+ pass
1018
+
1019
+ @staticmethod
1020
+ def _make_mcp_handler(client: Any, tool_name: str) -> ToolHandler:
1021
+ """Build a handler bound to a specific client + tool name.
1022
+
1023
+ Defined as its own method (not a lambda inside a loop) so the
1024
+ client/tool_name are captured per-tool — no late-binding bug where
1025
+ every registered MCP tool would point at the last loop iteration.
1026
+ """
1027
+ def handler(args: dict[str, Any]) -> str:
1028
+ try:
1029
+ return client.call_tool(tool_name, args or {})
1030
+ except Exception as exc: # never let a bad MCP call crash dispatch
1031
+ return f"MCP tool {tool_name!r} call failed: {exc}"
1032
+ return handler
1033
+
1034
+ def ensure_mcp_tools(self) -> None:
1035
+ """Lazily connect configured MCP servers and register their tools.
1036
+
1037
+ Runs at most once per toolkit (guarded by self._mcp_loaded). Each
1038
+ connected server's tools are registered as `mcp_<server>_<tool>`
1039
+ LocalCodeTools so the model can call them like any builtin. Fully
1040
+ defensive: a bad/missing MCP server never crashes the toolkit.
1041
+ """
1042
+ if self._mcp_loaded:
1043
+ return
1044
+ self._mcp_loaded = True # set first so we only ever attempt once
1045
+ try:
1046
+ from . import mcp as _mcp
1047
+ except Exception as exc:
1048
+ self.mcp_errors.append(f"mcp import failed: {exc}")
1049
+ return
1050
+ try:
1051
+ _count, errors = _mcp.connect_all()
1052
+ if errors:
1053
+ self.mcp_errors.extend(errors)
1054
+ for server, tools in _mcp.list_connected():
1055
+ client = _mcp.get_client(server)
1056
+ if client is None:
1057
+ continue
1058
+ self.mcp_clients[server] = client
1059
+ for t in tools:
1060
+ tool_name = (t.get("name") or "").strip()
1061
+ if not tool_name:
1062
+ continue
1063
+ schema = t.get("inputSchema") or {"type": "object", "properties": {}}
1064
+ if not isinstance(schema, dict):
1065
+ schema = {"type": "object", "properties": {}}
1066
+ description = (
1067
+ f"[MCP {server}] " + (t.get("description") or "")
1068
+ ).strip()[:1000]
1069
+ self._register(LocalCodeTool(
1070
+ name=f"mcp_{server}_{tool_name}",
1071
+ description=description,
1072
+ parameters=schema,
1073
+ handler=self._make_mcp_handler(client, tool_name),
1074
+ ))
1075
+ except Exception as exc: # connect_all/list shouldn't raise, but be safe
1076
+ self.mcp_errors.append(f"mcp wiring failed: {exc}")
1077
+
1078
+ # ── Public API ───────────────────────────────────────────────────────
1079
+
1080
+ # Core tools that small models can handle without choking
1081
+ # Core tools for small models
1082
+ CORE_TOOLS = {
1083
+ "read_file", "write_file", "edit_file",
1084
+ "bash", "web_search", "current_datetime",
1085
+ "tool_search",
1086
+ }
1087
+
1088
+ def schemas(self, compact: bool = False, minimal: bool = False) -> list[dict[str, Any]]:
1089
+ """Get tool schemas.
1090
+
1091
+ compact: only core tools
1092
+ minimal: ultra-short descriptions for small models (saves ~500 tokens)
1093
+ """
1094
+ self.ensure_mcp_tools()
1095
+ if compact:
1096
+ tools = [tool for tool in self.tools.values() if tool.name in self.CORE_TOOLS]
1097
+ else:
1098
+ tools = list(self.tools.values())
1099
+ if minimal:
1100
+ return [self._minimal_schema(tool) for tool in tools]
1101
+ return [tool.as_schema() for tool in tools]
1102
+
1103
+ @staticmethod
1104
+ def _minimal_schema(tool: LocalCodeTool) -> dict[str, Any]:
1105
+ """Ultra-compact schema — one-line description, minimal params."""
1106
+ # Strip description to first sentence
1107
+ desc = tool.description.split(".")[0].strip() + "."
1108
+ # Keep only required params
1109
+ params = tool.parameters.copy()
1110
+ props = params.get("properties", {})
1111
+ required = params.get("required", [])
1112
+ # Only include required properties
1113
+ if required:
1114
+ props = {k: {"type": v.get("type", "string")} for k, v in props.items() if k in required}
1115
+ else:
1116
+ props = {k: {"type": v.get("type", "string")} for k, v in list(props.items())[:2]}
1117
+ return {
1118
+ "type": "function",
1119
+ "function": {
1120
+ "name": tool.name,
1121
+ "description": desc,
1122
+ "parameters": {"type": "object", "properties": props, "required": required},
1123
+ },
1124
+ }
1125
+
1126
+ def list_tool_names(self) -> list[str]:
1127
+ self.ensure_mcp_tools()
1128
+ return sorted(self.tools.keys())
1129
+
1130
+ def summarize_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[str]:
1131
+ summaries: list[str] = []
1132
+ for call in tool_calls:
1133
+ function = call.get("function", {})
1134
+ name = function.get("name", "")
1135
+ raw_args = function.get("arguments", {})
1136
+ if isinstance(raw_args, str):
1137
+ args_text = raw_args
1138
+ else:
1139
+ args_text = json.dumps(raw_args)
1140
+ if len(args_text) > 180:
1141
+ args_text = args_text[:180] + "..."
1142
+ summaries.append(f"{name}({args_text})")
1143
+ return summaries
1144
+
1145
+ # Tools safe to run concurrently (read-only, no side effects)
1146
+ READ_ONLY_TOOLS = {
1147
+ "read_file", "grep", "glob", "list_files", "search_code",
1148
+ "git_status", "git_diff", "git_log", "current_datetime",
1149
+ "web_search", "web_fetch",
1150
+ }
1151
+
1152
+ def execute_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, str]]:
1153
+ """Execute tool calls — read-only tools run concurrently, writes run serially."""
1154
+ from concurrent.futures import ThreadPoolExecutor, as_completed
1155
+ self.ensure_mcp_tools()
1156
+
1157
+ if len(tool_calls) <= 1:
1158
+ return self._execute_serial(tool_calls)
1159
+
1160
+ # Partition into read-only (concurrent) and write (serial) batches
1161
+ read_calls = []
1162
+ write_calls = []
1163
+ for call in tool_calls:
1164
+ name = call.get("function", {}).get("name", "")
1165
+ if name in self.READ_ONLY_TOOLS:
1166
+ read_calls.append(call)
1167
+ else:
1168
+ write_calls.append(call)
1169
+
1170
+ outputs: list[dict[str, str]] = []
1171
+
1172
+ # Run read-only tools concurrently
1173
+ if read_calls:
1174
+ with ThreadPoolExecutor(max_workers=min(4, len(read_calls))) as pool:
1175
+ futures = {}
1176
+ for i, call in enumerate(read_calls):
1177
+ futures[pool.submit(self._execute_one, call)] = i
1178
+ results = [None] * len(read_calls)
1179
+ for future in as_completed(futures):
1180
+ idx = futures[future]
1181
+ try:
1182
+ results[idx] = future.result()
1183
+ except Exception as exc:
1184
+ results[idx] = {"role": "tool", "content": f"Tool error: {exc}"}
1185
+ # Abort siblings on error — cancel remaining futures
1186
+ for f in futures:
1187
+ f.cancel()
1188
+ break
1189
+ outputs.extend(r for r in results if r is not None)
1190
+
1191
+ # Run write tools serially
1192
+ outputs.extend(self._execute_serial(write_calls))
1193
+ return outputs
1194
+
1195
+ def _execute_one(self, call: dict[str, Any]) -> dict[str, str]:
1196
+ """Execute a single tool call with input validation."""
1197
+ function = call.get("function", {})
1198
+ # Strip whitespace from the tool name. Quantized models (Qwen 3.6
1199
+ # IQ2_M in particular) sometimes emit names with a trailing space
1200
+ # like 'list_files ' — without this strip, every such call would
1201
+ # fall through to "Unknown tool" with the trailing space visible
1202
+ # in the error, which the user kept reporting.
1203
+ name = (function.get("name", "") or "").strip()
1204
+ raw_args = function.get("arguments", {})
1205
+ args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
1206
+ tool = self.tools.get(name)
1207
+ if tool is None:
1208
+ return {"role": "tool", "content": f"Unknown tool: '{name}'. Available: {', '.join(sorted(self.tools.keys())[:8])}…"}
1209
+ # Validate required params
1210
+ required = tool.parameters.get("required", [])
1211
+ missing = [r for r in required if r not in (args or {})]
1212
+ if missing:
1213
+ return {"role": "tool", "content": f"Missing required args for {name}: {missing}"}
1214
+ try:
1215
+ content = tool.handler(args or {})
1216
+ except Exception as exc:
1217
+ content = f"Tool error for {name}: {exc}"
1218
+ return {"role": "tool", "content": content}
1219
+
1220
+ def _execute_serial(self, tool_calls: list[dict[str, Any]]) -> list[dict[str, str]]:
1221
+ """Execute tool calls one at a time."""
1222
+ return [self._execute_one(call) for call in tool_calls]
1223
+
1224
+ def close(self) -> None:
1225
+ for client in self.mcp_clients.values():
1226
+ client.close()
1227
+
1228
+ def search_status(self) -> tuple[str, str]:
1229
+ return "duckduckgo", "configured"
1230
+
1231
+ def diagnostics(self) -> list[str]:
1232
+ rows: list[str] = []
1233
+ rows.extend(f"plugin error: {item}" for item in self.plugin_errors)
1234
+ if not self._mcp_loaded:
1235
+ rows.append("mcp: deferred until needed")
1236
+ return rows
1237
+ rows.extend(f"mcp error: {item}" for item in self.mcp_errors)
1238
+ for name, client in self.mcp_clients.items():
1239
+ ok, detail = client.health()
1240
+ rows.append(f"mcp {name}: {'ok' if ok else 'bad'} ({detail})")
1241
+ return rows