pudu-ai 0.2.18 → 0.2.21
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.
- package/README.es.md +85 -0
- package/README.md +19 -2
- package/dist/cli/brew-ZRR7D4XL.js +80 -0
- package/dist/cli/chunk-6C5EPKRI.js +1597 -0
- package/dist/cli/chunk-QGEXKMQ5.js +501 -0
- package/dist/cli/chunk-SS7OXXQQ.js +82 -0
- package/dist/cli/index.js +191 -2991
- package/dist/cli/render-3UBPZNF4.js +812 -0
- package/dist/cli/repo-cli-4GSG5MD3.js +255 -0
- package/package.json +3 -2
- package/python/pudu_agent/__init__.py +1 -0
- package/python/pudu_agent/__main__.py +4 -0
- package/python/pudu_agent/__pycache__/__init__.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/__main__.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/ast_search.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/protocol.cpython-312.pyc +0 -0
- package/python/pudu_agent/__pycache__/search.cpython-312.pyc +0 -0
- package/python/pudu_agent/ast_search.py +153 -0
- package/python/pudu_agent/protocol.py +96 -0
- package/python/pudu_agent/search.py +342 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pudu_agent.ast_search import run_ast_grep, tool_info as ast_tool_info
|
|
11
|
+
from pudu_agent.protocol import (
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
classify_intent,
|
|
14
|
+
error_response,
|
|
15
|
+
read_request,
|
|
16
|
+
strategies_for,
|
|
17
|
+
write_response,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
RG_TIMEOUT_S = 20
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _which(name: str) -> str | None:
|
|
24
|
+
return shutil.which(name)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version(binary: str) -> str | None:
|
|
28
|
+
try:
|
|
29
|
+
proc = subprocess.run(
|
|
30
|
+
[binary, "--version"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=5,
|
|
34
|
+
check=False,
|
|
35
|
+
)
|
|
36
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
37
|
+
return None
|
|
38
|
+
line = (proc.stdout or proc.stderr or "").strip().splitlines()
|
|
39
|
+
return line[0] if line else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def rg_tool_info() -> dict[str, Any]:
|
|
43
|
+
path = _which("rg")
|
|
44
|
+
return {
|
|
45
|
+
"available": path is not None,
|
|
46
|
+
"path": path,
|
|
47
|
+
"version": _version(path) if path else None,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def graph_tool_info() -> dict[str, Any]:
|
|
52
|
+
return {"available": False, "path": None, "version": None}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_rg_json_line(line: str) -> dict[str, Any] | None:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if not line:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
msg = json.loads(line)
|
|
61
|
+
except ValueError:
|
|
62
|
+
return None
|
|
63
|
+
if not isinstance(msg, dict) or msg.get("type") != "match":
|
|
64
|
+
return None
|
|
65
|
+
data = msg.get("data") or {}
|
|
66
|
+
path_obj = data.get("path") or {}
|
|
67
|
+
lines_obj = data.get("lines") or {}
|
|
68
|
+
text = lines_obj.get("text")
|
|
69
|
+
if not isinstance(text, str):
|
|
70
|
+
text = ""
|
|
71
|
+
sub = (data.get("submatches") or [{}])[0]
|
|
72
|
+
start = sub.get("start") if isinstance(sub, dict) else None
|
|
73
|
+
file_path = path_obj.get("text") if isinstance(path_obj, dict) else None
|
|
74
|
+
line_no = data.get("line_number")
|
|
75
|
+
return {
|
|
76
|
+
"file": file_path if isinstance(file_path, str) else "",
|
|
77
|
+
"line": line_no if isinstance(line_no, int) else None,
|
|
78
|
+
"column": start + 1 if isinstance(start, int) else None,
|
|
79
|
+
"endLine": line_no if isinstance(line_no, int) else None,
|
|
80
|
+
"endColumn": None,
|
|
81
|
+
"text": text.rstrip("\n"),
|
|
82
|
+
"strategy": "rg",
|
|
83
|
+
"language": None,
|
|
84
|
+
"metavariables": {},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_rg(
|
|
89
|
+
repo: Path,
|
|
90
|
+
query: str,
|
|
91
|
+
globs: list[str],
|
|
92
|
+
limit: int,
|
|
93
|
+
timeout_s: float,
|
|
94
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
95
|
+
binary = _which("rg")
|
|
96
|
+
if not binary:
|
|
97
|
+
return [], [{"tool": "rg", "message": "rg not found on PATH", "origin": "MEASURED"}]
|
|
98
|
+
args = [
|
|
99
|
+
binary,
|
|
100
|
+
"--json",
|
|
101
|
+
"--no-mmap",
|
|
102
|
+
"-F",
|
|
103
|
+
"--glob",
|
|
104
|
+
"!**/.git/**",
|
|
105
|
+
"--glob",
|
|
106
|
+
"!**/node_modules/**",
|
|
107
|
+
"--glob",
|
|
108
|
+
"!**/.pudu-ai/**",
|
|
109
|
+
]
|
|
110
|
+
for glob in globs:
|
|
111
|
+
args.extend(["--glob", glob])
|
|
112
|
+
args.extend(["--", query, str(repo)])
|
|
113
|
+
try:
|
|
114
|
+
proc = subprocess.run(
|
|
115
|
+
args,
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
timeout=timeout_s,
|
|
119
|
+
check=False,
|
|
120
|
+
)
|
|
121
|
+
except subprocess.TimeoutExpired:
|
|
122
|
+
return [], [{"tool": "rg", "message": "rg timed out", "origin": "MEASURED"}]
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
return [], [{"tool": "rg", "message": str(exc), "origin": "MEASURED"}]
|
|
125
|
+
matches: list[dict[str, Any]] = []
|
|
126
|
+
for line in (proc.stdout or "").splitlines():
|
|
127
|
+
parsed = parse_rg_json_line(line)
|
|
128
|
+
if parsed:
|
|
129
|
+
matches.append(parsed)
|
|
130
|
+
if len(matches) >= limit:
|
|
131
|
+
break
|
|
132
|
+
errors: list[dict[str, Any]] = []
|
|
133
|
+
if proc.returncode not in (0, 1):
|
|
134
|
+
err = (proc.stderr or "").strip() or f"rg exit {proc.returncode}"
|
|
135
|
+
errors.append({"tool": "rg", "message": err, "origin": "MEASURED"})
|
|
136
|
+
return matches, errors
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def empty_search(
|
|
140
|
+
repo: str,
|
|
141
|
+
query: str | None,
|
|
142
|
+
structural: str | None,
|
|
143
|
+
intent: str,
|
|
144
|
+
strategy: str,
|
|
145
|
+
tools: dict[str, Any],
|
|
146
|
+
errors: list[dict[str, Any]],
|
|
147
|
+
unavailable: list[str],
|
|
148
|
+
duration_ms: int,
|
|
149
|
+
rg_queries: int = 0,
|
|
150
|
+
ast_queries: int = 0,
|
|
151
|
+
) -> dict[str, Any]:
|
|
152
|
+
return {
|
|
153
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
154
|
+
"ok": True,
|
|
155
|
+
"op": "search",
|
|
156
|
+
"repo": repo,
|
|
157
|
+
"query": query,
|
|
158
|
+
"structuralPattern": structural,
|
|
159
|
+
"intent": intent,
|
|
160
|
+
"strategy": strategy,
|
|
161
|
+
"matches": [],
|
|
162
|
+
"tools": tools,
|
|
163
|
+
"errors": errors,
|
|
164
|
+
"metrics": {
|
|
165
|
+
"durationMs": duration_ms,
|
|
166
|
+
"matchCount": 0,
|
|
167
|
+
"rgQueries": rg_queries,
|
|
168
|
+
"astQueries": ast_queries,
|
|
169
|
+
"graphQueries": 0,
|
|
170
|
+
"origin": "MEASURED",
|
|
171
|
+
},
|
|
172
|
+
"unavailable": unavailable,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def handle_search(req: dict[str, Any]) -> dict[str, Any]:
|
|
177
|
+
started = time.perf_counter()
|
|
178
|
+
repo_raw = req.get("repo") or "."
|
|
179
|
+
repo = Path(repo_raw).expanduser().resolve()
|
|
180
|
+
query = req.get("query")
|
|
181
|
+
if isinstance(query, str):
|
|
182
|
+
query = query.strip() or None
|
|
183
|
+
else:
|
|
184
|
+
query = None
|
|
185
|
+
structural = req.get("structuralPattern")
|
|
186
|
+
if isinstance(structural, str):
|
|
187
|
+
structural = structural.strip() or None
|
|
188
|
+
else:
|
|
189
|
+
structural = None
|
|
190
|
+
globs = req.get("globs") or []
|
|
191
|
+
if not isinstance(globs, list):
|
|
192
|
+
globs = []
|
|
193
|
+
globs = [g for g in globs if isinstance(g, str) and g]
|
|
194
|
+
limit = req.get("limit")
|
|
195
|
+
limit = int(limit) if isinstance(limit, int) and limit > 0 else 100
|
|
196
|
+
timeout_ms = req.get("timeoutMs")
|
|
197
|
+
timeout_s = max(1.0, (timeout_ms / 1000) if isinstance(timeout_ms, int) else RG_TIMEOUT_S)
|
|
198
|
+
|
|
199
|
+
tools = {
|
|
200
|
+
"rg": rg_tool_info(),
|
|
201
|
+
"astGrep": ast_tool_info(),
|
|
202
|
+
"graph": graph_tool_info(),
|
|
203
|
+
}
|
|
204
|
+
explicit = req.get("intent") if isinstance(req.get("intent"), str) else None
|
|
205
|
+
intent = classify_intent(query, structural, explicit)
|
|
206
|
+
planned = strategies_for(intent)
|
|
207
|
+
strategy = "hybrid" if len(planned) > 1 else (planned[0] if planned else "hybrid")
|
|
208
|
+
|
|
209
|
+
if not repo.is_dir():
|
|
210
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
211
|
+
result = empty_search(
|
|
212
|
+
str(repo),
|
|
213
|
+
query,
|
|
214
|
+
structural,
|
|
215
|
+
intent,
|
|
216
|
+
strategy,
|
|
217
|
+
tools,
|
|
218
|
+
[{"tool": "search", "message": "repo is not a directory", "origin": "MEASURED"}],
|
|
219
|
+
[],
|
|
220
|
+
duration_ms,
|
|
221
|
+
)
|
|
222
|
+
result["ok"] = False
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
if intent in {"RELATIONSHIP", "IMPACT", "SEMANTIC"} or planned == ["graph"] or not planned:
|
|
226
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
227
|
+
unavailable = ["graph"] if intent in {"RELATIONSHIP", "IMPACT"} else (["semantic"] if intent == "SEMANTIC" else planned)
|
|
228
|
+
errors = [
|
|
229
|
+
{
|
|
230
|
+
"tool": "graph" if intent != "SEMANTIC" else "semantic",
|
|
231
|
+
"message": "not implemented in Agent Lab iteration 1",
|
|
232
|
+
"origin": "MEASURED",
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
return empty_search(
|
|
236
|
+
str(repo),
|
|
237
|
+
query,
|
|
238
|
+
structural,
|
|
239
|
+
intent,
|
|
240
|
+
strategy if planned else "hybrid",
|
|
241
|
+
tools,
|
|
242
|
+
errors,
|
|
243
|
+
unavailable,
|
|
244
|
+
duration_ms,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
matches: list[dict[str, Any]] = []
|
|
248
|
+
errors: list[dict[str, Any]] = []
|
|
249
|
+
unavailable: list[str] = []
|
|
250
|
+
rg_queries = 0
|
|
251
|
+
ast_queries = 0
|
|
252
|
+
pattern_for_ast = structural or query
|
|
253
|
+
|
|
254
|
+
if "rg" in planned:
|
|
255
|
+
if not query:
|
|
256
|
+
errors.append({"tool": "rg", "message": "query required for text search", "origin": "MEASURED"})
|
|
257
|
+
elif not tools["rg"]["available"]:
|
|
258
|
+
unavailable.append("rg")
|
|
259
|
+
errors.append({"tool": "rg", "message": "rg not found on PATH", "origin": "MEASURED"})
|
|
260
|
+
else:
|
|
261
|
+
rg_queries = 1
|
|
262
|
+
found, rg_errors = run_rg(repo, query, globs, limit, timeout_s)
|
|
263
|
+
matches.extend(found)
|
|
264
|
+
errors.extend(rg_errors)
|
|
265
|
+
|
|
266
|
+
if "ast-grep" in planned:
|
|
267
|
+
if not pattern_for_ast:
|
|
268
|
+
errors.append({"tool": "ast-grep", "message": "pattern required for structural search", "origin": "MEASURED"})
|
|
269
|
+
elif not tools["astGrep"]["available"]:
|
|
270
|
+
unavailable.append("ast-grep")
|
|
271
|
+
errors.append({"tool": "ast-grep", "message": "ast-grep not found on PATH", "origin": "MEASURED"})
|
|
272
|
+
else:
|
|
273
|
+
ast_queries = 1
|
|
274
|
+
remaining = max(0, limit - len(matches))
|
|
275
|
+
found, ast_errors = run_ast_grep(repo, pattern_for_ast, globs, remaining or limit, timeout_s)
|
|
276
|
+
matches.extend(found)
|
|
277
|
+
errors.extend(ast_errors)
|
|
278
|
+
|
|
279
|
+
if strategy == "rg" and "rg" in unavailable:
|
|
280
|
+
ok = False
|
|
281
|
+
elif strategy == "ast-grep" and "ast-grep" in unavailable:
|
|
282
|
+
ok = False
|
|
283
|
+
else:
|
|
284
|
+
ok = True
|
|
285
|
+
|
|
286
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
287
|
+
return {
|
|
288
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
289
|
+
"ok": ok,
|
|
290
|
+
"op": "search",
|
|
291
|
+
"repo": str(repo),
|
|
292
|
+
"query": query,
|
|
293
|
+
"structuralPattern": structural,
|
|
294
|
+
"intent": intent,
|
|
295
|
+
"strategy": strategy,
|
|
296
|
+
"matches": matches[:limit],
|
|
297
|
+
"tools": tools,
|
|
298
|
+
"errors": errors,
|
|
299
|
+
"metrics": {
|
|
300
|
+
"durationMs": duration_ms,
|
|
301
|
+
"matchCount": min(len(matches), limit),
|
|
302
|
+
"rgQueries": rg_queries,
|
|
303
|
+
"astQueries": ast_queries,
|
|
304
|
+
"graphQueries": 0,
|
|
305
|
+
"origin": "MEASURED",
|
|
306
|
+
},
|
|
307
|
+
"unavailable": unavailable,
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def main() -> int:
|
|
312
|
+
try:
|
|
313
|
+
req = read_request()
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
write_response(error_response(str(exc)))
|
|
316
|
+
return 1
|
|
317
|
+
op = req.get("op") or "search"
|
|
318
|
+
if op == "ping":
|
|
319
|
+
write_response({"schemaVersion": PROTOCOL_VERSION, "ok": True, "op": "ping"})
|
|
320
|
+
return 0
|
|
321
|
+
if op == "classify":
|
|
322
|
+
intent = classify_intent(req.get("query"), req.get("structuralPattern"), req.get("intent"))
|
|
323
|
+
write_response(
|
|
324
|
+
{
|
|
325
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
326
|
+
"ok": True,
|
|
327
|
+
"op": "classify",
|
|
328
|
+
"intent": intent,
|
|
329
|
+
"strategies": strategies_for(intent),
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
return 0
|
|
333
|
+
if op != "search":
|
|
334
|
+
write_response(error_response(f"unknown op: {op}"))
|
|
335
|
+
return 1
|
|
336
|
+
result = handle_search(req)
|
|
337
|
+
write_response(result)
|
|
338
|
+
return 0 if result.get("ok") else 1
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
raise SystemExit(main())
|