@ainyc/canonry 4.116.0 → 4.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/README.md +0 -1
  2. package/assets/agent-workspace/skills/aero/references/reporting.md +10 -0
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +6 -0
  4. package/assets/assets/{BacklinksPage-Dy6Xc-LT.js → BacklinksPage-Bkafm10m.js} +1 -1
  5. package/assets/assets/{ChartPrimitives-CHJh5M8x.js → ChartPrimitives-NV_GgqFn.js} +1 -1
  6. package/assets/assets/{ProjectPage-Bjfa4CI5.js → ProjectPage-0-NnRxys.js} +1 -1
  7. package/assets/assets/{RunRow-DmvQnWPf.js → RunRow-df0d24S6.js} +1 -1
  8. package/assets/assets/{RunsPage-BxkxQEKE.js → RunsPage-CU6-4YTM.js} +1 -1
  9. package/assets/assets/{SettingsPage-DCyqHqDh.js → SettingsPage-BKw6B6cd.js} +1 -1
  10. package/assets/assets/{TrafficPage-BT_ae5aO.js → TrafficPage-CzpiYHKj.js} +1 -1
  11. package/assets/assets/{TrafficSourceDetailPage-Cps-tSHn.js → TrafficSourceDetailPage-CC5nuqM1.js} +1 -1
  12. package/assets/assets/{arrow-left-D8vdqhx4.js → arrow-left-CbQcW2pL.js} +1 -1
  13. package/assets/assets/{extract-error-message-BOY5txUA.js → extract-error-message-CvqIZ-le.js} +1 -1
  14. package/assets/assets/index-LjcWHaNZ.js +210 -0
  15. package/assets/assets/{trash-2-ap_Fa4zA.js → trash-2-3J2u0woR.js} +1 -1
  16. package/assets/index.html +1 -1
  17. package/dist/{chunk-WEAXJ25Y.js → chunk-FVVKNMZW.js} +353 -19
  18. package/dist/{chunk-IQPMK36Y.js → chunk-IAJ5TQ4S.js} +155 -3
  19. package/dist/{chunk-RVH6QPTA.js → chunk-PJATAPZW.js} +37 -1
  20. package/dist/{chunk-GV665WOU.js → chunk-ZZEA774R.js} +4 -4
  21. package/dist/cli.js +93 -4
  22. package/dist/index.js +4 -4
  23. package/dist/{intelligence-service-HEN5HLUM.js → intelligence-service-J7G4CMI5.js} +2 -2
  24. package/dist/mcp.js +2 -2
  25. package/package.json +9 -9
  26. package/assets/assets/index-DxjY63VC.js +0 -210
@@ -1 +1 @@
1
- import{c}from"./index-DxjY63VC.js";const a=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],h=c("circle-check",a);const e=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3",key:"1u773s"}],["path",{d:"M12 17h.01",key:"p32p05"}]],n=c("circle-question-mark",e);const o=[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]],s=c("download",o);const t=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],r=c("trash-2",t);export{h as C,s as D,r as T,n as a};
1
+ import{c}from"./index-LjcWHaNZ.js";const a=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],h=c("circle-check",a);const e=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3",key:"1u773s"}],["path",{d:"M12 17h.01",key:"p32p05"}]],n=c("circle-question-mark",e);const o=[["path",{d:"M12 15V3",key:"m9g1x1"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["path",{d:"m7 10 5 5 5-5",key:"brsn70"}]],s=c("download",o);const t=[["path",{d:"M10 11v6",key:"nco0om"}],["path",{d:"M14 11v6",key:"outv1u"}],["path",{d:"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6",key:"miytrc"}],["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2",key:"e791ji"}]],r=c("trash-2",t);export{h as C,s as D,r as T,n as a};
package/assets/index.html CHANGED
@@ -12,7 +12,7 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-DxjY63VC.js"></script>
15
+ <script type="module" crossorigin src="./assets/index-LjcWHaNZ.js"></script>
16
16
  <link rel="modulepreload" crossorigin href="./assets/vendor-tanstack-Be8JdV5M.js">
17
17
  <link rel="modulepreload" crossorigin href="./assets/vendor-radix-CQ77EO2y.js">
18
18
  <link rel="modulepreload" crossorigin href="./assets/vendor-recharts-C9EZkgbP.js">
