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
@@ -0,0 +1,883 @@
1
+ """SQLite storage for runs, captured traffic and the endpoint map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from collections.abc import Iterable, Sequence
9
+ from dataclasses import asdict, dataclass
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from ghost_api.analysis.models import Endpoint, PathParam, QueryParam
15
+ from ghost_api.capture.models import CapturedRequest
16
+
17
+ if TYPE_CHECKING:
18
+ from ghost_api.probe.prober import ProbeReport
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS runs (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ domain TEXT NOT NULL,
24
+ start_url TEXT NOT NULL,
25
+ goal TEXT NOT NULL,
26
+ started_at TEXT NOT NULL,
27
+ finished_at TEXT,
28
+ status TEXT NOT NULL DEFAULT 'running',
29
+ llm_calls INTEGER NOT NULL DEFAULT 0,
30
+ tokens INTEGER NOT NULL DEFAULT 0
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS requests (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ run_id INTEGER NOT NULL REFERENCES runs(id),
36
+ method TEXT NOT NULL,
37
+ url TEXT NOT NULL,
38
+ status INTEGER NOT NULL,
39
+ resource_type TEXT NOT NULL,
40
+ req_headers TEXT NOT NULL,
41
+ req_body TEXT,
42
+ req_content_type TEXT,
43
+ resp_content_type TEXT,
44
+ resp_body TEXT,
45
+ truncated INTEGER NOT NULL DEFAULT 0,
46
+ third_party INTEGER NOT NULL DEFAULT 0,
47
+ timestamp TEXT NOT NULL
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS endpoints (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ domain TEXT NOT NULL,
53
+ method TEXT NOT NULL,
54
+ host TEXT NOT NULL,
55
+ url_template TEXT NOT NULL,
56
+ name TEXT,
57
+ description TEXT,
58
+ auth_type TEXT,
59
+ pagination TEXT,
60
+ status TEXT NOT NULL DEFAULT 'discovered',
61
+ path_params TEXT NOT NULL DEFAULT '[]',
62
+ query_params TEXT NOT NULL DEFAULT '[]',
63
+ sample_count INTEGER NOT NULL DEFAULT 0,
64
+ statuses TEXT NOT NULL DEFAULT '[]',
65
+ example_urls TEXT NOT NULL DEFAULT '[]',
66
+ third_party INTEGER NOT NULL DEFAULT 0,
67
+ last_run_id INTEGER REFERENCES runs(id),
68
+ updated_at TEXT NOT NULL,
69
+ UNIQUE (domain, method, host, url_template)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS schemas (
73
+ endpoint_id INTEGER NOT NULL REFERENCES endpoints(id) ON DELETE CASCADE,
74
+ kind TEXT NOT NULL CHECK (kind IN ('request', 'response')),
75
+ json_schema TEXT NOT NULL,
76
+ sample_count INTEGER NOT NULL,
77
+ PRIMARY KEY (endpoint_id, kind)
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS agent_steps (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ run_id INTEGER NOT NULL REFERENCES runs(id),
83
+ step_no INTEGER NOT NULL,
84
+ thought TEXT NOT NULL,
85
+ action TEXT NOT NULL,
86
+ args TEXT NOT NULL,
87
+ result TEXT NOT NULL,
88
+ new_endpoints INTEGER NOT NULL DEFAULT 0,
89
+ created_at TEXT NOT NULL
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS hypotheses (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ run_id INTEGER NOT NULL REFERENCES runs(id),
95
+ endpoint_key TEXT NOT NULL,
96
+ text TEXT NOT NULL,
97
+ result TEXT NOT NULL CHECK (result IN ('confirmed', 'rejected', 'inconclusive')),
98
+ evidence TEXT NOT NULL,
99
+ created_at TEXT NOT NULL
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS run_events (
103
+ run_id INTEGER NOT NULL REFERENCES runs(id),
104
+ seq INTEGER NOT NULL,
105
+ type TEXT NOT NULL,
106
+ payload TEXT NOT NULL,
107
+ at TEXT NOT NULL,
108
+ PRIMARY KEY (run_id, seq)
109
+ );
110
+
111
+ CREATE TABLE IF NOT EXISTS lessons (
112
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
113
+ domain TEXT NOT NULL,
114
+ run_id INTEGER REFERENCES runs(id),
115
+ text TEXT NOT NULL,
116
+ source TEXT NOT NULL CHECK (source IN ('auto', 'llm')),
117
+ created_at TEXT NOT NULL,
118
+ UNIQUE (domain, text)
119
+ );
120
+
121
+ CREATE TABLE IF NOT EXISTS naming_plans (
122
+ domain TEXT PRIMARY KEY,
123
+ plan TEXT NOT NULL,
124
+ updated_at TEXT NOT NULL
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS llm_cache (
128
+ prompt_hash TEXT PRIMARY KEY,
129
+ provider TEXT NOT NULL,
130
+ model TEXT NOT NULL,
131
+ response TEXT NOT NULL,
132
+ created_at TEXT NOT NULL
133
+ );
134
+
135
+ -- Endpoints the user checked only read data (e.g. a POST search): `generate --allow`.
136
+ CREATE TABLE IF NOT EXISTS allowed_endpoints (
137
+ domain TEXT NOT NULL,
138
+ endpoint_key TEXT NOT NULL,
139
+ created_at TEXT NOT NULL,
140
+ PRIMARY KEY (domain, endpoint_key)
141
+ );
142
+
143
+ -- Client versions: the API's public shape each time it changed (ghost_api/drift.py).
144
+ CREATE TABLE IF NOT EXISTS api_versions (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ domain TEXT NOT NULL,
147
+ version TEXT NOT NULL,
148
+ snapshot TEXT NOT NULL,
149
+ changes TEXT NOT NULL DEFAULT '[]',
150
+ run_id INTEGER,
151
+ created_at TEXT NOT NULL
152
+ );
153
+
154
+ -- Watches: re-check an endpoint on a schedule and record what changed (ghost_api/watch.py).
155
+ CREATE TABLE IF NOT EXISTS watches (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ domain TEXT NOT NULL,
158
+ operation TEXT NOT NULL,
159
+ params TEXT NOT NULL DEFAULT '{}',
160
+ every_minutes INTEGER NOT NULL DEFAULT 360,
161
+ notify TEXT NOT NULL DEFAULT '[]',
162
+ track TEXT NOT NULL DEFAULT '[]',
163
+ max_pages INTEGER NOT NULL DEFAULT 5,
164
+ created_at TEXT NOT NULL,
165
+ last_check_at TEXT,
166
+ last_status TEXT
167
+ );
168
+
169
+ CREATE TABLE IF NOT EXISTS watch_items (
170
+ watch_id INTEGER NOT NULL,
171
+ item_key TEXT NOT NULL,
172
+ data TEXT NOT NULL,
173
+ first_seen TEXT NOT NULL,
174
+ last_seen TEXT NOT NULL,
175
+ gone_at TEXT,
176
+ PRIMARY KEY (watch_id, item_key)
177
+ );
178
+
179
+ CREATE TABLE IF NOT EXISTS watch_changes (
180
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
181
+ watch_id INTEGER NOT NULL,
182
+ at TEXT NOT NULL,
183
+ kind TEXT NOT NULL,
184
+ item_key TEXT NOT NULL,
185
+ summary TEXT NOT NULL
186
+ );
187
+
188
+ CREATE INDEX IF NOT EXISTS idx_requests_run ON requests(run_id);
189
+ CREATE INDEX IF NOT EXISTS idx_runs_domain ON runs(domain);
190
+ """
191
+
192
+
193
+ # (table, column, definition) for columns added after the first release of a table.
194
+ MIGRATIONS = (
195
+ ("requests", "third_party", "INTEGER NOT NULL DEFAULT 0"),
196
+ ("endpoints", "third_party", "INTEGER NOT NULL DEFAULT 0"),
197
+ ("endpoints", "required_headers", "TEXT NOT NULL DEFAULT '[]'"),
198
+ ("endpoints", "replayable", "INTEGER"),
199
+ ("endpoints", "status_note", "TEXT NOT NULL DEFAULT ''"),
200
+ ("requests", "personal", "TEXT NOT NULL DEFAULT '[]'"),
201
+ ("endpoints", "gone_run_id", "INTEGER"),
202
+ )
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class RunRow:
207
+ id: int
208
+ domain: str
209
+ start_url: str
210
+ goal: str
211
+ started_at: str
212
+ finished_at: str | None
213
+ status: str
214
+ request_count: int
215
+ llm_calls: int = 0
216
+ tokens: int = 0
217
+
218
+
219
+ def _watch(r: sqlite3.Row) -> dict[str, Any]:
220
+ return {
221
+ "id": r["id"],
222
+ "site": r["domain"],
223
+ "operation": r["operation"],
224
+ "params": json.loads(r["params"]),
225
+ "every_minutes": r["every_minutes"],
226
+ "notify": json.loads(r["notify"]),
227
+ "track": json.loads(r["track"]),
228
+ "max_pages": r["max_pages"],
229
+ "created_at": r["created_at"],
230
+ "last_check_at": r["last_check_at"],
231
+ "last_status": r["last_status"],
232
+ }
233
+
234
+
235
+ def _now() -> str:
236
+ return datetime.now(UTC).isoformat(timespec="seconds")
237
+
238
+
239
+ def _template_re(template: str) -> re.Pattern[str]:
240
+ parts = re.split(r"\{[^}]*\}", template)
241
+ return re.compile("[^/]+".join(re.escape(p) for p in parts) + r"\Z")
242
+
243
+
244
+ def _same_endpoint(old: Endpoint, new: Endpoint) -> bool:
245
+ """`/api/quotes/1` (an early, literal template) and `/api/quotes/{id}` are one endpoint."""
246
+ if (old.method, old.host) != (new.method, new.host):
247
+ return False
248
+ a, b = old.path_template.split("?")[0], new.path_template.split("?")[0]
249
+ return bool(_template_re(b).match(a) or _template_re(a).match(b))
250
+
251
+
252
+ class StoreError(Exception):
253
+ """The database file can't be used (damaged, or not a Ghost API database)."""
254
+
255
+
256
+ class Store:
257
+ def __init__(self, path: Path | str) -> None:
258
+ # check_same_thread=False: the dashboard opens a store per request; WAL lets it
259
+ # read while a run (another thread) writes; timeout waits out short locks.
260
+ self.path = Path(path)
261
+ self.conn = sqlite3.connect(str(path), timeout=15, check_same_thread=False)
262
+ self.conn.row_factory = sqlite3.Row
263
+ try:
264
+ self.conn.execute("PRAGMA journal_mode=WAL")
265
+ self.conn.execute("PRAGMA foreign_keys = ON")
266
+ self.conn.executescript(SCHEMA)
267
+ self._migrate()
268
+ except sqlite3.DatabaseError as exc:
269
+ self.conn.close()
270
+ if "locked" in str(exc):
271
+ raise
272
+ raise StoreError(
273
+ f"{path} is damaged or is not a Ghost API database ({exc}). Move it aside "
274
+ "(or use --db to pick another file) and Ghost API will start a fresh one."
275
+ ) from exc
276
+
277
+ def _migrate(self) -> None:
278
+ """Add columns introduced after a database was first created."""
279
+ for table, column, ddl in MIGRATIONS:
280
+ cols = {r["name"] for r in self.conn.execute(f"PRAGMA table_info({table})")}
281
+ if column not in cols:
282
+ self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {ddl}")
283
+ self.conn.commit()
284
+
285
+ def close(self) -> None:
286
+ self.conn.close()
287
+
288
+ # --- deleting data ---
289
+
290
+ def forget_site(self, domain: str) -> tuple[dict[str, int], list[int]]:
291
+ """Delete everything stored for one site. Returns rows deleted per table and the
292
+ deleted run ids (their screenshot folders can then be removed)."""
293
+ runs = [
294
+ r["id"] for r in self.conn.execute("SELECT id FROM runs WHERE domain = ?", (domain,))
295
+ ]
296
+ watches = [
297
+ r["id"] for r in self.conn.execute("SELECT id FROM watches WHERE domain = ?", (domain,))
298
+ ]
299
+ counts: dict[str, int] = {}
300
+
301
+ def delete(table: str, column: str, values: Sequence[object]) -> None:
302
+ if values:
303
+ marks = ", ".join("?" * len(values))
304
+ sql = f"DELETE FROM {table} WHERE {column} IN ({marks})"
305
+ counts[table] = counts.get(table, 0) + self.conn.execute(sql, values).rowcount
306
+
307
+ endpoint_ids = [
308
+ r["id"]
309
+ for r in self.conn.execute("SELECT id FROM endpoints WHERE domain = ?", (domain,))
310
+ ]
311
+ with self.conn:
312
+ for table in ("requests", "agent_steps", "hypotheses", "run_events", "lessons"):
313
+ delete(table, "run_id", runs)
314
+ delete("watch_items", "watch_id", watches)
315
+ delete("watch_changes", "watch_id", watches)
316
+ delete("schemas", "endpoint_id", endpoint_ids)
317
+ tables = ("lessons", "endpoints", "naming_plans", "allowed_endpoints", "watches",
318
+ "api_versions") # fmt: skip
319
+ for table in tables:
320
+ delete(table, "domain", [domain])
321
+ delete("runs", "id", runs)
322
+ return {k: v for k, v in counts.items() if v}, runs
323
+
324
+ # --- client versions (drift.py) ---
325
+
326
+ def add_api_version(
327
+ self,
328
+ domain: str,
329
+ version: str,
330
+ snapshot: dict[str, Any],
331
+ changes: list[dict[str, Any]],
332
+ run_id: int | None,
333
+ ) -> None:
334
+ with self.conn:
335
+ self.conn.execute(
336
+ "INSERT INTO api_versions (domain, version, snapshot, changes, run_id, created_at)"
337
+ " VALUES (?, ?, ?, ?, ?, ?)",
338
+ (domain, version, json.dumps(snapshot, sort_keys=True), json.dumps(changes),
339
+ run_id, _now()),
340
+ ) # fmt: skip
341
+
342
+ def api_versions(self, domain: str) -> list[dict[str, Any]]:
343
+ """Stored client versions for a site, newest first."""
344
+ rows = self.conn.execute(
345
+ "SELECT * FROM api_versions WHERE domain = ? ORDER BY id DESC", (domain,)
346
+ )
347
+ return [
348
+ {"version": r["version"], "snapshot": json.loads(r["snapshot"]),
349
+ "changes": json.loads(r["changes"]), "run_id": r["run_id"],
350
+ "created_at": r["created_at"]}
351
+ for r in rows
352
+ ] # fmt: skip
353
+
354
+ def clear_llm_cache(self) -> int:
355
+ """Remove cached LLM answers (they contain text from the pages that were explored)."""
356
+ with self.conn:
357
+ return self.conn.execute("DELETE FROM llm_cache").rowcount
358
+
359
+ def __enter__(self) -> Store:
360
+ return self
361
+
362
+ def __exit__(self, *exc: object) -> None:
363
+ self.close()
364
+
365
+ # --- runs ---
366
+
367
+ def create_run(self, domain: str, start_url: str, goal: str) -> int:
368
+ cur = self.conn.execute(
369
+ "INSERT INTO runs (domain, start_url, goal, started_at) VALUES (?, ?, ?, ?)",
370
+ (domain, start_url, goal, _now()),
371
+ )
372
+ self.conn.commit()
373
+ assert cur.lastrowid is not None
374
+ return cur.lastrowid
375
+
376
+ def finish_run(self, run_id: int, status: str) -> None:
377
+ self.conn.execute(
378
+ "UPDATE runs SET status = ?, finished_at = ? WHERE id = ?", (status, _now(), run_id)
379
+ )
380
+ self.conn.commit()
381
+
382
+ def list_runs(self, domain: str | None = None) -> list[RunRow]:
383
+ sql = """
384
+ SELECT r.*, (SELECT COUNT(*) FROM requests q WHERE q.run_id = r.id) AS request_count
385
+ FROM runs r {where} ORDER BY r.id DESC
386
+ """
387
+ if domain:
388
+ rows = self.conn.execute(sql.format(where="WHERE r.domain = ?"), (domain,))
389
+ else:
390
+ rows = self.conn.execute(sql.format(where=""))
391
+ return [
392
+ RunRow(
393
+ id=r["id"],
394
+ domain=r["domain"],
395
+ start_url=r["start_url"],
396
+ goal=r["goal"],
397
+ started_at=r["started_at"],
398
+ finished_at=r["finished_at"],
399
+ status=r["status"],
400
+ request_count=r["request_count"],
401
+ llm_calls=r["llm_calls"],
402
+ tokens=r["tokens"],
403
+ )
404
+ for r in rows
405
+ ]
406
+
407
+ def record_usage(self, run_id: int, llm_calls: int, tokens: int) -> None:
408
+ self.conn.execute(
409
+ "UPDATE runs SET llm_calls = ?, tokens = ? WHERE id = ?", (llm_calls, tokens, run_id)
410
+ )
411
+ self.conn.commit()
412
+
413
+ # --- requests ---
414
+
415
+ def add_request(self, run_id: int, req: CapturedRequest) -> int:
416
+ cur = self.conn.execute(
417
+ """INSERT INTO requests (run_id, method, url, status, resource_type, req_headers,
418
+ req_body, req_content_type, resp_content_type, resp_body, truncated,
419
+ third_party, timestamp, personal)
420
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
421
+ (
422
+ run_id,
423
+ req.method,
424
+ req.url,
425
+ req.status,
426
+ req.resource_type,
427
+ json.dumps(req.req_headers),
428
+ req.req_body,
429
+ req.req_content_type,
430
+ req.resp_content_type,
431
+ req.resp_body,
432
+ int(req.truncated),
433
+ int(req.third_party),
434
+ req.timestamp,
435
+ json.dumps(req.personal),
436
+ ),
437
+ )
438
+ self.conn.commit()
439
+ assert cur.lastrowid is not None
440
+ return cur.lastrowid
441
+
442
+ def requests_for_run(self, run_id: int) -> list[CapturedRequest]:
443
+ rows = self.conn.execute("SELECT * FROM requests WHERE run_id = ? ORDER BY id", (run_id,))
444
+ return self._requests(rows)
445
+
446
+ def requests_for_domain(self, domain: str) -> list[CapturedRequest]:
447
+ rows = self.conn.execute(
448
+ """SELECT q.* FROM requests q JOIN runs r ON r.id = q.run_id
449
+ WHERE r.domain = ? ORDER BY q.id""",
450
+ (domain,),
451
+ )
452
+ return self._requests(rows)
453
+
454
+ def get_run(self, run_id: int) -> RunRow | None:
455
+ return next((r for r in self.list_runs() if r.id == run_id), None)
456
+
457
+ def latest_run(self, domain: str) -> RunRow | None:
458
+ runs = self.list_runs(domain)
459
+ return runs[0] if runs else None
460
+
461
+ def _requests(self, rows: Iterable[sqlite3.Row]) -> list[CapturedRequest]:
462
+ return [
463
+ CapturedRequest(
464
+ method=r["method"],
465
+ url=r["url"],
466
+ status=r["status"],
467
+ resource_type=r["resource_type"],
468
+ req_headers=json.loads(r["req_headers"]),
469
+ req_body=r["req_body"],
470
+ req_content_type=r["req_content_type"],
471
+ resp_content_type=r["resp_content_type"],
472
+ resp_body=r["resp_body"],
473
+ truncated=bool(r["truncated"]),
474
+ third_party=bool(r["third_party"]),
475
+ timestamp=r["timestamp"],
476
+ personal=json.loads(r["personal"] or "[]"),
477
+ )
478
+ for r in rows
479
+ ]
480
+
481
+ # --- endpoints ---
482
+
483
+ def save_endpoints(self, domain: str, run_id: int, endpoints: list[Endpoint]) -> None:
484
+ """Insert or update endpoints. Fields set by later stages (name, auth...) are kept."""
485
+ for ep in endpoints:
486
+ values = (
487
+ json.dumps([asdict(p) for p in ep.path_params]),
488
+ json.dumps([asdict(p) for p in ep.query_params]),
489
+ ep.sample_count,
490
+ json.dumps(ep.statuses),
491
+ json.dumps(ep.example_urls),
492
+ int(ep.third_party),
493
+ run_id,
494
+ _now(),
495
+ )
496
+ self.conn.execute(
497
+ """INSERT INTO endpoints (path_params, query_params, sample_count, statuses,
498
+ example_urls, third_party, last_run_id, updated_at, domain, method, host,
499
+ url_template)
500
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
501
+ ON CONFLICT (domain, method, host, url_template) DO UPDATE SET
502
+ path_params = excluded.path_params,
503
+ query_params = excluded.query_params,
504
+ sample_count = excluded.sample_count,
505
+ statuses = excluded.statuses,
506
+ example_urls = excluded.example_urls,
507
+ third_party = excluded.third_party,
508
+ last_run_id = excluded.last_run_id,
509
+ gone_run_id = NULL,
510
+ updated_at = excluded.updated_at""",
511
+ (*values, domain, ep.method, ep.host, ep.path_template),
512
+ )
513
+ (endpoint_id,) = self.conn.execute(
514
+ "SELECT id FROM endpoints WHERE domain = ? AND method = ? AND host = ?"
515
+ " AND url_template = ?",
516
+ (domain, ep.method, ep.host, ep.path_template),
517
+ ).fetchone()
518
+ for kind, schema, count in (
519
+ ("request", ep.request_schema, ep.request_samples),
520
+ ("response", ep.response_schema, ep.response_samples),
521
+ ):
522
+ if schema is None:
523
+ continue
524
+ self.conn.execute(
525
+ """INSERT INTO schemas (endpoint_id, kind, json_schema, sample_count)
526
+ VALUES (?, ?, ?, ?)
527
+ ON CONFLICT (endpoint_id, kind) DO UPDATE SET
528
+ json_schema = excluded.json_schema,
529
+ sample_count = excluded.sample_count""",
530
+ (endpoint_id, kind, json.dumps(schema), count),
531
+ )
532
+ self.conn.commit()
533
+
534
+ def latest_endpoint_run(self, domain: str) -> int | None:
535
+ """The most recent run that saved endpoints for this domain."""
536
+ row = self.conn.execute(
537
+ "SELECT MAX(last_run_id) AS run_id FROM endpoints WHERE domain = ?", (domain,)
538
+ ).fetchone()
539
+ return int(row["run_id"]) if row and row["run_id"] is not None else None
540
+
541
+ def current_endpoints(self, domain: str) -> list[Endpoint]:
542
+ """What is known about a site now: the latest run's endpoints, plus endpoints found
543
+ by earlier runs (another goal, say) unless a refresh looked for them and they were
544
+ gone, or the latest run found the same endpoint under a better template."""
545
+ latest_run = self.latest_endpoint_run(domain)
546
+ if latest_run is None:
547
+ return []
548
+ latest = self.load_endpoints(domain, latest_run)
549
+ latest_keys = {e.key for e in latest}
550
+ gone = {
551
+ (r["method"], r["host"], r["url_template"])
552
+ for r in self.conn.execute(
553
+ "SELECT method, host, url_template FROM endpoints"
554
+ " WHERE domain = ? AND gone_run_id IS NOT NULL",
555
+ (domain,),
556
+ )
557
+ }
558
+ merged = list(latest)
559
+ for ep in self.load_endpoints(domain):
560
+ if ep.key in latest_keys or (ep.method, ep.host, ep.path_template) in gone:
561
+ continue
562
+ if not any(_same_endpoint(ep, new) for new in latest):
563
+ merged.append(ep)
564
+ return sorted(merged, key=lambda e: (e.host, e.path_template, e.method))
565
+
566
+ def mark_gone(self, domain: str, keys: Iterable[str], run_id: int) -> None:
567
+ """A refresh looked for these endpoints and did not see them again."""
568
+ wanted = set(keys)
569
+ for ep in self.load_endpoints(domain):
570
+ if ep.key in wanted:
571
+ self.conn.execute(
572
+ "UPDATE endpoints SET gone_run_id = ? WHERE domain = ? AND method = ?"
573
+ " AND host = ? AND url_template = ?",
574
+ (run_id, domain, ep.method, ep.host, ep.path_template),
575
+ )
576
+ self.conn.commit()
577
+
578
+ def load_endpoints(self, domain: str, run_id: int | None = None) -> list[Endpoint]:
579
+ """All endpoints ever seen for a domain, or only those seen in `run_id`."""
580
+ sql = "SELECT * FROM endpoints WHERE domain = ?"
581
+ args: tuple[object, ...] = (domain,)
582
+ if run_id is not None:
583
+ sql += " AND last_run_id = ?"
584
+ args = (domain, run_id)
585
+ rows = self.conn.execute(sql + " ORDER BY host, url_template, method", args).fetchall()
586
+ out: list[Endpoint] = []
587
+ for r in rows:
588
+ schemas = {
589
+ s["kind"]: (json.loads(s["json_schema"]), s["sample_count"])
590
+ for s in self.conn.execute(
591
+ "SELECT * FROM schemas WHERE endpoint_id = ?", (r["id"],)
592
+ )
593
+ }
594
+ request = schemas.get("request")
595
+ response = schemas.get("response")
596
+ out.append(
597
+ Endpoint(
598
+ method=r["method"],
599
+ host=r["host"],
600
+ path_template=r["url_template"],
601
+ path_params=[PathParam(**p) for p in json.loads(r["path_params"])],
602
+ query_params=[QueryParam(**p) for p in json.loads(r["query_params"])],
603
+ request_schema=request[0] if request else None,
604
+ response_schema=response[0] if response else None,
605
+ sample_count=r["sample_count"],
606
+ request_samples=request[1] if request else 0,
607
+ response_samples=response[1] if response else 0,
608
+ statuses=json.loads(r["statuses"]),
609
+ example_urls=json.loads(r["example_urls"]),
610
+ third_party=bool(r["third_party"]),
611
+ replayable=None if r["replayable"] is None else bool(r["replayable"]),
612
+ auth_type=r["auth_type"],
613
+ pagination=json.loads(r["pagination"]) if r["pagination"] else None,
614
+ required_headers=json.loads(r["required_headers"]),
615
+ status=r["status"],
616
+ status_note=r["status_note"],
617
+ )
618
+ )
619
+ return out
620
+
621
+ # --- probe results ---
622
+
623
+ def save_probe_report(
624
+ self, domain: str, run_id: int, endpoint: Endpoint, report: ProbeReport
625
+ ) -> None:
626
+ self.conn.execute(
627
+ """UPDATE endpoints SET replayable = ?, auth_type = ?, pagination = ?,
628
+ required_headers = ?, updated_at = ?
629
+ WHERE domain = ? AND method = ? AND host = ? AND url_template = ?""",
630
+ (
631
+ int(report.replayable),
632
+ report.auth_type,
633
+ json.dumps(report.pagination) if report.pagination else None,
634
+ json.dumps(report.required_headers),
635
+ _now(),
636
+ domain,
637
+ endpoint.method,
638
+ endpoint.host,
639
+ endpoint.path_template,
640
+ ),
641
+ )
642
+ for h in report.hypotheses:
643
+ self.conn.execute(
644
+ """INSERT INTO hypotheses (run_id, endpoint_key, text, result, evidence, created_at)
645
+ VALUES (?, ?, ?, ?, ?, ?)""",
646
+ (run_id, h.endpoint, h.text, h.result, h.evidence, _now()),
647
+ )
648
+ self.conn.commit()
649
+
650
+ def set_endpoint_status(self, domain: str, endpoint: Endpoint, status: str, note: str) -> None:
651
+ self.conn.execute(
652
+ """UPDATE endpoints SET status = ?, status_note = ?, updated_at = ?
653
+ WHERE domain = ? AND method = ? AND host = ? AND url_template = ?""",
654
+ (status, note, _now(), domain, endpoint.method, endpoint.host,
655
+ endpoint.path_template),
656
+ ) # fmt: skip
657
+ self.conn.commit()
658
+
659
+ def hypotheses_for_run(self, run_id: int) -> list[dict[str, object]]:
660
+ rows = self.conn.execute(
661
+ "SELECT endpoint_key, text, result, evidence FROM hypotheses WHERE run_id = ?"
662
+ " ORDER BY id",
663
+ (run_id,),
664
+ )
665
+ return [dict(r) for r in rows]
666
+
667
+ # --- agent steps ---
668
+
669
+ def add_step(
670
+ self,
671
+ run_id: int,
672
+ step_no: int,
673
+ thought: str,
674
+ action: str,
675
+ args: dict[str, object],
676
+ result: str,
677
+ new_endpoints: int,
678
+ ) -> None:
679
+ self.conn.execute(
680
+ """INSERT INTO agent_steps (run_id, step_no, thought, action, args, result,
681
+ new_endpoints, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
682
+ (run_id, step_no, thought, action, json.dumps(args), result, new_endpoints, _now()),
683
+ )
684
+ self.conn.commit()
685
+
686
+ def steps_for_run(self, run_id: int) -> list[dict[str, object]]:
687
+ rows = self.conn.execute(
688
+ "SELECT * FROM agent_steps WHERE run_id = ? ORDER BY step_no", (run_id,)
689
+ )
690
+ return [{**dict(r), "args": json.loads(r["args"])} for r in rows]
691
+
692
+ # --- run events (everything a run showed, for replay in the dashboard) ---
693
+
694
+ def add_event(self, run_id: int, event: dict[str, Any]) -> int:
695
+ row = self.conn.execute(
696
+ "SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM run_events WHERE run_id = ?", (run_id,)
697
+ ).fetchone()
698
+ seq = int(row["seq"])
699
+ self.conn.execute(
700
+ "INSERT INTO run_events (run_id, seq, type, payload, at) VALUES (?, ?, ?, ?, ?)",
701
+ (run_id, seq, str(event.get("type", "event")), json.dumps(event), _now()),
702
+ )
703
+ self.conn.commit()
704
+ return seq
705
+
706
+ def events_for_run(self, run_id: int, after: int = 0) -> list[dict[str, Any]]:
707
+ rows = self.conn.execute(
708
+ "SELECT seq, payload FROM run_events WHERE run_id = ? AND seq > ? ORDER BY seq",
709
+ (run_id, after),
710
+ )
711
+ return [{"seq": r["seq"], **json.loads(r["payload"])} for r in rows]
712
+
713
+ # --- lessons and naming plans (Stage 8) ---
714
+
715
+ def add_lessons(self, domain: str, run_id: int | None, texts: list[str], source: str) -> int:
716
+ """Store new lessons; duplicates of existing ones are ignored. Returns how many."""
717
+ added = 0
718
+ for text in texts:
719
+ cur = self.conn.execute(
720
+ """INSERT OR IGNORE INTO lessons (domain, run_id, text, source, created_at)
721
+ VALUES (?, ?, ?, ?, ?)""",
722
+ (domain, run_id, text, source, _now()),
723
+ )
724
+ added += cur.rowcount
725
+ self.conn.commit()
726
+ return added
727
+
728
+ def lessons_for(self, domain: str, limit: int = 8) -> list[str]:
729
+ """Most recent lessons first."""
730
+ rows = self.conn.execute(
731
+ "SELECT text FROM lessons WHERE domain = ? ORDER BY id DESC LIMIT ?", (domain, limit)
732
+ )
733
+ return [r["text"] for r in rows]
734
+
735
+ def save_naming_plan(self, domain: str, plan_json: str) -> None:
736
+ self.conn.execute(
737
+ """INSERT INTO naming_plans (domain, plan, updated_at) VALUES (?, ?, ?)
738
+ ON CONFLICT (domain) DO UPDATE SET plan = excluded.plan,
739
+ updated_at = excluded.updated_at""",
740
+ (domain, plan_json, _now()),
741
+ )
742
+ self.conn.commit()
743
+
744
+ def load_naming_plan(self, domain: str) -> str | None:
745
+ row = self.conn.execute(
746
+ "SELECT plan FROM naming_plans WHERE domain = ?", (domain,)
747
+ ).fetchone()
748
+ return str(row["plan"]) if row else None
749
+
750
+ def allow_endpoints(self, domain: str, keys: Iterable[str]) -> None:
751
+ self.conn.executemany(
752
+ "INSERT OR IGNORE INTO allowed_endpoints (domain, endpoint_key, created_at)"
753
+ " VALUES (?, ?, ?)",
754
+ [(domain, key, _now()) for key in keys],
755
+ )
756
+ self.conn.commit()
757
+
758
+ def disallow_endpoint(self, domain: str, key: str) -> None:
759
+ self.conn.execute(
760
+ "DELETE FROM allowed_endpoints WHERE domain = ? AND endpoint_key = ?", (domain, key)
761
+ )
762
+ self.conn.commit()
763
+
764
+ def allowed_endpoints(self, domain: str) -> set[str]:
765
+ rows = self.conn.execute(
766
+ "SELECT endpoint_key FROM allowed_endpoints WHERE domain = ?", (domain,)
767
+ )
768
+ return {str(r["endpoint_key"]) for r in rows}
769
+
770
+ # --- watches (ghost_api/watch.py) ---
771
+
772
+ def add_watch(self, watch: dict[str, Any]) -> int:
773
+ cur = self.conn.execute(
774
+ """INSERT INTO watches (domain, operation, params, every_minutes, notify, track,
775
+ max_pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
776
+ (
777
+ watch["site"], watch["operation"], json.dumps(watch.get("params") or {}),
778
+ watch.get("every_minutes", 360), json.dumps(watch.get("notify") or []),
779
+ json.dumps(watch.get("track") or []), watch.get("max_pages", 5), _now(),
780
+ ),
781
+ ) # fmt: skip
782
+ self.conn.commit()
783
+ return int(cur.lastrowid or 0)
784
+
785
+ def watches(self) -> list[dict[str, Any]]:
786
+ rows = self.conn.execute("SELECT * FROM watches ORDER BY id")
787
+ return [_watch(r) for r in rows]
788
+
789
+ def get_watch(self, watch_id: int) -> dict[str, Any] | None:
790
+ row = self.conn.execute("SELECT * FROM watches WHERE id = ?", (watch_id,)).fetchone()
791
+ return _watch(row) if row else None
792
+
793
+ def remove_watch(self, watch_id: int) -> bool:
794
+ cur = self.conn.execute("DELETE FROM watches WHERE id = ?", (watch_id,))
795
+ self.conn.execute("DELETE FROM watch_items WHERE watch_id = ?", (watch_id,))
796
+ self.conn.execute("DELETE FROM watch_changes WHERE watch_id = ?", (watch_id,))
797
+ self.conn.commit()
798
+ return cur.rowcount > 0
799
+
800
+ def watch_items(self, watch_id: int) -> dict[str, dict[str, Any]]:
801
+ """The last snapshot: item key -> {"data", "gone"}."""
802
+ rows = self.conn.execute(
803
+ "SELECT item_key, data, gone_at FROM watch_items WHERE watch_id = ?", (watch_id,)
804
+ )
805
+ return {
806
+ str(r["item_key"]): {"data": json.loads(r["data"]), "gone": r["gone_at"] is not None}
807
+ for r in rows
808
+ }
809
+
810
+ def save_watch_check(
811
+ self,
812
+ watch_id: int,
813
+ items: dict[str, dict[str, Any]],
814
+ gone: list[str],
815
+ changes: list[dict[str, Any]],
816
+ status: str,
817
+ ) -> None:
818
+ """Store one check: current items, items that disappeared, and the changes."""
819
+ now = _now()
820
+ self.conn.executemany(
821
+ """INSERT INTO watch_items (watch_id, item_key, data, first_seen, last_seen)
822
+ VALUES (?, ?, ?, ?, ?)
823
+ ON CONFLICT (watch_id, item_key) DO UPDATE SET data = excluded.data,
824
+ last_seen = excluded.last_seen, gone_at = NULL""",
825
+ [(watch_id, key, json.dumps(data), now, now) for key, data in items.items()],
826
+ )
827
+ self.conn.executemany(
828
+ "UPDATE watch_items SET gone_at = ? WHERE watch_id = ? AND item_key = ?",
829
+ [(now, watch_id, key) for key in gone],
830
+ )
831
+ self.conn.executemany(
832
+ """INSERT INTO watch_changes (watch_id, at, kind, item_key, summary)
833
+ VALUES (?, ?, ?, ?, ?)""",
834
+ [(watch_id, now, c["kind"], c["key"], json.dumps(c)) for c in changes],
835
+ )
836
+ self.conn.execute(
837
+ "UPDATE watches SET last_check_at = ?, last_status = ? WHERE id = ?",
838
+ (now, status, watch_id),
839
+ )
840
+ self.conn.commit()
841
+
842
+ def mark_watch_checked(self, watch_id: int, status: str) -> None:
843
+ self.conn.execute(
844
+ "UPDATE watches SET last_check_at = ?, last_status = ? WHERE id = ?",
845
+ (_now(), status, watch_id),
846
+ )
847
+ self.conn.commit()
848
+
849
+ def watch_changes(self, watch_id: int | None = None, limit: int = 50) -> list[dict[str, Any]]:
850
+ sql = "SELECT * FROM watch_changes"
851
+ args: tuple[Any, ...] = ()
852
+ if watch_id is not None:
853
+ sql += " WHERE watch_id = ?"
854
+ args = (watch_id,)
855
+ rows = self.conn.execute(sql + " ORDER BY id DESC LIMIT ?", (*args, limit))
856
+ return [
857
+ {**json.loads(r["summary"]), "watch_id": r["watch_id"], "at": r["at"]} for r in rows
858
+ ]
859
+
860
+ def first_run_llm_calls(self, domain: str) -> int | None:
861
+ """LLM calls of the most recent full exploration (not a refresh), for comparison."""
862
+ row = self.conn.execute(
863
+ """SELECT llm_calls FROM runs WHERE domain = ? AND goal NOT LIKE 'refresh:%'
864
+ ORDER BY id DESC LIMIT 1""",
865
+ (domain,),
866
+ ).fetchone()
867
+ return int(row["llm_calls"]) if row else None
868
+
869
+ # --- LLM cache ---
870
+
871
+ def cache_get(self, prompt_hash: str) -> str | None:
872
+ row = self.conn.execute(
873
+ "SELECT response FROM llm_cache WHERE prompt_hash = ?", (prompt_hash,)
874
+ ).fetchone()
875
+ return str(row["response"]) if row else None
876
+
877
+ def cache_put(self, prompt_hash: str, provider: str, model: str, response: str) -> None:
878
+ self.conn.execute(
879
+ """INSERT OR REPLACE INTO llm_cache (prompt_hash, provider, model, response, created_at)
880
+ VALUES (?, ?, ?, ?, ?)""",
881
+ (prompt_hash, provider, model, response, _now()),
882
+ )
883
+ self.conn.commit()