@canonry/canonry 5.20.4 → 5.21.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 (32) hide show
  1. package/assets/agent-workspace/skills/aero/references/portfolio-analysis.md +1 -1
  2. package/assets/agent-workspace/skills/aero/references/reporting.md +9 -4
  3. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +3 -0
  4. package/assets/assets/{AuditHistoryPanel-BSh3Oezj.js → AuditHistoryPanel-CjxWA3lx.js} +1 -1
  5. package/assets/assets/{BacklinksPage-DQ59dhCx.js → BacklinksPage-Bdd164Up.js} +1 -1
  6. package/assets/assets/{HistoryPage-BBvgid3o.js → HistoryPage-BvJmE-kP.js} +1 -1
  7. package/assets/assets/{MeasurementPropertyPage-Dmb18JbT.js → MeasurementPropertyPage-HuP6RMZ-.js} +1 -1
  8. package/assets/assets/{ProjectPage-CPg0q2Dy.js → ProjectPage-DOvXVbyy.js} +1 -1
  9. package/assets/assets/{RunRow-BK8sZCID.js → RunRow-Cnb3JOBw.js} +1 -1
  10. package/assets/assets/{RunsPage-CIcVBTdP.js → RunsPage-Dvz1JYIm.js} +1 -1
  11. package/assets/assets/{SettingsPage-Bc3aR-gz.js → SettingsPage-DYGvphkg.js} +1 -1
  12. package/assets/assets/{SiteHealthSection-FF14aIXT.js → SiteHealthSection-CUNiKaod.js} +3 -3
  13. package/assets/assets/{TrafficPage-C_qoeGDl.js → TrafficPage-Bf4Tq95q.js} +1 -1
  14. package/assets/assets/{TrafficSourceDetailPage-B3i_B2MI.js → TrafficSourceDetailPage-CIuLWZgT.js} +1 -1
  15. package/assets/assets/{extract-error-message-BisUn6nN.js → extract-error-message-DZex1e4H.js} +1 -1
  16. package/assets/assets/{index-Dht7JNX5.js → index-CeXW-lzj.js} +21 -21
  17. package/assets/assets/{react-sigma_core.esm.min-tWhS09SS.js → react-sigma_core.esm.min-CxvtwEkv.js} +1 -1
  18. package/assets/assets/{v2-overview-adapter-CAoeWKQJ.js → v2-overview-adapter-Bh4qIzLp.js} +1 -1
  19. package/assets/index.html +1 -1
  20. package/dist/{chunk-TOSPZ7I5.js → chunk-2LO7FLVD.js} +48 -5
  21. package/dist/{chunk-NSKFW3NF.js → chunk-FAVYLZQV.js} +318 -80
  22. package/dist/{chunk-PXVJ3P5G.js → chunk-FRWZWJB3.js} +4 -4
  23. package/dist/{chunk-TWLJRAWA.js → chunk-HDJHQPW3.js} +2 -2
  24. package/dist/{chunk-CVUR3O5G.js → chunk-MEAKE5W7.js} +3 -3
  25. package/dist/{chunk-P73YBGZP.js → chunk-NFN5GSEJ.js} +7 -7
  26. package/dist/{chunk-JJPZ4OQ2.js → chunk-WUT3G2GM.js} +5 -3
  27. package/dist/cli.js +69 -12
  28. package/dist/{demo-server-FH2K6TCD.js → demo-server-7BVVVXPM.js} +4 -4
  29. package/dist/index.js +6 -6
  30. package/dist/{intelligence-service-QHSR2H7F.js → intelligence-service-3UUKRAZ5.js} +2 -2
  31. package/dist/mcp.js +4 -4
  32. package/package.json +10 -10
@@ -417,6 +417,7 @@ import {
417
417
  isTemplateLinkRatio,
418
418
  isTrendBaseline,
419
419
  isVertexGroundingRedirect,
420
+ isVisibilityCompareClassMetric,
420
421
  isoDateDaysBeforeInTimeZone,
421
422
  keywordDtoSchema,
422
423
  keywordGenerateRequestSchema,
@@ -771,6 +772,7 @@ import {
771
772
  userListDtoSchema,
772
773
  validationError,
773
774
  visibilityCompareDtoSchema,
775
+ visibilityCompareSelectionSchema,
774
776
  visibilityReportQuerySchema,
775
777
  visibilityReportResponseSchema,
776
778
  visibilityStateFromAnswerMentioned,
@@ -792,7 +794,7 @@ import {
792
794
  wordpressSchemaDeployResultDtoSchema,
793
795
  wordpressSchemaStatusResultDtoSchema,
794
796
  wordpressStatusDtoSchema
795
- } from "./chunk-TOSPZ7I5.js";
797
+ } from "./chunk-2LO7FLVD.js";
796
798
 
797
799
  // src/intelligence-service.ts
798
800
  import { eq as eq71, desc as desc32, asc as asc12, and as and60, ne as ne11, or as or18, inArray as inArray27, gte as gte18, lte as lte15, isNull as isNull13, sql as sql28, exists } from "drizzle-orm";
@@ -16046,6 +16048,17 @@ function modelEvidenceStatesEqual(a, b) {
16046
16048
  if (a.status !== "mixed" || b.status !== "mixed") return false;
16047
16049
  return a.includesUnknown === b.includesUnknown && a.models.length === b.models.length && a.models.every((model, index2) => model === b.models[index2]);
16048
16050
  }
16051
+ function compareModelContinuity(from, to) {
16052
+ const fromEvidence = classifyModelEvidence(from);
16053
+ const toEvidence = classifyModelEvidence(to);
16054
+ const ids = (evidence) => evidence.status === "known" ? [evidence.model] : evidence.status === "mixed" ? evidence.models : [];
16055
+ const unknown = (evidence) => evidence.status === "unknown" || evidence.status === "mixed" && evidence.includesUnknown;
16056
+ return {
16057
+ status: unknown(fromEvidence) || unknown(toEvidence) ? "model-unknown" : fromEvidence.status === "known" && toEvidence.status === "known" && fromEvidence.model === toEvidence.model ? "included" : "model-discontinuous",
16058
+ fromModels: ids(fromEvidence),
16059
+ toModels: ids(toEvidence)
16060
+ };
16061
+ }
16049
16062
 
16050
16063
  // ../api-routes/src/competitor-landscape.ts
16051
16064
  var COMPETITOR_LANDSCAPE_RANKED_ROW_LIMIT = 100;
@@ -24412,6 +24425,50 @@ function buildVisibilityReport(input) {
24412
24425
  populations
24413
24426
  });