@@ -173,6 +173,7 @@ import {
173
173
  gscSitemapListResponseDtoSchema,
174
174
  gscUrlInspectionDtoSchema,
175
175
  hasLocationLabel,
176
+ hostOf,
176
177
  indexingRequestResponseDtoSchema,
177
178
  internalError,
178
179
  isReadOnlyKey,
@@ -259,8 +260,10 @@ import {
259
260
  trafficSyncResponseSchema,
260
261
  unsupportedKind,
261
262
  validationError,
263
+ visibilityCompareDtoSchema,
262
264
  visibilityStateFromAnswerMentioned,
263
265
  visibilityStatsDtoSchema,
266
+ wilsonInterval,
264
267
  windowCutoff,
265
268
  winnabilityClassLabel,
266
269
  winnabilityClassSchema,
@@ -277,7 +280,7 @@ import {
277
280
  wordpressSchemaDeployResultDtoSchema,
278
281
  wordpressSchemaStatusResultDtoSchema,
279
282
  wordpressStatusDtoSchema
280
- } from "./chunk-IQPMK36Y.js";
283
+ } from "./chunk-IAJ5TQ4S.js";
281
284
 
282
285
  // src/intelligence-service.ts
283
286
  import { eq as eq38, desc as desc18, asc as asc6, and as and30, ne as ne5, or as or6, inArray as inArray14, gte as gte7, lte as lte4 } from "drizzle-orm";
@@ -5647,8 +5650,8 @@ var METRIC_LABELS = {
5647
5650
  WEBSITE_CLICKS: "Website clicks",
5648
5651
  CALL_CLICKS: "Call clicks"
5649
5652
  };
5650
- function metricLabel(metric) {
5651
- return METRIC_LABELS[metric] ?? metric;
5653
+ function metricLabel(metric2) {
5654
+ return METRIC_LABELS[metric2] ?? metric2;
5652
5655
  }
5653
5656
  function formatAmenityList(amenities) {
5654
5657
  if (amenities.length === 1) return amenities[0];
@@ -5742,12 +5745,12 @@ function analyzeGbp(signals) {
5742
5745
  }
5743
5746
  function pickWorstMetricDrop(loc) {
5744
5747
  let worst = null;
5745
- for (const metric of GBP_HEADLINE_METRICS) {
5746
- const delta = loc.metricDeltaPct[metric];
5747
- const prior = loc.metricPrior7d[metric] ?? 0;
5748
+ for (const metric2 of GBP_HEADLINE_METRICS) {
5749
+ const delta = loc.metricDeltaPct[metric2];
5750
+ const prior = loc.metricPrior7d[metric2] ?? 0;
5748
5751
  if (delta == null || delta > -GBP_METRIC_DROP_PCT || prior < GBP_METRIC_MIN_BASELINE) continue;
5749
5752
  if (!worst || delta < worst.deltaPct) {
5750
- worst = { metric, deltaPct: delta, recent: loc.metricRecent7d[metric] ?? 0, prior };
5753
+ worst = { metric: metric2, deltaPct: delta, recent: loc.metricRecent7d[metric2] ?? 0, prior };
5751
5754
  }
5752
5755
  }
5753
5756
  return worst;
@@ -8541,7 +8544,7 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
8541
8544
  });
8542
8545
  if (eligible.length > 0) usable = eligible;
8543
8546
  }
8544
- const metric = computeProviderMetric(usable);
8547
+ const metric2 = computeProviderMetric(usable);
8545
8548
  const queryCount = new Set(usable.map((s) => s.queryId)).size;
8546
8549
  const byProvider = {};
8547
8550
  for (const provider of new Set(usable.map((s) => s.provider))) {
@@ -8550,12 +8553,12 @@ function computeBuckets(snapshots, projectRuns, bucketDays, queryCreatedAt, ment
8550
8553
  buckets.push({
8551
8554
  startDate: startISO,
8552
8555
  endDate: endISO,
8553
- citationRate: metric.citationRate,
8554
- cited: metric.cited,
8555
- total: metric.total,
8556
+ citationRate: metric2.citationRate,
8557
+ cited: metric2.cited,
8558
+ total: metric2.total,
8556
8559
  queryCount,
8557
- mentionRate: metric.mentionRate,
8558
- mentionedCount: metric.mentionedCount,
8560
+ mentionRate: metric2.mentionRate,
8561
+ mentionedCount: metric2.mentionedCount,
8559
8562
  mentionShare: computeMentionShareBucketMetric(usable, mentionShareCompetitors),
8560
8563
  byProvider
8561
8564
  });
@@ -13631,6 +13634,253 @@ function normalizeDomain2(domain) {
13631
13634
 
13632
13635
  // ../api-routes/src/visibility-stats.ts
13633
13636
  import { and as and14, desc as desc8, eq as eq17, inArray as inArray8 } from "drizzle-orm";
13637
+
13638
+ // ../api-routes/src/visibility-compare.ts
13639
+ var VISIBILITY_COMPARE_MIN_RUNS = 5;
13640
+ function basketPairKey(queryId, provider) {
13641
+ return JSON.stringify([queryId, provider]);
13642
+ }
13643
+ function citedHostMatches(citedHost, competitorHost) {
13644
+ return citedHost === competitorHost || citedHost.endsWith(`.${competitorHost}`);
13645
+ }
13646
+ function restrict(snapshots, attribution, pairs) {
13647
+ const out = [];
13648
+ for (const snap of snapshots) {
13649
+ const resolved = resolveCurrentQuery(attribution, snap);
13650
+ if (!resolved) continue;
13651
+ if (!pairs.has(basketPairKey(resolved.id, snap.provider))) continue;
13652
+ out.push({ ...snap, queryId: resolved.id });
13653
+ }
13654
+ return out;
13655
+ }
13656
+ function observed(snapshots, attribution) {
13657
+ const queryIds = /* @__PURE__ */ new Set();
13658
+ const providers = /* @__PURE__ */ new Set();
13659
+ for (const snap of snapshots) {
13660
+ const resolved = resolveCurrentQuery(attribution, snap);
13661
+ if (!resolved) continue;
13662
+ queryIds.add(resolved.id);
13663
+ providers.add(snap.provider);
13664
+ }
13665
+ return { queryIds, providers };
13666
+ }
13667
+ function observedPairs(snapshots, attribution, queryIds) {
13668
+ const pairs = /* @__PURE__ */ new Map();
13669
+ for (const snap of snapshots) {
13670
+ const resolved = resolveCurrentQuery(attribution, snap);
13671
+ if (!resolved || !queryIds.has(resolved.id)) continue;
13672
+ const pair = { queryId: resolved.id, provider: snap.provider };
13673
+ pairs.set(basketPairKey(pair.queryId, pair.provider), pair);
13674
+ }
13675
+ return pairs;
13676
+ }
13677
+ function period(numerator, denominator) {
13678
+ const ci = wilsonInterval(numerator, denominator);
13679
+ return {
13680
+ point: denominator > 0 ? Math.round(numerator / denominator * 1e4) / 1e4 : null,
13681
+ ciLow: ci ? ci.low : null,
13682
+ ciHigh: ci ? ci.high : null,
13683
+ numerator,
13684
+ denominator
13685
+ };
13686
+ }
13687
+ function ciOverlap(a, b) {
13688
+ if (a.ciLow === null || a.ciHigh === null || b.ciLow === null || b.ciHigh === null) return true;
13689
+ return a.ciLow <= b.ciHigh && b.ciLow <= a.ciHigh;
13690
+ }
13691
+ function metric(key, label, driftRobust, from, to, continuityBlock) {
13692
+ const verdict = continuityBlock ?? (from.denominator === 0 || to.denominator === 0 ? "insufficient-data" : ciOverlap(from, to) ? "within-noise" : "moved");
13693
+ const direction = from.point === null || to.point === null ? null : to.point > from.point ? "up" : to.point < from.point ? "down" : "flat";
13694
+ const rateRatio = from.point === null || from.point === 0 || to.point === null ? null : Math.round(to.point / from.point * 100) / 100;
13695
+ return { key, label, driftRobust, from, to, rateRatio, direction, verdict };
13696
+ }
13697
+ function countPeriod(snaps, competitors2) {
13698
+ let checked = 0;
13699
+ let mentioned = 0;
13700
+ let cited = 0;
13701
+ let competitorCited = 0;
13702
+ const perProvider = /* @__PURE__ */ new Map();
13703
+ const models = /* @__PURE__ */ new Map();
13704
+ const modelUnknownProviders = /* @__PURE__ */ new Set();
13705
+ const mentionedQueries = /* @__PURE__ */ new Set();
13706
+ const competitorHosts = competitors2.map((c) => hostOf(c.domain)).filter((h) => h !== null && h.length > 0);
13707
+ for (const snap of snaps) {
13708
+ const isMentioned = snap.answerMentioned === true;
13709
+ if (snap.answerMentioned === true || snap.answerMentioned === false) checked += 1;
13710
+ if (isMentioned) {
13711
+ mentioned += 1;
13712
+ mentionedQueries.add(snap.queryId);
13713
+ }
13714
+ const isCited = snap.citationState === CitationStates.cited;
13715
+ if (isCited) cited += 1;
13716
+ const pp = perProvider.get(snap.provider) ?? { checked: 0, mentioned: 0, cited: 0 };
13717
+ if (snap.answerMentioned === true || snap.answerMentioned === false) pp.checked += 1;
13718
+ if (isMentioned) pp.mentioned += 1;
13719
+ if (isCited) pp.cited += 1;
13720
+ perProvider.set(snap.provider, pp);
13721
+ if (snap.model) {
13722
+ const set = models.get(snap.provider) ?? /* @__PURE__ */ new Set();
13723
+ set.add(snap.model);
13724
+ models.set(snap.provider, set);
13725
+ } else {
13726
+ modelUnknownProviders.add(snap.provider);
13727
+ }
13728
+ if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
13729
+ const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
13730
+ for (const compHost of competitorHosts) {
13731
+ if (citedHosts.some((ch) => citedHostMatches(ch, compHost))) competitorCited += 1;
13732
+ }
13733
+ }
13734
+ }
13735
+ const mentionShare = buildMentionShare(
13736
+ snaps.map((s) => ({ projectMentioned: s.answerMentioned === true, answerText: s.answerText })),
13737
+ { competitors: competitors2 }
13738
+ );
13739
+ return {
13740
+ checked,
13741
+ mentioned,
13742
+ total: snaps.length,
13743
+ cited,
13744
+ projectCited: cited,
13745
+ competitorCited,
13746
+ queriesMentioned: mentionedQueries.size,
13747
+ perProvider,
13748
+ models,
13749
+ modelUnknownProviders,
13750
+ mentionShare,
13751
+ competitors: mentionShare.breakdown.perCompetitor.map((c) => ({ domain: c.domain, mentions: c.mentionSnapshots }))
13752
+ };
13753
+ }
13754
+ function computeVisibilityCompare(input) {
13755
+ const attribution = buildQueryAttribution(input.queries);
13756
+ const fromObs = observed(input.from.snapshots, attribution);
13757
+ const toObs = observed(input.to.snapshots, attribution);
13758
+ const queriesObservedBoth = new Set([...fromObs.queryIds].filter((q) => toObs.queryIds.has(q)));
13759
+ const fromPairs = observedPairs(input.from.snapshots, attribution, queriesObservedBoth);
13760
+ const toPairs = observedPairs(input.to.snapshots, attribution, queriesObservedBoth);
13761
+ const pairsBoth = new Map([...fromPairs].filter(([key]) => toPairs.has(key)));
13762
+ const candidateProviders = new Set([...pairsBoth.values()].map((pair) => pair.provider));
13763
+ const fromCandidateSnaps = restrict(input.from.snapshots, attribution, pairsBoth);
13764
+ const toCandidateSnaps = restrict(input.to.snapshots, attribution, pairsBoth);
13765
+ const fromCandidateCounts = countPeriod(fromCandidateSnaps, input.competitors);
13766
+ const toCandidateCounts = countPeriod(toCandidateSnaps, input.competitors);
13767
+ const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
13768
+ const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13769
+ const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13770
+ const fromModelUnknown = fromCandidateCounts.modelUnknownProviders.has(provider) || fromModels.length === 0;
13771
+ const toModelUnknown = toCandidateCounts.modelUnknownProviders.has(provider) || toModels.length === 0;
13772
+ const status = fromModelUnknown || toModelUnknown ? "model-unknown" : fromModels.length === 1 && toModels.length === 1 && fromModels[0] === toModels[0] ? "included" : "model-discontinuous";
13773
+ return { provider, status, fromModels, toModels };
13774
+ });
13775
+ const providersBoth = new Set(
13776
+ continuityProviders.filter((provider) => provider.status === "included").map((provider) => provider.provider)
13777
+ );
13778
+ const continuityStatus = candidateProviders.size === 0 ? "insufficient-data" : providersBoth.size > 0 ? "comparable" : continuityProviders.every((provider) => provider.status === "model-unknown") ? "model-unknown" : "model-discontinuous";
13779
+ const continuityBlock = continuityStatus === "model-discontinuous" || continuityStatus === "model-unknown" ? continuityStatus : null;
13780
+ const comparedPairs = new Map([...pairsBoth].filter(([, pair]) => providersBoth.has(pair.provider)));
13781
+ const queriesBoth = new Set([...comparedPairs.values()].map((pair) => pair.queryId));
13782
+ const excludedProviders = [.../* @__PURE__ */ new Set([...fromObs.providers, ...toObs.providers])].filter((p) => !providersBoth.has(p)).sort((a, b) => a.localeCompare(b));
13783
+ const fromSnaps = restrict(input.from.snapshots, attribution, comparedPairs);
13784
+ const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
13785
+ const fromCounts = countPeriod(fromSnaps, input.competitors);
13786
+ const toCounts = countPeriod(toSnaps, input.competitors);
13787
+ const shareCounts = (c) => ({
13788
+ proj: c.mentionShare.breakdown.projectMentionSnapshots,
13789
+ comp: c.mentionShare.breakdown.competitorMentionSnapshots
13790
+ });
13791
+ const fromShare = shareCounts(fromCounts);
13792
+ const toShare = shareCounts(toCounts);
13793
+ const metrics = [
13794
+ metric(
13795
+ "mention-share-of-voice",
13796
+ "Named share of voice",
13797
+ true,
13798
+ period(fromShare.proj, fromShare.proj + fromShare.comp),
13799
+ period(toShare.proj, toShare.proj + toShare.comp),
13800
+ continuityBlock
13801
+ ),
13802
+ // Share of voice is undefined without a competitive frame: with zero
13803
+ // configured competitors the denominator degenerates to the project's own
13804
+ // count and the metric would fabricate a 100%. buildMentionShare already
13805
+ // refuses that on the mention side ("reporting 100% would mislead");
13806
+ // degrade the cited side identically to a 0/0 period -> insufficient-data.
13807
+ metric(
13808
+ "cited-share-of-voice",
13809
+ "Cited share of voice",
13810
+ true,
13811
+ input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : period(0, 0),
13812
+ input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : period(0, 0),
13813
+ continuityBlock
13814
+ ),
13815
+ metric(
13816
+ "mention-rate",
13817
+ "Named rate",
13818
+ false,
13819
+ period(fromCounts.mentioned, fromCounts.checked),
13820
+ period(toCounts.mentioned, toCounts.checked),
13821
+ continuityBlock
13822
+ ),
13823
+ metric(
13824
+ "cited-rate",
13825
+ "Cited rate",
13826
+ false,
13827
+ period(fromCounts.cited, fromCounts.total),
13828
+ period(toCounts.cited, toCounts.total),
13829
+ continuityBlock
13830
+ )
13831
+ ];
13832
+ const modelChanges = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
13833
+ const fromModels = [...fromCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13834
+ const toModels = [...toCandidateCounts.models.get(provider) ?? /* @__PURE__ */ new Set()].sort((a, b) => a.localeCompare(b));
13835
+ return { provider, fromModels, toModels };
13836
+ }).filter(
13837
+ (c) => c.fromModels.length > 0 && c.toModels.length > 0 && JSON.stringify(c.fromModels) !== JSON.stringify(c.toModels)
13838
+ );
13839
+ const byProvider = [...providersBoth].sort((a, b) => a.localeCompare(b)).map((provider) => ({
13840
+ provider,
13841
+ from: fromCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 },
13842
+ to: toCounts.perProvider.get(provider) ?? { checked: 0, mentioned: 0, cited: 0 }
13843
+ }));
13844
+ return {
13845
+ project: input.project,
13846
+ from: {
13847
+ month: input.from.month,
13848
+ since: input.from.since,
13849
+ until: input.from.until,
13850
+ runCount: input.from.runCount,
13851
+ lowRunCount: input.from.runCount < VISIBILITY_COMPARE_MIN_RUNS
13852
+ },
13853
+ to: {
13854
+ month: input.to.month,
13855
+ since: input.to.since,
13856
+ until: input.to.until,
13857
+ runCount: input.to.runCount,
13858
+ lowRunCount: input.to.runCount < VISIBILITY_COMPARE_MIN_RUNS
13859
+ },
13860
+ basket: {
13861
+ queryCount: queriesBoth.size,
13862
+ excludedFromOnly: [...fromObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
13863
+ excludedToOnly: [...toObs.queryIds].filter((q) => !queriesBoth.has(q)).length,
13864
+ providers: [...providersBoth].sort((a, b) => a.localeCompare(b)),
13865
+ excludedProviders
13866
+ },
13867
+ metrics,
13868
+ queriesMentioned: {
13869
+ from: { count: fromCounts.queriesMentioned, of: queriesBoth.size },
13870
+ to: { count: toCounts.queriesMentioned, of: queriesBoth.size }
13871
+ },
13872
+ byProvider,
13873
+ modelChanges,
13874
+ continuity: {
13875
+ status: continuityStatus,
13876
+ comparedProviders: [...providersBoth].sort((a, b) => a.localeCompare(b)),
13877
+ providers: continuityProviders
13878
+ },
13879
+ competitors: { from: fromCounts.competitors, to: toCounts.competitors }
13880
+ };
13881
+ }
13882
+
13883
+ // ../api-routes/src/visibility-stats.ts
13634
13884
  function round42(value) {
13635
13885
  return Math.round(value * 1e4) / 1e4;
13636
13886
  }
@@ -13849,6 +14099,62 @@ async function visibilityStatsRoutes(app) {
13849
14099
  };
13850
14100
  return reply.send(response);
13851
14101
  });
