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.
@@ -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 in {"RELATIONSHIP", "IMPACT", "SEMANTIC"} or planned == ["graph"] or not planned:
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
- errors,
243
- unavailable,
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