@ainyc/canonry 4.1.3 → 4.2.2

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.
@@ -4,8 +4,9 @@ import {
4
4
  configExists,
5
5
  loadConfig,
6
6
  saveConfigPatch
7
- } from "./chunk-KCETXLDF.js";
7
+ } from "./chunk-SR7TGHHG.js";
8
8
  import {
9
+ DEFAULT_RUN_HISTORY_LIMIT,
9
10
  IntelligenceService,
10
11
  MIN_TREND_POINTS,
11
12
  agentMemory,
@@ -16,11 +17,22 @@ import {
16
17
  backlinkSummaries,
17
18
  bingCoverageSnapshots,
18
19
  bingUrlInspections,
20
+ buildAiSourceOrigin,
19
21
  buildBrandTokens,
22
+ buildCitationScorecard,
23
+ buildCompetitorLandscape,
24
+ buildCompetitorPressureScore,
20
25
  buildContentGapRows,
21
26
  buildContentSourceRows,
22
27
  buildContentTargetRows,
28
+ buildGapQueryScore,
23
29
  buildInventory,
30
+ buildMentionLandscape,
31
+ buildMovementSummary,
32
+ buildOverviewCompetitors,
33
+ buildProviderScores,
34
+ buildRunHistory,
35
+ buildVisibilityScore,
24
36
  categorizeQueryByIntent,
25
37
  ccReleaseSyncs,
26
38
  competitors,
@@ -49,7 +61,7 @@ import {
49
61
  runs,
50
62
  schedules,
51
63
  usageCounters
52
- } from "./chunk-AXMSAMKN.js";
64
+ } from "./chunk-7YSI4GFA.js";
53
65
  import {
54
66
  AGENT_MEMORY_VALUE_MAX_BYTES,
55
67
  AGENT_PROVIDER_IDS,
@@ -115,7 +127,7 @@ import {
115
127
  visibilityStateFromAnswerMentioned,
116
128
  windowCutoff,
117
129
  wordpressEnvSchema
118
- } from "./chunk-O5JZQUPX.js";
130
+ } from "./chunk-T2I6AO7D.js";
119
131
 
120
132
  // src/telemetry.ts
121
133
  import crypto from "crypto";
@@ -3846,7 +3858,6 @@ function extractPath(url) {
3846
3858
  var TOP_QUERIES_LIMIT = 20;
3847
3859
  var TOP_LANDING_PAGES_LIMIT = 20;
3848
3860
  var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
3849
- var TOP_SOURCE_DOMAINS_LIMIT = 20;
3850
3861
  var TOP_CAMPAIGN_LIMIT = 10;
3851
3862
  var INSIGHT_LOOKBACK_RUNS = 5;
3852
3863
  function safeNum(value) {
@@ -3857,14 +3868,6 @@ function safeNum(value) {
3857
3868
  }
3858
3869
  return 0;
3859
3870
  }
3860
- function citedDomainBelongsToProject(citedDomain, projectDomains) {
3861
- const candidate = normalizeProjectDomain(citedDomain);
3862
- for (const domain of projectDomains) {
3863
- const normalized = normalizeProjectDomain(domain);
3864
- if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
3865
- }
3866
- return false;
3867
- }
3868
3871
  function categorizeQuery(query, projectDisplayName, canonicalDomain) {
3869
3872
  return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectDisplayName));
3870
3873
  }
@@ -3891,186 +3894,6 @@ function loadQueryLookup(db, projectId) {
3891
3894
  for (const row of rows) byId.set(row.id, row.query);
3892
3895
  return { byId };
3893
3896
  }