24414
24427
  }
24428
+ function visibilityComparisonPopulation(run, selection) {
24429
+ const targets = targetMap(run.definition);
24430
+ const byClass = ALL_CLASSES.map((queryClass) => ({ queryClass, candidates: candidatesFor(run, selection, queryClass).candidates }));
24431
+ const pooled = /* @__PURE__ */ new Map();
24432
+ for (const { candidates } of byClass) {
24433
+ for (const candidate of candidates) {
24434
+ const existing = pooled.get(candidate.slot.id);
24435
+ if (existing) existing.edges.push(...candidate.edges);
24436
+ else pooled.set(candidate.slot.id, { ...candidate, edges: [...candidate.edges] });
24437
+ }
24438
+ }
24439
+ const cohortKeys = new Map([...pooled.values()].map((candidate) => [candidate.slot.id, JSON.stringify({
24440
+ definition: run.comparableDefinitionIds.at(-1) ?? run.definitionId,
24441
+ query: candidate.slot.query,
24442
+ execution: candidate.slot.executionId,
24443
+ location: candidate.slot.location,
24444
+ edges: candidate.edges.map((edge) => [edge.targetKey, edge.queryClass, [...edge.marketKeys].sort()]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
24445
+ })]));
24446
+ const row = (candidate, queryClass) => {
24447
+ const signals = targetValues(candidate, targets);
24448
+ return {
24449
+ queryId: candidate.slot.queryId ?? candidate.slot.queryKey,
24450
+ queryText: candidate.slot.query,
24451
+ provider: candidate.slot.provider,
24452
+ model: candidate.observation.model,
24453
+ answerMentioned: signals.mention,
24454
+ mentionApplicable: candidate.edges.some((edge) => targets.get(edge.targetKey)?.mentionEligible === true),
24455
+ citation: citationForCoverage(candidate, targets),
24456
+ answerText: candidate.observation.answerText,
24457
+ competitorDomains: [...new Set(candidate.edges.flatMap((edge) => edge.competitorDomains))],
24458
+ competitorMentions: candidate.observation.competitorMentionDomains.filter((domain) => candidate.edges.some((edge) => edge.competitorDomains.includes(domain))),
24459
+ competitorCitations: candidate.observation.competitorCitationDomains.filter((domain) => candidate.edges.some((edge) => edge.competitorDomains.includes(domain))),
24460
+ cohortKey: cohortKeys.get(candidate.slot.id),
24461
+ queryClass: queryClass === "unknown" ? null : queryClass
24462
+ };
24463
+ };
24464
+ return {
24465
+ snapshots: [...pooled.values()].filter((candidate) => candidate.observation !== null).map((candidate) => {
24466
+ const classes = new Set(candidate.edges.map((edge) => edge.queryClass));
24467
+ return row(candidate, classes.size === 1 ? candidate.edges[0].queryClass : null);
24468
+ }),
24469
+ classSnapshots: byClass.flatMap(({ queryClass, candidates }) => candidates.filter((candidate) => candidate.observation !== null).map((candidate) => row(candidate, queryClass)))
24470
+ };
24471
+ }
24415
24472
 
24416
24473
  // ../api-routes/src/visibility-report.ts
24417
24474
  function normalizedText3(value) {
@@ -24928,6 +24985,13 @@ function comparableVersionIds(rows, versionId) {
24928
24985
  }
24929
24986
  return ids;
24930
24987
  }
24988
+ function comparableChainHeads(rows, rowsById) {
24989
+ const heads = /* @__PURE__ */ new Map();
24990
+ for (const row of [...rows].sort((left, right) => left.revision - right.revision)) {
24991
+ for (const id of comparableVersionIds(rowsById, row.id)) heads.set(id, row.id);
24992
+ }
24993
+ return heads;
24994
+ }
24931
24995
  function assignmentSignature(plan, assignment) {
24932
24996
  const node = plan.executionNodes.find((candidate) => candidate.stableKey === assignment.executionNodeKey);
24933
24997
  if (!node) throw new Error(`Frozen assignment references missing execution node ${assignment.executionNodeKey}`);
@@ -25082,6 +25146,37 @@ async function visibilityReportRoutes(app) {
25082
25146
  async (request) => readVisibilityReport(app.db, resolveProject(app.db, request.params.name), request.query)
25083
25147
  );
25084
25148
  }
25149
+ function readVisibilityComparisonRuns(db, projectId, since, until) {
25150
+ const sourceRuns = db.select().from(runs).where(and21(
25151
+ eq26(runs.projectId, projectId),
25152
+ eq26(runs.kind, RunKinds["answer-visibility"]),
25153
+ inArray10(runs.status, [RunStatuses.completed, RunStatuses.partial]),
25154
+ notProbeRun(),
25155
+ isNotNull(runs.measurementPlanVersionId),
25156
+ isNull5(runs.measurementScope),
25157
+ gte4(runs.createdAt, since),
25158
+ lte4(runs.createdAt, until)
25159
+ )).all();
25160
+ const versionRows = db.select().from(measurementPlanVersions).where(eq26(measurementPlanVersions.projectId, projectId)).all();
25161
+ const rowsById = new Map(versionRows.map((version) => [version.id, version]));
25162
+ const versions = parseV2Versions(versionRows);
25163
+ const heads = comparableChainHeads(versionRows, rowsById);
25164
+ const byRun = loadVisibilitySnapshots(db, sourceRuns.map((run) => run.id));
25165
+ let unavailable5 = false;
25166
+ const selected = sourceRuns.flatMap((run) => {
25167
+ const source = versions.get(run.measurementPlanVersionId);
25168
+ if (!source) {
25169
+ unavailable5 = true;
25170
+ return [];
25171
+ }
25172
+ const presentation = versions.get(heads.get(source.row.id) ?? source.row.id) ?? source;
25173
+ const snapshots = byRun.get(run.id) ?? [];
25174
+ const models = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot.model]));
25175
+ const materialized = advancedRun(run, presentation.row, presentation.plan, snapshots, comparableVersionIds(rowsById, presentation.row.id));
25176
+ return [{ ...materialized, observations: materialized.observations.map((observation) => ({ ...observation, model: models.get(observation.answerId) ?? null })) }];
25177
+ });
25178
+ return { runs: selected, unavailable: unavailable5 };
25179
+ }
25085
25180
 