14102
+ app.get("/projects/:name/visibility-compare", async (request, reply) => {
14103
+ const project = resolveProject(app.db, request.params.name);
14104
+ const { from: fromRaw, to: toRaw } = request.query;
14105
+ if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
14106
+ if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
14107
+ let fromBounds;
14108
+ let toBounds;
14109
+ try {
14110
+ fromBounds = calendarMonthBounds(fromRaw);
14111
+ } catch (err) {
14112
+ throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
14113
+ }
14114
+ try {
14115
+ toBounds = calendarMonthBounds(toRaw);
14116
+ } catch (err) {
14117
+ throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
14118
+ }
14119
+ if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
14120
+ throw validationError('"from" must be a month strictly before "to"');
14121
+ }
14122
+ const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq17(queries.projectId, project.id)).all();
14123
+ const competitorRows = app.db.select({ domain: competitors.domain }).from(competitors).where(eq17(competitors.projectId, project.id)).all();
14124
+ const competitorInputs = competitorRows.map((c) => ({
14125
+ domain: c.domain,
14126
+ brandTokens: [brandLabelFromDomain(c.domain)].filter((t) => t.length >= 3)
14127
+ }));
14128
+ const loadMonth = (bounds) => {
14129
+ const sinceMs = Date.parse(bounds.since);
14130
+ const untilMs = Date.parse(bounds.until);
14131
+ const monthRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and14(eq17(runs.projectId, project.id), eq17(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
14132
+ (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
14133
+ );
14134
+ const runIds = monthRuns.map((r) => r.id);
14135
+ const snapshots = runIds.length > 0 && projectQueries.length > 0 ? app.db.select({
14136
+ queryId: querySnapshots.queryId,
14137
+ queryText: querySnapshots.queryText,
14138
+ provider: querySnapshots.provider,
14139
+ model: querySnapshots.model,
14140
+ citationState: querySnapshots.citationState,
14141
+ answerMentioned: querySnapshots.answerMentioned,
14142
+ answerText: querySnapshots.answerText,
14143
+ citedDomains: querySnapshots.citedDomains
14144
+ }).from(querySnapshots).where(inArray8(querySnapshots.runId, runIds)).all() : [];
14145
+ return { runCount: monthRuns.length, snapshots };
14146
+ };
14147
+ const fromMonth = loadMonth(fromBounds);
14148
+ const toMonth = loadMonth(toBounds);
14149
+ const dto = computeVisibilityCompare({
14150
+ project: project.name,
14151
+ queries: projectQueries,
14152
+ competitors: competitorInputs,
14153
+ from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
14154
+ to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
14155
+ });
14156
+ return reply.send(dto);
14157
+ });
13852
14158
  }
