@agentsean/daemon 1.0.0

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 (82) hide show
  1. package/LICENSE +661 -0
  2. package/dist/action-routes.d.ts +10 -0
  3. package/dist/action-routes.d.ts.map +1 -0
  4. package/dist/action-routes.js +303 -0
  5. package/dist/action-routes.js.map +1 -0
  6. package/dist/activity-page.d.ts +2 -0
  7. package/dist/activity-page.d.ts.map +1 -0
  8. package/dist/activity-page.js +115 -0
  9. package/dist/activity-page.js.map +1 -0
  10. package/dist/bind.d.ts +12 -0
  11. package/dist/bind.d.ts.map +1 -0
  12. package/dist/bind.js +62 -0
  13. package/dist/bind.js.map +1 -0
  14. package/dist/boot.d.ts +15 -0
  15. package/dist/boot.d.ts.map +1 -0
  16. package/dist/boot.js +113 -0
  17. package/dist/boot.js.map +1 -0
  18. package/dist/connect-page.d.ts +2 -0
  19. package/dist/connect-page.d.ts.map +1 -0
  20. package/dist/connect-page.js +170 -0
  21. package/dist/connect-page.js.map +1 -0
  22. package/dist/dashboard-routes.d.ts +16 -0
  23. package/dist/dashboard-routes.d.ts.map +1 -0
  24. package/dist/dashboard-routes.js +1059 -0
  25. package/dist/dashboard-routes.js.map +1 -0
  26. package/dist/events.d.ts +6 -0
  27. package/dist/events.d.ts.map +1 -0
  28. package/dist/events.js +16 -0
  29. package/dist/events.js.map +1 -0
  30. package/dist/google-routes.d.ts +13 -0
  31. package/dist/google-routes.d.ts.map +1 -0
  32. package/dist/google-routes.js +297 -0
  33. package/dist/google-routes.js.map +1 -0
  34. package/dist/hosted-routes.d.ts +7 -0
  35. package/dist/hosted-routes.d.ts.map +1 -0
  36. package/dist/hosted-routes.js +88 -0
  37. package/dist/hosted-routes.js.map +1 -0
  38. package/dist/index.d.ts +10 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +10 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/main.d.ts +3 -0
  43. package/dist/main.d.ts.map +1 -0
  44. package/dist/main.js +25 -0
  45. package/dist/main.js.map +1 -0
  46. package/dist/paths.d.ts +13 -0
  47. package/dist/paths.d.ts.map +1 -0
  48. package/dist/paths.js +49 -0
  49. package/dist/paths.js.map +1 -0
  50. package/dist/pdf.d.ts +3 -0
  51. package/dist/pdf.d.ts.map +1 -0
  52. package/dist/pdf.js +51 -0
  53. package/dist/pdf.js.map +1 -0
  54. package/dist/pid.d.ts +11 -0
  55. package/dist/pid.d.ts.map +1 -0
  56. package/dist/pid.js +38 -0
  57. package/dist/pid.js.map +1 -0
  58. package/dist/security.d.ts +14 -0
  59. package/dist/security.d.ts.map +1 -0
  60. package/dist/security.js +93 -0
  61. package/dist/security.js.map +1 -0
  62. package/dist/server.d.ts +25 -0
  63. package/dist/server.d.ts.map +1 -0
  64. package/dist/server.js +84 -0
  65. package/dist/server.js.map +1 -0
  66. package/dist/settings.d.ts +5 -0
  67. package/dist/settings.d.ts.map +1 -0
  68. package/dist/settings.js +25 -0
  69. package/dist/settings.js.map +1 -0
  70. package/dist/spa.d.ts +4 -0
  71. package/dist/spa.d.ts.map +1 -0
  72. package/dist/spa.js +95 -0
  73. package/dist/spa.js.map +1 -0
  74. package/dist/token.d.ts +6 -0
  75. package/dist/token.d.ts.map +1 -0
  76. package/dist/token.js +35 -0
  77. package/dist/token.js.map +1 -0
  78. package/dist/version.d.ts +2 -0
  79. package/dist/version.d.ts.map +1 -0
  80. package/dist/version.js +2 -0
  81. package/dist/version.js.map +1 -0
  82. package/package.json +81 -0