25086
25181
  // ../api-routes/src/query-tracking.ts
25087
25182
  import crypto21 from "crypto";
@@ -34822,15 +34917,15 @@ import { and as and35, desc as desc18, eq as eq39, inArray as inArray18 } from "
34822
34917
 
34823
34918
  // ../api-routes/src/visibility-compare.ts
34824
34919
  var VISIBILITY_COMPARE_MIN_RUNS = 5;
34825
- function basketPairKey(queryId, provider) {
34826
- return JSON.stringify([queryId, provider]);
34920
+ function basketPairKey(queryId, provider, cohortKey) {
34921
+ return JSON.stringify([queryId, provider, cohortKey ?? null]);
34827
34922
  }
34828
34923
  function restrict(snapshots, attribution, pairs) {
34829
34924
  const out = [];
34830
34925
  for (const snap of snapshots) {
34831
34926
  const resolved = resolveCurrentQuery(attribution, snap);
34832
34927
  if (!resolved) continue;
34833
- if (!pairs.has(basketPairKey(resolved.id, snap.provider))) continue;
34928
+ if (!pairs.has(basketPairKey(resolved.id, snap.provider, snap.cohortKey))) continue;
34834
34929
  out.push({ ...snap, queryId: resolved.id });
34835
34930
  }
34836
34931
  return out;
@@ -34851,8 +34946,8 @@ function observedPairs(snapshots, attribution, queryIds) {
34851
34946
  for (const snap of snapshots) {
34852
34947
  const resolved = resolveCurrentQuery(attribution, snap);
34853
34948
  if (!resolved || !queryIds.has(resolved.id)) continue;
34854
- const pair = { queryId: resolved.id, provider: snap.provider };
34855
- pairs.set(basketPairKey(pair.queryId, pair.provider), pair);
34949
+ const pair = { queryId: resolved.id, provider: snap.provider, cohortKey: snap.cohortKey };
34950
+ pairs.set(basketPairKey(pair.queryId, pair.provider, pair.cohortKey), pair);
34856
34951
  }
34857
34952
  return pairs;
34858
34953
  }
@@ -34899,7 +34994,9 @@ function modelEvidenceOf(snaps) {
34899
34994
  function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable, projectMatcher) {
34900
34995
  let checked = 0;
34901
34996
  let mentioned = 0;
34997
+ let total = 0;
34902
34998
  let cited = 0;
34999
+ let citationUnknown = 0;
34903
35000
  let competitorCited = 0;
34904
35001
  const perProvider = /* @__PURE__ */ new Map();
34905
35002
  const mentionedQueries = /* @__PURE__ */ new Set();
@@ -34911,21 +35008,26 @@ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable,
34911
35008
  mentioned += 1;
34912
35009
  mentionedQueries.add(snap.queryId);
34913
35010
  }
34914
- const isCited = snap.citationState === CitationStates.cited;
35011
+ const citationKnown = snap.citationChecked !== false;
35012
+ if (citationKnown) total += 1;
35013
+ else citationUnknown += 1;
35014
+ const isCited = citationKnown && snap.citationState === CitationStates.cited;
34915
35015
  if (isCited) cited += 1;
34916
35016
  const pp = perProvider.get(snap.provider) ?? { checked: 0, mentioned: 0, cited: 0 };
34917
35017
  if (snap.answerMentioned === true || snap.answerMentioned === false) pp.checked += 1;
34918
35018
  if (isMentioned) pp.mentioned += 1;
34919
35019
  if (isCited) pp.cited += 1;
34920
35020
  perProvider.set(snap.provider, pp);
34921
- if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
35021
+ if (!citationKnown) continue;
35022
+ if (snap.competitorCitations !== void 0) competitorCited += snap.competitorCitations.length;
35023
+ else if (competitorHosts.length > 0 && snap.citedDomains.length > 0) {
34922
35024
  const citedHosts = snap.citedDomains.map((d) => hostOf(d)).filter((h) => h !== null && h.length > 0);
34923
35025
  for (const compHost of competitorHosts) {
34924
35026
  if (citedHosts.some((ch) => hostMatchesDomain(ch, compHost))) competitorCited += 1;
34925
35027
  }
34926
35028
  }
34927
35029
  }
34928
- const mentionShare = buildMentionShare(
35030
+ let mentionShare = buildMentionShare(
34929
35031
  snaps.map((s) => ({
34930
35032
  // Share uses current identity; named-rate counts above deliberately keep
34931
35033
  // their historical persisted-boolean semantics.
@@ -34935,11 +35037,26 @@ function countPeriod(snaps, competitors2, queryClassOf, classificationAvailable,
34935
35037
  })),
34936
35038
  { competitors: competitors2, classificationAvailable }
34937
35039
  );
35040
+ if (snaps.some((snapshot) => snapshot.competitorMentions !== void 0)) {
35041
+ const selected = snaps.filter((snapshot) => (!classificationAvailable || queryClassOf(snapshot) === "non-brand") && snapshot.answerMentioned !== null);
35042
+ const perCompetitor = competitors2.map((competitor) => ({
35043
+ domain: competitor.domain,
35044
+ mentionSnapshots: selected.filter((snapshot) => snapshot.competitorMentions?.includes(competitor.domain)).length
35045
+ })).filter((row) => row.mentionSnapshots > 0).sort((a, b) => b.mentionSnapshots - a.mentionSnapshots || (a.domain < b.domain ? -1 : 1));
35046
+ mentionShare = { ...mentionShare, breakdown: {
35047
+ ...mentionShare.breakdown,
35048
+ projectMentionSnapshots: selected.filter((snapshot) => snapshot.answerMentioned === true).length,
35049
+ competitorMentionSnapshots: perCompetitor.reduce((total2, competitor) => total2 + competitor.mentionSnapshots, 0),
35050
+ perCompetitor
35051
+ } };
35052
+ }
34938
35053
  return {
34939
35054
  checked,
34940
35055
  mentioned,
34941
- total: snaps.length,
35056
+ total,
34942
35057
  cited,
35058
+ citationUnknown,
35059
+ citationEvidence: snaps.some((snapshot) => snapshot.citationChecked !== void 0),
34943
35060
  projectCited: cited,
34944
35061
  competitorCited,
34945
35062
  queriesMentioned: mentionedQueries.size,
@@ -34953,15 +35070,12 @@ function modelIds(evidence) {
34953
35070
  if (evidence.status === "mixed") return evidence.models;
34954
35071
  return [];
34955
35072
  }
34956
- function isUnknownModelEvidence(evidence) {
34957
- return evidence.status === "unknown" || evidence.status === "mixed" && evidence.includesUnknown;
34958
- }
34959
35073
  function computeVisibilityCompare(input) {
34960
35074
  const attribution = buildQueryAttribution(input.queries);
34961
35075
  const classifier = compileQueryClassifier(input.brandNames ?? []);
34962
- const classificationAvailable = classifier !== null;
35076
+ const classificationAvailable = input.frozenClassification === true || classifier !== null;
34963
35077
  const queryTextById = new Map(input.queries.map((q) => [q.id, q.query]));
34964
- const queryClassOf = (snap) => classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
35078
+ const queryClassOf = (snap) => input.frozenClassification ? snap.queryClass ?? null : classifier ? classifier.classify(queryTextById.get(snap.queryId) ?? snap.queryText) : null;
34965
35079
  const projectMatcher = compileBrandAliases(input.brandNames ?? []);
34966
35080
  const fromObs = observed(input.from.snapshots, attribution);
34967
35081
  const toObs = observed(input.to.snapshots, attribution);
@@ -34973,12 +35087,10 @@ function computeVisibilityCompare(input) {
34973
35087
  const fromCandidateModels = modelEvidenceOf(restrict(input.from.snapshots, attribution, pairsBoth));
34974
35088
  const toCandidateModels = modelEvidenceOf(restrict(input.to.snapshots, attribution, pairsBoth));
34975
35089
  const continuityProviders = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
34976
- const fromEvidence = classifyModelEvidence(fromCandidateModels.get(provider) ?? []);
34977
- const toEvidence = classifyModelEvidence(toCandidateModels.get(provider) ?? []);
34978
- const fromModels = modelIds(fromEvidence);
34979
- const toModels = modelIds(toEvidence);
34980
- const status = isUnknownModelEvidence(fromEvidence) || isUnknownModelEvidence(toEvidence) ? "model-unknown" : fromEvidence.status === "known" && toEvidence.status === "known" && fromEvidence.model === toEvidence.model ? "included" : "model-discontinuous";
34981
- return { provider, status, fromModels, toModels };
35090
+ return { provider, ...compareModelContinuity(
35091
+ fromCandidateModels.get(provider) ?? [],
35092
+ toCandidateModels.get(provider) ?? []
35093
+ ) };
34982
35094
  });
34983
35095
  const providersBoth = new Set(
34984
35096
  continuityProviders.filter((provider) => provider.status === "included").map((provider) => provider.provider)
@@ -34992,12 +35104,18 @@ function computeVisibilityCompare(input) {
34992
35104
  const toSnaps = restrict(input.to.snapshots, attribution, comparedPairs);
34993
35105
  const fromCounts = countPeriod(fromSnaps, input.competitors, queryClassOf, classificationAvailable, projectMatcher);
34994
35106
  const toCounts = countPeriod(toSnaps, input.competitors, queryClassOf, classificationAvailable, projectMatcher);
35107
+ const fromClassSnaps = input.from.classSnapshots === void 0 ? fromSnaps : restrict(input.from.classSnapshots, attribution, comparedPairs);
35108
+ const toClassSnaps = input.to.classSnapshots === void 0 ? toSnaps : restrict(input.to.classSnapshots, attribution, comparedPairs);
35109
+ const noProjectMatcher = compileBrandAliases([]);
35110
+ const fromShareCounts = input.frozenClassification ? countPeriod(fromClassSnaps, input.competitors, queryClassOf, true, noProjectMatcher) : fromCounts;
35111
+ const toShareCounts = input.frozenClassification ? countPeriod(toClassSnaps, input.competitors, queryClassOf, true, noProjectMatcher) : toCounts;
34995
35112
  const shareCounts = (c) => ({
34996
35113
  proj: c.mentionShare.breakdown.projectMentionSnapshots,
34997
35114
  comp: c.mentionShare.breakdown.competitorMentionSnapshots
34998
35115
  });
34999
- const fromShare = shareCounts(fromCounts);
35000
- const toShare = shareCounts(toCounts);
35116
+ const fromShare = shareCounts(fromShareCounts);
35117
+ const toShare = shareCounts(toShareCounts);
35118
+ const citationPeriod = (c, value) => c.citationEvidence ? { ...value, excludedUnknown: c.citationUnknown } : value;
35001
35119
  const metrics = [
35002
35120
  metric(
35003
35121
  "mention-share-of-voice",
@@ -35018,8 +35136,8 @@ function computeVisibilityCompare(input) {
35018
35136
  "Cited share of voice",
35019
35137
  "all",
35020
35138
  true,
35021
- input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited),
35022
- input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited),
35139
+ citationPeriod(fromCounts, input.competitors.length > 0 ? period(fromCounts.projectCited, fromCounts.projectCited + fromCounts.competitorCited) : unavailableCompetitivePeriod(fromCounts.projectCited)),
35140
+ citationPeriod(toCounts, input.competitors.length > 0 ? period(toCounts.projectCited, toCounts.projectCited + toCounts.competitorCited) : unavailableCompetitivePeriod(toCounts.projectCited)),
35023
35141
  continuityBlock
35024
35142
  ),
35025
35143
  metric(
@@ -35036,11 +35154,31 @@ function computeVisibilityCompare(input) {
35036
35154
  "Cited rate",
35037
35155
  "all",
35038
35156
  false,
35039
- period(fromCounts.cited, fromCounts.total),
35040
- period(toCounts.cited, toCounts.total),
35157
+ citationPeriod(fromCounts, period(fromCounts.cited, fromCounts.total)),
35158
+ citationPeriod(toCounts, period(toCounts.cited, toCounts.total)),
35041
35159
  continuityBlock
35042
35160
  )
35043
35161
  ];
35162
+ const classPeriod = (snapshots, queryClass, signal) => {
35163
+ if (!classificationAvailable) return { ...period(0, 0), availability: "classification-unavailable" };
35164
+ const selected = snapshots.filter((snapshot) => queryClassOf(snapshot) === queryClass && (signal === "cited" || snapshot.mentionApplicable !== false));
35165
+ const checked = selected.filter((snapshot) => signal === "mention" ? typeof snapshot.answerMentioned === "boolean" : snapshot.citationChecked !== false);
35166
+ const numerator = checked.filter((snapshot) => signal === "mention" ? snapshot.answerMentioned === true : snapshot.citationState === CitationStates.cited).length;
35167
+ return { ...period(numerator, checked.length), excludedUnknown: selected.length - checked.length };
35168
+ };
35169
+ for (const queryClass of ["branded", "non-brand"]) {
35170
+ for (const signal of ["mention", "cited"]) {
35171
+ metrics.push(metric(
35172
+ `${signal}-rate-${queryClass}`,
35173
+ signal === "mention" ? "Mention rate" : "Cited rate",
35174
+ queryClass,
35175
+ false,
35176
+ classPeriod(fromClassSnaps, queryClass, signal),
35177
+ classPeriod(toClassSnaps, queryClass, signal),
35178
+ continuityBlock
35179
+ ));
35180
+ }
35181
+ }
35044
35182
  const modelChanges = [...candidateProviders].sort((a, b) => a.localeCompare(b)).map((provider) => {
35045
35183
  const fromModels = modelIds(classifyModelEvidence(fromCandidateModels.get(provider) ?? []));
35046
35184
  const toModels = modelIds(classifyModelEvidence(toCandidateModels.get(provider) ?? []));
@@ -35088,7 +35226,7 @@ function computeVisibilityCompare(input) {
35088
35226
  comparedProviders: [...providersBoth].sort((a, b) => a.localeCompare(b)),
35089
35227
  providers: continuityProviders
35090
35228
  },
35091
- competitors: { from: fromCounts.competitors, to: toCounts.competitors }
35229
+ competitors: { from: fromShareCounts.competitors, to: toShareCounts.competitors }
35092
35230
  };
35093
35231
  }
35094
35232
 
@@ -35306,59 +35444,150 @@ async function visibilityStatsRoutes(app) {
35306
35444
  return reply.send(response);
35307
35445
  });
35308
35446
  app.get("/projects/:name/visibility-compare", async (request, reply) => {
35309
- const project = resolveProject(app.db, request.params.name);
35310
- const { from: fromRaw, to: toRaw } = request.query;
35311
- if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
35312
- if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
35313
- let fromBounds;
35314
- let toBounds;
35315
- try {
35316
- fromBounds = calendarMonthBounds(fromRaw);
35317
- } catch (err) {
35318
- throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
35319
- }
35320
- try {
35321
- toBounds = calendarMonthBounds(toRaw);
35322
- } catch (err) {
35323
- throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
35324
- }
35325
- if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
35326
- throw validationError('"from" must be a month strictly before "to"');
35327
- }
35328
- const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq39(queries.projectId, project.id)).all();
35329
- const competitorRows2 = app.db.select({ domain: competitors.domain }).from(competitors).where(eq39(competitors.projectId, project.id)).all();
35330
- const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows2.map((c) => c.domain));
35331
- const loadMonth = (bounds) => {
35332
- const sinceMs = Date.parse(bounds.since);
35333
- const untilMs = Date.parse(bounds.until);
35334
- const monthRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and35(eq39(runs.projectId, project.id), eq39(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
35335
- (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
35336
- );
35337
- const runIds = monthRuns.map((r) => r.id);
35338
- const snapshots = runIds.length > 0 && projectQueries.length > 0 ? app.db.select({
35339
- queryId: querySnapshots.queryId,
35340
- queryText: querySnapshots.queryText,
35341
- provider: querySnapshots.provider,
35342
- model: querySnapshots.model,
35343
- citationState: querySnapshots.citationState,
35344
- answerMentioned: querySnapshots.answerMentioned,
35345
- answerText: querySnapshots.answerText,
35346
- citedDomains: querySnapshots.citedDomains
35347
- }).from(querySnapshots).where(inArray18(querySnapshots.runId, runIds)).all() : [];
35348
- return { runCount: monthRuns.length, snapshots };
35447
+ const dto = readVisibilityCompare(app.db, request.params.name, request.query);
35448
+ return reply.send(dto);
35449
+ });
35450
+ }
35451
+ function readVisibilityCompare(db, projectName, query) {
35452
+ const project = resolveProject(db, projectName);
35453
+ const selection = visibilityCompareSelectionSchema.safeParse(query);
35454
+ if (!selection.success) throw validationError("Invalid comparison selection", { issues: selection.error.issues });
35455
+ const filters = selection.data;
35456
+ const scope = filters.scope ?? "project";
35457
+ if (scope !== "project" && filters.scopeKey === void 0) throw validationError("A non-project comparison scope requires scopeKey.");
35458
+ if (scope === "project" && filters.scopeKey !== void 0) throw validationError("scopeKey is not valid for project scope.");
35459
+ if (scope === "market" && filters.marketKey !== void 0) throw validationError("marketKey is not valid for market scope.");
35460
+ const { from: fromRaw, to: toRaw } = query;
35461
+ if (fromRaw === void 0 || fromRaw === "") throw validationError('"from" (YYYY-MM) is required');
35462
+ if (toRaw === void 0 || toRaw === "") throw validationError('"to" (YYYY-MM) is required');
35463
+ let fromBounds;
35464
+ let toBounds;
35465
+ try {
35466
+ fromBounds = calendarMonthBounds(fromRaw);
35467
+ } catch (err) {
35468
+ throw validationError(err instanceof RangeError ? `"from": ${err.message}` : '"from" must be in YYYY-MM format');
35469
+ }
35470
+ try {
35471
+ toBounds = calendarMonthBounds(toRaw);
35472
+ } catch (err) {
35473
+ throw validationError(err instanceof RangeError ? `"to": ${err.message}` : '"to" must be in YYYY-MM format');
35474
+ }
35475
+ if (Date.parse(fromBounds.since) >= Date.parse(toBounds.since)) {
35476
+ throw validationError('"from" must be a month strictly before "to"');
35477
+ }
35478
+ const providerKey2 = filters.provider === void 0 ? void 0 : normalizeText3(filters.provider);
35479
+ const locationKey2 = filters.location === void 0 ? void 0 : normalizeText3(filters.location);
35480
+ const scoped = scope !== "project" || filters.marketKey !== void 0;
35481
+ let advancedComparison;
35482
+ if (activeMeasurementPlan(db, project.id) !== null) {
35483
+ let missingScope;
35484
+ let measuredScope = 0;
35485
+ const loadAdvancedMonth = (bounds) => {
35486
+ const selected = readVisibilityComparisonRuns(db, project.id, bounds.since, bounds.until);
35487
+ const snapshots = [];
35488
+ const classSnapshots = [];
35489
+ let runCount = 0;
35490
+ for (const run of selected.runs) {
35491
+ let population;
35492
+ try {
35493
+ population = visibilityComparisonPopulation(run, {
35494
+ ...filters,
35495
+ scope,
35496
+ queryClass: "all",
35497
+ location: locationKey2 === void 0 ? { kind: "all" } : locationKey2 === "none" ? { kind: "none" } : { kind: "exact", value: filters.location },
35498
+ limit: 100
35499
+ });
35500
+ } catch (error) {
35501
+ if (error instanceof VisibilityReportScopeError && error.details !== void 0) {
35502
+ missingScope ??= error;
35503
+ continue;
35504
+ }
35505
+ if (error instanceof VisibilityReportScopeError) throw validationError(error.message, error.details);
35506
+ throw error;
35507
+ }
35508
+ runCount += 1;
35509
+ const adapt = (snapshot) => ({
35510
+ ...snapshot,
35511
+ citationState: snapshot.citation === true ? CitationStates.cited : CitationStates["not-cited"],
35512
+ citationChecked: snapshot.citation !== null,
35513
+ citedDomains: []
35514
+ });
35515
+ snapshots.push(...population.snapshots.map(adapt));
35516
+ classSnapshots.push(...population.classSnapshots.map(adapt));
35517
+ }
35518
+ measuredScope += runCount;
35519
+ return { runCount, snapshots, classSnapshots, classificationAvailable: !selected.unavailable };
35349
35520
  };
35350
- const fromMonth = loadMonth(fromBounds);
35351
- const toMonth = loadMonth(toBounds);
35352
- const dto = computeVisibilityCompare({
35521
+ const fromMonth2 = loadAdvancedMonth(fromBounds);
35522
+ const toMonth2 = loadAdvancedMonth(toBounds);
35523
+ if (missingScope !== void 0 && measuredScope === 0) throw validationError(missingScope.message, missingScope.details);
35524
+ const queries2 = [...new Map([...fromMonth2.snapshots, ...toMonth2.snapshots].map((snapshot) => [snapshot.queryId, { id: snapshot.queryId, query: snapshot.queryText }])).values()];
35525
+ advancedComparison = { ...computeVisibilityCompare({
35353
35526
  project: project.name,
35354
- queries: projectQueries,
35355
- competitors: competitorInputs,
35356
- brandNames: effectiveBrandNames(project),
35357
- from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
35358
- to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
35359
- });
35360
- return reply.send(dto);
35527
+ queries: queries2,
35528
+ competitors: [...new Set([...fromMonth2.snapshots, ...toMonth2.snapshots].flatMap((snapshot) => snapshot.competitorDomains ?? []))].map((domain) => ({ domain, brandTokens: [] })),
35529
+ frozenClassification: fromMonth2.classificationAvailable && toMonth2.classificationAvailable,
35530
+ from: { ...fromMonth2, month: fromRaw, ...fromBounds },
35531
+ to: { ...toMonth2, month: toRaw, ...toBounds }
35532
+ }), selection: filters };
35533
+ if (scoped) {
35534
+ const unreadable = [fromMonth2.classificationAvailable ? null : fromRaw, toMonth2.classificationAvailable ? null : toRaw].filter((month) => month !== null);
35535
+ if (unreadable.length > 0) {
35536
+ throw validationError(`Property, group and market comparisons need frozen schema-v2 plan history; ${unreadable.join(" and ")} include runs whose plan cannot be reconstructed.`, { months: unreadable });
35537
+ }
35538
+ return advancedComparison;
35539
+ }
35540
+ }
35541
+ if (advancedComparison === void 0 && scoped) throw validationError("Property, group and market comparison scopes require an Advanced portfolio.");
35542
+ const projectQueries = db.select({ id: queries.id, query: queries.query }).from(queries).where(eq39(queries.projectId, project.id)).all();
35543
+ const competitorRows2 = db.select({ domain: competitors.domain }).from(competitors).where(eq39(competitors.projectId, project.id)).all();
35544
+ const competitorInputs = mentionShareCompetitorsFromDomains(competitorRows2.map((c) => c.domain));
35545
+ const loadMonth = (bounds) => {
35546
+ const sinceMs = Date.parse(bounds.since);
35547
+ const untilMs = Date.parse(bounds.until);
35548
+ const monthRuns = db.select({ id: runs.id, createdAt: runs.createdAt, status: runs.status }).from(runs).where(and35(eq39(runs.projectId, project.id), eq39(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).all().filter(
35549
+ (r) => (r.status === RunStatuses.completed || r.status === RunStatuses.partial) && Date.parse(r.createdAt) >= sinceMs && Date.parse(r.createdAt) <= untilMs
35550
+ );
35551
+ const runIds = monthRuns.map((r) => r.id);
35552
+ const snapshots = runIds.length > 0 && projectQueries.length > 0 ? db.select({
35553
+ queryId: querySnapshots.queryId,
35554
+ queryText: querySnapshots.queryText,
35555
+ provider: querySnapshots.provider,
35556
+ model: querySnapshots.model,
35557
+ citationState: querySnapshots.citationState,
35558
+ answerMentioned: querySnapshots.answerMentioned,
35559
+ answerText: querySnapshots.answerText,
35560
+ citedDomains: querySnapshots.citedDomains,
35561
+ location: querySnapshots.location
35562
+ }).from(querySnapshots).where(inArray18(querySnapshots.runId, runIds)).all() : [];
35563
+ return {
35564
+ runCount: monthRuns.length,
35565
+ snapshots: snapshots.filter((snapshot) => (providerKey2 === void 0 || normalizeText3(snapshot.provider) === providerKey2) && (locationKey2 === void 0 || (locationKey2 === "none" ? snapshot.location === null : snapshot.location !== null && normalizeText3(snapshot.location) === locationKey2)))
35566
+ };
35567
+ };
35568
+ const fromMonth = loadMonth(fromBounds);
35569
+ const toMonth = loadMonth(toBounds);
35570
+ const dto = computeVisibilityCompare({
35571
+ project: project.name,
35572
+ queries: projectQueries,
35573
+ competitors: competitorInputs,
35574
+ brandNames: effectiveBrandNames(project),
35575
+ from: { month: fromRaw, since: fromBounds.since, until: fromBounds.until, runCount: fromMonth.runCount, snapshots: fromMonth.snapshots },
35576
+ to: { month: toRaw, since: toBounds.since, until: toBounds.until, runCount: toMonth.runCount, snapshots: toMonth.snapshots }
35361
35577
  });
35578
+ if (advancedComparison !== void 0) {
35579
+ const { from, to, basket, continuity: continuity2, modelChanges } = advancedComparison;
35580
+ return {
35581
+ ...dto,
35582
+ selection: filters,
35583
+ metrics: [
35584
+ ...dto.metrics.filter((metric2) => !isVisibilityCompareClassMetric(metric2.key)),
35585
+ ...advancedComparison.metrics.filter((metric2) => isVisibilityCompareClassMetric(metric2.key))
35586
+ ],
35587
+ classComparison: { from, to, basket, continuity: continuity2, modelChanges }
35588
+ };
35589
+ }
35590
+ return { ...dto, selection: filters };
35362
35591
  }
35363
35592
 
35364
35593
  // ../api-routes/src/results-export.ts
@@ -39055,9 +39284,18 @@ var routeCatalog = [
39055
39284
  method: "get",
39056
39285
  path: "/api/v1/projects/{name}/visibility-compare",
39057
39286
  summary: "Compare AEO visibility month over month",
39058
- 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.",
39287
+ 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`. Adds separate branded/non-brand mention and cited rates with classification-unavailable rather than pooled fallback. Advanced class metrics read frozen Property/market/execution assignments and reuse each answer once per class. Without a scope or marketKey the four original metrics keep the project frame (provider/location narrow it the same way for Simple and Advanced) and Advanced class metrics carry their own `classComparison` cohort. A scope or marketKey answers entirely from the frozen frame and is refused when either month holds runs whose plan cannot be reconstructed (schema v1). On frozen frames, answers with incomplete source capture leave every citation figure and are counted in `excludedUnknown`. A silent upstream version bump under an unchanged configured id remains undetectable.",
39059
39288
  tags: ["analytics"],
39060
- parameters: [nameParameter, compareFromQueryParameter, compareToQueryParameter],
39289
+ parameters: [
39290
+ nameParameter,
39291
+ compareFromQueryParameter,
39292
+ compareToQueryParameter,
39293
+ { name: "scope", in: "query", description: "Advanced frozen population scope; default project.", schema: { type: "string", enum: ["project", "group", "market", "property"] } },
39294
+ { name: "scopeKey", in: "query", description: "Stable Property Target, group, or market key; required for non-project scope.", schema: stringSchema },
39295
+ { name: "marketKey", in: "query", description: "Intersect project/group/property selection with exact frozen market edges.", schema: stringSchema },
39296
+ { name: "provider", in: "query", description: "Restrict both frames to one provider (case-insensitive).", schema: stringSchema },
39297
+ { name: "location", in: "query", description: "Execution location label (case-insensitive), or none for no location.", schema: stringSchema }
39298
+ ],
39061
39299
  responses: {
39062
39300
  200: jsonResponse("Month-over-month visibility comparison returned.", "VisibilityCompareDto"),
39063
39301
  400: errorResponse("Invalid or missing from/to months."),
@@ -5,7 +5,7 @@ import {
5
5
  PACKAGE_VERSION,
6
6
  canonryMcpTools,
7
7
  isMachineFormat
8
- } from "./chunk-JJPZ4OQ2.js";
8
+ } from "./chunk-WUT3G2GM.js";
9
9
  import {
10
10
  AGENT_PROVIDER_IDS,
11
11
  CANONRY_NPM_PACKAGE_URL,
@@ -37,7 +37,7 @@ import {
37
37
  skillsClientSchema,
38
38
  upgradeCaveatFor,
39
39
  upgradeCommandFor
40
- } from "./chunk-TOSPZ7I5.js";
40
+ } from "./chunk-2LO7FLVD.js";
41
41
 
42
42
  // src/config.ts
43
43
  import fs from "fs";
@@ -8891,12 +8891,12 @@ var ApiClient = class {
8891
8891
  })
8892
8892
  );
8893
8893
  }
8894
- async getVisibilityCompare(project, from, to) {
8894
+ async getVisibilityCompare(project, from, to, selection = {}) {
8895
8895
  return this.invoke(
8896
8896
  () => getApiV1ProjectsByNameVisibilityCompare({
8897
8897
  client: this.heyClient,
8898
8898
  path: { name: project },
8899
- query: { from, to }
8899
+ query: { from, to, ...selection }
8900
8900
  })
8901
8901
  );
8902
8902
  }
@@ -5,12 +5,12 @@ import {
5
5
  siteCrawlGraphNodes,
6
6
  siteCrawlPages,
7
7
  siteCrawlSnapshots
8
- } from "./chunk-NSKFW3NF.js";
8
+ } from "./chunk-FAVYLZQV.js";
9
9
  import {
10
10
  SITE_CRAWL_GRAPH_MAX_EDGES,
11
11
  SITE_CRAWL_GRAPH_MAX_NODES,
12
12
  TEMPLATE_LINK_RATIO_THRESHOLD
13
- } from "./chunk-TOSPZ7I5.js";
13
+ } from "./chunk-2LO7FLVD.js";
14
14
 
15
15
  // src/site-crawl-graph-layout.ts
16
16
  import crypto from "crypto";
@@ -4,14 +4,14 @@ import {
4
4
  installSkills,
5
5
  loadConfigRaw,
6
6
  saveConfigPatch
7
- } from "./chunk-PXVJ3P5G.js";
7
+ } from "./chunk-FRWZWJB3.js";
8
8
  import {
9
9
  PACKAGE_VERSION
10
- } from "./chunk-JJPZ4OQ2.js";
10
+ } from "./chunk-WUT3G2GM.js";
11
11
  import {
12
12
  SKILL_MANIFEST_FILENAME,
13
13
  SkillsClients
14
- } from "./chunk-TOSPZ7I5.js";
14
+ } from "./chunk-2LO7FLVD.js";
15
15
 
16
16
  // src/skills-autosync.ts
17
17
  import fs from "fs";