@canonry/canonry 5.18.0 → 5.19.1

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 (34) hide show
  1. package/assets/agent-workspace/skills/aero/SKILL.md +13 -8
  2. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +27 -10
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +4 -1
  4. package/assets/assets/{AuditHistoryPanel-Di5c6xSa.js → AuditHistoryPanel-ddq5tdlB.js} +1 -1
  5. package/assets/assets/{BacklinksPage-CZi3sZ-7.js → BacklinksPage-g57_xzTX.js} +1 -1
  6. package/assets/assets/{HistoryPage-BY7HiZWU.js → HistoryPage-C-TdCUQ2.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-D3AdBHpI.js → MeasurementPropertyPage-CAADmNfw.js} +1 -1
  8. package/assets/assets/{ProjectPage-NyUl1dkF.js → ProjectPage-CoBwpHmA.js} +1 -1
  9. package/assets/assets/{RunRow-cRjiHQbt.js → RunRow-BpG0Zy-x.js} +1 -1
  10. package/assets/assets/{RunsPage-DZP_kqJs.js → RunsPage-DK3pb1LH.js} +1 -1
  11. package/assets/assets/{SettingsPage-BqJXYCDc.js → SettingsPage-BruiY8Zh.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-Brr0C56X.js → SiteHealthSection-De4U11Gv.js} +3 -3
  13. package/assets/assets/{TrafficPage-k5NPSDur.js → TrafficPage-BVolLXT2.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-BKNzWnxA.js → TrafficSourceDetailPage-CFDb6lPD.js} +1 -1
  15. package/assets/assets/{extract-error-message-Cnvelll0.js → extract-error-message-Bk1RTjzo.js} +1 -1
  16. package/assets/assets/index-B--AA1mG.js +86 -0
  17. package/assets/assets/{react-sigma_core.esm.min-CthGjQKl.js → react-sigma_core.esm.min--QXVK1JS.js} +1 -1
  18. package/assets/assets/{v2-overview-adapter-q2o2MzmG.js → v2-overview-adapter-CpjJPZsC.js} +1 -1
  19. package/assets/index.html +1 -1
  20. package/dist/{chunk-Y5IDGH5B.js → chunk-3IAIW5KH.js} +534 -136
  21. package/dist/chunk-4WROKFN6.js +3991 -0
  22. package/dist/{chunk-TMTO2JHU.js → chunk-EJXDABGU.js} +21 -3970
  23. package/dist/{chunk-HIFBIDOW.js → chunk-EJZFTNZG.js} +444 -185
  24. package/dist/{chunk-X3GFCKUJ.js → chunk-J3JXJIST.js} +3 -3
  25. package/dist/{chunk-HAABFHXB.js → chunk-PGOID3LE.js} +2 -2
  26. package/dist/{chunk-77VHT3JD.js → chunk-SOCYTKTF.js} +223 -23
  27. package/dist/cli.js +84 -27
  28. package/dist/{demo-server-JBGYZKAI.js → demo-server-BLDYKCFS.js} +1071 -187
  29. package/dist/index.js +6 -6
  30. package/dist/{intelligence-service-EORTVFDF.js → intelligence-service-3MHYLKMS.js} +2 -2
  31. package/dist/mcp.js +4 -4
  32. package/package.json +9 -9
  33. package/assets/assets/index-Cd_1GknE.js +0 -86
  34. package/dist/chunk-AOJU5LKX.js +0 -8
@@ -4,10 +4,11 @@ import {
4
4
  import {
5
5
  layoutSiteCrawlGraphInput,
6
6
  persistSiteCrawlGraphLayout
7
- } from "./chunk-HAABFHXB.js";
7
+ } from "./chunk-PGOID3LE.js";
8
8
  import {
9
- PACKAGE_VERSION
10
- } from "./chunk-AOJU5LKX.js";
9
+ PACKAGE_VERSION,
10
+ canonryMcpTools
11
+ } from "./chunk-4WROKFN6.js";
11
12
  import {
12
13
  HISTORY_WINDOW_RUNS,
13
14
  IntelligenceService,
@@ -83,12 +84,13 @@ import {
83
84
  siteCrawlPages,
84
85
  siteCrawlSnapshots,
85
86
  trafficSources
86
- } from "./chunk-Y5IDGH5B.js";
87
+ } from "./chunk-3IAIW5KH.js";
87
88
  import {
88
89
  RunKinds,
89
90
  SiteCrawlFetchStates,
90
91
  SiteCrawlIndexabilityReasons,
91
92
  SiteCrawlIndexabilityStates,
93
+ aeroPreviewResponseSchema,
92
94
  buildMeasurementExecutionIdentity,
93
95
  buildMeasurementRunManifestV1,
94
96
  buildSimpleMeasurementDefinition,
@@ -102,19 +104,1008 @@ import {
102
104
  factorStatusFromScore,
103
105
  measurementPlanV2ChecksumJson,
104
106
  measurementPlanV2Schema,
107
+ parseStoredMeasurementPlanAnyVersion,
105
108
  placementLinkDecision,
106
- siteAuditPageFactorSchema
107
- } from "./chunk-77VHT3JD.js";
109
+ siteAuditPageFactorSchema,
110
+ wilsonInterval
111
+ } from "./chunk-SOCYTKTF.js";
108
112
 
109
113
  // src/demo-server.ts
110
114
  import { dirname, join as join2 } from "path";
111
115
  import { fileURLToPath } from "url";
112
116
 