3894
- function buildCitationScorecard(snapshots, queryLookup) {
3895
- if (snapshots.length === 0) {
3896
- return { queries: [], providers: [], matrix: [], providerRates: [] };
3897
- }
3898
- const querySet = /* @__PURE__ */ new Set();
3899
- const providerSet = /* @__PURE__ */ new Set();
3900
- for (const snap of snapshots) {
3901
- const q = queryLookup.byId.get(snap.queryId);
3902
- if (!q) continue;
3903
- querySet.add(q);
3904
- providerSet.add(snap.provider);
3905
- }
3906
- const queryList = [...querySet].sort();
3907
- const providerList = [...providerSet].sort();
3908
- const matrix = queryList.map(
3909
- () => providerList.map(() => null)
3910
- );
3911
- const providerCounts = /* @__PURE__ */ new Map();
3912
- for (const snap of snapshots) {
3913
- const q = queryLookup.byId.get(snap.queryId);
3914
- if (!q) continue;
3915
- const qi = queryList.indexOf(q);
3916
- const pi = providerList.indexOf(snap.provider);
3917
- if (qi < 0 || pi < 0) continue;
3918
- matrix[qi][pi] = {
3919
- citationState: snap.citationState === CitationStates.cited ? CitationStates.cited : CitationStates["not-cited"],
3920
- answerMentioned: snap.answerMentioned ?? null,
3921
- model: snap.model
3922
- };
3923
- const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
3924
- counts.total++;
3925
- if (snap.citationState === CitationStates.cited) counts.cited++;
3926
- providerCounts.set(snap.provider, counts);
3927
- }
3928
- const providerRates = providerList.map((provider) => {
3929
- const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
3930
- const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
3931
- return {
3932
- provider,
3933
- citedCount: counts.cited,
3934
- totalCount: counts.total,
3935
- citationRate
3936
- };
3937
- });
3938
- return { queries: queryList, providers: providerList, matrix, providerRates };
3939
- }
3940
- function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, queryLookup) {
3941
- let projectCitationCount = 0;
3942
- const competitorMap = /* @__PURE__ */ new Map();
3943
- for (const c of competitorDomains) {
3944
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
3945
- }
3946
- for (const snap of snapshots) {
3947
- const q = queryLookup.byId.get(snap.queryId);
3948
- const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
3949
- if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
3950
- projectCitationCount++;
3951
- }
3952
- for (const competitor of competitorDomains) {
3953
- if (allDomains.some((d) => citedDomainBelongsToProject(d, [competitor]))) {
3954
- const entry = competitorMap.get(competitor);
3955
- entry.count++;
3956
- if (q) entry.queries.add(q);
3957
- }
3958
- const competitorNorm = normalizeDomain(competitor);
3959
- for (const gs of snap.groundingSources) {
3960
- const host = normalizeDomain(extractHostFromUri(gs.uri));
3961
- if (!host) continue;
3962
- if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
3963
- const entry = competitorMap.get(competitor);
3964
- const pageQueries = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
3965
- if (q) pageQueries.add(q);
3966
- entry.pages.set(gs.uri, pageQueries);
3967
- }
3968
- }
3969
- }
3970
- }
3971
- const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
3972
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
3973
- const total = snapshots.length;
3974
- const ratio = total > 0 ? data.count / total : 0;
3975
- let pressureLabel = "None";
3976
- if (data.count > 0) {
3977
- if (ratio >= 0.5) pressureLabel = "High";
3978
- else if (ratio >= 0.2) pressureLabel = "Moderate";
3979
- else pressureLabel = "Low";
3980
- }
3981
- const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
3982
- const theirCitedPages = [...data.pages.entries()].map(([url, qs]) => ({ url, citedFor: [...qs].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
3983
- return {
3984
- domain,
3985
- citationCount: data.count,
3986
- totalCount: total,
3987
- pressureLabel,
3988
- citedQueries: [...data.queries].sort(),
3989
- sharePct,
3990
- theirCitedPages
3991
- };
3992
- });
3993
- competitorRows.sort((a, b) => b.citationCount - a.citationCount);
3994
- return { projectCitationCount, competitors: competitorRows };
3995
- }
3996
- function buildMentionLandscape(snapshots, competitorDomains, projectDisplayName, projectDomains, queryLookup) {
3997
- let projectMentionCount = 0;
3998
- let totalAnswerSnapshots = 0;
3999
- const competitorMap = /* @__PURE__ */ new Map();
4000
- for (const c of competitorDomains) {
4001
- competitorMap.set(c, { count: 0, queries: /* @__PURE__ */ new Set() });
4002
- }
4003
- for (const snap of snapshots) {
4004
- const text = snap.answerText;
4005
- if (!text) continue;
4006
- totalAnswerSnapshots++;
4007
- const q = queryLookup.byId.get(snap.queryId);
4008
- const projectMentioned = snap.answerMentioned ?? determineAnswerMentioned(
4009
- text,
4010
- projectDisplayName,
4011
- projectDomains
4012
- );
4013
- if (projectMentioned) projectMentionCount++;
4014
- for (const competitor of competitorDomains) {
4015
- const brand = brandLabelFromDomain(competitor);
4016
- const mentioned = determineAnswerMentioned(text, brand, [competitor]);
4017
- if (mentioned) {
4018
- const entry = competitorMap.get(competitor);
4019
- entry.count++;
4020
- if (q) entry.queries.add(q);
4021
- }
4022
- }
4023
- }
4024
- const totalMentionedSlots = projectMentionCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
4025
- const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
4026
- const ratio = totalAnswerSnapshots > 0 ? data.count / totalAnswerSnapshots : 0;
4027
- let pressureLabel = "None";
4028
- if (data.count > 0) {
4029
- if (ratio >= 0.5) pressureLabel = "High";
4030
- else if (ratio >= 0.2) pressureLabel = "Moderate";
4031
- else pressureLabel = "Low";
4032
- }
4033
- const sharePct = totalMentionedSlots > 0 ? Math.round(data.count / totalMentionedSlots * 100) : 0;
4034
- return {
4035
- domain,
4036
- mentionCount: data.count,
4037
- totalCount: totalAnswerSnapshots,
4038
- pressureLabel,
4039
- mentionedQueries: [...data.queries].sort(),
4040
- sharePct
4041
- };
4042
- });
4043
- competitorRows.sort((a, b) => b.mentionCount - a.mentionCount);
4044
- return { projectMentionCount, totalAnswerSnapshots, competitors: competitorRows };
4045
- }
4046
- function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
4047
- const categoryCounts = /* @__PURE__ */ new Map();
4048
- const domainCounts = /* @__PURE__ */ new Map();
4049
- let totalCitations = 0;
4050
- for (const snap of snapshots) {
4051
- for (const raw of snap.citedDomains) {
4052
- if (citedDomainBelongsToProject(raw, projectDomains)) continue;
4053
- const { category, label, domain } = categorizeSource(raw);
4054
- const cat = categoryCounts.get(category) ?? { label, count: 0 };
4055
- cat.count++;
4056
- categoryCounts.set(category, cat);
4057
- domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
4058
- totalCitations++;
4059
- }
4060
- }
4061
- const categories = [...categoryCounts.entries()].map(([category, { label, count }]) => ({
4062
- category,
4063
- label,
4064
- count,
4065
- sharePct: totalCitations > 0 ? Math.round(count / totalCitations * 100) : 0
4066
- })).sort((a, b) => b.count - a.count);
4067
- const topDomains = [...domainCounts.entries()].map(([domain, count]) => ({
4068
- domain,
4069
- count,
4070
- isCompetitor: citedDomainBelongsToProject(domain, competitorDomains)
4071
- })).sort((a, b) => b.count - a.count).slice(0, TOP_SOURCE_DOMAINS_LIMIT);
4072
- return { categories, topDomains };
4073
- }
4074
3897
  function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedQueries) {
4075
3898
  const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
4076
3899
  if (rows.length === 0) return null;
@@ -4785,24 +4608,68 @@ function normalizeDomain2(domain) {
4785
4608
  }
4786
4609
 
4787
4610
  // ../api-routes/src/composites.ts
4788
- import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3 } from "drizzle-orm";
4611
+ import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3, inArray as inArray6 } from "drizzle-orm";
4789
4612
  var TOP_INSIGHT_LIMIT = 5;
