@canonry/canonry 4.177.1 → 4.177.3

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.
@@ -72,21 +72,17 @@ sync flow does NOT echo the private key back in any response.
72
72
  ## Connecting a WordPress source
73
73
 
74
74
  The WordPress adapter pulls events from the **Canonry Traffic Logger**
75
- WordPress plugin, which captures every non-admin GET page-load **that
76
- reaches PHP** and exposes a paginated REST endpoint protected by an
77
- Application Password.
78
-
79
- > **Cache blind spot.** The plugin is a PHP hook, so it only sees
80
- > requests that execute WordPress. A full-page cache (LiteSpeed, WP
81
- > Rocket, W3 Total Cache, WP Super Cache) or CDN serves cached pages
82
- > before PHP runs, so cache-served page views, including live AI
83
- > user-fetches (Claude-User, ChatGPT-User), are NOT logged. Bot crawls
84
- > of uncached endpoints (sitemap, feeds, assets, cache misses) still
85
- > come through, which can make capture look healthy while real page
86
- > views go uncounted. Exclude AI user-agents from the cache (and any
87
- > CDN), or capture from access/edge logs instead. The
88
- > `traffic.source.cache-blindspot` doctor check warns whenever a
89
- > WordPress source is connected.
75
+ WordPress plugin, which is PHP-only: it captures non-admin GET page-loads
76
+ **that reach PHP** and exposes a paginated REST endpoint protected by an
77
+ Application Password. It has no browser-side capture path.
78
+
79
+ > **Cache blind spot.** Cache-served requests never execute PHP, so they
80
+ > produce no plugin event. A full-page cache (LiteSpeed, WP Rocket, W3 Total
81
+ > Cache, WP Super Cache) or any CDN can therefore make the source look active
82
+ > while real page views, AI crawlers, and live AI user-fetches such as
83
+ > `Claude-User` and `ChatGPT-User` go uncounted. To use this source for
84
+ > AI-agent traffic, bypass **every** cache layer for the selected AI user
85
+ > agents, or capture from access/edge logs instead.
90
86
 
