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.
Files changed (45) hide show
  1. sid_harness/__init__.py +13 -0
  2. sid_harness/__main__.py +4 -0
  3. sid_harness/_native.py +1639 -0
  4. sid_harness/brave_proof.py +246 -0
  5. sid_harness/bridge.py +851 -0
  6. sid_harness/cli.py +937 -0
  7. sid_harness/client.py +161 -0
  8. sid_harness/companion.py +151 -0
  9. sid_harness/config.py +68 -0
  10. sid_harness/context/__init__.py +40 -0
  11. sid_harness/context/index.py +267 -0
  12. sid_harness/context/retrieval.py +74 -0
  13. sid_harness/editor.py +231 -0
  14. sid_harness/gateway.py +106 -0
  15. sid_harness/git_write.py +598 -0
  16. sid_harness/local_workspace.py +421 -0
  17. sid_harness/loop.py +394 -0
  18. sid_harness/lsp.py +598 -0
  19. sid_harness/policy/__init__.py +44 -0
  20. sid_harness/policy/repo_policy.py +626 -0
  21. sid_harness/receipt.py +142 -0
  22. sid_harness/runner.py +468 -0
  23. sid_harness/selftest.py +149 -0
  24. sid_harness/session/__init__.py +33 -0
  25. sid_harness/session/bounds.py +140 -0
  26. sid_harness/session/diff_gate.py +93 -0
  27. sid_harness/session/session.py +427 -0
  28. sid_harness/session/store.py +88 -0
  29. sid_harness/tools/__init__.py +52 -0
  30. sid_harness/tools/mcp_client.py +285 -0
  31. sid_harness/tools/plan.py +138 -0
  32. sid_harness/tools/registry.py +159 -0
  33. sid_harness/tools/skills.py +185 -0
  34. sid_harness/tools/subagents.py +112 -0
  35. sid_harness/tui.py +96 -0
  36. sid_harness/workspace.py +107 -0
  37. sid_harness-0.3.0.data/data/share/sid-harness/editors/README.md +46 -0
  38. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/README.md +32 -0
  39. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/extension.js +395 -0
  40. sid_harness-0.3.0.data/data/share/sid-harness/editors/vscode/package.json +120 -0
  41. sid_harness-0.3.0.dist-info/METADATA +229 -0
  42. sid_harness-0.3.0.dist-info/RECORD +45 -0
  43. sid_harness-0.3.0.dist-info/WHEEL +5 -0
  44. sid_harness-0.3.0.dist-info/entry_points.txt +2 -0
  45. 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)
@@ -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
+ ]