13853
14159
 
13854
14160
  // ../api-routes/src/composites.ts
@@ -14654,6 +14960,7 @@ var SCHEMA_TABLE = {
14654
14960
  TrafficSourceListResponse: trafficSourceListResponseSchema,
14655
14961
  TrafficStatusResponse: trafficStatusResponseSchema,
14656
14962
  TrafficSyncResponse: trafficSyncResponseSchema,
14963
+ VisibilityCompareDto: visibilityCompareDtoSchema,
14657
14964
  VisibilityStatsDto: visibilityStatsDtoSchema,
14658
14965
  WordpressAuditPageDto: wordpressAuditPageDtoSchema,
14659
14966
  WordpressBulkMetaResultDto: wordpressBulkMetaResultDtoSchema,
@@ -14930,6 +15237,20 @@ var monthQueryParameter = {
14930
15237
  description: `Aggregate a single calendar month (YYYY-MM), expanded to that month's inclusive UTC bounds. Mutually exclusive with "since"/"until"/"lastRuns".`,
14931
15238
  schema: stringSchema
14932
15239
  };
15240
+ var compareFromQueryParameter = {
15241
+ name: "from",
15242
+ in: "query",
15243
+ required: true,
15244
+ description: 'Earlier calendar month (YYYY-MM) \u2014 the baseline period. Must be strictly before "to".',
15245
+ schema: stringSchema
15246
+ };
15247
+ var compareToQueryParameter = {
15248
+ name: "to",
15249
+ in: "query",
15250
+ required: true,
15251
+ description: 'Later calendar month (YYYY-MM) \u2014 the reporting period compared against "from".',
15252
+ schema: stringSchema
15253
+ };
14933
15254
  var shareOfVoiceQueryParameter = {
14934
15255
  name: "shareOfVoice",
14935
15256
  in: "query",
@@ -15705,6 +16026,19 @@ var routeCatalog = [
15705
16026
  404: errorResponse("Project not found.")
15706
16027
  }
15707
16028
  },
16029
+ {
16030
+ method: "get",
16031
+ path: "/api/v1/projects/{name}/visibility-compare",
16032
+ summary: "Compare AEO visibility month over month",
16033
+ description: "Statistically honest month-over-month AEO comparison in one call. PRIMARY metric is share of voice (brand vs competitor mentions in the same answers), which is less exposed to broad model-wide naming propensity than an absolute rate but never bypasses model continuity. Rates are pooled per-snapshot over each month (invariant to sweep count), restricted to query/provider pairs present in BOTH months, and then restricted again to providers with exactly one known, identical configured model id in both months. `continuity` reports every provider and its model evidence; changed, mixed mid-month, or legacy-unknown models are excluded. When no provider remains, metrics return a continuity-blocked verdict rather than a directional call. `from` must be a month strictly before `to`. A silent upstream version bump under an unchanged configured id remains undetectable.",
16034
+ tags: ["analytics"],
16035
+ parameters: [nameParameter, compareFromQueryParameter, compareToQueryParameter],
16036
+ responses: {
16037
+ 200: jsonResponse("Month-over-month visibility comparison returned.", "VisibilityCompareDto"),
16038
+ 400: errorResponse("Invalid or missing from/to months."),
16039
+ 404: errorResponse("Project not found.")
16040
+ }
16041
+ },
15708
16042
  {
15709
16043
  method: "get",
15710
16044
  path: "/api/v1/projects/{name}/snapshots/diff",
@@ -19516,12 +19850,12 @@ function computeWindowDelta(rows, referenceDate) {
19516
19850
  }
19517
19851
  const deltaPct = {};
19518
19852
  const metrics = /* @__PURE__ */ new Set([...Object.keys(recent7d), ...Object.keys(prior7d)]);
19519
- for (const metric of metrics) {
19520
- const recent = recent7d[metric] ?? 0;
19521
- const prior = prior7d[metric] ?? 0;
19522
- recent7d[metric] = recent;
19523
- prior7d[metric] = prior;
19524
- deltaPct[metric] = prior === 0 ? null : Math.round((recent - prior) / prior * 100);
19853
+ for (const metric2 of metrics) {
19854
+ const recent = recent7d[metric2] ?? 0;
19855
+ const prior = prior7d[metric2] ?? 0;
19856
+ recent7d[metric2] = recent;
19857
+ prior7d[metric2] = prior;
19858
+ deltaPct[metric2] = prior === 0 ? null : Math.round((recent - prior) / prior * 100);
19525
19859
  }
19526
19860
  return { recent7d, prior7d, deltaPct };
19527
19861
  }