pudu-ai 0.2.21 → 0.2.23
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.md +2 -0
- package/dist/cli/chunk-473MNTMS.js +24 -0
- package/dist/cli/chunk-47LK5I2X.js +39 -0
- package/dist/cli/{chunk-QGEXKMQ5.js → chunk-4VEDGI4L.js} +56 -31
- package/dist/cli/{chunk-6C5EPKRI.js → chunk-6BGYI7XY.js} +7 -38
- package/dist/cli/chunk-7KPNGK6A.js +61 -0
- package/dist/cli/chunk-XEEGLMVT.js +25 -0
- package/dist/cli/harness-launch-KEUKZZXH.js +118 -0
- package/dist/cli/index.js +17 -7
- package/dist/cli/{render-3UBPZNF4.js → render-Y4DNH7FW.js} +279 -26
- package/dist/cli/{repo-cli-4GSG5MD3.js → repo-cli-BK76EVBZ.js} +90 -71
- package/package.json +1 -1
- package/python/pudu_agent/__main__.py +7 -1
- 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__/graph.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/graph.py +398 -0
- package/python/pudu_agent/mcp_server.py +140 -0
- package/python/pudu_agent/search.py +61 -15
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
SKIP_DIRS = {
|
|
10
|
+
".git",
|
|
11
|
+
"node_modules",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".pudu-ai",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
".tox",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
}
|
|
21
|
+
MAX_FILES = 500
|
|
22
|
+
MAX_EDGES = 3000
|
|
23
|
+
MAX_BYTES = 400_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def graph_tool_info() -> dict[str, Any]:
|
|
27
|
+
return {"available": True, "path": "python-ast", "version": "stdlib"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _rel(repo: Path, path: Path) -> str:
|
|
31
|
+
try:
|
|
32
|
+
return str(path.relative_to(repo)).replace("\\", "/")
|
|
33
|
+
except ValueError:
|
|
34
|
+
return str(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_py_files(repo: Path) -> list[Path]:
|
|
38
|
+
files: list[Path] = []
|
|
39
|
+
for path in repo.rglob("*.py"):
|
|
40
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
41
|
+
continue
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
continue
|
|
44
|
+
files.append(path)
|
|
45
|
+
if len(files) >= MAX_FILES:
|
|
46
|
+
break
|
|
47
|
+
return files
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _module_name(rel: str) -> str:
|
|
51
|
+
body = rel[:-3] if rel.endswith(".py") else rel
|
|
52
|
+
if body.endswith("/__init__"):
|
|
53
|
+
body = body[: -len("/__init__")]
|
|
54
|
+
return body.replace("/", ".")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_test(rel: str) -> bool:
|
|
58
|
+
name = Path(rel).name
|
|
59
|
+
return name.startswith("test_") or name.endswith("_test.py") or "/tests/" in f"/{rel}/"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_graphify(repo: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
|
63
|
+
path = repo / "graphify-out" / "graph.json"
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
return [], [], []
|
|
66
|
+
try:
|
|
67
|
+
raw = json.loads(path.read_text(encoding="utf8"))
|
|
68
|
+
except (OSError, ValueError) as exc:
|
|
69
|
+
return [], [], [{"tool": "graphify", "message": str(exc), "origin": "MEASURED"}]
|
|
70
|
+
nodes_in = raw.get("nodes") if isinstance(raw, dict) else None
|
|
71
|
+
edges_in = raw.get("edges") if isinstance(raw, dict) else None
|
|
72
|
+
if not isinstance(nodes_in, list) or not isinstance(edges_in, list):
|
|
73
|
+
return [], [], [{"tool": "graphify", "message": "graphify-out/graph.json missing nodes/edges arrays", "origin": "MEASURED"}]
|
|
74
|
+
nodes: list[dict[str, Any]] = []
|
|
75
|
+
for item in nodes_in:
|
|
76
|
+
if not isinstance(item, dict):
|
|
77
|
+
continue
|
|
78
|
+
ident = item.get("id") or item.get("name")
|
|
79
|
+
if not isinstance(ident, str):
|
|
80
|
+
continue
|
|
81
|
+
kind = item.get("type") or item.get("kind") or "file"
|
|
82
|
+
nodes.append(
|
|
83
|
+
{
|
|
84
|
+
"id": ident,
|
|
85
|
+
"type": kind if isinstance(kind, str) else "file",
|
|
86
|
+
"name": item.get("name") if isinstance(item.get("name"), str) else ident,
|
|
87
|
+
"file": item.get("file") if isinstance(item.get("file"), str) else None,
|
|
88
|
+
"line": item.get("line") if isinstance(item.get("line"), int) else None,
|
|
89
|
+
"backend": "graphify",
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
edges: list[dict[str, Any]] = []
|
|
93
|
+
allowed = {"imports", "calls", "inherits", "references", "definitions"}
|
|
94
|
+
for item in edges_in:
|
|
95
|
+
if not isinstance(item, dict):
|
|
96
|
+
continue
|
|
97
|
+
source = item.get("source") or item.get("from")
|
|
98
|
+
target = item.get("target") or item.get("to")
|
|
99
|
+
kind = item.get("type") or item.get("kind") or item.get("relation")
|
|
100
|
+
if not isinstance(source, str) or not isinstance(target, str) or not isinstance(kind, str):
|
|
101
|
+
continue
|
|
102
|
+
if kind not in allowed:
|
|
103
|
+
continue
|
|
104
|
+
edges.append(
|
|
105
|
+
{
|
|
106
|
+
"source": source,
|
|
107
|
+
"target": target,
|
|
108
|
+
"type": kind,
|
|
109
|
+
"confidence": "EXTRACTED",
|
|
110
|
+
"file": item.get("file") if isinstance(item.get("file"), str) else None,
|
|
111
|
+
"line": item.get("line") if isinstance(item.get("line"), int) else None,
|
|
112
|
+
"symbol": item.get("symbol") if isinstance(item.get("symbol"), str) else None,
|
|
113
|
+
"backend": "graphify",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return nodes, edges, []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_python_graph(repo: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]], int, list[dict[str, Any]]]:
|
|
120
|
+
nodes: list[dict[str, Any]] = []
|
|
121
|
+
edges: list[dict[str, Any]] = []
|
|
122
|
+
errors: list[dict[str, Any]] = []
|
|
123
|
+
chars = 0
|
|
124
|
+
seen_nodes: set[str] = set()
|
|
125
|
+
|
|
126
|
+
def add_node(node: dict[str, Any]) -> None:
|
|
127
|
+
ident = node["id"]
|
|
128
|
+
if ident in seen_nodes:
|
|
129
|
+
return
|
|
130
|
+
seen_nodes.add(ident)
|
|
131
|
+
nodes.append(node)
|
|
132
|
+
|
|
133
|
+
for path in _iter_py_files(repo):
|
|
134
|
+
rel = _rel(repo, path)
|
|
135
|
+
try:
|
|
136
|
+
source = path.read_text(encoding="utf8", errors="replace")
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: {exc}", "origin": "MEASURED"})
|
|
139
|
+
continue
|
|
140
|
+
if len(source) > MAX_BYTES:
|
|
141
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: skipped, file larger than {MAX_BYTES} bytes", "origin": "MEASURED"})
|
|
142
|
+
continue
|
|
143
|
+
chars += len(source)
|
|
144
|
+
try:
|
|
145
|
+
tree = ast.parse(source, filename=rel)
|
|
146
|
+
except SyntaxError as exc:
|
|
147
|
+
errors.append({"tool": "python-ast", "message": f"{rel}: {exc.msg}", "origin": "MEASURED"})
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
file_id = f"file:{rel}"
|
|
151
|
+
mod_id = f"module:{_module_name(rel)}"
|
|
152
|
+
add_node({"id": file_id, "type": "test" if _is_test(rel) else "file", "name": rel, "file": rel, "line": 1, "backend": "python-ast"})
|
|
153
|
+
add_node({"id": mod_id, "type": "module", "name": _module_name(rel), "file": rel, "line": 1, "backend": "python-ast"})
|
|
154
|
+
edges.append(
|
|
155
|
+
{
|
|
156
|
+
"source": file_id,
|
|
157
|
+
"target": mod_id,
|
|
158
|
+
"type": "definitions",
|
|
159
|
+
"confidence": "EXTRACTED",
|
|
160
|
+
"file": rel,
|
|
161
|
+
"line": 1,
|
|
162
|
+
"symbol": _module_name(rel),
|
|
163
|
+
"backend": "python-ast",
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
defs: dict[str, str] = {}
|
|
168
|
+
current_fn: str | None = None
|
|
169
|
+
|
|
170
|
+
class Visitor(ast.NodeVisitor):
|
|
171
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
172
|
+
nonlocal current_fn
|
|
173
|
+
ident = f"def:{rel}:{node.name}"
|
|
174
|
+
add_node({"id": ident, "type": "function", "name": node.name, "file": rel, "line": node.lineno, "backend": "python-ast"})
|
|
175
|
+
defs[node.name] = ident
|
|
176
|
+
edges.append(
|
|
177
|
+
{
|
|
178
|
+
"source": file_id,
|
|
179
|
+
"target": ident,
|
|
180
|
+
"type": "definitions",
|
|
181
|
+
"confidence": "EXTRACTED",
|
|
182
|
+
"file": rel,
|
|
183
|
+
"line": node.lineno,
|
|
184
|
+
"symbol": node.name,
|
|
185
|
+
"backend": "python-ast",
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
prev = current_fn
|
|
189
|
+
current_fn = ident
|
|
190
|
+
self.generic_visit(node)
|
|
191
|
+
current_fn = prev
|
|
192
|
+
|
|
193
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
194
|
+
self.visit_FunctionDef(node) # type: ignore[arg-type]
|
|
195
|
+
|
|
196
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
197
|
+
ident = f"class:{rel}:{node.name}"
|
|
198
|
+
add_node({"id": ident, "type": "class", "name": node.name, "file": rel, "line": node.lineno, "backend": "python-ast"})
|
|
199
|
+
defs[node.name] = ident
|
|
200
|
+
edges.append(
|
|
201
|
+
{
|
|
202
|
+
"source": file_id,
|
|
203
|
+
"target": ident,
|
|
204
|
+
"type": "definitions",
|
|
205
|
+
"confidence": "EXTRACTED",
|
|
206
|
+
"file": rel,
|
|
207
|
+
"line": node.lineno,
|
|
208
|
+
"symbol": node.name,
|
|
209
|
+
"backend": "python-ast",
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
for base in node.bases:
|
|
213
|
+
name = base.id if isinstance(base, ast.Name) else None
|
|
214
|
+
if not name:
|
|
215
|
+
continue
|
|
216
|
+
target = defs.get(name, f"name:{name}")
|
|
217
|
+
if target.startswith("name:"):
|
|
218
|
+
add_node({"id": target, "type": "name", "name": name, "file": None, "line": None, "backend": "python-ast"})
|
|
219
|
+
edges.append(
|
|
220
|
+
{
|
|
221
|
+
"source": ident,
|
|
222
|
+
"target": target,
|
|
223
|
+
"type": "inherits",
|
|
224
|
+
"confidence": "EXTRACTED" if target in defs.values() else "EXTRACTED",
|
|
225
|
+
"file": rel,
|
|
226
|
+
"line": node.lineno,
|
|
227
|
+
"symbol": name,
|
|
228
|
+
"backend": "python-ast",
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
self.generic_visit(node)
|
|
232
|
+
|
|
233
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
234
|
+
for alias in node.names:
|
|
235
|
+
target = f"module:{alias.name}"
|
|
236
|
+
add_node({"id": target, "type": "module", "name": alias.name, "file": None, "line": None, "backend": "python-ast"})
|
|
237
|
+
edges.append(
|
|
238
|
+
{
|
|
239
|
+
"source": file_id,
|
|
240
|
+
"target": target,
|
|
241
|
+
"type": "imports",
|
|
242
|
+
"confidence": "EXTRACTED",
|
|
243
|
+
"file": rel,
|
|
244
|
+
"line": node.lineno,
|
|
245
|
+
"symbol": alias.name,
|
|
246
|
+
"backend": "python-ast",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
251
|
+
if not node.module:
|
|
252
|
+
return
|
|
253
|
+
target = f"module:{node.module}"
|
|
254
|
+
add_node({"id": target, "type": "module", "name": node.module, "file": None, "line": None, "backend": "python-ast"})
|
|
255
|
+
edges.append(
|
|
256
|
+
{
|
|
257
|
+
"source": file_id,
|
|
258
|
+
"target": target,
|
|
259
|
+
"type": "imports",
|
|
260
|
+
"confidence": "EXTRACTED",
|
|
261
|
+
"file": rel,
|
|
262
|
+
"line": node.lineno,
|
|
263
|
+
"symbol": node.module,
|
|
264
|
+
"backend": "python-ast",
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
269
|
+
name: str | None = None
|
|
270
|
+
if isinstance(node.func, ast.Name):
|
|
271
|
+
name = node.func.id
|
|
272
|
+
elif isinstance(node.func, ast.Attribute):
|
|
273
|
+
name = node.func.attr
|
|
274
|
+
if name and current_fn:
|
|
275
|
+
target = defs.get(name, f"name:{name}")
|
|
276
|
+
confidence = "RESOLVED" if name in defs else "EXTRACTED"
|
|
277
|
+
if target.startswith("name:"):
|
|
278
|
+
add_node({"id": target, "type": "name", "name": name, "file": None, "line": None, "backend": "python-ast"})
|
|
279
|
+
edges.append(
|
|
280
|
+
{
|
|
281
|
+
"source": current_fn,
|
|
282
|
+
"target": target,
|
|
283
|
+
"type": "calls",
|
|
284
|
+
"confidence": confidence,
|
|
285
|
+
"file": rel,
|
|
286
|
+
"line": getattr(node, "lineno", None),
|
|
287
|
+
"symbol": name,
|
|
288
|
+
"backend": "python-ast",
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
self.generic_visit(node)
|
|
292
|
+
|
|
293
|
+
Visitor().visit(tree)
|
|
294
|
+
if len(edges) >= MAX_EDGES:
|
|
295
|
+
errors.append({"tool": "python-ast", "message": f"edge cap {MAX_EDGES} reached", "origin": "MEASURED"})
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
return nodes, edges[:MAX_EDGES], chars, errors
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def effort_from_counts(file_count: int, edge_count: int, token_estimate: int) -> dict[str, Any]:
|
|
302
|
+
score = min(100, int(round(file_count * 1.2 + edge_count * 0.15 + token_estimate / 800)))
|
|
303
|
+
if score < 20:
|
|
304
|
+
label = "LOW"
|
|
305
|
+
elif score < 45:
|
|
306
|
+
label = "MEDIUM"
|
|
307
|
+
elif score < 75:
|
|
308
|
+
label = "HIGH"
|
|
309
|
+
else:
|
|
310
|
+
label = "VERY_HIGH"
|
|
311
|
+
return {
|
|
312
|
+
"label": label,
|
|
313
|
+
"score0to100": score,
|
|
314
|
+
"origin": "DERIVED",
|
|
315
|
+
"humanBaselineMinutes": None,
|
|
316
|
+
"agentRuntimeMinutes": None,
|
|
317
|
+
"humanInterventionMinutes": None,
|
|
318
|
+
"potentialTimeReductionPct": None,
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_graph(repo: Path, persist: bool = True) -> dict[str, Any]:
|
|
323
|
+
started = time.perf_counter()
|
|
324
|
+
py_nodes, py_edges, chars, errors = build_python_graph(repo)
|
|
325
|
+
gy_nodes, gy_edges, gy_errors = _load_graphify(repo)
|
|
326
|
+
errors.extend(gy_errors)
|
|
327
|
+
nodes = py_nodes + gy_nodes
|
|
328
|
+
edges = py_edges + gy_edges
|
|
329
|
+
backend = "hybrid" if gy_nodes or gy_edges else "python-ast"
|
|
330
|
+
token_estimate = max(0, chars // 4)
|
|
331
|
+
file_count = sum(1 for n in py_nodes if n["type"] in {"file", "test"})
|
|
332
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
333
|
+
payload = {
|
|
334
|
+
"schemaVersion": 1,
|
|
335
|
+
"ok": True,
|
|
336
|
+
"op": "graph",
|
|
337
|
+
"repo": str(repo),
|
|
338
|
+
"backend": backend,
|
|
339
|
+
"writtenTo": None,
|
|
340
|
+
"nodes": nodes,
|
|
341
|
+
"edges": edges,
|
|
342
|
+
"metrics": {
|
|
343
|
+
"fileCount": file_count,
|
|
344
|
+
"nodeCount": len(nodes),
|
|
345
|
+
"edgeCount": len(edges),
|
|
346
|
+
"tokenEstimate": token_estimate,
|
|
347
|
+
"tokenOrigin": "ESTIMATED",
|
|
348
|
+
"durationMs": duration_ms,
|
|
349
|
+
"origin": "MEASURED",
|
|
350
|
+
},
|
|
351
|
+
"effort": effort_from_counts(file_count, len(edges), token_estimate),
|
|
352
|
+
"errors": errors,
|
|
353
|
+
"tools": {"graph": graph_tool_info()},
|
|
354
|
+
}
|
|
355
|
+
if persist:
|
|
356
|
+
out_dir = repo / ".pudu-ai"
|
|
357
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
358
|
+
out = out_dir / "code-graph.json"
|
|
359
|
+
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf8")
|
|
360
|
+
payload["writtenTo"] = str(out)
|
|
361
|
+
return payload
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def query_graph(graph: dict[str, Any], query: str | None, limit: int) -> list[dict[str, Any]]:
|
|
365
|
+
if not query:
|
|
366
|
+
return []
|
|
367
|
+
needle = query.strip()
|
|
368
|
+
for prefix in ("who calls ", "callers of ", "impact of ", "dependencies of ", "imports of "):
|
|
369
|
+
if needle.lower().startswith(prefix):
|
|
370
|
+
needle = needle[len(prefix) :].strip()
|
|
371
|
+
break
|
|
372
|
+
needle = needle.split()[0] if needle.split() else needle
|
|
373
|
+
if not needle:
|
|
374
|
+
return []
|
|
375
|
+
matches: list[dict[str, Any]] = []
|
|
376
|
+
for edge in graph.get("edges") or []:
|
|
377
|
+
if not isinstance(edge, dict):
|
|
378
|
+
continue
|
|
379
|
+
symbol = edge.get("symbol") if isinstance(edge.get("symbol"), str) else ""
|
|
380
|
+
target = edge.get("target") if isinstance(edge.get("target"), str) else ""
|
|
381
|
+
if needle not in symbol and needle not in target:
|
|
382
|
+
continue
|
|
383
|
+
matches.append(
|
|
384
|
+
{
|
|
385
|
+
"file": edge.get("file") or "",
|
|
386
|
+
"line": edge.get("line") if isinstance(edge.get("line"), int) else None,
|
|
387
|
+
"column": None,
|
|
388
|
+
"endLine": edge.get("line") if isinstance(edge.get("line"), int) else None,
|
|
389
|
+
"endColumn": None,
|
|
390
|
+
"text": f"{edge.get('type')} {edge.get('source')} -> {edge.get('target')} [{edge.get('confidence')}]",
|
|
391
|
+
"strategy": "graph",
|
|
392
|
+
"language": "python",
|
|
393
|
+
"metavariables": {"symbol": symbol or needle},
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
if len(matches) >= limit:
|
|
397
|
+
break
|
|
398
|
+
return matches
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pudu_agent.graph import build_graph
|
|
9
|
+
from pudu_agent.search import handle_search
|
|
10
|
+
|
|
11
|
+
PROTOCOL = "2024-11-05"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read() -> dict[str, Any] | None:
|
|
15
|
+
headers: dict[str, str] = {}
|
|
16
|
+
while True:
|
|
17
|
+
line = sys.stdin.buffer.readline()
|
|
18
|
+
if not line:
|
|
19
|
+
return None
|
|
20
|
+
if line in (b"\r\n", b"\n"):
|
|
21
|
+
break
|
|
22
|
+
decoded = line.decode("utf-8", errors="replace")
|
|
23
|
+
if ":" not in decoded:
|
|
24
|
+
continue
|
|
25
|
+
key, value = decoded.split(":", 1)
|
|
26
|
+
headers[key.strip().lower()] = value.strip()
|
|
27
|
+
length = int(headers.get("content-length") or "0")
|
|
28
|
+
if length <= 0:
|
|
29
|
+
return None
|
|
30
|
+
body = sys.stdin.buffer.read(length)
|
|
31
|
+
return json.loads(body.decode("utf-8"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write(payload: dict[str, Any]) -> None:
|
|
35
|
+
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
36
|
+
sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii"))
|
|
37
|
+
sys.stdout.buffer.write(raw)
|
|
38
|
+
sys.stdout.buffer.flush()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _result(req_id: Any, result: Any) -> dict[str, Any]:
|
|
42
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _error(req_id: Any, message: str) -> dict[str, Any]:
|
|
46
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": message}}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _tools() -> list[dict[str, Any]]:
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
"name": "pudu_repo_search",
|
|
53
|
+
"description": "Deterministic repo search via rg/ast-grep/graph. No LLM. Never invents hits.",
|
|
54
|
+
"inputSchema": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"repo": {"type": "string"},
|
|
58
|
+
"query": {"type": "string"},
|
|
59
|
+
"intent": {"type": "string"},
|
|
60
|
+
"limit": {"type": "integer"},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "pudu_repo_graph",
|
|
66
|
+
"description": "Build a Python AST implementation graph. Edges are EXTRACTED or RESOLVED.",
|
|
67
|
+
"inputSchema": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {"repo": {"type": "string"}},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _call(name: str, args: dict[str, Any]) -> str:
|
|
76
|
+
repo = args.get("repo") or "."
|
|
77
|
+
if name == "pudu_repo_search":
|
|
78
|
+
result = handle_search(
|
|
79
|
+
{
|
|
80
|
+
"op": "search",
|
|
81
|
+
"repo": repo,
|
|
82
|
+
"query": args.get("query"),
|
|
83
|
+
"intent": args.get("intent"),
|
|
84
|
+
"limit": args.get("limit") or 50,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
return json.dumps(result, ensure_ascii=False)
|
|
88
|
+
if name == "pudu_repo_graph":
|
|
89
|
+
graph = build_graph(Path(str(repo)).expanduser().resolve(), persist=True)
|
|
90
|
+
slim = {
|
|
91
|
+
"ok": graph.get("ok"),
|
|
92
|
+
"repo": graph.get("repo"),
|
|
93
|
+
"backend": graph.get("backend"),
|
|
94
|
+
"metrics": graph.get("metrics"),
|
|
95
|
+
"effort": graph.get("effort"),
|
|
96
|
+
"writtenTo": graph.get("writtenTo"),
|
|
97
|
+
"nodeCount": len(graph.get("nodes") or []),
|
|
98
|
+
"edgeCount": len(graph.get("edges") or []),
|
|
99
|
+
}
|
|
100
|
+
return json.dumps(slim, ensure_ascii=False)
|
|
101
|
+
raise ValueError(f"unknown tool: {name}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
while True:
|
|
106
|
+
req = _read()
|
|
107
|
+
if req is None:
|
|
108
|
+
return 0
|
|
109
|
+
method = req.get("method")
|
|
110
|
+
req_id = req.get("id")
|
|
111
|
+
if method == "initialize":
|
|
112
|
+
_write(
|
|
113
|
+
_result(
|
|
114
|
+
req_id,
|
|
115
|
+
{
|
|
116
|
+
"protocolVersion": PROTOCOL,
|
|
117
|
+
"capabilities": {"tools": {}},
|
|
118
|
+
"serverInfo": {"name": "pudu-ai", "version": "0.2.23"},
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
if method == "notifications/initialized" or method == "initialized":
|
|
124
|
+
continue
|
|
125
|
+
if method == "tools/list":
|
|
126
|
+
_write(_result(req_id, {"tools": _tools()}))
|
|
127
|
+
continue
|
|
128
|
+
if method == "tools/call":
|
|
129
|
+
params = req.get("params") or {}
|
|
130
|
+
name = params.get("name")
|
|
131
|
+
arguments = params.get("arguments") or {}
|
|
132
|
+
try:
|
|
133
|
+
text = _call(str(name), arguments if isinstance(arguments, dict) else {})
|
|
134
|
+
_write(_result(req_id, {"content": [{"type": "text", "text": text}]}))
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_write(_error(req_id, str(exc)))
|
|
137
|
+
continue
|
|
138
|
+
if req_id is not None:
|
|
139
|
+
_write(_error(req_id, f"unknown method: {method}"))
|
|
140
|
+
return 0
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from pudu_agent.ast_search import run_ast_grep, tool_info as ast_tool_info
|
|
11
|
+
from pudu_agent.graph import build_graph, graph_tool_info, query_graph
|
|
11
12
|
from pudu_agent.protocol import (
|
|
12
13
|
PROTOCOL_VERSION,
|
|
13
14
|
classify_intent,
|
|
@@ -48,10 +49,6 @@ def rg_tool_info() -> dict[str, Any]:
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
|
|
51
|
-
def graph_tool_info() -> dict[str, Any]:
|
|
52
|
-
return {"available": False, "path": None, "version": None}
|
|
53
|
-
|
|
54
|
-
|
|
55
52
|
def parse_rg_json_line(line: str) -> dict[str, Any] | None:
|
|
56
53
|
line = line.strip()
|
|
57
54
|
if not line:
|
|
@@ -222,16 +219,8 @@ def handle_search(req: dict[str, Any]) -> dict[str, Any]:
|
|
|
222
219
|
result["ok"] = False
|
|
223
220
|
return result
|
|
224
221
|
|
|
225
|
-
if intent
|
|
222
|
+
if intent == "SEMANTIC" or not planned:
|
|
226
223
|
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
224
|
return empty_search(
|
|
236
225
|
str(repo),
|
|
237
226
|
query,
|
|
@@ -239,11 +228,39 @@ def handle_search(req: dict[str, Any]) -> dict[str, Any]:
|
|
|
239
228
|
intent,
|
|
240
229
|
strategy if planned else "hybrid",
|
|
241
230
|
tools,
|
|
242
|
-
|
|
243
|
-
|
|
231
|
+
[{"tool": "semantic", "message": "semantic search is not implemented; no LLM router", "origin": "MEASURED"}],
|
|
232
|
+
["semantic"],
|
|
244
233
|
duration_ms,
|
|
245
234
|
)
|
|
246
235
|
|
|
236
|
+
if intent in {"RELATIONSHIP", "IMPACT"} or planned == ["graph"]:
|
|
237
|
+
graph = build_graph(repo, persist=True)
|
|
238
|
+
duration_ms = int((time.perf_counter() - started) * 1000)
|
|
239
|
+
matches = query_graph(graph, query, limit)
|
|
240
|
+
errors = list(graph.get("errors") or [])
|
|
241
|
+
return {
|
|
242
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
243
|
+
"ok": True,
|
|
244
|
+
"op": "search",
|
|
245
|
+
"repo": str(repo),
|
|
246
|
+
"query": query,
|
|
247
|
+
"structuralPattern": structural,
|
|
248
|
+
"intent": intent,
|
|
249
|
+
"strategy": "graph",
|
|
250
|
+
"matches": matches,
|
|
251
|
+
"tools": tools,
|
|
252
|
+
"errors": errors,
|
|
253
|
+
"metrics": {
|
|
254
|
+
"durationMs": duration_ms,
|
|
255
|
+
"matchCount": len(matches),
|
|
256
|
+
"rgQueries": 0,
|
|
257
|
+
"astQueries": 0,
|
|
258
|
+
"graphQueries": 1,
|
|
259
|
+
"origin": "MEASURED",
|
|
260
|
+
},
|
|
261
|
+
"unavailable": [],
|
|
262
|
+
}
|
|
263
|
+
|
|
247
264
|
matches: list[dict[str, Any]] = []
|
|
248
265
|
errors: list[dict[str, Any]] = []
|
|
249
266
|
unavailable: list[str] = []
|
|
@@ -330,6 +347,35 @@ def main() -> int:
|
|
|
330
347
|
}
|
|
331
348
|
)
|
|
332
349
|
return 0
|
|
350
|
+
if op == "graph":
|
|
351
|
+
repo_raw = req.get("repo") or "."
|
|
352
|
+
repo = Path(repo_raw).expanduser().resolve()
|
|
353
|
+
if not repo.is_dir():
|
|
354
|
+
write_response(error_response("repo is not a directory", op="graph"))
|
|
355
|
+
return 1
|
|
356
|
+
result = build_graph(repo, persist=True)
|
|
357
|
+
write_response(result)
|
|
358
|
+
return 0
|
|
359
|
+
if op == "harness":
|
|
360
|
+
repo_raw = req.get("repo") or "."
|
|
361
|
+
repo = Path(repo_raw).expanduser().resolve()
|
|
362
|
+
task = req.get("task") if isinstance(req.get("task"), str) else None
|
|
363
|
+
if not repo.is_dir():
|
|
364
|
+
write_response(error_response("repo is not a directory", op="harness"))
|
|
365
|
+
return 1
|
|
366
|
+
graph = build_graph(repo, persist=True)
|
|
367
|
+
write_response(
|
|
368
|
+
{
|
|
369
|
+
"schemaVersion": PROTOCOL_VERSION,
|
|
370
|
+
"ok": True,
|
|
371
|
+
"op": "harness",
|
|
372
|
+
"repo": str(repo),
|
|
373
|
+
"task": task,
|
|
374
|
+
"graph": graph,
|
|
375
|
+
"effort": graph.get("effort"),
|
|
376
|
+
}
|
|
377
|
+
)
|
|
378
|
+
return 0
|
|
333
379
|
if op != "search":
|
|
334
380
|
write_response(error_response(f"unknown op: {op}"))
|
|
335
381
|
return 1
|