4790
4613
  var SEARCH_HIT_HARD_LIMIT = 50;
4791
4614
  var SEARCH_SNIPPET_RADIUS = 80;
4792
4615
  async function compositeRoutes(app) {
4793
4616
  app.get("/projects/:name/overview", async (request, reply) => {
4794
4617
  const project = resolveProject(app.db, request.params.name);
4795
- const totalRunsRow = app.db.select({ count: sql3`count(*)` }).from(runs).where(eq15(runs.projectId, project.id)).get();
4796
- const totalRuns = totalRunsRow?.count ?? 0;
4797
- const recentRuns = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).limit(2).all();
4798
- const [latestRunRow, previousRunRow] = recentRuns;
4618
+ const filterLocation = (request.query.location ?? "").trim() || null;
4619
+ const sinceIso = parseSinceFilter(request.query.since);
4620
+ const allRunsRaw = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).all();
4621
+ const allRuns = allRunsRaw.filter((r) => runMatchesFilters(r, filterLocation, sinceIso));
4622
+ const totalRuns = allRuns.length;
4623
+ const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
4624
+ const completedVisRuns = visibilityRuns.filter(
4625
+ (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
4626
+ );
4627
+ const latestVisibilityRun = completedVisRuns[0] ?? null;
4628
+ const previousVisibilityRun = completedVisRuns[1] ?? null;
4629
+ const latestRunRow = allRuns[0] ?? null;
4799
4630
  const latestRun = latestRunRow ? { totalRuns, run: summarizeRun(latestRunRow) } : { totalRuns: 0, run: null };
4800
4631
  const healthRow = app.db.select().from(healthSnapshots).where(eq15(healthSnapshots.projectId, project.id)).orderBy(desc7(healthSnapshots.createdAt)).limit(1).get();
4801
4632
  const health = healthRow ? mapHealthRow2(healthRow) : null;
4802
4633
  const insightRows = app.db.select().from(insights).where(eq15(insights.projectId, project.id)).orderBy(desc7(insights.createdAt)).all();
4803
4634
  const topInsights = insightRows.filter((row) => !row.dismissed).slice(0, TOP_INSIGHT_LIMIT).map(mapInsightRow2);
4804
- const { queryCounts, providers } = summarizeLatestRun(app, latestRunRow ?? null);
4805
- const transitions = summarizeTransitions(app, latestRunRow ?? null, previousRunRow ?? null);
4635
+ const sparklineRunIds = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => r.id);
4636
+ const snapshotRunIds = new Set(sparklineRunIds);
4637
+ if (latestVisibilityRun) snapshotRunIds.add(latestVisibilityRun.id);
4638
+ if (previousVisibilityRun) snapshotRunIds.add(previousVisibilityRun.id);
4639
+ const snapshotsByRun = loadSnapshotsByRunIds(app, [...snapshotRunIds]);
4640
+ const latestSnapshots = latestVisibilityRun ? snapshotsByRun.get(latestVisibilityRun.id) ?? [] : [];
4641
+ const previousSnapshots = previousVisibilityRun ? snapshotsByRun.get(previousVisibilityRun.id) ?? [] : [];
4642
+ const { queryCounts, providers } = summarizeFromSnapshots(latestSnapshots);
4643
+ const transitions = summarizeTransitionsFromSnapshots(
4644
+ latestSnapshots,
4645
+ previousSnapshots,
4646
+ previousVisibilityRun?.createdAt ?? null
4647
+ );
4648
+ const competitorRows = app.db.select().from(competitors).where(eq15(competitors.projectId, project.id)).all();
4649
+ const projectQueries = app.db.select({ id: queries.id, query: queries.query }).from(queries).where(eq15(queries.projectId, project.id)).all();
4650
+ const queryLookup = { byId: new Map(projectQueries.map((q) => [q.id, q.query])) };
4651
+ const configuredApiProviders = parseJsonColumn(project.providers, []).filter((p) => !p.startsWith("cdp:"));
4652
+ const scores = {
4653
+ visibility: buildVisibilityScore(latestSnapshots, { configuredApiProviders }),
4654
+ gapQueries: buildGapQueryScore(latestSnapshots),
4655
+ indexCoverage: buildIndexCoverageScore(app, project.id),
4656
+ competitorPressure: buildCompetitorPressureScore(
4657
+ latestSnapshots,
4658
+ competitorRows.map((c) => c.domain),
4659
+ competitorRows.length
4660
+ ),
4661
+ runStatus: buildRunStatusScore(allRuns)
4662
+ };
4663
+ const movementSummary = buildMovementSummary(latestSnapshots, previousSnapshots);
4664
+ const providerScores = buildProviderScores(latestSnapshots);
4665
+ const overviewCompetitors = buildOverviewCompetitors(
4666
+ latestSnapshots,
4667
+ competitorRows.map((c) => ({ id: c.id, domain: c.domain })),
4668
+ queryLookup
4669
+ );
4670
+ const attentionItems = buildAttentionItems(insightRows, allRuns);
4671
+ const sparklineRuns = visibilityRuns.slice(0, DEFAULT_RUN_HISTORY_LIMIT).map((r) => ({ id: r.id, createdAt: r.createdAt, status: r.status }));
4672
+ const runHistory = buildRunHistory(sparklineRuns, snapshotsByRun);
4806
4673
  const result = {
4807
4674
  project: formatProject2(project),
4808
4675
  latestRun,
@@ -4810,7 +4677,15 @@ async function compositeRoutes(app) {
4810
4677
  topInsights,
4811
4678
  queryCounts,
4812
4679
  providers,
4813
- transitions
4680
+ transitions,
4681
+ scores,
4682
+ movementSummary,
4683
+ competitors: overviewCompetitors,
4684
+ providerScores,
4685
+ attentionItems,
4686
+ runHistory,
4687
+ dateRangeLabel: "All time",
4688
+ contextLabel: `${project.country} / ${project.language.toUpperCase()}`
4814
4689
  };
4815
4690
  return reply.send(result);
4816
4691
  });
