@canonry/canonry 7.3.0 → 7.5.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 (34) hide show
  1. package/assets/agent-workspace/skills/aero/references/orchestration.md +1 -1
  2. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +2 -1
  3. package/assets/assets/{AuditHistoryPanel-DP92khTY.js → AuditHistoryPanel-B5aLMnWE.js} +1 -1
  4. package/assets/assets/{BacklinksPage-BI_9t1iI.js → BacklinksPage-Biq1jrqe.js} +1 -1
  5. package/assets/assets/{HistoryPage-tDE6Nv0J.js → HistoryPage-CGGkAgnz.js} +1 -1
  6. package/assets/assets/MeasurementPropertyPage-BwyWGGBg.js +1 -0
  7. package/assets/assets/ProjectPage-DEnKyD3e.js +8 -0
  8. package/assets/assets/{RunsPage-Bl_c9-vA.js → RunsPage-DNBn0Rau.js} +1 -1
  9. package/assets/assets/{SettingsPage-DQ5_Q2rc.js → SettingsPage-D3m-BLuf.js} +1 -1
  10. package/assets/assets/SiteHealthSection-CPMu1l-l.js +4 -0
  11. package/assets/assets/{TrafficPage-CROectSE.js → TrafficPage-BCK_V__i.js} +3 -3
  12. package/assets/assets/{TrafficSourceDetailPage-Bthvvwg5.js → TrafficSourceDetailPage-DaCehUm8.js} +1 -1
  13. package/assets/assets/{extract-error-message-DOLCGQMa.js → extract-error-message-CqBLzzuR.js} +1 -1
  14. package/assets/assets/{index-CCOcEGdz.js → index-C2X_UARl.js} +20 -20
  15. package/assets/assets/{react-sigma_core.esm.min-TWiq-VWU.js → react-sigma_core.esm.min-BvO4SZKT.js} +1 -1
  16. package/assets/assets/v2-overview-adapter-CgDChBa5.js +1 -0
  17. package/assets/index.html +1 -1
  18. package/dist/{chunk-YQXQBFHT.js → chunk-3L7QPB37.js} +9850 -9767
  19. package/dist/{chunk-RVELNJAW.js → chunk-6Z5MRX24.js} +2 -2
  20. package/dist/{chunk-3JEUZ2K5.js → chunk-E7EWAAH3.js} +3 -3
  21. package/dist/{chunk-FWV6IIYY.js → chunk-JURAGZOO.js} +7 -6
  22. package/dist/{chunk-QND5LEAC.js → chunk-KS66E5H6.js} +2 -2
  23. package/dist/{chunk-MQPBOQUH.js → chunk-OY5QVO34.js} +7 -7
  24. package/dist/{chunk-XODXE6PT.js → chunk-ZHI2MCMD.js} +83 -32
  25. package/dist/cli.js +64 -24
  26. package/dist/{demo-server-BXABH765.js → demo-server-55M7DF3U.js} +4 -4
  27. package/dist/index.js +6 -6
  28. package/dist/{intelligence-service-EIWYQYVG.js → intelligence-service-HZSRDMFP.js} +2 -2
  29. package/dist/mcp.js +4 -4
  30. package/package.json +14 -14
  31. package/assets/assets/MeasurementPropertyPage-Y2F334Qs.js +0 -1
  32. package/assets/assets/ProjectPage-DW581TNr.js +0 -8
  33. package/assets/assets/SiteHealthSection-DdZruU72.js +0 -4
  34. package/assets/assets/v2-overview-adapter-CxmV639A.js +0 -1
@@ -5,7 +5,7 @@ import {
5
5
  PACKAGE_VERSION,
6
6
  canonryMcpTools,
7
7
  isMachineFormat
8
- } from "./chunk-FWV6IIYY.js";
8
+ } from "./chunk-JURAGZOO.js";
9
9
  import {
10
10
  AGENT_PROVIDER_IDS,
11
11
  CANONRY_NPM_PACKAGE_URL,
@@ -50,7 +50,7 @@ import {
50
50
  skillsClientSchema,
51
51
  upgradeCaveatFor,
52
52
  upgradeCommandFor
53
- } from "./chunk-YQXQBFHT.js";
53
+ } from "./chunk-3L7QPB37.js";
54
54
 
55
55
  // src/config.ts
56
56
  import fs from "fs";
@@ -4,14 +4,14 @@ import {
4
4
  installSkills,
5
5
  loadConfigRaw,
6
6
  saveConfigPatch
7
- } from "./chunk-RVELNJAW.js";
7
+ } from "./chunk-6Z5MRX24.js";
8
8
  import {
9
9
  PACKAGE_VERSION
10
- } from "./chunk-FWV6IIYY.js";
10
+ } from "./chunk-JURAGZOO.js";
11
11
  import {
12
12
  SKILL_MANIFEST_FILENAME,
13
13
  SkillsClients
14
- } from "./chunk-YQXQBFHT.js";
14
+ } from "./chunk-3L7QPB37.js";
15
15
 
16
16
  // src/skills-autosync.ts
17
17
  import fs from "fs";
@@ -124,6 +124,7 @@ import {
124
124
  sentimentSummaryIncludeSchema,
125
125
  sentimentSummaryReadSchema,
126
126
  settingsDtoSchema,
127
+ siteAuditPageBudgetSchema,
127
128
  snapshotRequestInputSchema,
128
129
  telemetryStatusDtoSchema,
129
130
  trafficAnalyticsPeriodSchema,
@@ -134,7 +135,7 @@ import {
134
135
  trafficSeriesGranularitySchema,
135
136
  visibilityCompareSelectionSchema,
136
137
  visibilityReportRequestSchema
137
- } from "./chunk-YQXQBFHT.js";
138
+ } from "./chunk-3L7QPB37.js";
138
139
 
139
140
  // src/cli-error.ts
