sid-harness 0.3.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.
- sid_harness/__init__.py +13 -0
- sid_harness/__main__.py +4 -0
- sid_harness/_native.py +1639 -0
- sid_harness/brave_proof.py +246 -0
- sid_harness/bridge.py +851 -0
- sid_harness/cli.py +937 -0
- sid_harness/client.py +161 -0
- sid_harness/companion.py +151 -0
- sid_harness/config.py +68 -0
- sid_harness/context/__init__.py +40 -0
- sid_harness/context/index.py +267 -0
- sid_harness/context/retrieval.py +74 -0
- sid_harness/editor.py +231 -0
- sid_harness/gateway.py +106 -0
- sid_harness/git_write.py +598 -0
- sid_harness/local_workspace.py +421 -0
- sid_harness/loop.py +394 -0
- sid_harness/lsp.py +598 -0
- sid_harness/policy/__init__.py +44 -0
- sid_harness/policy/repo_policy.py +626 -0
- sid_harness/receipt.py +142 -0
- sid_harness/runner.py +468 -0
- sid_harness/selftest.py +149 -0
- sid_harness/session/__init__.py +33 -0
- sid_harness/session/bounds.py +140 -0
- sid_harness/session/diff_gate.py +93 -0
- sid_harness/session/session.py +427 -0
- sid_harness/session/store.py +88 -0
- sid_harness/tools/__init__.py +52 -0
- sid_harness/tools/mcp_client.py +285 -0
- sid_harness/tools/plan.py +138 -0
- sid_harness/tools/registry.py +159 -0
- sid_harness/tools/skills.py +185 -0
- sid_harness/tools/subagents.py +112 -0
- sid_harness/tui.py +96 -0
- sid_harness/workspace.py +107 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
- sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
- sid_harness-0.3.0.dist-info/METADATA +229 -0
- sid_harness-0.3.0.dist-info/RECORD +45 -0
- sid_harness-0.3.0.dist-info/WHEEL +5 -0
- sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
- sid_harness-0.3.0.dist-info/top_level.txt +1 -0
sid_harness/client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""HTTP client for SID's OpenAI-compatible ``/v1`` surface.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library (``urllib``), so the harness has zero third-party
|
|
4
|
+
dependencies. Every call rides the caller's own SID key: routing, metering, and
|
|
5
|
+
spend caps are the gateway's, not the harness's.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://superintelligencedog.com/v1"
|
|
19
|
+
DEFAULT_ROUTING_PROFILE = "agent"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SIDClientError(RuntimeError):
|
|
23
|
+
"""A SID call could not be completed (transport, auth, or bad response)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SIDClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
base_url: str | None = None,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
routing_profile: str | None = None,
|
|
33
|
+
stability: str | None = None,
|
|
34
|
+
timeout: float = 180.0,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.base_url = (
|
|
37
|
+
base_url
|
|
38
|
+
or os.environ.get("SID_BASE_URL")
|
|
39
|
+
or config.get_base_url()
|
|
40
|
+
or DEFAULT_BASE_URL
|
|
41
|
+
).rstrip("/")
|
|
42
|
+
self.api_key = (
|
|
43
|
+
api_key or os.environ.get("SID_API_KEY") or config.get_api_key() or ""
|
|
44
|
+
)
|
|
45
|
+
if not self.api_key:
|
|
46
|
+
raise SIDClientError(
|
|
47
|
+
"no SID API key: run `sid setup`, set SID_API_KEY, or pass --api-key."
|
|
48
|
+
)
|
|
49
|
+
self.routing_profile = (
|
|
50
|
+
routing_profile
|
|
51
|
+
or os.environ.get("SID_ROUTING_PROFILE")
|
|
52
|
+
or DEFAULT_ROUTING_PROFILE
|
|
53
|
+
)
|
|
54
|
+
self.stability = stability or os.environ.get("SID_ROUTING_STABILITY") or ""
|
|
55
|
+
self.timeout = timeout
|
|
56
|
+
|
|
57
|
+
def headers(self) -> dict[str, str]:
|
|
58
|
+
headers = {
|
|
59
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
}
|
|
62
|
+
if self.routing_profile:
|
|
63
|
+
headers["X-Routing-Profile"] = self.routing_profile
|
|
64
|
+
if self.stability:
|
|
65
|
+
headers["X-Routing-Stability"] = self.stability
|
|
66
|
+
return headers
|
|
67
|
+
|
|
68
|
+
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
url = f"{self.base_url}{path}"
|
|
70
|
+
data = json.dumps(payload).encode("utf-8")
|
|
71
|
+
request = urllib.request.Request(
|
|
72
|
+
url, data=data, headers=self.headers(), method="POST"
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
76
|
+
body = response.read().decode("utf-8")
|
|
77
|
+
except urllib.error.HTTPError as exc:
|
|
78
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
79
|
+
raise SIDClientError(
|
|
80
|
+
f"SID {path} failed: HTTP {exc.code}: {detail[:500]}"
|
|
81
|
+
) from exc
|
|
82
|
+
except urllib.error.URLError as exc:
|
|
83
|
+
raise SIDClientError(
|
|
84
|
+
f"SID {path} unreachable at {self.base_url}: {exc.reason}"
|
|
85
|
+
) from exc
|
|
86
|
+
try:
|
|
87
|
+
parsed = json.loads(body)
|
|
88
|
+
except json.JSONDecodeError as exc:
|
|
89
|
+
raise SIDClientError(
|
|
90
|
+
f"SID {path} returned a non-JSON response"
|
|
91
|
+
) from exc
|
|
92
|
+
if not isinstance(parsed, dict):
|
|
93
|
+
raise SIDClientError(f"SID {path} returned an unexpected response shape")
|
|
94
|
+
return parsed
|
|
95
|
+
|
|
96
|
+
def _get(self, path: str) -> dict[str, Any]:
|
|
97
|
+
url = f"{self.base_url}{path}"
|
|
98
|
+
request = urllib.request.Request(url, headers=self.headers(), method="GET")
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
101
|
+
body = response.read().decode("utf-8")
|
|
102
|
+
except urllib.error.HTTPError as exc:
|
|
103
|
+
detail = exc.read().decode("utf-8", "replace")
|
|
104
|
+
raise SIDClientError(
|
|
105
|
+
f"SID {path} failed: HTTP {exc.code}: {detail[:500]}"
|
|
106
|
+
) from exc
|
|
107
|
+
except urllib.error.URLError as exc:
|
|
108
|
+
raise SIDClientError(
|
|
109
|
+
f"SID {path} unreachable at {self.base_url}: {exc.reason}"
|
|
110
|
+
) from exc
|
|
111
|
+
try:
|
|
112
|
+
parsed = json.loads(body)
|
|
113
|
+
except json.JSONDecodeError as exc:
|
|
114
|
+
raise SIDClientError(
|
|
115
|
+
f"SID {path} returned a non-JSON response"
|
|
116
|
+
) from exc
|
|
117
|
+
if not isinstance(parsed, dict):
|
|
118
|
+
raise SIDClientError(f"SID {path} returned an unexpected response shape")
|
|
119
|
+
return parsed
|
|
120
|
+
|
|
121
|
+
def me(self) -> dict[str, Any]:
|
|
122
|
+
"""Verify the key and return the account summary from ``GET /v1/me``."""
|
|
123
|
+
return self._get("/me")
|
|
124
|
+
|
|
125
|
+
def chat(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
messages: list[dict[str, Any]],
|
|
129
|
+
tools: list[dict[str, Any]] | None = None,
|
|
130
|
+
model: str = "auto",
|
|
131
|
+
max_tokens: int = 8000,
|
|
132
|
+
temperature: float = 0.0,
|
|
133
|
+
reasoning_effort: str | None = None,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
payload: dict[str, Any] = {
|
|
136
|
+
"model": model,
|
|
137
|
+
"messages": messages,
|
|
138
|
+
"temperature": temperature,
|
|
139
|
+
"max_tokens": max_tokens,
|
|
140
|
+
}
|
|
141
|
+
if tools:
|
|
142
|
+
payload["tools"] = tools
|
|
143
|
+
payload["tool_choice"] = "auto"
|
|
144
|
+
if reasoning_effort:
|
|
145
|
+
payload["reasoning_effort"] = reasoning_effort
|
|
146
|
+
return self._post("/chat/completions", payload)
|
|
147
|
+
|
|
148
|
+
def verify_patch(
|
|
149
|
+
self,
|
|
150
|
+
diff: str,
|
|
151
|
+
*,
|
|
152
|
+
context: str | None = None,
|
|
153
|
+
reviewer_model: str | None = None,
|
|
154
|
+
model: str = "auto",
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
payload: dict[str, Any] = {"diff": diff, "model": model}
|
|
157
|
+
if context:
|
|
158
|
+
payload["context"] = context
|
|
159
|
+
if reviewer_model:
|
|
160
|
+
payload["reviewer_model"] = reviewer_model
|
|
161
|
+
return self._post("/verify/patch", payload)
|
sid_harness/companion.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""sid companion — the desktop launcher for the localhost bridge.
|
|
2
|
+
|
|
3
|
+
One process, one click:
|
|
4
|
+
|
|
5
|
+
* starts the loopback bridge over the folder the user chose;
|
|
6
|
+
* opens the SID Studio with a one-time pairing code in the URL fragment, which
|
|
7
|
+
the Studio exchanges for the bridge token — no manual URL or token paste;
|
|
8
|
+
* read-only by default. ``--write`` makes write mode *available*; the user still
|
|
9
|
+
has to click "Enable write access" in the Studio, which is the explicit
|
|
10
|
+
per-session grant the bridge records.
|
|
11
|
+
|
|
12
|
+
The installer (``infra/sid-harness/install/``) registers this command at login
|
|
13
|
+
so the bridge is already running when the user opens the Studio.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlencode
|
|
26
|
+
|
|
27
|
+
from .bridge import (
|
|
28
|
+
ALLOWED_ORIGINS,
|
|
29
|
+
DEFAULT_PORT,
|
|
30
|
+
PAIR_TTL_SECONDS,
|
|
31
|
+
bridge_state_dir,
|
|
32
|
+
serve,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DEFAULT_STUDIO_URL = "https://app.superintelligencedog.com/studio.html"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pair_url(base_url: str, studio_url: str, code: str) -> str:
|
|
39
|
+
"""The one-click URL: code rides the fragment, so it never hits a server log."""
|
|
40
|
+
fragment = urlencode({"sid-pair": code, "sid-bridge": base_url})
|
|
41
|
+
return f"{studio_url}#{fragment}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_session_file(path: Path, payload: dict[str, Any]) -> Path:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
48
|
+
try:
|
|
49
|
+
os.chmod(tmp, 0o600)
|
|
50
|
+
except OSError: # Windows has no POSIX mode bits here
|
|
51
|
+
pass
|
|
52
|
+
os.replace(tmp, path)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(
|
|
57
|
+
directory: str | os.PathLike[str],
|
|
58
|
+
*,
|
|
59
|
+
port: int = DEFAULT_PORT,
|
|
60
|
+
studio_url: str = DEFAULT_STUDIO_URL,
|
|
61
|
+
write: bool = False,
|
|
62
|
+
open_browser: bool = True,
|
|
63
|
+
token: str | None = None,
|
|
64
|
+
pair_code: str | None = None,
|
|
65
|
+
pair_ttl: float = PAIR_TTL_SECONDS,
|
|
66
|
+
state_dir: str | os.PathLike[str] | None = None,
|
|
67
|
+
allowed_origins: tuple[str, ...] | list[str] | None = None,
|
|
68
|
+
base_url: str | None = None,
|
|
69
|
+
) -> int:
|
|
70
|
+
httpd = serve(
|
|
71
|
+
directory,
|
|
72
|
+
port=port,
|
|
73
|
+
token=token,
|
|
74
|
+
write_capable=write,
|
|
75
|
+
pair_code=pair_code,
|
|
76
|
+
pair_ttl=pair_ttl,
|
|
77
|
+
state_dir=state_dir,
|
|
78
|
+
allowed_origins=tuple(allowed_origins) if allowed_origins is not None else ALLOWED_ORIGINS,
|
|
79
|
+
base_url=base_url,
|
|
80
|
+
)
|
|
81
|
+
bridge_url = f"http://127.0.0.1:{httpd.server_address[1]}"
|
|
82
|
+
url = pair_url(bridge_url, studio_url, httpd.bridge_pair_code)
|
|
83
|
+
state = Path(state_dir).expanduser().resolve() if state_dir else bridge_state_dir()
|
|
84
|
+
try:
|
|
85
|
+
write_session_file(state / "session.json", {
|
|
86
|
+
"pid": os.getpid(),
|
|
87
|
+
"started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
88
|
+
"bridge_url": bridge_url,
|
|
89
|
+
"studio_url": studio_url,
|
|
90
|
+
"dir": str(httpd.bridge_dir),
|
|
91
|
+
"read_only": httpd.read_only,
|
|
92
|
+
"write_capable": httpd.bridge_write_capable,
|
|
93
|
+
"pair_expires_at": int(httpd.bridge_pair_expires_at),
|
|
94
|
+
"pair_url": url,
|
|
95
|
+
})
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
print(f"sid companion: could not write {state / 'session.json'}: {exc}")
|
|
98
|
+
|
|
99
|
+
print(f"sid companion: {bridge_url}")
|
|
100
|
+
print(f"workspace: {httpd.bridge_dir}")
|
|
101
|
+
print(f"mode: {'write available (explicit grant required)' if write else 'read-only'}")
|
|
102
|
+
print(f"pair: {url}")
|
|
103
|
+
if open_browser:
|
|
104
|
+
try:
|
|
105
|
+
webbrowser.open(url)
|
|
106
|
+
except Exception as exc: # opening the browser is best-effort; the URL is printed
|
|
107
|
+
print(f"sid companion: could not open the browser: {exc}")
|
|
108
|
+
try:
|
|
109
|
+
httpd.serve_forever()
|
|
110
|
+
except KeyboardInterrupt:
|
|
111
|
+
print("\nsid companion: stopped")
|
|
112
|
+
finally:
|
|
113
|
+
httpd.server_close()
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main(argv: list[str] | None = None) -> int:
|
|
118
|
+
ap = argparse.ArgumentParser(
|
|
119
|
+
prog="sid companion",
|
|
120
|
+
description="Start the local bridge and open the Studio with one-click pairing.",
|
|
121
|
+
)
|
|
122
|
+
ap.add_argument("--dir", required=True, help="Folder the Studio may read (and, if granted, write).")
|
|
123
|
+
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
124
|
+
ap.add_argument("--studio-url", default=DEFAULT_STUDIO_URL)
|
|
125
|
+
ap.add_argument("--write", action="store_true", help="Make write mode available (default: read-only).")
|
|
126
|
+
ap.add_argument("--no-open", action="store_true", help="Do not open the browser (print the pair URL).")
|
|
127
|
+
ap.add_argument("--token", default=None, help="Fixed token (default: generated per launch).")
|
|
128
|
+
ap.add_argument("--pair-code", default=None, help="Fixed pairing code (default: generated per launch).")
|
|
129
|
+
ap.add_argument("--pair-ttl", type=float, default=PAIR_TTL_SECONDS)
|
|
130
|
+
ap.add_argument("--state-dir", default=None)
|
|
131
|
+
ap.add_argument("--allow-origin", action="append", default=None, metavar="ORIGIN")
|
|
132
|
+
ap.add_argument("--base-url", default=None)
|
|
133
|
+
args = ap.parse_args(argv)
|
|
134
|
+
origins = ALLOWED_ORIGINS + tuple(args.allow_origin or ())
|
|
135
|
+
return run(
|
|
136
|
+
args.dir,
|
|
137
|
+
port=args.port,
|
|
138
|
+
studio_url=args.studio_url,
|
|
139
|
+
write=args.write,
|
|
140
|
+
open_browser=not args.no_open,
|
|
141
|
+
token=args.token,
|
|
142
|
+
pair_code=args.pair_code,
|
|
143
|
+
pair_ttl=args.pair_ttl,
|
|
144
|
+
state_dir=args.state_dir,
|
|
145
|
+
allowed_origins=origins,
|
|
146
|
+
base_url=args.base_url,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__": # pragma: no cover
|
|
151
|
+
raise SystemExit(main())
|
sid_harness/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Persistent local configuration for the SID harness.
|
|
2
|
+
|
|
3
|
+
``sid setup`` writes ``~/.sid/config.json`` (owner-only where the OS supports
|
|
4
|
+
it). The CLI and client read it as a fallback so the key and default workspace
|
|
5
|
+
survive new terminals. ``SID_CONFIG_PATH`` overrides the location for tests and
|
|
6
|
+
packaged installs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONFIG_PATH = "~/.sid/config.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
override = os.environ.get("SID_CONFIG_PATH")
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser()
|
|
24
|
+
return Path(DEFAULT_CONFIG_PATH).expanduser()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> dict[str, Any]:
|
|
28
|
+
path = config_path()
|
|
29
|
+
try:
|
|
30
|
+
raw = path.read_text(encoding="utf-8")
|
|
31
|
+
except (FileNotFoundError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(raw)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return {}
|
|
37
|
+
return data if isinstance(data, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_config(config: dict[str, Any]) -> Path:
|
|
41
|
+
path = config_path()
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
payload = json.dumps(config, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
44
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
45
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
46
|
+
descriptor = os.open(temporary, flags, 0o600)
|
|
47
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
48
|
+
handle.write(payload)
|
|
49
|
+
os.replace(temporary, path)
|
|
50
|
+
# pragma: no cover - Windows ACLs do not map cleanly
|
|
51
|
+
with contextlib.suppress(OSError):
|
|
52
|
+
os.chmod(path, 0o600)
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_api_key() -> str:
|
|
57
|
+
value = load_config().get("api_key")
|
|
58
|
+
return value if isinstance(value, str) else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_base_url() -> str:
|
|
62
|
+
value = load_config().get("base_url")
|
|
63
|
+
return value if isinstance(value, str) else ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_default_dir() -> str:
|
|
67
|
+
value = load_config().get("default_dir")
|
|
68
|
+
return value if isinstance(value, str) else ""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Repo-scale context for the SID harness (SUB-55).
|
|
2
|
+
|
|
3
|
+
A local, read-only index over a whole repository plus retrieval that reuses the
|
|
4
|
+
gateway's existing ``app.rag`` seam — so the agent reasons over the tree, not a
|
|
5
|
+
submitted subset (design/07-agent-service.md:126-127).
|
|
6
|
+
|
|
7
|
+
- ``build_index(dir_or_driver)`` -> ``RepoIndex`` (paths, languages, symbols).
|
|
8
|
+
- ``retrieve_context(index, query, k=...)`` -> ``app.rag`` chunks.
|
|
9
|
+
- ``context_block(index, query, k=...)`` -> sanitized, data-boundaried text.
|
|
10
|
+
|
|
11
|
+
Nothing here writes to the tree or leaves the machine except as model context
|
|
12
|
+
under the caller's own key. Secret / VCS / dependency paths are excluded by the
|
|
13
|
+
same denylist SUB-54 uses (``local_workspace.safe_rel_path``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .index import (
|
|
17
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
18
|
+
DEFAULT_MAX_FILES,
|
|
19
|
+
DEFAULT_MAX_TOTAL_BYTES,
|
|
20
|
+
IndexedFile,
|
|
21
|
+
RepoIndex,
|
|
22
|
+
build_index,
|
|
23
|
+
extract_symbols,
|
|
24
|
+
language_of,
|
|
25
|
+
)
|
|
26
|
+
from .retrieval import Chunk, context_block, retrieve_context
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_MAX_FILES",
|
|
30
|
+
"DEFAULT_MAX_FILE_BYTES",
|
|
31
|
+
"DEFAULT_MAX_TOTAL_BYTES",
|
|
32
|
+
"Chunk",
|
|
33
|
+
"IndexedFile",
|
|
34
|
+
"RepoIndex",
|
|
35
|
+
"build_index",
|
|
36
|
+
"context_block",
|
|
37
|
+
"extract_symbols",
|
|
38
|
+
"language_of",
|
|
39
|
+
"retrieve_context",
|
|
40
|
+
]
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Local, read-only repo index for the SID harness (SUB-55).
|
|
2
|
+
|
|
3
|
+
The SUB-53/54 workspace is deliberately bounded (<=200 files / <=8 MB) because
|
|
4
|
+
it is handed to the loop as in-memory ``workspace_files``. That bound is wrong
|
|
5
|
+
for *context*: to reason over a repository the agent needs the whole tree, not a
|
|
6
|
+
submitted subset (design/07-agent-service.md:126-127 calls full-repo
|
|
7
|
+
provisioning the "later slice").
|
|
8
|
+
|
|
9
|
+
This module is that slice, local and read-only:
|
|
10
|
+
|
|
11
|
+
- it walks the whole tree through the SAME denylist and ``.gitignore`` SUB-54
|
|
12
|
+
already uses (``local_workspace``), so secrets / VCS / dependency paths are
|
|
13
|
+
excluded by construction — no second safety model;
|
|
14
|
+
- it builds a lightweight index — path, language, size, and extracted symbol
|
|
15
|
+
names — with generous, configurable bounds and an honest ``truncated`` flag;
|
|
16
|
+
- it never writes to the tree and never touches the network.
|
|
17
|
+
|
|
18
|
+
Retrieval lives in ``retrieval`` and reuses the gateway's ``app.rag.retrieve``
|
|
19
|
+
seam (no second RAG).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ..local_workspace import LocalWorkspace, LocalWorkspaceError, safe_rel_path
|
|
30
|
+
|
|
31
|
+
# Repo-scale defaults: large enough for a real repository, still bounded so a
|
|
32
|
+
# runaway tree cannot exhaust memory. The in-memory workspace bound (200 files
|
|
33
|
+
# / 8 MB) is intentionally NOT used here.
|
|
34
|
+
DEFAULT_MAX_FILES = 20000
|
|
35
|
+
DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024
|
|
36
|
+
DEFAULT_MAX_FILE_BYTES = 512 * 1024
|
|
37
|
+
|
|
38
|
+
# Pruned during the walk for speed; correctness still runs every file through
|
|
39
|
+
# the shared denylist (``safe_rel_path``), which refuses the same names.
|
|
40
|
+
_SKIP_DIRS = {
|
|
41
|
+
".git",
|
|
42
|
+
".hg",
|
|
43
|
+
".svn",
|
|
44
|
+
"node_modules",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"__pycache__",
|
|
48
|
+
".mypy_cache",
|
|
49
|
+
".ruff_cache",
|
|
50
|
+
".pytest_cache",
|
|
51
|
+
".tox",
|
|
52
|
+
".idea",
|
|
53
|
+
".vscode",
|
|
54
|
+
"dist",
|
|
55
|
+
"build",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_LANGUAGE_BY_SUFFIX = {
|
|
59
|
+
".py": "python",
|
|
60
|
+
".pyi": "python",
|
|
61
|
+
".js": "javascript",
|
|
62
|
+
".mjs": "javascript",
|
|
63
|
+
".cjs": "javascript",
|
|
64
|
+
".jsx": "javascript",
|
|
65
|
+
".ts": "typescript",
|
|
66
|
+
".tsx": "typescript",
|
|
67
|
+
".go": "go",
|
|
68
|
+
".rs": "rust",
|
|
69
|
+
".rb": "ruby",
|
|
70
|
+
".java": "java",
|
|
71
|
+
".kt": "kotlin",
|
|
72
|
+
".c": "c",
|
|
73
|
+
".h": "c",
|
|
74
|
+
".cc": "cpp",
|
|
75
|
+
".cpp": "cpp",
|
|
76
|
+
".hpp": "cpp",
|
|
77
|
+
".cs": "csharp",
|
|
78
|
+
".php": "php",
|
|
79
|
+
".sh": "shell",
|
|
80
|
+
".bash": "shell",
|
|
81
|
+
".ps1": "powershell",
|
|
82
|
+
".sql": "sql",
|
|
83
|
+
".md": "markdown",
|
|
84
|
+
".rst": "markdown",
|
|
85
|
+
".toml": "toml",
|
|
86
|
+
".yaml": "yaml",
|
|
87
|
+
".yml": "yaml",
|
|
88
|
+
".json": "json",
|
|
89
|
+
".html": "html",
|
|
90
|
+
".css": "css",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Lightweight, deterministic symbol extraction — not a parser. Python gets
|
|
94
|
+
# def/class at any indent; a generic pattern covers the common declaration
|
|
95
|
+
# keywords of the other indexed languages.
|
|
96
|
+
_PY_SYMBOL = re.compile(r"^[ \t]*(?:async[ \t]+)?(?:def|class)[ \t]+([A-Za-z_]\w*)", re.MULTILINE)
|
|
97
|
+
_GENERIC_SYMBOL = re.compile(
|
|
98
|
+
r"^[ \t]*(?:export[ \t]+)?(?:default[ \t]+)?(?:async[ \t]+)?"
|
|
99
|
+
r"(?:function|class|interface|type|enum|struct|impl|trait|fn|func)"
|
|
100
|
+
r"[ \t]+([A-Za-z_]\w*)",
|
|
101
|
+
re.MULTILINE,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def language_of(path: str) -> str:
|
|
106
|
+
"""Map a path to a coarse language label by extension ('text' otherwise)."""
|
|
107
|
+
return _LANGUAGE_BY_SUFFIX.get(Path(path).suffix.lower(), "text")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_symbols(text: str, language: str) -> tuple[str, ...]:
|
|
111
|
+
"""Extract declared symbol names (deduped, source order). Deterministic."""
|
|
112
|
+
pattern = _PY_SYMBOL if language == "python" else _GENERIC_SYMBOL
|
|
113
|
+
seen: dict[str, None] = {}
|
|
114
|
+
for name in pattern.findall(text):
|
|
115
|
+
seen.setdefault(name, None)
|
|
116
|
+
return tuple(seen)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class IndexedFile:
|
|
121
|
+
path: str
|
|
122
|
+
language: str
|
|
123
|
+
size: int
|
|
124
|
+
symbols: tuple[str, ...]
|
|
125
|
+
text: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class RepoIndex:
|
|
130
|
+
"""A read-only snapshot of an indexed tree."""
|
|
131
|
+
|
|
132
|
+
root: Path
|
|
133
|
+
git: dict
|
|
134
|
+
files: dict[str, IndexedFile]
|
|
135
|
+
skipped: list[tuple[str, str]]
|
|
136
|
+
truncated: bool = False
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def total_bytes(self) -> int:
|
|
140
|
+
return sum(entry.size for entry in self.files.values())
|
|
141
|
+
|
|
142
|
+
def paths(self) -> list[str]:
|
|
143
|
+
return sorted(self.files)
|
|
144
|
+
|
|
145
|
+
def entries(self) -> list[IndexedFile]:
|
|
146
|
+
return [self.files[path] for path in self.paths()]
|
|
147
|
+
|
|
148
|
+
def symbol_index(self) -> dict[str, list[str]]:
|
|
149
|
+
"""Map each symbol name to the sorted paths that declare it."""
|
|
150
|
+
index: dict[str, list[str]] = {}
|
|
151
|
+
for path in self.paths():
|
|
152
|
+
for symbol in self.files[path].symbols:
|
|
153
|
+
index.setdefault(symbol, []).append(path)
|
|
154
|
+
return index
|
|
155
|
+
|
|
156
|
+
def find_symbol(self, name: str) -> list[str]:
|
|
157
|
+
return self.symbol_index().get(name, [])
|
|
158
|
+
|
|
159
|
+
def documents(self) -> list[dict]:
|
|
160
|
+
"""rag-shaped documents: ``{"id": int, "title": path, "content": text}``."""
|
|
161
|
+
return [
|
|
162
|
+
{"id": index, "title": path, "content": self.files[path].text}
|
|
163
|
+
for index, path in enumerate(self.paths())
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
def stats(self) -> dict:
|
|
167
|
+
languages: dict[str, int] = {}
|
|
168
|
+
for entry in self.files.values():
|
|
169
|
+
languages[entry.language] = languages.get(entry.language, 0) + 1
|
|
170
|
+
return {
|
|
171
|
+
"root": str(self.root),
|
|
172
|
+
"files": len(self.files),
|
|
173
|
+
"bytes": self.total_bytes,
|
|
174
|
+
"symbols": len(self.symbol_index()),
|
|
175
|
+
"skipped": len(self.skipped),
|
|
176
|
+
"truncated": self.truncated,
|
|
177
|
+
"languages": languages,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _iter_files(root: Path):
|
|
182
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
183
|
+
dirnames[:] = sorted(name for name in dirnames if name.lower() not in _SKIP_DIRS)
|
|
184
|
+
for name in sorted(filenames):
|
|
185
|
+
yield Path(dirpath) / name
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def build_index(
|
|
189
|
+
directory: str | os.PathLike[str] | LocalWorkspace,
|
|
190
|
+
*,
|
|
191
|
+
max_files: int = DEFAULT_MAX_FILES,
|
|
192
|
+
max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES,
|
|
193
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
194
|
+
) -> RepoIndex:
|
|
195
|
+
"""Build a read-only repo index for ``directory`` (or an open driver).
|
|
196
|
+
|
|
197
|
+
Applies the shared denylist and the tree's ``.gitignore``; never writes.
|
|
198
|
+
"""
|
|
199
|
+
workspace = directory if isinstance(directory, LocalWorkspace) else LocalWorkspace.open(directory)
|
|
200
|
+
root = workspace.root
|
|
201
|
+
ignore = workspace.gitignore.ignored
|
|
202
|
+
|
|
203
|
+
files: dict[str, IndexedFile] = {}
|
|
204
|
+
skipped: list[tuple[str, str]] = []
|
|
205
|
+
total = 0
|
|
206
|
+
truncated = False
|
|
207
|
+
|
|
208
|
+
for path in _iter_files(root):
|
|
209
|
+
rel = path.relative_to(root).as_posix()
|
|
210
|
+
try:
|
|
211
|
+
safe = safe_rel_path(rel)
|
|
212
|
+
except LocalWorkspaceError as exc:
|
|
213
|
+
skipped.append((rel, str(exc)))
|
|
214
|
+
continue
|
|
215
|
+
if ignore(rel):
|
|
216
|
+
skipped.append((rel, "gitignored"))
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
size = path.stat().st_size
|
|
220
|
+
except OSError as exc:
|
|
221
|
+
skipped.append((rel, f"stat failed: {exc}"))
|
|
222
|
+
continue
|
|
223
|
+
if size > max_file_bytes:
|
|
224
|
+
skipped.append((rel, f"exceeds {max_file_bytes}-byte file limit"))
|
|
225
|
+
continue
|
|
226
|
+
if len(files) >= max_files:
|
|
227
|
+
truncated = True
|
|
228
|
+
skipped.append((rel, f"exceeds {max_files}-file index limit"))
|
|
229
|
+
continue
|
|
230
|
+
if total + size > max_total_bytes:
|
|
231
|
+
truncated = True
|
|
232
|
+
skipped.append((rel, f"exceeds {max_total_bytes}-byte index limit"))
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
text = path.read_text(encoding="utf-8")
|
|
236
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
237
|
+
skipped.append((rel, f"not readable utf-8 text: {exc}"))
|
|
238
|
+
continue
|
|
239
|
+
language = language_of(safe)
|
|
240
|
+
files[safe] = IndexedFile(
|
|
241
|
+
path=safe,
|
|
242
|
+
language=language,
|
|
243
|
+
size=size,
|
|
244
|
+
symbols=extract_symbols(text, language),
|
|
245
|
+
text=text,
|
|
246
|
+
)
|
|
247
|
+
total += size
|
|
248
|
+
|
|
249
|
+
return RepoIndex(
|
|
250
|
+
root=root,
|
|
251
|
+
git=workspace.git.as_dict(),
|
|
252
|
+
files=files,
|
|
253
|
+
skipped=skipped,
|
|
254
|
+
truncated=truncated,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = [
|
|
259
|
+
"DEFAULT_MAX_FILES",
|
|
260
|
+
"DEFAULT_MAX_FILE_BYTES",
|
|
261
|
+
"DEFAULT_MAX_TOTAL_BYTES",
|
|
262
|
+
"IndexedFile",
|
|
263
|
+
"RepoIndex",
|
|
264
|
+
"build_index",
|
|
265
|
+
"extract_symbols",
|
|
266
|
+
"language_of",
|
|
267
|
+
]
|