@@ -4876,6 +4751,21 @@ async function compositeRoutes(app) {
4876
4751
  return reply.send(response);
4877
4752
  });
4878
4753
  }
4754
+ function parseSinceFilter(raw) {
4755
+ if (!raw) return null;
4756
+ const trimmed = raw.trim();
4757
+ if (!trimmed) return null;
4758
+ const parsed = Date.parse(trimmed);
4759
+ if (Number.isNaN(parsed)) {
4760
+ throw validationError('"since" must be an ISO 8601 datetime');
4761
+ }
4762
+ return new Date(parsed).toISOString();
4763
+ }
4764
+ function runMatchesFilters(run, location, sinceIso) {
4765
+ if (location !== null && (run.location ?? "") !== location) return false;
4766
+ if (sinceIso !== null && run.createdAt < sinceIso) return false;
4767
+ return true;
4768
+ }
4879
4769
  function clampSearchLimit(raw) {
4880
4770
  if (!raw) return 25;
4881
4771
  const parsed = Number.parseInt(raw, 10);
@@ -4901,29 +4791,49 @@ function summarizeRun(run) {
4901
4791
  createdAt: run.createdAt
4902
4792
  };
4903
4793
  }
4904
- function summarizeLatestRun(app, run) {
4794
+ function loadSnapshotsByRunIds(app, runIds) {
4795
+ const result = /* @__PURE__ */ new Map();
4796
+ if (runIds.length === 0) return result;
4797
+ const rows = app.db.select({
4798
+ runId: querySnapshots.runId,
4799
+ queryId: querySnapshots.queryId,
4800
+ provider: querySnapshots.provider,
4801
+ model: querySnapshots.model,
4802
+ citationState: querySnapshots.citationState,
4803
+ competitorOverlap: querySnapshots.competitorOverlap,
4804
+ citedDomains: querySnapshots.citedDomains
4805
+ }).from(querySnapshots).where(inArray6(querySnapshots.runId, [...runIds])).all();
4806
+ for (const row of rows) {
4807
+ const list = result.get(row.runId) ?? [];
4808
+ list.push({
4809
+ queryId: row.queryId,
4810
+ provider: row.provider,
4811
+ model: row.model,
4812
+ citationState: row.citationState,
4813
+ competitorOverlap: parseJsonColumn(row.competitorOverlap, []),
4814
+ citedDomains: parseJsonColumn(row.citedDomains, [])
4815
+ });
4816
+ result.set(row.runId, list);
4817
+ }
4818
+ return result;
4819
+ }
4820
+ function summarizeFromSnapshots(snapshots) {
4905
4821
  const empty = {
4906
4822
  queryCounts: { totalQueries: 0, citedQueries: 0, notCitedQueries: 0, citedRate: 0 },
4907
4823
  providers: []
4908
4824
  };
4909
- if (!run) return empty;
4910
- const rows = app.db.select({
4911
- queryId: querySnapshots.queryId,
4912
- provider: querySnapshots.provider,
4913
- citationState: querySnapshots.citationState
4914
- }).from(querySnapshots).where(eq15(querySnapshots.runId, run.id)).all();
4915
- if (rows.length === 0) return empty;
4825
+ if (snapshots.length === 0) return empty;
4916
4826
  const perQuery = /* @__PURE__ */ new Map();
4917
4827
  const perProvider = /* @__PURE__ */ new Map();
4918
- for (const row of rows) {
4919
- const cited = row.citationState === CitationStates.cited;
4920
- if (!perQuery.has(row.queryId) || cited) {
4921
- perQuery.set(row.queryId, cited);
4828
+ for (const snap of snapshots) {
4829
+ const cited = snap.citationState === CitationStates.cited;
4830
+ if (!perQuery.has(snap.queryId) || cited) {
4831
+ perQuery.set(snap.queryId, cited);
4922
4832
  }
4923
- const bucket = perProvider.get(row.provider) ?? { cited: 0, total: 0 };
4833
+ const bucket = perProvider.get(snap.provider) ?? { cited: 0, total: 0 };
4924
4834
  bucket.total += 1;
4925
4835
  if (cited) bucket.cited += 1;
4926
- perProvider.set(row.provider, bucket);
4836
+ perProvider.set(snap.provider, bucket);
4927
4837
  }
4928
4838
  const totalQueries = perQuery.size;
4929
4839
  let citedQueries = 0;
@@ -4943,23 +4853,20 @@ function summarizeLatestRun(app, run) {
4943
4853
  providers
4944
4854
  };
4945
4855
  }
4946
- function summarizeTransitions(app, latest, previous) {
4947
- const empty = { since: null, gained: 0, lost: 0, emerging: 0 };
4948
- if (!latest || !previous) return empty;
4949
- const fetchCited = (runId) => {
4950
- const rows = app.db.select({
4951
- queryId: querySnapshots.queryId,
4952
- citationState: querySnapshots.citationState
4953
- }).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
4954
- const map = /* @__PURE__ */ new Map();
4955
- for (const row of rows) {
4956
- const cited = row.citationState === CitationStates.cited;
4957
- if (!map.has(row.queryId) || cited) map.set(row.queryId, cited);
4856
+ function summarizeTransitionsFromSnapshots(latest, previous, since) {
4857
+ if (!since || previous.length === 0) {
4858
+ return { since: null, gained: 0, lost: 0, emerging: 0 };
4859
+ }
4860
+ const buildMap = (snaps) => {
4861
+ const m = /* @__PURE__ */ new Map();
4862
+ for (const s of snaps) {
4863
+ const cited = s.citationState === CitationStates.cited;
4864
+ if (!m.has(s.queryId) || cited) m.set(s.queryId, cited);
4958
4865
  }
4959
- return map;
4866
+ return m;
4960
4867
  };
4961
- const latestMap = fetchCited(latest.id);
4962
- const previousMap = fetchCited(previous.id);
4868
+ const latestMap = buildMap(latest);
4869
+ const previousMap = buildMap(previous);
4963
4870
  let gained = 0;
4964
4871
  let lost = 0;
4965
4872
  let emerging = 0;
@@ -4972,7 +4879,142 @@ function summarizeTransitions(app, latest, previous) {
4972
4879
  if (latestCited && !previousCited) gained += 1;
4973
4880
  else if (!latestCited && previousCited) lost += 1;
4974
4881
  }
4975
- return { since: previous.createdAt, gained, lost, emerging };
4882
+ return { since, gained, lost, emerging };
4883
+ }
4884
+ function buildIndexCoverageScore(app, projectId) {
4885
+ const tooltip = "Percentage of inspected URLs currently indexed. Google Search Console is preferred when available, otherwise Bing Webmaster Tools is used.";
4886
+ const empty = {
4887
+ label: "Index Coverage",
4888
+ value: "No data",
4889
+ delta: "Connect GSC or Bing",
4890
+ tone: "neutral",
4891
+ description: "Connect Google Search Console or Bing Webmaster Tools and inspect your sitemap to populate coverage.",
4892
+ tooltip,
4893
+ trend: []
4894
+ };
4895
+ const gscRow = app.db.select().from(gscCoverageSnapshots).where(eq15(gscCoverageSnapshots.projectId, projectId)).orderBy(desc7(gscCoverageSnapshots.date)).limit(1).get();
4896
+ const bingRow = app.db.select().from(bingCoverageSnapshots).where(eq15(bingCoverageSnapshots.projectId, projectId)).orderBy(desc7(bingCoverageSnapshots.date)).limit(1).get();
4897
+ const chosen = pickIndexCoverageRow(gscRow, bingRow);
4898
+ if (!chosen) return empty;
4899
+ const total = chosen.indexed + chosen.notIndexed;
4900
+ if (total === 0) return empty;
4901
+ const deindexed = chosen.provider === "Google" ? countGoogleDeindexedUrls(app, projectId) : 0;
4902
+ const percentage = chosen.indexed / total * 100;
4903
+ const tone = deindexed > 0 ? "negative" : percentage >= 90 ? "positive" : percentage >= 70 ? "caution" : "negative";
4904
+ const notIndexedLabel = chosen.notIndexed === 1 ? "URL is" : "URLs are";
4905
+ const deindexedLabel = deindexed === 1 ? "URL" : "URLs";
4906
+ return {
4907
+ label: "Index Coverage",
4908
+ value: `${Math.round(percentage)}`,
4909
+ delta: `${chosen.provider} \xB7 ${chosen.indexed} of ${total} indexed`,
4910
+ tone,
4911
+ description: deindexed > 0 ? `${deindexed} deindexed ${deindexedLabel} detected in the latest Google Search Console inspection.` : `${chosen.notIndexed} ${notIndexedLabel} not indexed in ${chosen.provider === "Google" ? "Google Search Console" : "Bing Webmaster Tools"}.`,
4912
+ tooltip,
4913
+ trend: [],
4914
+ progress: Math.round(percentage)
4915
+ };
4916
+ }
4917
+ function countGoogleDeindexedUrls(app, projectId) {
4918
+ const rows = app.db.select({
4919
+ url: gscUrlInspections.url,
4920
+ indexingState: gscUrlInspections.indexingState,
4921
+ inspectedAt: gscUrlInspections.inspectedAt
4922
+ }).from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).orderBy(desc7(gscUrlInspections.inspectedAt)).all();
4923
+ if (rows.length === 0) return 0;
4924
+ const canonicalUrl = (url) => url.replace(/^http:\/\//, "https://");
4925
+ const historyByUrl = /* @__PURE__ */ new Map();
4926
+ for (const row of rows) {
4927
+ const key = canonicalUrl(row.url);
4928
+ const list = historyByUrl.get(key);
4929
+ if (list) list.push(row);
4930
+ else historyByUrl.set(key, [row]);
4931
+ }
4932
+ let deindexed = 0;
4933
+ for (const history of historyByUrl.values()) {
4934
+ if (history.length < 2) continue;
4935
+ const latest = history[0];
4936
+ const previous = history[1];
4937
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
4938
+ deindexed++;
4939
+ }
4940
+ }
4941
+ return deindexed;
4942
+ }
4943
+ function pickIndexCoverageRow(gsc, bing) {
4944
+ if (gsc && gsc.indexed + gsc.notIndexed > 0) {
4945
+ return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
4946
+ }
4947
+ if (bing && bing.indexed + bing.notIndexed > 0) {
4948
+ return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
4949
+ }
4950
+ if (gsc) return { provider: "Google", indexed: gsc.indexed, notIndexed: gsc.notIndexed };
4951
+ if (bing) return { provider: "Bing", indexed: bing.indexed, notIndexed: bing.notIndexed };
4952
+ return null;
4953
+ }
4954
+ function buildRunStatusScore(allRuns) {
4955
+ const tooltip = "Current execution state of visibility sweeps. Shows the status of the most recent run and total run count.";
4956
+ if (allRuns.length === 0) {
4957
+ return {
4958
+ label: "Run Status",
4959
+ value: "None",
4960
+ delta: "No runs yet",
4961
+ tone: "neutral",
4962
+ description: "Trigger a visibility sweep to start tracking.",
4963
+ tooltip,
4964
+ trend: []
4965
+ };
4966
+ }
4967
+ const latestVisibility = allRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
4968
+ const latest = latestVisibility ?? allRuns[0];
4969
+ const value = latest.status === RunStatuses.completed ? "Healthy" : latest.status === RunStatuses.running ? "Running" : latest.status === RunStatuses.queued ? "Queued" : latest.status === RunStatuses.partial ? "Partial" : "Failed";
4970
+ const tone = latest.status === RunStatuses.completed ? "positive" : latest.status === RunStatuses.failed ? "negative" : latest.status === RunStatuses.partial ? "caution" : "neutral";
4971
+ const visibilityRunCount = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]).length;
4972
+ const syncRunCount = allRuns.length - visibilityRunCount;
4973
+ const delta = syncRunCount > 0 ? `${visibilityRunCount} visibility \xB7 ${syncRunCount} sync` : `${visibilityRunCount} visibility run${visibilityRunCount === 1 ? "" : "s"}`;
4974
+ return {
4975
+ label: "Run Status",
4976
+ value,
4977
+ delta,
4978
+ tone,
4979
+ description: `Latest run ${value.toLowerCase()}. ${allRuns.length} total run${allRuns.length === 1 ? "" : "s"}.`,
4980
+ tooltip,
4981
+ trend: []
4982
+ };
4983
+ }
4984
+ var ATTENTION_INSIGHT_LIMIT = 5;
4985
+ function buildAttentionItems(insightRows, allRuns) {
4986
+ const items = [];
4987
+ for (const row of insightRows) {
4988
+ if (row.dismissed) continue;
4989
+ if (row.severity !== "critical" && row.severity !== "high") continue;
4990
+ if (items.length >= ATTENTION_INSIGHT_LIMIT) break;
4991
+ items.push({
4992
+ id: `insight_${row.id}`,
4993
+ tone: row.severity === "critical" ? "negative" : "caution",
4994
+ title: row.title,
4995
+ detail: row.query ? `On query: ${row.query}` : "",
4996
+ actionLabel: row.severity === "critical" ? "Critical" : "High",
4997
+ href: `#insight-${row.id}`
4998
+ });
4999
+ }
5000
+ const sortedRuns = [...allRuns].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
5001
+ const latestVisRun = sortedRuns.find((r) => r.kind === RunKinds["answer-visibility"]);
5002
+ const latestSyncRun = sortedRuns.find((r) => r.kind !== RunKinds["answer-visibility"]);
5003
+ if (latestVisRun && latestSyncRun) {
5004
+ const visibilityAge = new Date(latestSyncRun.createdAt).getTime() - new Date(latestVisRun.createdAt).getTime();
5005
+ const ONE_DAY = 24 * 60 * 60 * 1e3;
5006
+ if (visibilityAge > ONE_DAY) {
5007
+ items.push({
5008
+ id: "stale_visibility",
5009
+ tone: "caution",
5010
+ title: "Stale visibility data",
5011
+ detail: `Last visibility sweep was ${latestVisRun.createdAt}; integration syncs have run since.`,
5012
+ actionLabel: "Stale",
5013
+ href: "#runs"
5014
+ });
5015
+ }
5016
+ }
5017
+ return items;
4976
5018
  }
4977
5019
  function mapInsightRow2(r) {
4978
5020
  return {
@@ -17299,7 +17341,7 @@ import crypto19 from "crypto";
17299
17341
  import fs7 from "fs";
17300
17342
  import path9 from "path";
17301
17343
  import os4 from "os";
17302
- import { and as and12, eq as eq23, inArray as inArray6, sql as sql7 } from "drizzle-orm";
17344
+ import { and as and12, eq as eq23, inArray as inArray7, sql as sql7 } from "drizzle-orm";
17303
17345
 
17304
17346
  // src/citation-utils.ts
17305
17347
  function domainMatches(domain, canonicalDomain) {
@@ -17556,7 +17598,7 @@ var JobRunner = class {
17556
17598
  this.registry = registry;
17557
17599
  }
17558
17600
  recoverStaleRuns() {
17559
- const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray6(runs.status, ["running", "queued"])).all();
17601
+ const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray7(runs.status, ["running", "queued"])).all();
17560
17602
  if (stale.length === 0) return;
17561
17603
  const now = (/* @__PURE__ */ new Date()).toISOString();
17562
17604
  for (const run of stale) {
@@ -12,7 +12,7 @@ import {
12
12
  queryGenerateRequestSchema,
13
13
  runTriggerRequestSchema,
14
14
  scheduleUpsertRequestSchema
15
- } from "./chunk-O5JZQUPX.js";
15
+ } from "./chunk-T2I6AO7D.js";
16
16
 
17
17
  // src/config.ts
18
18
  import fs from "fs";
@@ -864,8 +864,13 @@ var ApiClient = class {
864
864
  async getHealth(project) {
865
865
  return this.request("GET", `/projects/${encodeURIComponent(project)}/health/latest`);
866
866
  }
867
- async getProjectOverview(project) {
868
- return this.request("GET", `/projects/${encodeURIComponent(project)}/overview`);
867
+ async getProjectOverview(project, opts) {
868
+ const params = new URLSearchParams();
869
+ if (opts?.location) params.set("location", opts.location);
870
+ if (opts?.since) params.set("since", opts.since);
871
+ const query = params.toString();
872
+ const path2 = `/projects/${encodeURIComponent(project)}/overview${query ? `?${query}` : ""}`;
873
+ return this.request("GET", path2);
869
874
  }
870
875
  async searchProject(project, opts) {
871
876
  const params = new URLSearchParams({ q: opts.q });
@@ -1153,13 +1158,20 @@ var canonryMcpTools = [
1153
1158
  defineTool({
1154
1159
  name: "canonry_project_overview",
1155
1160
  title: "Get project overview (composite)",
1156
- description: 'One-call summary for "how is project X doing?" \u2014 bundles project info, latest run, top undismissed insights, latest health snapshot, query cited rate, per-provider breakdown, and gained/lost/emerging vs the previous run. Prefer this over fanning out to separate tools.',
1161
+ description: 'One-call summary for "how is project X doing?" \u2014 bundles project info, latest run, top undismissed insights, latest health snapshot, query cited rate, per-provider breakdown, gained/lost/emerging vs the previous run, the five score gauges (visibility, gap queries, index coverage, competitor pressure, run status), per-(provider, model) scores, configured competitors with pressure labels, an attention queue of critical/high insights, and a recent-runs sparkline. Filterable by location and time window. Prefer this over fanning out to separate tools.',
1157
1162
  access: "read",
1158
1163
  tier: "core",
1159
- inputSchema: projectInputSchema,
1164
+ inputSchema: z2.object({
1165
+ project: projectNameSchema,
1166
+ location: z2.string().optional().describe('Filter to runs from this location label (e.g. "Boston, MA, US"). Omit for all locations.'),
1167
+ since: z2.string().optional().describe("ISO 8601 datetime \u2014 only include runs at or after this time. Omit for full history.")
1168
+ }),
1160
1169
  annotations: readAnnotations(),
1161
1170
  openApiOperations: ["GET /api/v1/projects/{name}/overview"],
1162
- handler: (client, input) => client.getProjectOverview(input.project)
1171
+ handler: (client, input) => client.getProjectOverview(input.project, {
1172
+ location: input.location,
1173
+ since: input.since
1174
+ })
1163
1175
  }),
1164
1176
  defineTool({
1165
1177
  name: "canonry_report",