140
141
  function isMachineFormat(format) {
@@ -1209,7 +1210,7 @@ var technicalAeoRunInputSchema = z3.object({
1209
1210
  project: projectNameSchema,
1210
1211
  sitemapUrl: z3.string().url().optional().describe("Override the sitemap URL. Defaults to https://<canonicalDomain>/sitemap.xml."),
1211
1212
  limit: z3.number().int().positive().max(2e3).optional().describe("Deprecated compatibility alias for maxPages."),
1212
- maxPages: z3.number().int().positive().max(5e4).optional().describe("Maximum pages crawled and audited. Defaults to 1,000; hard maximum 50,000."),
1213
+ maxPages: siteAuditPageBudgetSchema.optional().describe("Maximum pages crawled and audited (1 to 50,000). Omitted uses the project's saved budget (siteAuditMaxPages, set with canonry_project_upsert or canonry_apply_config), else the full site, up to 50,000; pass a smaller number for a quick look."),
1213
1214
  maxEdges: z3.number().int().positive().max(1e6).optional().describe("Maximum link observations retained for this crawl. When omitted the crawl engine derives the budget from the page count; hard maximum 1,000,000."),
1214
1215
  maxDepth: z3.number().int().min(0).max(100).optional().describe("Maximum internal-link depth from the root page."),
1215
1216
  checkDeadLinks: z3.boolean().optional().describe("Opt in to internal dead-link checks. Omitted and false both disable checks.")
@@ -2694,7 +2695,7 @@ var canonryMcpTools = [
2694
2695
  defineTool({
2695
2696
  name: "canonry_project_upsert",
2696
2697
  title: "Create or replace project",
2697
- description: "Create or replace a Canonry project. PUT semantics: fields not in the request are reset to their defaults. Provide the full intended project shape. Exceptions: an omitted providerDispatchModes (provider \u2192 sync|batch for scheduled sweeps) keeps the stored preference; send {} to clear it. An omitted qualifiedAliases (the aliases Simple sentiment treats as this brand's own names) keeps the stored list, minus names that no longer qualify (a removed alias, a spelling of the display name, or a competitor's name); send [] to clear it. Competitors are not part of this request, but a new identity (display name, aliases, domains) that claims a tracked competitor's curated alias drops that alias, and the drop is audited.",
2698
+ description: "Create or replace a Canonry project. PUT semantics: fields not in the request are reset to their defaults. Provide the full intended project shape. Exceptions: an omitted providerDispatchModes (provider \u2192 sync|batch for scheduled sweeps) keeps the stored preference; send {} to clear it. An omitted qualifiedAliases (the aliases Simple sentiment treats as this brand's own names) keeps the stored list, minus names that no longer qualify (a removed alias, a spelling of the display name, or a competitor's name); send [] to clear it. Competitors are not part of this request, but a new identity (display name, aliases, domains) that claims a tracked competitor's curated alias drops that alias, and the drop is audited. An omitted siteAuditMaxPages (the Site Health page budget for scans that set none, 1 to 50,000) keeps the stored budget; send null for the full site.",
2698
2699
  access: "write",
2699
2700
  tier: "setup",
2700
2701
  inputSchema: projectUpsertInputSchema,
@@ -2705,7 +2706,7 @@ var canonryMcpTools = [
2705
2706
  defineTool({
2706
2707
  name: "canonry_apply_config",
2707
2708
  title: "Apply project config",
2708
- description: "Apply one Canonry config-as-code project document. Replaces the project to match the config; fields omitted from the spec are reset to defaults, with these exceptions: a spec with neither queries nor keywords leaves the tracked-query basket unchanged (to clear the basket, pass an explicit empty queries list); an omitted providerDispatchModes keeps the stored preference; an omitted qualifiedAliases keeps the stored list, minus names that no longer qualify (a removed alias, a spelling of the display name, or a competitor's name); send [] to clear it; and a competitor given as a plain domain keeps its stored curated aliases, while `{ domain, aliases }` sets them exactly (`[]` clears). A spec identity that claims a competitor's alias drops that alias (audited); a new competitor whose name overlaps another competitor's alias (one brand key contains the other) or that is the project's own site fails the apply. For multi-document YAML, call this tool once per project document.",
2709
+ description: "Apply one Canonry config-as-code project document. Replaces the project to match the config; fields omitted from the spec are reset to defaults, with these exceptions: a spec with neither queries nor keywords leaves the tracked-query basket unchanged (to clear the basket, pass an explicit empty queries list); an omitted providerDispatchModes keeps the stored preference; an omitted qualifiedAliases keeps the stored list, minus names that no longer qualify (a removed alias, a spelling of the display name, or a competitor's name); send [] to clear it; and a competitor given as a plain domain keeps its stored curated aliases, while `{ domain, aliases }` sets them exactly (`[]` clears). A spec identity that claims a competitor's alias drops that alias (audited); a new competitor whose name overlaps another competitor's alias (one brand key contains the other) or that is the project's own site fails the apply. An omitted siteAuditMaxPages keeps the stored Site Health page budget (null means the full site). For multi-document YAML, call this tool once per project document.",
2709
2710
  access: "write",
2710
2711
  tier: "core",
2711
2712
  inputSchema: applyConfigInputSchema,
@@ -2910,7 +2911,7 @@ var canonryMcpTools = [
2910
2911
  defineTool({
2911
2912
  name: "canonry_measurement_portfolio_summary",
2912
2913
  title: "Summarize measured Properties",
2913
- description: "Start here for best and worst Properties. One call returns the weakest Properties, the names answers wrote instead of them, and the domains engines cited for them. Defaults to non-brand queries; state the returned queryClass and never pool branded with non-brand. Returns at most 4 Property rows per list, the most that fit one result; a larger limit is lowered and limitNote says so. For other rows pass groupKey (one metro's weakest), or page canonry_measurement_overview. Mention (answer text names the Property) and citation (a source URL on its page) are separate: report each as numerator/denominator, with any unattributed count beside mention. Denominators count answers, one per query per engine (queries x engines = answers). Group Properties only by each row's metro and submarkets, never by label. namedInsteadInAnswerText lists names written in the answer text of answers that neither named nor cited the Property, counted by answer: they are not citations, and namedInsteadInAnswerTextTotal counts distinct names, not answers. citedDomains and weakestAnswerSources count answers citing each domain, every engine included; weakestAnswerSources pools the weakest rows plus every tied Property, each answer once, and its domainTotal counts distinct domains, not citations. weakestAnswerSources.ownDomainAnswers counts those answers that cite any of your own domains. A Property's citationCoverage counts only answers citing that Property's own page, so 0% citation does not mean your domain went uncited; use ownDomainAnswers for that. When tiedAtWeakest is set, tiedAtWeakest.count Properties share the weakest rates and the rows are the first of them by name, not a rank: report the count, tiedAtWeakest.byMetro (tied Properties per metro over the whole tie; a Property in two metros counts in both) and tiedAtWeakest.namedInstead (names written instead across the whole tie, counted by distinct answer; namedInsteadTotal is distinct names), and call the rows examples. mentionRanking.strongest/.weakest rank every Property with an available mention rate; .excluded lists the rest with reasons. markets lists every market at one level, worst-first (every metro, or a groupKey's submarkets), whatever the limit. Markets may share Properties and never sum to portfolio totals. Reads stored data only; never starts provider work.",
2914
+ description: "Start here for best and worst Properties. One call returns the weakest Properties, the names answers wrote instead of them, and the domains engines cited for them. Defaults to non-brand queries; state the returned queryClass and never pool branded with non-brand. Returns at most 4 Property rows per list, the most that fit one result; a larger limit is lowered and limitNote says so. For other rows pass groupKey (one metro's weakest), or page canonry_measurement_overview. Mention (answer text names the Property) and citation (a source URL on its page) are separate: report each as numerator/denominator, with any unattributed count beside mention and any unchecked count beside citation (saved answers whose source capture was incomplete; they are in neither side). Denominators count answers, one per query per engine (queries x engines = answers, less any unattributed or unchecked). Group Properties only by each row's metro and submarkets, never by label. namedInsteadInAnswerText lists names written in the answer text of answers that neither named nor cited the Property, counted by answer: they are not citations, and namedInsteadInAnswerTextTotal counts distinct names, not answers. citedDomains and weakestAnswerSources count answers citing each domain, every engine included; weakestAnswerSources pools the weakest rows plus every tied Property, each answer once, and its domainTotal counts distinct domains, not citations. weakestAnswerSources.ownDomainAnswers counts those answers that cite any of your own domains. A Property's citationCoverage counts only answers citing that Property's own page, so 0% citation does not mean your domain went uncited; use ownDomainAnswers for that. When tiedAtWeakest is set, tiedAtWeakest.count Properties share the weakest rates and the rows are the first of them by name, not a rank: report the count, tiedAtWeakest.byMetro (tied Properties per metro over the whole tie; a Property in two metros counts in both) and tiedAtWeakest.namedInstead (names written instead across the whole tie, counted by distinct answer; namedInsteadTotal is distinct names), and call the rows examples. mentionRanking.strongest/.weakest rank every Property with an available mention rate; .excluded lists the rest with reasons. markets lists every market at one level, worst-first (every metro, or a groupKey's submarkets), whatever the limit. Markets may share Properties and never sum to portfolio totals. Reads stored data only; never starts provider work.",
2914
2915
  access: "read",
2915
2916
  tier: "monitoring",
2916
2917
  inputSchema: measurementPortfolioSummaryInputSchema,
@@ -3815,7 +3816,7 @@ var canonryMcpTools = [
3815
3816
  defineTool({
3816
3817
  name: "canonry_technical_aeo_run",
3817
3818
  title: "Run Technical AEO site audit",
3818
- description: "Start a site-audit run. The run discovers root, sitemap, and linked pages. It defaults to 1,000 pages, and the engine derives the link-observation budget from that page count with a 100,000 floor, so raising maxPages raises it too. An explicit maxEdges is a ceiling that replaces the derivation and can sit below it. It returns {runId, status} and continues in the background. If an active run has identical effective options, this tool returns it; different options are refused. Poll canonry_run_get, then read the crawl and score tools.",
3819
+ description: "Start a site-audit run. The run discovers root, sitemap, and linked pages. Without maxPages it uses the project's saved page budget (siteAuditMaxPages), else the full site (up to 50,000 pages), and the engine derives the link-observation budget from that page count with a 100,000 floor, so raising maxPages raises it too. An explicit maxEdges is a ceiling that replaces the derivation and can sit below it. It returns {runId, status} and continues in the background. If an active run has identical effective options, this tool returns it; different options are refused. Poll canonry_run_get, then read the crawl and score tools.",
3819
3820
  access: "write",
3820
3821
  tier: "monitoring",
3821
3822
  inputSchema: technicalAeoRunInputSchema,
@@ -5,7 +5,7 @@ import {
5
5
  siteCrawlGraphNodes,
6
6
  siteCrawlPages,
7
7
  siteCrawlSnapshots
8
- } from "./chunk-XODXE6PT.js";
8
+ } from "./chunk-ZHI2MCMD.js";
9
9
  import {
10
10
  SITE_CRAWL_GRAPH_MAX_EDGES,
11
11
  SITE_CRAWL_GRAPH_MAX_NODES,
@@ -13,7 +13,7 @@ import {
13
13
  TEMPLATE_LINK_RATIO_THRESHOLD,
14
14
  factorStatusFromScore,
15
15
  roundPreservingTotal
16
- } from "./chunk-YQXQBFHT.js";
16
+ } from "./chunk-3L7QPB37.js";
17
17
 
18
18
  // src/site-audit-factors.ts
19
19
  function recordedSharePct(factor) {
@@ -20,20 +20,20 @@ import {
20
20
  saveConfig,
21
21
  saveConfigPatch,
22
22
  sentimentInstallReadiness
23
- } from "./chunk-RVELNJAW.js";
23
+ } from "./chunk-6Z5MRX24.js";
24
24
  import {
25
25
  computeFactorAverages,
26
26
  deleteSiteCrawlGraphLayout,
27
27
  persistSiteCrawlGraphLayout,
28
28
  prepareSiteCrawlGraphLayout,
29
29
  recordedSharePct
30
- } from "./chunk-QND5LEAC.js";
30
+ } from "./chunk-KS66E5H6.js";
31
31
  import {
32
32
  CanonryMcpToolNames,
33
33
  CliError,
34
34
  PACKAGE_VERSION,
35
35
  canonryMcpTools
36
- } from "./chunk-FWV6IIYY.js";
36
+ } from "./chunk-JURAGZOO.js";
37
37
  import {
38
38
  CC_CACHE_DIR,
39
39
  DUCKDB_SPEC,
@@ -205,7 +205,7 @@ import {
205
205
  siteLivenessState,
206
206
  toAlertView,
207
207
  usageCounters
208
- } from "./chunk-XODXE6PT.js";
208
+ } from "./chunk-ZHI2MCMD.js";
209
209
  import {
210
210
  AGENT_MEMORY_VALUE_MAX_BYTES,
211
211
  AGENT_NONE,
@@ -411,7 +411,7 @@ import {
411
411
  visibilityReportRequestSchema,
412
412
  winnabilityClassLabel,
413
413
  withRetry
414
- } from "./chunk-YQXQBFHT.js";
414
+ } from "./chunk-3L7QPB37.js";
415
415
 
416
416
  // src/runtime-context.ts
417
417
  function cliRuntimeContext(env = process.env, stdio = process) {
@@ -7869,7 +7869,7 @@ function nonBlank(value) {
7869
7869
  return trimmed ? trimmed : void 0;
7870
7870
  }
7871
7871
  function resolveBuildCommit(env = process.env) {
7872
- const embedded = true ? "254a7fb5e66f080e53bad263c955da797653b6b0" : void 0;
7872
+ const embedded = true ? "1fae609bb90f155eec013fe7e84214c3306cc9b7" : void 0;
7873
7873
  return nonBlank(embedded) ?? nonBlank(env.CANONRY_COMMIT);
7874
7874
  }
7875
7875
  function resolveInstanceIdentity(env = process.env) {
@@ -14780,7 +14780,7 @@ var Scheduler = class {
14780
14780
  return;
14781
14781
  }
14782
14782
  const runId2 = crypto21.randomUUID();
14783
- const effectiveRequest = normalizeSiteAuditRunRequest({ maxPages: SITE_AUDIT_MAX_PAGE_LIMIT });
14783
+ const effectiveRequest = normalizeSiteAuditRunRequest({}, project.siteAuditMaxPages);
14784
14784
  this.db.transaction((tx) => {
14785
14785
  tx.insert(runs).values({
14786
14786
  id: runId2,
@@ -626,6 +626,7 @@ import {
626
626
  queryTrackingWorkspaceResponseSchema,
627
627
  quotaExceeded,
628
628
  rankCriticizedProperties,
629
+ rateOverChecked,
629
630
  recommendationBriefDtoSchema,
630
631
  recommendationExplainRequestSchema,
631
632
  recommendationExplanationDtoSchema,
@@ -795,7 +796,7 @@ import {
795
796
  wordpressSchemaDeployResultDtoSchema,
796
797
  wordpressSchemaStatusResultDtoSchema,
797
798
  wordpressStatusDtoSchema
798
- } from "./chunk-YQXQBFHT.js";
799
+ } from "./chunk-3L7QPB37.js";
799
800
 
800
801
  // src/intelligence-service.ts
801
802
  import { eq as eq84, desc as desc37, asc as asc15, and as and71, ne as ne15, or as or20, inArray as inArray33, gte as gte22, lte as lte20, isNull as isNull16, sql as sql31, exists as exists2 } from "drizzle-orm";
@@ -953,6 +954,8 @@ var projects = sqliteTable("projects", {
953
954
  * sidecar at dispatch; never read by mention detection or query classes.
954
955
  */
955
956
  qualifiedAliases: text("qualified_aliases", { mode: "json" }).$type().notNull().default([]),
957
+ /** Site Health page budget for scans that set none; null means the full site (up to 50,000 pages). */
958
+ siteAuditMaxPages: integer("site_audit_max_pages"),
956
959
  country: text("country").notNull(),
957
960
  language: text("language").notNull(),
958
961
  tags: text("tags", { mode: "json" }).$type().notNull().default([]),
@@ -8152,6 +8155,16 @@ var MIGRATION_VERSIONS = [
8152
8155
  statements: [
8153
8156
  `ALTER TABLE competitors ADD COLUMN aliases TEXT NOT NULL DEFAULT '[]'`
8154
8157
  ]
8158
+ },
8159
+ {
8160
+ // Saved Site Health page budget for scans that set none. Nullable with no
8161
+ // default: every existing project reads as full site, and an older writer
8162
+ // that never sets it leaves it null.
8163
+ version: 169,
8164
+ name: "projects-site-audit-max-pages",
8165
+ statements: [
8166
+ `ALTER TABLE projects ADD COLUMN site_audit_max_pages INTEGER`
8167
+ ]
8155
8168
  }
8156
8169
  ];
8157
8170
  var AERO_LEDGER_REBUILDS = [
@@ -11041,7 +11054,7 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
11041
11054
  }
11042
11055
  const totalCount = queryCited.size;
11043
11056
  const citedCount = [...queryCited.values()].filter(Boolean).length;
11044
- const citationRate = percentOf(citedCount, totalCount) ?? 0;
11057
+ const citationRate2 = percentOf(citedCount, totalCount) ?? 0;
11045
11058
  const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
11046
11059
  const mentionRate3 = percentOf(mentionedCount, totalCount) ?? 0;
11047
11060
  return {
@@ -11049,7 +11062,7 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
11049
11062
  createdAt: run.createdAt,
11050
11063
  citedCount,
11051
11064
  totalCount,
11052
- citationRate,
11065
+ citationRate: citationRate2,
11053
11066
  mentionedCount,
11054
11067
  mentionRate: mentionRate3,
11055
11068
  status: run.status
@@ -15719,11 +15732,10 @@ function coverageRate(slots, edges, prepared) {
15719
15732
  if (slots.length === 0) return { numerator: null, denominator: null, rate: null, reason: "no-population" };
15720
15733
  if (!status.complete) return { numerator: null, denominator: null, rate: null, reason: "incomplete" };
15721
15734
  const basis = sourceCompleteSlots(slots, prepared);
15722
- if (basis.length === 0) return { numerator: null, denominator: null, rate: null, reason: "evidence-incomplete" };
15723
15735
  const edgeIds = new Set(edges.map((edge) => edge.id));
15724
15736
  const assignedSlots = new Set(prepared.evidence.filter((row) => edgeIds.has(row.usageEdgeId) && row.classification === "assigned").map((row) => row.expectedSlotId));
15725
15737
  const numerator = basis.filter((slot) => assignedSlots.has(slot.id)).length;
15726
- return { numerator, denominator: basis.length, rate: numerator / basis.length };
15738
+ return rateOverChecked(numerator, slots.length, slots.length - basis.length, "unchecked") ?? unavailable("evidence-incomplete");
15727
15739
  }
15728
15740
  function targetCoverageRate(targetIds, slots, edges, prepared) {
15729
15741
  const status = completeness(slots, prepared);
@@ -15751,9 +15763,7 @@ function mentionRate(target, slots, prepared) {
15751
15763
  return attributableMentionRate(numerator, slots.length, unattributed);
15752
15764
  }
15753
15765
  function attributableMentionRate(numerator, answered2, unattributed) {
15754
- const denominator = answered2 - unattributed;
15755
- if (denominator <= 0) return unavailable("identity-ambiguous");
15756
- return unattributed > 0 ? { numerator, denominator, rate: numerator / denominator, unattributed } : { numerator, denominator, rate: numerator / denominator };
15766
+ return rateOverChecked(numerator, answered2, unattributed, "unattributed") ?? unavailable("identity-ambiguous");
15757
15767
  }
15758
15768
  function providersFor(slots) {
15759
15769
  return sortedUnique(slots.map((slot) => slot.provider));
@@ -15867,11 +15877,13 @@ function buildMeasurementOverviewIndexes(input, prepared, onEvidenceIndexed) {
15867
15877
  slots.push(slot);
15868
15878
  slotsByExecutionId.set(slot.executionId, slots);
15869
15879
  }
15880
+ const observedSlotIds = /* @__PURE__ */ new Set();
15870
15881
  const answeredSlotIds = /* @__PURE__ */ new Set();
15871
15882
  const sourceCompleteSlotIds = /* @__PURE__ */ new Set();
15872
15883
  const mentionedSlotIdsByTargetId = /* @__PURE__ */ new Map();
15873
15884
  const unknownMentionSlotIdsByTargetId = /* @__PURE__ */ new Map();
15874
15885
  for (const [slotId, observation] of prepared.observationsBySlot) {
15886
+ observedSlotIds.add(slotId);
15875
15887
  if (observation.sourceComplete) sourceCompleteSlotIds.add(slotId);
15876
15888
  if (observation.input.answerText === null) continue;
15877
15889
  answeredSlotIds.add(slotId);
@@ -15902,6 +15914,7 @@ function buildMeasurementOverviewIndexes(input, prepared, onEvidenceIndexed) {
15902
15914
  targetsById,
15903
15915
  targetEdgesByTargetId,
15904
15916
  slotsByExecutionId,
15917
+ observedSlotIds,
15905
15918
  answeredSlotIds,
15906
15919
  sourceCompleteSlotIds,
15907
15920
  mentionedSlotIdsByTargetId,
@@ -15956,14 +15969,14 @@ function scopeMentionRate(input, targetIds, slots, answered2, prepared, edges) {
15956
15969
  }
15957
15970
  function indexedScopeCitationRate(slots, edges, indexes) {
15958
15971
  if (slots.length === 0) return unavailable("no-population");
15972
+ if (slots.some((slot) => !indexes.observedSlotIds.has(slot.id))) return unavailable("evidence-incomplete");
15959
15973
  const basis = slots.filter((slot) => indexes.sourceCompleteSlotIds.has(slot.id));
15960
- if (basis.length !== slots.length) return unavailable("evidence-incomplete");
15961
15974
  const assignedSlots = /* @__PURE__ */ new Set();
15962
15975
  for (const edge of edges) {
15963
15976
  for (const slotId of indexes.assignedSlotIdsByEdgeId.get(edge.id) ?? []) assignedSlots.add(slotId);
15964
15977
  }
15965
15978
  const numerator = basis.filter((slot) => assignedSlots.has(slot.id)).length;
15966
- return { numerator, denominator: basis.length, rate: numerator / basis.length };
15979
+ return rateOverChecked(numerator, slots.length, slots.length - basis.length, "unchecked") ?? unavailable("evidence-incomplete");
15967
15980
  }
15968
15981
  function targetMentionRate(targets, slots, answered2, indexes) {
15969
15982
  const mentionable = targets?.filter((target) => target.aliases.some((alias2) => words(alias2).length > 0)) ?? [];
@@ -17088,6 +17101,7 @@ async function projectRoutes(app, opts) {
17088
17101
  locations: nextLocations,
17089
17102
  defaultLocation: nextDefaultLocation,
17090
17103
  autoExtractBacklinks: body.autoExtractBacklinks ?? false,
17104
+ siteAuditMaxPages: body.siteAuditMaxPages ?? null,
17091
17105
  configSource: body.configSource ?? "api",
17092
17106
  configRevision: 1,
17093
17107
  createdAt: now,
@@ -17160,6 +17174,7 @@ async function projectRoutes(app, opts) {
17160
17174
  });
17161
17175
  }
17162
17176
  const nextAutoExtractBacklinks = body.autoExtractBacklinks !== void 0 ? body.autoExtractBacklinks : existing?.autoExtractBacklinks ?? false;
17177
+ const nextSiteAuditMaxPages = body.siteAuditMaxPages !== void 0 ? body.siteAuditMaxPages : existing?.siteAuditMaxPages ?? null;
17163
17178
  const nextNegativeReviewMaxStars = body.negativeReviewMaxStars !== void 0 ? body.negativeReviewMaxStars : existing ? readNegativeReviewMaxStars(app.db, existing.id) : null;
17164
17179
  const nextMeasurement = body.measurement ?? existing?.measurement ?? DEFAULT_MEASUREMENT_CONFIG;
17165
17180
  const nextAliases = normalizeProjectAliases(body.displayName, body.aliases ?? []);
@@ -17199,6 +17214,7 @@ async function projectRoutes(app, opts) {
17199
17214
  locations: nextLocations,
17200
17215
  defaultLocation: nextDefaultLocation,
17201
17216
  autoExtractBacklinks: nextAutoExtractBacklinks,
17217
+ siteAuditMaxPages: nextSiteAuditMaxPages,
17202
17218
  configSource: body.configSource ?? "api",
17203
17219
  configRevision: existing.configRevision + 1,
17204
17220
  updatedAt: now
@@ -17247,6 +17263,7 @@ async function projectRoutes(app, opts) {
17247
17263
  locations: nextLocations,
17248
17264
  defaultLocation: nextDefaultLocation,
17249
17265
  autoExtractBacklinks: nextAutoExtractBacklinks,
17266
+ siteAuditMaxPages: nextSiteAuditMaxPages,
17250
17267
  configSource: body.configSource ?? "api",
17251
17268
  configRevision: 1,
17252
17269
  createdAt: now,
@@ -17457,6 +17474,7 @@ async function projectRoutes(app, opts) {
17457
17474
  ...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
17458
17475
  ...project.autoExtractBacklinks ? { autoExtractBacklinks: true } : {},
17459
17476
  ...negativeReviewMaxStars !== null ? { negativeReviewMaxStars } : {},
17477
+ ...project.siteAuditMaxPages !== null ? { siteAuditMaxPages: project.siteAuditMaxPages } : {},
17460
17478
  notifications: notificationRows.map((row) => {
17461
17479
  const cfg = row.config;
17462
17480
  return {
@@ -17536,6 +17554,7 @@ function formatProject(row, negativeReviewMaxStars) {
17536
17554
  defaultLocation: row.defaultLocation,
17537
17555
  autoExtractBacklinks: row.autoExtractBacklinks,
17538
17556
  negativeReviewMaxStars,
17557
+ siteAuditMaxPages: row.siteAuditMaxPages ?? null,
17539
17558
  configSource: row.configSource,
17540
17559
  configRevision: row.configRevision,
17541
17560
  createdAt: row.createdAt,
@@ -18398,7 +18417,9 @@ function coverageMetric(rate2) {
18398
18417
  numerator: rate2.numerator,
18399
18418
  denominator: rate2.denominator,
18400
18419
  // Answers the mention rate left out because their identity was unresolved.
18401
- ...rate2.unattributed === void 0 ? {} : { unattributed: rate2.unattributed }
18420
+ ...rate2.unattributed === void 0 ? {} : { unattributed: rate2.unattributed },
18421
+ // Saved answers the citation rate left out because their source capture was incomplete.
18422
+ ...rate2.unchecked === void 0 ? {} : { unchecked: rate2.unchecked }
18402
18423
  };
18403
18424
  }
18404
18425
  function providerRows(property) {
@@ -18562,6 +18583,10 @@ function measurementOutcomeCounts(rows) {
18562
18583
  continue;
18563
18584
  }
18564
18585
  const cited = (citation.numerator ?? citation.value) > 0;
18586
+ if (!cited && citation.unchecked !== void 0) {
18587
+ counts2.notMeasured += 1;
18588
+ continue;
18589
+ }
18565
18590
  if (mentioned && cited) counts2.bothSignals += 1;
18566
18591
  else if (mentioned) counts2.mentionedOnly += 1;
18567
18592
  else if (cited) counts2.citedOnly += 1;
@@ -25478,7 +25503,9 @@ function coverageMetric2(rate2) {
25478
25503
  numerator: rate2.numerator,
25479
25504
  denominator: rate2.denominator,
25480
25505
  // Answers the mention rate left out because their identity was unresolved.
25481
- ...rate2.unattributed === void 0 ? {} : { unattributed: rate2.unattributed }
25506
+ ...rate2.unattributed === void 0 ? {} : { unattributed: rate2.unattributed },
25507
+ // Saved answers the citation rate left out because their source capture was incomplete.
25508
+ ...rate2.unchecked === void 0 ? {} : { unchecked: rate2.unchecked }
25482
25509
  };
25483
25510
  }
25484
25511
  function countMetric2(rate2) {
@@ -25936,15 +25963,15 @@ function weakestTie(ranked) {
25936
25963
  return { summary: null, rows: [] };
25937
25964
  }
25938
25965
  const mentionRate3 = first.mentionCoverage.value;
25939
- const citationRate = first.citationCoverage.value;
25940
- const rows = ranked.filter((row) => row.mentionCoverage.state === "available" && row.mentionCoverage.value === mentionRate3 && row.citationCoverage.state === "available" && row.citationCoverage.value === citationRate);
25966
+ const citationRate2 = first.citationCoverage.value;
25967
+ const rows = ranked.filter((row) => row.mentionCoverage.state === "available" && row.mentionCoverage.value === mentionRate3 && row.citationCoverage.state === "available" && row.citationCoverage.value === citationRate2);
25941
25968
  if (rows.length < 2) return { summary: null, rows: [] };
25942
25969
  const namedInstead = tieNamedInstead(rows);
25943
25970
  return {
25944
25971
  summary: {
25945
25972
  count: rows.length,
25946
25973
  mentionRate: mentionRate3,
25947
- citationRate,
25974
+ citationRate: citationRate2,
25948
25975
  note: MEASUREMENT_PORTFOLIO_TIE_NOTE,
25949
25976
  byMetro: tieByMetro(rows),
25950
25977
  namedInstead: namedInstead.rows,
@@ -26736,8 +26763,27 @@ function targetValues(candidate, targets, targetKey) {
26736
26763
  }
26737
26764
  return { mention: false, citation };
26738
26765
  }
26739
- function citationForCoverage(candidate, targets, targetKey) {
26740
- return candidate.observation?.citationComplete ? targetValues(candidate, targets, targetKey).citation : null;
26766
+ function citationForCoverage(candidate, targets) {
26767
+ return candidate.observation?.citationComplete ? targetValues(candidate, targets).citation : null;
26768
+ }
26769
+ function citationRate(candidates, isCited) {
26770
+ if (candidates.length === 0) return unavailable4("no-population");
26771
+ let numerator = 0;
26772
+ let unchecked = 0;
26773
+ for (const candidate of candidates) {
26774
+ if (candidate.observation === null) return unavailable4("evidence-incomplete");
26775
+ if (!candidate.observation.citationComplete) {
26776
+ unchecked++;
26777
+ continue;
26778
+ }
26779
+ const cited = isCited(candidate);
26780
+ if (cited === null) return unavailable4("evidence-incomplete");
26781
+ if (cited) numerator++;
26782
+ }
26783
+ return rateOverChecked(numerator, candidates.length, unchecked, "unchecked") ?? unavailable4("evidence-incomplete");
26784
+ }
26785
+ function targetCitationRate(candidates, targets, targetKey) {
26786
+ return citationRate(candidates, (candidate) => targetValues(candidate, targets, targetKey).citation);
26741
26787
  }
26742
26788
  function mentionRate2(candidates, targets, targetKey) {
26743
26789
  const values = candidates.map((candidate) => targetValues(candidate, targets, targetKey));
@@ -26751,9 +26797,7 @@ function mentionRate2(candidates, targets, targetKey) {
26751
26797
  unattributed++;
26752
26798
  }
26753
26799
  }
26754
- const denominator = values.length - unattributed;
26755
- if (denominator === 0) return unavailable4("identity-ambiguous");
26756
- return unattributed > 0 ? { numerator, denominator, rate: numerator / denominator, unattributed } : { numerator, denominator, rate: numerator / denominator };
26800
+ return rateOverChecked(numerator, values.length, unattributed, "unattributed") ?? unavailable4("identity-ambiguous");
26757
26801
  }
26758
26802
  function answered(candidate) {
26759
26803
  return candidate.observation !== null;
@@ -26768,16 +26812,18 @@ function targetMetrics(candidates, targets, targetKey) {
26768
26812
  const own = candidates.filter((candidate) => candidate.edges.some((edge) => edge.targetKey === targetKey));
26769
26813
  return {
26770
26814
  mentionCoverage: mentionRate2(own, targets, targetKey),
26771
- citationCoverage: rate(own.map((candidate) => citationForCoverage(candidate, targets, targetKey)), own.length)
26815
+ citationCoverage: targetCitationRate(own, targets, targetKey)
26772
26816
  };
26773
26817
  }
26774
26818
  function targetPresence(candidates, targets, targetKey) {
26775
- const values = candidates.filter((candidate) => candidate.edges.some((edge) => edge.targetKey === targetKey)).map((candidate) => targetValues(candidate, targets, targetKey));
26819
+ const scoped = candidates.filter((candidate) => candidate.edges.some((edge) => edge.targetKey === targetKey));
26820
+ const values = scoped.map((candidate) => targetValues(candidate, targets, targetKey));
26821
+ const citations = scoped.map((candidate, index2) => candidate.observation?.citationComplete === false ? null : values[index2].citation);
26776
26822
  const anyKnown = (signals) => signals.includes(true) ? true : signals.length === 0 || signals.includes(null) ? null : false;
26777
26823
  return {
26778
26824
  targetKey,
26779
26825
  mention: anyKnown(values.map((value) => value.mention)),
26780
- citation: anyKnown(values.map((value) => value.citation)),
26826
+ citation: anyKnown(citations),
26781
26827
  identityAmbiguous: values.some((value) => value.mentionUnavailableReason === "identity-ambiguous")
26782
26828
  };
26783
26829
  }
@@ -26806,7 +26852,7 @@ function coverageSummary(candidates, definition, targets = targetMap(definition)
26806
26852
  queryCount: new Set(rows.map((candidate) => candidate.slot.queryKey)).size,
26807
26853
  answerCount: rows.filter(answered).length,
26808
26854
  mentionCoverage: mentionRate2(rows, targets),
26809
- citationCoverage: rate(rows.map((candidate) => citationForCoverage(candidate, targets)), rows.length)
26855
+ citationCoverage: targetCitationRate(rows, targets)
26810
26856
  };
26811
26857
  }
26812
26858
  function summary2(candidates, definition, populationTargetKeys = selectedTargetKeys(candidates)) {
@@ -26902,15 +26948,12 @@ function competitorRows(candidates) {
26902
26948
  const observation = candidate.observation;
26903
26949
  return observation === null || observation.answerText === null ? null : observation.competitorMentionDomains.includes(domain);
26904
26950
  });
26905
- const citationValues = unique3.map((candidate) => {
26906
- const observation = candidate.observation;
26907
- return observation === null || !observation.citationComplete ? null : observation.competitorCitationDomains.includes(domain);
26908
- });
26909
26951
  return {
26910
26952
  domain,
26911
26953
  answerCount: unique3.filter(answered).length,
26912
26954
  mentionCoverage: rate(mentionValues, unique3.length),
26913
- citationCoverage: rate(citationValues, unique3.length)
26955
+ // The Property rule: an unchecked answer leaves both sides of the rate.
26956
+ citationCoverage: citationRate(unique3, (candidate) => candidate.observation.competitorCitationDomains.includes(domain))
26914
26957
  };
26915
26958
  });
26916
26959
  }
@@ -26950,7 +26993,7 @@ function breakdown(candidates, definition, populationTargetKeys = selectedTarget
26950
26993
  const groupTargetKeys = new Set(targetKeys);
26951
26994
  const own = candidates.map((candidate) => narrowedToTargetKeys(candidate, groupTargetKeys)).filter((candidate) => candidate !== null);
26952
26995
  const metrics = mentionRate2(own, targets);
26953
- const citations = rate(own.map((candidate) => citationForCoverage(candidate, targets)), own.length);
26996
+ const citations = targetCitationRate(own, targets);
26954
26997
  return {
26955
26998
  id: group.id,
26956
26999
  label: group.label,
@@ -27578,6 +27621,8 @@ function frozenSimpleRun(run, definition, snapshots, includeEvidence = true) {
27578
27621
  mentionComplete: snapshot.answerText !== null || snapshot.answerMentioned !== null,
27579
27622
  mentionedTargetKeys: mentioned ? ["project"] : [],
27580
27623
  citedTargetKeys: snapshot.citationState === "cited" ? ["project"] : [],
27624
+ // Simple citation state comes from the stored cited domains, not from
27625
+ // source-URL capture, so a saved Simple answer is never `unchecked`.
27581
27626
  citationComplete: true,
27582
27627
  competitorMentionDomains: competitorSignalsForSnapshot.mentioned,
27583
27628
  competitorCitationDomains: competitorSignalsForSnapshot.cited,
@@ -27660,6 +27705,7 @@ function legacySimpleRun(project, run, snapshots, includeEvidence = true) {
27660
27705
  mentionComplete: snapshot.answerMentioned !== null,
27661
27706
  mentionedTargetKeys: snapshot.answerMentioned === true ? ["project"] : [],
27662
27707
  citedTargetKeys: snapshot.citationState === "cited" ? ["project"] : [],
27708
+ // Legacy rows carry only the stored citation state (see the frozen-simple note).
27663
27709
  citationComplete: true,
27664
27710
  competitorMentionDomains: [],
27665
27711
  competitorCitationDomains: [],
@@ -29977,6 +30023,8 @@ async function applyRoutes(app, opts) {
29977
30023
  locations: config.spec.locations ?? [],
29978
30024
  defaultLocation: config.spec.defaultLocation ?? null,
29979
30025
  autoExtractBacklinks: config.spec.autoExtractBacklinks ?? false,
30026
+ // Present sets (null = full site); absent leaves the stored budget alone.
30027
+ ...config.spec.siteAuditMaxPages !== void 0 ? { siteAuditMaxPages: config.spec.siteAuditMaxPages } : {},
29980
30028
  configSource: "config-file",
29981
30029
  configRevision: existing.configRevision + 1,
29982
30030
  updatedAt: now
@@ -30010,6 +30058,7 @@ async function applyRoutes(app, opts) {
30010
30058
  locations: config.spec.locations ?? [],
30011
30059
  defaultLocation: config.spec.defaultLocation ?? null,
30012
30060
  autoExtractBacklinks: config.spec.autoExtractBacklinks ?? false,
30061
+ siteAuditMaxPages: config.spec.siteAuditMaxPages ?? null,
30013
30062
  configSource: "config-file",
30014
30063
  configRevision: 1,
30015
30064
  createdAt: now,
@@ -30156,6 +30205,7 @@ async function applyRoutes(app, opts) {
30156
30205
  defaultLocation: project.defaultLocation,
30157
30206
  autoExtractBacklinks: project.autoExtractBacklinks,
30158
30207
  negativeReviewMaxStars: readNegativeReviewMaxStars(app.db, project.id),
30208
+ siteAuditMaxPages: project.siteAuditMaxPages ?? null,
30159
30209
  configSource: project.configSource,
30160
30210
  configRevision: project.configRevision,
30161
30211
  createdAt: project.createdAt,
@@ -35327,6 +35377,7 @@ function formatProject2(row, negativeReviewMaxStars) {
35327
35377
  defaultLocation: row.defaultLocation,
35328
35378
  autoExtractBacklinks: row.autoExtractBacklinks,
35329
35379
  negativeReviewMaxStars,
35380
+ siteAuditMaxPages: row.siteAuditMaxPages ?? null,
35330
35381
  configSource: row.configSource,
35331
35382
  configRevision: row.configRevision,
35332
35383
  createdAt: row.createdAt,
@@ -43838,7 +43889,7 @@ var routeCatalog = [
43838
43889
  properties: {
43839
43890
  sitemapUrl: { ...stringSchema, description: "Deprecated compatibility alias for the sitemap override." },
43840
43891
  limit: { ...integerSchema, minimum: 1, maximum: 2e3, description: "Deprecated compatibility alias for maxPages. Max 2000." },
43841
- maxPages: { ...integerSchema, minimum: 1, maximum: 5e4, description: "Crawl page budget. Defaults to 1000; max 50000." },
43892
+ maxPages: { ...integerSchema, minimum: 1, maximum: 5e4, description: "Crawl page budget. When omitted, the project's saved budget (siteAuditMaxPages) applies, else the full site; max 50000." },
43842
43893
  maxEdges: { ...integerSchema, minimum: 1, maximum: 1e6, description: "Internal-link observation budget. When omitted the crawl engine derives it from the page count; max 1000000." },
43843
43894
  maxDepth: { ...integerSchema, minimum: 0, maximum: 100, description: "Maximum crawl depth from the root. Max 100." },
43844
43895
  checkDeadLinks: { ...booleanSchema, default: false, description: "Enable dead-link checking. Defaults to false." }
@@ -86752,7 +86803,7 @@ async function technicalAeoRoutes(app, opts) {
86752
86803
  reason: "no-site-audit-handler"
86753
86804
  });
86754
86805
  }
86755
- const effectiveRequest = normalizeSiteAuditRunRequest(parsed.data);
86806
+ const effectiveRequest = normalizeSiteAuditRunRequest(parsed.data, project.siteAuditMaxPages);
86756
86807
  const identityKey = siteAuditRequestIdentity(effectiveRequest);
86757
86808
  const result = app.db.transaction((tx) => {
86758
86809
  const existing = tx.select({