pyrepowiki-cli 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.
- pyrepowiki_cli-0.1.0.dist-info/METADATA +875 -0
- pyrepowiki_cli-0.1.0.dist-info/RECORD +28 -0
- pyrepowiki_cli-0.1.0.dist-info/WHEEL +4 -0
- pyrepowiki_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repowiki/__init__.py +3 -0
- repowiki/cli.py +43 -0
- repowiki/services/__init__.py +1 -0
- repowiki/services/codewiki/__init__.py +5 -0
- repowiki/services/codewiki/boq.py +57 -0
- repowiki/services/codewiki/cli.py +315 -0
- repowiki/services/codewiki/client.py +237 -0
- repowiki/services/codewiki/wiki.py +162 -0
- repowiki/services/deepwiki/__init__.py +5 -0
- repowiki/services/deepwiki/cli.py +851 -0
- repowiki/services/deepwiki/client.py +164 -0
- repowiki/services/deepwiki/codemap.py +74 -0
- repowiki/services/deepwiki/devin.py +355 -0
- repowiki/services/zread/__init__.py +5 -0
- repowiki/services/zread/cli.py +653 -0
- repowiki/services/zread/client.py +414 -0
- repowiki/services/zread/flight.py +116 -0
- repowiki/shared/__init__.py +1 -0
- repowiki/shared/async_.py +15 -0
- repowiki/shared/export.py +36 -0
- repowiki/shared/model.py +29 -0
- repowiki/shared/output.py +204 -0
- repowiki/shared/repo.py +45 -0
- repowiki/shared/save.py +27 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""MCP client wrapper for the DeepWiki API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from mcp import ClientSession
|
|
12
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
13
|
+
|
|
14
|
+
from repowiki.shared.model import Answer
|
|
15
|
+
|
|
16
|
+
logging.getLogger("httpx_sse").setLevel(logging.ERROR)
|
|
17
|
+
logging.getLogger("mcp.client.streamable_http").setLevel(logging.ERROR)
|
|
18
|
+
|
|
19
|
+
DEFAULT_MCP_URL = "https://mcp.deepwiki.com/mcp"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DeepWikiError(Exception):
|
|
23
|
+
"""Base exception for DeepWiki errors."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConnectionError(DeepWikiError):
|
|
27
|
+
"""Failed to connect to the DeepWiki server."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolError(DeepWikiError):
|
|
31
|
+
"""Error returned from an MCP tool execution."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _root_cause(exc: BaseException) -> BaseException:
|
|
35
|
+
"""Unwrap an exception group (Python 3.11+) to its first leaf cause."""
|
|
36
|
+
while (sub := getattr(exc, "exceptions", None)):
|
|
37
|
+
exc = sub[0]
|
|
38
|
+
return exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _is_connection_failure(exc: BaseException) -> bool:
|
|
42
|
+
if isinstance(exc, (OSError, httpx.TransportError)):
|
|
43
|
+
return True
|
|
44
|
+
message = str(exc).lower()
|
|
45
|
+
return "connection" in message or "timeout" in message
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DeepWikiClient:
|
|
49
|
+
"""Client for interacting with DeepWiki via MCP.
|
|
50
|
+
|
|
51
|
+
Can be used one-shot (each call opens and closes its own connection) or as
|
|
52
|
+
a persistent session via the async context manager, which reuses a single
|
|
53
|
+
connection across calls — the REPL uses this to avoid re-handshaking per
|
|
54
|
+
question.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, base_url: str | None = None) -> None:
|
|
58
|
+
self.base_url = base_url or os.environ.get("DEEPWIKI_MCP_URL", DEFAULT_MCP_URL)
|
|
59
|
+
self._stack: AsyncExitStack | None = None
|
|
60
|
+
self._session: ClientSession | None = None
|
|
61
|
+
|
|
62
|
+
# -- persistent session management -------------------------------------
|
|
63
|
+
|
|
64
|
+
async def __aenter__(self) -> "DeepWikiClient":
|
|
65
|
+
await self.open()
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
69
|
+
await self.close()
|
|
70
|
+
|
|
71
|
+
async def open(self) -> None:
|
|
72
|
+
"""Open and initialize a session, retrying once on connection failure."""
|
|
73
|
+
last: ConnectionError | None = None
|
|
74
|
+
for _ in range(2):
|
|
75
|
+
try:
|
|
76
|
+
await self._open_once()
|
|
77
|
+
return
|
|
78
|
+
except ConnectionError as exc:
|
|
79
|
+
last = exc
|
|
80
|
+
assert last is not None
|
|
81
|
+
raise last
|
|
82
|
+
|
|
83
|
+
async def close(self) -> None:
|
|
84
|
+
"""Close the open session, if any."""
|
|
85
|
+
stack, self._stack = self._stack, None
|
|
86
|
+
self._session = None
|
|
87
|
+
if stack is not None:
|
|
88
|
+
await stack.aclose()
|
|
89
|
+
|
|
90
|
+
async def _open_once(self) -> None:
|
|
91
|
+
stack = AsyncExitStack()
|
|
92
|
+
try:
|
|
93
|
+
read, write, _ = await stack.enter_async_context(
|
|
94
|
+
streamablehttp_client(self.base_url)
|
|
95
|
+
)
|
|
96
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
97
|
+
await session.initialize()
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
await stack.aclose()
|
|
100
|
+
root = _root_cause(exc)
|
|
101
|
+
if _is_connection_failure(root):
|
|
102
|
+
raise ConnectionError(
|
|
103
|
+
f"Failed to connect to DeepWiki server: {root}"
|
|
104
|
+
) from exc
|
|
105
|
+
raise ToolError(f"Failed to initialize MCP session: {root}") from exc
|
|
106
|
+
self._stack = stack
|
|
107
|
+
self._session = session
|
|
108
|
+
|
|
109
|
+
# -- tool calls ---------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
async def _invoke(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
112
|
+
assert self._session is not None
|
|
113
|
+
try:
|
|
114
|
+
result = await self._session.call_tool(tool_name, arguments)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
root = _root_cause(exc)
|
|
117
|
+
if _is_connection_failure(root):
|
|
118
|
+
raise ConnectionError(
|
|
119
|
+
f"Failed to connect to DeepWiki server: {root}"
|
|
120
|
+
) from exc
|
|
121
|
+
raise ToolError(f"Tool '{tool_name}' failed: {root}") from exc
|
|
122
|
+
return self._extract_text_content(result)
|
|
123
|
+
|
|
124
|
+
async def _call_oneshot(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
125
|
+
try:
|
|
126
|
+
await self.open()
|
|
127
|
+
return await self._invoke(tool_name, arguments)
|
|
128
|
+
finally:
|
|
129
|
+
await self.close()
|
|
130
|
+
|
|
131
|
+
async def _call_persistent(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
132
|
+
for attempt in range(2):
|
|
133
|
+
try:
|
|
134
|
+
return await self._invoke(tool_name, arguments)
|
|
135
|
+
except ConnectionError as exc:
|
|
136
|
+
if attempt == 0:
|
|
137
|
+
await self.close()
|
|
138
|
+
await self.open()
|
|
139
|
+
continue
|
|
140
|
+
raise
|
|
141
|
+
|
|
142
|
+
def _extract_text_content(self, result: Any) -> str:
|
|
143
|
+
parts = [c.text for c in result.content if getattr(c, "type", None) == "text"]
|
|
144
|
+
text = "\n".join(parts)
|
|
145
|
+
if result.isError:
|
|
146
|
+
raise ToolError(text or "Unknown tool error")
|
|
147
|
+
return text
|
|
148
|
+
|
|
149
|
+
async def _call_tool(self, tool_name: str, arguments: dict[str, Any]) -> str:
|
|
150
|
+
if self._session is None:
|
|
151
|
+
return await self._call_oneshot(tool_name, arguments)
|
|
152
|
+
return await self._call_persistent(tool_name, arguments)
|
|
153
|
+
|
|
154
|
+
async def read_wiki_structure(self, repo_name: str) -> str:
|
|
155
|
+
return await self._call_tool("read_wiki_structure", {"repoName": repo_name})
|
|
156
|
+
|
|
157
|
+
async def read_wiki_contents(self, repo_name: str) -> str:
|
|
158
|
+
return await self._call_tool("read_wiki_contents", {"repoName": repo_name})
|
|
159
|
+
|
|
160
|
+
async def ask_question(self, repo_name: str, question: str) -> Answer:
|
|
161
|
+
text = await self._call_tool(
|
|
162
|
+
"ask_question", {"repoName": repo_name, "question": question}
|
|
163
|
+
)
|
|
164
|
+
return Answer(body=text)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Codemap JSON → Mermaid flowchart rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
_TRACE_COLORS = [
|
|
9
|
+
("#e8f5e9", "#4caf50"),
|
|
10
|
+
("#e3f2fd", "#2196f3"),
|
|
11
|
+
("#fff3e0", "#ff9800"),
|
|
12
|
+
("#f3e5f5", "#9c27b0"),
|
|
13
|
+
("#fff8e1", "#ffc107"),
|
|
14
|
+
("#fce4ec", "#e91e63"),
|
|
15
|
+
("#e0f2f1", "#009688"),
|
|
16
|
+
("#fbe9e7", "#ff5722"),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sanitize_id(value: object) -> str:
|
|
21
|
+
return re.sub(r"[^a-zA-Z0-9]", "_", str(value))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _escape_label(value: str) -> str:
|
|
25
|
+
return value.replace('"', "#quot;").replace("\n", " ")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _short_path(path: str) -> str:
|
|
29
|
+
return path.rsplit("/", 1)[-1]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def codemap_to_mermaid(text: str) -> str | None:
|
|
33
|
+
"""Return a Mermaid flowchart for a codemap JSON blob, else None."""
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(text)
|
|
36
|
+
except (json.JSONDecodeError, TypeError):
|
|
37
|
+
return None
|
|
38
|
+
if not isinstance(data, dict) or not isinstance(data.get("traces"), list):
|
|
39
|
+
return None
|
|
40
|
+
return _render(data["traces"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _render(traces: list[dict]) -> str:
|
|
44
|
+
lines = ["flowchart TB"]
|
|
45
|
+
for i, trace in enumerate(traces):
|
|
46
|
+
sg_id = _sanitize_id(f"trace_{trace.get('id', i)}")
|
|
47
|
+
title = _escape_label(str(trace.get("title", "")))
|
|
48
|
+
lines.append("")
|
|
49
|
+
lines.append(f' subgraph {sg_id}["{trace.get("id", i)}. {title}"]')
|
|
50
|
+
locations = trace.get("locations", [])
|
|
51
|
+
for loc in locations:
|
|
52
|
+
loc_id = _sanitize_id(f"loc_{loc.get('id')}")
|
|
53
|
+
loc_title = _escape_label(str(loc.get("title", "")))
|
|
54
|
+
filename = _short_path(str(loc.get("path", "")))
|
|
55
|
+
label = f"{loc_title}\\n{filename}:{loc.get('lineNumber', '')}"
|
|
56
|
+
lines.append(f' {loc_id}["{label}"]')
|
|
57
|
+
lines.append(" end")
|
|
58
|
+
for j in range(len(locations) - 1):
|
|
59
|
+
a = _sanitize_id(f"loc_{locations[j].get('id')}")
|
|
60
|
+
b = _sanitize_id(f"loc_{locations[j + 1].get('id')}")
|
|
61
|
+
lines.append(f" {a} --> {b}")
|
|
62
|
+
for i in range(len(traces) - 1):
|
|
63
|
+
curr = traces[i].get("locations", [])
|
|
64
|
+
nxt = traces[i + 1].get("locations", [])
|
|
65
|
+
if curr and nxt:
|
|
66
|
+
a = _sanitize_id(f"loc_{curr[-1].get('id')}")
|
|
67
|
+
b = _sanitize_id(f"loc_{nxt[0].get('id')}")
|
|
68
|
+
lines.append(f" {a} -.-> {b}")
|
|
69
|
+
lines.append("")
|
|
70
|
+
for i, trace in enumerate(traces):
|
|
71
|
+
sg_id = _sanitize_id(f"trace_{trace.get('id', i)}")
|
|
72
|
+
fill, stroke = _TRACE_COLORS[i % len(_TRACE_COLORS)]
|
|
73
|
+
lines.append(f" style {sg_id} fill:{fill},stroke:{stroke},stroke-width:2px")
|
|
74
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Reverse-engineered api.devin.ai client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import ssl
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from contextlib import nullcontext
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
import websockets
|
|
17
|
+
from websockets.exceptions import ConnectionClosedOK, WebSocketException
|
|
18
|
+
|
|
19
|
+
from repowiki.services.deepwiki.client import ConnectionError, ToolError
|
|
20
|
+
from repowiki.shared.model import Answer, Reference, SourceFile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_CITE_RE = re.compile(r"<cite\s+([^>]*?)\s*/?>")
|
|
24
|
+
_CITE_ATTR_RE = re.compile(r'(\w+)="([^"]*)"')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_cites(body: str) -> tuple[str, list[Reference]]:
|
|
28
|
+
"""Replace inline ``<cite …/>`` tags with ``[i]`` markers, returning references.
|
|
29
|
+
|
|
30
|
+
Some responses encode citations as self-closing tags carrying ``path`` and
|
|
31
|
+
``start`` (an ``X-Y`` line range) instead of separate ``reference`` events.
|
|
32
|
+
"""
|
|
33
|
+
references: list[Reference] = []
|
|
34
|
+
|
|
35
|
+
def _replace(match: re.Match[str]) -> str:
|
|
36
|
+
attrs = dict(_CITE_ATTR_RE.findall(match.group(1)))
|
|
37
|
+
path = attrs.get("path", "")
|
|
38
|
+
if not path:
|
|
39
|
+
return match.group(0)
|
|
40
|
+
parts = attrs.get("start", "").split("-")
|
|
41
|
+
try:
|
|
42
|
+
range_start = int(parts[0])
|
|
43
|
+
range_end = int(parts[1]) if len(parts) > 1 else range_start
|
|
44
|
+
except (ValueError, IndexError):
|
|
45
|
+
range_start = range_end = None
|
|
46
|
+
references.append(Reference(path, range_start, range_end))
|
|
47
|
+
return f"[{len(references)}]"
|
|
48
|
+
|
|
49
|
+
return _CITE_RE.sub(_replace, body), references
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_response(query: dict, query_id: str | None) -> Answer:
|
|
53
|
+
"""Assemble an Answer from a single query block's response events."""
|
|
54
|
+
body: list[str] = []
|
|
55
|
+
summary: list[str] = []
|
|
56
|
+
references: list[Reference] = []
|
|
57
|
+
sources: list[SourceFile] = []
|
|
58
|
+
seen: set[tuple[str, str]] = set()
|
|
59
|
+
stats: dict[str, float] = {}
|
|
60
|
+
|
|
61
|
+
for event in query.get("response", []):
|
|
62
|
+
kind = event.get("type")
|
|
63
|
+
data = event.get("data")
|
|
64
|
+
if kind == "chunk":
|
|
65
|
+
body.append(data)
|
|
66
|
+
elif kind == "summary_chunk":
|
|
67
|
+
summary.append(data)
|
|
68
|
+
elif kind == "reference":
|
|
69
|
+
references.append(
|
|
70
|
+
Reference(
|
|
71
|
+
file_path=data["file_path"],
|
|
72
|
+
range_start=data.get("range_start"),
|
|
73
|
+
range_end=data.get("range_end"),
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
body.append(f"[{len(references)}]")
|
|
77
|
+
elif kind == "file_contents":
|
|
78
|
+
repo, path, content = data
|
|
79
|
+
key = (repo, path)
|
|
80
|
+
if key not in seen:
|
|
81
|
+
seen.add(key)
|
|
82
|
+
sources.append(SourceFile(repo=repo, path=path, content=content))
|
|
83
|
+
elif kind == "stats":
|
|
84
|
+
stats[data["key"]] = data["value"]
|
|
85
|
+
elif kind == "done":
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
body_text = "".join(body)
|
|
89
|
+
if not references and "<cite" in body_text:
|
|
90
|
+
body_text, references = _parse_cites(body_text)
|
|
91
|
+
|
|
92
|
+
return Answer(
|
|
93
|
+
body=body_text,
|
|
94
|
+
summary="".join(summary) or None,
|
|
95
|
+
references=references,
|
|
96
|
+
sources=sources,
|
|
97
|
+
stats=stats,
|
|
98
|
+
query_id=query_id,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
DEFAULT_API_URL = "https://api.devin.ai"
|
|
103
|
+
|
|
104
|
+
ENGINE_MAP = {"fast": "multihop_faster", "deep": "agent", "codemap": "codemap"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _http_detail(exc: httpx.HTTPStatusError) -> str:
|
|
108
|
+
"""Surface FastAPI's ``{"detail": ...}`` body so the CLI can classify it."""
|
|
109
|
+
try:
|
|
110
|
+
detail = exc.response.json().get("detail")
|
|
111
|
+
except Exception:
|
|
112
|
+
return ""
|
|
113
|
+
if isinstance(detail, str) and detail:
|
|
114
|
+
return f": {detail}"
|
|
115
|
+
return ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_cert_error(exc: BaseException) -> bool:
|
|
119
|
+
"""True if any cause in the exception chain is a TLS cert verification error."""
|
|
120
|
+
while exc is not None:
|
|
121
|
+
if isinstance(exc, ssl.SSLCertVerificationError):
|
|
122
|
+
return True
|
|
123
|
+
exc = exc.__cause__ or exc.__context__
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _connection_message(host: str, exc: httpx.TransportError) -> str:
|
|
128
|
+
"""Build a connection error message, naming the host and hinting on TLS failure."""
|
|
129
|
+
message = f"Failed to connect to {host}: {exc}"
|
|
130
|
+
if _is_cert_error(exc):
|
|
131
|
+
message += (
|
|
132
|
+
" (TLS certificate verification failed; set SSL_CERT_FILE "
|
|
133
|
+
"to your CA bundle to trust a proxy/mirror)"
|
|
134
|
+
)
|
|
135
|
+
return message
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DevinClient:
|
|
139
|
+
"""Client for the reverse-engineered api.devin.ai Q&A endpoints."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, base_url: str | None = None) -> None:
|
|
142
|
+
self.base_url = base_url or os.environ.get("DEEPWIKI_API_URL", DEFAULT_API_URL)
|
|
143
|
+
|
|
144
|
+
async def _get_json(
|
|
145
|
+
self,
|
|
146
|
+
path: str,
|
|
147
|
+
*,
|
|
148
|
+
params: dict | None = None,
|
|
149
|
+
client: httpx.AsyncClient | None = None,
|
|
150
|
+
) -> dict:
|
|
151
|
+
ctx = nullcontext(client) if client is not None else httpx.AsyncClient(
|
|
152
|
+
base_url=self.base_url, timeout=30.0
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
async with ctx as c:
|
|
156
|
+
resp = await c.get(path, params=params)
|
|
157
|
+
resp.raise_for_status()
|
|
158
|
+
return resp.json()
|
|
159
|
+
except httpx.TransportError as exc:
|
|
160
|
+
raise ConnectionError(_connection_message("Devin server", exc)) from exc
|
|
161
|
+
except httpx.HTTPStatusError as exc:
|
|
162
|
+
raise ToolError(
|
|
163
|
+
f"Devin API returned HTTP {exc.response.status_code}{_http_detail(exc)}"
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
async def _post_json(
|
|
167
|
+
self,
|
|
168
|
+
path: str,
|
|
169
|
+
*,
|
|
170
|
+
params: dict | None = None,
|
|
171
|
+
json: dict | None = None,
|
|
172
|
+
client: httpx.AsyncClient | None = None,
|
|
173
|
+
) -> dict:
|
|
174
|
+
ctx = nullcontext(client) if client is not None else httpx.AsyncClient(
|
|
175
|
+
base_url=self.base_url, timeout=30.0
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
async with ctx as c:
|
|
179
|
+
resp = await c.post(path, params=params, json=json)
|
|
180
|
+
resp.raise_for_status()
|
|
181
|
+
return resp.json()
|
|
182
|
+
except httpx.TransportError as exc:
|
|
183
|
+
raise ConnectionError(_connection_message("Devin server", exc)) from exc
|
|
184
|
+
except httpx.HTTPStatusError as exc:
|
|
185
|
+
raise ToolError(
|
|
186
|
+
f"Devin API returned HTTP {exc.response.status_code}{_http_detail(exc)}"
|
|
187
|
+
) from exc
|
|
188
|
+
|
|
189
|
+
async def ask(
|
|
190
|
+
self,
|
|
191
|
+
repos: list[str],
|
|
192
|
+
question: str,
|
|
193
|
+
*,
|
|
194
|
+
mode: str = "fast",
|
|
195
|
+
query_id: str | None = None,
|
|
196
|
+
timeout: float = 120.0,
|
|
197
|
+
poll_interval: float = 2.0,
|
|
198
|
+
context: str = "",
|
|
199
|
+
generate_summary: bool = True,
|
|
200
|
+
on_chunk: Callable[[str], None] | None = None,
|
|
201
|
+
) -> Answer:
|
|
202
|
+
engine_id = ENGINE_MAP.get(mode)
|
|
203
|
+
if engine_id is None:
|
|
204
|
+
raise ToolError(f"unknown mode {mode!r}")
|
|
205
|
+
qid = query_id or str(uuid4())
|
|
206
|
+
payload = {
|
|
207
|
+
"engine_id": engine_id,
|
|
208
|
+
"user_query": question,
|
|
209
|
+
"keywords": [],
|
|
210
|
+
"repo_names": list(repos),
|
|
211
|
+
"additional_context": context,
|
|
212
|
+
"query_id": qid,
|
|
213
|
+
"use_notes": False,
|
|
214
|
+
"attached_context": [],
|
|
215
|
+
"generate_summary": generate_summary,
|
|
216
|
+
}
|
|
217
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=30.0) as client:
|
|
218
|
+
await self._post_json("/ada/query", json=payload, client=client)
|
|
219
|
+
if on_chunk is not None:
|
|
220
|
+
events = await self._stream_chunks(qid, on_chunk, timeout=timeout)
|
|
221
|
+
return parse_response({"response": events}, qid)
|
|
222
|
+
return await self._poll_query(
|
|
223
|
+
qid, client=client, poll_interval=poll_interval, timeout=timeout
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def _stream_chunks(
|
|
227
|
+
self, qid: str, on_chunk: Callable[[str], None], *, timeout: float
|
|
228
|
+
) -> list[dict]:
|
|
229
|
+
"""Stream events over the WebSocket, emitting answer text, until 'done'.
|
|
230
|
+
|
|
231
|
+
Chunk text and inline citation markers (``[i]`` emitted at each
|
|
232
|
+
``reference`` event) are forwarded through ``on_chunk``, so the streamed
|
|
233
|
+
body shows citations inline and lines up with the final ``## Sources``.
|
|
234
|
+
|
|
235
|
+
Returns the full event list so the caller can assemble a complete
|
|
236
|
+
Answer (body, summary, references, sources) without a follow-up GET.
|
|
237
|
+
"""
|
|
238
|
+
ws_url = f"{self.base_url.replace('http', 'ws', 1)}/ada/ws/query/{qid}"
|
|
239
|
+
deadline = time.monotonic() + timeout
|
|
240
|
+
events: list[dict] = []
|
|
241
|
+
ref_count = 0
|
|
242
|
+
try:
|
|
243
|
+
async with websockets.connect(ws_url) as ws:
|
|
244
|
+
while True:
|
|
245
|
+
remaining = deadline - time.monotonic()
|
|
246
|
+
if remaining <= 0:
|
|
247
|
+
raise ToolError("timed out waiting for answer")
|
|
248
|
+
try:
|
|
249
|
+
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
|
250
|
+
except asyncio.TimeoutError:
|
|
251
|
+
raise ToolError("timed out waiting for answer")
|
|
252
|
+
except ConnectionClosedOK:
|
|
253
|
+
break
|
|
254
|
+
try:
|
|
255
|
+
msg = json.loads(raw)
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
continue
|
|
258
|
+
if not isinstance(msg, dict):
|
|
259
|
+
continue
|
|
260
|
+
kind = msg.get("type")
|
|
261
|
+
if kind is None:
|
|
262
|
+
continue
|
|
263
|
+
if kind == "done":
|
|
264
|
+
break
|
|
265
|
+
events.append(msg)
|
|
266
|
+
if kind == "chunk":
|
|
267
|
+
on_chunk(msg.get("data", ""))
|
|
268
|
+
elif kind == "reference":
|
|
269
|
+
ref_count += 1
|
|
270
|
+
on_chunk(f"[{ref_count}]")
|
|
271
|
+
except (OSError, WebSocketException) as exc:
|
|
272
|
+
raise ConnectionError(f"WebSocket connection failed: {exc}") from exc
|
|
273
|
+
return events
|
|
274
|
+
|
|
275
|
+
async def _poll_query(
|
|
276
|
+
self,
|
|
277
|
+
qid: str,
|
|
278
|
+
*,
|
|
279
|
+
client: httpx.AsyncClient,
|
|
280
|
+
poll_interval: float,
|
|
281
|
+
timeout: float,
|
|
282
|
+
) -> Answer:
|
|
283
|
+
deadline = time.monotonic() + timeout
|
|
284
|
+
while True:
|
|
285
|
+
await asyncio.sleep(poll_interval)
|
|
286
|
+
if time.monotonic() > deadline:
|
|
287
|
+
raise ToolError("timed out waiting for answer")
|
|
288
|
+
data = await self._get_json(f"/ada/query/{qid}", client=client)
|
|
289
|
+
queries = data.get("queries")
|
|
290
|
+
if not queries:
|
|
291
|
+
raise ToolError("Devin API returned no query results")
|
|
292
|
+
query = queries[-1]
|
|
293
|
+
if query.get("state") in ("done", "error"):
|
|
294
|
+
break
|
|
295
|
+
if query.get("error"):
|
|
296
|
+
raise ToolError(str(query["error"]))
|
|
297
|
+
return parse_response(query, qid)
|
|
298
|
+
|
|
299
|
+
async def list_public_indexes(self, search: str) -> dict:
|
|
300
|
+
return await self._get_json("/ada/list_public_indexes", params={"search_repo": search})
|
|
301
|
+
|
|
302
|
+
async def public_repo_indexing_status(self, repo: str) -> dict:
|
|
303
|
+
return await self._get_json("/ada/public_repo_indexing_status", params={"repo_name": repo})
|
|
304
|
+
|
|
305
|
+
async def warm_public_repo(self, repo: str) -> dict:
|
|
306
|
+
return await self._post_json("/ada/warm_public_repo", params={"repo_name": repo})
|
|
307
|
+
|
|
308
|
+
async def repo_index(self, repo: str) -> dict | None:
|
|
309
|
+
"""Return the ``list_public_indexes`` entry matching ``repo`` exactly.
|
|
310
|
+
|
|
311
|
+
The search is fuzzy, so filter to the entry whose ``repo_name`` equals
|
|
312
|
+
the requested repo; return ``None`` when the repo has no index.
|
|
313
|
+
"""
|
|
314
|
+
data = await self.list_public_indexes(repo)
|
|
315
|
+
for entry in data.get("indices") or []:
|
|
316
|
+
if entry.get("repo_name") == repo:
|
|
317
|
+
return entry
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
async def github_head(self, repo: str) -> dict:
|
|
321
|
+
"""Return the GitHub HEAD sha and commit date for ``repo``.
|
|
322
|
+
|
|
323
|
+
``id`` in the index (e.g. ``…/IDPFold2/5315b279``) ends in a short
|
|
324
|
+
commit sha, so compare it against GitHub HEAD to detect a stale wiki.
|
|
325
|
+
"""
|
|
326
|
+
owner, name = repo.split("/", 1)
|
|
327
|
+
try:
|
|
328
|
+
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
|
329
|
+
resp = await client.get(
|
|
330
|
+
f"https://api.github.com/repos/{owner}/{name}/commits/HEAD"
|
|
331
|
+
)
|
|
332
|
+
resp.raise_for_status()
|
|
333
|
+
try:
|
|
334
|
+
commit = resp.json()
|
|
335
|
+
except json.JSONDecodeError as exc:
|
|
336
|
+
raise ToolError("invalid JSON response from GitHub") from exc
|
|
337
|
+
except httpx.TransportError as exc:
|
|
338
|
+
raise ConnectionError(_connection_message("GitHub", exc)) from exc
|
|
339
|
+
except httpx.HTTPStatusError as exc:
|
|
340
|
+
raise ToolError(f"GitHub API returned HTTP {exc.response.status_code}") from exc
|
|
341
|
+
if not isinstance(commit, dict):
|
|
342
|
+
raise ToolError("unexpected GitHub response")
|
|
343
|
+
committer = commit.get("commit", {}).get("committer") or {}
|
|
344
|
+
when = committer.get("date") if isinstance(committer, dict) else None
|
|
345
|
+
return {"sha": commit.get("sha") or "", "when": when}
|
|
346
|
+
|
|
347
|
+
async def get_query(self, query_id: str) -> Answer:
|
|
348
|
+
data = await self._get_json(f"/ada/query/{query_id}")
|
|
349
|
+
queries = data.get("queries")
|
|
350
|
+
if not queries:
|
|
351
|
+
raise ToolError("Devin API returned no query results")
|
|
352
|
+
query = queries[-1]
|
|
353
|
+
if query.get("error"):
|
|
354
|
+
raise ToolError(str(query["error"]))
|
|
355
|
+
return parse_response(query, query_id)
|