117
+ // src/demo/aero-preview.ts
118
+ import { and, asc, desc, eq } from "drizzle-orm";
119
+
120
+ // src/demo/summit-sweeps.ts
121
+ var SUMMIT_SWEEP_COUNT = 6;
122
+ var SUMMIT_ENGINES = ["openai", "gemini", "claude"];
123
+ var RIVALS = {
124
+ roofcraft: { name: "RoofCraft", domain: "roofcraft.example", path: "/roof-replacement/", blurb: "A replacement contractor known for metal and shingle installs." },
125
+ everlast: { name: "Everlast Roofing", domain: "everlast-roofing.example", path: "/services/", blurb: "A repair and gutter company with a large emergency crew." }
126
+ };
127
+ var SUMMIT_COMPETITORS = Object.values(RIVALS).map((rival) => ({ domain: rival.domain, label: rival.name }));
128
+ var SUMMIT_QUERIES = [
129
+ { key: "reviews", text: "Summit Roofing reviews", pages: ["/reviews/"], rivals: [], source: "https://home-services-reviews.example/roofers/", lead: "Recent homeowner reviews are mostly positive.", blurb: "Reviewers mention clear estimates, tidy crews and quick follow-up on warranty visits." },
130
+ { key: "near-me", text: "roof repair contractor near me", pages: ["/services/roof-repair/", "/service-areas/"], rivals: ["everlast", "roofcraft"], source: "https://local-contractor-directory.example/roofing/", lead: "Several roofers in the area handle repair work.", blurb: "A local roofer covering leak repairs, storm damage and inspections across two dozen towns." },
131
+ { key: "emergency-leak", text: "emergency roof leak repair", pages: ["/services/roof-repair/leak-repair/", "/services/roof-repair/emergency-tarping/"], rivals: ["everlast"], source: "https://homeowner-guides.example/roof-leaks/", lead: "For an active leak, look for a crew that can tarp the roof the same day.", blurb: "Offers emergency tarping and leak repair, with same-day visits in its service area." },
132
+ { key: "metal-installer", text: "best metal roof installer", pages: ["/services/roof-replacement/metal-roofing/", "/guides/metal-vs-asphalt-shingles/"], rivals: ["roofcraft"], source: "https://metal-roofing-council.example/find-an-installer/", lead: "Metal roofs need an installer with standing-seam experience.", blurb: "Installs standing-seam and metal shingle roofs and publishes a metal versus asphalt comparison." },
133
+ { key: "warranty", text: "Summit Roofing warranty", pages: ["/about/warranty/"], rivals: [], source: "https://homeowner-guides.example/roof-warranties/", lead: "Roofing warranties usually combine manufacturer and workmanship coverage.", blurb: "Backs replacements with a written workmanship warranty on top of the manufacturer coverage." },
134
+ { key: "replacement-cost", text: "how much does a roof replacement cost", pages: ["/guides/how-much-does-a-roof-replacement-cost/", "/financing/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/roof-replacement/", lead: "Most full replacements land between 9,000 and 18,000 dollars, depending on size and material.", blurb: "Publishes a cost guide with typical price ranges and offers financing on full replacements." },
135
+ { key: "storm-claims", text: "storm damage roof repair and insurance claims", pages: ["/services/roof-repair/storm-damage/", "/guides/filing-a-roof-insurance-claim/"], rivals: ["everlast"], source: "https://homeowner-guides.example/storm-claims/", lead: "Photograph the damage before any repair and call your insurer early.", blurb: "Documents storm damage for insurance claims and joins the adjuster visit." },
136
+ { key: "asphalt-shingles", text: "asphalt shingle roof replacement", pages: ["/services/roof-replacement/asphalt-shingles/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/asphalt-shingles/", lead: "Architectural shingles are the most common replacement choice.", blurb: "Replaces asphalt shingle roofs with a choice of architectural shingle lines." },
137
+ { key: "seamless-gutters", text: "seamless gutter installation", pages: ["/services/gutters/seamless-gutters/"], rivals: ["everlast"], source: "https://homeowner-guides.example/gutters/", lead: "Seamless gutters are formed on site, so they leak less at the joints.", blurb: "Installs seamless gutters and gutter guards, often alongside a roof replacement." },
138
+ { key: "pre-purchase-inspection", text: "roof inspection before buying a house", pages: ["/services/roof-inspection/real-estate-inspection/", "/guides/real-estate-roof-inspections/"], rivals: ["roofcraft", "everlast"], source: "https://home-buyer-checklist.example/roof-inspection/", lead: "A roof inspection before closing can surface repairs to negotiate.", blurb: "Runs pre-purchase roof inspections with photo reports for buyers and agents." }
139
+ ];
140
+ var SUMMIT_BRANDED_QUERY_KEYS = ["reviews", "warranty"];
141
+ var SUMMIT_ENGINE_PLANS = {
142
+ // Climbs steadily.
143
+ openai: {
144
+ mentioned: [2, 3, 3, 4, 5, 6],
145
+ mentionOrder: ["emergency-leak", "replacement-cost", "near-me", "storm-claims", "pre-purchase-inspection", "seamless-gutters", "asphalt-shingles", "metal-installer"],
146
+ cited: [1, 2, 2, 3, 4, 5],
147
+ citationOrder: ["replacement-cost", "storm-claims", "pre-purchase-inspection", "emergency-leak", "asphalt-shingles", "metal-installer", "seamless-gutters", "near-me"]
148
+ },
149
+ // Steady, then a dip and a recovery.
150
+ gemini: {
151
+ mentioned: [4, 4, 4, 2, 3, 5],
152
+ mentionOrder: ["near-me", "emergency-leak", "replacement-cost", "storm-claims", "seamless-gutters", "pre-purchase-inspection", "metal-installer", "asphalt-shingles"],
153
+ cited: [3, 3, 3, 1, 2, 3],
154
+ citationOrder: ["replacement-cost", "pre-purchase-inspection", "storm-claims", "metal-installer", "emergency-leak", "asphalt-shingles", "seamless-gutters", "near-me"]
155
+ },
156
+ // Swings from week to week.
157
+ claude: {
158
+ mentioned: [3, 1, 4, 2, 4, 3],
159
+ mentionOrder: ["emergency-leak", "replacement-cost", "storm-claims", "near-me", "pre-purchase-inspection", "asphalt-shingles", "seamless-gutters", "metal-installer"],
160
+ cited: [2, 1, 2, 1, 3, 2],
161
+ citationOrder: ["pre-purchase-inspection", "replacement-cost", "storm-claims", "emergency-leak", "asphalt-shingles", "near-me", "metal-installer", "seamless-gutters"]
162
+ }
163
+ };
164
+ var BRANDED_PLAN = {
165
+ reviews: {
166
+ openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
167
+ gemini: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
168
+ claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, false, false, false, false] }
169
+ },
170
+ warranty: {
171
+ openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
172
+ gemini: { mentioned: [true, true, true, false, true, true], cited: [true, true, true, false, true, true] },
173
+ claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, true, false, true, true] }
174
+ }
175
+ };
176
+ function isBranded(key) {
177
+ return SUMMIT_BRANDED_QUERY_KEYS.includes(key);
178
+ }
179
+ function summitAnswerPlan(sweep, engine, queryKey) {
180
+ if (isBranded(queryKey)) {
181
+ const plan2 = BRANDED_PLAN[queryKey][engine];
182
+ return { mentioned: plan2.mentioned[sweep], cited: plan2.cited[sweep] };
183
+ }
184
+ const plan = SUMMIT_ENGINE_PLANS[engine];
185
+ return {
186
+ mentioned: plan.mentionOrder.slice(0, plan.mentioned[sweep]).includes(queryKey),
187
+ cited: plan.citationOrder.slice(0, plan.cited[sweep]).includes(queryKey)
188
+ };
189
+ }
190
+ function summitAnswer(input) {
191
+ const { sweep, engine, queryIndex, displayName, domain } = input;
192
+ const query = SUMMIT_QUERIES[queryIndex];
193
+ const engineIndex = SUMMIT_ENGINES.indexOf(engine);
194
+ const plan = summitAnswerPlan(sweep, engine, query.key);
195
+ const rivals = plan.mentioned ? (sweep + engineIndex + queryIndex) % 2 === 0 ? query.rivals.slice(0, 1) : [] : query.rivals;
196
+ const entries = [
197
+ ...plan.mentioned ? [{ name: displayName, blurb: query.blurb }] : [],
198
+ ...rivals.map((key) => ({ name: RIVALS[key].name, blurb: RIVALS[key].blurb }))
199
+ ];
200
+ const answerText = isBranded(query.key) && !plan.mentioned ? `Fictional answer.
201
+
202
+ I could not find published details for that company. ${query.lead} Most residential roofers offer 5 to 10 years of workmanship coverage.` : [
203
+ "Fictional answer.",
204
+ query.lead,
205
+ entries.map((entry, index) => `${index + 1}. **${entry.name}**: ${entry.blurb}`).join("\n"),
206
+ "Get two or three written estimates before you commit."
207
+ ].join("\n\n");
208
+ const citedRivals = rivals.filter((_, index) => (sweep + engineIndex + queryIndex + index) % 3 !== 0);
209
+ const citedUrls = [
210
+ ...plan.cited ? [`https://${domain}${query.pages[(sweep + engineIndex) % query.pages.length]}`] : [],
211
+ ...citedRivals.map((key) => `https://${RIVALS[key].domain}${RIVALS[key].path}`),
212
+ query.source
213
+ ];
214
+ const citedDomains = citedUrls.map((url) => new URL(url).hostname);
215
+ const normalized = {
216
+ provider: engine,
217
+ answerText,
218
+ citedDomains,
219
+ groundingSources: citedUrls.map((uri) => ({ uri, title: "" })),
220
+ searchQueries: [query.text],
221
+ retrievalStatus: "used"
222
+ };
223
+ const domains = effectiveDomains({ canonicalDomain: domain, ownedDomains: [domain] });
224
+ const brandNames = effectiveBrandNames({ displayName, canonicalDomain: domain, ownedDomains: [domain] });
225
+ const competitorDomains = SUMMIT_COMPETITORS.map((competitor) => competitor.domain);
226
+ return {
227
+ answerText,
228
+ answerMentioned: determineAnswerMentioned(answerText, brandNames, domains),
229
+ citationState: determineCitationState(normalized, domains),
230
+ citedDomains,
231
+ citedUrls,
232
+ competitorOverlap: computeCompetitorOverlap(normalized, competitorDomains),
233
+ recommendedCompetitors: extractRecommendedCompetitors(answerText, domains, citedDomains, competitorDomains, brandNames)
234
+ };
235
+ }
236
+
237
+ // src/demo/aero-preview.ts
238
+ var ENGINE_ORDER = ["openai", "gemini", "claude"];
239
+ var ENGINE_NAMES = { openai: "OpenAI", gemini: "Gemini", claude: "Claude" };
240
+ var SUMMIT_TOPICS = {
241
+ "reviews": "Reviews",
242
+ "warranty": "Warranty",
243
+ "near-me": "Local repair",
244
+ "emergency-leak": "Emergency repair",
245
+ "metal-installer": "Metal roofing",
246
+ "replacement-cost": "Replacement cost",
247
+ "storm-claims": "Storm damage",
248
+ "asphalt-shingles": "Shingle replacement",
249
+ "seamless-gutters": "Gutters",
250
+ "pre-purchase-inspection": "Inspections"
251
+ };
252
+ var DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
253
+ function formatPreviewDay(iso) {
254
+ return DAY.format(new Date(iso));
255
+ }
256
+ var engineName = (provider) => ENGINE_NAMES[provider] ?? provider;
257
+ var quoted = (text) => `"${text}"`;
258
+ var ratio = (numerator, denominator) => `${numerator}/${denominator}`;
259
+ var percent = (numerator, denominator) => denominator === 0 ? 0 : Math.round(numerator / denominator * 100);
260
+ var unique = (values) => [...new Set(values)];
261
+ function listJoin(items, conjunction = "and") {
262
+ if (items.length <= 1) return items[0] ?? "";
263
+ return `${items.slice(0, -1).join(", ")} ${conjunction} ${items.at(-1)}`;
264
+ }
265
+ function engineOrder(left, right) {
266
+ const rank = (provider) => {
267
+ const index = ENGINE_ORDER.indexOf(provider);
268
+ return index === -1 ? ENGINE_ORDER.length : index;
269
+ };
270
+ return rank(left) - rank(right) || (left < right ? -1 : left > right ? 1 : 0);
271
+ }
272
+ function tally(answers) {
273
+ return {
274
+ mentioned: answers.filter((answer) => answer.mentioned).length,
275
+ cited: answers.filter((answer) => answer.cited).length,
276
+ total: answers.length
277
+ };
278
+ }
279
+ function withinNoise(before, after) {
280
+ const a = wilsonInterval(before.n, before.d);
281
+ const b = wilsonInterval(after.n, after.d);
282
+ if (!a || !b) return true;
283
+ return a.low <= b.high && b.low <= a.high;
284
+ }
285
+ function noisePhrase(within) {
286
+ return within ? "Within normal run-to-run noise." : "Bigger than normal run-to-run noise.";
287
+ }
288
+ function movement(current, previous, denominator) {
289
+ if (current === previous) return `${ratio(current, denominator)}, unchanged`;
290
+ return `${ratio(current, denominator)}, ${current > previous ? "up" : "down"} from ${ratio(previous, denominator)}`;
291
+ }
292
+ function step(name, args, result, durationMs, text) {
293
+ const tool = canonryMcpTools.find((candidate) => candidate.name === name);
294
+ if (!tool) throw new Error(`Aero preview names an unregistered tool: ${name}`);
295
+ const parsed = tool.inputSchema.safeParse(args);
296
+ if (!parsed.success) throw new Error(`Aero preview passes invalid arguments to ${name}: ${parsed.error.message}`);
297
+ const unknownKeys = Object.keys(args).filter((key) => !(key in parsed.data));
298
+ if (unknownKeys.length > 0) throw new Error(`Aero preview passes unknown arguments to ${name}: ${unknownKeys.join(", ")}`);
299
+ return { ...text ? { text } : {}, tool: { name, label: tool.title, arguments: args, result, durationMs } };
300
+ }
301
+ function recommendationClause(reason) {
302
+ if (!reason) return "";
303
+ const head = reason.split(/\s+[\u2014\u2013]\s+/)[0].trim();
304
+ const sentences = head.split(/(?<=\.)\s+/).filter(Boolean);
305
+ const last = (sentences.at(-1) ?? head).replace(/\.$/, "");
306
+ return last.charAt(0).toLowerCase() + last.slice(1);
307
+ }
308
+ function storedRecommendation(reasons) {
309
+ const clauses = reasons.map(recommendationClause).filter(Boolean);
310
+ if (clauses.length === 0) return "";
311
+ return `Stored recommendation${clauses.length === 1 ? "" : "s"}: ${clauses.join(", and ")}.`;
312
+ }
313
+ function loadFacts(db, project) {
314
+ const rows = db.select().from(runs).where(and(eq(runs.projectId, project.id), eq(runs.kind, "answer-visibility"), eq(runs.status, "completed"))).orderBy(asc(runs.createdAt), asc(runs.id)).all().filter((run) => run.trigger !== "probe");
315
+ const competitors2 = /* @__PURE__ */ new Map();
316
+ const properties = /* @__PURE__ */ new Map();
317
+ const sweeps = rows.map((run) => {
318
+ const classes = /* @__PURE__ */ new Map();
319
+ let planRevision = null;
320
+ if (run.measurementPlanVersionId) {
321
+ const version = db.select().from(measurementPlanVersions).where(eq(measurementPlanVersions.id, run.measurementPlanVersionId)).get();
322
+ if (!version) throw new Error(`Demo sweep ${run.id} names a missing plan revision`);
323
+ const plan = parseStoredMeasurementPlanAnyVersion(version.canonicalJson);
324
+ if (plan.schemaVersion !== 2) throw new Error(`Demo sweep ${run.id} is not measured by a portfolio plan`);
325
+ planRevision = version.revision;
326
+ for (const assignment of plan.assignments) classes.set(assignment.queryId, { queryClass: assignment.queryClass, targetKey: assignment.targetKey });
327
+ for (const group of plan.groups) {
328
+ const market = group.label.replace(/\s+market$/i, "");
329
+ for (const competitor of group.competitors) {
330
+ if (competitor.domain) competitors2.set(competitor.domain, { label: competitor.label, domain: competitor.domain });
331
+ }
332
+ for (const targetKey of group.targetKeys) {
333
+ const target = plan.targets.find((candidate) => candidate.stableKey === targetKey);
334
+ if (target) {
335
+ const pathPrefixes = target.urlMatchers.flatMap((matcher) => matcher.kind === "prefix" ? [matcher.pathPrefix] : []);
336
+ properties.set(targetKey, { key: targetKey, label: target.label, market, pathPrefixes });
337
+ }
338
+ }
339
+ }
340
+ } else {
341
+ const definition = db.select().from(simpleMeasurementDefinitions).where(eq(simpleMeasurementDefinitions.runId, run.id)).get();
342
+ if (!definition) throw new Error(`Demo sweep ${run.id} has no frozen measurement definition`);
343
+ for (const query of definition.definition.queries) {
344
+ classes.set(query.queryId, { queryClass: query.queryClass === "branded" || query.queryClass === "non-brand" ? query.queryClass : null, targetKey: null });
345
+ }
346
+ for (const competitor of definition.definition.competitors ?? []) competitors2.set(competitor.domain, { label: competitor.label, domain: competitor.domain });
347
+ }
348
+ const answers = db.select().from(querySnapshots).where(eq(querySnapshots.runId, run.id)).all().map((row) => {
349
+ const assigned = row.queryId ? classes.get(row.queryId) : void 0;
350
+ const citedUrls = row.citedUrls ?? [];
351
+ return {
352
+ queryId: row.queryId ?? row.queryText ?? "",
353
+ query: row.queryText ?? "",
354
+ provider: row.provider,
355
+ queryClass: assigned?.queryClass ?? null,
356
+ targetKey: assigned?.targetKey ?? null,
357
+ mentioned: row.answerMentioned === true,
358
+ cited: row.citationState === "cited",
359
+ named: [...competitors2.values()].filter((competitor) => (row.answerText ?? "").includes(competitor.label)).map((competitor) => competitor.label),
360
+ competitorCited: [...competitors2.values()].filter((competitor) => row.citedDomains.some((domain) => domain === competitor.domain || domain.endsWith(`.${competitor.domain}`))).map((competitor) => competitor.label),
361
+ citedDomains: row.citedDomains,
362
+ ownCitedPaths: citedUrls.flatMap((url) => {
363
+ const parsed = new URL(url);
364
+ return parsed.hostname === project.domain ? [parsed.pathname] : [];
365
+ })
366
+ };
367
+ });
368
+ const manifest = run.measurementManifest;
369
+ return {
370
+ id: run.id,
371
+ createdAt: run.createdAt,
372
+ planRevision,
373
+ executionChecksum: run.measurementExecutionIdentity?.checksum ?? null,
374
+ expectedAnswers: Array.isArray(manifest?.expectedSlots) ? manifest.expectedSlots.length : null,
375
+ answers
376
+ };
377
+ });
378
+ if (sweeps.length < 2) throw new Error(`Demo project ${project.name} needs two completed sweeps for the Aero preview`);
379
+ return { project, sweeps, latest: sweeps.at(-1), previous: sweeps.at(-2), competitors: [...competitors2.values()], properties };
380
+ }
381
+ function enginesOf(sweep) {
382
+ return unique(sweep.answers.map((answer) => answer.provider)).sort(engineOrder);
383
+ }
384
+ function ofClass(sweep, queryClass, provider) {
385
+ return sweep.answers.filter((answer) => answer.queryClass === queryClass && (provider === void 0 || answer.provider === provider));
386
+ }
387
+ function queryIdsOf(answers) {
388
+ return unique(answers.map((answer) => answer.queryId));
389
+ }
390
+ function byQuery(answers) {
391
+ const grouped = /* @__PURE__ */ new Map();
392
+ for (const answer of answers) grouped.set(answer.queryId, [...grouped.get(answer.queryId) ?? [], answer]);
393
+ return grouped;
394
+ }
395
+ function series(facts, queryClass, key, provider) {
396
+ return facts.sweeps.map((sweep) => tally(ofClass(sweep, queryClass, provider))[key]);
397
+ }
398
+ function summary(answers) {
399
+ const counts = tally(answers);
400
+ return {
401
+ queryCount: queryIdsOf(answers).length,
402
+ answerCount: counts.total,
403
+ mentionCoverage: { numerator: counts.mentioned, denominator: counts.total },
404
+ citationCoverage: { numerator: counts.cited, denominator: counts.total }
405
+ };
406
+ }
407
+ function providerRollup(sweep, queryClass) {
408
+ return Object.fromEntries(enginesOf(sweep).map((provider) => {
409
+ const counts = tally(ofClass(sweep, queryClass, provider));
410
+ return [provider, { mentioned: counts.mentioned, cited: counts.cited, answers: counts.total }];
411
+ }));
412
+ }
413
+ function namedInstead(answers) {
414
+ const missing = answers.filter((answer) => !answer.mentioned);
415
+ return unique(missing.flatMap((answer) => answer.named)).map((label) => ({
416
+ label,
417
+ engines: unique(missing.filter((answer) => answer.named.includes(label)).map((answer) => answer.provider)).sort(engineOrder)
418
+ }));
419
+ }
420
+ function link(label, path) {
421
+ return `[${label}](${path})`;
422
+ }
423
+ function summitTopic(query) {
424
+ const key = SUMMIT_QUERIES.find((candidate) => candidate.text === query)?.key;
425
+ return key ? SUMMIT_TOPICS[key] ?? null : null;
426
+ }
427
+ function summitStatus(facts) {
428
+ const { project, latest, sweeps } = facts;
429
+ const engines = enginesOf(latest);
430
+ const queryCount = queryIdsOf(latest.answers).length;
431
+ const branded = ofClass(latest, "branded");
432
+ const nonBrand = ofClass(latest, "non-brand");
433
+ const brandedCounts = tally(branded);
434
+ const nonBrandCounts = tally(nonBrand);
435
+ const nonBrandQueries = queryIdsOf(nonBrand).length;
436
+ const perEngine = engines.map((provider) => ({ provider, ...tally(ofClass(latest, "non-brand", provider)) }));
437
+ const unnamed = [...byQuery(nonBrand).values()].filter((answers) => answers.every((answer2) => !answer2.mentioned));
438
+ const unnamedRivals = unique(unnamed.flatMap((answers) => namedInstead(answers).map((entry) => entry.label)));
439
+ const allAnswered = engines.every((provider) => latest.answers.filter((answer2) => answer2.provider === provider).length === queryCount);
440
+ const weakest = [...perEngine].sort((left, right) => left.mentioned - right.mentioned);
441
+ const uniqueWeakest = weakest.length > 1 && weakest[0].mentioned < weakest[1].mentioned ? weakest[0] : null;
442
+ const brandedMisses = branded.filter((answer2) => !answer2.cited);
443
+ const brandedUnnamed = branded.filter((answer2) => !answer2.mentioned);
444
+ const attention = [];
445
+ if (unnamed.length > 0) {
446
+ const queries2 = listJoin(unnamed.map((answers) => quoted(answers[0].query)), "or");
447
+ const rivals = unnamedRivals.length > 0 ? ` ${listJoin(unnamedRivals)} ${unnamedRivals.length === 1 ? "is" : "are"} named ${unnamed.length === 1 ? "there" : `on ${unnamed.length === 2 ? "both" : "all of them"}`}.` : "";
448
+ attention.push(`- No engine names ${project.displayName} on ${queries2}.${rivals}`);
449
+ }
450
+ if (uniqueWeakest) attention.push(`- ${engineName(uniqueWeakest.provider)} is the weakest engine on non-brand, naming you on ${uniqueWeakest.mentioned} of ${uniqueWeakest.total}.`);
451
+ if (brandedUnnamed.length === 0 && brandedMisses.length > 0) {
452
+ attention.push(`- Branded mentions are complete. The ${brandedMisses.length === 1 ? "one branded gap is a citation" : "branded gaps are citations"}: ${listJoin(brandedMisses.map((answer2) => `${engineName(answer2.provider)} does not cite your site on ${quoted(answer2.query)}`))}.`);
453
+ }
454
+ const answer = [
455
+ `**Latest sweep: ${formatPreviewDay(latest.createdAt)}, completed.** ${listJoin(engines.map(engineName))} ${allAnswered ? `all answered all ${queryCount} queries` : "answered"}, ${latest.answers.length} answers in total. That makes ${sweeps.length} sweeps so far; the first ran on ${formatPreviewDay(sweeps[0].createdAt)}.`,
456
+ [
457
+ "| Queries | Mentioned | Cited |",
458
+ "|---|---|---|",
459
+ `| Branded (${queryIdsOf(branded).length}) | ${ratio(brandedCounts.mentioned, brandedCounts.total)} | ${ratio(brandedCounts.cited, brandedCounts.total)} |`,
460
+ `| Non-brand (${nonBrandQueries}) | ${ratio(nonBrandCounts.mentioned, nonBrandCounts.total)} (${percent(nonBrandCounts.mentioned, nonBrandCounts.total)}%) | ${ratio(nonBrandCounts.cited, nonBrandCounts.total)} (${percent(nonBrandCounts.cited, nonBrandCounts.total)}%) |`
461
+ ].join("\n"),
462
+ `Non-brand by engine, mentioned then cited: ${perEngine.map((entry) => `${engineName(entry.provider)} ${ratio(entry.mentioned, entry.total)} and ${ratio(entry.cited, entry.total)}`).join(", ")}.`,
463
+ ...attention.length > 0 ? [`**Needs attention**
464
+ ${attention.join("\n")}`] : [],
465
+ unnamed.length > 0 ? `Next: open ${unnamed.length === 1 ? "that query" : `those ${unnamed.length} queries`} and read the answers that name ${listJoin(unnamedRivals)}; that is the content your pages need to match. ${link("Open queries", `/projects/${project.name}/queries`)}` : `Next: keep the weekly sweep running and watch the weakest engine. ${link("Open queries", `/projects/${project.name}/queries`)}`
466
+ ].join("\n\n");
467
+ return {
468
+ id: "status",
469
+ steps: [
470
+ step("canonry_project_overview", { project: project.name }, {
471
+ latestRun: { run: { id: latest.id, kind: "answer-visibility", status: "completed", createdAt: latest.createdAt } },
472
+ health: {
473
+ runId: latest.id,
474
+ totalPairs: latest.answers.length,
475
+ mentionedPairs: tally(latest.answers).mentioned,
476
+ citedPairs: tally(latest.answers).cited,
477
+ providerBreakdown: Object.fromEntries(engines.map((provider) => {
478
+ const counts = tally(latest.answers.filter((item) => item.provider === provider));
479
+ return [provider, { mentioned: counts.mentioned, cited: counts.cited, total: counts.total }];
480
+ }))
481
+ }
482
+ }, 820, "Checking the latest sweep and which engines answered."),
483
+ step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
484
+ selection: { queryClass: "all", run: { id: latest.id } },
485
+ populations: [
486
+ { queryClass: "branded", summary: summary(branded) },
487
+ {
488
+ queryClass: "non-brand",
489
+ summary: summary(nonBrand),
490
+ providerRollup: providerRollup(latest, "non-brand"),
491
+ unnamedQueries: unnamed.map((answers) => ({ query: answers[0].query, namedInstead: namedInstead(answers).map((entry) => entry.label) }))
492
+ }
493
+ ]
494
+ }, 1140, "The overview pools branded and non-brand, so reading the classes separately.")
495
+ ],
496
+ answer
497
+ };
498
+ }
499
+ function summitChanges(facts) {
500
+ const { project, latest, previous, sweeps } = facts;
501
+ const sameQueries = queryIdsOf(latest.answers).sort().join("\n") === queryIdsOf(previous.answers).sort().join("\n");
502
+ const lines = [];
503
+ const reading = (queryClass, key) => {
504
+ const now = tally(ofClass(latest, queryClass));
505
+ const before2 = tally(ofClass(previous, queryClass));
506
+ return { now: now[key], before: before2[key], total: now.total, beforeTotal: before2.total };
507
+ };
508
+ const verdicts = [];
509
+ for (const [key, heading] of [["mentioned", "Mentions"], ["cited", "Citations"]]) {
510
+ const rows = [];
511
+ for (const [queryClass, label] of [["non-brand", "Non-brand"], ["branded", "Branded"]]) {
512
+ const value = reading(queryClass, key);
513
+ if (value.total === 0) continue;
514
+ let line = `- ${label}: ${movement(value.now, value.before, value.total)}.`;
515
+ if (value.now !== value.before) {
516
+ const within = withinNoise({ n: value.before, d: value.beforeTotal }, { n: value.now, d: value.total });
517
+ verdicts.push(within);
518
+ line += ` ${noisePhrase(within)}`;
519
+ const history = series(facts, queryClass, key);
520
+ if (key === "mentioned" && value.now === Math.max(...history) && history.slice(0, -1).every((item) => item < value.now)) {
521
+ line += ` It is the highest of the ${sweeps.length} sweeps (${history.join(", ")}).`;
522
+ }
523
+ }
524
+ rows.push(line);
525
+ }
526
+ lines.push(`**${heading}**
527
+ ${rows.join("\n")}`);
528
+ }
529
+ const queryState = (sweep) => new Map([...byQuery(sweep.answers)].map(([queryId, answers]) => [queryId, {
530
+ query: answers[0].query,
531
+ mentioned: answers.some((answer2) => answer2.mentioned),
532
+ cited: answers.some((answer2) => answer2.cited)
533
+ }]));
534
+ const before = queryState(previous);
535
+ const after = queryState(latest);
536
+ const diff = [...after].flatMap(([queryId, state]) => {
537
+ const prior = before.get(queryId);
538
+ if (!prior || prior.mentioned === state.mentioned && prior.cited === state.cited) return [];
539
+ return [{
540
+ query: state.query,
541
+ run1MentionState: prior.mentioned ? "mentioned" : "not-mentioned",
542
+ run2MentionState: state.mentioned ? "mentioned" : "not-mentioned",
543
+ run1State: prior.cited ? "cited" : "not-cited",
544
+ run2State: state.cited ? "cited" : "not-cited"
545
+ }];
546
+ });
547
+ const gainedMention = diff.filter((row) => row.run2MentionState === "mentioned" && row.run1MentionState !== "mentioned");
548
+ const gainedCitation = diff.filter((row) => row.run2State === "cited" && row.run1State !== "cited");
549
+ const lost = diff.filter((row) => row.run1MentionState === "mentioned" && row.run2MentionState !== "mentioned" || row.run1State === "cited" && row.run2State !== "cited");
550
+ const citationFirsts = gainedCitation.map((row) => {
551
+ const everCited = sweeps.slice(0, -1).some((sweep) => sweep.answers.some((answer2) => answer2.query === row.query && answer2.cited));
552
+ const citedBy = latest.answers.filter((answer2) => answer2.query === row.query && answer2.cited).map((answer2) => engineName(answer2.provider));
553
+ return `${quoted(row.query)} gained a citation${everCited ? "" : `, its first from any engine (${listJoin(citedBy)})`}`;
554
+ });
555
+ const flipParts = [
556
+ ...gainedMention.map((row) => `${quoted(row.query)} gained a mention`),
557
+ ...citationFirsts
558
+ ];
559
+ const flips = flipParts.length > 0 ? `At query level, ${listJoin(flipParts)}. ${lost.length === 0 ? "No query lost either signal." : `${listJoin(lost.map((row) => quoted(row.query)))} lost a signal.`}` : "No query changed state at query level.";
560
+ const climbs = enginesOf(latest).map((provider) => ({
561
+ provider,
562
+ mentions: series(facts, "non-brand", "mentioned", provider),
563
+ citations: series(facts, "non-brand", "cited", provider),
564
+ total: tally(ofClass(latest, "non-brand", provider)).total
565
+ })).filter((entry) => entry.mentions.every((value, index) => index === 0 || value >= entry.mentions[index - 1]) && entry.mentions.at(-1) > entry.mentions[0]).sort((left, right) => right.mentions.at(-1) - right.mentions[0] - (left.mentions.at(-1) - left.mentions[0]));
566
+ const climb = climbs.at(0);
567
+ const allWithin = verdicts.every(Boolean);
568
+ let trend = allWithin ? "Nothing between these two sweeps is bigger than normal run-to-run noise." : "At least one change is bigger than normal run-to-run noise.";
569
+ if (climb) {
570
+ const firstToLast = withinNoise({ n: climb.mentions[0], d: climb.total }, { n: climb.mentions.at(-1), d: climb.total });
571
+ trend += ` The direction worth watching is ${engineName(climb.provider)}: its non-brand mentions have not dropped in ${sweeps.length} sweeps (${climb.mentions.join(", ")} of ${climb.total}), and citations went from ${climb.citations[0]} to ${climb.citations.at(-1)} of ${climb.total}.`;
572
+ trend += firstToLast ? ` Even first to last is within noise at ${climb.total} answers a sweep, so treat it as a direction to confirm, not a win.` : ` First to last is bigger than noise at ${climb.total} answers a sweep.`;
573
+ }
574
+ const nextParts = [
575
+ ...climb ? [`let the next sweep land before calling the ${engineName(climb.provider)} climb real`] : ["let the next sweep land before acting on any single move"],
576
+ ...gainedCitation[0] ? [`check that the new ${quoted(gainedCitation[0].query)} citation holds`] : []
577
+ ];
578
+ const answer = [
579
+ `**${formatPreviewDay(latest.createdAt)} vs ${formatPreviewDay(previous.createdAt)}${sameQueries ? `: the same ${queryIdsOf(latest.answers).length} queries on both sweeps` : ""}.**`,
580
+ ...lines,
581
+ flips,
582
+ trend,
583
+ `Next: ${nextParts.join(", and ")}. ${link("Open queries", `/projects/${project.name}/queries`)}`
584
+ ].join("\n\n");
585
+ const populations = ["branded", "non-brand"].map((queryClass) => {
586
+ const now = summary(ofClass(latest, queryClass));
587
+ const prior = tally(ofClass(previous, queryClass));
588
+ return {
589
+ queryClass,
590
+ summary: now,
591
+ comparison: {
592
+ previousRun: { id: previous.id, createdAt: previous.createdAt },
593
+ mentionCoverage: { previous: { numerator: prior.mentioned, denominator: prior.total } },
594
+ citationCoverage: { previous: { numerator: prior.cited, denominator: prior.total } }
595
+ },
596
+ ...queryClass === "non-brand" ? { trend: { runs: sweeps.map((sweep) => sweep.id), mentioned: series(facts, "non-brand", "mentioned"), cited: series(facts, "non-brand", "cited"), answers: tally(ofClass(latest, "non-brand")).total } } : {}
597
+ };
598
+ });
599
+ const steps = [
600
+ step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
601
+ selection: { queryClass: "all", run: { id: latest.id } },
602
+ populations
603
+ }, 1060, `Comparing the ${formatPreviewDay(latest.createdAt)} sweep with ${formatPreviewDay(previous.createdAt)}, branded and non-brand kept apart.`),
604
+ step("canonry_snapshots_diff", { project: project.name, run1: previous.id, run2: latest.id }, { run1: previous.id, run2: latest.id, changed: diff }, 540, "Checking which queries flipped.")
605
+ ];
606
+ if (climb) {
607
+ steps.push(step("canonry_visibility_report", { project: project.name, queryClass: "non-brand", provider: climb.provider }, {
608
+ selection: { queryClass: "non-brand", provider: climb.provider, run: { id: latest.id } },
609
+ trend: { mentioned: climb.mentions, cited: climb.citations, answers: climb.total }
610
+ }, 910, `${engineName(climb.provider)} has moved the most, so reading its trend.`));
611
+ }
612
+ return { id: "changes", steps, answer };
613
+ }
614
+ function summitGaps(facts) {
615
+ const { project, latest } = facts;
616
+ const nonBrand = ofClass(latest, "non-brand");
617
+ const groups = [...byQuery(nonBrand).values()];
618
+ const engines = enginesOf(latest);
619
+ const unnamed = groups.filter((answers) => answers.every((answer2) => !answer2.mentioned));
620
+ const partial = groups.filter((answers) => answers.some((answer2) => answer2.mentioned) && answers.some((answer2) => !answer2.mentioned));
621
+ const topicOf = (query) => summitTopic(query) ?? query;
622
+ const rivals = unique(unnamed.flatMap((answers) => namedInstead(answers).map((entry) => entry.label)));
623
+ const rows = unnamed.map((answers) => {
624
+ const query = answers[0].query;
625
+ const instead = namedInstead(answers).map((entry) => `${entry.label}, by ${entry.engines.length} of ${answers.length} engines`).join("; ") || "no tracked competitor";
626
+ const citedBy = answers.filter((answer2) => answer2.cited).map((answer2) => engineName(answer2.provider));
627
+ return `| ${topicOf(query)} | ${query} | ${instead} | ${citedBy.length} of ${answers.length}${citedBy.length > 0 ? ` (${listJoin(citedBy)})` : ""} |`;
628
+ });
629
+ const readButUnnamed = unnamed.flatMap((answers) => answers.filter((answer2) => answer2.cited && answer2.ownCitedPaths.length > 0).map((answer2) => `On ${quoted(answer2.query)}, ${engineName(answer2.provider)} cites ${answer2.ownCitedPaths[0]} without naming ${project.displayName}, so the page is read but the brand does not come through.`));
630
+ const partialLines = partial.map((answers) => {
631
+ const missing = answers.filter((answer2) => !answer2.mentioned);
632
+ const who = listJoin(missing.map((answer2) => engineName(answer2.provider)));
633
+ const instead = unique(missing.flatMap((answer2) => answer2.named));
634
+ const stillCited = missing.every((answer2) => answer2.cited);
635
+ return `- ${topicOf(answers[0].query)}, ${quoted(answers[0].query)}: ${who} ${instead.length > 0 ? `name${missing.length === 1 ? "s" : ""} ${listJoin(instead)} instead` : `do${missing.length === 1 ? "es" : ""} not name you`}${stillCited ? `, though ${missing.length === 1 ? "it still cites" : "both still cite"} your site` : ""}.`;
636
+ });
637
+ const pages = unique(unnamed.map((answers) => {
638
+ const cited = answers.find((answer2) => answer2.ownCitedPaths.length > 0)?.ownCitedPaths[0];
639
+ return cited ?? SUMMIT_QUERIES.find((candidate) => candidate.text === answers[0].query)?.pages[0];
640
+ }).filter((page) => Boolean(page)));
641
+ const answer = unnamed.length === 0 ? `**Every non-brand query is named by at least one engine on ${formatPreviewDay(latest.createdAt)}.**
642
+
643
+ ${partialLines.join("\n")}
644
+
645
+ Next: work the partial gaps above. ${link("Open queries", `/projects/${project.name}/queries`)}` : [
646
+ `**${unnamed.length} of ${groups.length} non-brand queries are named by no engine on ${formatPreviewDay(latest.createdAt)}.** ${rivals.length === 1 ? `${rivals[0]} takes ${unnamed.length === 1 ? "it" : unnamed.length === 2 ? "both" : "all of them"}.` : ""}`.trim(),
647
+ ["| Topic | Query | Named instead | Your site cited |", "|---|---|---|---|", ...rows].join("\n"),
648
+ ...readButUnnamed,
649
+ ...partialLines.length > 0 ? [`Partial gaps, where some engines name you and others do not:
650
+ ${partialLines.join("\n")}`] : [],
651
+ `Next: make ${project.displayName} and the service explicit on ${listJoin(pages)}, then check ${unnamed.length === 1 ? "the query" : unnamed.length === 2 ? "both queries" : "these queries"} on the next sweep. ${link("Open queries", `/projects/${project.name}/queries`)}`
652
+ ].join("\n\n");
653
+ const queryRows = groups.map((answers) => ({
654
+ query: answers[0].query,
655
+ mentionedBy: answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.provider).sort(engineOrder),
656
+ citedBy: answers.filter((answer2) => answer2.cited).map((answer2) => answer2.provider).sort(engineOrder)
657
+ }));
658
+ const missingAnswers = nonBrand.filter((answer2) => !answer2.mentioned).sort((left, right) => left.query.localeCompare(right.query) || engineOrder(left.provider, right.provider)).map((answer2) => ({
659
+ query: answer2.query,
660
+ provider: answer2.provider,
661
+ answerMentioned: false,
662
+ citationState: answer2.cited ? "cited" : "not-cited",
663
+ recommendedCompetitors: answer2.named
664
+ }));
665
+ return {
666
+ id: "gaps",
667
+ steps: [
668
+ step("canonry_visibility_report", { project: project.name, queryClass: "non-brand" }, {
669
+ selection: { queryClass: "non-brand", run: { id: latest.id } },
670
+ engines,
671
+ queries: queryRows
672
+ }, 980, "Reading the latest non-brand query rows."),
673
+ step("canonry_run_get", { runId: latest.id }, { id: latest.id, status: "completed", snapshots: missingAnswers }, 690, "Pulling the answers that leave you out to see who they name.")
674
+ ],
675
+ answer
676
+ };
677
+ }
678
+ function summitInsights(db, facts) {
679
+ const { project, latest, sweeps } = facts;
680
+ const active = db.select().from(insights).where(and(eq(insights.projectId, project.id), eq(insights.dismissed, false))).orderBy(desc(insights.createdAt), asc(insights.id)).all();
681
+ const severe = active.filter((row) => row.severity === "high" || row.severity === "critical");
682
+ const fromLatest = active.filter((row) => row.runId === latest.id);
683
+ const medium = fromLatest.filter((row) => row.severity === "medium");
684
+ const low = fromLatest.filter((row) => row.severity === "low");
685
+ if (severe.length > 0) throw new Error("The Summit Aero preview assumes no high or critical insight; rewrite its insights turn");
686
+ const gaps = medium.filter((row) => row.type === "persistent-gap");
687
+ const gains = medium.filter((row) => row.type === "competitor-gained");
688
+ const firsts = medium.filter((row) => row.type === "first-citation");
689
+ const latestByQuery = byQuery(latest.answers.filter((answer2) => answer2.queryClass !== null));
690
+ const answersFor = (query) => [...latestByQuery.values()].find((answers) => answers[0].query === query) ?? [];
691
+ const previousFor = (query) => facts.previous.answers.filter((answer2) => answer2.query === query);
692
+ const sharedSource = (answers) => {
693
+ const own = project.domain;
694
+ const competitorDomains = new Set(facts.competitors.map((competitor) => competitor.domain));
695
+ const candidates = unique(answers.flatMap((answer2) => answer2.citedDomains)).filter((domain) => domain !== own && !competitorDomains.has(domain));
696
+ return candidates.find((domain) => answers.every((answer2) => answer2.citedDomains.includes(domain))) ?? null;
697
+ };
698
+ const ranked = [...gaps].sort((left, right) => {
699
+ const score = (row) => {
700
+ const answers = answersFor(row.query);
701
+ return (answers.every((answer2) => !answer2.mentioned) ? 0 : 2) + (gains.some((gain) => gain.query === row.query) ? 0 : 1);
702
+ };
703
+ return score(left) - score(right);
704
+ }).slice(0, 3);
705
+ const items = ranked.map((row, index) => {
706
+ const answers = answersFor(row.query);
707
+ const mentionedBy = answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.provider).sort(engineOrder);
708
+ const gain = gains.find((candidate) => candidate.query === row.query);
709
+ const parts = [];
710
+ if (mentionedBy.length === 0) {
711
+ const instead = namedInstead(answers);
712
+ const everMentioned = sweeps.some((sweep) => sweep.answers.some((answer2) => answer2.query === row.query && answer2.mentioned));
713
+ parts.push(`No engine names ${project.displayName} on it${everMentioned ? "" : ` in any of the ${sweeps.length} sweeps`}${instead.length > 0 ? `, and ${listJoin(instead.map((entry) => entry.engines.length === answers.length ? `all ${answers.length} engines name ${entry.label}` : `${listJoin(entry.engines.map(engineName))} name${entry.engines.length === 1 ? "s" : ""} ${entry.label}`))}` : ""}.`);
714
+ } else {
715
+ const newly = mentionedBy.filter((provider) => !previousFor(row.query).some((answer2) => answer2.provider === provider && answer2.mentioned));
716
+ parts.push(newly.length === mentionedBy.length ? `${listJoin(mentionedBy.map(engineName))} started naming you on this sweep, but no engine cites you.` : `${listJoin(mentionedBy.map(engineName))} name${mentionedBy.length === 1 ? "s" : ""} you, but no engine cites you.`);
717
+ const rivalsCited = unique(answers.flatMap((answer2) => answer2.competitorCited));
718
+ for (const rival of rivalsCited) {
719
+ const citing = answers.filter((answer2) => answer2.competitorCited.includes(rival)).map((answer2) => engineName(answer2.provider));
720
+ if (!gain) parts.push(`${listJoin(citing)} cite${citing.length === 1 ? "s" : ""} ${rival} instead.`);
721
+ }
722
+ const source = sharedSource(answers);
723
+ if (source) parts.push(`Every answer cites ${source}, so check how that site covers you.`);
724
+ }
725
+ const stored = storedRecommendation([gain?.recommendation?.reason, row.recommendation?.reason]);
726
+ const pages = mentionedBy.length === 0 ? SUMMIT_QUERIES.find((candidate) => candidate.text === row.query)?.pages ?? [] : [];
727
+ const title = gain ? `${quoted(row.query)}: uncited for ${row.title.match(/for (\d+) runs/)?.[1] ?? "several"} runs, and ${facts.competitors.find((competitor) => competitor.domain === gain.cause?.competitorDomain)?.label ?? gain.cause?.competitorDomain ?? "a competitor"} just earned a citation there` : row.title;
728
+ return `${index + 1}. **${title}.** ${parts.join(" ")} ${stored}${pages.length > 0 ? ` Start with ${listJoin(pages)}.` : ""}`;
729
+ });
730
+ const win = firsts.at(0);
731
+ const winLine = win ? `The win: ${quoted(win.query)} got its first citation from any engine (${engineName(win.provider)}). ${storedRecommendation([win.recommendation?.reason])}` : null;
732
+ const firstUnnamed = ranked.find((row) => answersFor(row.query).every((answer2) => !answer2.mentioned));
733
+ const answer = [
734
+ `None of the ${active.length} active insights is high or critical, so I ranked the medium ones from the latest sweep. The ${formatPreviewDay(latest.createdAt)} sweep added ${fromLatest.length}, ${medium.length} medium and ${low.length} low, and the medium ones come down to ${items.length} issues${win ? " plus one win" : ""}.`,
735
+ items.join("\n"),
736
+ ...winLine ? [winLine] : [],
737
+ `Next: ${firstUnnamed ? `start with the ${quoted(firstUnnamed.query)} audit, since it is the only one of the ${items.length} where no engine names you` : "start with the first item"}. ${link("Open queries", `/projects/${project.name}/queries`)}`
738
+ ].join("\n\n");
739
+ return {
740
+ id: "insights",
741
+ steps: [
742
+ step("canonry_insights_list", { project: project.name }, {
743
+ active: active.length,
744
+ highOrCritical: severe.length,
745
+ latestSweep: { runId: latest.id, count: fromLatest.length, medium: medium.length, low: low.length },
746
+ medium: medium.map((row) => ({ id: row.id, type: row.type, title: row.title, provider: row.provider, recommendation: row.recommendation?.action ?? null, ...row.cause?.cause ? { cause: row.cause.cause } : {} }))
747
+ }, 470, "Listing active insights."),
748
+ step("canonry_run_get", { runId: latest.id }, {
749
+ id: latest.id,
750
+ status: "completed",
751
+ snapshots: ranked.flatMap((row) => answersFor(row.query).sort((left, right) => engineOrder(left.provider, right.provider)).map((answer2) => ({
752
+ query: answer2.query,
753
+ provider: answer2.provider,
754
+ answerMentioned: answer2.mentioned,
755
+ citationState: answer2.cited ? "cited" : "not-cited",
756
+ recommendedCompetitors: answer2.named,
757
+ citedDomains: answer2.citedDomains
758
+ })))
759
+ }, 730, "All medium, so checking the answers behind each one before ranking them.")
760
+ ],
761
+ answer
762
+ };
763
+ }
764
+ function shortPropertyLabel(property) {
765
+ const prefix = `Harbor ${property.market} `;
766
+ return property.label.startsWith(prefix) ? property.label.slice(prefix.length) : property.label;
767
+ }
768
+ function markets(facts) {
769
+ return unique([...facts.properties.values()].map((property) => property.market));
770
+ }
771
+ function marketOf(facts, answer) {
772
+ return answer.targetKey ? facts.properties.get(answer.targetKey)?.market ?? null : null;
773
+ }
774
+ function loadDeadLinks(db, facts) {
775
+ const { project } = facts;
776
+ const audit = db.select().from(runs).where(and(eq(runs.projectId, project.id), eq(runs.kind, "site-audit"), eq(runs.status, "completed"))).orderBy(desc(runs.createdAt), desc(runs.id)).get();
777
+ if (!audit) return null;
778
+ const snapshot = db.select().from(siteCrawlSnapshots).where(and(eq(siteCrawlSnapshots.projectId, project.id), eq(siteCrawlSnapshots.runId, audit.id))).get();
779
+ const findings = db.select().from(siteCrawlFindings).where(and(eq(siteCrawlFindings.projectId, project.id), eq(siteCrawlFindings.runId, audit.id), eq(siteCrawlFindings.findingType, "dead-link"))).orderBy(asc(siteCrawlFindings.findingKey)).all();
780
+ if (!snapshot || findings.length === 0) return null;
781
+ const targets = findings.map((finding) => finding.targetNodeKey ?? "");
782
+ const sources = unique(findings.map((finding) => finding.sourceNodeKey ?? ""));
783
+ const tails = targets.map((target) => target.split("/").filter(Boolean).at(-1) ?? "");
784
+ const owners = sources.map((source) => [...facts.properties.values()].find((property) => property.pathPrefixes.some((prefix) => source.startsWith(`${prefix}/`)))?.key);
785
+ return {
786
+ state: snapshot.deadLinkState,
787
+ checked: snapshot.deadLinksChecked,
788
+ found: snapshot.deadLinksFound,
789
+ sources,
790
+ targets,
791
+ statusCodes: unique(findings.map((finding) => Number(finding.evidence.statusCode)).filter(Number.isFinite)),
792
+ sharedTarget: tails[0] && tails.every((tail) => tail === tails[0]) ? tails[0] : null,
793
+ onePerPropertyOffersPage: sources.length === findings.length && sources.length === facts.properties.size && sources.every((source) => source.endsWith("/offers/")) && owners.every(Boolean) && unique(owners).length === owners.length
794
+ };
795
+ }
796
+ function deadLinkStep(project, deadLinks, text) {
797
+ return step("canonry_technical_aeo_dead_links", { project: project.name }, {
798
+ state: deadLinks.state,
799
+ checked: deadLinks.checked,
800
+ found: deadLinks.found,
801
+ deadLinks: deadLinks.targets.slice(0, 2).map((target, index) => ({ sourceNodeKey: deadLinks.sources[index], targetNodeKey: target, evidence: { statusCode: deadLinks.statusCodes[0] } }))
802
+ }, 610, text);
803
+ }
804
+ function deadLinkDetail(deadLinks) {
805
+ const where = deadLinks.onePerPropertyOffersPage ? "one on each property's offers page" : `on ${deadLinks.sources.length} pages`;
806
+ const codes = deadLinks.statusCodes.join(" or ");
807
+ const what = deadLinks.sharedTarget ? `all pointing to an ${deadLinks.sharedTarget} page that returns ${codes}` : `all returning ${codes}`;
808
+ return `${where}, ${what}`;
809
+ }
810
+ function harborStatus(facts, deadLinks) {
811
+ const { project, latest } = facts;
812
+ const engines = enginesOf(latest);
813
+ const branded = ofClass(latest, "branded");
814
+ const nonBrand = ofClass(latest, "non-brand");
815
+ const queryCount = queryIdsOf(latest.answers).length;
816
+ const allAnswered = engines.every((provider) => latest.answers.filter((answer2) => answer2.provider === provider).length === queryCount);
817
+ const together = latest.answers.every((answer2) => answer2.mentioned === answer2.cited);
818
+ const reached = (answers) => unique(answers.filter((answer2) => answer2.mentioned).map((answer2) => answer2.targetKey)).length;
819
+ const propertyCount = facts.properties.size;
820
+ const row = (label, answers) => {
821
+ const counts = tally(answers);
822
+ return `| ${label} (${queryIdsOf(answers).length}) | ${ratio(counts.mentioned, counts.total)} | ${ratio(counts.cited, counts.total)} | ${engines.map((provider) => {
823
+ const engine = tally(answers.filter((answer2) => answer2.provider === provider));
824
+ return ratio(engine.mentioned, engine.total);
825
+ }).join(" | ")} |`;
826
+ };
827
+ const dropped = engines.map((provider) => ({ provider, answers: nonBrand.filter((answer2) => answer2.provider === provider) })).filter((entry) => entry.answers.length > 0 && entry.answers.every((answer2) => !answer2.mentioned));
828
+ const attention = [
829
+ ...dropped.map((entry) => {
830
+ const instead = namedInstead(entry.answers);
831
+ const top = instead.sort((left, right) => right.engines.length - left.engines.length).at(0);
832
+ const count = top ? entry.answers.filter((answer2) => answer2.named.includes(top.label)).length : 0;
833
+ return `- ${engineName(entry.provider)} named Harbor on 0 of ${entry.answers.length} non-brand queries${top ? ` and named ${top.label} on ${count === entry.answers.length ? `all ${count}` : count} instead` : ""}.`;
834
+ }),
835
+ ...deadLinks ? [`- The site audit found ${deadLinks.found} dead links, ${deadLinkDetail(deadLinks)}.`] : []
836
+ ];
837
+ const answer = [
838
+ `**Latest sweep: ${formatPreviewDay(latest.createdAt)}, complete.** ${latest.expectedAnswers !== null ? `${latest.answers.length} of ${latest.expectedAnswers} expected answers came back: ` : ""}${listJoin(engines.map(engineName))} ${allAnswered ? `each answered all ${queryCount} queries` : "answered"} for ${propertyCount} properties in ${listJoin(markets(facts))}.`,
839
+ [
840
+ `| Queries | Mentioned | Cited | ${engines.map(engineName).join(" | ")} |`,
841
+ `|---|---|---|${engines.map(() => "---").join("|")}|`,
842
+ row("Branded", branded),
843
+ row("Non-brand", nonBrand)
844
+ ].join("\n"),
845
+ `Engine columns count mentions.${together ? " In this sample every answer that names a property also cites it, so the two signals match." : ""}${reached(branded) === propertyCount && reached(nonBrand) === propertyCount ? ` All ${propertyCount} properties were named in both classes.` : ""}`,
846
+ ...attention.length > 0 ? [`**Needs attention**
847
+ ${attention.join("\n")}`] : [],
848
+ `Next: ${[
849
+ ...dropped[0] ? [`read ${engineName(dropped[0].provider)}'s non-brand answers in one market`] : [],
850
+ ...deadLinks ? [`remove or redirect the broken link on all ${deadLinks.sources.length} ${deadLinks.onePerPropertyOffersPage ? "offers pages" : "pages"}`] : []
851
+ ].join(", and ") || "keep the weekly sweep running"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
852
+ ].join("\n\n");
853
+ const steps = [
854
+ step("canonry_measurement_data_quality", { project: project.name, runId: latest.id }, {
855
+ run: { state: "complete", displayedRunId: latest.id, planRevision: latest.planRevision, completedAt: latest.createdAt },
856
+ completeness: { expected: latest.expectedAnswers, answered: latest.answers.length, missing: Math.max(0, (latest.expectedAnswers ?? latest.answers.length) - latest.answers.length) },
857
+ comparison: { previousDisplayedRunId: facts.previous.id }
858
+ }, 520, "Checking that the latest sweep is complete before reading it."),
859
+ step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
860
+ selection: { queryClass: "all", run: { id: latest.id } },
861
+ populations: ["branded", "non-brand"].map((queryClass) => ({
862
+ queryClass,
863
+ summary: { ...summary(ofClass(latest, queryClass)), propertyReach: { numerator: reached(ofClass(latest, queryClass)), denominator: propertyCount } },
864
+ providerRollup: providerRollup(latest, queryClass)
865
+ }))
866
+ }, 1210, "Reading branded and non-brand separately, with the engine split.")
867
+ ];
868
+ if (deadLinks) steps.push(deadLinkStep(project, deadLinks, "Checking the latest site audit for anything broken."));
869
+ return { id: "status", steps, answer };
870
+ }
871
+ function harborChanges(facts) {
872
+ const { project, latest, previous, sweeps } = facts;
873
+ const engines = enginesOf(latest);
874
+ const together = [latest, previous].every((sweep) => sweep.answers.every((answer2) => answer2.mentioned === answer2.cited));
875
+ const sameIdentity = latest.planRevision === previous.planRevision && latest.executionChecksum !== null && latest.executionChecksum === previous.executionChecksum;
876
+ const propertyMentions = (sweep, queryClass) => {
877
+ const counts = /* @__PURE__ */ new Map();
878
+ for (const answer2 of ofClass(sweep, queryClass)) if (answer2.targetKey && answer2.mentioned) counts.set(answer2.targetKey, (counts.get(answer2.targetKey) ?? 0) + 1);
879
+ return counts;
880
+ };
881
+ const changedProperties = (queryClass) => {
882
+ const now = propertyMentions(latest, queryClass);
883
+ const before = propertyMentions(previous, queryClass);
884
+ return [...facts.properties.values()].filter((property) => (now.get(property.key) ?? 0) !== (before.get(property.key) ?? 0)).map((property) => ({
885
+ targetKey: property.key,
886
+ label: property.label,
887
+ previous: before.get(property.key) ?? 0,
888
+ current: now.get(property.key) ?? 0,
889
+ engines: engines.length
890
+ }));
891
+ };
892
+ let usualMoves = 0;
893
+ const beyondDetail = /* @__PURE__ */ new Map();
894
+ const classLines = ["non-brand", "branded"].map((queryClass) => {
895
+ const now = tally(ofClass(latest, queryClass));
896
+ const before = tally(ofClass(previous, queryClass));
897
+ const label = queryClass === "branded" ? "Branded" : "Non-brand";
898
+ const perEngine = engines.map((provider) => ({
899
+ provider,
900
+ history: series(facts, queryClass, "mentioned", provider),
901
+ total: tally(ofClass(latest, queryClass, provider)).total
902
+ }));
903
+ const moves = perEngine.filter((entry) => entry.history.at(-1) !== entry.history.at(-2));
904
+ for (const entry of moves) {
905
+ const prior = entry.history.slice(0, -1);
906
+ const value = entry.history.at(-1);
907
+ const outside = value < Math.min(...prior) || value > Math.max(...prior);
908
+ if (!outside) usualMoves += 1;
909
+ else beyondDetail.set(`${entry.provider}:${queryClass}`, { provider: entry.provider, queryClass, history: entry.history, previousLow: Math.min(...prior), previousHigh: Math.max(...prior), total: entry.total });
910
+ }
911
+ const engineMoves = moves.map((entry) => `${engineName(entry.provider)} ${entry.history.at(-1) < entry.history.at(-2) ? "fell" : "rose"} from ${ratio(entry.history.at(-2), entry.total)} to ${ratio(entry.history.at(-1), entry.total)}`);
912
+ const changed = changedProperties(queryClass);
913
+ const totals = series(facts, queryClass, "mentioned");
914
+ const alternates = totals.length >= 4 && totals.every((value, index) => index < 2 || value === totals[index - 2]) && totals[0] !== totals[1];
915
+ let line = `**${label}:** `;
916
+ if (now.mentioned === before.mentioned) {
917
+ line += `${ratio(now.mentioned, now.total)} on both sweeps`;
918
+ line += engineMoves.length > 0 ? `, but the engine mix changed. ${engineMoves.join("; ")}.` : ".";
919
+ } else {
920
+ line += `${movement(now.mentioned, before.mentioned, now.total)}. ${noisePhrase(withinNoise({ n: before.mentioned, d: before.total }, { n: now.mentioned, d: now.total }))}`;
921
+ if (alternates) line += ` The ${label.toLowerCase()} total has alternated every sweep: ${totals.join(", ")}.`;
922
+ if (engineMoves.length > 0) line += ` ${engineMoves.join("; ")}.`;
923
+ }
924
+ if (changed.length === 0) {
925
+ line += " No property changed.";
926
+ } else {
927
+ const uniform = changed.every((property) => property.previous === changed[0].previous && property.current === changed[0].current);
928
+ const marketList = markets(facts);
929
+ const perMarket = marketList.map((market) => changed.filter((property) => facts.properties.get(property.targetKey)?.market === market).map((property) => shortPropertyLabel(facts.properties.get(property.targetKey))).sort().join("|"));
930
+ const who = perMarket.every((value) => value !== "" && value === perMarket[0]) ? `the ${listJoin(perMarket[0].split("|"))} in each of the ${marketList.length} markets` : listJoin(changed.map((property) => property.label));
931
+ line += uniform ? ` ${changed.length} ${changed.length === 1 ? "property" : "properties"} went from ${changed[0].previous} of ${engines.length} engines to ${changed[0].current} of ${engines.length}: ${who}.` : ` ${changed.length} ${changed.length === 1 ? "property" : "properties"} changed: ${who}.`;
932
+ }
933
+ return line;
934
+ });
935
+ const detail = [...beyondDetail.values()];
936
+ let noise = "";
937
+ if (detail.length > 0) {
938
+ noise = `**Bigger than the usual swing:** only ${listJoin(detail.map((entry) => `${engineName(entry.provider)} on ${entry.queryClass}`))}. ${detail.map((entry) => `Its series over ${sweeps.length} sweeps is ${entry.history.join(", ")}, so ${entry.history.at(-1)} is ${entry.history.at(-1) < entry.previousLow ? `below its previous low of ${entry.previousLow}` : `above its previous high of ${entry.previousHigh}`}`).join(". ")}.`;
939
+ if (usualMoves > 0) noise += ` Every other engine move stays inside the range that engine has swung through over ${sweeps.length} sweeps, so it is the usual swing, even though a Wilson interval at ${detail[0].total} answers would flag it.`;
940
+ } else {
941
+ noise = "Every engine stayed inside the range it has swung through before, so nothing here is bigger than the usual swing.";
942
+ }
943
+ const worst = detail.at(0);
944
+ const worstCompetitor = worst ? namedInstead(ofClass(latest, worst.queryClass, worst.provider))[0]?.label : void 0;
945
+ const answer = [
946
+ `**${formatPreviewDay(latest.createdAt)} vs ${formatPreviewDay(previous.createdAt)}${sameIdentity ? `: same plan revision (${latest.planRevision}) and the same engines and models on both sweeps` : ""}.**${together ? " In this sample every answer that names a property also cites it, so the citation numbers match the mention numbers below." : ""}`,
947
+ ...classLines,
948
+ noise,
949
+ `Next: ${worst ? `confirm ${engineName(worst.provider)} ${worst.queryClass} on the next sweep before changing pages${worstCompetitor ? `; if it stays at ${worst.history.at(-1)}, compare the ${worstCompetitor} pages it cites with your market pages` : ""}` : "keep the weekly sweep running"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
950
+ ].join("\n\n");
951
+ const changesResult = (queryClass) => {
952
+ const now = tally(ofClass(latest, queryClass));
953
+ const before = tally(ofClass(previous, queryClass));
954
+ return {
955
+ current: { displayedRunId: latest.id, planRevision: latest.planRevision },
956
+ comparison: {
957
+ previous: { displayedRunId: previous.id, planRevision: previous.planRevision },
958
+ sameExecutionIdentity: sameIdentity,
959
+ metrics: {
960
+ mentionCoverage: { previous: { numerator: before.mentioned, denominator: before.total }, current: { numerator: now.mentioned, denominator: now.total } },
961
+ citationCoverage: { previous: { numerator: before.cited, denominator: before.total }, current: { numerator: now.cited, denominator: now.total } }
962
+ },
963
+ changedProperties: changedProperties(queryClass).map((property) => ({ label: property.label, mentioned: { previous: `${property.previous}/${property.engines}`, current: `${property.current}/${property.engines}` } }))
964
+ }
965
+ };
966
+ };
967
+ return {
968
+ id: "changes",
969
+ steps: [
970
+ step("canonry_measurement_changes", { project: project.name, queryClass: "non-brand" }, changesResult("non-brand"), 780, `Comparing ${formatPreviewDay(latest.createdAt)} with ${formatPreviewDay(previous.createdAt)}, non-brand first.`),
971
+ step("canonry_measurement_changes", { project: project.name, queryClass: "branded" }, changesResult("branded"), 690),
972
+ step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
973
+ selection: { queryClass: "all" },
974
+ trend: Object.fromEntries(["branded", "non-brand"].map((queryClass) => [queryClass, {
975
+ runs: sweeps.map((sweep) => sweep.id),
976
+ mentioned: series(facts, queryClass, "mentioned"),
977
+ byProvider: Object.fromEntries(engines.map((provider) => [provider, series(facts, queryClass, "mentioned", provider)]))
978
+ }]))
979
+ }, 1180, `Totals hide engine swings here, so reading each engine across all ${sweeps.length} sweeps.`)
980
+ ],
981
+ answer
982
+ };
983
+ }
984
+ function harborGaps(facts) {
985
+ const { project, latest } = facts;
986
+ const engines = enginesOf(latest);
987
+ const groups = [...byQuery(latest.answers).values()];
988
+ const unnamed = groups.filter((answers) => answers.every((answer2) => !answer2.mentioned));
989
+ const namedCounts = groups.map((answers) => answers.filter((answer2) => answer2.mentioned).length);
990
+ const uniformCount = namedCounts.every((count) => count === namedCounts[0]) ? namedCounts[0] : null;
991
+ const cell = (market, queryClass) => {
992
+ const answers = ofClass(latest, queryClass).filter((answer2) => marketOf(facts, answer2) === market);
993
+ const queries2 = queryIdsOf(answers).length;
994
+ const missing2 = answers.filter((answer2) => !answer2.mentioned);
995
+ const byEngine = engines.map((provider) => ({ provider, answers: missing2.filter((answer2) => answer2.provider === provider) })).filter((entry) => entry.answers.length > 0);
996
+ const rivals = unique(missing2.flatMap((answer2) => answer2.named));
997
+ if (missing2.length === 0) return "none";
998
+ const rivalText = rivals.length > 0 ? listJoin(rivals) : "no tracked competitor";
999
+ if (byEngine.length === 1) return `${engineName(byEngine[0].provider)}: ${rivalText} on ${byEngine[0].answers.length === queries2 ? `all ${queries2}` : `${byEngine[0].answers.length} of ${queries2}`}`;
1000
+ return `${listJoin(byEngine.map((entry) => engineName(entry.provider)))}: ${rivalText} on ${byEngine.map((entry) => entry.answers.length).every((count) => count === byEngine[0].answers.length) ? `${byEngine[0].answers.length} each` : byEngine.map((entry) => `${entry.answers.length} (${engineName(entry.provider)})`).join(", ")}`;
1001
+ };
1002
+ const marketList = markets(facts);
1003
+ const rows = marketList.map((market) => `| ${market} | ${cell(market, "non-brand")} | ${cell(market, "branded")} |`);
1004
+ const topics = unique(ofClass(latest, "non-brand").map((answer2) => answer2.query.match(/\bfor (.+)$/)?.[1]).filter((topic) => Boolean(topic)));
1005
+ const nonBrandMissing = ofClass(latest, "non-brand").filter((answer2) => !answer2.mentioned);
1006
+ const nonBrandEngines = unique(nonBrandMissing.map((answer2) => answer2.provider));
1007
+ const nonBrandRivals = unique(nonBrandMissing.flatMap((answer2) => answer2.named));
1008
+ const brandedMissing = ofClass(latest, "branded").filter((answer2) => !answer2.mentioned);
1009
+ const brandedPattern = unique(brandedMissing.map((answer2) => answer2.provider)).sort(engineOrder).map((provider) => {
1010
+ const perMarket = marketList.map((market) => brandedMissing.filter((answer2) => answer2.provider === provider && marketOf(facts, answer2) === market && answer2.targetKey).map((answer2) => shortPropertyLabel(facts.properties.get(answer2.targetKey))).sort().join("|"));
1011
+ return perMarket.every((value) => value === perMarket[0]) && perMarket[0] ? `by ${engineName(provider)} on ${listJoin(perMarket[0].split("|"))}` : null;
1012
+ });
1013
+ const brandedRivals = unique(brandedMissing.flatMap((answer2) => answer2.named));
1014
+ const sentences = [];
1015
+ if (nonBrandMissing.length > 0 && nonBrandEngines.length === 1 && topics.length > 0) {
1016
+ sentences.push(`Non-brand covers ${listJoin(topics)} in each market. ${engineName(nonBrandEngines[0])} names ${listJoin(nonBrandRivals)} instead of Harbor on all of them, ${nonBrandMissing.length} in total.`);
1017
+ }
1018
+ if (brandedMissing.length > 0 && brandedPattern.every(Boolean)) {
1019
+ sentences.push(`On branded "reviews" queries, ${listJoin(brandedRivals)} ${brandedRivals.length === 1 ? "is" : "are"} named instead ${brandedPattern.join(", and ")}, in every market: ${brandedMissing.length} in total.`);
1020
+ }
1021
+ const answer = [
1022
+ unnamed.length === 0 ? `**No tracked query goes unnamed by every engine on ${formatPreviewDay(latest.createdAt)}.**${uniformCount !== null ? ` All ${groups.length} are named by ${uniformCount} of ${engines.length} engines.` : ""} The misses repeat the same way in every market:` : `**${unnamed.length} of ${groups.length} tracked queries are named by no engine on ${formatPreviewDay(latest.createdAt)}.** By market:`,
1023
+ ['| Market | Non-brand, named instead | Branded "reviews", named instead |', "|---|---|---|", ...rows].join("\n"),
1024
+ ...sentences,
1025
+ `Next: start with ${nonBrandEngines.length === 1 ? `${engineName(nonBrandEngines[0])} non-brand, since it covers every market; read one answer per market to see what ${listJoin(nonBrandRivals)} ${nonBrandRivals.length === 1 ? "is" : "are"} recommended for` : "the non-brand misses"}. ${link("Open portfolio", `/projects/${project.name}/portfolio`)}`
1026
+ ].join("\n\n");
1027
+ const missing = latest.answers.filter((answer2) => !answer2.mentioned);
1028
+ const rollup = unique(missing.map((answer2) => `${answer2.provider}\0${answer2.queryClass}\0${answer2.named.join(",")}`)).map((key) => {
1029
+ const [provider, queryClass, named] = key.split("\0");
1030
+ const matching = missing.filter((answer2) => answer2.provider === provider && answer2.queryClass === queryClass && answer2.named.join(",") === named);
1031
+ return { provider, queryClass, namedInstead: named ? named.split(",") : [], answers: matching.length, markets: unique(matching.map((answer2) => marketOf(facts, answer2))).length };
1032
+ }).sort((left, right) => engineOrder(left.provider, right.provider));
1033
+ return {
1034
+ id: "gaps",
1035
+ steps: [
1036
+ step("canonry_visibility_report", { project: project.name, queryClass: "all" }, {
1037
+ selection: { queryClass: "all", run: { id: latest.id } },
1038
+ queries: { total: groups.length, namedByNoEngine: unnamed.length, namedByEngines: uniformCount !== null ? { [`${uniformCount}/${engines.length}`]: groups.length } : namedCounts }
1039
+ }, 1020, "Looking for queries no engine names Harbor on."),
1040
+ step("canonry_run_get", { runId: latest.id }, { id: latest.id, status: "completed", answersWithoutHarbor: missing.length, byEngine: rollup }, 760, "None, so reading who gets named on the answers that leave Harbor out.")
1041
+ ],
1042
+ answer
1043
+ };
1044
+ }
1045
+ function harborInsights(db, facts, deadLinks) {
1046
+ const { project, latest, sweeps } = facts;
1047
+ const active = db.select().from(insights).where(and(eq(insights.projectId, project.id), eq(insights.dismissed, false))).all();
1048
+ const sweepIds = new Set(sweeps.map((sweep) => sweep.id));
1049
+ const fromSweeps = active.filter((row) => row.runId !== null && sweepIds.has(row.runId));
1050
+ const engines = enginesOf(latest);
1051
+ const perEngine = engines.map((provider) => ({ provider, history: series(facts, "non-brand", "mentioned", provider), total: tally(ofClass(latest, "non-brand", provider)).total }));
1052
+ const dropped = perEngine.find((entry) => entry.history.at(-1) === 0);
1053
+ const droppedRival = dropped ? namedInstead(ofClass(latest, "non-brand", dropped.provider))[0]?.label : void 0;
1054
+ const others = perEngine.filter((entry) => entry !== dropped);
1055
+ const othersSwing = others.filter((entry) => Math.min(...entry.history) !== Math.max(...entry.history));
1056
+ const totals = series(facts, "non-brand", "mentioned");
1057
+ const totalDenominator = tally(ofClass(latest, "non-brand")).total;
1058
+ const findings = [];
1059
+ if (dropped) {
1060
+ const prior = dropped.history.slice(0, -1);
1061
+ findings.push(`**${engineName(dropped.provider)} stopped naming Harbor on non-brand.** It named Harbor on 0 of ${dropped.total} non-brand queries on ${formatPreviewDay(latest.createdAt)}${droppedRival ? ` and named ${droppedRival} on all ${dropped.total}` : ""}. Its previous low in ${sweeps.length} sweeps was ${Math.min(...prior)} of ${dropped.total}. Action: confirm on the next sweep, then compare the ${droppedRival ?? "competitor"} pages it cites with your market pages.`);
1062
+ findings.push(`**The engines swing every sweep.** ${engineName(dropped.provider)}'s non-brand series is ${dropped.history.join(", ")}${othersSwing.length > 0 ? `, and ${listJoin(othersSwing.map((entry) => engineName(entry.provider)))} move between ${Math.min(...othersSwing.flatMap((entry) => entry.history))} and ${Math.max(...othersSwing.flatMap((entry) => entry.history))} of ${othersSwing[0].total}` : ""}. Action: judge each week on the ${engines.length}-engine total, ${totals.at(-1) === totals.at(-2) ? `${totals.at(-1)} of ${totalDenominator} on both of the last two sweeps` : `${totals.at(-1)} of ${totalDenominator} now against ${totals.at(-2)} before`}, not on one engine's week.`);
1063
+ }
1064
+ if (deadLinks) {
1065
+ const detail = deadLinkDetail(deadLinks);
1066
+ findings.push(`**${deadLinks.found} dead links from the site audit.** There is ${detail}. Action: remove or redirect that link on all ${deadLinks.sources.length} pages. ${link("Open Site Health", `/projects/${project.name}/technical-aeo`)}`);
1067
+ }
1068
+ const answer = [
1069
+ `Only ${active.length} ${active.length === 1 ? "insight is" : "insights are"} stored for ${project.displayName}${fromSweeps.length === 0 ? `, and ${active.length === 2 ? "neither" : "none"} comes from a sweep` : ""}, so there is no severity ranking worth walking through. These are the ${findings.length} most important measured findings instead:`,
1070
+ findings.map((finding, index) => `${index + 1}. ${finding}`).join("\n"),
1071
+ `Next: ${deadLinks ? "fix the dead links now, since they need no sweep to confirm, and " : ""}${dropped ? `recheck ${engineName(dropped.provider)} non-brand after the next sweep` : "keep the weekly sweep running"}.`
1072
+ ].join("\n\n");
1073
+ const steps = [
1074
+ step("canonry_insights_list", { project: project.name }, { active: active.length, fromSweeps: fromSweeps.length, fromLatestSweep: fromSweeps.filter((row) => row.runId === latest.id).length }, 430, "Listing active insights."),
1075
+ step("canonry_visibility_report", { project: project.name, queryClass: "non-brand" }, {
1076
+ selection: { queryClass: "non-brand", run: { id: latest.id } },
1077
+ summary: summary(ofClass(latest, "non-brand")),
1078
+ trend: { runs: sweeps.map((sweep) => sweep.id), byProvider: Object.fromEntries(perEngine.map((entry) => [entry.provider, entry.history])) },
1079
+ ...dropped && droppedRival ? { namedInstead: { [dropped.provider]: { [droppedRival]: ofClass(latest, "non-brand", dropped.provider).filter((answer2) => answer2.named.includes(droppedRival)).length } } } : {}
1080
+ }, 1090, "Too few stored insights to rank, so reading the measured non-brand picture instead.")
1081
+ ];
1082
+ if (deadLinks) steps.push(deadLinkStep(project, deadLinks, "And the latest site audit."));
1083
+ return { id: "insights", steps, answer };
1084
+ }
1085
+ function buildDemoAeroPreviews(db, context) {
1086
+ const seededAt = context.now.toISOString();
1087
+ const previews = /* @__PURE__ */ new Map();
1088
+ const summit = loadFacts(db, context.simple);
1089
+ previews.set(context.simple.name, aeroPreviewResponseSchema.parse({
1090
+ project: context.simple.name,
1091
+ seededAt,
1092
+ starters: [summitStatus(summit), summitChanges(summit), summitGaps(summit), summitInsights(db, summit)]
1093
+ }));
1094
+ const harbor = loadFacts(db, context.portfolio);
1095
+ const deadLinks = loadDeadLinks(db, harbor);
1096
+ previews.set(context.portfolio.name, aeroPreviewResponseSchema.parse({
1097
+ project: context.portfolio.name,
1098
+ seededAt,
1099
+ starters: [harborStatus(harbor, deadLinks), harborChanges(harbor), harborGaps(harbor), harborInsights(db, harbor, deadLinks)]
1100
+ }));
1101
+ return previews;
1102
+ }
1103
+
113
1104
  // src/demo/http.ts
114
1105
  import { createHash, randomUUID } from "crypto";
115
1106
  import { existsSync, readFileSync } from "fs";
116
1107
  import { join } from "path";
117
- import { desc, eq } from "drizzle-orm";
1108
+ import { desc as desc2, eq as eq2 } from "drizzle-orm";
118
1109
  import Fastify from "fastify";
119
1110
  import fastifyStatic from "@fastify/static";
120
1111
  import rateLimit from "@fastify/rate-limit";
@@ -139,6 +1130,8 @@ var DEMO_READ_ROUTES = /* @__PURE__ */ new Set([
139
1130
  "/api/v1/projects/:name/ads/operations/:operationKey",
140
1131
  "/api/v1/projects/:name/ads/status",
141
1132
  "/api/v1/projects/:name/ads/summary",
1133
+ // Scripted Aero preview built from seeded rows; the live agent routes stay closed.
1134
+ "/api/v1/projects/:name/agent/preview",
142
1135
  "/api/v1/projects/:name/analytics/competitors",
143
1136
  "/api/v1/projects/:name/analytics/gaps",
144
1137
  "/api/v1/projects/:name/analytics/metrics",
@@ -375,9 +1368,9 @@ async function createDemoHttpServer(options) {
375
1368
  reply.header("Cache-Control", "no-store");
376
1369
  if (route === "/api/v1/projects/:name/bing/coverage") {
377
1370
  const name = request.params.name;
378
- const project = db.select().from(projects).where(eq(projects.name, name)).get();
1371
+ const project = db.select().from(projects).where(eq2(projects.name, name)).get();
379
1372
  if (!project) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
380
- const rows = db.select().from(bingUrlInspections).where(eq(bingUrlInspections.projectId, project.id)).orderBy(desc(bingUrlInspections.inspectedAt)).all();
1373
+ const rows = db.select().from(bingUrlInspections).where(eq2(bingUrlInspections.projectId, project.id)).orderBy(desc2(bingUrlInspections.inspectedAt)).all();
381
1374
  const latest = /* @__PURE__ */ new Map();
382
1375
  for (const row of rows) if (!latest.has(row.url)) latest.set(row.url, row);
383
1376
  const format = (row) => ({ url: row.url, httpCode: row.httpCode, inIndex: row.inIndex, lastCrawledDate: row.lastCrawledDate, inIndexDate: row.inIndexDate, inspectedAt: row.inspectedAt, documentSize: row.documentSize, anchorCount: row.anchorCount, discoveryDate: row.discoveryDate });
@@ -389,9 +1382,9 @@ async function createDemoHttpServer(options) {
389
1382
  }
390
1383
  if (route === "/api/v1/projects/:name/bing/performance") {
391
1384
  const name = request.params.name;
392
- const project = db.select().from(projects).where(eq(projects.name, name)).get();
1385
+ const project = db.select().from(projects).where(eq2(projects.name, name)).get();
393
1386
  if (!project) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
394
- return reply.send(db.select().from(bingKeywordStats).where(eq(bingKeywordStats.projectId, project.id)).limit(200).all().map((row) => ({
1387
+ return reply.send(db.select().from(bingKeywordStats).where(eq2(bingKeywordStats.projectId, project.id)).limit(200).all().map((row) => ({
395
1388
  query: row.query,
396
1389
  impressions: row.impressions,
397
1390
  clicks: row.clicks,
@@ -411,6 +1404,12 @@ async function createDemoHttpServer(options) {
411
1404
  app.get("/health", async () => ({ status: "ok", service: "canonry-demo", version: PACKAGE_VERSION, demo: true, workerEnabled: false }));
412
1405
  app.get("/api/v1/session", async () => ({ authenticated: true, setupRequired: false }));
413
1406
  app.get("/api/v1/demo", async () => ({ mode: "view-only", sampleData: true, seededAt: now.toISOString() }));
1407
+ const aeroPreviews = options.aeroPreviews ?? /* @__PURE__ */ new Map();
1408
+ app.get("/api/v1/projects/:name/agent/preview", async (request, reply) => {
1409
+ const preview = aeroPreviews.get(request.params.name);
1410
+ if (!preview) return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Project not found." } });
1411
+ return reply.send(preview);
1412
+ });
414
1413
  await app.register(apiRoutes, { db, skipAuth: true, ...options.readOptions });
415
1414
  await app.register(fastifyStatic, {
416
1415
  root: join(assetsDir, "assets"),
@@ -646,7 +1645,7 @@ function seedProjectMarketing(db, project, now) {
646
1645
  }
647
1646
 
648
1647
  // src/demo/seed-exploration.ts
649
- import { and, eq as eq2 } from "drizzle-orm";
1648
+ import { and as and2, eq as eq3 } from "drizzle-orm";
650
1649
  function seedDemoExploration(db, context) {
651
1650
  const createdAt = context.now.toISOString();
652
1651
  for (const project of [context.simple, context.portfolio]) {
@@ -674,7 +1673,7 @@ function seedDemoExploration(db, context) {
674
1673
  }
675
1674
  }
676
1675
  function seedPageAudits(db, projectId, runId, root, createdAt) {
677
- const crawlPages = db.select().from(siteCrawlPages).where(and(eq2(siteCrawlPages.projectId, projectId), eq2(siteCrawlPages.runId, runId))).all();
1676
+ const crawlPages = db.select().from(siteCrawlPages).where(and2(eq3(siteCrawlPages.projectId, projectId), eq3(siteCrawlPages.runId, runId))).all();
678
1677
  const audited = crawlPages.filter((page) => page.auditState === "success" && page.auditScore !== null);
679
1678
  const pages = audited.map((page, index) => {
680
1679
  const factors2 = siteAuditPageFactorSchema.array().parse(page.auditFields.factors);
@@ -744,10 +1743,10 @@ function harborProperties() {
744
1743
  }
745
1744
 
746
1745
  // src/demo/seed-answer-intelligence.ts
747
- import { and as and2, asc, eq as eq3 } from "drizzle-orm";
1746
+ import { and as and3, asc as asc2, eq as eq4 } from "drizzle-orm";
748
1747
  function seedDemoAnswerIntelligence(db, project) {
749
1748
  const service2 = new IntelligenceService(db);
750
- const sweeps = db.select().from(runs).where(and2(eq3(runs.projectId, project.id), eq3(runs.kind, RunKinds["answer-visibility"]))).orderBy(asc(runs.createdAt)).all();
1749
+ const sweeps = db.select().from(runs).where(and3(eq4(runs.projectId, project.id), eq4(runs.kind, RunKinds["answer-visibility"]))).orderBy(asc2(runs.createdAt)).all();
751
1750
  for (const [index, sweep] of sweeps.entries()) {
752
1751
  const previous = index > 0 ? sweeps[index - 1] : null;
753
1752
  const history = sweeps.slice(Math.max(0, index - (HISTORY_WINDOW_RUNS - 1)), index + 1);
@@ -787,123 +1786,6 @@ function seedDemoAnswerIntelligence(db, project) {
787
1786
  }
788
1787
  }
789
1788
 
790
- // src/demo/summit-sweeps.ts
791
- var SUMMIT_SWEEP_COUNT = 6;
792
- var SUMMIT_ENGINES = ["openai", "gemini", "claude"];
793
- var RIVALS = {
794
- roofcraft: { name: "RoofCraft", domain: "roofcraft.example", path: "/roof-replacement/", blurb: "A replacement contractor known for metal and shingle installs." },
795
- everlast: { name: "Everlast Roofing", domain: "everlast-roofing.example", path: "/services/", blurb: "A repair and gutter company with a large emergency crew." }
796
- };
797
- var SUMMIT_COMPETITORS = Object.values(RIVALS).map((rival) => ({ domain: rival.domain, label: rival.name }));
798
- var SUMMIT_QUERIES = [
799
- { key: "reviews", text: "Summit Roofing reviews", pages: ["/reviews/"], rivals: [], source: "https://home-services-reviews.example/roofers/", lead: "Recent homeowner reviews are mostly positive.", blurb: "Reviewers mention clear estimates, tidy crews and quick follow-up on warranty visits." },
800
- { key: "near-me", text: "roof repair contractor near me", pages: ["/services/roof-repair/", "/service-areas/"], rivals: ["everlast", "roofcraft"], source: "https://local-contractor-directory.example/roofing/", lead: "Several roofers in the area handle repair work.", blurb: "A local roofer covering leak repairs, storm damage and inspections across two dozen towns." },
801
- { key: "emergency-leak", text: "emergency roof leak repair", pages: ["/services/roof-repair/leak-repair/", "/services/roof-repair/emergency-tarping/"], rivals: ["everlast"], source: "https://homeowner-guides.example/roof-leaks/", lead: "For an active leak, look for a crew that can tarp the roof the same day.", blurb: "Offers emergency tarping and leak repair, with same-day visits in its service area." },
802
- { key: "metal-installer", text: "best metal roof installer", pages: ["/services/roof-replacement/metal-roofing/", "/guides/metal-vs-asphalt-shingles/"], rivals: ["roofcraft"], source: "https://metal-roofing-council.example/find-an-installer/", lead: "Metal roofs need an installer with standing-seam experience.", blurb: "Installs standing-seam and metal shingle roofs and publishes a metal versus asphalt comparison." },
803
- { key: "warranty", text: "Summit Roofing warranty", pages: ["/about/warranty/"], rivals: [], source: "https://homeowner-guides.example/roof-warranties/", lead: "Roofing warranties usually combine manufacturer and workmanship coverage.", blurb: "Backs replacements with a written workmanship warranty on top of the manufacturer coverage." },
804
- { key: "replacement-cost", text: "how much does a roof replacement cost", pages: ["/guides/how-much-does-a-roof-replacement-cost/", "/financing/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/roof-replacement/", lead: "Most full replacements land between 9,000 and 18,000 dollars, depending on size and material.", blurb: "Publishes a cost guide with typical price ranges and offers financing on full replacements." },
805
- { key: "storm-claims", text: "storm damage roof repair and insurance claims", pages: ["/services/roof-repair/storm-damage/", "/guides/filing-a-roof-insurance-claim/"], rivals: ["everlast"], source: "https://homeowner-guides.example/storm-claims/", lead: "Photograph the damage before any repair and call your insurer early.", blurb: "Documents storm damage for insurance claims and joins the adjuster visit." },
806
- { key: "asphalt-shingles", text: "asphalt shingle roof replacement", pages: ["/services/roof-replacement/asphalt-shingles/"], rivals: ["roofcraft"], source: "https://home-cost-report.example/asphalt-shingles/", lead: "Architectural shingles are the most common replacement choice.", blurb: "Replaces asphalt shingle roofs with a choice of architectural shingle lines." },
807
- { key: "seamless-gutters", text: "seamless gutter installation", pages: ["/services/gutters/seamless-gutters/"], rivals: ["everlast"], source: "https://homeowner-guides.example/gutters/", lead: "Seamless gutters are formed on site, so they leak less at the joints.", blurb: "Installs seamless gutters and gutter guards, often alongside a roof replacement." },
808
- { key: "pre-purchase-inspection", text: "roof inspection before buying a house", pages: ["/services/roof-inspection/real-estate-inspection/", "/guides/real-estate-roof-inspections/"], rivals: ["roofcraft", "everlast"], source: "https://home-buyer-checklist.example/roof-inspection/", lead: "A roof inspection before closing can surface repairs to negotiate.", blurb: "Runs pre-purchase roof inspections with photo reports for buyers and agents." }
809
- ];
810
- var SUMMIT_BRANDED_QUERY_KEYS = ["reviews", "warranty"];
811
- var SUMMIT_ENGINE_PLANS = {
812
- // Climbs steadily.
813
- openai: {
814
- mentioned: [2, 3, 3, 4, 5, 6],
815
- mentionOrder: ["emergency-leak", "replacement-cost", "near-me", "storm-claims", "pre-purchase-inspection", "seamless-gutters", "asphalt-shingles", "metal-installer"],
816
- cited: [1, 2, 2, 3, 4, 5],
817
- citationOrder: ["replacement-cost", "storm-claims", "pre-purchase-inspection", "emergency-leak", "asphalt-shingles", "metal-installer", "seamless-gutters", "near-me"]
818
- },
819
- // Steady, then a dip and a recovery.
820
- gemini: {
821
- mentioned: [4, 4, 4, 2, 3, 5],
822
- mentionOrder: ["near-me", "emergency-leak", "replacement-cost", "storm-claims", "seamless-gutters", "pre-purchase-inspection", "metal-installer", "asphalt-shingles"],
823
- cited: [3, 3, 3, 1, 2, 3],
824
- citationOrder: ["replacement-cost", "pre-purchase-inspection", "storm-claims", "metal-installer", "emergency-leak", "asphalt-shingles", "seamless-gutters", "near-me"]
825
- },
826
- // Swings from week to week.
827
- claude: {
828
- mentioned: [3, 1, 4, 2, 4, 3],
829
- mentionOrder: ["emergency-leak", "replacement-cost", "storm-claims", "near-me", "pre-purchase-inspection", "asphalt-shingles", "seamless-gutters", "metal-installer"],
830
- cited: [2, 1, 2, 1, 3, 2],
831
- citationOrder: ["pre-purchase-inspection", "replacement-cost", "storm-claims", "emergency-leak", "asphalt-shingles", "near-me", "metal-installer", "seamless-gutters"]
832
- }
833
- };
834
- var BRANDED_PLAN = {
835
- reviews: {
836
- openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
837
- gemini: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
838
- claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, false, false, false, false] }
839
- },
840
- warranty: {
841
- openai: { mentioned: [true, true, true, true, true, true], cited: [true, true, true, true, true, true] },
842
- gemini: { mentioned: [true, true, true, false, true, true], cited: [true, true, true, false, true, true] },
843
- claude: { mentioned: [true, true, true, true, true, true], cited: [false, false, true, false, true, true] }
844
- }
845
- };
846
- function isBranded(key) {
847
- return SUMMIT_BRANDED_QUERY_KEYS.includes(key);
848
- }
849
- function summitAnswerPlan(sweep, engine, queryKey) {
850
- if (isBranded(queryKey)) {
851
- const plan2 = BRANDED_PLAN[queryKey][engine];
852
- return { mentioned: plan2.mentioned[sweep], cited: plan2.cited[sweep] };
853
- }
854
- const plan = SUMMIT_ENGINE_PLANS[engine];
855
- return {
856
- mentioned: plan.mentionOrder.slice(0, plan.mentioned[sweep]).includes(queryKey),
857
- cited: plan.citationOrder.slice(0, plan.cited[sweep]).includes(queryKey)
858
- };
859
- }
860
- function summitAnswer(input) {
861
- const { sweep, engine, queryIndex, displayName, domain } = input;
862
- const query = SUMMIT_QUERIES[queryIndex];
863
- const engineIndex = SUMMIT_ENGINES.indexOf(engine);
864
- const plan = summitAnswerPlan(sweep, engine, query.key);
865
- const rivals = plan.mentioned ? (sweep + engineIndex + queryIndex) % 2 === 0 ? query.rivals.slice(0, 1) : [] : query.rivals;
866
- const entries = [
867
- ...plan.mentioned ? [{ name: displayName, blurb: query.blurb }] : [],
868
- ...rivals.map((key) => ({ name: RIVALS[key].name, blurb: RIVALS[key].blurb }))
869
- ];
870
- const answerText = isBranded(query.key) && !plan.mentioned ? `Fictional answer.
871
-
872
- I could not find published details for that company. ${query.lead} Most residential roofers offer 5 to 10 years of workmanship coverage.` : [
873
- "Fictional answer.",
874
- query.lead,
875
- entries.map((entry, index) => `${index + 1}. **${entry.name}**: ${entry.blurb}`).join("\n"),
876
- "Get two or three written estimates before you commit."
877
- ].join("\n\n");
878
- const citedRivals = rivals.filter((_, index) => (sweep + engineIndex + queryIndex + index) % 3 !== 0);
879
- const citedUrls = [
880
- ...plan.cited ? [`https://${domain}${query.pages[(sweep + engineIndex) % query.pages.length]}`] : [],
881
- ...citedRivals.map((key) => `https://${RIVALS[key].domain}${RIVALS[key].path}`),
882
- query.source
883
- ];
884
- const citedDomains = citedUrls.map((url) => new URL(url).hostname);
885
- const normalized = {
886
- provider: engine,
887
- answerText,
888
- citedDomains,
889
- groundingSources: citedUrls.map((uri) => ({ uri, title: "" })),
890
- searchQueries: [query.text],
891
- retrievalStatus: "used"
892
- };
893
- const domains = effectiveDomains({ canonicalDomain: domain, ownedDomains: [domain] });
894
- const brandNames = effectiveBrandNames({ displayName, canonicalDomain: domain, ownedDomains: [domain] });
895
- const competitorDomains = SUMMIT_COMPETITORS.map((competitor) => competitor.domain);
896
- return {
897
- answerText,
898
- answerMentioned: determineAnswerMentioned(answerText, brandNames, domains),
899
- citationState: determineCitationState(normalized, domains),
900
- citedDomains,
901
- citedUrls,
902
- competitorOverlap: computeCompetitorOverlap(normalized, competitorDomains),
903
- recommendedCompetitors: extractRecommendedCompetitors(answerText, domains, citedDomains, competitorDomains, brandNames)
904
- };
905
- }
906
-
907
1789
  // src/demo/seed-core.ts