91
87
  **Which user-agents to exclude from the cache** (one per line in
92
88
  LiteSpeed's "Do Not Cache User Agents", WP Rocket's
@@ -94,23 +90,61 @@ LiteSpeed's "Do Not Cache User Agents", WP Rocket's
94
90
  Agents"):
95
91
 
96
92
  ```
97
- Claude-User
98
- ClaudeBot
99
- ChatGPT-User
100
- OAI-SearchBot
101
93
  GPTBot
94
+ OAI-SearchBot
95
+ OAI-AdsBot
96
+ ChatGPT-User
97
+ openai-mcp
98
+ ClaudeBot
99
+ Claude-
100
+ anthropic-ai
102
101
  PerplexityBot
103
102
  Perplexity-User
103
+ ShapBot
104
+ Shap-User
105
+ Google-Agent
106
+ Google-GeminiNotebook
107
+ Google-NotebookLM
108
+ Google-CloudVertexBot
109
+ Bytespider
110
+ Applebot
111
+ meta-externalagent
112
+ CCBot
113
+ cohere-ai
114
+ Diffbot
115
+ MistralAI-User
116
+ MistralAI-Index
117
+ MistralAI-Training
118
+ MistralBot
119
+ DeepSeekBot
120
+ xAI-Bot
121
+ Grok-Bot
122
+ GrokBot
123
+ YouBot
124
+ DuckAssistBot
125
+ Amazonbot
126
+ Amzn-SearchBot
127
+ Amzn-User
104
128
  ```
105
129
 
106
- These are the answer-engine fetchers in both live-user-fetch (`*-User`)
107
- and crawler forms. Do NOT add `Googlebot` or `Bingbot`: caching helps
108
- search crawlers (page speed is a ranking signal, and cached pages let
109
- them crawl more per visit, which matters most on crawl-budget-starved
110
- sites), and their crawl stats are already authoritative in GSC and Bing
111
- Webmaster Tools. Rule of thumb: bypass cache only for agents you cannot
112
- measure elsewhere and that gain nothing from being cached. Answer-engine
113
- fetchers fit both; search crawlers fit neither.
130
+ This list mirrors Canonry's current classifier: answer-engine user-fetch,
131
+ crawl, search, and training agents for which WordPress has no cache-independent
132
+ measurement surface. `ClaudeBot` covers Anthropic's unhyphenated core crawler;
133
+ `Claude-` is a separate family rule so newly named `Claude-*Bot` variants
134
+ inherit the bypass. Do NOT add traditional search agents
135
+ `Googlebot`, `bingbot`, `DuckDuckBot`, `YandexBot`, or `Baiduspider`: caching
136
+ helps them crawl efficiently, and the important Google/Bing crawl evidence is
137
+ available through their webmaster tools.
138
+
139
+ > **1.1.0 -> 1.1.1 measurement boundary.** Plugin 1.1.0 briefly added a
140
+ > JavaScript beacon to recover browser/referral page views served from cache.
141
+ > Plugin 1.1.1 removes it and returns to PHP-only capture. This intentionally
142
+ > means cache-served browser referrals can again be absent even when GA4
143
+ > reports sessions; neither version made cache-served crawler requests visible
144
+ > to PHP. Annotate the upgrade time, do not interpret a trend spanning it as
145
+ > like-for-like traffic, and purge HTML from both the WordPress cache and every
146
+ > outer CDN. A temporary `/wp-json/canonry/v1/pv` compatibility route returns
147
+ > `204` for scripts stranded in old cached HTML, but never records an event.
114
148
 
115
149
  ```bash
116
150
  # 1. Install the plugin. Download the latest release zip from the
@@ -978,7 +1012,7 @@ The doctor checks are adapter-agnostic. When they fail or warn:
978
1012
  | `traffic.source.queue-backlog` | `traffic.queue-backlog.within-drain-budget` | 1–1,000 Queue messages remain. If no new messages arrive, the next scheduled sync can drain them. Run a manual sync to accelerate. |
979
1013
  | `traffic.source.queue-backlog` | `traffic.queue-backlog.remaining` | More than 1,000 Queue messages remain. Run a manual sync. If the backlog persists, shorten the traffic-sync schedule. |
980
1014
  | `traffic.source.credentials` | `traffic.credentials.resolve-failed` | Reconnect from the host that owns the source credentials. Queue pull requires a non-empty Account Queues Edit token paired by source ID. |
981
- | `traffic.source.cache-blindspot` | `traffic.cache-blindspot.wordpress-plugin` | A WordPress source is connected, so the plugin cannot see cache-served page views. Exclude AI user-agents from the page cache and any CDN, or switch to a log/edge source. Warns only, not a failure. |
1015
+ | `traffic.source.cache-blindspot` | `traffic.cache-blindspot.wordpress-plugin` | Cache-served requests never execute PHP and produce no event, so a source can look active while real page views go uncounted. Exclude selected AI user agents from every page-cache and CDN layer, or switch to a log/edge source. Warns only, not a failure. |
982
1016
  | `traffic.source.worker-version` | `traffic.worker-version.waiting-for-first-event` | Send a smoke-test request through the Worker. For Queue delivery, run a sync to ingest it. Then run the doctor again. |
983
1017
  | `traffic.source.worker-version` | `traffic.worker-version.stale` | Regenerate and redeploy the Worker from the credential-owning host with the source's existing delivery mode, then verify the route or Queue binding. |
984
1018
 
@@ -1043,21 +1077,18 @@ domains, or PII are surfaced.
1043
1077
 
1044
1078
  ## Limits & caveats
1045
1079
 
1046
- - **The WordPress plugin is blind to cache-served traffic.** The
1047
- `wordpress` adapter logs only requests that reach PHP. A full-page
1048
- cache or CDN serves cached pages from the edge, so cache-served page
1049
- views, including live AI user-fetches (Claude-User, ChatGPT-User),
1050
- never reach the plugin and go uncounted, even though bot crawls of
1051
- uncached endpoints (sitemap, assets) still appear. On a cached
1052
- WordPress site, treat the plugin's page-view counts as a floor, not a
1053
- total. Either exclude AI user-agents from the cache + CDN, or capture
1054
- cache-independent via a `cloud-run` / `vercel` / edge-log source. The
1055
- `traffic.source.cache-blindspot` doctor check surfaces this. Adapter
1056
- coverage differs: `vercel` ingests edge request-logs so cache hits are
1057
- captured (it records the `cache` HIT/MISS label), and `cloud-run` logs
1058
- every request that reaches the service, missing only what a CDN placed
1059
- in front of Cloud Run serves from its own edge cache. Only the
1060
- hook-based `wordpress` adapter has the always-present blind spot.
1080
+ - **The WordPress plugin is PHP-only.** The `wordpress` adapter records only
1081
+ requests that reach PHP. Cache-served page requests, including AI crawlers
1082
+ and live AI user-fetches, go uncounted even while crawls of uncached
1083
+ sitemaps, assets, or cache misses appear. On a cached WordPress site, treat
1084
+ this source as a floor unless the selected AI user agents bypass every
1085
+ page-cache and CDN layer. The static doctor warning surfaces this limitation
1086
+ without requesting the origin. Otherwise, use a cache-independent
1087
+ `cloud-run`, `vercel`, or edge-log source. Adapter coverage
1088
+ differs: `vercel` ingests edge request logs so cache hits are captured (it
1089
+ records the `cache` HIT/MISS label), and `cloud-run` logs every request that
1090
+ reaches the service, missing only what a CDN ahead of Cloud Run serves at its
1091
+ own edge.
1061
1092
  - **Path-level citation cross-reference is not implemented yet.** The
1062
1093
  citation store is domain-grain (`query_snapshots.cited_domains`). A
1063
1094
  future iteration that lands URL-grain citation evidence will extend
@@ -1078,17 +1109,21 @@ domains, or PII are surfaced.
1078
1109
  `traffic_sources.source_type` and a `TrafficSourceValidator`
1079
1110
  registration.
1080
1111
 
1081
- ### Page caches and the beacon lane
1082
-
1083
- A page cache (LiteSpeed, WP Rocket, Super Cache, any `advanced-cache.php`
1084
- drop-in) serves visitors before WordPress PHP boots, so the plugin's request
1085
- hook never sees those page views: on a cached site the PHP lane records only
1086
- redirects, errors, and crawlers on uncached URLs, and landed AI referrals read
1087
- as zero while GA4 shows sessions. Plugin 1.1.0 adds a beacon lane — a
1088
- first-party inline ping to the plugin's own REST route (never page-cached) —
1089
- that records real browser views, cached or not. It auto-enables when a page
1090
- cache is detected (`Settings Canonry Traffic Logger` can force on/off).
1091
- Bots and non-200s stay in the PHP lane; browser 200s belong to the beacon, so
1092
- uncached views are not double-counted. On sites running plugin ≤ 1.0.x behind
1093
- a cache, treat GA4 as the landed-visit signal and the server source as the
1094
- bot/crawler signal only.
1112
+ ### Page caches and PHP-only coverage
1113
+
1114
+ A page cache (LiteSpeed, WP Rocket, Super Cache, or any
1115
+ `advanced-cache.php` drop-in) can serve a request before WordPress boots. The
1116
+ plugin therefore has one capture lane only: PHP. It cannot recover a
1117
+ cache-served crawler, user fetch, referral, or browser page view after the
1118
+ fact.
1119
+
1120
+ For the AI agents measured by this source, configure cache bypasses at every
1121
+ layer that can answer the public request: the WordPress cache, a host/CDN cache,
1122
+ and any edge cache. A LiteSpeed exclusion alone is insufficient when an outer
1123
+ CDN can return a cached response first.
1124
+
1125
+ Cache exclusions for the selected AI agents can recover crawler and user-fetch
1126
+ coverage, but they do not recover ordinary browser referrals served from cache.
1127
+ Use GA4 for landed browser visits or edge/access logs for cache-independent
1128
+ request coverage. The `traffic.source.cache-blindspot` doctor check is a static
1129
+ warning; it does not request or impersonate traffic against the public origin.
@@ -633,7 +633,7 @@ import {
633
633
  } from "./chunk-PQT3ECM3.js";
634
634
 
635
635
  // src/intelligence-service.ts
636
- import { eq as eq61, desc as desc27, asc as asc11, and as and50, ne as ne8, or as or14, inArray as inArray21, gte as gte14, lte as lte12 } from "drizzle-orm";
636
+ import { eq as eq61, desc as desc27, asc as asc11, and as and50, ne as ne8, or as or14, inArray as inArray21, gte as gte14, lte as lte12, isNull as isNull8, sql as sql25, exists } from "drizzle-orm";
637
637
 
638
638
  // ../db/src/client.ts
639
639
  import { mkdirSync } from "fs";
@@ -7208,6 +7208,13 @@ function detectRegressions(currentRun, previousRun) {
7208
7208
  return regressions;
7209
7209
  }
7210
7210
 
7211
+ // ../intelligence/src/observation-coverage.ts
7212
+ function observedKeys(run, keyOf) {
7213
+ const observed2 = /* @__PURE__ */ new Set();
7214
+ for (const snap of run.snapshots) observed2.add(keyOf(snap));
7215
+ return observed2;
7216
+ }
7217
+
7211
7218
  // ../intelligence/src/gains.ts
7212
7219
  function snapshotKey2(snap) {
7213
7220
  const loc = snap.location ?? "__none__";
@@ -7218,6 +7225,7 @@ function detectGains(currentRun, previousRun) {
7218
7225
  return [];
7219
7226
  }
7220
7227
  const gains = [];
7228
+ const previousObserved = observedKeys(previousRun, snapshotKey2);
7221
7229
  const previousCited = /* @__PURE__ */ new Set();
7222
7230
  for (const snap of previousRun.snapshots) {
7223
7231
  if (snap.cited) {
@@ -7226,7 +7234,7 @@ function detectGains(currentRun, previousRun) {
7226
7234
  }
7227
7235
  for (const snap of currentRun.snapshots) {
7228
7236
  const key = snapshotKey2(snap);
7229
- if (snap.cited && !previousCited.has(key)) {
7237
+ if (snap.cited && previousObserved.has(key) && !previousCited.has(key)) {
7230
7238
  gains.push({
7231
7239
  query: snap.query,
7232
7240
  provider: snap.provider,
@@ -7452,6 +7460,7 @@ function generateInsights(input) {
7452
7460
 
7453
7461
  // ../intelligence/src/first-citations.ts
7454
7462
  function detectFirstCitations(currentRun, previousRun) {
7463
+ const previousObservedQueries = observedKeys(previousRun, (snap) => snap.query);
7455
7464
  const previousCitedQueries = /* @__PURE__ */ new Set();
7456
7465
  for (const snap of previousRun.snapshots) {
7457
7466
  if (snap.cited) previousCitedQueries.add(snap.query);
@@ -7460,6 +7469,7 @@ function detectFirstCitations(currentRun, previousRun) {
7460
7469
  const seen = /* @__PURE__ */ new Set();
7461
7470
  for (const snap of currentRun.snapshots) {
7462
7471
  if (!snap.cited) continue;
7472
+ if (!previousObservedQueries.has(snap.query)) continue;
7463
7473
  if (previousCitedQueries.has(snap.query)) continue;
7464
7474
  const key = `${snap.query}:${snap.provider}`;
7465
7475
  if (seen.has(key)) continue;
@@ -7477,6 +7487,7 @@ function detectFirstCitations(currentRun, previousRun) {
7477
7487
 
7478
7488
  // ../intelligence/src/provider-pickups.ts
7479
7489
  function detectProviderPickups(currentRun, previousRun) {
7490
+ const previousObservedPairs = observedKeys(previousRun, (snap) => `${snap.query}:${snap.provider}`);
7480
7491
  const previousCitedQueries = /* @__PURE__ */ new Set();
7481
7492
  const previousCitedPairs = /* @__PURE__ */ new Set();
7482
7493
  for (const snap of previousRun.snapshots) {
@@ -7490,6 +7501,7 @@ function detectProviderPickups(currentRun, previousRun) {
7490
7501
  if (!snap.cited) continue;
7491
7502
  if (!previousCitedQueries.has(snap.query)) continue;
7492
7503
  const key = `${snap.query}:${snap.provider}`;
7504
+ if (!previousObservedPairs.has(key)) continue;
7493
7505
  if (previousCitedPairs.has(key)) continue;
7494
7506
  if (seen.has(key)) continue;
7495
7507
  seen.add(key);
@@ -7551,11 +7563,13 @@ function detectCompetitorGains(currentRun, previousRun, opts) {
7551
7563
  if (tracked.size === 0) return [];
7552
7564
  const currentMap = buildCompetitorQueryMap(currentRun, tracked);
7553
7565
  const previousMap = buildCompetitorQueryMap(previousRun, tracked);
7566
+ const previousObservedQueries = observedKeys(previousRun, (snap) => snap.query);
7554
7567
  const result = [];
7555
7568
  for (const competitorDomain of tracked) {
7556
7569
  const currentQs = currentMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
7557
7570
  const previousQs = previousMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
7558
7571
  for (const query of currentQs) {
7572
+ if (!previousObservedQueries.has(query)) continue;
7559
7573
  if (previousQs.has(query)) continue;
7560
7574
  result.push({ query, competitorDomain });
7561
7575
  }
@@ -7567,11 +7581,13 @@ function detectCompetitorLosses(currentRun, previousRun, opts) {
7567
7581
  if (tracked.size === 0) return [];
7568
7582
  const currentMap = buildCompetitorQueryMap(currentRun, tracked);
7569
7583
  const previousMap = buildCompetitorQueryMap(previousRun, tracked);
7584
+ const currentObservedQueries = observedKeys(currentRun, (snap) => snap.query);
7570
7585
  const result = [];
7571
7586
  for (const competitorDomain of tracked) {
7572
7587
  const currentQs = currentMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
7573
7588
  const previousQs = previousMap.get(competitorDomain) ?? /* @__PURE__ */ new Set();
7574
7589
  for (const query of previousQs) {
7590
+ if (!currentObservedQueries.has(query)) continue;
7575
7591
  if (currentQs.has(query)) continue;
7576
7592
  result.push({ query, competitorDomain });
7577
7593
  }
@@ -51119,7 +51135,7 @@ async function queryBacklinks(opts) {
51119
51135
  const reversed = opts.targets.map(reverseDomain);
51120
51136
  const targetList = reversed.map(quote).join(", ");
51121
51137
  const limitClause = opts.limitPerTarget ? `QUALIFY row_number() OVER (PARTITION BY t.target_rev_domain ORDER BY v.num_hosts DESC) <= ${Math.floor(opts.limitPerTarget)}` : "";
51122
- const sql25 = `
51138
+ const sql26 = `
51123
51139
  WITH vertices AS (
51124
51140
  SELECT * FROM read_csv(
51125
51141
  ${quote(opts.vertexPath)},
@@ -51155,7 +51171,7 @@ async function queryBacklinks(opts) {
51155
51171
  const conn = await instance.connect();
51156
51172
  let rows;
51157
51173
  try {
51158
- const reader = await conn.runAndReadAll(sql25);
51174
+ const reader = await conn.runAndReadAll(sql26);
51159
51175
  rows = reader.getRowObjects();
51160
51176
  } finally {
51161
51177
  conn.disconnectSync?.();
@@ -77833,6 +77849,19 @@ function matchesBrandAlias(candidate, aliases) {
77833
77849
  var RECURRENCE_LOOKBACK_RUNS = 5;
77834
77850
  var HISTORY_WINDOW_RUNS = Math.max(PERSISTENT_GAP_THRESHOLD, 5);
77835
77851
  var log = createLogger("IntelligenceService");
77852
+ var runChronologyKey = sql25`coalesce(${runs.finishedAt}, ${runs.createdAt})`;
77853
+ function analyzableRunPredicates(projectId) {
77854
+ return [
77855
+ eq61(runs.projectId, projectId),
77856
+ // Only this kind writes query_snapshots.
77857
+ eq61(runs.kind, RunKinds["answer-visibility"]),
77858
+ or14(eq61(runs.status, RunStatuses.completed), eq61(runs.status, RunStatuses.partial)),
77859
+ // Defensive: RunCoordinator already skips probes before analyzeAndPersist
77860
+ // is called, but a future call site invoking it directly for a probe must
77861
+ // still not pollute the intelligence window.
77862
+ ne8(runs.trigger, RunTriggers.probe)
77863
+ ];
77864
+ }
77836
77865
  function readStoredGroundingSources(rawResponse) {
77837
77866
  const parsed = parseJsonColumn(rawResponse, {});
77838
77867
  const sources = parsed.groundingSources;
@@ -77865,26 +77894,27 @@ var IntelligenceService = class {
77865
77894
  * Returns the analysis result for the coordinator to inspect (e.g. for webhook dispatch).
77866
77895
  */
77867
77896
  analyzeAndPersist(runId, projectId) {
77897
+ const currentRunRecord = this.db.select().from(runs).where(and50(eq61(runs.id, runId), ...analyzableRunPredicates(projectId))).get();
77898
+ if (!currentRunRecord) {
77899
+ log.info("intelligence.skip", { runId, reason: "run not eligible for analysis" });
77900
+ return null;
77901
+ }
77902
+ const currentLocation = currentRunRecord.location ?? null;
77868
77903
  const recentRuns = this.db.select().from(runs).where(
77869
77904
  and50(
77870
- eq61(runs.projectId, projectId),
77871
- or14(eq61(runs.status, "completed"), eq61(runs.status, "partial")),
77872
- // Defensive: RunCoordinator already skips probes before this is
77873
- // called, but if a future call site invokes analyzeAndPersist
77874
- // directly for a probe, probes still must not pollute the
77875
- // intelligence window.
77876
- ne8(runs.trigger, RunTriggers.probe)
77905
+ ...analyzableRunPredicates(projectId),
77906
+ currentLocation === null ? isNull8(runs.location) : eq61(runs.location, currentLocation),
77907
+ // Never look forward. The window is this run's own past, so
77908
+ // re-analyzing a historical run compares it against its true
77909
+ // predecessor rather than against sweeps that came after it.
77910
+ lte12(runChronologyKey, currentRunRecord.finishedAt ?? currentRunRecord.createdAt),
77911
+ // MEASUREMENT: only runs that actually wrote snapshots — see above.
77912
+ // Index seek on idx_snapshots_run, short-circuited on the first row.
77913
+ exists(
77914
+ this.db.select({ one: sql25`1` }).from(querySnapshots).where(eq61(querySnapshots.runId, runs.id))
77915
+ )
77877
77916
  )
77878
77917
  ).orderBy(desc27(runs.finishedAt), desc27(runs.createdAt)).limit(HISTORY_WINDOW_RUNS).all();
77879
- if (recentRuns.length === 0) {
77880
- log.info("intelligence.skip", { runId, reason: "no completed runs" });
77881
- return null;
77882
- }
77883
- const currentRunRecord = recentRuns.find((r) => r.id === runId);
77884
- if (!currentRunRecord) {
77885
- log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
77886
- return null;
77887
- }
77888
77918
  const trackedCompetitors = this.loadTrackedCompetitors(projectId);
77889
77919
  const currentRun = this.buildRunData(
77890
77920
  runId,
@@ -77898,10 +77928,8 @@ var IntelligenceService = class {
77898
77928
  return null;
77899
77929
  }
77900
77930
  const orderedRecent = [...recentRuns].reverse();
77901
- const currentLocation = currentRunRecord.location ?? null;
77902
- const sameLocationOrdered = orderedRecent.filter((r) => (r.location ?? null) === currentLocation);
77903
- const currentLocIdx = sameLocationOrdered.findIndex((r) => r.id === runId);
77904
- const previousRunRecord = currentLocIdx > 0 ? sameLocationOrdered[currentLocIdx - 1] : null;
77931
+ const currentIdx = orderedRecent.findIndex((r) => r.id === runId);
77932
+ const previousRunRecord = currentIdx > 0 ? orderedRecent[currentIdx - 1] : null;
77905
77933
  const previousRun = previousRunRecord ? this.buildRunData(
77906
77934
  previousRunRecord.id,
77907
77935
  projectId,
@@ -77909,7 +77937,7 @@ var IntelligenceService = class {
77909
77937
  previousRunRecord.location ?? null,
77910
77938
  trackedCompetitors
77911
77939
  ) : null;
77912
- const history = sameLocationOrdered.slice(0, currentLocIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(
77940
+ const history = orderedRecent.slice(0, currentIdx + 1).map((r) => r.id === runId ? currentRun : this.buildRunData(
77913
77941
  r.id,
77914
77942
  projectId,
77915
77943
  r.finishedAt ?? r.createdAt,
@@ -78184,14 +78212,9 @@ var IntelligenceService = class {
78184
78212
  }
78185
78213
  sinceTimestamp = parsed;
78186
78214
  }
78187
- const allRuns = this.db.select().from(runs).where(
78188
- and50(
78189
- eq61(runs.projectId, project.id),
78190
- or14(eq61(runs.status, "completed"), eq61(runs.status, "partial")),
78191
- // Backfill must not replay probe runs as if they were real sweeps.
78192
- ne8(runs.trigger, RunTriggers.probe)
78193
- )
78194
- ).orderBy(asc11(runs.finishedAt)).all();
78215
+ const allRuns = this.db.select().from(runs).where(and50(...analyzableRunPredicates(project.id))).orderBy(asc11(runs.finishedAt)).all();
78216
+ const measuredRunIds = this.runIdsWithSnapshots(allRuns.map((r) => r.id));
78217
+ const measuredRuns = allRuns.filter((r) => measuredRunIds.has(r.id));
78195
78218
  let startIdx = 0;
78196
78219
  let endIdx = allRuns.length;
78197
78220
  if (opts?.fromRunId) {
@@ -78229,7 +78252,7 @@ var IntelligenceService = class {
78229
78252
  for (let i = 0; i < targetRuns.length; i++) {
78230
78253
  const run = targetRuns[i];
78231
78254
  const runLocation = run.location ?? null;
78232
- const sameLocationRuns = allRuns.filter((r) => (r.location ?? null) === runLocation);
78255
+ const sameLocationRuns = measuredRuns.filter((r) => (r.location ?? null) === runLocation);
78233
78256
  const sameLocIdx = sameLocationRuns.indexOf(run);
78234
78257
  const previousRun = sameLocIdx > 0 ? sameLocationRuns[sameLocIdx - 1] : null;
78235
78258
  const historyStart = Math.max(0, sameLocIdx - (HISTORY_WINDOW_RUNS - 1));
@@ -78268,6 +78291,16 @@ var IntelligenceService = class {
78268
78291
  loadTrackedCompetitors(projectId) {
78269
78292
  return this.db.select({ domain: competitors.domain }).from(competitors).where(eq61(competitors.projectId, projectId)).all().map((r) => r.domain);
78270
78293
  }
78294
+ /**
78295
+ * Which of `runIds` actually wrote query snapshots. One grouped read, so
78296
+ * callers can drop the runs that measured nothing before paying to build
78297
+ * RunData for them. An empty input returns an empty set without querying.
78298
+ */
78299
+ runIdsWithSnapshots(runIds) {
78300
+ if (runIds.length === 0) return /* @__PURE__ */ new Set();
78301
+ const rows = this.db.select({ runId: querySnapshots.runId }).from(querySnapshots).where(inArray21(querySnapshots.runId, [...runIds])).groupBy(querySnapshots.runId).all();
78302
+ return new Set(rows.map((r) => r.runId));
78303
+ }
78271
78304
  /**
78272
78305
  * Wipe transition signals from an analysis result while keeping health.
78273
78306
  * Used when there's no baseline (first run) to avoid emitting false transitions.
@@ -145,7 +145,7 @@ import {
145
145
  siteCrawlSnapshots,
146
146
  toAlertView,
147
147
  usageCounters
148
- } from "./chunk-GJE334DA.js";
148
+ } from "./chunk-5EH666EO.js";
149
149
  import {
150
150
  AGENT_MEMORY_VALUE_MAX_BYTES,
151
151
  AGENT_PROVIDER_IDS,
@@ -11839,7 +11839,7 @@ function readStoredGroundingSources(rawResponse) {
11839
11839
  return result;
11840
11840
  }
11841
11841
  async function backfillInsightsCommand(project, opts) {
11842
- const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-C57RIB37.js");
11842
+ const { IntelligenceService: IntelligenceService2 } = await import("./intelligence-service-CHZGHFGS.js");
11843
11843
  const config = loadConfig();
11844
11844
  const db = createClient(config.database);
11845
11845
  migrate(db);
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  showFirstRunNotice,
27
27
  trackCliCommandFinished,
28
28
  trackEvent
29
- } from "./chunk-XFAPF2YX.js";
29
+ } from "./chunk-7ABFIVKO.js";
30
30
  import {
31
31
  autoSyncSkills,
32
32
  formatAutoSyncNotice
@@ -69,7 +69,7 @@ import {
69
69
  projects,
70
70
  queries,
71
71
  renderReportHtml
72
- } from "./chunk-GJE334DA.js";
72
+ } from "./chunk-5EH666EO.js";
73
73
  import {
74
74
  AdsDeliverySnapshotStatuses,
75
75
  AdsHistoricalCampaignRollupStatuses,
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  createGoogleMarketingCredentialStore,
4
4
  createGoogleMarketingRuntime,
5
5
  createServer
6
- } from "./chunk-XFAPF2YX.js";
6
+ } from "./chunk-7ABFIVKO.js";
7
7
  import {
8
8
  loadConfig
9
9
  } from "./chunk-6XUWDSLN.js";
10
- import "./chunk-GJE334DA.js";
10
+ import "./chunk-5EH666EO.js";
11
11
  import "./chunk-PQT3ECM3.js";
12
12
  export {
13
13
  GoogleMarketingRuntimeError,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IntelligenceService
3
- } from "./chunk-GJE334DA.js";
3
+ } from "./chunk-5EH666EO.js";
4
4
  import "./chunk-PQT3ECM3.js";
5
5
  export {
6
6
  IntelligenceService
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonry/canonry",
3
- "version": "4.177.1",
3
+ "version": "4.177.3",
4
4
  "type": "module",
5
5
  "description": "Self-hosted AI visibility (AEO) platform: track how ChatGPT, Claude, Gemini, and Perplexity cite your domain, join it with Search Console, GA4, server-side traffic, and paid media, and fix what you find through agent tools (CLI, REST, MCP). Local SQLite.",
6
6
  "license": "FSL-1.1-ALv2",
@@ -70,27 +70,27 @@
70
70
  "@types/node-cron": "^3.0.11",
71
71
  "tsup": "^8.5.1",
72
72
  "tsx": "^4.19.0",
73
+ "@ainyc/canonry-api-client": "0.0.0",
73
74
  "@ainyc/canonry-api-routes": "0.0.0",
74
- "@ainyc/canonry-contracts": "0.0.0",
75
75
  "@ainyc/canonry-config": "0.0.0",
76
- "@ainyc/canonry-api-client": "0.0.0",
77
76
  "@ainyc/canonry-db": "0.0.0",
77
+ "@ainyc/canonry-contracts": "0.0.0",
78
78
  "@ainyc/canonry-integration-bing": "0.0.0",
79
79
  "@ainyc/canonry-integration-cloud-run": "0.0.0",
80
80
  "@ainyc/canonry-integration-cloudflare-queue": "0.0.0",
81
81
  "@ainyc/canonry-integration-cloudflare-worker": "0.0.0",
82
82
  "@ainyc/canonry-integration-commoncrawl": "0.0.0",
83
- "@ainyc/canonry-integration-google": "0.0.0",
84
83
  "@ainyc/canonry-integration-google-ads": "0.0.0",
85
- "@ainyc/canonry-integration-google-places": "0.0.0",
86
84
  "@ainyc/canonry-integration-google-business-profile": "0.0.0",
85
+ "@ainyc/canonry-integration-google": "0.0.0",
86
+ "@ainyc/canonry-integration-google-places": "0.0.0",
87
87
  "@ainyc/canonry-integration-google-tag-manager": "0.0.0",
88
- "@ainyc/canonry-integration-wordpress": "0.0.0",
89
- "@ainyc/canonry-integration-openai-ads": "0.0.0",
90
88
  "@ainyc/canonry-integration-traffic": "0.0.0",
91
- "@ainyc/canonry-intelligence": "0.0.0",
92
- "@ainyc/canonry-provider-cdp": "0.0.0",
89
+ "@ainyc/canonry-integration-openai-ads": "0.0.0",
90
+ "@ainyc/canonry-integration-wordpress": "0.0.0",
93
91
  "@ainyc/canonry-provider-gemini": "0.0.0",
92
+ "@ainyc/canonry-provider-cdp": "0.0.0",
93
+ "@ainyc/canonry-intelligence": "0.0.0",
94
94
  "@ainyc/canonry-provider-local": "0.0.0",
95
95
  "@ainyc/canonry-provider-openai": "0.0.0",
96
96
  "@ainyc/canonry-provider-claude": "0.0.0",