@@ -0,0 +1,1059 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { eq } from "drizzle-orm";
3
+ import { actions, changes, costLedger, crawls, findings, gscDaily, gscPageDaily, gscQueryDaily, pages, pageSnapshots, reports, searchFindingsFts, sites, aiRuns, bingAiRows, verticalProfiles, } from "@agentsean/db";
4
+ import { Secret } from "@agentsean/credentials";
5
+ import { consentTelemetry, isTelemetryEnabled, loadTelemetryConfig, previewPayload, TELEMETRY_EVENTS, } from "@agentsean/launch";
6
+ import { DEFAULT_EVIDENCE_TIER, EVIDENCE_MEANING, listContent, } from "@agentsean/content";
7
+ import { analyzeExperiment, getExperiment, headline, listClaims, listCohortUrls, listExperiments, monthlyClicksForSite, registerExperiment, runMeasureJob, seriesForUrls, sitePowerBrief, startExperiment, } from "@agentsean/measure";
8
+ import { createBingClient, createProviderStack, loadProviderKeys, PROVIDER_ACCOUNTS, } from "@agentsean/providers";
9
+ import { createHashEmbeddings, listClusters, listKeywords, listRanks, loadGscQueries, runKeywordsJob, } from "@agentsean/keywords";
10
+ import { loadLlmConfig } from "@agentsean/llm";
11
+ import { AEO_REFUSALS, GBP_EDITS_PER_MIN, GBP_QPM, GbpNotApprovedError, GbpQuotaError, ONBOARDING_QUESTIONS, applyGbpEdit, bingCitationShare, draftOutreach, listGbpLocations, listInbound404s, listMentions, listOutreach, localCitationGap, runSurfacesJob, runVerticalDetect, saveOnboardingAnswers, verticalRules, } from "@agentsean/surfaces";
12
+ import { ACTION_KINDS, BLAST, KIND_TIER, addTwoKey, actionFromRow, signApproval, } from "@agentsean/actions";
13
+ import { crawlSite, persistCrawl, persistFindings } from "@agentsean/crawler";
14
+ import { buildReport, flattenForDb, SITE_SCORE_FORMULA, SITE_SCORE_VERSION, } from "@agentsean/analyzers";
15
+ import { brandTermsFromOrigin, computeGscInsights, createSqliteQueue, ensureCadences, } from "@agentsean/scheduler";
16
+ import { isHalted, setHalted } from "./paths.js";
17
+ import { textToPdf } from "./pdf.js";
18
+ import { getSetting, getSettingNumber, setSetting } from "./settings.js";
19
+ function approvalKey(token) {
20
+ return createHash("sha256").update(token).digest();
21
+ }
22
+ function parseScore(raw) {
23
+ if (!raw)
24
+ return null;
25
+ try {
26
+ const v = JSON.parse(raw);
27
+ if (typeof v.value !== "number")
28
+ return null;
29
+ return {
30
+ score: v.value,
31
+ version: v.version ?? SITE_SCORE_VERSION,
32
+ formula: SITE_SCORE_FORMULA,
33
+ band: v.band ?? "Needs work",
34
+ };
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function registerDashboardRoutes(app, opts) {
41
+ const queue = opts.queue ?? createSqliteQueue(opts.db);
42
+ app.get("/api/events", (req, reply) => {
43
+ reply.hijack();
44
+ reply.raw.writeHead(200, {
45
+ "Content-Type": "text/event-stream",
46
+ "Cache-Control": "no-cache, no-transform",
47
+ Connection: "keep-alive",
48
+ "X-Accel-Buffering": "no",
49
+ });
50
+ reply.raw.write(`data: ${JSON.stringify({ keys: ["overview"] })}\n\n`);
51
+ const unsub = opts.bus.subscribe((key) => {
52
+ reply.raw.write(`data: ${JSON.stringify({ keys: [key] })}\n\n`);
53
+ });
54
+ const ping = setInterval(() => {
55
+ reply.raw.write(`: ping\n\n`);
56
+ }, 25_000);
57
+ req.raw.on("close", () => {
58
+ clearInterval(ping);
59
+ unsub();
60
+ });
61
+ });
62
+ app.get("/api/sites", () => {
63
+ const rows = opts.db.select().from(sites).all();
64
+ return {
65
+ sites: rows.map((s) => ({
66
+ id: s.id,
67
+ origin: s.origin,
68
+ name: s.name,
69
+ observeUntil: s.observeUntil,
70
+ autonomyMode: s.autonomyMode,
71
+ killswitch: s.killswitch,
72
+ })),
73
+ };
74
+ });
75
+ app.get("/api/overview", (req) => {
76
+ const q = req.query;
77
+ const site = q.siteId
78
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
79
+ : opts.db.select().from(sites).all()[0];
80
+ if (!site)
81
+ return {
82
+ origin: null,
83
+ score: null,
84
+ findings: {},
85
+ thisWeek: { applied: 0, queued: 0, reverted: 0 },
86
+ costUsd: 0,
87
+ };
88
+ const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
89
+ const findingRows = opts.db
90
+ .select()
91
+ .from(findings)
92
+ .where(eq(findings.siteId, site.id))
93
+ .all();
94
+ const bySev = {};
95
+ for (const row of findingRows) {
96
+ if (row.status !== "open")
97
+ continue;
98
+ bySev[row.severity] = (bySev[row.severity] ?? 0) + 1;
99
+ }
100
+ const changeRows = opts.db
101
+ .select()
102
+ .from(changes)
103
+ .where(eq(changes.siteId, site.id))
104
+ .all();
105
+ const thisWeek = {
106
+ applied: changeRows.filter((c) => c.appliedAt >= weekAgo).length,
107
+ queued: opts.db
108
+ .select()
109
+ .from(actions)
110
+ .where(eq(actions.siteId, site.id))
111
+ .all()
112
+ .filter((a) => a.state === "queued").length,
113
+ reverted: changeRows.filter((c) => c.revertedAt && c.revertedAt >= weekAgo)
114
+ .length,
115
+ };
116
+ const costUsd = opts.db
117
+ .select()
118
+ .from(costLedger)
119
+ .where(eq(costLedger.siteId, site.id))
120
+ .all()
121
+ .filter((r) => r.ts >= weekAgo)
122
+ .reduce((s, r) => s + r.costUsd, 0);
123
+ const claimRows = listClaims(opts.db, site.id);
124
+ const evidence = headline(claimRows);
125
+ return {
126
+ origin: site.origin,
127
+ siteId: site.id,
128
+ score: parseScore(getSetting(opts.db, `score:${site.id}`)),
129
+ findings: bySev,
130
+ thisWeek,
131
+ costUsd,
132
+ observeUntil: site.observeUntil,
133
+ evidence: {
134
+ headline: evidence.line,
135
+ byTier: evidence.byTier,
136
+ defaultTier: DEFAULT_EVIDENCE_TIER,
137
+ meaning: EVIDENCE_MEANING,
138
+ },
139
+ };
140
+ });
141
+ app.post("/api/onboard", async (req, reply) => {
142
+ const body = (req.body ?? {});
143
+ let startUrl;
144
+ try {
145
+ const u = new URL(body.url ?? "");
146
+ if (u.protocol !== "http:" && u.protocol !== "https:")
147
+ throw new Error("protocol");
148
+ startUrl = u.href;
149
+ }
150
+ catch {
151
+ return reply.code(400).send({ error: "invalid_url" });
152
+ }
153
+ const t0 = Date.now();
154
+ const crawl = await crawlSite({
155
+ startUrl,
156
+ maxPages: body.maxPages ?? 80,
157
+ render: body.render ?? false,
158
+ rps: 8,
159
+ });
160
+ const { siteId, crawlId } = await persistCrawl(opts.db, crawl);
161
+ const report = buildReport(crawl, Date.now() - t0);
162
+ persistFindings(opts.db, siteId, flattenForDb(siteId, report.findings));
163
+ setSetting(opts.db, `score:${siteId}`, JSON.stringify({
164
+ value: report.score.value,
165
+ version: report.score.version,
166
+ band: report.score.band,
167
+ }));
168
+ await ensureCadences(queue, opts.db, new Date());
169
+ const power = sitePowerBrief({
170
+ monthlyClicks: monthlyClicksForSite(opts.db, siteId),
171
+ pageCount: report.pages,
172
+ });
173
+ const vertical = runVerticalDetect(opts.db, siteId);
174
+ opts.bus.emit("sites");
175
+ opts.bus.emit("findings");
176
+ opts.bus.emit("overview");
177
+ return {
178
+ ok: true,
179
+ siteId,
180
+ crawlId,
181
+ origin: report.origin,
182
+ pages: report.pages,
183
+ findingCount: report.findings.length,
184
+ score: report.score,
185
+ vertical,
186
+ questions: ONBOARDING_QUESTIONS,
187
+ elapsedMs: report.elapsedMs,
188
+ power,
189
+ };
190
+ });
191
+ app.get("/api/findings", (req) => {
192
+ const q = req.query;
193
+ const limit = Math.min(Number(q.limit) || 50, 200);
194
+ let cursor;
195
+ if (q.cursor) {
196
+ const [detectedAt, id] = q.cursor.split(",");
197
+ if (detectedAt && id)
198
+ cursor = { detectedAt, id };
199
+ }
200
+ const hits = searchFindingsFts(opts.sqlite, {
201
+ q: q.q ?? "",
202
+ ...(q.siteId ? { siteId: q.siteId } : {}),
203
+ ...(q.severity ? { severity: q.severity } : {}),
204
+ ...(q.tier ? { autonomyTier: q.tier } : {}),
205
+ status: "open",
206
+ limit: limit + 1,
207
+ ...(cursor ? { cursor } : {}),
208
+ });
209
+ const page = hits.slice(0, limit);
210
+ const extra = hits[limit];
211
+ const nextCursor = extra
212
+ ? `${page[page.length - 1]?.firstDetectedAt},${page[page.length - 1]?.id}`
213
+ : null;
214
+ return { findings: page, nextCursor };
215
+ });
216
+ app.get("/api/crawls", (req) => {
217
+ const q = req.query;
218
+ const rows = q.siteId
219
+ ? opts.db.select().from(crawls).where(eq(crawls.siteId, q.siteId)).all()
220
+ : opts.db.select().from(crawls).all();
221
+ const ordered = rows.toSorted((a, b) => (a.startedAt < b.startedAt ? 1 : -1));
222
+ return {
223
+ crawls: ordered.map((c) => ({
224
+ id: c.id,
225
+ siteId: c.siteId,
226
+ startedAt: c.startedAt,
227
+ finishedAt: c.finishedAt,
228
+ status: c.status,
229
+ pagesSeen: c.pagesSeen,
230
+ })),
231
+ };
232
+ });
233
+ app.get("/api/crawls/:id/diff", (req, reply) => {
234
+ const id = req.params.id;
235
+ const q = req.query;
236
+ const current = opts.db.select().from(crawls).where(eq(crawls.id, id)).get();
237
+ if (!current)
238
+ return reply.code(404).send({ error: "unknown_crawl" });
239
+ const otherId = q.otherId;
240
+ const other = otherId
241
+ ? opts.db.select().from(crawls).where(eq(crawls.id, otherId)).get()
242
+ : opts.db
243
+ .select()
244
+ .from(crawls)
245
+ .where(eq(crawls.siteId, current.siteId))
246
+ .all()
247
+ .filter((c) => c.id !== id)
248
+ .toSorted((a, b) => (a.startedAt < b.startedAt ? 1 : -1))[0];
249
+ const curSnaps = opts.db
250
+ .select()
251
+ .from(pageSnapshots)
252
+ .where(eq(pageSnapshots.crawlId, id))
253
+ .all();
254
+ const othSnaps = other
255
+ ? opts.db
256
+ .select()
257
+ .from(pageSnapshots)
258
+ .where(eq(pageSnapshots.crawlId, other.id))
259
+ .all()
260
+ : [];
261
+ const pageRows = opts.db
262
+ .select()
263
+ .from(pages)
264
+ .where(eq(pages.siteId, current.siteId))
265
+ .all();
266
+ const urlOf = new Map(pageRows.map((p) => [p.id, p]));
267
+ const cur = new Map();
268
+ for (const s of curSnaps) {
269
+ const p = urlOf.get(s.pageId);
270
+ if (!p)
271
+ continue;
272
+ cur.set(p.url, {
273
+ hash: s.contentHash,
274
+ status: s.statusCode,
275
+ firstSeenAt: p.firstSeenAt,
276
+ });
277
+ }
278
+ const prev = new Map();
279
+ for (const s of othSnaps) {
280
+ const p = urlOf.get(s.pageId);
281
+ if (!p)
282
+ continue;
283
+ prev.set(p.url, { hash: s.contentHash, status: s.statusCode });
284
+ }
285
+ const urls = new Set([...cur.keys(), ...prev.keys()]);
286
+ const rows = [];
287
+ for (const url of urls) {
288
+ const a = cur.get(url);
289
+ const b = prev.get(url);
290
+ let mode = "no_change";
291
+ if (a && !b)
292
+ mode = a.firstSeenAt >= current.startedAt ? "new" : "added";
293
+ else if (!a && b)
294
+ mode = "removed";
295
+ else if (a && b && a.hash !== b.hash)
296
+ mode = "changed";
297
+ else if (a && b)
298
+ mode = "no_change";
299
+ if (mode === "removed") {
300
+ /* missing is the sitemap-expected case; without a stored sitemap it aliases removed */
301
+ }
302
+ rows.push({ url, mode, statusCode: a?.status ?? b?.status ?? null });
303
+ }
304
+ const filter = q.filter ?? "all";
305
+ const filtered = filter === "all"
306
+ ? rows
307
+ : filter === "missing"
308
+ ? rows.filter((r) => r.mode === "removed")
309
+ : rows.filter((r) => r.mode === filter);
310
+ return {
311
+ rows: filtered.toSorted((x, y) => x.url.localeCompare(y.url)),
312
+ filter,
313
+ currentId: id,
314
+ otherId: other?.id ?? null,
315
+ };
316
+ });
317
+ app.get("/api/approvals", (req) => {
318
+ const q = req.query;
319
+ const rows = opts.db
320
+ .select()
321
+ .from(actions)
322
+ .all()
323
+ .filter((a) => {
324
+ if (q.siteId && a.siteId !== q.siteId)
325
+ return false;
326
+ return ((a.tier === "T3" || a.tier === "3") &&
327
+ (a.state === "queued" || a.state === "proposed"));
328
+ });
329
+ const out = rows.map((row) => {
330
+ const action = actionFromRow(row);
331
+ const payload = action?.payload ?? {};
332
+ const title = "title" in payload ? String(payload.title) : "";
333
+ const meta = "metaDescription" in payload ? String(payload.metaDescription) : "";
334
+ const json = "json" in payload
335
+ ? JSON.stringify(payload.json, null, 2)
336
+ : "";
337
+ const before = row.targetRef;
338
+ return {
339
+ id: row.id,
340
+ kind: row.actionType,
341
+ targetRef: row.targetRef,
342
+ rationale: action?.rationale ?? [],
343
+ blast: "T3 — human click required, not overridable",
344
+ expires: null,
345
+ diffs: {
346
+ source: { before, after: JSON.stringify(payload, null, 2) },
347
+ rendered: { before, after: title || meta || json || before },
348
+ serp: {
349
+ before: before,
350
+ after: `${title || "(title)"}\n${meta || "(meta description)"}`,
351
+ },
352
+ jsonld: { before: "", after: json || "(no structured data in this action)" },
353
+ },
354
+ };
355
+ });
356
+ return { actions: out };
357
+ });
358
+ app.post("/api/approvals/:id/approve", async (req, reply) => {
359
+ const id = req.params.id;
360
+ const body = (req.body ?? {});
361
+ const actor = (body.actor ?? "").trim();
362
+ if (!actor)
363
+ return reply.code(400).send({ error: "missing_actor" });
364
+ const row = opts.db.select().from(actions).where(eq(actions.id, id)).get();
365
+ if (!row)
366
+ return reply.code(404).send({ error: "unknown_action" });
367
+ const action = actionFromRow(row);
368
+ if (!action)
369
+ return reply.code(400).send({ error: "malformed_action" });
370
+ const hmac = signApproval(approvalKey(opts.token), action, actor);
371
+ addTwoKey(opts.db, id, actor, hmac);
372
+ opts.bus.emit("actions");
373
+ return { ok: true, actor, remaining: Math.max(0, 2 - 1) };
374
+ });
375
+ app.get("/api/automations", (req) => {
376
+ const q = req.query;
377
+ const disabled = new Set((getSetting(opts.db, `automations:${q.siteId ?? "global"}`) ?? "")
378
+ .split(",")
379
+ .filter(Boolean));
380
+ const matrix = ACTION_KINDS.map((kind) => {
381
+ const tier = KIND_TIER[kind];
382
+ const locked = tier >= 3;
383
+ return {
384
+ kind,
385
+ tier,
386
+ auto: !locked && !disabled.has(kind),
387
+ locked,
388
+ note: tier === 4
389
+ ? "Refused. No setting exists."
390
+ : tier === 3
391
+ ? "Always requires a human click."
392
+ : tier === 2
393
+ ? "Auto with budget / rate cap."
394
+ : tier === 1
395
+ ? "Auto, logged, revertible."
396
+ : "Observe only.",
397
+ };
398
+ });
399
+ return { matrix, t1: true, t2: true };
400
+ });
401
+ app.get("/api/content", (req) => {
402
+ const q = req.query;
403
+ const listed = listContent(opts.db, q.siteId);
404
+ const rows = opts.db
405
+ .select()
406
+ .from(actions)
407
+ .all()
408
+ .filter((a) => {
409
+ if (q.siteId && a.siteId !== q.siteId)
410
+ return false;
411
+ return a.actionType === "create_page" || a.actionType === "refresh_content";
412
+ });
413
+ return {
414
+ cap: {
415
+ newPagesPerDay: BLAST.newPagesPerDay,
416
+ contentRefreshPerDay: BLAST.contentRefreshPerDay,
417
+ overridable: false,
418
+ },
419
+ evidence: {
420
+ default: DEFAULT_EVIDENCE_TIER,
421
+ meaning: EVIDENCE_MEANING[DEFAULT_EVIDENCE_TIER],
422
+ },
423
+ briefs: listed.briefs.map((b) => ({
424
+ id: b.id,
425
+ kind: b.kind,
426
+ targetUrl: b.targetUrl,
427
+ score: b.score,
428
+ createdAt: b.createdAt,
429
+ })),
430
+ drafts: listed.drafts.map((d) => ({
431
+ id: d.id,
432
+ state: d.state,
433
+ title: d.title,
434
+ evidenceTier: d.evidenceTier,
435
+ createdAt: d.createdAt,
436
+ publishedAt: d.publishedAt,
437
+ })),
438
+ items: rows.map((r) => ({
439
+ id: r.id,
440
+ kind: r.actionType,
441
+ state: r.state,
442
+ createdAt: r.createdAt,
443
+ targetRef: r.targetRef,
444
+ })),
445
+ };
446
+ });
447
+ app.get("/api/search", (req, reply) => {
448
+ const q = req.query;
449
+ const site = q.siteId
450
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
451
+ : opts.db.select().from(sites).all()[0];
452
+ if (!site)
453
+ return reply.code(400).send({ error: "unknown_site" });
454
+ const pagesDaily = opts.db
455
+ .select()
456
+ .from(gscPageDaily)
457
+ .where(eq(gscPageDaily.siteId, site.id))
458
+ .all();
459
+ const queries = opts.db
460
+ .select()
461
+ .from(gscQueryDaily)
462
+ .where(eq(gscQueryDaily.siteId, site.id))
463
+ .all();
464
+ const days = opts.db
465
+ .select()
466
+ .from(gscDaily)
467
+ .where(eq(gscDaily.siteId, site.id))
468
+ .all();
469
+ return computeGscInsights({
470
+ pages: pagesDaily.map((p) => ({
471
+ date: p.date,
472
+ page: p.page,
473
+ clicks: p.clicks,
474
+ impressions: p.impressions,
475
+ position: p.position,
476
+ })),
477
+ queries: queries.map((r) => ({
478
+ date: r.date,
479
+ query: r.query,
480
+ clicks: r.clicks,
481
+ impressions: r.impressions,
482
+ position: r.position,
483
+ })),
484
+ days: days.map((d) => ({
485
+ date: d.date,
486
+ clicks: d.clicks,
487
+ impressions: d.impressions,
488
+ })),
489
+ brandTerms: brandTermsFromOrigin(site.origin),
490
+ });
491
+ });
492
+ app.get("/api/keywords", (req, reply) => {
493
+ const q = req.query;
494
+ const site = q.siteId
495
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
496
+ : opts.db.select().from(sites).all()[0];
497
+ if (!site)
498
+ return reply.code(400).send({ error: "unknown_site" });
499
+ return {
500
+ origin: site.origin,
501
+ keywords: listKeywords(opts.db, site.id),
502
+ clusters: listClusters(opts.db, site.id),
503
+ ranks: listRanks(opts.db, site.id),
504
+ strikingDistance: listKeywords(opts.db, site.id).filter((r) => r.position !== null && r.position >= 8 && r.position <= 20),
505
+ };
506
+ });
507
+ app.post("/api/keywords", async (req, reply) => {
508
+ const body = (req.body ?? {});
509
+ const site = body.siteId
510
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
511
+ : opts.db.select().from(sites).all()[0];
512
+ if (!site)
513
+ return reply.code(400).send({ error: "unknown_site" });
514
+ const keys = await loadProviderKeys(opts.store);
515
+ const gsc = loadGscQueries(opts.db, site.id);
516
+ const stack = createProviderStack({
517
+ keys,
518
+ gsc: gsc.map((r) => ({
519
+ query: r.query,
520
+ source: "gsc",
521
+ clicks: r.clicks,
522
+ impressions: r.impressions,
523
+ position: r.position,
524
+ })),
525
+ });
526
+ const bing = keys.bing ? createBingClient({ apiKey: keys.bing }) : null;
527
+ const result = await runKeywordsJob(opts.db, {
528
+ siteId: site.id,
529
+ origin: site.origin,
530
+ stack,
531
+ gsc,
532
+ brandTerms: brandTermsFromOrigin(site.origin),
533
+ embeddings: createHashEmbeddings(),
534
+ dailyBudgetUsd: getSettingNumber(opts.db, "budgetUsdDaily", 8),
535
+ ...(bing ? { expand: (seed) => bing.getRelatedKeywords(seed) } : {}),
536
+ });
537
+ opts.bus.emit("search");
538
+ return {
539
+ ok: true,
540
+ paidUpgrade: result.paidUpgrade,
541
+ opportunities: result.opportunities.length,
542
+ clusters: result.clusters.length,
543
+ strikingDistance: result.strikingDistance.length,
544
+ ranks: result.ranks.length,
545
+ embeddingsModel: result.embeddingsModel,
546
+ quotes: result.quotes,
547
+ reason: result.reason ?? null,
548
+ };
549
+ });
550
+ app.get("/api/providers", async () => {
551
+ const keys = await loadProviderKeys(opts.store);
552
+ return {
553
+ dataforseo: Boolean(keys.dataforseo),
554
+ bing: Boolean(keys.bing),
555
+ openpagerank: Boolean(keys.openpagerank),
556
+ paidUpgrade: Boolean(keys.dataforseo),
557
+ neverScrapesGoogle: true,
558
+ free: [
559
+ "gsc",
560
+ "bing_webmaster",
561
+ "autocomplete",
562
+ "openpagerank",
563
+ "psi",
564
+ "crux",
565
+ "wayback",
566
+ "jina",
567
+ "wikidata",
568
+ ],
569
+ };
570
+ });
571
+ app.get("/api/ai", (req) => {
572
+ const q = req.query;
573
+ const site = q.siteId
574
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
575
+ : opts.db.select().from(sites).all()[0];
576
+ if (!site) {
577
+ return {
578
+ engines: [],
579
+ citationShare: 0,
580
+ shareOfVoice: 0,
581
+ estimatedUsd: 0,
582
+ bingShare: null,
583
+ refusals: AEO_REFUSALS,
584
+ note: "No site yet.",
585
+ };
586
+ }
587
+ const runs = opts.db.select().from(aiRuns).where(eq(aiRuns.siteId, site.id)).all();
588
+ const latest = runs.toSorted((a, b) => b.ranAt.localeCompare(a.ranAt))[0];
589
+ const bingRows = opts.db
590
+ .select()
591
+ .from(bingAiRows)
592
+ .where(eq(bingAiRows.siteId, site.id))
593
+ .all();
594
+ return {
595
+ engines: latest ? [latest.engine] : [],
596
+ citationShare: latest?.citationShare ?? 0,
597
+ shareOfVoice: latest?.shareOfVoice ?? 0,
598
+ estimatedUsd: latest?.estimatedUsd ?? 0,
599
+ bingShare: bingRows.length ? bingCitationShare(bingRows) : null,
600
+ bingRows: bingRows.slice(0, 20),
601
+ ranAt: latest?.ranAt ?? null,
602
+ refusals: AEO_REFUSALS,
603
+ note: "Schema, content length, and llms.txt are not sold as AEO levers. Training crawlers ≠ citation crawlers.",
604
+ };
605
+ });
606
+ app.post("/api/ai", async (req, reply) => {
607
+ const body = (req.body ?? {});
608
+ const site = body.siteId
609
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
610
+ : opts.db.select().from(sites).all()[0];
611
+ if (!site)
612
+ return reply.code(400).send({ error: "unknown_site" });
613
+ const llm = await loadLlmConfig(opts.store ? { store: opts.store } : {});
614
+ const result = await runSurfacesJob(opts.db, {
615
+ siteId: site.id,
616
+ origin: site.origin,
617
+ ...(llm?.generate ? { generate: llm.generate } : {}),
618
+ ...(body.bingCsv ? { bingCsv: body.bingCsv } : {}),
619
+ });
620
+ opts.bus.emit("overview");
621
+ opts.bus.emit("ai");
622
+ return { ok: true, ...result, refusals: AEO_REFUSALS };
623
+ });
624
+ app.get("/api/local", (req, reply) => {
625
+ const q = req.query;
626
+ const site = q.siteId
627
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
628
+ : opts.db.select().from(sites).all()[0];
629
+ if (!site)
630
+ return reply.code(400).send({ error: "unknown_site" });
631
+ const locations = listGbpLocations(opts.db, site.id);
632
+ const gap = localCitationGap({
633
+ gbpListed: locations.length > 0,
634
+ aiMentions: listMentions(opts.db, site.id).length,
635
+ localPackVisible: locations.length > 0,
636
+ });
637
+ return {
638
+ locations,
639
+ gap,
640
+ editsPerMin: GBP_EDITS_PER_MIN,
641
+ qpm: GBP_QPM,
642
+ reviewGeneration: "t4_refused",
643
+ cityServicePages: "t4_refused",
644
+ };
645
+ });
646
+ app.post("/api/local", async (req, reply) => {
647
+ const body = (req.body ?? {});
648
+ const site = body.siteId
649
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
650
+ : opts.db.select().from(sites).all()[0];
651
+ if (!site)
652
+ return reply.code(400).send({ error: "unknown_site" });
653
+ if (!body.locationId || !body.kind)
654
+ return reply.code(400).send({ error: "missing_write" });
655
+ try {
656
+ const applied = await applyGbpEdit(opts.db, site.id, {
657
+ locationId: body.locationId,
658
+ kind: body.kind,
659
+ payload: body.payload ?? {},
660
+ });
661
+ opts.bus.emit("overview");
662
+ return { ok: true, id: applied.id };
663
+ }
664
+ catch (err) {
665
+ if (err instanceof GbpNotApprovedError) {
666
+ return reply
667
+ .code(409)
668
+ .send({ error: "gbp_not_approved", message: err.message });
669
+ }
670
+ if (err instanceof GbpQuotaError) {
671
+ return reply.code(429).send({ error: "gbp_quota", message: err.message });
672
+ }
673
+ throw err;
674
+ }
675
+ });
676
+ app.get("/api/mentions", (req, reply) => {
677
+ const q = req.query;
678
+ const site = q.siteId
679
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
680
+ : opts.db.select().from(sites).all()[0];
681
+ if (!site)
682
+ return reply.code(400).send({ error: "unknown_site" });
683
+ return {
684
+ mentions: listMentions(opts.db, site.id),
685
+ inbound404s: listInbound404s(opts.db, site.id),
686
+ outreach: listOutreach(opts.db, site.id),
687
+ sendRequiresApproval: true,
688
+ };
689
+ });
690
+ app.post("/api/mentions", (req, reply) => {
691
+ const body = (req.body ?? {});
692
+ const site = body.siteId
693
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
694
+ : opts.db.select().from(sites).all()[0];
695
+ if (!site)
696
+ return reply.code(400).send({ error: "unknown_site" });
697
+ if (!body.mentionId)
698
+ return reply.code(400).send({ error: "missing_mention" });
699
+ const draftId = draftOutreach(opts.db, site.id, body.mentionId);
700
+ opts.bus.emit("overview");
701
+ return { ok: true, draftId, sendRequiresApproval: true };
702
+ });
703
+ app.get("/api/vertical", (req, reply) => {
704
+ const q = req.query;
705
+ const site = q.siteId
706
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
707
+ : opts.db.select().from(sites).all()[0];
708
+ if (!site)
709
+ return reply.code(400).send({ error: "unknown_site" });
710
+ const stored = opts.db
711
+ .select()
712
+ .from(verticalProfiles)
713
+ .where(eq(verticalProfiles.siteId, site.id))
714
+ .get();
715
+ const detected = stored
716
+ ? {
717
+ preset: stored.preset,
718
+ confidence: stored.confidence,
719
+ suppressedChecks: stored.suppressedChecks,
720
+ v1: stored.preset === "b2b_saas" || stored.preset === "multi_location",
721
+ }
722
+ : runVerticalDetect(opts.db, site.id);
723
+ let answers = {};
724
+ try {
725
+ answers = stored?.answersJson
726
+ ? JSON.parse(stored.answersJson)
727
+ : {};
728
+ }
729
+ catch {
730
+ answers = {};
731
+ }
732
+ return {
733
+ ...detected,
734
+ questions: ONBOARDING_QUESTIONS,
735
+ rules: verticalRules(detected.preset),
736
+ answers,
737
+ };
738
+ });
739
+ app.post("/api/vertical", (req, reply) => {
740
+ const body = (req.body ?? {});
741
+ const site = body.siteId
742
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
743
+ : opts.db.select().from(sites).all()[0];
744
+ if (!site)
745
+ return reply.code(400).send({ error: "unknown_site" });
746
+ const detected = saveOnboardingAnswers(opts.db, site.id, body.answers ?? {});
747
+ opts.bus.emit("overview");
748
+ return {
749
+ ok: true,
750
+ ...detected,
751
+ questions: ONBOARDING_QUESTIONS,
752
+ rules: verticalRules(detected.preset),
753
+ };
754
+ });
755
+ app.get("/api/claims", (req, reply) => {
756
+ const q = req.query;
757
+ const site = q.siteId
758
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
759
+ : opts.db.select().from(sites).all()[0];
760
+ if (!site)
761
+ return reply.code(400).send({ error: "unknown_site" });
762
+ const rows = listClaims(opts.db, site.id);
763
+ return {
764
+ origin: site.origin,
765
+ claims: rows,
766
+ headline: headline(rows).line,
767
+ meaning: EVIDENCE_MEANING,
768
+ };
769
+ });
770
+ app.get("/api/experiments", (req, reply) => {
771
+ const q = req.query;
772
+ const site = q.siteId
773
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
774
+ : opts.db.select().from(sites).all()[0];
775
+ if (!site)
776
+ return reply.code(400).send({ error: "unknown_site" });
777
+ return { experiments: listExperiments(opts.db, site.id) };
778
+ });
779
+ app.post("/api/experiments", (req, reply) => {
780
+ const body = (req.body ?? {});
781
+ const site = body.siteId
782
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
783
+ : opts.db.select().from(sites).all()[0];
784
+ if (!site)
785
+ return reply.code(400).send({ error: "unknown_site" });
786
+ if (!body.hypothesis || !body.preStart || !body.plannedEnd) {
787
+ return reply.code(400).send({ error: "missing_preregistration" });
788
+ }
789
+ const registered = registerExperiment(opts.db, {
790
+ siteId: site.id,
791
+ hypothesis: body.hypothesis,
792
+ interventionKind: body.interventionKind ?? "title",
793
+ design: body.design ?? "split_cohort",
794
+ unit: body.unit ?? "page_group",
795
+ preStart: body.preStart,
796
+ preEnd: body.preEnd ?? body.preStart,
797
+ postStart: body.postStart ?? body.preEnd ?? body.preStart,
798
+ plannedEnd: body.plannedEnd,
799
+ treatmentUrls: body.treatmentUrls ?? [],
800
+ controlUrls: body.controlUrls ?? [],
801
+ reserveUrls: body.reserveUrls ?? [],
802
+ preClicks: body.preClicks ?? {},
803
+ });
804
+ if (registered.status !== "refused")
805
+ startExperiment(opts.db, registered);
806
+ opts.bus.emit("overview");
807
+ return registered;
808
+ });
809
+ app.post("/api/experiments/:id/analyze", (req, reply) => {
810
+ const id = req.params.id;
811
+ const exp = getExperiment(opts.db, id);
812
+ if (!exp)
813
+ return reply.code(404).send({ error: "unknown_experiment" });
814
+ const urls = listCohortUrls(opts.db, exp.id);
815
+ const now = new Date();
816
+ const treatment = seriesForUrls(opts.db, exp.siteId, urls.treatment, exp.preStart, exp.preEnd, exp.postStart, exp.plannedEnd);
817
+ const control = seriesForUrls(opts.db, exp.siteId, urls.control, exp.preStart, exp.preEnd, exp.postStart, exp.plannedEnd);
818
+ const result = analyzeExperiment(opts.db, {
819
+ experimentId: exp.id,
820
+ now,
821
+ treatment,
822
+ control,
823
+ });
824
+ opts.bus.emit("overview");
825
+ return result;
826
+ });
827
+ app.get("/api/measure/power", (req, reply) => {
828
+ const q = req.query;
829
+ const site = q.siteId
830
+ ? opts.db.select().from(sites).where(eq(sites.id, q.siteId)).get()
831
+ : opts.db.select().from(sites).all()[0];
832
+ if (!site)
833
+ return reply.code(400).send({ error: "unknown_site" });
834
+ const pageCount = opts.db
835
+ .select()
836
+ .from(pages)
837
+ .where(eq(pages.siteId, site.id))
838
+ .all().length;
839
+ return sitePowerBrief({
840
+ monthlyClicks: monthlyClicksForSite(opts.db, site.id),
841
+ pageCount,
842
+ });
843
+ });
844
+ app.post("/api/measure", (req, reply) => {
845
+ const body = (req.body ?? {});
846
+ const site = body.siteId
847
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
848
+ : opts.db.select().from(sites).all()[0];
849
+ if (!site)
850
+ return reply.code(400).send({ error: "unknown_site" });
851
+ const result = runMeasureJob(opts.db, { siteId: site.id });
852
+ opts.bus.emit("overview");
853
+ return {
854
+ ok: true,
855
+ headline: result.headline,
856
+ power: result.power,
857
+ claims: result.claims.length,
858
+ analysed: result.analysed.length,
859
+ waterfall: result.waterfall
860
+ ? {
861
+ residual: result.waterfall.residual,
862
+ anonymizedQueryShare: result.waterfall.anonymizedQueryShare,
863
+ euInvisibleShare: result.waterfall.euInvisibleShare,
864
+ causes: result.waterfall.steps.length,
865
+ }
866
+ : null,
867
+ };
868
+ });
869
+ app.get("/api/reports", (req) => {
870
+ const q = req.query;
871
+ const rows = q.siteId
872
+ ? opts.db.select().from(reports).where(eq(reports.siteId, q.siteId)).all()
873
+ : opts.db.select().from(reports).all();
874
+ const ordered = rows.toSorted((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
875
+ return {
876
+ reports: ordered.map((r) => ({
877
+ id: r.id,
878
+ title: r.title,
879
+ createdAt: r.createdAt,
880
+ hash: r.hash,
881
+ whiteLabel: Boolean(r.whiteLabel),
882
+ })),
883
+ };
884
+ });
885
+ app.post("/api/reports", async (req, reply) => {
886
+ const body = (req.body ?? {});
887
+ const site = body.siteId
888
+ ? opts.db.select().from(sites).where(eq(sites.id, body.siteId)).get()
889
+ : opts.db.select().from(sites).all()[0];
890
+ if (!site)
891
+ return reply.code(400).send({ error: "unknown_site" });
892
+ const overview = opts.db
893
+ .select()
894
+ .from(findings)
895
+ .where(eq(findings.siteId, site.id))
896
+ .all();
897
+ const score = parseScore(getSetting(opts.db, `score:${site.id}`));
898
+ const payload = {
899
+ origin: site.origin,
900
+ capturedAt: new Date().toISOString(),
901
+ score,
902
+ openFindings: overview.filter((f) => f.status === "open").length,
903
+ bySeverity: overview.reduce((acc, f) => {
904
+ if (f.status === "open")
905
+ acc[f.severity] = (acc[f.severity] ?? 0) + 1;
906
+ return acc;
907
+ }, {}),
908
+ };
909
+ const payloadJson = JSON.stringify(payload);
910
+ const hash = createHash("sha256").update(payloadJson).digest("hex");
911
+ const white = getSetting(opts.db, "whiteLabel") === "1";
912
+ const title = white
913
+ ? `${site.origin} SEO snapshot`
914
+ : `Agent Sean snapshot — ${site.origin}`;
915
+ const bodyHtml = `<html><body><h1>${title}</h1><pre>${payloadJson}</pre></body></html>`;
916
+ const id = randomUUID();
917
+ opts.db
918
+ .insert(reports)
919
+ .values({
920
+ id,
921
+ siteId: site.id,
922
+ createdAt: payload.capturedAt,
923
+ title,
924
+ bodyHtml,
925
+ payloadJson,
926
+ hash,
927
+ whiteLabel: white ? 1 : 0,
928
+ })
929
+ .run();
930
+ opts.bus.emit("reports");
931
+ return { ok: true, id, hash };
932
+ });
933
+ app.get("/api/reports/:id.pdf", (req, reply) => {
934
+ const id = req.params.id.replace(/\.pdf$/, "");
935
+ const row = opts.db.select().from(reports).where(eq(reports.id, id)).get();
936
+ if (!row)
937
+ return reply.code(404).send({ error: "unknown_report" });
938
+ const pdf = textToPdf(row.title, row.payloadJson);
939
+ reply.header("content-type", "application/pdf");
940
+ reply.header("content-disposition", `attachment; filename="sean-${id.slice(0, 8)}.pdf"`);
941
+ return reply.send(pdf);
942
+ });
943
+ app.get("/api/settings", async () => {
944
+ const llmProvider = getSetting(opts.db, "llmProvider") ?? "anthropic";
945
+ let llmConfigured = false;
946
+ if (opts.store) {
947
+ const secret = await opts.store.get(`llm:${llmProvider}`);
948
+ llmConfigured = Boolean(secret) || Boolean(process.env["OLLAMA_HOST"]);
949
+ }
950
+ return {
951
+ halted: isHalted(opts.seanHome),
952
+ budgetUsdDaily: getSettingNumber(opts.db, "budgetUsdDaily", 8),
953
+ observeDays: getSettingNumber(opts.db, "observeDays", 7),
954
+ whiteLabel: getSetting(opts.db, "whiteLabel") === "1",
955
+ rankCadence: getSetting(opts.db, "rankCadence") ?? "weekly",
956
+ notifications: getSetting(opts.db, "notifications") ?? "off",
957
+ llmProvider,
958
+ llmConfigured,
959
+ aiDisclosure: getSetting(opts.db, "aiDisclosure") ?? "html_comment",
960
+ providers: await loadProviderKeys(opts.store).then((k) => ({
961
+ dataforseo: Boolean(k.dataforseo),
962
+ bing: Boolean(k.bing),
963
+ openpagerank: Boolean(k.openpagerank),
964
+ })),
965
+ caps: {
966
+ newPagesPerDay: BLAST.newPagesPerDay,
967
+ contentRefreshPerDay: BLAST.contentRefreshPerDay,
968
+ overridable: false,
969
+ },
970
+ telemetry: {
971
+ enabled: isTelemetryEnabled(opts.seanHome),
972
+ consentedAt: loadTelemetryConfig(opts.seanHome).consentedAt,
973
+ events: TELEMETRY_EVENTS,
974
+ preview: previewPayload(),
975
+ },
976
+ };
977
+ });
978
+ app.post("/api/settings", (req, reply) => {
979
+ const body = (req.body ?? {});
980
+ if (body.newPagesPerDay !== undefined || body.contentRefreshPerDay !== undefined) {
981
+ return reply.code(400).send({
982
+ error: "rate_limit_locked",
983
+ detail: "new-page and content-refresh caps are not overridable",
984
+ });
985
+ }
986
+ if (typeof body.budgetUsdDaily === "number") {
987
+ setSetting(opts.db, "budgetUsdDaily", String(body.budgetUsdDaily));
988
+ }
989
+ if (typeof body.observeDays === "number") {
990
+ const days = Math.max(1, Math.min(7, Math.round(body.observeDays)));
991
+ setSetting(opts.db, "observeDays", String(days));
992
+ }
993
+ if (typeof body.whiteLabel === "boolean") {
994
+ setSetting(opts.db, "whiteLabel", body.whiteLabel ? "1" : "0");
995
+ }
996
+ if (typeof body.rankCadence === "string")
997
+ setSetting(opts.db, "rankCadence", body.rankCadence);
998
+ if (typeof body.notifications === "string")
999
+ setSetting(opts.db, "notifications", body.notifications);
1000
+ if (typeof body.llmProvider === "string")
1001
+ setSetting(opts.db, "llmProvider", body.llmProvider);
1002
+ if (typeof body.aiDisclosure === "string")
1003
+ setSetting(opts.db, "aiDisclosure", body.aiDisclosure);
1004
+ if (typeof body.telemetryEnabled === "boolean") {
1005
+ consentTelemetry(opts.seanHome, body.telemetryEnabled, "dashboard");
1006
+ }
1007
+ opts.bus.emit("settings");
1008
+ return { ok: true };
1009
+ });
1010
+ app.post("/api/settings/provider", async (req, reply) => {
1011
+ if (!opts.store)
1012
+ return reply.code(400).send({ error: "no_store" });
1013
+ const body = (req.body ?? {});
1014
+ const provider = body.provider ?? "";
1015
+ if (!body.apiKey || body.apiKey.length < 4) {
1016
+ return reply.code(400).send({ error: "missing_key" });
1017
+ }
1018
+ const account = provider === "dataforseo"
1019
+ ? PROVIDER_ACCOUNTS.dataforseo
1020
+ : provider === "bing"
1021
+ ? PROVIDER_ACCOUNTS.bing
1022
+ : provider === "openpagerank"
1023
+ ? PROVIDER_ACCOUNTS.openpagerank
1024
+ : null;
1025
+ if (!account)
1026
+ return reply.code(400).send({ error: "unknown_provider" });
1027
+ await opts.store.set(account, new Secret(body.apiKey));
1028
+ opts.bus.emit("settings");
1029
+ return { ok: true, provider, configured: true };
1030
+ });
1031
+ app.post("/api/settings/llm", async (req, reply) => {
1032
+ if (!opts.store)
1033
+ return reply.code(400).send({ error: "no_store" });
1034
+ const body = (req.body ?? {});
1035
+ const provider = body.provider ?? getSetting(opts.db, "llmProvider") ?? "anthropic";
1036
+ if (!body.apiKey || body.apiKey.length < 8) {
1037
+ return reply.code(400).send({ error: "missing_key" });
1038
+ }
1039
+ await opts.store.set(`llm:${provider}`, new Secret(body.apiKey));
1040
+ setSetting(opts.db, "llmProvider", provider);
1041
+ opts.bus.emit("settings");
1042
+ return { ok: true, provider, configured: true };
1043
+ });
1044
+ app.post("/api/freeze", (req) => {
1045
+ const body = (req.body ?? {});
1046
+ const halted = Boolean(body.halted);
1047
+ setHalted(opts.seanHome, halted);
1048
+ opts.bus.emit("settings");
1049
+ opts.bus.emit("overview");
1050
+ return { ok: true, halted: isHalted(opts.seanHome) };
1051
+ });
1052
+ app.get("/api/jobs", (req) => {
1053
+ const q = req.query;
1054
+ return queue
1055
+ .list(q.siteId ? { siteId: q.siteId } : undefined)
1056
+ .then((jobs) => ({ jobs }));
1057
+ });
1058
+ }
1059
+ //# sourceMappingURL=dashboard-routes.js.map