gdmcode 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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- src/voice/voice_loop.py +156 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""LRU+TTL cache for tool results — tools-004.
|
|
2
|
+
|
|
3
|
+
Thread-safe, bounded cache that avoids redundant tool calls.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from functools import wraps
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__all__ = ["CacheEntry", "ToolResultCache", "cached_tool"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CacheEntry:
|
|
22
|
+
result: Any
|
|
23
|
+
created_at: float = field(default_factory=time.monotonic)
|
|
24
|
+
hits: int = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolResultCache:
|
|
28
|
+
"""Bounded LRU cache with TTL for tool call results.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
max_size: Maximum number of entries before LRU eviction.
|
|
32
|
+
ttl: Time-to-live in seconds (uses ``time.monotonic``).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, max_size: int = 256, ttl: float = 300.0) -> None:
|
|
36
|
+
self._max_size = max_size
|
|
37
|
+
self._ttl = ttl
|
|
38
|
+
self._store: OrderedDict[str, CacheEntry] = OrderedDict()
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
self._hits = 0
|
|
41
|
+
self._misses = 0
|
|
42
|
+
self._evictions = 0
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Public API
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def get(self, tool_name: str, args: dict[str, Any]) -> Any | None:
|
|
49
|
+
"""Return cached result or None if missing / expired."""
|
|
50
|
+
key = self._make_key(tool_name, args)
|
|
51
|
+
with self._lock:
|
|
52
|
+
entry = self._store.get(key)
|
|
53
|
+
if entry is None:
|
|
54
|
+
self._misses += 1
|
|
55
|
+
return None
|
|
56
|
+
if time.monotonic() - entry.created_at > self._ttl:
|
|
57
|
+
del self._store[key]
|
|
58
|
+
self._misses += 1
|
|
59
|
+
return None
|
|
60
|
+
# Move to end (most-recently-used)
|
|
61
|
+
self._store.move_to_end(key)
|
|
62
|
+
entry.hits += 1
|
|
63
|
+
self._hits += 1
|
|
64
|
+
return entry.result
|
|
65
|
+
|
|
66
|
+
def put(self, tool_name: str, args: dict[str, Any], result: Any) -> None:
|
|
67
|
+
"""Store a result, evicting the LRU entry if at capacity."""
|
|
68
|
+
key = self._make_key(tool_name, args)
|
|
69
|
+
with self._lock:
|
|
70
|
+
if key in self._store:
|
|
71
|
+
self._store.move_to_end(key)
|
|
72
|
+
self._store[key] = CacheEntry(result=result)
|
|
73
|
+
return
|
|
74
|
+
if len(self._store) >= self._max_size:
|
|
75
|
+
self._store.popitem(last=False) # evict LRU (oldest)
|
|
76
|
+
self._evictions += 1
|
|
77
|
+
self._store[key] = CacheEntry(result=result)
|
|
78
|
+
|
|
79
|
+
def invalidate(self, tool_name: str | None = None) -> int:
|
|
80
|
+
"""Remove entries.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
tool_name: If given, remove only entries for that tool.
|
|
84
|
+
If None, clear everything.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Number of entries removed.
|
|
88
|
+
"""
|
|
89
|
+
with self._lock:
|
|
90
|
+
if tool_name is None:
|
|
91
|
+
count = len(self._store)
|
|
92
|
+
self._store.clear()
|
|
93
|
+
return count
|
|
94
|
+
prefix = tool_name + ":"
|
|
95
|
+
keys = [k for k in self._store if k.startswith(prefix)]
|
|
96
|
+
for k in keys:
|
|
97
|
+
del self._store[k]
|
|
98
|
+
return len(keys)
|
|
99
|
+
|
|
100
|
+
def stats(self) -> dict[str, int]:
|
|
101
|
+
"""Return cache statistics."""
|
|
102
|
+
with self._lock:
|
|
103
|
+
return {
|
|
104
|
+
"size": len(self._store),
|
|
105
|
+
"hits": self._hits,
|
|
106
|
+
"misses": self._misses,
|
|
107
|
+
"evictions": self._evictions,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Internal helpers
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _make_key(tool_name: str, args: dict[str, Any]) -> str:
|
|
116
|
+
"""Deterministic SHA-256 key; arg order does not affect the result."""
|
|
117
|
+
canonical = json.dumps(args, sort_keys=True, separators=(",", ":"), default=str)
|
|
118
|
+
payload = f"{tool_name}:{canonical}"
|
|
119
|
+
return tool_name + ":" + hashlib.sha256(payload.encode()).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Decorator
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cached_tool(
|
|
128
|
+
cache: ToolResultCache,
|
|
129
|
+
tool_name: str | None = None,
|
|
130
|
+
) -> Callable:
|
|
131
|
+
"""Decorator that caches a tool function''s return value.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
cache: ``ToolResultCache`` instance to use.
|
|
135
|
+
tool_name: Cache key prefix. Defaults to the wrapped function''s name.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def decorator(fn: Callable) -> Callable:
|
|
139
|
+
name = tool_name or fn.__name__
|
|
140
|
+
|
|
141
|
+
@wraps(fn)
|
|
142
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
143
|
+
# Build a stable args dict from positional + keyword arguments
|
|
144
|
+
call_args: dict[str, Any] = {f"_arg{i}": v for i, v in enumerate(args)}
|
|
145
|
+
call_args.update(kwargs)
|
|
146
|
+
|
|
147
|
+
cached = cache.get(name, call_args)
|
|
148
|
+
if cached is not None:
|
|
149
|
+
return cached
|
|
150
|
+
|
|
151
|
+
result = fn(*args, **kwargs)
|
|
152
|
+
cache.put(name, call_args, result)
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
return wrapper
|
|
156
|
+
|
|
157
|
+
return decorator
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""GlobTool and GrepTool — fast file pattern and content search.
|
|
2
|
+
|
|
3
|
+
GlobTool: pathlib-based file pattern matching with .gdmignore support.
|
|
4
|
+
GrepTool: ripgrep-based content search (falls back to Python re if rg not found).
|
|
5
|
+
|
|
6
|
+
Both tools tag their output as untrusted before returning, since file paths and
|
|
7
|
+
content could contain adversarial data.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, ClassVar
|
|
18
|
+
|
|
19
|
+
from src.tools import REGISTRY, ToolBase, ToolResult
|
|
20
|
+
|
|
21
|
+
__all__ = ["GlobTool", "GrepTool"]
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_MAX_RESULTS: int = 500 # max file paths to return
|
|
26
|
+
_MAX_GREP_OUTPUT_BYTES: int = 30_000
|
|
27
|
+
_GDMIGNORE_FILENAME = ".gdmignore"
|
|
28
|
+
|
|
29
|
+
# Directories always excluded from search.
|
|
30
|
+
_DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset({
|
|
31
|
+
".git", "__pycache__", ".venv", "venv", "env",
|
|
32
|
+
"node_modules", ".next", ".nuxt", "dist", "build",
|
|
33
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
34
|
+
".context-memory",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# .gdmignore support
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def _load_ignore_patterns(workspace: Path) -> list[str]:
|
|
43
|
+
"""Read .gdmignore from workspace root; return list of fnmatch patterns."""
|
|
44
|
+
ignore_file = workspace / _GDMIGNORE_FILENAME
|
|
45
|
+
if not ignore_file.exists():
|
|
46
|
+
return []
|
|
47
|
+
try:
|
|
48
|
+
lines = ignore_file.read_text(encoding="utf-8").splitlines()
|
|
49
|
+
return [ln.strip() for ln in lines if ln.strip() and not ln.startswith("#")]
|
|
50
|
+
except OSError:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_ignored(path: Path, patterns: list[str]) -> bool:
|
|
55
|
+
"""Return True if any glob pattern matches the path."""
|
|
56
|
+
name = path.name
|
|
57
|
+
return any(fnmatch.fnmatch(name, pat) for pat in patterns)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# GlobTool
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
class GlobTool(ToolBase):
|
|
65
|
+
"""Find files matching a glob pattern within the project."""
|
|
66
|
+
|
|
67
|
+
name: ClassVar[str] = "glob"
|
|
68
|
+
description: ClassVar[str] = (
|
|
69
|
+
"Find files matching a glob pattern (e.g. '**/*.py', 'src/**/*.ts'). "
|
|
70
|
+
"Returns up to 500 matching paths relative to the project root. "
|
|
71
|
+
"Respects .gdmignore and always excludes .git, node_modules, __pycache__."
|
|
72
|
+
)
|
|
73
|
+
input_schema: ClassVar[dict[str, Any]] = {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"required": ["pattern"],
|
|
76
|
+
"properties": {
|
|
77
|
+
"pattern": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"description": "Glob pattern, e.g. '**/*.py' or 'src/**/*.ts'.",
|
|
80
|
+
},
|
|
81
|
+
"cwd": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "Search root. Defaults to project workspace.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
"additionalProperties": False,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
90
|
+
self._workspace = workspace
|
|
91
|
+
|
|
92
|
+
def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
|
|
93
|
+
pattern: str = params["pattern"]
|
|
94
|
+
cwd_str: str | None = params.get("cwd")
|
|
95
|
+
|
|
96
|
+
root = Path(cwd_str).resolve() if cwd_str else (self._workspace or Path.cwd())
|
|
97
|
+
if not root.is_dir():
|
|
98
|
+
return ToolResult(output="", error=f"Directory not found: {root}")
|
|
99
|
+
|
|
100
|
+
ignore_patterns = _load_ignore_patterns(root)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
matches: list[Path] = []
|
|
104
|
+
for p in root.glob(pattern):
|
|
105
|
+
if not p.is_file():
|
|
106
|
+
continue
|
|
107
|
+
# Skip excluded dirs.
|
|
108
|
+
if any(part in _DEFAULT_EXCLUDE_DIRS for part in p.parts):
|
|
109
|
+
continue
|
|
110
|
+
if _is_ignored(p, ignore_patterns):
|
|
111
|
+
continue
|
|
112
|
+
matches.append(p)
|
|
113
|
+
if len(matches) >= _MAX_RESULTS:
|
|
114
|
+
break
|
|
115
|
+
except Exception as exc: # noqa: BLE001
|
|
116
|
+
return ToolResult(output="", error=f"Glob failed: {exc}")
|
|
117
|
+
|
|
118
|
+
if not matches:
|
|
119
|
+
return ToolResult(output="No files matched.")
|
|
120
|
+
|
|
121
|
+
lines = [str(p.relative_to(root)) for p in sorted(matches)]
|
|
122
|
+
output = "\n".join(lines)
|
|
123
|
+
truncated = len(matches) >= _MAX_RESULTS
|
|
124
|
+
|
|
125
|
+
return ToolResult(
|
|
126
|
+
output=output,
|
|
127
|
+
truncated=truncated,
|
|
128
|
+
metadata={"count": len(matches), "root": str(root)},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# GrepTool
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
class GrepTool(ToolBase):
|
|
137
|
+
"""Search file contents using ripgrep (or Python regex fallback)."""
|
|
138
|
+
|
|
139
|
+
name: ClassVar[str] = "grep"
|
|
140
|
+
description: ClassVar[str] = (
|
|
141
|
+
"Search for a regex pattern in file contents. "
|
|
142
|
+
"Uses ripgrep if available for speed; otherwise falls back to Python re. "
|
|
143
|
+
"Returns matching lines with file:line format. Output capped at 30 KB."
|
|
144
|
+
)
|
|
145
|
+
input_schema: ClassVar[dict[str, Any]] = {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"required": ["pattern"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"pattern": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Regular expression to search for.",
|
|
152
|
+
},
|
|
153
|
+
"path": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "File or directory to search. Defaults to project root.",
|
|
156
|
+
},
|
|
157
|
+
"glob": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"description": "File glob filter, e.g. '*.py'.",
|
|
160
|
+
},
|
|
161
|
+
"case_insensitive": {
|
|
162
|
+
"type": "boolean",
|
|
163
|
+
"description": "Case-insensitive search.",
|
|
164
|
+
},
|
|
165
|
+
"context_lines": {
|
|
166
|
+
"type": "integer",
|
|
167
|
+
"description": "Lines of context before and after each match (0–10).",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
"additionalProperties": False,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
174
|
+
self._workspace = workspace
|
|
175
|
+
|
|
176
|
+
def execute(self, params: dict[str, Any]) -> ToolResult: # noqa: D102
|
|
177
|
+
pattern: str = params["pattern"]
|
|
178
|
+
path_str: str | None = params.get("path")
|
|
179
|
+
glob_filter: str | None = params.get("glob")
|
|
180
|
+
case_insensitive: bool = bool(params.get("case_insensitive", False))
|
|
181
|
+
context_lines: int = min(int(params.get("context_lines", 0)), 10)
|
|
182
|
+
|
|
183
|
+
search_root = (
|
|
184
|
+
Path(path_str).resolve()
|
|
185
|
+
if path_str
|
|
186
|
+
else (self._workspace or Path.cwd())
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if _rg_available():
|
|
190
|
+
output, err = _rg_search(
|
|
191
|
+
pattern,
|
|
192
|
+
search_root,
|
|
193
|
+
glob_filter=glob_filter,
|
|
194
|
+
case_insensitive=case_insensitive,
|
|
195
|
+
context_lines=context_lines,
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
output, err = _python_search(
|
|
199
|
+
pattern,
|
|
200
|
+
search_root,
|
|
201
|
+
glob_filter=glob_filter,
|
|
202
|
+
case_insensitive=case_insensitive,
|
|
203
|
+
context_lines=context_lines,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if err:
|
|
207
|
+
return ToolResult(output="", error=err)
|
|
208
|
+
if not output.strip():
|
|
209
|
+
return ToolResult(output="No matches found.")
|
|
210
|
+
|
|
211
|
+
truncated = len(output.encode("utf-8")) >= _MAX_GREP_OUTPUT_BYTES
|
|
212
|
+
return ToolResult(output=output, truncated=truncated)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# ripgrep implementation
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
def _rg_available() -> bool:
|
|
220
|
+
import shutil
|
|
221
|
+
return shutil.which("rg") is not None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _rg_search(
|
|
225
|
+
pattern: str,
|
|
226
|
+
root: Path,
|
|
227
|
+
*,
|
|
228
|
+
glob_filter: str | None,
|
|
229
|
+
case_insensitive: bool,
|
|
230
|
+
context_lines: int,
|
|
231
|
+
) -> tuple[str, str | None]:
|
|
232
|
+
"""Run ripgrep; return (output, error)."""
|
|
233
|
+
args = ["rg", "--line-number", "--no-heading", "--color=never"]
|
|
234
|
+
if case_insensitive:
|
|
235
|
+
args.append("--ignore-case")
|
|
236
|
+
if context_lines:
|
|
237
|
+
args.extend(["-C", str(context_lines)])
|
|
238
|
+
if glob_filter:
|
|
239
|
+
args.extend(["--glob", glob_filter])
|
|
240
|
+
# Exclude default dirs.
|
|
241
|
+
for d in _DEFAULT_EXCLUDE_DIRS:
|
|
242
|
+
args.extend(["--glob", f"!{d}/**"])
|
|
243
|
+
args.extend(["--", pattern, str(root)])
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
result = subprocess.run(args, capture_output=True, text=True, timeout=30)
|
|
247
|
+
output = result.stdout
|
|
248
|
+
if len(output.encode("utf-8")) > _MAX_GREP_OUTPUT_BYTES:
|
|
249
|
+
output = output.encode("utf-8")[:_MAX_GREP_OUTPUT_BYTES].decode("utf-8", errors="replace")
|
|
250
|
+
return output, None
|
|
251
|
+
except subprocess.TimeoutExpired:
|
|
252
|
+
return "", "Grep timed out after 30s"
|
|
253
|
+
except Exception as exc: # noqa: BLE001
|
|
254
|
+
return "", str(exc)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Pure-Python fallback
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _python_search(
|
|
262
|
+
pattern: str,
|
|
263
|
+
root: Path,
|
|
264
|
+
*,
|
|
265
|
+
glob_filter: str | None,
|
|
266
|
+
case_insensitive: bool,
|
|
267
|
+
context_lines: int,
|
|
268
|
+
) -> tuple[str, str | None]:
|
|
269
|
+
"""Walk root and search files with re; return (output, error)."""
|
|
270
|
+
flags = re.IGNORECASE if case_insensitive else 0
|
|
271
|
+
try:
|
|
272
|
+
compiled = re.compile(pattern, flags)
|
|
273
|
+
except re.error as exc:
|
|
274
|
+
return "", f"Invalid regex: {exc}"
|
|
275
|
+
|
|
276
|
+
file_glob = glob_filter or "*"
|
|
277
|
+
results: list[str] = []
|
|
278
|
+
total_bytes = 0
|
|
279
|
+
|
|
280
|
+
for filepath in root.rglob(file_glob):
|
|
281
|
+
if not filepath.is_file():
|
|
282
|
+
continue
|
|
283
|
+
if any(part in _DEFAULT_EXCLUDE_DIRS for part in filepath.parts):
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
287
|
+
except OSError:
|
|
288
|
+
continue
|
|
289
|
+
|
|
290
|
+
for i, line in enumerate(lines):
|
|
291
|
+
if compiled.search(line):
|
|
292
|
+
ctx_start = max(0, i - context_lines)
|
|
293
|
+
ctx_end = min(len(lines), i + context_lines + 1)
|
|
294
|
+
for j in range(ctx_start, ctx_end):
|
|
295
|
+
prefix = ">" if j == i else " "
|
|
296
|
+
entry = f"{filepath}:{j + 1}{prefix} {lines[j]}"
|
|
297
|
+
results.append(entry)
|
|
298
|
+
total_bytes += len(entry.encode("utf-8"))
|
|
299
|
+
if total_bytes >= _MAX_GREP_OUTPUT_BYTES:
|
|
300
|
+
return "\n".join(results), None
|
|
301
|
+
|
|
302
|
+
return "\n".join(results), None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Auto-register
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
REGISTRY.register(GlobTool())
|
|
310
|
+
REGISTRY.register(GrepTool())
|