graphite-code 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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/io.py ADDED
@@ -0,0 +1,32 @@
1
+ """Safe file-writing helpers for Graphite artifacts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def atomic_write_text(path: Path, text: str) -> None:
12
+ """Atomically replace a text file after fsyncing the temporary file."""
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
15
+ tmp_path = Path(tmp_name)
16
+ try:
17
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
18
+ f.write(text)
19
+ f.flush()
20
+ os.fsync(f.fileno())
21
+ os.replace(tmp_path, path)
22
+ except Exception:
23
+ try:
24
+ tmp_path.unlink(missing_ok=True)
25
+ finally:
26
+ raise
27
+
28
+
29
+ def atomic_write_json(path: Path, data: Any, *, indent: int | None = 2) -> None:
30
+ """Atomically write JSON with deterministic UTF-8 encoding."""
31
+ text = json.dumps(data, ensure_ascii=False, indent=indent)
32
+ atomic_write_text(path, text)
graphite/listing.py ADDED
@@ -0,0 +1,51 @@
1
+ """Render a bounded list so that emptiness and truncation are always visible.
2
+
3
+ Every human-output listing goes through :func:`listing_lines`. Two guarantees
4
+ hold by construction: a header is never printed over an empty body, and a
5
+ capped list always names how many items it dropped.
6
+
7
+ See docs/superpowers/specs/2026-07-26-listing-marker-contract-design.md.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable, Sequence
13
+ from typing import Any
14
+
15
+ __all__ = ["listing_lines"]
16
+
17
+
18
+ def listing_lines(
19
+ values: Sequence[Any],
20
+ render: Callable[[Any], str] = str,
21
+ *,
22
+ header: str | None = None,
23
+ cap: int | None = None,
24
+ indent: str = " ",
25
+ empty: str | None = "none found",
26
+ more_hint: str | None = None,
27
+ ) -> list[str]:
28
+ """Lines for one bounded listing: header, body, and a truncation marker.
29
+
30
+ ``cap`` of None (or <= 0) means no cap. ``empty=None`` means the whole
31
+ listing is omitted when there is nothing to show -- header included, since
32
+ a header over an empty body is the defect this module exists to prevent.
33
+ ``more_hint`` is appended verbatim; callers supply their own separator.
34
+ """
35
+ if not values and empty is None:
36
+ return []
37
+
38
+ lines: list[str] = []
39
+ if header is not None:
40
+ lines.append(header)
41
+
42
+ if not values:
43
+ lines.append(f"{indent}- {empty}")
44
+ return lines
45
+
46
+ shown = list(values) if cap is None or cap <= 0 else list(values[:cap])
47
+ lines.extend(f"{indent}- {render(value)}" for value in shown)
48
+ dropped = len(values) - len(shown)
49
+ if dropped > 0:
50
+ lines.append(f"{indent}... {dropped} more{more_hint or ''}")
51
+ return lines
graphite/llm.py ADDED
@@ -0,0 +1,518 @@
1
+ """Provider-agnostic LLM enrichment primitives for explicit overlays only."""
2
+ from __future__ import annotations
3
+
4
+ import ipaddress
5
+ import json
6
+ from dataclasses import replace
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal, Protocol
11
+ from urllib.parse import urlsplit
12
+
13
+ from .config import Config
14
+
15
+ CANONICAL_ENRICHMENT_MIGRATION_MESSAGE = (
16
+ "[graphite] canonical graph commands do not accept LLM enrichment; "
17
+ "use 'graphite overlay build' after building the canonical graph"
18
+ )
19
+
20
+ MAX_RESPONSE_BYTES = 64 * 1024
21
+ DEFAULT_MAX_OUTPUT_TOKENS = 512
22
+ PROBE_MAX_OUTPUT_TOKENS = 16
23
+ MAX_OUTPUT_TOKENS = 4096
24
+ ProviderErrorCategory = Literal[
25
+ "configuration",
26
+ "authentication",
27
+ "timeout",
28
+ "connection",
29
+ "provider_error",
30
+ ]
31
+ _PROVIDER_ERROR_CATEGORIES = frozenset(
32
+ {"configuration", "authentication", "timeout", "connection", "provider_error"}
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class CompletionResult:
38
+ text: str
39
+ input_tokens: int = 0
40
+ output_tokens: int = 0
41
+ total_tokens: int = 0
42
+
43
+
44
+ class CompletionProvider(Protocol):
45
+ name: str
46
+
47
+ def complete(self, system: str, user: str) -> CompletionResult:
48
+ ...
49
+
50
+
51
+ class LLMConfigurationError(ValueError):
52
+ """Raised when optional LLM enrichment is enabled but misconfigured."""
53
+
54
+
55
+ class LLMProviderError(RuntimeError):
56
+ """Fixed-category provider failure that never contains remote response data."""
57
+
58
+ def __init__(self, category: ProviderErrorCategory) -> None:
59
+ self.category: ProviderErrorCategory = (
60
+ category if category in _PROVIDER_ERROR_CATEGORIES else "provider_error"
61
+ )
62
+ super().__init__(f"LLM provider failure: {self.category}")
63
+
64
+
65
+ def _bounded_output_tokens(value: object) -> int:
66
+ if not isinstance(value, int) or isinstance(value, bool):
67
+ return DEFAULT_MAX_OUTPUT_TOKENS
68
+ return min(max(value, 1), MAX_OUTPUT_TOKENS)
69
+
70
+
71
+ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
72
+ """Turn every redirect into an HTTPError without forwarding credentials."""
73
+
74
+ def redirect_request(
75
+ self,
76
+ req: urllib.request.Request,
77
+ fp: Any,
78
+ code: int,
79
+ msg: str,
80
+ headers: Any,
81
+ newurl: str,
82
+ ) -> None:
83
+ del req, fp, code, msg, headers, newurl
84
+ return None
85
+
86
+
87
+ def _open_no_redirect(request: urllib.request.Request, timeout: float) -> Any:
88
+ opener = urllib.request.build_opener(_NoRedirectHandler())
89
+ return opener.open(request, timeout=timeout)
90
+
91
+
92
+ class OpenAICompatibleProvider:
93
+ """Chat-completions adapter for OpenAI-compatible HTTP APIs.
94
+
95
+ This intentionally avoids a vendor SDK. It works with OpenAI-compatible gateways such as
96
+ OpenAI, OpenRouter, LM Studio, vLLM, Groq-style endpoints, and local servers that expose
97
+ `/v1/chat/completions`.
98
+ """
99
+
100
+ name = "openai-compatible"
101
+
102
+ def __init__(self, cfg: Config) -> None:
103
+ base_url = cfg.llm_base_url or _default_openai_base_url(cfg.llm_provider)
104
+ if not base_url:
105
+ raise LLMConfigurationError(
106
+ "GRAPHITE_LLM_BASE_URL is required for openai-compatible providers"
107
+ )
108
+ self.base_url = base_url.rstrip("/")
109
+ _validate_llm_base_url(self.base_url, provider=cfg.llm_provider)
110
+ self.model = cfg.llm_model or _default_model(cfg.llm_provider)
111
+ self.api_key = cfg.llm_api_key
112
+ self.timeout = cfg.llm_timeout_seconds
113
+ self.seed = cfg.seed
114
+ self.max_output_tokens = _bounded_output_tokens(cfg.llm_max_output_tokens)
115
+
116
+ def complete(self, system: str, user: str) -> CompletionResult:
117
+ payload = {
118
+ "model": self.model,
119
+ "messages": [
120
+ {"role": "system", "content": system},
121
+ {"role": "user", "content": user},
122
+ ],
123
+ "temperature": 0.1,
124
+ "stream": False,
125
+ "max_tokens": self.max_output_tokens,
126
+ }
127
+ if self.seed is not None:
128
+ payload["seed"] = self.seed
129
+ data = json.dumps(payload).encode("utf-8")
130
+ headers = {"Content-Type": "application/json"}
131
+ if self.api_key:
132
+ headers["Authorization"] = f"Bearer {self.api_key}"
133
+ request = urllib.request.Request(
134
+ f"{self.base_url}/chat/completions",
135
+ data=data,
136
+ headers=headers,
137
+ method="POST",
138
+ )
139
+ body = _urlopen_json(request, self.timeout)
140
+ choices = body.get("choices") or []
141
+ text = ""
142
+ if choices:
143
+ message = choices[0].get("message") or {}
144
+ text = str(message.get("content") or "").strip()
145
+ usage = body.get("usage") or {}
146
+ return CompletionResult(
147
+ text=text,
148
+ input_tokens=int(usage.get("prompt_tokens") or 0),
149
+ output_tokens=int(usage.get("completion_tokens") or 0),
150
+ total_tokens=int(usage.get("total_tokens") or 0),
151
+ )
152
+
153
+
154
+ class OllamaProvider:
155
+ """Native Ollama chat adapter using `/api/chat`."""
156
+
157
+ name = "ollama"
158
+
159
+ def __init__(self, cfg: Config) -> None:
160
+ self.base_url = (cfg.llm_base_url or "http://localhost:11434").rstrip("/")
161
+ _validate_llm_base_url(self.base_url, provider=cfg.llm_provider)
162
+ self.model = cfg.llm_model or "llama3.1"
163
+ self.timeout = cfg.llm_timeout_seconds
164
+ self.seed = cfg.seed
165
+ self.max_output_tokens = _bounded_output_tokens(cfg.llm_max_output_tokens)
166
+
167
+ def complete(self, system: str, user: str) -> CompletionResult:
168
+ payload = {
169
+ "model": self.model,
170
+ "messages": [
171
+ {"role": "system", "content": system},
172
+ {"role": "user", "content": user},
173
+ ],
174
+ "stream": False,
175
+ "options": {
176
+ "temperature": 0.1,
177
+ "seed": self.seed,
178
+ "num_predict": self.max_output_tokens,
179
+ },
180
+ }
181
+ request = urllib.request.Request(
182
+ f"{self.base_url}/api/chat",
183
+ data=json.dumps(payload).encode("utf-8"),
184
+ headers={"Content-Type": "application/json"},
185
+ method="POST",
186
+ )
187
+ body = _urlopen_json(request, self.timeout)
188
+ message = body.get("message") or {}
189
+ input_tokens = int(body.get("prompt_eval_count") or 0)
190
+ output_tokens = int(body.get("eval_count") or 0)
191
+ return CompletionResult(
192
+ text=str(message.get("content") or "").strip(),
193
+ input_tokens=input_tokens,
194
+ output_tokens=output_tokens,
195
+ total_tokens=input_tokens + output_tokens,
196
+ )
197
+
198
+
199
+ def make_provider(cfg: Config) -> CompletionProvider:
200
+ provider = cfg.llm_provider.strip().lower().replace("_", "-")
201
+ if provider in {"ollama", "local"}:
202
+ return OllamaProvider(cfg)
203
+ if provider in {"openai", "openai-compatible", "compatible", "lmstudio", "lm-studio", "vllm", "openrouter", "groq"}:
204
+ return OpenAICompatibleProvider(cfg)
205
+ raise LLMConfigurationError(
206
+ f"unsupported Graphite LLM provider '{cfg.llm_provider}'. Use 'ollama' or 'openai-compatible'."
207
+ )
208
+
209
+
210
+ def canonical_provider_name(provider: str) -> Literal[
211
+ "ollama", "openai-compatible", "custom/unknown"
212
+ ]:
213
+ """Return a fixed public identifier without reflecting configuration input."""
214
+ normalized = provider.strip().lower().replace("_", "-")
215
+ if normalized in {"ollama", "local"}:
216
+ return "ollama"
217
+ if normalized in {
218
+ "openai",
219
+ "openai-compatible",
220
+ "compatible",
221
+ "lmstudio",
222
+ "lm-studio",
223
+ "vllm",
224
+ "openrouter",
225
+ "groq",
226
+ }:
227
+ return "openai-compatible"
228
+ return "custom/unknown"
229
+
230
+
231
+ def _provider_requires_api_key(provider: str) -> bool:
232
+ normalized = provider.strip().lower().replace("_", "-")
233
+ return normalized in {"openai", "openrouter", "groq"}
234
+
235
+
236
+ def _provider_requires_secure_egress(provider: str) -> bool:
237
+ normalized = provider.strip().lower().replace("_", "-")
238
+ return normalized in {"openai", "openrouter", "groq"}
239
+
240
+
241
+ # RFC 6598 carrier-grade NAT / shared address space. Not classified as private by
242
+ # is_private on every CPython patch version, so reject it explicitly rather than relying
243
+ # on the stdlib's version-dependent classification.
244
+ _CGNAT_SHARED_IPV4 = ipaddress.ip_network("100.64.0.0/10")
245
+
246
+
247
+ def _validate_llm_base_url(base_url: str, *, provider: str) -> None:
248
+ """Apply the provider-class egress policy to an operator-supplied base URL.
249
+
250
+ Every provider rejects non-HTTP(S) schemes. The keyed cloud providers
251
+ (openai/openrouter/groq) additionally require HTTPS and reject IP-literal
252
+ loopback/private/link-local/reserved hosts and the name 'localhost' -- those
253
+ providers have no legitimate internal target, so an internal target there is a
254
+ misconfiguration that would leak the bearer token. Local and generic
255
+ openai-compatible providers keep http + loopback/private, which are intended.
256
+ DNS-name hosts are accepted; name-based private targets are out of scope
257
+ because base_url is operator-controlled (no DNS-rebinding adversary).
258
+ """
259
+ parts = urlsplit(base_url)
260
+ scheme = parts.scheme.lower()
261
+ if scheme not in {"http", "https"}:
262
+ raise LLMConfigurationError("GRAPHITE_LLM_BASE_URL must use http or https")
263
+
264
+ if not _provider_requires_secure_egress(provider):
265
+ return
266
+
267
+ if scheme != "https":
268
+ raise LLMConfigurationError("this LLM provider requires an https GRAPHITE_LLM_BASE_URL")
269
+ host = parts.hostname or ""
270
+ if host.lower() == "localhost":
271
+ raise LLMConfigurationError("this LLM provider may not target a loopback or private host")
272
+ try:
273
+ address = ipaddress.ip_address(host)
274
+ except ValueError:
275
+ return # A DNS name; name-based private targets are out of scope.
276
+ if (
277
+ address.is_loopback
278
+ or address.is_private
279
+ or address.is_link_local
280
+ or address.is_reserved
281
+ or address.is_multicast
282
+ or address.is_unspecified
283
+ or (address.version == 4 and address in _CGNAT_SHARED_IPV4)
284
+ ):
285
+ raise LLMConfigurationError("this LLM provider may not target a loopback or private host")
286
+
287
+
288
+ def _provider_has_default_base_url(provider: str) -> bool:
289
+ normalized = provider.strip().lower().replace("_", "-")
290
+ return normalized in {"openai", "openrouter", "groq", "lmstudio", "lm-studio", "vllm"}
291
+
292
+
293
+ def _auto_effective_mode(provider: str) -> str:
294
+ normalized = provider.strip().lower().replace("_", "-")
295
+ if normalized in {"ollama", "local", "lmstudio", "lm-studio", "vllm"}:
296
+ return "local"
297
+ return "cloud"
298
+
299
+
300
+ def decide_auto_llm(
301
+ graph_data: dict[str, Any],
302
+ clusters: dict[str, Any],
303
+ analysis: dict[str, Any],
304
+ cfg: Config,
305
+ ) -> dict[str, Any]:
306
+ """Decide whether optional LLM enrichment is worth running for this graph."""
307
+ metadata = graph_data.get("metadata") or {}
308
+ node_count = int(metadata.get("node_count") or len(graph_data.get("nodes") or []))
309
+ edge_count = int(metadata.get("edge_count") or len(graph_data.get("edges") or []))
310
+ cluster_count = int(clusters.get("count") or len(clusters.get("clusters") or []))
311
+ god_node_count = len(analysis.get("god_nodes") or [])
312
+ surprising_count = len(analysis.get("surprising_connections") or [])
313
+ top_link_count = len(analysis.get("top_files_by_links") or [])
314
+
315
+ signals: list[str] = []
316
+ if node_count >= 250:
317
+ signals.append("node_count>=250")
318
+ if edge_count >= 350:
319
+ signals.append("edge_count>=350")
320
+ if cluster_count >= 8:
321
+ signals.append("cluster_count>=8")
322
+ if god_node_count >= 2:
323
+ signals.append("god_nodes>=2")
324
+ if surprising_count >= 2:
325
+ signals.append("surprising_connections>=2")
326
+ if top_link_count >= 10 and edge_count >= 200:
327
+ signals.append("high_linked_files")
328
+
329
+ provider = cfg.llm_provider.strip().lower().replace("_", "-")
330
+ base_url_ready = bool(cfg.llm_base_url) or _provider_has_default_base_url(provider) or provider in {"ollama", "local"}
331
+ api_key_ready = bool(cfg.llm_api_key) or not _provider_requires_api_key(provider)
332
+ provider_ready = base_url_ready and api_key_ready
333
+
334
+ decision = {
335
+ "enabled": bool(signals) and provider_ready,
336
+ "signals": signals,
337
+ "provider": cfg.llm_provider,
338
+ "model": cfg.llm_model or _default_model(cfg.llm_provider),
339
+ "effective_mode": _auto_effective_mode(cfg.llm_provider),
340
+ "node_count": node_count,
341
+ "edge_count": edge_count,
342
+ "cluster_count": cluster_count,
343
+ "god_node_count": god_node_count,
344
+ "surprising_connection_count": surprising_count,
345
+ "provider_ready": provider_ready,
346
+ }
347
+ if not signals:
348
+ decision["reason"] = "graph_below_auto_threshold"
349
+ elif not base_url_ready:
350
+ decision["reason"] = "missing_llm_base_url"
351
+ elif not api_key_ready:
352
+ decision["reason"] = "missing_llm_api_key"
353
+ else:
354
+ decision["reason"] = "auto_threshold_met"
355
+ return decision
356
+
357
+ def enrich_report(
358
+ graph_data: dict[str, Any],
359
+ clusters: dict[str, Any],
360
+ analysis: dict[str, Any],
361
+ cfg: Config,
362
+ ) -> dict[str, Any]:
363
+ """Return an optional annotation payload for the explicit overlay pipeline."""
364
+ mode = cfg.llm_mode.strip().lower()
365
+ if mode in {"", "0", "false", "none", "off", "disabled"}:
366
+ return {"enabled": False, "mode": "none", "tokens": 0}
367
+
368
+ auto_decision: dict[str, Any] | None = None
369
+ effective_cfg = cfg
370
+ if mode == "auto":
371
+ auto_decision = decide_auto_llm(graph_data, clusters, analysis, cfg)
372
+ if not auto_decision["enabled"]:
373
+ return {
374
+ "enabled": False,
375
+ "status": "skipped",
376
+ "mode": "auto",
377
+ "tokens": 0,
378
+ "provider": cfg.llm_provider,
379
+ "model": auto_decision["model"],
380
+ "reason": auto_decision["reason"],
381
+ "auto": auto_decision,
382
+ }
383
+ effective_cfg = replace(cfg, llm_mode=str(auto_decision["effective_mode"]))
384
+
385
+ try:
386
+ provider = make_provider(effective_cfg)
387
+ system, user = build_report_prompt(graph_data, clusters, analysis, effective_cfg)
388
+ completion = provider.complete(system, user)
389
+ return {
390
+ "enabled": True,
391
+ "status": "ok",
392
+ "mode": mode,
393
+ "effective_mode": effective_cfg.llm_mode,
394
+ "provider": provider.name,
395
+ "model": cfg.llm_model or _default_model(cfg.llm_provider),
396
+ "summary": completion.text,
397
+ "auto": auto_decision,
398
+ "input_tokens": completion.input_tokens,
399
+ "output_tokens": completion.output_tokens,
400
+ "tokens": completion.total_tokens or completion.input_tokens + completion.output_tokens,
401
+ }
402
+ except Exception as exc: # Overlay failures remain isolated from canonical graphs.
403
+ if isinstance(exc, LLMConfigurationError):
404
+ category: ProviderErrorCategory = "configuration"
405
+ elif isinstance(exc, LLMProviderError):
406
+ category = exc.category
407
+ elif isinstance(exc, TimeoutError):
408
+ category = "timeout"
409
+ else:
410
+ category = "provider_error"
411
+ return {
412
+ "enabled": True,
413
+ "status": "error",
414
+ "mode": mode,
415
+ "effective_mode": effective_cfg.llm_mode,
416
+ "provider": cfg.llm_provider,
417
+ "model": cfg.llm_model or _default_model(cfg.llm_provider),
418
+ "auto": auto_decision,
419
+ "tokens": 0,
420
+ "error_category": category,
421
+ }
422
+
423
+
424
+ def build_report_prompt(
425
+ graph_data: dict[str, Any],
426
+ clusters: dict[str, Any],
427
+ analysis: dict[str, Any],
428
+ cfg: Config,
429
+ ) -> tuple[str, str]:
430
+ """Build a bounded prompt from graph metadata only, not source code."""
431
+ payload = {
432
+ "metadata": graph_data.get("metadata", {}),
433
+ "top_files_by_links": analysis.get("top_files_by_links", [])[:12],
434
+ "god_nodes": analysis.get("god_nodes", [])[:12],
435
+ "entry_points": analysis.get("entry_points", [])[:12],
436
+ "surprising_connections": analysis.get("surprising_connections", [])[:12],
437
+ "clusters": [
438
+ {
439
+ "id": c.get("id"),
440
+ "size": c.get("size"),
441
+ "file_count": c.get("file_count"),
442
+ "function_count": c.get("function_count"),
443
+ "class_count": c.get("class_count"),
444
+ "labels": c.get("labels", []),
445
+ }
446
+ for c in clusters.get("clusters", [])[:12]
447
+ ],
448
+ }
449
+ data = json.dumps(payload, ensure_ascii=False, indent=2)
450
+ if len(data) > cfg.llm_max_input_chars:
451
+ data = data[: cfg.llm_max_input_chars] + "\n... truncated by GRAPHITE_LLM_MAX_INPUT_CHARS"
452
+ system = (
453
+ "You are Graphite's optional code-graph analyst. Use only the provided graph metrics. "
454
+ "Do not claim to have inspected source code. Focus on architecture risks, likely hotspots, "
455
+ "and practical next checks. Keep the answer concise."
456
+ )
457
+ user = (
458
+ "Analyze this code graph summary. Return concise bullets under: Hotspots, Coupling risks, "
459
+ "Suggested next checks.\n\n"
460
+ f"{data}"
461
+ )
462
+ return system, user
463
+
464
+
465
+ def _urlopen_json(request: urllib.request.Request, timeout: float) -> dict[str, Any]:
466
+ try:
467
+ with _open_no_redirect(request, timeout) as response: # nosec: opt-in user URL
468
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
469
+ except urllib.error.HTTPError as exc:
470
+ try:
471
+ exc.read(MAX_RESPONSE_BYTES + 1)
472
+ except Exception:
473
+ pass
474
+ category: ProviderErrorCategory = (
475
+ "authentication" if exc.code in {401, 403} else "provider_error"
476
+ )
477
+ raise LLMProviderError(category) from None
478
+ except (TimeoutError, urllib.error.URLError) as exc:
479
+ reason = getattr(exc, "reason", None)
480
+ category = "timeout" if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError) else "connection"
481
+ raise LLMProviderError(category) from None
482
+ except OSError:
483
+ raise LLMProviderError("connection") from None
484
+ if len(raw) > MAX_RESPONSE_BYTES:
485
+ raise LLMProviderError("provider_error")
486
+ try:
487
+ payload = json.loads(raw.decode("utf-8"))
488
+ except (UnicodeDecodeError, json.JSONDecodeError):
489
+ raise LLMProviderError("provider_error") from None
490
+ if not isinstance(payload, dict):
491
+ raise LLMProviderError("provider_error")
492
+ return payload
493
+
494
+
495
+ def _default_openai_base_url(provider: str) -> str | None:
496
+ normalized = provider.strip().lower().replace("_", "-")
497
+ if normalized == "openai":
498
+ return "https://api.openai.com/v1"
499
+ if normalized in {"lmstudio", "lm-studio"}:
500
+ return "http://localhost:1234/v1"
501
+ if normalized == "vllm":
502
+ return "http://localhost:8000/v1"
503
+ if normalized == "openrouter":
504
+ return "https://openrouter.ai/api/v1"
505
+ if normalized == "groq":
506
+ return "https://api.groq.com/openai/v1"
507
+ return None
508
+
509
+
510
+ def _default_model(provider: str) -> str:
511
+ normalized = provider.strip().lower().replace("_", "-")
512
+ if normalized in {"ollama", "local"}:
513
+ return "llama3.1"
514
+ if normalized == "openrouter":
515
+ return "moonshotai/kimi-k2.7-code"
516
+ if normalized == "groq":
517
+ return "llama-3.1-8b-instant"
518
+ return "gpt-4o-mini"