908
1790
  var PROVIDERS = [
909
1791
  { provider: "openai", requestedModel: "gpt-5-demo", servedModel: "gpt-5-demo-2026-08-15" },
@@ -1731,7 +2613,7 @@ var guide = (slug) => `/guides/${slug}/`;
1731
2613
  function summitRoofingInventory() {
1732
2614
  const pages = [];
1733
2615
  const links = [];
1734
- const link = (source, ...targets) => {
2616
+ const link2 = (source, ...targets) => {
1735
2617
  for (const target of targets) links.push([source, target]);
1736
2618
  };
1737
2619
  const projects2 = Array.from({ length: 36 }, (_, index) => {
@@ -1744,7 +2626,7 @@ function summitRoofingInventory() {
1744
2626
  { path: "/llms.txt", kind: "resource", contentType: "text/plain; charset=utf-8", sitemap: true },
1745
2627
  { path: "/services/", kind: "html", score: 84 }
1746
2628
  );
1747
- link(
2629
+ link2(
1748
2630
  "/",
1749
2631
  ...SERVICES.map(([key]) => service(key)),
1750
2632
  "/service-areas/",
@@ -1756,12 +2638,12 @@ function summitRoofingInventory() {
1756
2638
  "/financing/",
1757
2639
  "/get-a-quote/"
1758
2640
  );
1759
- link("/services/", ...SERVICES.map(([key]) => service(key)), "/service-areas/");
2641
+ link2("/services/", ...SERVICES.map(([key]) => service(key)), "/service-areas/");
1760
2642
  for (const [categoryIndex, [category, subs]] of SERVICES.entries()) {
1761
2643
  pages.push({ path: service(category), kind: "html", score: 86 - categoryIndex * 2 });
1762
2644
  const categoryGuides = GUIDES.filter(([, key]) => key === category);
1763
2645
  const categoryProjects = projects2.filter((project) => project.category === category);
1764
- link(
2646
+ link2(
1765
2647
  service(category),
1766
2648
  ...subs.map((sub) => service(`${category}/${sub}`)),
1767
2649
  "/get-a-quote/",
@@ -1773,17 +2655,17 @@ function summitRoofingInventory() {
1773
2655
  const path = service(`${category}/${sub}`);
1774
2656
  pages.push({ path, kind: "html", score: 83 - (subIndex * 7 + categoryIndex * 3) % 17 });
1775
2657
  const project = categoryProjects.find((candidate) => candidate.sub === sub) ?? categoryProjects.at(0);
1776
- link(path, service(category), service(`${category}/${subs[(subIndex + 1) % subs.length]}`), "/get-a-quote/");
1777
- if (project) link(path, project.path);
2658
+ link2(path, service(category), service(`${category}/${subs[(subIndex + 1) % subs.length]}`), "/get-a-quote/");
2659
+ if (project) link2(path, project.path);
1778
2660
  const related = categoryGuides.at(subIndex % Math.max(1, categoryGuides.length));
1779
- if (related) link(path, guide(related[0]));
2661
+ if (related) link2(path, guide(related[0]));
1780
2662
  }
1781
2663
  }
1782
2664
  pages.push({ path: "/service-areas/", kind: "html", score: 78 });
1783
- link("/service-areas/", ...TOWNS.map(town));
2665
+ link2("/service-areas/", ...TOWNS.map(town));
1784
2666
  for (const [townIndex, townKey] of TOWNS.entries()) {
1785
2667
  pages.push({ path: town(townKey), kind: "html", score: 76 - townIndex % 6 * 2, factorScores: { "geographic-signals": 92 } });
1786
- link(
2668
+ link2(
1787
2669
  town(townKey),
1788
2670
  service("roof-replacement"),
1789
2671
  service("roof-repair"),
@@ -1799,16 +2681,16 @@ function summitRoofingInventory() {
1799
2681
  const path = `${town(townKey)}${serviceKey}/`;
1800
2682
  const thin = THIN_TOWN_SERVICES.has(path);
1801
2683
  pages.push({ path, kind: "html", score: thin ? 58 : 72, factorScores: thin ? { "content-depth": 34, "geographic-signals": 88 } : { "geographic-signals": 94 } });
1802
- link(path, town(townKey), service(serviceKey), "/get-a-quote/");
1803
- if (path !== ORPHAN_TOWN_SERVICE) link(town(townKey), path);
2684
+ link2(path, town(townKey), service(serviceKey), "/get-a-quote/");
2685
+ if (path !== ORPHAN_TOWN_SERVICE) link2(town(townKey), path);
1804
2686
  }
1805
2687
  }
1806
2688
  pages.push({ path: "/projects/", kind: "html", score: 80 });
1807
- link("/projects/", ...projects2.map((project) => project.path));
2689
+ link2("/projects/", ...projects2.map((project) => project.path));
1808
2690
  for (const project of projects2) {
1809
2691
  const thin = THIN_PROJECTS.has(project.index);
1810
2692
  pages.push({ path: project.path, kind: "html", score: thin ? 60 : 77 - project.index % 7 * 2, factorScores: thin ? { "content-depth": 31 } : void 0 });
1811
- link(
2693
+ link2(
1812
2694
  project.path,
1813
2695
  service(project.category),
1814
2696
  service(`${project.category}/${project.sub}`),
@@ -1825,21 +2707,21 @@ function summitRoofingInventory() {
1825
2707
  { path: "/guides/topic/", kind: "noindex", score: 70 },
1826
2708
  { path: "/guides/roof-maintenance-checklist.pdf", kind: "resource", contentType: "application/pdf" }
1827
2709
  );
1828
- link("/guides/", ...GUIDES.slice(0, 20).map(([slug]) => guide(slug)), "/guides/page/2/", "/guides/topic/");
1829
- link("/guides/page/2/", ...GUIDES.slice(20, 40).map(([slug]) => guide(slug)), "/guides/", "/guides/page/3/");
1830
- link("/guides/page/3/", ...GUIDES.slice(40).map(([slug]) => guide(slug)), "/guides/page/2/");
1831
- link("/guides/topic/", ...GUIDE_TOPICS2.map((topic) => `/guides/topic/${topic}/`));
2710
+ link2("/guides/", ...GUIDES.slice(0, 20).map(([slug]) => guide(slug)), "/guides/page/2/", "/guides/topic/");
2711
+ link2("/guides/page/2/", ...GUIDES.slice(20, 40).map(([slug]) => guide(slug)), "/guides/", "/guides/page/3/");
2712
+ link2("/guides/page/3/", ...GUIDES.slice(40).map(([slug]) => guide(slug)), "/guides/page/2/");
2713
+ link2("/guides/topic/", ...GUIDE_TOPICS2.map((topic) => `/guides/topic/${topic}/`));
1832
2714
  for (const topic of GUIDE_TOPICS2) {
1833
2715
  pages.push({ path: `/guides/topic/${topic}/`, kind: "noindex", score: 68 });
1834
- link(`/guides/topic/${topic}/`, "/guides/", ...GUIDES.filter(([, , guideTopic]) => guideTopic === topic).map(([slug]) => guide(slug)));
2716
+ link2(`/guides/topic/${topic}/`, "/guides/", ...GUIDES.filter(([, , guideTopic]) => guideTopic === topic).map(([slug]) => guide(slug)));
1835
2717
  }
1836
2718
  for (const [guideIndex, [slug, serviceKey, topic]] of GUIDES.entries()) {
1837
2719
  const noAuthor = guideIndex % 5 === 4;
1838
2720
  pages.push({ path: guide(slug), kind: "html", score: 81 - guideIndex % 6 * 3, factorScores: noAuthor ? { "eeat-signals": 28 } : void 0 });
1839
- link(guide(slug), service(serviceKey), guide(GUIDES[(guideIndex + 1) % GUIDES.length][0]), `/guides/topic/${topic}/`, "/get-a-quote/");
2721
+ link2(guide(slug), service(serviceKey), guide(GUIDES[(guideIndex + 1) % GUIDES.length][0]), `/guides/topic/${topic}/`, "/get-a-quote/");
1840
2722
  }
1841
- link(guide(GUIDES[18][0]), "/guides/roof-maintenance-checklist.pdf");
1842
- link(guide("what-a-roof-warranty-covers"), "/about/warranty/");
2723
+ link2(guide(GUIDES[18][0]), "/guides/roof-maintenance-checklist.pdf");
2724
+ link2(guide("what-a-roof-warranty-covers"), "/about/warranty/");
1843
2725
  pages.push(
1844
2726
  { path: "/financing/", kind: "html", score: 79 },
1845
2727
  { path: "/financing/apply/", kind: "html", score: 71 },
@@ -1867,19 +2749,19 @@ function summitRoofingInventory() {
1867
2749
  { path: "/terms/", kind: "html", score: 63 },
1868
2750
  { path: "/accessibility/", kind: "html", score: 69 }
1869
2751
  );
1870
- link("/financing/", "/financing/apply/", "/financing/faq/", "/financing/financing-terms.pdf", "/get-a-quote/");
1871
- link("/financing/faq/", "/financing/apply/");
1872
- link("/reviews/", "/reviews/leave-a-review/", ...projects2.slice(0, 6).map((project) => project.path));
1873
- link("/about/", "/about/team/", "/about/certifications/", "/about/warranty/", "/about/community/", "/reviews/");
1874
- link("/about/warranty/", "/about/warranty/warranty-guide.pdf", guide("what-a-roof-warranty-covers"));
1875
- link("/about/certifications/", service("roof-replacement"));
1876
- link("/about/community/", "/projects/");
1877
- link("/careers/", "/careers/roofing-crew-lead/", "/careers/project-manager/", "/careers/sales-estimator/");
1878
- for (const role of ["roofing-crew-lead", "project-manager", "sales-estimator"]) link(`/careers/${role}/`, "/careers/");
1879
- link("/contact/", "/contact/thank-you/");
1880
- link("/get-a-quote/", "/get-a-quote/roof-details/", "/financing/");
1881
- link("/get-a-quote/roof-details/", "/get-a-quote/schedule/");
1882
- link("/get-a-quote/schedule/", "/get-a-quote/thank-you/");
2752
+ link2("/financing/", "/financing/apply/", "/financing/faq/", "/financing/financing-terms.pdf", "/get-a-quote/");
2753
+ link2("/financing/faq/", "/financing/apply/");
2754
+ link2("/reviews/", "/reviews/leave-a-review/", ...projects2.slice(0, 6).map((project) => project.path));
2755
+ link2("/about/", "/about/team/", "/about/certifications/", "/about/warranty/", "/about/community/", "/reviews/");
2756
+ link2("/about/warranty/", "/about/warranty/warranty-guide.pdf", guide("what-a-roof-warranty-covers"));
2757
+ link2("/about/certifications/", service("roof-replacement"));
2758
+ link2("/about/community/", "/projects/");
2759
+ link2("/careers/", "/careers/roofing-crew-lead/", "/careers/project-manager/", "/careers/sales-estimator/");
2760
+ for (const role of ["roofing-crew-lead", "project-manager", "sales-estimator"]) link2(`/careers/${role}/`, "/careers/");
2761
+ link2("/contact/", "/contact/thank-you/");
2762
+ link2("/get-a-quote/", "/get-a-quote/roof-details/", "/financing/");
2763
+ link2("/get-a-quote/roof-details/", "/get-a-quote/schedule/");
2764
+ link2("/get-a-quote/schedule/", "/get-a-quote/thank-you/");
1883
2765
  pages.push(
1884
2766
  { path: "/roof-repair/", kind: "redirect", redirectTo: service("roof-repair") },
1885
2767
  { path: "/blog/", kind: "redirect", redirectTo: "/guides/" },
@@ -1888,32 +2770,32 @@ function summitRoofingInventory() {
1888
2770
  { path: "/service-areas/old-county/", kind: "redirect", redirectTo: "/service-areas/" },
1889
2771
  { path: "/free-estimate/", kind: "redirect", redirectTo: "/get-a-quote/" }
1890
2772
  );
1891
- link(service("roof-repair/leak-repair"), "/roof-repair/");
1892
- link(guide("how-to-spot-a-roof-leak"), "/roof-repair/");
1893
- link(guide("signs-you-need-a-new-roof"), "/blog/");
1894
- link(guide("roof-repair-vs-replacement"), "/blog/");
1895
- link(guide("metal-vs-asphalt-shingles"), "/services/metal-roofs/");
1896
- link(guide("winter-roof-care"), "/guides/ice-dam-prevention-2023/");
1897
- link(town(TOWNS.at(-1)), "/service-areas/old-county/");
1898
- link(projects2[0].path, "/free-estimate/");
1899
- link("/financing/faq/", "/free-estimate/");
2773
+ link2(service("roof-repair/leak-repair"), "/roof-repair/");
2774
+ link2(guide("how-to-spot-a-roof-leak"), "/roof-repair/");
2775
+ link2(guide("signs-you-need-a-new-roof"), "/blog/");
2776
+ link2(guide("roof-repair-vs-replacement"), "/blog/");
2777
+ link2(guide("metal-vs-asphalt-shingles"), "/services/metal-roofs/");
2778
+ link2(guide("winter-roof-care"), "/guides/ice-dam-prevention-2023/");
2779
+ link2(town(TOWNS.at(-1)), "/service-areas/old-county/");
2780
+ link2(projects2[0].path, "/free-estimate/");
2781
+ link2("/financing/faq/", "/free-estimate/");
1900
2782
  pages.push(
1901
2783
  { path: "/careers/estimator-2025/", kind: "broken" },
1902
2784
  { path: "/financing/spring-promo/", kind: "broken" },
1903
2785
  { path: "/guides/hail-season-update/", kind: "broken" },
1904
2786
  { path: `/projects/${TOWNS[2]}-reroof-draft/`, kind: "broken" }
1905
2787
  );
1906
- link("/careers/", "/careers/estimator-2025/");
1907
- link("/financing/", "/financing/spring-promo/");
1908
- link(guide("what-to-do-after-hail-damage"), "/guides/hail-season-update/");
1909
- link(guide("storm-season-preparation"), "/guides/hail-season-update/");
1910
- link("/projects/", `/projects/${TOWNS[2]}-reroof-draft/`);
2788
+ link2("/careers/", "/careers/estimator-2025/");
2789
+ link2("/financing/", "/financing/spring-promo/");
2790
+ link2(guide("what-to-do-after-hail-damage"), "/guides/hail-season-update/");
2791
+ link2(guide("storm-season-preparation"), "/guides/hail-season-update/");
2792
+ link2("/projects/", `/projects/${TOWNS[2]}-reroof-draft/`);
1911
2793
  pages.push(
1912
2794
  { path: "/spring-roof-special/", kind: "html", score: 52, factorScores: { "content-freshness": 18 } },
1913
2795
  { path: "/guides/choosing-roof-color/", kind: "html", score: 74 }
1914
2796
  );
1915
- link("/spring-roof-special/", "/get-a-quote/");
1916
- link("/guides/choosing-roof-color/", service("roof-replacement"));
2797
+ link2("/spring-roof-special/", "/get-a-quote/");
2798
+ link2("/guides/choosing-roof-color/", service("roof-replacement"));
1917
2799
  return { pages, templateLinks: [...HEADER, ...FOOTER], links };
1918
2800
  }
1919
2801
 
@@ -2010,7 +2892,7 @@ function seedLocalAndCommercialSignals(db, input) {
2010
2892
  }
2011
2893
 
2012
2894
  // src/demo/stores.ts
2013
- import { and as and3, eq as eq4 } from "drizzle-orm";
2895
+ import { and as and4, eq as eq5 } from "drizzle-orm";
2014
2896
  function refuseMutation() {
2015
2897
  throw new Error("The public demo stores are view only.");
2016
2898
  }
@@ -2029,9 +2911,9 @@ function demoReadOptions(db, context) {
2029
2911
  // allowed, and no Google client id, secret, token, or transport is wired.
2030
2912
  googleStateSecret: "public-demo-no-oauth-callbacks",
2031
2913
  googleConnectionStore: {
2032
- listConnections: (domain) => db.select().from(googleConnections).where(eq4(googleConnections.domain, domain)).all().map(mapGoogle),
2914
+ listConnections: (domain) => db.select().from(googleConnections).where(eq5(googleConnections.domain, domain)).all().map(mapGoogle),
2033
2915
  getConnection: (domain, type) => {
2034
- const row = db.select().from(googleConnections).where(and3(eq4(googleConnections.domain, domain), eq4(googleConnections.connectionType, type))).get();
2916
+ const row = db.select().from(googleConnections).where(and4(eq5(googleConnections.domain, domain), eq5(googleConnections.connectionType, type))).get();
2035
2917
  return row ? mapGoogle(row) : void 0;
2036
2918
  },
2037
2919
  upsertConnection: refuseMutation,
@@ -2040,7 +2922,7 @@ function demoReadOptions(db, context) {
2040
2922
  },
2041
2923
  bingConnectionStore: {
2042
2924
  getConnection: (domain) => {
2043
- const row = db.select().from(bingConnections).where(eq4(bingConnections.domain, domain)).get();
2925
+ const row = db.select().from(bingConnections).where(eq5(bingConnections.domain, domain)).get();
2044
2926
  return row ? { domain, siteUrl: row.siteUrl, apiKey: "", createdAt: row.createdAt, updatedAt: row.updatedAt } : void 0;
2045
2927
  },
2046
2928
  upsertConnection: refuseMutation,
@@ -2107,12 +2989,14 @@ async function createDemoServer(options = {}) {
2107
2989
  await seedDemoSignals(db, context);
2108
2990
  seedDemoExploration(db, context);
2109
2991
  seedDemoMarketing(db, context);
2992
+ const aeroPreviews = buildDemoAeroPreviews(db, context);
2110
2993
  const app = await createDemoHttpServer({
2111
2994
  db,
2112
2995
  now: context.now,
2113
2996
  trustProxy: options.trustProxy,
2114
2997
  assetsDir: options.assetsDir ?? join2(dirname(fileURLToPath(import.meta.url)), "..", "assets"),
2115
- readOptions: demoReadOptions(db, context)
2998
+ readOptions: demoReadOptions(db, context),
2999
+ aeroPreviews
2116
3000
  });
2117
3001
  db.$client.pragma("query_only = ON");
2118
3002
  app.addHook("onClose", async () => {