ghost-api-cli 0.2.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 (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ghost API: turn any website into a typed, tested API client."""
2
+
3
+ __version__ = "0.2.0"
ghost_api/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow `python -m ghost_api ...`."""
2
+
3
+ from ghost_api.cli import entry
4
+
5
+ entry()
ghost_api/adopt.py ADDED
@@ -0,0 +1,173 @@
1
+ """`ghost-api adopt PATH`: find hand-written calls to undocumented APIs in a codebase.
2
+
3
+ Scans source files for HTTP calls with a literal URL (fetch, axios, requests, httpx,
4
+ urllib, aiohttp...), groups them by host and, for hosts Ghost API has explored, matches each
5
+ call to the generated client method that replaces it. The result is a replacement map a
6
+ developer (or an AI assistant) can apply; nothing is edited here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+ from urllib.parse import urlsplit
16
+
17
+ from ghost_api.analysis.graphql import split_template
18
+ from ghost_api.generator import NothingToGenerate, site_spec
19
+ from ghost_api.memory.store import Store
20
+ from ghost_api.site import SiteAmbiguous, resolve_site
21
+
22
+ SOURCE_SUFFIXES = {".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".vue", ".svelte"}
23
+ SKIP_DIRS = {
24
+ "node_modules", ".git", ".venv", "venv", "env", "dist", "build", "__pycache__", ".next",
25
+ "out", "output", "coverage", ".tox", ".mypy_cache", ".ruff_cache", "site-packages",
26
+ } # fmt: skip
27
+ MAX_FILE_BYTES = 1_000_000
28
+
29
+ # A call that sends a request, then the first string literal URL in its arguments.
30
+ CALL = re.compile(
31
+ r"""(?P<call>
32
+ \bfetch\s*\(
33
+ | \baxios(?:\.(?P<axios>get|post|put|patch|delete))?\s*\(
34
+ | \b(?:requests|httpx|session|client|aiohttp\.ClientSession\(\)|ky|got|superagent)
35
+ \.(?P<verb>get|post|put|patch|delete|request)\s*\(
36
+ | \burlopen\s*\(
37
+ | \$\.(?:get|post|ajax|getJSON)\s*\(
38
+ )\s*(?:["'`](?P<method_arg>GET|POST|PUT|PATCH|DELETE)["'`]\s*,\s*)?
39
+ [fF]?["'`](?P<url>https?://[^"'`\s]+)["'`]""",
40
+ re.VERBOSE,
41
+ )
42
+ METHOD_OPTION = re.compile(r"""method\s*[:=]\s*["'`](GET|POST|PUT|PATCH|DELETE)["'`]""", re.I)
43
+
44
+
45
+ @dataclass
46
+ class Call:
47
+ file: str
48
+ line: int
49
+ method: str
50
+ url: str
51
+ code: str # the line, trimmed
52
+ replacement: str | None = None # "client.get_quotes(page=2)"
53
+ endpoint: str | None = None # "GET /api/quotes"
54
+
55
+
56
+ @dataclass
57
+ class HostReport:
58
+ host: str
59
+ site: str | None # the explored site that covers it, if any
60
+ calls: list[Call] = field(default_factory=list)
61
+ client_class: str | None = None
62
+ next_step: str = ""
63
+
64
+
65
+ def scan(root: Path) -> list[Call]:
66
+ """Every HTTP call with a literal http(s) URL under `root`."""
67
+ calls: list[Call] = []
68
+ for path in sorted(_source_files(root)):
69
+ try:
70
+ text = path.read_text(encoding="utf-8", errors="replace")
71
+ except OSError:
72
+ continue
73
+ for match in CALL.finditer(text):
74
+ line = text.count("\n", 0, match.start()) + 1
75
+ window = text[match.end() : match.end() + 300]
76
+ method = (
77
+ match.group("method_arg")
78
+ or (match.group("verb") or match.group("axios") or "").upper()
79
+ or _method_option(window)
80
+ or "GET"
81
+ )
82
+ if method == "REQUEST":
83
+ method = _method_option(window) or "GET"
84
+ code = text.splitlines()[line - 1].strip()[:160]
85
+ calls.append(Call(str(path.relative_to(root)), line, method,
86
+ match.group("url").rstrip(").,;"), code)) # fmt: skip
87
+ return calls
88
+
89
+
90
+ def _method_option(window: str) -> str | None:
91
+ found = METHOD_OPTION.search(window.split(")", 1)[0])
92
+ return found.group(1).upper() if found else None
93
+
94
+
95
+ def _source_files(root: Path) -> list[Path]:
96
+ files = []
97
+ for path in root.rglob("*"):
98
+ if any(part in SKIP_DIRS for part in path.relative_to(root).parts):
99
+ continue
100
+ small = path.is_file() and path.stat().st_size <= MAX_FILE_BYTES
101
+ if path.suffix in SOURCE_SUFFIXES and small:
102
+ files.append(path)
103
+ return files
104
+
105
+
106
+ def adopt(root: Path, store: Store | None) -> list[HostReport]:
107
+ """The calls grouped by host, matched to explored sites' client methods."""
108
+ by_host: dict[str, list[Call]] = {}
109
+ for call in scan(root):
110
+ host = (urlsplit(call.url).hostname or "").lower()
111
+ if host and host not in ("localhost", "127.0.0.1"):
112
+ by_host.setdefault(host, []).append(call)
113
+ known = sorted({r.domain for r in store.list_runs()}) if store is not None else []
114
+ reports = []
115
+ for host, calls in sorted(by_host.items(), key=lambda kv: -len(kv[1])):
116
+ site = _site_for(host, calls, known)
117
+ report = HostReport(host, site, calls)
118
+ if site is None or store is None:
119
+ origin = f"https://{host}"
120
+ report.next_step = (
121
+ f'ghost-api run {origin} --goal "..." (or record it in your browser and '
122
+ "ghost-api import FILE.har)"
123
+ )
124
+ else:
125
+ _match(store, site, report)
126
+ reports.append(report)
127
+ return reports
128
+
129
+
130
+ def _site_for(host: str, calls: list[Call], known: list[str]) -> str | None:
131
+ candidates = [s for s in known if s.split("/")[0] == host]
132
+ if not candidates:
133
+ return None
134
+ try:
135
+ return resolve_site(host, set(candidates))
136
+ except SiteAmbiguous: # several sections: the one most calls' paths start with
137
+ paths = [urlsplit(c.url).path for c in calls]
138
+ return max(candidates, key=lambda s: sum(p.startswith("/" + s.partition("/")[2])
139
+ for p in paths)) # fmt: skip
140
+
141
+
142
+ def _match(store: Store, site: str, report: HostReport) -> None:
143
+ try:
144
+ spec = site_spec(store, site)
145
+ except NothingToGenerate:
146
+ report.next_step = f"ghost-api refresh {site} (nothing usable stored yet)"
147
+ return
148
+ report.client_class = spec.class_name
149
+ patterns = []
150
+ for method in spec.methods:
151
+ path, _ = split_template(method.path)
152
+ regex = "^" + re.sub(r"\\\{[^}]*\\\}", "[^/]+", re.escape(path)) + "/?$"
153
+ patterns.append((method, re.compile(regex)))
154
+ unmatched = 0
155
+ for call in report.calls:
156
+ parts = urlsplit(call.url)
157
+ found = next(
158
+ (m for m, rx in patterns if m.http_method == call.method and rx.match(parts.path)),
159
+ None,
160
+ )
161
+ if found is None:
162
+ unmatched += 1
163
+ continue
164
+ call.endpoint = f"{found.http_method} {found.path}"
165
+ args = ", ".join(f"{p.name}=..." for p in found.required)
166
+ call.replacement = f"api.{found.name}({args})"
167
+ report.next_step = f"ghost-api generate {site} then use {spec.class_name}" + (
168
+ f"; {unmatched} call(s) not mapped yet: ghost-api refresh {site}" if unmatched else ""
169
+ )
170
+
171
+
172
+ def to_json(reports: list[HostReport]) -> list[dict[str, Any]]:
173
+ return [asdict(r) for r in reports]
File without changes
@@ -0,0 +1,413 @@
1
+ """The exploration loop: observe -> decide -> act -> learn (Stage 4).
2
+
3
+ Stopping rules (any one ends the loop):
4
+ - the agent finishes (goal covered, or the site has no API for it)
5
+ - the step budget is used up
6
+ - the LLM call budget is used up, or no provider can answer
7
+ - no new endpoint for NO_PROGRESS_STEPS steps in a row
8
+ - the time limit is reached
9
+ - the user presses Ctrl+C (partial results are kept)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from functools import partial
18
+ from typing import Protocol
19
+ from urllib.parse import urljoin, urlsplit
20
+
21
+ from ghost_api.agent.prompts import SYSTEM_PROMPT, HistoryItem, step_prompt
22
+ from ghost_api.agent.tools import Decision, is_typeable
23
+ from ghost_api.analysis.analyzer import analyze, endpoint_keys
24
+ from ghost_api.analysis.detectors import find_items, is_bot_challenge
25
+ from ghost_api.analysis.models import Endpoint
26
+ from ghost_api.browser.driver import ActionError
27
+ from ghost_api.browser.snapshot import PageSnapshot
28
+ from ghost_api.capture.filters import is_same_site, redact_text
29
+ from ghost_api.capture.models import CapturedRequest, RawRequest
30
+ from ghost_api.llm.gateway import BudgetExceeded, LLMReply, LLMUnavailable
31
+ from ghost_api.net import RobotsPolicy
32
+ from ghost_api.probe.replay import Replayer, ReplayRefused, apply_params, is_replayable
33
+
34
+ NO_PROGRESS_STEPS = 8
35
+
36
+
37
+ class Browser(Protocol):
38
+ """What the orchestrator needs from a browser (BrowserSession, or a fake in tests)."""
39
+
40
+ popup_urls: list[str]
41
+
42
+ @property
43
+ def url(self) -> str: ...
44
+
45
+ def snapshot(self, target_host: str, robots: RobotsPolicy | None = None) -> PageSnapshot: ...
46
+ def goto(self, url: str) -> list[CapturedRequest]: ...
47
+ def click(self, element_id: int) -> list[CapturedRequest]: ...
48
+ def type_text(self, element_id: int, text: str, submit: bool) -> list[CapturedRequest]: ...
49
+ def scroll(self, direction: str = "down") -> list[CapturedRequest]: ...
50
+ def go_back(self) -> list[CapturedRequest]: ...
51
+
52
+
53
+ class LLM(Protocol):
54
+ def ask(
55
+ self,
56
+ messages: list[dict[str, str]],
57
+ response_model: type[Decision] | None = None,
58
+ temperature: float = 0.0,
59
+ ) -> LLMReply[Decision]: ...
60
+
61
+
62
+ @dataclass
63
+ class StepRecord:
64
+ step: int
65
+ thought: str
66
+ action: str
67
+ args: dict[str, object]
68
+ result: str
69
+ new_endpoints: list[str] = field(default_factory=list)
70
+ requests: int = 0
71
+ screenshot: str | None = None # name of the screenshot taken after this step
72
+ box: dict[str, float] | None = None # the element clicked or typed into
73
+
74
+
75
+ @dataclass
76
+ class AgentResult:
77
+ status: str # goal-covered | finished | max-steps | no-progress | time-limit |
78
+ # llm-budget | llm-unavailable | bot-protection | interrupted
79
+ reason: str
80
+ steps: list[StepRecord]
81
+ endpoints: list[Endpoint]
82
+
83
+
84
+ class Explorer:
85
+ def __init__(
86
+ self,
87
+ browser: Browser,
88
+ llm: LLM | None,
89
+ captured: Callable[[], list[CapturedRequest]],
90
+ goal: str,
91
+ start_url: str,
92
+ target_host: str,
93
+ max_steps: int = 40,
94
+ max_minutes: float = 15.0,
95
+ robots: RobotsPolicy | None = None,
96
+ on_step: Callable[[StepRecord], None] | None = None,
97
+ clock: Callable[[], float] = time.monotonic,
98
+ replayer: Replayer | None = None,
99
+ raw_requests: Callable[[], list[RawRequest | None]] | None = None,
100
+ add_sample: Callable[[CapturedRequest, RawRequest], object] | None = None,
101
+ ask_user: Callable[[str], str] | None = None,
102
+ memory: str = "",
103
+ screenshot: Callable[[str], str | None] | None = None,
104
+ should_stop: Callable[[], bool] | None = None,
105
+ ) -> None:
106
+ self.browser = browser
107
+ self.llm = llm
108
+ self.captured = captured
109
+ self.goal = goal
110
+ self.start_url = start_url
111
+ self.host = target_host
112
+ self.max_steps = max_steps
113
+ self.deadline_s = max_minutes * 60
114
+ self.robots = robots
115
+ self.on_step = on_step
116
+ self.clock = clock
117
+ self.replayer = replayer
118
+ self.raw_requests = raw_requests
119
+ self.add_sample = add_sample
120
+ self.ask_user = ask_user
121
+ self.memory = memory # notes from earlier runs on this site (Stage 8)
122
+ # Saves a screenshot under the given name and returns it (for the live view).
123
+ self.screenshot = screenshot
124
+ self.should_stop = should_stop # e.g. the dashboard's Stop button
125
+ self.steps: list[StepRecord] = []
126
+ self.history: list[HistoryItem] = []
127
+ self._known: set[str] = set()
128
+
129
+ # --- main loop ---
130
+
131
+ def run(self) -> AgentResult:
132
+ started = self.clock()
133
+ try:
134
+ self.browser.goto(self.start_url)
135
+ self._shoot("start")
136
+ known = {e.key for e in self._endpoints()}
137
+ since_progress = 0
138
+ for step in range(1, self.max_steps + 1):
139
+ if self.clock() - started > self.deadline_s:
140
+ return self._result("time-limit", "time limit reached")
141
+ if self.should_stop is not None and self.should_stop():
142
+ return self._result("interrupted", "stopped by user")
143
+ snapshot = self.browser.snapshot(self.host, self.robots)
144
+ if is_bot_challenge(snapshot.title, snapshot.text):
145
+ # Never try to get past bot protection (NFR-19).
146
+ return self._result(
147
+ "bot-protection", f"bot-protection page detected: {snapshot.title!r}"
148
+ )
149
+ try:
150
+ decision = self._decide(step, snapshot)
151
+ except BudgetExceeded as exc:
152
+ return self._result("llm-budget", str(exc))
153
+ except LLMUnavailable as exc:
154
+ return self._result("llm-unavailable", str(exc))
155
+
156
+ if decision.action == "finish":
157
+ self._record(step, decision, "finished", [], 0)
158
+ # "Covered" only when the agent names nothing missing and found something.
159
+ covered = decision.goal_covered and not decision.missing.strip() and bool(known)
160
+ status = "goal-covered" if covered else "finished"
161
+ return self._result(status, decision.thought)
162
+
163
+ box = self._box(decision)
164
+ result, new_requests = self._execute(decision, snapshot)
165
+ endpoints = self._endpoints()
166
+ new = [self._label(e) for e in endpoints if e.key not in known]
167
+ known |= {e.key for e in endpoints}
168
+ shot = self._shoot(f"step-{step}")
169
+ self._record(step, decision, result, new, len(new_requests), shot, box)
170
+
171
+ since_progress = 0 if new else since_progress + 1
172
+ if since_progress >= NO_PROGRESS_STEPS:
173
+ return self._result(
174
+ "no-progress", f"no new endpoint in {NO_PROGRESS_STEPS} steps"
175
+ )
176
+ return self._result("max-steps", f"used all {self.max_steps} steps")
177
+ except KeyboardInterrupt:
178
+ return self._result("interrupted", "stopped by user")
179
+
180
+ # --- driven step by step from outside (an assistant through MCP: serve/explorer_mcp) ---
181
+
182
+ def begin(self) -> None:
183
+ """Open the start page; then call `act` once per decision."""
184
+ self.browser.goto(self.start_url)
185
+ self._shoot("start")
186
+ self._known = {e.key for e in self._endpoints()}
187
+
188
+ def act(self, decision: Decision) -> StepRecord:
189
+ """Run one action decided elsewhere, with every rule of `run` (same-site only, no
190
+ destructive elements, robots.txt, read-only endpoint tests). Raises BotProtection
191
+ on a bot-protection page: nothing is done there."""
192
+ snapshot = self.browser.snapshot(self.host, self.robots)
193
+ if is_bot_challenge(snapshot.title, snapshot.text):
194
+ raise BotProtection(snapshot.title)
195
+ step = len(self.steps) + 1
196
+ box = self._box(decision)
197
+ result, new_requests = self._execute(decision, snapshot)
198
+ endpoints = self._endpoints()
199
+ new = [self._label(e) for e in endpoints if e.key not in self._known]
200
+ self._known |= {e.key for e in endpoints}
201
+ shot = self._shoot(f"step-{step}")
202
+ self._record(step, decision, result, new, len(new_requests), shot, box)
203
+ return self.steps[-1]
204
+
205
+ # --- decide ---
206
+
207
+ def _decide(self, step: int, snapshot: PageSnapshot) -> Decision:
208
+ assert self.llm is not None, "run() needs an LLM; act() is for outside decisions"
209
+ prompt = step_prompt(
210
+ self.goal,
211
+ step,
212
+ self.max_steps,
213
+ snapshot,
214
+ self._endpoints(),
215
+ self.host,
216
+ self.history,
217
+ self.memory,
218
+ )
219
+ reply = self.llm.ask(
220
+ [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}],
221
+ response_model=Decision,
222
+ )
223
+ assert reply.parsed is not None # the gateway validates structured replies
224
+ return reply.parsed
225
+
226
+ # --- act ---
227
+
228
+ def _execute(
229
+ self, decision: Decision, snapshot: PageSnapshot
230
+ ) -> tuple[str, list[CapturedRequest]]:
231
+ """Run one action. Returns a short result for the agent and the new requests."""
232
+ if decision.action == "call_endpoint":
233
+ return self._call_endpoint(decision)
234
+ if decision.action == "ask_user":
235
+ return self._ask(decision), []
236
+ try:
237
+ new = self._dispatch(decision, snapshot)
238
+ except _Rejected as exc:
239
+ return f"rejected: {exc}", []
240
+ except ActionError as exc:
241
+ return f"failed: {exc}", []
242
+
243
+ notes: list[str] = []
244
+ # A link that tried to open a new tab: follow it in this tab if it's allowed.
245
+ while self.browser.popup_urls:
246
+ popup = self.browser.popup_urls.pop(0)
247
+ if self._allowed_url(popup):
248
+ new += self._safe(partial(self.browser.goto, popup))
249
+ notes.append("opened link in same tab")
250
+ if not is_same_site(self.browser.url, self.host):
251
+ new += self._safe(self.browser.go_back)
252
+ notes.append("left the site, went back")
253
+ return _describe(new, self.browser.url, notes), new
254
+
255
+ def _dispatch(self, d: Decision, snapshot: PageSnapshot) -> list[CapturedRequest]:
256
+ if d.action in ("click", "type"):
257
+ assert d.element_id is not None
258
+ el = snapshot.element(d.element_id)
259
+ if el is None:
260
+ raise _Rejected(f"[{d.element_id}] is not in the element list")
261
+ if d.action == "click":
262
+ return self.browser.click(d.element_id)
263
+ if not is_typeable(el.kind):
264
+ raise _Rejected(f"[{d.element_id}] is a {el.kind}, not a text field")
265
+ assert d.text is not None
266
+ return self.browser.type_text(d.element_id, d.text, d.submit)
267
+ if d.action == "scroll":
268
+ return self.browser.scroll(d.direction or "down")
269
+ if d.action == "navigate":
270
+ assert d.url is not None
271
+ url = urljoin(self.browser.url, d.url)
272
+ if not self._allowed_url(url):
273
+ raise _Rejected(f"{d.url} is off-site, not http(s), or disallowed by robots.txt")
274
+ return self.browser.goto(url)
275
+ if d.action == "go_back":
276
+ return self.browser.go_back()
277
+ raise _Rejected(f"unknown action {d.action}")
278
+
279
+ def _call_endpoint(self, d: Decision) -> tuple[str, list[CapturedRequest]]:
280
+ if self.replayer is None or self.raw_requests is None:
281
+ return "rejected: calling endpoints directly is not enabled", []
282
+ endpoints = self._endpoints()
283
+ target = _find_endpoint(d.endpoint or "", endpoints, self.host)
284
+ if target is None:
285
+ return f"rejected: no endpoint named {d.endpoint!r}; use a name as listed", []
286
+ keys = endpoint_keys(self.captured())
287
+ raws = self.raw_requests()
288
+ samples = [r for k, r in zip(keys, raws, strict=False) if k == target.key and r]
289
+ sample = next((s for s in reversed(samples) if is_replayable(s)), None)
290
+ if sample is None:
291
+ return "rejected: this endpoint can't be called safely (not a read-only request)", []
292
+ params = d.params or {}
293
+ request = apply_params(
294
+ sample, target.path_template, {p.name for p in target.path_params}, params
295
+ )
296
+ try:
297
+ result = self.replayer.send(request)
298
+ except ReplayRefused as exc:
299
+ return f"rejected: {exc}", []
300
+ if result.captured is None:
301
+ return f"failed: {result.error}", []
302
+ if self.add_sample:
303
+ self.add_sample(result.captured, request)
304
+ items = find_items(result.body) if result.ok else None
305
+ summary = f"HTTP {result.status}"
306
+ if result.error:
307
+ summary += f", {result.error}"
308
+ elif items is not None:
309
+ summary += f", {len(items)} records"
310
+ return f"called {target.label} with {params}: {summary}", [result.captured]
311
+
312
+ def _ask(self, d: Decision) -> str:
313
+ if self.ask_user is None:
314
+ return "rejected: no human is available (run with --headed in a terminal)"
315
+ answer = self.ask_user(d.question or "")
316
+ return f"user answered: {redact_text(answer)[:200] or '(no answer)'}"
317
+
318
+ def _allowed_url(self, url: str) -> bool:
319
+ if urlsplit(url).scheme not in ("http", "https") or not is_same_site(url, self.host):
320
+ return False
321
+ return self.robots is None or self.robots.allows(url)
322
+
323
+ def _safe(self, action: Callable[[], list[CapturedRequest]]) -> list[CapturedRequest]:
324
+ try:
325
+ return action()
326
+ except ActionError:
327
+ return []
328
+
329
+ # --- learn / record ---
330
+
331
+ def _label(self, ep: Endpoint) -> str:
332
+ return ep.label if ep.host == self.host else f"{ep.method} {ep.host}{ep.path_template}"
333
+
334
+ def _endpoints(self) -> list[Endpoint]:
335
+ return analyze(self.captured())
336
+
337
+ def _shoot(self, name: str) -> str | None:
338
+ if self.screenshot is None:
339
+ return None
340
+ try:
341
+ return self.screenshot(name)
342
+ except Exception: # noqa: BLE001 - a missing screenshot must not stop the run
343
+ return None
344
+
345
+ def _box(self, d: Decision) -> dict[str, float] | None:
346
+ locate = getattr(self.browser, "element_box", None)
347
+ if d.action not in ("click", "type") or d.element_id is None or locate is None:
348
+ return None
349
+ try:
350
+ box: dict[str, float] | None = locate(d.element_id)
351
+ except Exception: # noqa: BLE001
352
+ return None
353
+ return box
354
+
355
+ def _record(
356
+ self,
357
+ step: int,
358
+ d: Decision,
359
+ result: str,
360
+ new_endpoints: list[str],
361
+ requests: int,
362
+ screenshot: str | None = None,
363
+ box: dict[str, float] | None = None,
364
+ ) -> None:
365
+ if new_endpoints:
366
+ result += f"; NEW ENDPOINT: {', '.join(new_endpoints)}"
367
+ rec = StepRecord(
368
+ step, d.thought, d.action, d.args(), result, new_endpoints, requests, screenshot, box
369
+ )
370
+ self.steps.append(rec)
371
+ self.history.append(HistoryItem(step, d.action, d.args(), result))
372
+ if self.on_step:
373
+ self.on_step(rec)
374
+
375
+ def _result(self, status: str, reason: str) -> AgentResult:
376
+ return AgentResult(status, reason, self.steps, self._endpoints())
377
+
378
+
379
+ class _Rejected(Exception):
380
+ pass
381
+
382
+
383
+ class BotProtection(Exception):
384
+ """The page is a bot-protection challenge; Ghost API never tries to get past it."""
385
+
386
+
387
+ def _find_endpoint(name: str, endpoints: list[Endpoint], host: str) -> Endpoint | None:
388
+ """Match 'GET /api/x', 'GET host/api/x' or the prompt's '... [third-party: host]'."""
389
+ wanted = " ".join(name.split("[", 1)[0].split()).lower()
390
+ for ep in endpoints:
391
+ names = {ep.label.lower(), f"{ep.method} {ep.host}{ep.path_template}".lower()}
392
+ if wanted in names:
393
+ return ep
394
+ return None
395
+
396
+
397
+ def _describe(new: list[CapturedRequest], url: str, notes: list[str]) -> str:
398
+ if new:
399
+ shown = ", ".join(f"{r.method} {_path(r.url)}" for r in new[:4])
400
+ more = f" (+{len(new) - 4} more)" if len(new) > 4 else ""
401
+ text = f"{len(new)} new request{'s' if len(new) != 1 else ''}: {shown}{more}"
402
+ else:
403
+ text = "no new requests"
404
+ text += f"; page is now {_path(url)}"
405
+ if notes:
406
+ text += f" ({'; '.join(notes)})"
407
+ return text
408
+
409
+
410
+ def _path(url: str) -> str:
411
+ parts = urlsplit(url)
412
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
413
+ return path if len(path) <= 60 else path[:57] + "..."