bharatcode 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.
- bharatcode/__init__.py +13 -0
- bharatcode/agent.py +2088 -0
- bharatcode/commands.py +1072 -0
- bharatcode/config.py +93 -0
- bharatcode/coordinator.py +670 -0
- bharatcode/cost.py +77 -0
- bharatcode/diff.py +113 -0
- bharatcode/hooks.py +75 -0
- bharatcode/index.py +155 -0
- bharatcode/main.py +846 -0
- bharatcode/memory.py +286 -0
- bharatcode/permissions.py +99 -0
- bharatcode/project.py +179 -0
- bharatcode/session_storage.py +108 -0
- bharatcode/skills.py +1746 -0
- bharatcode/subagent.py +363 -0
- bharatcode/tools.py +1021 -0
- bharatcode/ui.py +72 -0
- bharatcode-0.1.0.dist-info/METADATA +150 -0
- bharatcode-0.1.0.dist-info/RECORD +23 -0
- bharatcode-0.1.0.dist-info/WHEEL +5 -0
- bharatcode-0.1.0.dist-info/entry_points.txt +3 -0
- bharatcode-0.1.0.dist-info/top_level.txt +1 -0
bharatcode/tools.py
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import glob as _glob
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
BLOCKED_COMMANDS = ["rm -rf /", "format c:", "DROP TABLE", "sudo rm -rf", "mkfs", ":(){:|:&};:"]
|
|
9
|
+
|
|
10
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".idea"}
|
|
11
|
+
|
|
12
|
+
def _read_text_safe(p: Path) -> str:
|
|
13
|
+
"""Read text tolerantly: UTF-8 first, then cp1252 (common on Windows),
|
|
14
|
+
finally UTF-8 with replacement so a stray byte never crashes a task."""
|
|
15
|
+
try:
|
|
16
|
+
return p.read_text(encoding="utf-8")
|
|
17
|
+
except UnicodeDecodeError:
|
|
18
|
+
try:
|
|
19
|
+
return p.read_text(encoding="cp1252")
|
|
20
|
+
except (UnicodeDecodeError, LookupError):
|
|
21
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
22
|
+
|
|
23
|
+
# ── Tool Definitions (OpenAI-compatible function calling format) ──────────────
|
|
24
|
+
|
|
25
|
+
TOOLS = [
|
|
26
|
+
{
|
|
27
|
+
"type": "function",
|
|
28
|
+
"function": {
|
|
29
|
+
"name": "bash",
|
|
30
|
+
"description": (
|
|
31
|
+
"Execute any shell command and return its output. "
|
|
32
|
+
"Use for: running tests, installing packages (pip/npm/yarn), git operations, "
|
|
33
|
+
"building projects, checking system state, creating directories. "
|
|
34
|
+
"Runs in Windows cmd.exe — use cmd syntax: chain with &&, use dir/type/mkdir/del "
|
|
35
|
+
"(NOT ls/cat/rm, NOT PowerShell cmdlets like Get-ChildItem). "
|
|
36
|
+
"For PowerShell features wrap explicitly: powershell -NoProfile -Command \"...\". "
|
|
37
|
+
"Output is capped at 8000 chars. Default timeout 60s — pass timeout=300 for "
|
|
38
|
+
"installs, builds, test suites. "
|
|
39
|
+
"SERVERS / LONG-RUNNING PROCESSES: pass run_in_background=true — returns a "
|
|
40
|
+
"process_id immediately instead of blocking. Then use process_output to read "
|
|
41
|
+
"its logs and process_kill to stop it. This is how you VERIFY a server starts. "
|
|
42
|
+
"Examples: 'pip install flask', 'git status', 'python manage.py migrate', "
|
|
43
|
+
"'npm run build', 'dir \"C:\\my project\"'"
|
|
44
|
+
),
|
|
45
|
+
"parameters": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"properties": {
|
|
48
|
+
"command": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "The shell command to execute. Use full paths for files with spaces."
|
|
51
|
+
},
|
|
52
|
+
"timeout": {
|
|
53
|
+
"type": "integer",
|
|
54
|
+
"description": "Max seconds to wait (default 60). Use 300+ for installs, builds, tests. Ignored when run_in_background=true."
|
|
55
|
+
},
|
|
56
|
+
"run_in_background": {
|
|
57
|
+
"type": "boolean",
|
|
58
|
+
"description": "true = start the process in the background and return a process_id immediately. REQUIRED for servers (python app.py, npm run dev, uvicorn, vite) — they never exit, so a foreground call would hang."
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"required": ["command"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "function",
|
|
67
|
+
"function": {
|
|
68
|
+
"name": "process_output",
|
|
69
|
+
"description": (
|
|
70
|
+
"Read the accumulated output of a background process started with "
|
|
71
|
+
"bash(run_in_background=true). Shows RUNNING/EXITED status plus the last "
|
|
72
|
+
"lines of stdout+stderr. Use wait_seconds=4 after starting a server to give "
|
|
73
|
+
"it time to boot before checking for errors/tracebacks."
|
|
74
|
+
),
|
|
75
|
+
"parameters": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"process_id": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "The process_id returned by bash(run_in_background=true), e.g. 'proc-1'"
|
|
81
|
+
},
|
|
82
|
+
"wait_seconds": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"description": "Seconds to wait before reading (max 30). Use 3-5 for server boot."
|
|
85
|
+
},
|
|
86
|
+
"tail_lines": {
|
|
87
|
+
"type": "integer",
|
|
88
|
+
"description": "How many trailing output lines to return (default 60)."
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"required": ["process_id"]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"type": "function",
|
|
97
|
+
"function": {
|
|
98
|
+
"name": "process_kill",
|
|
99
|
+
"description": (
|
|
100
|
+
"Stop a background process (and its child processes) started with "
|
|
101
|
+
"bash(run_in_background=true). ALWAYS call this on every server you started "
|
|
102
|
+
"once verification is done — never leave processes running."
|
|
103
|
+
),
|
|
104
|
+
"parameters": {
|
|
105
|
+
"type": "object",
|
|
106
|
+
"properties": {
|
|
107
|
+
"process_id": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"description": "The process_id to stop, e.g. 'proc-1'"
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"required": ["process_id"]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"type": "function",
|
|
118
|
+
"function": {
|
|
119
|
+
"name": "read_file",
|
|
120
|
+
"description": (
|
|
121
|
+
"Read a file's contents with line numbers. Returns up to 2000 lines per call; "
|
|
122
|
+
"the header shows the total line count and a notice tells you when the file "
|
|
123
|
+
"continues — call again with offset= to read the rest. Most files fit in one call. "
|
|
124
|
+
"RULES: "
|
|
125
|
+
"(1) 'path' must be a full file path like 'C:/project/src/app.py' — never a directory, never '.', never a bare number. "
|
|
126
|
+
"(2) Repeat reads are served instantly from the session cache — re-read whenever you are "
|
|
127
|
+
"not 100% sure of a file's CURRENT exact content (e.g. right before edit_file). "
|
|
128
|
+
"(3) You MUST read a file before you can edit_file it — edits on unread files are blocked. "
|
|
129
|
+
"(4) Check saved memories first — if the file's contents are already described there, skip reading it. "
|
|
130
|
+
"Use offset/limit for targeted ranges (e.g. grep gave you a line number)."
|
|
131
|
+
),
|
|
132
|
+
"parameters": {
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"path": {
|
|
136
|
+
"type": "string",
|
|
137
|
+
"description": "Absolute file path, e.g. 'C:/chhelu 1/analysis/site3_report.html' or 'C:/project/backend/app.py'"
|
|
138
|
+
},
|
|
139
|
+
"offset": {
|
|
140
|
+
"type": "integer",
|
|
141
|
+
"description": "Line number to start from (0-indexed, default 0 = start of file). NOT a file path."
|
|
142
|
+
},
|
|
143
|
+
"limit": {
|
|
144
|
+
"type": "integer",
|
|
145
|
+
"description": "Max lines to return (default 2000). Use with offset for a specific range, e.g. offset=190, limit=40."
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
"required": ["path"]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"type": "function",
|
|
154
|
+
"function": {
|
|
155
|
+
"name": "write_file",
|
|
156
|
+
"description": (
|
|
157
|
+
"Write content to a file (create or overwrite). Use for small-to-medium files (<150 lines). "
|
|
158
|
+
"WARNING: For large files (HTML dashboards, full reports, >150 lines), do NOT use this tool — "
|
|
159
|
+
"use the <<<FILE:path>>> marker in your response text instead (avoids JSON truncation). "
|
|
160
|
+
"RULES: "
|
|
161
|
+
"(1) 'path' must be the full file path WITH filename — e.g. 'C:/project/app.py'. Never '.', never a directory. "
|
|
162
|
+
"(2) mode='w' creates/overwrites (default). mode='a' appends to existing file. "
|
|
163
|
+
"(3) Parent directories are created automatically."
|
|
164
|
+
),
|
|
165
|
+
"parameters": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"path": {
|
|
169
|
+
"type": "string",
|
|
170
|
+
"description": "Full absolute file path including filename, e.g. 'C:/project/src/utils.py'"
|
|
171
|
+
},
|
|
172
|
+
"content": {
|
|
173
|
+
"type": "string",
|
|
174
|
+
"description": "Text content to write. For files >150 lines, use <<<FILE:path>>> in response text instead."
|
|
175
|
+
},
|
|
176
|
+
"mode": {
|
|
177
|
+
"type": "string",
|
|
178
|
+
"description": "'w' to create/overwrite (default), 'a' to append to existing file without erasing it"
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
"required": ["path", "content"]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"type": "function",
|
|
187
|
+
"function": {
|
|
188
|
+
"name": "edit_file",
|
|
189
|
+
"description": (
|
|
190
|
+
"Replace an exact string in an existing file — surgical, targeted edits only. "
|
|
191
|
+
"Perfect for: fixing a bug on 2-3 lines, updating a config value, changing a function signature. "
|
|
192
|
+
"ENFORCED: you must have READ the file this session before editing it, and if the file "
|
|
193
|
+
"changed on disk after your last read the edit is blocked until you re-read. "
|
|
194
|
+
"WORKFLOW: "
|
|
195
|
+
"(1) grep for the function/line you want to change → get the line number. "
|
|
196
|
+
"(2) read_file(offset=LINE-5, limit=20) → get the exact text around that line. "
|
|
197
|
+
"(3) Copy old_string VERBATIM from the read_file output — never type it from memory. "
|
|
198
|
+
"(4) Call edit_file with that exact old_string. "
|
|
199
|
+
"If edit_file returns 'not found': re-read that section, copy exact text, retry. "
|
|
200
|
+
"NEVER write a helper script to do what edit_file should do. "
|
|
201
|
+
"RULES: "
|
|
202
|
+
"(1) 'path' = full file path with filename. Never '.', never a directory. "
|
|
203
|
+
"(2) 'old_string' must appear EXACTLY ONCE in the file — include enough surrounding lines to make it unique. "
|
|
204
|
+
"(3) If old_string appears 0 times → re-read the file, copy exact text, retry. "
|
|
205
|
+
"(4) Use replace_all=true to rename a variable/function everywhere in the file. "
|
|
206
|
+
"(5) Do NOT use for large rewrites — use <<<FILE:path>>> marker for that."
|
|
207
|
+
),
|
|
208
|
+
"parameters": {
|
|
209
|
+
"type": "object",
|
|
210
|
+
"properties": {
|
|
211
|
+
"path": {
|
|
212
|
+
"type": "string",
|
|
213
|
+
"description": "Full absolute file path, e.g. 'C:/project/src/routes.py'"
|
|
214
|
+
},
|
|
215
|
+
"old_string": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "Exact text to find and replace. Must be unique in the file. Include 2-3 lines of context if needed."
|
|
218
|
+
},
|
|
219
|
+
"new_string": {
|
|
220
|
+
"type": "string",
|
|
221
|
+
"description": "Replacement text. Can be empty string to delete old_string."
|
|
222
|
+
},
|
|
223
|
+
"replace_all": {
|
|
224
|
+
"type": "boolean",
|
|
225
|
+
"description": "If true, replace ALL occurrences of old_string (default false replaces only the first). Use for renaming a variable or function across an entire file."
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"required": ["path", "old_string", "new_string"]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"type": "function",
|
|
234
|
+
"execution_mode": "parallel",
|
|
235
|
+
"function": {
|
|
236
|
+
"name": "glob",
|
|
237
|
+
"description": (
|
|
238
|
+
"Find files by name pattern using glob syntax. Returns matching paths sorted by modification time. "
|
|
239
|
+
"Use when you need to discover files: find all Python files, all HTML reports, all config files. "
|
|
240
|
+
"Examples: '**/*.py' (all Python files recursively), '*.html' (HTML in current dir), "
|
|
241
|
+
"'src/**/*.ts' (TypeScript under src/), 'requirements*.txt' (requirements files). "
|
|
242
|
+
"Skips: node_modules, .git, __pycache__, .venv, dist, build."
|
|
243
|
+
),
|
|
244
|
+
"parameters": {
|
|
245
|
+
"type": "object",
|
|
246
|
+
"properties": {
|
|
247
|
+
"pattern": {
|
|
248
|
+
"type": "string",
|
|
249
|
+
"description": "Glob pattern, e.g. '**/*.py', '*.html', 'src/**/*.ts', 'config/*.json'"
|
|
250
|
+
},
|
|
251
|
+
"directory": {
|
|
252
|
+
"type": "string",
|
|
253
|
+
"description": "Base directory to search from (default: current working directory)"
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
"required": ["pattern"]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
"type": "function",
|
|
262
|
+
"execution_mode": "parallel",
|
|
263
|
+
"function": {
|
|
264
|
+
"name": "grep",
|
|
265
|
+
"description": (
|
|
266
|
+
"Search for a regex pattern inside files and return matching lines with line numbers. "
|
|
267
|
+
"THIS IS YOUR ENTRY POINT FOR EVERY CODE FIX — always grep before read_file. "
|
|
268
|
+
"grep tells you the exact file + exact line number so you never read blindly. "
|
|
269
|
+
"Use when you need to: find where a function/class/variable is defined, find all usages of an API, "
|
|
270
|
+
"search for a string across a codebase, locate error messages, find imports. "
|
|
271
|
+
"Returns up to 100 matches with file path + line number + matching line content. "
|
|
272
|
+
"Bug-fix workflow: grep for the function/string in the error → "
|
|
273
|
+
"get line number → read_file(offset=LINE-5, limit=30) → edit_file with exact text. "
|
|
274
|
+
"Examples: pattern='def authenticate' finds all auth function definitions; "
|
|
275
|
+
"pattern='import.*pandas' glob='*.py' finds all pandas imports; "
|
|
276
|
+
"pattern='UnicodeDecodeError' finds where encoding errors are handled."
|
|
277
|
+
),
|
|
278
|
+
"parameters": {
|
|
279
|
+
"type": "object",
|
|
280
|
+
"properties": {
|
|
281
|
+
"pattern": {
|
|
282
|
+
"type": "string",
|
|
283
|
+
"description": "Regex or literal string to search for, e.g. 'def authenticate', 'TODO', 'api_key\\s*='"
|
|
284
|
+
},
|
|
285
|
+
"path": {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"description": "File or directory to search in (default: current directory)"
|
|
288
|
+
},
|
|
289
|
+
"glob": {
|
|
290
|
+
"type": "string",
|
|
291
|
+
"description": "Only search files matching this pattern, e.g. '*.py', '*.js', '*.html'"
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
"required": ["pattern"]
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"type": "function",
|
|
300
|
+
"execution_mode": "parallel",
|
|
301
|
+
"function": {
|
|
302
|
+
"name": "web_fetch",
|
|
303
|
+
"description": (
|
|
304
|
+
"Fetch and return the text content of any URL. Strips HTML tags for clean readable output. "
|
|
305
|
+
"Use for: reading API documentation, Stack Overflow answers, GitHub READMEs, error pages, "
|
|
306
|
+
"npm/PyPI package pages, official docs, any webpage with relevant information. "
|
|
307
|
+
"Returns up to 8000 chars by default (increase max_chars for longer docs)."
|
|
308
|
+
),
|
|
309
|
+
"parameters": {
|
|
310
|
+
"type": "object",
|
|
311
|
+
"properties": {
|
|
312
|
+
"url": {
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "Full URL to fetch, e.g. 'https://docs.python.org/3/library/pathlib.html'"
|
|
315
|
+
},
|
|
316
|
+
"max_chars": {
|
|
317
|
+
"type": "integer",
|
|
318
|
+
"description": "Max characters to return (default 8000). Use 20000+ for long documentation pages."
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
"required": ["url"]
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
"type": "function",
|
|
327
|
+
"execution_mode": "parallel",
|
|
328
|
+
"function": {
|
|
329
|
+
"name": "list_dir",
|
|
330
|
+
"description": (
|
|
331
|
+
"List files and subdirectories with sizes. Use to explore project structure before diving in. "
|
|
332
|
+
"Shows file sizes so you know what you're dealing with before reading. "
|
|
333
|
+
"Use recursive=true to see the full tree (avoid on very large projects). "
|
|
334
|
+
"Skips: node_modules, .git, __pycache__, .venv, dist, build."
|
|
335
|
+
),
|
|
336
|
+
"parameters": {
|
|
337
|
+
"type": "object",
|
|
338
|
+
"properties": {
|
|
339
|
+
"path": {
|
|
340
|
+
"type": "string",
|
|
341
|
+
"description": "Directory path to list (default: current working directory)"
|
|
342
|
+
},
|
|
343
|
+
"recursive": {
|
|
344
|
+
"type": "boolean",
|
|
345
|
+
"description": "If true, lists all files and subdirs recursively. Default false (top-level only)."
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"type": "function",
|
|
353
|
+
"function": {
|
|
354
|
+
"name": "todo",
|
|
355
|
+
"description": (
|
|
356
|
+
"Create or update your live task checklist — REQUIRED for any task with 3+ steps "
|
|
357
|
+
"(app builds, multi-file fixes, refactors). Pass the COMPLETE list every time "
|
|
358
|
+
"(it replaces the previous list). "
|
|
359
|
+
"WORKFLOW: (1) at the start, call todo with every step planned, first one "
|
|
360
|
+
"in_progress; (2) the moment a step is done, call todo again marking it completed "
|
|
361
|
+
"and the next one in_progress; (3) exactly ONE task in_progress at a time; "
|
|
362
|
+
"(4) never end your turn with tasks still pending — finish them or tell the user why. "
|
|
363
|
+
"The current list is re-shown to you every turn so you never lose track mid-build."
|
|
364
|
+
),
|
|
365
|
+
"parameters": {
|
|
366
|
+
"type": "object",
|
|
367
|
+
"properties": {
|
|
368
|
+
"tasks": {
|
|
369
|
+
"type": "array",
|
|
370
|
+
"description": "The complete task list (replaces the previous list).",
|
|
371
|
+
"items": {
|
|
372
|
+
"type": "object",
|
|
373
|
+
"properties": {
|
|
374
|
+
"content": {
|
|
375
|
+
"type": "string",
|
|
376
|
+
"description": "Short imperative description, e.g. 'Write backend auth routes'"
|
|
377
|
+
},
|
|
378
|
+
"status": {
|
|
379
|
+
"type": "string",
|
|
380
|
+
"enum": ["pending", "in_progress", "completed"],
|
|
381
|
+
"description": "Current status of this task."
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
"required": ["content", "status"]
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
"required": ["tasks"]
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
"type": "function",
|
|
394
|
+
"function": {
|
|
395
|
+
"name": "remember",
|
|
396
|
+
"description": (
|
|
397
|
+
"Save a fact to PERSISTENT MEMORY that survives across sessions. "
|
|
398
|
+
"Call this at the end of every task — future sessions read these memories so you won't "
|
|
399
|
+
"re-read files or re-discover context you already have. "
|
|
400
|
+
"WHAT to save: files you created/edited (path + line count + what it does), "
|
|
401
|
+
"key data extracted (metrics, config values, API keys location), "
|
|
402
|
+
"project structure discoveries, user preferences, task outcomes, decisions made. "
|
|
403
|
+
"BE SPECIFIC: include exact paths, numbers, and what the content is. "
|
|
404
|
+
"BAD: remember('worked on dashboard') "
|
|
405
|
+
"GOOD: remember('C:/chhelu 1/analysis/master_dashboard.html — 800 lines, Chart.js dark-theme, "
|
|
406
|
+
"7 charts comparing 3 Sylithe forest sites, radar scorecard added June 2026', tag='file')"
|
|
407
|
+
),
|
|
408
|
+
"parameters": {
|
|
409
|
+
"type": "object",
|
|
410
|
+
"properties": {
|
|
411
|
+
"text": {
|
|
412
|
+
"type": "string",
|
|
413
|
+
"description": "The specific fact to save. Include paths, numbers, outcomes. Be precise — vague memories are useless."
|
|
414
|
+
},
|
|
415
|
+
"tag": {
|
|
416
|
+
"type": "string",
|
|
417
|
+
"description": "Category tag: 'file' (created/edited files), 'data' (extracted numbers/metrics), 'project' (structure/stack), 'user' (preferences), 'task' (outcomes). Default: 'project'"
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"required": ["text"]
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"type": "function",
|
|
426
|
+
"execution_mode": "parallel",
|
|
427
|
+
"function": {
|
|
428
|
+
"name": "spawn_agent",
|
|
429
|
+
"description": (
|
|
430
|
+
"Spawn a specialized sub-agent to handle a subtask with its own isolated context, "
|
|
431
|
+
"dedicated toolset, and purpose-built system prompt.\n\n"
|
|
432
|
+
"WHEN TO USE:\n"
|
|
433
|
+
" • Before building: spawn explore to map the codebase so you understand it before writing\n"
|
|
434
|
+
" • During build: spawn researcher to fetch docs for an unknown library while you implement\n"
|
|
435
|
+
" • After building: spawn verifier to audit your code for bugs and security issues\n"
|
|
436
|
+
" • Complex subtask: spawn coder to handle a large isolated piece (e.g. auth module)\n"
|
|
437
|
+
" • Parallel work: call spawn_agent twice in one response — both run simultaneously\n\n"
|
|
438
|
+
"AGENT TYPES — choose the right specialist:\n"
|
|
439
|
+
" explore ← READ-ONLY analyst. Reads files, greps code, maps structure. Cannot write.\n"
|
|
440
|
+
" Use to understand existing code before making changes.\n"
|
|
441
|
+
" coder ← FULL-ACCESS implementer. Reads, writes, runs bash. Ships complete code.\n"
|
|
442
|
+
" Use for isolated implementation tasks.\n"
|
|
443
|
+
" verifier ← STRICT AUDITOR. Reads code and runs tests. Finds bugs + security holes.\n"
|
|
444
|
+
" Always spawn after implementing a feature before telling the user it's done.\n"
|
|
445
|
+
" researcher ← LIVE WEB FETCHER. Gets real docs, real examples, real API specs.\n"
|
|
446
|
+
" Use when you don't know an API exactly — don't guess, research it.\n"
|
|
447
|
+
" general ← ALL TOOLS. No restrictions. Use when the task spans multiple roles.\n\n"
|
|
448
|
+
"KEY FACTS:\n"
|
|
449
|
+
" - Subagents share your file_cache — no duplicate reads, zero wasted API calls\n"
|
|
450
|
+
" - Subagents have fresh history — write self-contained tasks with all needed context\n"
|
|
451
|
+
" - Subagents cannot spawn further agents — no runaway chains\n"
|
|
452
|
+
" - The agent's final response is returned as this tool's result"
|
|
453
|
+
),
|
|
454
|
+
"parameters": {
|
|
455
|
+
"type": "object",
|
|
456
|
+
"properties": {
|
|
457
|
+
"task": {
|
|
458
|
+
"type": "string",
|
|
459
|
+
"description": (
|
|
460
|
+
"Full, self-contained task for the subagent. Include all context it needs — "
|
|
461
|
+
"file paths, goal, constraints. The agent has no memory of your conversation."
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
"agent_type": {
|
|
465
|
+
"type": "string",
|
|
466
|
+
"enum": ["explore", "coder", "verifier", "researcher", "general"],
|
|
467
|
+
"description": "Type of specialized agent to spawn.",
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
"required": ["task"],
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
]
|
|
475
|
+
|
|
476
|
+
# Tools filtered for subagents — spawn_agent removed to prevent recursion
|
|
477
|
+
_TOOLS_NO_SPAWN = [t for t in TOOLS if t["function"]["name"] != "spawn_agent"]
|
|
478
|
+
|
|
479
|
+
# ── Coordinator-only Tools ────────────────────────────────────────────────────
|
|
480
|
+
# These 3 tools are ONLY given to the main agent when in coordinator mode.
|
|
481
|
+
# Workers never receive them (no recursive coordination).
|
|
482
|
+
|
|
483
|
+
COORDINATOR_ONLY_TOOLS = [
|
|
484
|
+
{
|
|
485
|
+
"type": "function",
|
|
486
|
+
"function": {
|
|
487
|
+
"name": "spawn_worker",
|
|
488
|
+
"description": (
|
|
489
|
+
"Spawn an async specialist worker. Returns worker_id IMMEDIATELY — does not block.\n\n"
|
|
490
|
+
"The worker runs in the background. When it completes, a <task-notification> XML "
|
|
491
|
+
"message will appear in your conversation with its full output.\n\n"
|
|
492
|
+
"PARALLELISM: Call spawn_worker multiple times in ONE response to run workers in parallel. "
|
|
493
|
+
"This is your superpower — read-only workers (explore/researcher) can always run in parallel.\n\n"
|
|
494
|
+
"WORKER TYPES:\n"
|
|
495
|
+
" explore — read_file, glob, grep, list_dir only. Use for codebase mapping.\n"
|
|
496
|
+
" researcher — web_fetch + read_file. Use for docs, API specs, real examples.\n"
|
|
497
|
+
" coder — ALL tools (read + write + bash). Use for implementation & commits.\n"
|
|
498
|
+
" verifier — read_file, grep, bash. Use for testing & security audit.\n"
|
|
499
|
+
" general — all tools. Use for multi-role tasks.\n\n"
|
|
500
|
+
"CRITICAL: Write self-contained prompts — workers have NO memory of your conversation. "
|
|
501
|
+
"Include file paths, line numbers, what 'done' looks like. "
|
|
502
|
+
"For implementation: ask them to run tests and commit before reporting."
|
|
503
|
+
),
|
|
504
|
+
"parameters": {
|
|
505
|
+
"type": "object",
|
|
506
|
+
"properties": {
|
|
507
|
+
"task": {
|
|
508
|
+
"type": "string",
|
|
509
|
+
"description": (
|
|
510
|
+
"Full self-contained task description. Must include: exact goal, "
|
|
511
|
+
"file paths and line numbers when known, what 'done' looks like. "
|
|
512
|
+
"For implementation tasks: 'Run tests, commit, report the commit hash.' "
|
|
513
|
+
"For research tasks: 'Report findings — do not modify files.'"
|
|
514
|
+
)
|
|
515
|
+
},
|
|
516
|
+
"agent_type": {
|
|
517
|
+
"type": "string",
|
|
518
|
+
"enum": ["explore", "coder", "verifier", "researcher", "general"],
|
|
519
|
+
"description": "Specialist type. Choose based on what the worker needs to do."
|
|
520
|
+
},
|
|
521
|
+
"description": {
|
|
522
|
+
"type": "string",
|
|
523
|
+
"description": (
|
|
524
|
+
"Short human-readable label shown in the UI and in the <task-notification> "
|
|
525
|
+
"summary. Examples: 'Auth bug investigation', 'Razorpay docs research', "
|
|
526
|
+
"'Fix null pointer in validate.py'. Max 60 chars."
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
"required": ["task"]
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
"type": "function",
|
|
536
|
+
"function": {
|
|
537
|
+
"name": "send_message",
|
|
538
|
+
"description": (
|
|
539
|
+
"Send a follow-up message to an existing worker using its worker_id.\n\n"
|
|
540
|
+
"Use to CONTINUE a worker — either it's still running (live injection) or "
|
|
541
|
+
"it already completed and you want it to do the next phase (re-start with context).\n\n"
|
|
542
|
+
"WHEN TO CONTINUE vs SPAWN FRESH:\n"
|
|
543
|
+
" Continue — worker already explored the exact files that need editing\n"
|
|
544
|
+
" Continue — correcting a worker's own test failures (it has the error context)\n"
|
|
545
|
+
" Spawn fresh — verifying code another worker wrote (fresh eyes)\n"
|
|
546
|
+
" Spawn fresh — broad research worker, narrow implementation task\n\n"
|
|
547
|
+
"The worker_id comes from spawn_worker's result OR from <task-id> in a <task-notification>.\n\n"
|
|
548
|
+
"Write a complete self-contained instruction. Reference what the worker did "
|
|
549
|
+
"('the null check you added'), not what you discussed with the user."
|
|
550
|
+
),
|
|
551
|
+
"parameters": {
|
|
552
|
+
"type": "object",
|
|
553
|
+
"properties": {
|
|
554
|
+
"worker_id": {
|
|
555
|
+
"type": "string",
|
|
556
|
+
"description": "The worker_id from spawn_worker result or <task-id> in a notification."
|
|
557
|
+
},
|
|
558
|
+
"message": {
|
|
559
|
+
"type": "string",
|
|
560
|
+
"description": (
|
|
561
|
+
"Follow-up instruction. Be specific: include file paths, exact changes, "
|
|
562
|
+
"what 'done' looks like. Reference the worker's own actions when correcting "
|
|
563
|
+
"('the test failure at line 58'). Self-contained — the worker has context "
|
|
564
|
+
"from its prior run but NOT from your conversation with the user."
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
"required": ["worker_id", "message"]
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
"type": "function",
|
|
574
|
+
"function": {
|
|
575
|
+
"name": "task_stop",
|
|
576
|
+
"description": (
|
|
577
|
+
"Send an abort signal to a running worker.\n\n"
|
|
578
|
+
"Use when:\n"
|
|
579
|
+
" - You launched a worker in the wrong direction\n"
|
|
580
|
+
" - The user changed requirements after you launched the worker\n"
|
|
581
|
+
" - A worker is taking too long on an approach you've already ruled out\n\n"
|
|
582
|
+
"The worker stops cleanly at the next safe boundary (not mid-write). "
|
|
583
|
+
"A stopped worker CAN be continued with send_message — it keeps its history."
|
|
584
|
+
),
|
|
585
|
+
"parameters": {
|
|
586
|
+
"type": "object",
|
|
587
|
+
"properties": {
|
|
588
|
+
"worker_id": {
|
|
589
|
+
"type": "string",
|
|
590
|
+
"description": "The worker_id to stop."
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
"required": ["worker_id"]
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
]
|
|
598
|
+
|
|
599
|
+
# Tools the coordinator uses directly (no write/bash — workers do that)
|
|
600
|
+
_COORDINATOR_READ_NAMES = {"read_file", "glob", "grep", "list_dir", "web_fetch", "remember"}
|
|
601
|
+
|
|
602
|
+
COORDINATOR_TOOLS = (
|
|
603
|
+
COORDINATOR_ONLY_TOOLS
|
|
604
|
+
+ [t for t in TOOLS if t["function"]["name"] in _COORDINATOR_READ_NAMES]
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
# ── Tool Implementations ──────────────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
# ── Background process registry ───────────────────────────────────────────────
|
|
610
|
+
# Lets the agent START servers, CHECK their output, and KILL them — the
|
|
611
|
+
# verify-by-running loop. Output goes to a log file so reading it never blocks.
|
|
612
|
+
|
|
613
|
+
_BG_PROCS: dict = {}
|
|
614
|
+
_BG_COUNTER = [0]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _kill_proc_tree(proc) -> None:
|
|
618
|
+
try:
|
|
619
|
+
if os.name == "nt":
|
|
620
|
+
# taskkill /T kills the whole tree (cmd → python/node children)
|
|
621
|
+
subprocess.run(
|
|
622
|
+
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
623
|
+
capture_output=True, timeout=10,
|
|
624
|
+
)
|
|
625
|
+
else:
|
|
626
|
+
proc.terminate()
|
|
627
|
+
proc.wait(timeout=5)
|
|
628
|
+
except Exception:
|
|
629
|
+
try:
|
|
630
|
+
proc.kill()
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _cleanup_bg_procs() -> None:
|
|
636
|
+
"""Never leave servers running after Sylithe Code exits."""
|
|
637
|
+
for info in list(_BG_PROCS.values()):
|
|
638
|
+
proc = info.get("proc")
|
|
639
|
+
if proc is not None and proc.poll() is None:
|
|
640
|
+
_kill_proc_tree(proc)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
import atexit
|
|
644
|
+
atexit.register(_cleanup_bg_procs)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _bash_background(command: str) -> str:
|
|
648
|
+
import tempfile
|
|
649
|
+
import time as _time
|
|
650
|
+
_BG_COUNTER[0] += 1
|
|
651
|
+
proc_id = f"proc-{_BG_COUNTER[0]}"
|
|
652
|
+
log_path = Path(tempfile.gettempdir()) / f"bharatcode_{os.getpid()}_{proc_id}.log"
|
|
653
|
+
fh = open(log_path, "w", encoding="utf-8", errors="replace")
|
|
654
|
+
try:
|
|
655
|
+
proc = subprocess.Popen(
|
|
656
|
+
command, shell=True, stdout=fh, stderr=subprocess.STDOUT,
|
|
657
|
+
stdin=subprocess.DEVNULL, cwd=os.getcwd(),
|
|
658
|
+
)
|
|
659
|
+
except Exception as e:
|
|
660
|
+
fh.close()
|
|
661
|
+
return f"Error starting background process: {e}"
|
|
662
|
+
_BG_PROCS[proc_id] = {
|
|
663
|
+
"proc": proc, "fh": fh, "log": str(log_path),
|
|
664
|
+
"command": command, "started": _time.time(),
|
|
665
|
+
}
|
|
666
|
+
return (
|
|
667
|
+
f"Started background process {proc_id} (system pid {proc.pid}).\n"
|
|
668
|
+
f"Command: {command}\n"
|
|
669
|
+
f"Next: process_output(process_id='{proc_id}', wait_seconds=4) to check its "
|
|
670
|
+
f"boot logs, then verify (e.g. web_fetch the health endpoint), then "
|
|
671
|
+
f"process_kill(process_id='{proc_id}') when you are done. "
|
|
672
|
+
f"ALWAYS kill what you started."
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def bash(command: str, timeout: int = 60, run_in_background: bool = False) -> str:
|
|
677
|
+
for blocked in BLOCKED_COMMANDS:
|
|
678
|
+
if blocked in command:
|
|
679
|
+
return f"Error: Blocked command '{blocked}'"
|
|
680
|
+
if run_in_background:
|
|
681
|
+
return _bash_background(command)
|
|
682
|
+
try:
|
|
683
|
+
result = subprocess.run(
|
|
684
|
+
command, shell=True, capture_output=True,
|
|
685
|
+
encoding="utf-8", errors="replace", timeout=timeout, cwd=os.getcwd()
|
|
686
|
+
)
|
|
687
|
+
out = result.stdout
|
|
688
|
+
if result.stderr:
|
|
689
|
+
out += f"\nSTDERR:\n{result.stderr}"
|
|
690
|
+
if result.returncode != 0:
|
|
691
|
+
out += f"\n[exit code: {result.returncode}]"
|
|
692
|
+
return out[:8000] or "(no output)"
|
|
693
|
+
except subprocess.TimeoutExpired:
|
|
694
|
+
return (
|
|
695
|
+
f"Timed out after {timeout}s. If this is a server/long-running process, "
|
|
696
|
+
f"use run_in_background=true instead. If it is a slow install/build, "
|
|
697
|
+
f"retry with timeout=300."
|
|
698
|
+
)
|
|
699
|
+
except Exception as e:
|
|
700
|
+
return f"Error: {e}"
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def process_output(process_id: str, wait_seconds: int = 0, tail_lines: int = 60) -> str:
|
|
704
|
+
info = _BG_PROCS.get(process_id)
|
|
705
|
+
if info is None:
|
|
706
|
+
active = ", ".join(_BG_PROCS) or "none"
|
|
707
|
+
return f"Error: no background process '{process_id}'. Active processes: {active}"
|
|
708
|
+
if wait_seconds:
|
|
709
|
+
import time as _time
|
|
710
|
+
_time.sleep(min(int(wait_seconds), 30))
|
|
711
|
+
proc = info["proc"]
|
|
712
|
+
code = proc.poll()
|
|
713
|
+
status = "RUNNING" if code is None else f"EXITED (code {code})"
|
|
714
|
+
try:
|
|
715
|
+
info["fh"].flush()
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
try:
|
|
719
|
+
content = Path(info["log"]).read_text(encoding="utf-8", errors="replace")
|
|
720
|
+
except Exception:
|
|
721
|
+
content = ""
|
|
722
|
+
lines = content.splitlines()
|
|
723
|
+
tail = "\n".join(lines[-int(tail_lines):])
|
|
724
|
+
return (
|
|
725
|
+
f"[{process_id}] {info['command']}\n"
|
|
726
|
+
f"Status: {status}\n"
|
|
727
|
+
f"Output (last {min(len(lines), int(tail_lines))} of {len(lines)} lines):\n"
|
|
728
|
+
f"{tail or '(no output yet — a server may need a few seconds; retry with wait_seconds=4)'}"
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def process_kill(process_id: str) -> str:
|
|
733
|
+
info = _BG_PROCS.get(process_id)
|
|
734
|
+
if info is None:
|
|
735
|
+
active = ", ".join(_BG_PROCS) or "none"
|
|
736
|
+
return f"Error: no background process '{process_id}'. Active processes: {active}"
|
|
737
|
+
proc = info["proc"]
|
|
738
|
+
if proc.poll() is not None:
|
|
739
|
+
return f"{process_id} had already exited with code {proc.returncode}."
|
|
740
|
+
_kill_proc_tree(proc)
|
|
741
|
+
try:
|
|
742
|
+
info["fh"].close()
|
|
743
|
+
except Exception:
|
|
744
|
+
pass
|
|
745
|
+
return f"Killed {process_id} and its child processes."
|
|
746
|
+
|
|
747
|
+
_READ_DEFAULT_LIMIT = 2000 # lines per call — protects context from giant files
|
|
748
|
+
|
|
749
|
+
def read_file(path: str, offset: int = 0, limit: int = None) -> str:
|
|
750
|
+
if not path or not path.strip():
|
|
751
|
+
return (
|
|
752
|
+
"Error: 'path' is empty. You must provide the file path. "
|
|
753
|
+
"Example: read_file(path='C:/chhelu 1/analysis/site3_report.html', offset=0)"
|
|
754
|
+
)
|
|
755
|
+
# Catch the common mistake of passing a line number as path
|
|
756
|
+
if path.strip().lstrip("-").isdigit():
|
|
757
|
+
return (
|
|
758
|
+
f"Error: '{path}' looks like a line number, not a file path. "
|
|
759
|
+
"The 'path' parameter must be the file path (e.g. 'C:/chhelu 1/analysis/site3_report.html'). "
|
|
760
|
+
f"Use offset={path} to start reading from that line."
|
|
761
|
+
)
|
|
762
|
+
try:
|
|
763
|
+
p = Path(path)
|
|
764
|
+
if p.is_dir():
|
|
765
|
+
return f"Error: '{path}' is a directory. Use list_dir to explore it."
|
|
766
|
+
if not limit or limit <= 0:
|
|
767
|
+
limit = _READ_DEFAULT_LIMIT
|
|
768
|
+
if offset < 0:
|
|
769
|
+
offset = 0
|
|
770
|
+
lines = _read_text_safe(p).splitlines()
|
|
771
|
+
total = len(lines)
|
|
772
|
+
chunk = lines[offset:offset + limit]
|
|
773
|
+
end = min(offset + limit, total)
|
|
774
|
+
header = f"File: {path} (lines {offset+1}-{end} of {total})\n"
|
|
775
|
+
numbered = "\n".join(f"{offset+i+1}\t{line}" for i, line in enumerate(chunk))
|
|
776
|
+
if end < total:
|
|
777
|
+
numbered += (
|
|
778
|
+
f"\n\n[file continues — {total - end} more lines. "
|
|
779
|
+
f"Call read_file with offset={end} to keep reading, "
|
|
780
|
+
f"or grep to jump straight to what you need.]"
|
|
781
|
+
)
|
|
782
|
+
return header + numbered
|
|
783
|
+
except FileNotFoundError:
|
|
784
|
+
return f"File not found: {path}"
|
|
785
|
+
except Exception as e:
|
|
786
|
+
return f"Error: {e}"
|
|
787
|
+
|
|
788
|
+
def write_file(path: str, content: str, mode: str = "w") -> str:
|
|
789
|
+
if not path or not path.strip():
|
|
790
|
+
return (
|
|
791
|
+
"Error: 'path' is empty. Provide the full file path. "
|
|
792
|
+
"For large files, write in chunks: "
|
|
793
|
+
"first write_file(path='C:/chhelu 1/analysis/master_dashboard.html', content='<html>...', mode='w'), "
|
|
794
|
+
"then write_file(path='C:/chhelu 1/analysis/master_dashboard.html', content='...more...', mode='a')"
|
|
795
|
+
)
|
|
796
|
+
if path.strip() in (".", "./", "/", "\\", ".."):
|
|
797
|
+
return f"Error: '{path}' is not a file path. Use a full path like 'C:/chhelu 1/analysis/master_dashboard.html'."
|
|
798
|
+
try:
|
|
799
|
+
p = Path(path)
|
|
800
|
+
if p.exists() and p.is_dir():
|
|
801
|
+
return f"Error: '{path}' is a directory. Include a filename."
|
|
802
|
+
already_existed = p.exists() and p.is_file()
|
|
803
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
804
|
+
write_mode = "a" if mode == "a" else "w"
|
|
805
|
+
with open(p, write_mode, encoding="utf-8") as f:
|
|
806
|
+
f.write(content)
|
|
807
|
+
total_lines = len(p.read_text(encoding="utf-8").splitlines())
|
|
808
|
+
if write_mode == "a":
|
|
809
|
+
return f"Appended to {path} ({total_lines} lines total)"
|
|
810
|
+
warning = " WARNING: File already existed and was overwritten. If you had not read it first, use git diff to check for lost content." if already_existed else ""
|
|
811
|
+
return f"Written {path} ({total_lines} lines total){warning}"
|
|
812
|
+
except Exception as e:
|
|
813
|
+
return f"Error writing '{path}': {e}"
|
|
814
|
+
|
|
815
|
+
def edit_file(path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
816
|
+
if not path or not path.strip():
|
|
817
|
+
return (
|
|
818
|
+
"Error: 'path' is empty. You must provide the file path to edit. "
|
|
819
|
+
"Example: edit_file(path='C:/chhelu 1/analysis/site3_report.html', old_string='...', new_string='...')"
|
|
820
|
+
)
|
|
821
|
+
if path.strip() in (".", "./", "/", "\\", ".."):
|
|
822
|
+
return f"Error: '{path}' is not a valid file path. Provide the full path including filename."
|
|
823
|
+
try:
|
|
824
|
+
p = Path(path)
|
|
825
|
+
if p.is_dir():
|
|
826
|
+
return f"Error: '{path}' is a directory, not a file."
|
|
827
|
+
|
|
828
|
+
content = _read_text_safe(p)
|
|
829
|
+
|
|
830
|
+
# Normalize CRLF → LF on both sides before matching (Windows files vs typed strings)
|
|
831
|
+
content_n = content.replace("\r\n", "\n")
|
|
832
|
+
old_n = old_string.replace("\r\n", "\n")
|
|
833
|
+
|
|
834
|
+
if old_n in content_n:
|
|
835
|
+
count = content_n.count(old_n)
|
|
836
|
+
if replace_all:
|
|
837
|
+
new_content = content_n.replace(old_n, new_string)
|
|
838
|
+
p.write_text(new_content, encoding="utf-8")
|
|
839
|
+
return f"Edited {path} ({count} occurrence{'s' if count != 1 else ''} replaced)"
|
|
840
|
+
if count > 1:
|
|
841
|
+
return (
|
|
842
|
+
f"Error: old_string appears {count} times in {path} — make it more unique, "
|
|
843
|
+
f"or use replace_all=true to replace all {count} occurrences at once."
|
|
844
|
+
)
|
|
845
|
+
new_content = content_n.replace(old_n, new_string, 1)
|
|
846
|
+
p.write_text(new_content, encoding="utf-8")
|
|
847
|
+
return f"Edited {path}"
|
|
848
|
+
|
|
849
|
+
# Not found — give the model useful context so it can fix its old_string
|
|
850
|
+
lines = content_n.splitlines()
|
|
851
|
+
first_line = old_n.split("\n")[0].strip()
|
|
852
|
+
|
|
853
|
+
# Find lines in the file that contain the first line of old_string
|
|
854
|
+
hits = [i for i, l in enumerate(lines) if first_line and first_line in l]
|
|
855
|
+
|
|
856
|
+
if hits:
|
|
857
|
+
i = hits[0]
|
|
858
|
+
old_line_count = len(old_n.split("\n"))
|
|
859
|
+
start = max(0, i - 1)
|
|
860
|
+
end = min(len(lines), i + old_line_count + 2)
|
|
861
|
+
snippet = "\n".join(f" {start+j+1}: {lines[start+j]}" for j in range(end - start))
|
|
862
|
+
hint = (
|
|
863
|
+
f"\n\nNearest match in file (lines {start+1}-{end}):\n{snippet}"
|
|
864
|
+
f"\n\nAction: Call read_file on this file, copy the exact text from those lines into old_string."
|
|
865
|
+
)
|
|
866
|
+
else:
|
|
867
|
+
preview = "\n".join(f" {i+1}: {l}" for i, l in enumerate(lines[:10]))
|
|
868
|
+
hint = (
|
|
869
|
+
f"\n\nFirst 10 lines of file:\n{preview}"
|
|
870
|
+
f"\n\nAction: Call read_file on '{path}' and copy the exact text you want to replace."
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
return f"Error: old_string not found in {path}.{hint}"
|
|
874
|
+
|
|
875
|
+
except FileNotFoundError:
|
|
876
|
+
return f"File not found: {path}"
|
|
877
|
+
except Exception as e:
|
|
878
|
+
return f"Error: {e}"
|
|
879
|
+
|
|
880
|
+
def glob(pattern: str, directory: str = ".") -> str:
|
|
881
|
+
try:
|
|
882
|
+
base = Path(directory)
|
|
883
|
+
matches = []
|
|
884
|
+
# Path.glob natively supports ** recursion — never mangle the pattern.
|
|
885
|
+
for p in base.glob(pattern):
|
|
886
|
+
if not any(skip in p.parts for skip in SKIP_DIRS):
|
|
887
|
+
matches.append(str(p))
|
|
888
|
+
matches.sort()
|
|
889
|
+
return "\n".join(matches[:200]) or "No files found"
|
|
890
|
+
except Exception as e:
|
|
891
|
+
return f"Error: {e}"
|
|
892
|
+
|
|
893
|
+
def grep(pattern: str, path: str = ".", glob: str = None) -> str:
|
|
894
|
+
try:
|
|
895
|
+
cmd = ["grep", "-rn", "--color=never", pattern, path]
|
|
896
|
+
if glob:
|
|
897
|
+
cmd += ["--include", glob]
|
|
898
|
+
for skip in SKIP_DIRS:
|
|
899
|
+
cmd += ["--exclude-dir", skip]
|
|
900
|
+
result = subprocess.run(cmd, capture_output=True, encoding="utf-8",
|
|
901
|
+
errors="replace", timeout=15)
|
|
902
|
+
return result.stdout[:6000] or "No matches found"
|
|
903
|
+
except FileNotFoundError:
|
|
904
|
+
try:
|
|
905
|
+
return _python_grep(pattern, path, glob)
|
|
906
|
+
except Exception as e:
|
|
907
|
+
return f"Error: {e}"
|
|
908
|
+
except Exception as e:
|
|
909
|
+
return f"Error: {e}"
|
|
910
|
+
|
|
911
|
+
def _python_grep(pattern: str, path: str, glob: str = None) -> str:
|
|
912
|
+
results = []
|
|
913
|
+
base = Path(path)
|
|
914
|
+
if not base.exists():
|
|
915
|
+
return f"Error: path not found: {path}"
|
|
916
|
+
files = base.rglob(glob or "*") if base.is_dir() else [base]
|
|
917
|
+
try:
|
|
918
|
+
regex = re.compile(pattern)
|
|
919
|
+
except re.error:
|
|
920
|
+
# Invalid regex from the model — fall back to a literal search
|
|
921
|
+
regex = re.compile(re.escape(pattern))
|
|
922
|
+
for f in files:
|
|
923
|
+
if not f.is_file():
|
|
924
|
+
continue
|
|
925
|
+
if any(skip in f.parts for skip in SKIP_DIRS):
|
|
926
|
+
continue
|
|
927
|
+
try:
|
|
928
|
+
for i, line in enumerate(f.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
|
|
929
|
+
if regex.search(line):
|
|
930
|
+
results.append(f"{f}:{i}: {line}")
|
|
931
|
+
if len(results) >= 100:
|
|
932
|
+
break
|
|
933
|
+
except Exception:
|
|
934
|
+
continue
|
|
935
|
+
return "\n".join(results) or "No matches found"
|
|
936
|
+
|
|
937
|
+
def web_fetch(url: str, max_chars: int = 8000) -> str:
|
|
938
|
+
try:
|
|
939
|
+
import urllib.request
|
|
940
|
+
import html
|
|
941
|
+
req = urllib.request.Request(
|
|
942
|
+
url,
|
|
943
|
+
headers={"User-Agent": "Sylithe Code/1.0 (AI coding agent)"}
|
|
944
|
+
)
|
|
945
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
946
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
947
|
+
|
|
948
|
+
# Strip HTML tags for cleaner text
|
|
949
|
+
import re as _re
|
|
950
|
+
# Remove scripts and styles
|
|
951
|
+
raw = _re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw, flags=_re.DOTALL | _re.IGNORECASE)
|
|
952
|
+
# Remove all tags
|
|
953
|
+
text = _re.sub(r"<[^>]+>", " ", raw)
|
|
954
|
+
# Collapse whitespace
|
|
955
|
+
text = _re.sub(r"\s{3,}", "\n\n", text)
|
|
956
|
+
text = html.unescape(text).strip()
|
|
957
|
+
|
|
958
|
+
if len(text) > max_chars:
|
|
959
|
+
text = text[:max_chars] + f"\n\n[... truncated at {max_chars} chars ...]"
|
|
960
|
+
return text or "(empty response)"
|
|
961
|
+
except Exception as e:
|
|
962
|
+
return f"Error fetching {url}: {e}"
|
|
963
|
+
|
|
964
|
+
def list_dir(path: str = ".", recursive: bool = False) -> str:
|
|
965
|
+
try:
|
|
966
|
+
root = Path(path)
|
|
967
|
+
if not root.exists():
|
|
968
|
+
return f"Path not found: {path}"
|
|
969
|
+
|
|
970
|
+
lines = []
|
|
971
|
+
if recursive:
|
|
972
|
+
items = sorted(root.rglob("*"))
|
|
973
|
+
else:
|
|
974
|
+
items = sorted(root.iterdir())
|
|
975
|
+
|
|
976
|
+
for item in items:
|
|
977
|
+
if any(skip in item.parts for skip in SKIP_DIRS):
|
|
978
|
+
continue
|
|
979
|
+
try:
|
|
980
|
+
if item.is_dir():
|
|
981
|
+
lines.append(f" [DIR] {item.relative_to(root) if recursive else item.name}/")
|
|
982
|
+
else:
|
|
983
|
+
size = item.stat().st_size
|
|
984
|
+
size_str = f"{size:>8,} B" if size < 1024 else f"{size//1024:>6,} KB"
|
|
985
|
+
lines.append(f" {size_str} {item.relative_to(root) if recursive else item.name}")
|
|
986
|
+
except Exception:
|
|
987
|
+
continue
|
|
988
|
+
return f"Directory: {path}\n" + "\n".join(lines[:300]) or "(empty)"
|
|
989
|
+
except Exception as e:
|
|
990
|
+
return f"Error: {e}"
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
# ── Dispatcher ────────────────────────────────────────────────────────────────
|
|
994
|
+
|
|
995
|
+
def execute_tool(name: str, args: dict) -> str:
|
|
996
|
+
dispatch = {
|
|
997
|
+
"bash": bash,
|
|
998
|
+
"read_file": read_file,
|
|
999
|
+
"write_file": write_file,
|
|
1000
|
+
"edit_file": edit_file,
|
|
1001
|
+
"glob": glob,
|
|
1002
|
+
"grep": grep,
|
|
1003
|
+
"web_fetch": web_fetch,
|
|
1004
|
+
"list_dir": list_dir,
|
|
1005
|
+
"process_output": process_output,
|
|
1006
|
+
"process_kill": process_kill,
|
|
1007
|
+
}
|
|
1008
|
+
if name == "remember":
|
|
1009
|
+
from .memory import remember as _remember
|
|
1010
|
+
fn = _remember
|
|
1011
|
+
else:
|
|
1012
|
+
fn = dispatch.get(name)
|
|
1013
|
+
if not fn:
|
|
1014
|
+
return f"Unknown tool: {name}"
|
|
1015
|
+
try:
|
|
1016
|
+
return fn(**args)
|
|
1017
|
+
except TypeError as e:
|
|
1018
|
+
# Model passed wrong/unknown argument names — tell it instead of crashing
|
|
1019
|
+
return f"Error: invalid arguments for {name}: {e}. Check the tool's parameter names and retry."
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
return f"Error executing {name}: {type(e).__name__}: {e}"
|