cortex-llm 1.0.9__py3-none-any.whl → 1.0.11__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.
cortex/tools/search.py ADDED
@@ -0,0 +1,135 @@
1
+ """Search utilities for repo tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional
11
+
12
+ from cortex.tools.errors import ToolError, ValidationError
13
+ from cortex.tools.fs_ops import RepoFS
14
+
15
+
16
+ class RepoSearch:
17
+ """Search helper constrained to a repo root."""
18
+
19
+ _DEFAULT_SKIP_DIRS = {
20
+ ".git",
21
+ ".cortex",
22
+ ".eggs",
23
+ ".mypy_cache",
24
+ ".pytest_cache",
25
+ ".ruff_cache",
26
+ ".tox",
27
+ ".venv",
28
+ "__pycache__",
29
+ "build",
30
+ "dist",
31
+ "node_modules",
32
+ "venv",
33
+ }
34
+
35
+ def __init__(self, repo_fs: RepoFS) -> None:
36
+ self.repo_fs = repo_fs
37
+
38
+ def search(self, query: str, path: str = ".", use_regex: bool = True, max_results: int = 100) -> Dict[str, List[Dict[str, object]]]:
39
+ if not isinstance(query, str) or not query:
40
+ raise ValidationError("query must be a non-empty string")
41
+ if isinstance(max_results, bool) or not isinstance(max_results, int):
42
+ raise ValidationError("max_results must be an int")
43
+ if max_results < 1:
44
+ raise ValidationError("max_results must be >= 1")
45
+ if not isinstance(use_regex, bool):
46
+ raise ValidationError("use_regex must be a bool")
47
+ target = self.repo_fs.resolve_path(path)
48
+ if not target.exists():
49
+ raise ValidationError("path does not exist")
50
+
51
+ if shutil.which("rg"):
52
+ return {"results": self._rg_search(query, target, use_regex, max_results)}
53
+ return {"results": self._python_search(query, target, use_regex, max_results)}
54
+
55
+ def _rg_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
56
+ args = ["rg", "--line-number", "--with-filename", "--no-heading"]
57
+ if not use_regex:
58
+ args.append("-F")
59
+ args.extend(["-e", query, str(target)])
60
+ result = subprocess.run(args, cwd=self.repo_fs.root, capture_output=True, text=True)
61
+ if result.returncode not in (0, 1):
62
+ raise ToolError(f"rg failed: {result.stderr.strip()}")
63
+ matches: List[Dict[str, object]] = []
64
+ for line in result.stdout.splitlines():
65
+ try:
66
+ file_path, line_no, text = line.split(":", 2)
67
+ except ValueError:
68
+ continue
69
+ matches.append({"path": file_path, "line": int(line_no), "text": text})
70
+ if len(matches) >= max_results:
71
+ break
72
+ return matches
73
+
74
+ def _python_search(self, query: str, target: Path, use_regex: bool, max_results: int) -> List[Dict[str, object]]:
75
+ pattern: Optional[re.Pattern[str]] = None
76
+ if use_regex:
77
+ try:
78
+ pattern = re.compile(query)
79
+ except re.error as e:
80
+ raise ValidationError(f"invalid regex: {e}") from e
81
+ results: List[Dict[str, object]] = []
82
+
83
+ if target.is_file():
84
+ if self._looks_binary(target):
85
+ return results
86
+ self._scan_file(target, pattern, query, results, max_results)
87
+ return results
88
+
89
+ skip_dirs = set(self._DEFAULT_SKIP_DIRS)
90
+ if target.name in skip_dirs:
91
+ skip_dirs.remove(target.name)
92
+
93
+ for dirpath, dirnames, filenames in os.walk(target):
94
+ dirnames[:] = [d for d in dirnames if d not in skip_dirs]
95
+ for name in filenames:
96
+ path = Path(dirpath) / name
97
+ if self._looks_binary(path):
98
+ continue
99
+ if self._scan_file(path, pattern, query, results, max_results):
100
+ return results
101
+ return results
102
+
103
+ def _scan_file(
104
+ self,
105
+ path: Path,
106
+ pattern: Optional[re.Pattern[str]],
107
+ query: str,
108
+ results: List[Dict[str, object]],
109
+ max_results: int,
110
+ ) -> bool:
111
+ try:
112
+ with path.open("r", encoding="utf-8", errors="ignore") as handle:
113
+ for idx, line in enumerate(handle, start=1):
114
+ found = bool(pattern.search(line)) if pattern else (query in line)
115
+ if found:
116
+ results.append(
117
+ {
118
+ "path": str(path.relative_to(self.repo_fs.root)),
119
+ "line": idx,
120
+ "text": line.rstrip("\n"),
121
+ }
122
+ )
123
+ if len(results) >= max_results:
124
+ return True
125
+ except OSError:
126
+ return False
127
+ return False
128
+
129
+ def _looks_binary(self, path: Path, sniff_bytes: int = 4096) -> bool:
130
+ try:
131
+ with path.open("rb") as handle:
132
+ chunk = handle.read(sniff_bytes)
133
+ except OSError:
134
+ return True
135
+ return b"\x00" in chunk
@@ -0,0 +1,204 @@
1
+ """Tool runner and specifications for Cortex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import json
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ from cortex.tools.errors import ToolError, ValidationError
13
+ from cortex.tools.fs_ops import RepoFS
14
+ from cortex.tools.search import RepoSearch
15
+
16
+
17
+ ConfirmCallback = Callable[[str], bool]
18
+
19
+
20
+ class ToolRunner:
21
+ """Execute tool calls with safety checks."""
22
+
23
+ def __init__(self, root: Path, confirm_callback: Optional[ConfirmCallback] = None) -> None:
24
+ self.fs = RepoFS(root)
25
+ self.search = RepoSearch(self.fs)
26
+ self.confirm_callback = confirm_callback
27
+
28
+ def set_confirm_callback(self, callback: ConfirmCallback) -> None:
29
+ self.confirm_callback = callback
30
+
31
+ def tool_spec(self) -> Dict[str, Any]:
32
+ return {
33
+ "list_dir": {"args": {"path": "string", "recursive": "bool", "max_depth": "int", "max_entries": "int"}},
34
+ "read_file": {"args": {"path": "string", "start_line": "int", "end_line": "int", "max_bytes": "int"}},
35
+ "search": {"args": {"query": "string", "path": "string", "use_regex": "bool", "max_results": "int"}},
36
+ "write_file": {"args": {"path": "string", "content": "string", "expected_sha256": "string"}},
37
+ "create_file": {"args": {"path": "string", "content": "string", "overwrite": "bool"}},
38
+ "delete_file": {"args": {"path": "string"}},
39
+ "replace_in_file": {"args": {"path": "string", "old": "string", "new": "string", "expected_replacements": "int"}},
40
+ "insert_after": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
41
+ "insert_before": {"args": {"path": "string", "anchor": "string", "content": "string", "expected_matches": "int"}},
42
+ }
43
+
44
+ def tool_instructions(self) -> str:
45
+ spec = json.dumps(self.tool_spec(), ensure_ascii=True, indent=2)
46
+ repo_root = str(self.fs.root)
47
+ return (
48
+ "[CORTEX_TOOL_INSTRUCTIONS v3]\n"
49
+ "You have access to repo-scoped file tools.\n"
50
+ "Use tools ONLY when the user asks for repo file operations or when repo data is required to answer.\n"
51
+ "Never use tools for general conversation, creative writing, or questions unrelated to the repo.\n"
52
+ "If a tool is required, respond ONLY with a <tool_calls> JSON block.\n"
53
+ "Do not include any other text when calling tools.\n"
54
+ f"Repo root: {repo_root}\n"
55
+ "All paths must be relative to the repo root (use '.' for root). Do not use absolute paths or ~.\n"
56
+ "For create/write/replace/insert/delete, paths must be file paths (not '.' or directories).\n"
57
+ "If you are unsure about paths, call list_dir with path '.' first.\n"
58
+ "Format:\n"
59
+ "<tool_calls>{\"calls\":[{\"id\":\"call_1\",\"name\":\"tool_name\",\"arguments\":{...}}]}</tool_calls>\n"
60
+ "Available tools:\n"
61
+ f"{spec}"
62
+ )
63
+
64
+ def run_calls(self, calls: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
65
+ results: List[Dict[str, Any]] = []
66
+ for call in calls:
67
+ call_id = call.get("id", "unknown")
68
+ name = call.get("name")
69
+ args = call.get("arguments") or {}
70
+ try:
71
+ if name == "list_dir":
72
+ result = self.fs.list_dir(**args)
73
+ elif name == "read_file":
74
+ result = self.fs.read_text(**args)
75
+ elif name == "search":
76
+ result = self.search.search(**args)
77
+ elif name == "write_file":
78
+ result = self._write_file(**args)
79
+ elif name == "create_file":
80
+ result = self._create_file(**args)
81
+ elif name == "delete_file":
82
+ result = self._delete_file(**args)
83
+ elif name == "replace_in_file":
84
+ result = self._replace_in_file(**args)
85
+ elif name == "insert_after":
86
+ result = self._insert_relative(after=True, **args)
87
+ elif name == "insert_before":
88
+ result = self._insert_relative(after=False, **args)
89
+ else:
90
+ raise ValidationError(f"unknown tool: {name}")
91
+ results.append({"id": call_id, "name": name, "ok": True, "result": result, "error": None})
92
+ except Exception as e:
93
+ results.append({"id": call_id, "name": name, "ok": False, "result": None, "error": str(e)})
94
+ return results
95
+
96
+ def _write_file(self, path: str, content: str, expected_sha256: Optional[str] = None) -> Dict[str, Any]:
97
+ self._validate_str("content", content)
98
+ expected_sha256 = self._validate_sha256(expected_sha256)
99
+ before = self.fs.read_full_text(path)
100
+ self._confirm_change(path, before, content, "write")
101
+ return self.fs.write_text(path, content, expected_sha256=expected_sha256)
102
+
103
+ def _create_file(self, path: str, content: str, overwrite: bool = False) -> Dict[str, Any]:
104
+ target = self._validate_create_path(path)
105
+ self._validate_str("content", content)
106
+ self._validate_bool("overwrite", overwrite)
107
+ if target.exists() and not overwrite:
108
+ raise ValidationError("path already exists")
109
+ before = self.fs.read_full_text(path) if target.exists() else ""
110
+ self._confirm_change(path, before, content, "create")
111
+ return self.fs.create_text(path, content, overwrite=overwrite)
112
+
113
+ def _delete_file(self, path: str) -> Dict[str, Any]:
114
+ target = self.fs.resolve_path(path)
115
+ if not target.exists() or not target.is_file():
116
+ raise ValidationError("path does not exist or is not a file")
117
+ if not self.fs.is_git_tracked(target):
118
+ raise ToolError("delete blocked: file is not tracked by git")
119
+ before = self.fs.read_full_text(path)
120
+ self._confirm_change(path, before, "", "delete")
121
+ return self.fs.delete_file(path)
122
+
123
+ def _replace_in_file(self, path: str, old: str, new: str, expected_replacements: int = 1) -> Dict[str, Any]:
124
+ self._validate_non_empty_str("old", old)
125
+ self._validate_str("new", new)
126
+ expected_replacements = self._validate_int("expected_replacements", expected_replacements, minimum=1)
127
+ content = self.fs.read_full_text(path)
128
+ count = content.count(old)
129
+ if count != expected_replacements:
130
+ raise ToolError(f"expected {expected_replacements} replacements, found {count}")
131
+ updated = content.replace(old, new)
132
+ self._confirm_change(path, content, updated, "replace")
133
+ return self.fs.write_text(path, updated)
134
+
135
+ def _insert_relative(self, path: str, anchor: str, content: str, expected_matches: int = 1, after: bool = True) -> Dict[str, Any]:
136
+ self._validate_non_empty_str("anchor", anchor)
137
+ self._validate_str("content", content)
138
+ expected_matches = self._validate_int("expected_matches", expected_matches, minimum=1)
139
+ original = self.fs.read_full_text(path)
140
+ count = original.count(anchor)
141
+ if count != expected_matches:
142
+ raise ToolError(f"expected {expected_matches} matches, found {count}")
143
+ insert_text = anchor + content if after else content + anchor
144
+ updated = original.replace(anchor, insert_text, count if expected_matches > 1 else 1)
145
+ self._confirm_change(path, original, updated, "insert")
146
+ return self.fs.write_text(path, updated)
147
+
148
+ def _confirm_change(self, path: str, before: str, after: str, action: str) -> None:
149
+ if self.confirm_callback is None:
150
+ raise ToolError("confirmation required but no callback configured")
151
+ if before == after:
152
+ raise ToolError("no changes to apply")
153
+ diff = "\n".join(
154
+ difflib.unified_diff(
155
+ before.splitlines(),
156
+ after.splitlines(),
157
+ fromfile=f"{path} (before)",
158
+ tofile=f"{path} (after)",
159
+ lineterm="",
160
+ )
161
+ )
162
+ prompt = f"Apply {action} to {path}?\n{diff}\n"
163
+ if not self.confirm_callback(prompt):
164
+ raise ToolError("change declined by user")
165
+
166
+ def _validate_str(self, name: str, value: object) -> None:
167
+ if not isinstance(value, str):
168
+ raise ValidationError(f"{name} must be a string")
169
+
170
+ def _validate_non_empty_str(self, name: str, value: object) -> None:
171
+ if not isinstance(value, str) or not value:
172
+ raise ValidationError(f"{name} must be a non-empty string")
173
+
174
+ def _validate_bool(self, name: str, value: object) -> None:
175
+ if not isinstance(value, bool):
176
+ raise ValidationError(f"{name} must be a bool")
177
+
178
+ def _validate_int(self, name: str, value: object, minimum: int = 0) -> int:
179
+ if isinstance(value, bool) or not isinstance(value, int):
180
+ raise ValidationError(f"{name} must be an int")
181
+ if value < minimum:
182
+ raise ValidationError(f"{name} must be >= {minimum}")
183
+ return value
184
+
185
+ def _validate_sha256(self, value: Optional[str]) -> Optional[str]:
186
+ if value is None:
187
+ return None
188
+ if not isinstance(value, str):
189
+ raise ValidationError("expected_sha256 must be a string")
190
+ normalized = value.lower()
191
+ if not re.fullmatch(r"[0-9a-f]{64}", normalized):
192
+ raise ValidationError("expected_sha256 must be a 64-character hex string")
193
+ return normalized
194
+
195
+ def _validate_create_path(self, path: str) -> Path:
196
+ self._validate_non_empty_str("path", path)
197
+ if path in {".", ""}:
198
+ raise ValidationError("path must be a file path, not a directory")
199
+ if path.endswith(("/", os.sep)):
200
+ raise ValidationError("path must be a file path, not a directory")
201
+ target = self.fs.resolve_path(path)
202
+ if target.exists() and target.is_dir():
203
+ raise ValidationError("path already exists and is a directory")
204
+ return target
@@ -0,0 +1,97 @@
1
+ """Box rendering utilities for CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+ from typing import List, Optional
8
+
9
+
10
+ def get_visible_length(text: str) -> int:
11
+ """Get visible length of text, ignoring ANSI escape codes and accounting for wide characters."""
12
+ ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
13
+ visible_text = ansi_escape.sub("", text)
14
+
15
+ display_width = 0
16
+ for char in visible_text:
17
+ width = unicodedata.east_asian_width(char)
18
+ if width in ("W", "F"):
19
+ display_width += 2
20
+ elif width == "A" and char in "●○":
21
+ display_width += 1
22
+ else:
23
+ display_width += 1
24
+
25
+ return display_width
26
+
27
+
28
+ def print_box_line(content: str, width: int, align: str = "left") -> None:
29
+ """Print a single line in a box with proper padding."""
30
+ visible_len = get_visible_length(content)
31
+ padding = width - visible_len - 2
32
+
33
+ if align == "center":
34
+ left_pad = padding // 2
35
+ right_pad = padding - left_pad
36
+ print(f"│{' ' * left_pad}{content}{' ' * right_pad}│")
37
+ else:
38
+ print(f"│{content}{' ' * padding}│")
39
+
40
+
41
+ def print_box_header(title: str, width: int) -> None:
42
+ """Print a box header with title."""
43
+ if title:
44
+ title_with_color = f" \033[96m{title}\033[0m "
45
+ visible_len = get_visible_length(title_with_color)
46
+ padding = width - visible_len - 3
47
+ print(f"╭─{title_with_color}" + "─" * padding + "╮")
48
+ else:
49
+ print("╭" + "─" * (width - 2) + "╮")
50
+
51
+
52
+ def print_box_footer(width: int) -> None:
53
+ """Print a box footer."""
54
+ print("╰" + "─" * (width - 2) + "╯")
55
+
56
+
57
+ def print_box_separator(width: int) -> None:
58
+ """Print a separator line inside a box."""
59
+ print("├" + "─" * (width - 2) + "┤")
60
+
61
+
62
+ def print_empty_line(width: int) -> None:
63
+ """Print an empty line inside a box."""
64
+ print("│" + " " * (width - 2) + "│")
65
+
66
+
67
+ def create_box(
68
+ lines: List[str],
69
+ *,
70
+ width: Optional[int],
71
+ terminal_width: int,
72
+ ) -> str:
73
+ """Create a box with Unicode borders."""
74
+ if width is None:
75
+ width = min(terminal_width - 2, 80)
76
+
77
+ top_left = "╭"
78
+ top_right = "╮"
79
+ bottom_left = "╰"
80
+ bottom_right = "╯"
81
+ horizontal = "─"
82
+ vertical = "│"
83
+
84
+ inner_width = width - 4
85
+
86
+ result = []
87
+ result.append(top_left + horizontal * (width - 2) + top_right)
88
+
89
+ for line in lines:
90
+ visible_len = get_visible_length(line)
91
+ padding_needed = inner_width - visible_len
92
+ padded = f" {line}{' ' * padding_needed} "
93
+ result.append(vertical + padded + vertical)
94
+
95
+ result.append(bottom_left + horizontal * (width - 2) + bottom_right)
96
+
97
+ return "\n".join(result)