@ainyc/canonry 4.99.0 → 4.100.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 (27) hide show
  1. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +13 -1
  2. package/assets/assets/{BacklinksPage-CuWs7I1D.js → BacklinksPage-DWZdWEm3.js} +1 -1
  3. package/assets/assets/{ChartPrimitives-bm37tprp.js → ChartPrimitives-C-ky5206.js} +1 -1
  4. package/assets/assets/ProjectPage-CpbFpi6J.js +6 -0
  5. package/assets/assets/{RunRow-DzhQl87g.js → RunRow-Bj-D4kZe.js} +1 -1
  6. package/assets/assets/{RunsPage-wOaewhbF.js → RunsPage-GktitsAV.js} +1 -1
  7. package/assets/assets/{SettingsPage-CJdhhkkE.js → SettingsPage-AsFLFDS3.js} +1 -1
  8. package/assets/assets/{TrafficPage-BJBeN5Ph.js → TrafficPage-Cf3uCkxs.js} +1 -1
  9. package/assets/assets/{TrafficSourceDetailPage-D3PmH0B1.js → TrafficSourceDetailPage-B5t7nf9C.js} +1 -1
  10. package/assets/assets/{arrow-left-BmITMI6O.js → arrow-left-CZFQuQO_.js} +1 -1
  11. package/assets/assets/{extract-error-message-BGwnyAfP.js → extract-error-message--bieZ2IX.js} +1 -1
  12. package/assets/assets/index-9hI0ODQU.css +1 -0
  13. package/assets/assets/index-Cb2h7KkD.js +210 -0
  14. package/assets/assets/{trash-2-Bd2mkP_w.js → trash-2-sKO0k75f.js} +1 -1
  15. package/assets/index.html +2 -2
  16. package/dist/{chunk-CEHD33NQ.js → chunk-74SOIAIY.js} +262 -119
  17. package/dist/{chunk-CWFTMFEU.js → chunk-ADLS64PG.js} +117 -3
  18. package/dist/{chunk-DJVUNURL.js → chunk-SNHYRSUF.js} +401 -40
  19. package/dist/{chunk-VQN74WWA.js → chunk-UVIHWF45.js} +27 -1
  20. package/dist/cli.js +110 -4
  21. package/dist/index.js +4 -4
  22. package/dist/{intelligence-service-QHBXW4KI.js → intelligence-service-3CRNJLUZ.js} +2 -2
  23. package/dist/mcp.js +2 -2
  24. package/package.json +10 -10
  25. package/assets/assets/ProjectPage-BksYe8FP.js +0 -6
  26. package/assets/assets/index-BSX6r6ju.js +0 -210
  27. package/assets/assets/index-BsZNCwg8.css +0 -1
@@ -18,6 +18,7 @@ import {
18
18
  DiscoveryBuckets,
19
19
  DiscoveryCompetitorTypes,
20
20
  DiscoverySessionStatuses,
21
+ PortfolioChangeTypes,
21
22
  RunKinds,
22
23
  RunStatuses,
23
24
  RunTriggers,
@@ -131,6 +132,7 @@ import {
131
132
  formatIsoDate,
132
133
  formatNumber,
133
134
  formatRatio,
135
+ formatRunErrorOneLine,
134
136
  formatWindowCountDelta,
135
137
  ga4AiReferralHistoryEntrySchema,
136
138
  ga4SessionHistoryEntrySchema,
@@ -184,6 +186,7 @@ import {
184
186
  parseRunError,
185
187
  parseWindow,
186
188
  pickClusterRepresentative,
189
+ portfolioDtoSchema,
187
190
  projectConfigSchema,
188
191
  projectDtoSchema,
189
192
  projectOverviewDtoSchema,
@@ -263,10 +266,10 @@ import {
263
266
  wordpressSchemaDeployResultDtoSchema,
264
267
  wordpressSchemaStatusResultDtoSchema,
265
268
  wordpressStatusDtoSchema
266
- } from "./chunk-CWFTMFEU.js";
269
+ } from "./chunk-ADLS64PG.js";
267
270
 
268
271
  // src/intelligence-service.ts
269
- import { eq as eq37, desc as desc18, asc as asc5, and as and27, ne as ne5, or as or5, inArray as inArray14, gte as gte7, lte as lte4 } from "drizzle-orm";
272
+ import { eq as eq37, desc as desc18, asc as asc6, and as and27, ne as ne5, or as or5, inArray as inArray14, gte as gte7, lte as lte4 } from "drizzle-orm";
270
273
 
271
274
  // ../db/src/client.ts
272
275
  import { mkdirSync } from "fs";
@@ -4428,19 +4431,23 @@ function buildCitationScorecard(snapshots, queryLookup) {
4428
4431
  answerMentioned: snap.answerMentioned ?? null,
4429
4432
  model: snap.model
4430
4433
  };
4431
- const counts2 = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
4434
+ const counts2 = providerCounts.get(snap.provider) ?? { cited: 0, mentioned: 0, total: 0 };
4432
4435
  counts2.total++;
4433
4436
  if (snap.citationState === CitationStates.cited) counts2.cited++;
4437
+ if (snap.answerMentioned === true) counts2.mentioned++;
4434
4438
  providerCounts.set(snap.provider, counts2);
4435
4439
  }
4436
4440
  const providerRates = providerList.map((provider) => {
4437
- const counts2 = providerCounts.get(provider) ?? { cited: 0, total: 0 };
4441
+ const counts2 = providerCounts.get(provider) ?? { cited: 0, mentioned: 0, total: 0 };
4438
4442
  const citationRate = counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0;
4443
+ const mentionRate = counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0;
4439
4444
  return {
4440
4445
  provider,
4441
4446
  citedCount: counts2.cited,
4447
+ mentionedCount: counts2.mentioned,
4442
4448
  totalCount: counts2.total,
4443
- citationRate
4449
+ citationRate,
4450
+ mentionRate
4444
4451
  };
4445
4452
  });
4446
4453
  return { queries: queryList, providers: providerList, matrix, providerRates };
@@ -5041,6 +5048,233 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
5041
5048
  });
5042
5049
  }
5043
5050
 
5051
+ // ../intelligence/src/portfolio-change-feed.ts
5052
+ var PORTFOLIO_CHANGE_FEED_LIMIT = 12;
5053
+ var TONE_SEVERITY = {
5054
+ negative: 0,
5055
+ caution: 1,
5056
+ positive: 2,
5057
+ neutral: 3
5058
+ };
5059
+ function pluralizeQueries(n) {
5060
+ return n === 1 ? "query" : "queries";
5061
+ }
5062
+ function pluralizeAnswers(n) {
5063
+ return n === 1 ? "answer" : "answers";
5064
+ }
5065
+ function affectedDetail(queries2, max = 3) {
5066
+ const list = queries2 ?? [];
5067
+ if (list.length === 0) return "";
5068
+ const shown = list.slice(0, max).map((q) => `"${q}"`).join(", ");
5069
+ const remaining = list.length - max;
5070
+ return remaining > 0 ? `${shown} +${remaining} more` : shown;
5071
+ }
5072
+ function buildProjectChanges(input) {
5073
+ const {
5074
+ projectName,
5075
+ projectSlug,
5076
+ citationMovement,
5077
+ mentionMovement,
5078
+ movementComparison,
5079
+ attentionItems,
5080
+ latestRun
5081
+ } = input;
5082
+ const href = `/projects/${encodeURIComponent(projectSlug)}`;
5083
+ const rows = [];
5084
+ if (!latestRun) {
5085
+ rows.push({
5086
+ id: `${projectSlug}:${PortfolioChangeTypes["project-never-run"]}`,
5087
+ projectName,
5088
+ projectSlug,
5089
+ changeType: PortfolioChangeTypes["project-never-run"],
5090
+ tone: "neutral",
5091
+ title: `${projectName} has no completed sweep yet`,
5092
+ detail: `${input.trackedQueryCount} ${pluralizeQueries(input.trackedQueryCount)} tracked \u2014 run a sweep to start measuring.`,
5093
+ occurredAt: input.projectCreatedAt,
5094
+ href,
5095
+ actionLabel: "Run sweep",
5096
+ comparable: false
5097
+ });
5098
+ return rows;
5099
+ }
5100
+ const { runId, occurredAt } = latestRun;
5101
+ const comparable = movementComparison.comparable;
5102
+ if (latestRun.status === RunStatuses.failed) {
5103
+ rows.push({
5104
+ id: `${projectSlug}:${PortfolioChangeTypes["run-failed"]}:${runId}`,
5105
+ projectName,
5106
+ projectSlug,
5107
+ changeType: PortfolioChangeTypes["run-failed"],
5108
+ tone: "negative",
5109
+ title: `${projectName} sweep failed`,
5110
+ detail: latestRun.error ? formatRunErrorOneLine(latestRun.error) : "The latest visibility sweep failed.",
5111
+ occurredAt,
5112
+ href,
5113
+ actionLabel: "View run",
5114
+ comparable
5115
+ });
5116
+ }
5117
+ if (comparable) {
5118
+ if (citationMovement.lost > 0) {
5119
+ rows.push({
5120
+ id: `${projectSlug}:${PortfolioChangeTypes["citation-lost"]}:${runId}`,
5121
+ projectName,
5122
+ projectSlug,
5123
+ changeType: PortfolioChangeTypes["citation-lost"],
5124
+ tone: "negative",
5125
+ title: `${projectName} lost ${citationMovement.lost} cited ${pluralizeQueries(citationMovement.lost)}`,
5126
+ detail: affectedDetail(citationMovement.lostQueries),
5127
+ occurredAt,
5128
+ href,
5129
+ actionLabel: "Open project",
5130
+ comparable: true
5131
+ });
5132
+ }
5133
+ if (citationMovement.gained > 0) {
5134
+ rows.push({
5135
+ id: `${projectSlug}:${PortfolioChangeTypes["citation-gained"]}:${runId}`,
5136
+ projectName,
5137
+ projectSlug,
5138
+ changeType: PortfolioChangeTypes["citation-gained"],
5139
+ tone: "positive",
5140
+ title: `${projectName} gained ${citationMovement.gained} cited ${pluralizeQueries(citationMovement.gained)}`,
5141
+ detail: affectedDetail(citationMovement.gainedQueries),
5142
+ occurredAt,
5143
+ href,
5144
+ actionLabel: "Open project",
5145
+ comparable: true
5146
+ });
5147
+ }
5148
+ if (mentionMovement.lost > 0) {
5149
+ const queries2 = affectedDetail(mentionMovement.lostQueries);
5150
+ rows.push({
5151
+ id: `${projectSlug}:${PortfolioChangeTypes["mention-lost"]}:${runId}`,
5152
+ projectName,
5153
+ projectSlug,
5154
+ changeType: PortfolioChangeTypes["mention-lost"],
5155
+ tone: "negative",
5156
+ title: `${projectName} dropped from ${mentionMovement.lost} ${pluralizeAnswers(mentionMovement.lost)}`,
5157
+ detail: queries2 ? `No longer mentioned for ${queries2}` : "",
5158
+ occurredAt,
5159
+ href,
5160
+ actionLabel: "Open project",
5161
+ comparable: true
5162
+ });
5163
+ }
5164
+ if (mentionMovement.gained > 0) {
5165
+ rows.push({
5166
+ id: `${projectSlug}:${PortfolioChangeTypes["mention-gained"]}:${runId}`,
5167
+ projectName,
5168
+ projectSlug,
5169
+ changeType: PortfolioChangeTypes["mention-gained"],
5170
+ tone: "positive",
5171
+ title: `${projectName} now mentioned in ${mentionMovement.gained} more ${pluralizeAnswers(mentionMovement.gained)}`,
5172
+ detail: affectedDetail(mentionMovement.gainedQueries),
5173
+ occurredAt,
5174
+ href,
5175
+ actionLabel: "Open project",
5176
+ comparable: true
5177
+ });
5178
+ }
5179
+ } else if (movementComparison.hasPreviousRun && movementComparison.querySetChanged) {
5180
+ rows.push({
5181
+ id: `${projectSlug}:${PortfolioChangeTypes["query-set-changed"]}:${runId}`,
5182
+ projectName,
5183
+ projectSlug,
5184
+ changeType: PortfolioChangeTypes["query-set-changed"],
5185
+ tone: "neutral",
5186
+ title: `${projectName} query set changed`,
5187
+ detail: `+${movementComparison.addedQueryCount} added \xB7 \u2212${movementComparison.removedQueryCount} removed since last sweep \u2014 movement compares the shared queries`,
5188
+ occurredAt,
5189
+ href,
5190
+ actionLabel: "Open project",
5191
+ comparable: false
5192
+ });
5193
+ }
5194
+ for (const item of attentionItems) {
5195
+ if (item.id.startsWith("insight_")) {
5196
+ const isCritical = item.tone === "negative";
5197
+ const changeType = isCritical ? PortfolioChangeTypes["insight-critical"] : PortfolioChangeTypes["insight-high"];
5198
+ rows.push({
5199
+ id: `${projectSlug}:${changeType}:${item.id}`,
5200
+ projectName,
5201
+ projectSlug,
5202
+ changeType,
5203
+ tone: item.tone,
5204
+ title: item.title,
5205
+ detail: item.detail,
5206
+ occurredAt,
5207
+ href,
5208
+ actionLabel: isCritical ? "Critical" : "High",
5209
+ comparable
5210
+ });
5211
+ } else if (item.id === "stale_visibility") {
5212
+ rows.push({
5213
+ id: `${projectSlug}:${PortfolioChangeTypes["stale-visibility"]}:${runId}`,
5214
+ projectName,
5215
+ projectSlug,
5216
+ changeType: PortfolioChangeTypes["stale-visibility"],
5217
+ tone: "caution",
5218
+ title: `${projectName} visibility data is stale`,
5219
+ detail: "Integration syncs have run since the last sweep.",
5220
+ occurredAt,
5221
+ href,
5222
+ actionLabel: "Re-sweep",
5223
+ comparable
5224
+ });
5225
+ }
5226
+ }
5227
+ return rows;
5228
+ }
5229
+ function resolveEmptyState(projectCount, comparableProjectCount) {
5230
+ if (comparableProjectCount === 0) {
5231
+ return {
5232
+ kind: "awaiting-second-sweep",
5233
+ title: "No changes to compare yet",
5234
+ detail: "Visibility movement appears after a second sweep."
5235
+ };
5236
+ }
5237
+ return {
5238
+ kind: "all-clear",
5239
+ title: "No changes since the last sweep",
5240
+ detail: `All ${projectCount} project${projectCount === 1 ? "" : "s"} held their mention and citation coverage.`
5241
+ };
5242
+ }
5243
+ function buildPortfolioChangeFeed(inputs, _nowIso) {
5244
+ const all = [];
5245
+ let comparableProjectCount = 0;
5246
+ let firstSweepProjectCount = 0;
5247
+ for (const input of inputs) {
5248
+ if (input.latestRun) {
5249
+ if (input.movementComparison.comparable) comparableProjectCount += 1;
5250
+ if (!input.movementComparison.hasPreviousRun) firstSweepProjectCount += 1;
5251
+ }
5252
+ all.push(...buildProjectChanges(input));
5253
+ }
5254
+ const byId = /* @__PURE__ */ new Map();
5255
+ for (const row of all) {
5256
+ if (!byId.has(row.id)) byId.set(row.id, row);
5257
+ }
5258
+ const deduped = [...byId.values()];
5259
+ deduped.sort((a, b) => {
5260
+ if (a.occurredAt !== b.occurredAt) return a.occurredAt < b.occurredAt ? 1 : -1;
5261
+ const severity = TONE_SEVERITY[a.tone] - TONE_SEVERITY[b.tone];
5262
+ if (severity !== 0) return severity;
5263
+ if (a.projectName !== b.projectName) return a.projectName < b.projectName ? -1 : 1;
5264
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
5265
+ });
5266
+ const changeFeedTotal = deduped.length;
5267
+ const changeFeed = deduped.slice(0, PORTFOLIO_CHANGE_FEED_LIMIT);
5268
+ const feedEmptyState = changeFeedTotal === 0 ? resolveEmptyState(inputs.length, comparableProjectCount) : null;
5269
+ return {
5270
+ changeFeed,
5271
+ changeFeedTotal,
5272
+ feedEmptyState,
5273
+ comparableProjectCount,
5274
+ firstSweepProjectCount
5275
+ };
5276
+ }
5277
+
5044
5278
  // ../intelligence/src/provider-trends.ts
5045
5279
  function buildProviderTrends(runs2, snapshotsByRunId, limit = 12) {
5046
5280
  const recent = [...runs2].sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
@@ -10221,9 +10455,9 @@ function renderWhatsChanged(report, audience) {
10221
10455
  );
10222
10456
  }
10223
10457
  const rateTiles = `<div class="metric-grid">
10224
- ${renderRateDeltaTile(isClient ? "AI links to your website" : "Citation rate", w.citationRate, "%")}
10225
- ${renderRateDeltaTile(isClient ? "AI mentions your name" : "Mention rate", w.mentionRate, "%")}
10226
- ${renderRateDeltaTile(isClient ? "Questions AI answered with you" : "Cited queries", w.citedQueryCount, "count")}
10458
+ ${renderRateDeltaTile(isClient ? "AI mentions your name" : "Citation rate", isClient ? w.mentionRate : w.citationRate, "%")}
10459
+ ${renderRateDeltaTile(isClient ? "AI links to your website" : "Mention rate", isClient ? w.citationRate : w.mentionRate, "%")}
10460
+ ${renderRateDeltaTile(isClient ? "Questions AI mentioned you in" : "Cited queries", isClient ? w.mentionedQueryCount : w.citedQueryCount, "count")}
10227
10461
  ${renderTrafficDeltaTile(isClient ? "Visitors from Google" : "GSC clicks", w.gscClicksDelta, isClient ? "visits" : "clicks", w.comparisonWindowDays)}
10228
10462
  ${renderTrafficDeltaTile(isClient ? "Visitors from AI tools" : "AI referral sessions", w.aiReferralsDelta, isClient ? "visits" : "sessions", w.comparisonWindowDays)}
10229
10463
  </div>`;
@@ -11117,9 +11351,9 @@ function renderClientSummary(report) {
11117
11351
  const s = report.executiveSummary;
11118
11352
  const sc = report.citationScorecard;
11119
11353
  const totalQ = s.totalQueryCount;
11120
- const heroNumber = totalQ > 0 ? `${s.citationRate}%` : "\u2014";
11121
- const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "question")} about your industry, AI linked to your website in ${s.citedQueryCount} of ${totalQ === 1 ? "them" : "those answers"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer questions about your business.";
11122
- const trend = clientTrendCopy(report.whatsChanged.citationRate);
11354
+ const heroNumber = totalQ > 0 ? `${s.mentionRate}%` : "\u2014";
11355
+ const heroSentence = totalQ > 0 ? `When customers asked AI ${totalQ} ${pluralize(totalQ, "question")} about your industry, AI mentioned you in ${s.mentionedQueryCount} of ${totalQ === 1 ? "them" : "those answers"}.` : "No AI check has been run yet. Run a check to see how AI tools answer customer questions about your business.";
11356
+ const trend = clientTrendCopy(report.whatsChanged.mentionRate);
11123
11357
  const heroTrend = trend ? `<p class="client-hero-trend tone-${trend.tone}"><span style="margin-right:6px;">${trend.arrow}</span>${escapeHtml(trend.text)}</p>` : "";
11124
11358
  const hero = `<div class="client-hero">
11125
11359
  <div class="client-hero-eyebrow">Overview</div>
@@ -11159,15 +11393,15 @@ function renderClientSummary(report) {
11159
11393
  </ol>
11160
11394
  </div>` : "";
11161
11395
  const providerBars = sc.providerRates.length > 0 ? `<div class="client-card">
11162
- <h3>How often each AI tool links to your website</h3>
11163
- <p class="card-subtitle">Higher is better. Each bar shows the share of customer questions where the AI cited your site.</p>
11396
+ <h3>How often each AI tool mentions you</h3>
11397
+ <p class="card-subtitle">Higher is better. Each bar shows the share of customer questions where the AI named you in the answer.</p>
11164
11398
  <div class="client-bar-list">
11165
11399
  ${sc.providerRates.map((r) => {
11166
- const pct = Math.max(r.citationRate, 1.5);
11400
+ const pct = Math.max(r.mentionRate, 1.5);
11167
11401
  return `<div class="client-bar-row">
11168
11402
  <span class="bar-label">${escapeHtml(providerDisplayName(r.provider))}</span>
11169
11403
  <div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
11170
- <span class="bar-value">${r.citationRate}% <span class="bar-value-sub">(${r.citedCount}/${r.totalCount})</span></span>
11404
+ <span class="bar-value">${r.mentionRate}% <span class="bar-value-sub">(${r.mentionedCount}/${r.totalCount})</span></span>
11171
11405
  </div>`;
11172
11406
  }).join("")}
11173
11407
  </div>
@@ -11953,9 +12187,10 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
11953
12187
  considered++;
11954
12188
  if (snap.citationState === CitationStates.cited) citedQueryIds.add(snap.queryId);
11955
12189
  if (snap.answerMentioned) mentionedQueryIds.add(snap.queryId);
11956
- const counts2 = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
12190
+ const counts2 = providerCounts.get(snap.provider) ?? { cited: 0, mentioned: 0, total: 0 };
11957
12191
  counts2.total++;
11958
12192
  if (snap.citationState === CitationStates.cited) counts2.cited++;
12193
+ if (snap.answerMentioned === true) counts2.mentioned++;
11959
12194
  providerCounts.set(snap.provider, counts2);
11960
12195
  }
11961
12196
  if (considered === 0) continue;
@@ -11965,7 +12200,8 @@ function buildCitationsTrend(db, projectId, queryLookup, locationFilter) {
11965
12200
  const mentionRate = totalQueries > 0 ? Math.round(mentionedQueryCount / totalQueries * 100) : 0;
11966
12201
  const providerRates = [...providerCounts.entries()].map(([provider, counts2]) => ({
11967
12202
  provider,
11968
- citationRate: counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0
12203
+ citationRate: counts2.total > 0 ? Math.round(counts2.cited / counts2.total * 100) : 0,
12204
+ mentionRate: counts2.total > 0 ? Math.round(counts2.mentioned / counts2.total * 100) : 0
11969
12205
  })).sort((a, b) => a.provider.localeCompare(b.provider));
11970
12206
  points.push({
11971
12207
  runId: run.id,
@@ -12345,23 +12581,17 @@ function buildReportActionPlan(input) {
12345
12581
  }
12346
12582
  return actions.sort((a, b) => a.priority - b.priority).slice(0, 10);
12347
12583
  }
12348
- function trendSentence(trend) {
12349
- switch (trend) {
12350
- case "up":
12351
- return "Citation coverage improved versus the prior comparable check.";
12352
- case "down":
12353
- return "Citation coverage declined versus the prior comparable check.";
12354
- case "flat":
12355
- return "Citation coverage is flat versus the prior comparable check.";
12356
- case "unknown":
12357
- return "There is not enough comparable run history yet to call a trend.";
12358
- }
12584
+ function mentionTrendSentence(delta) {
12585
+ if (!delta) return "There is not enough comparable run history yet to call a mention trend.";
12586
+ if (delta.direction === "up") return "Mention coverage improved versus the prior comparable checks.";
12587
+ if (delta.direction === "down") return "Mention coverage declined versus the prior comparable checks.";
12588
+ return "Mention coverage is flat versus the prior comparable checks.";
12359
12589
  }
12360
12590
  function buildClientSummary(reportLike) {
12361
12591
  const s = reportLike.executiveSummary;
12362
12592
  const queryNoun = s.totalQueryCount === 1 ? "query" : "queries";
12363
- const headline = s.totalQueryCount > 0 ? `${s.citedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} are cited by AI engines` : "No tracked queries have completed a check yet";
12364
- const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is cited on ${s.citationRate}% of tracked queries and mentioned on ${s.mentionRate}% of tracked queries. ${trendSentence(s.trend)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
12593
+ const headline = s.totalQueryCount > 0 ? `${s.mentionedQueryCount} of ${s.totalQueryCount} tracked ${queryNoun} mention the brand in AI answers` : "No tracked queries have completed a check yet";
12594
+ const overview = s.totalQueryCount > 0 ? `${reportLike.canonicalDomain} is mentioned on ${s.mentionRate}% of tracked queries and cited on ${s.citationRate}% of tracked queries. ${mentionTrendSentence(reportLike.whatsChanged.mentionRate)}` : "At least one completed check is needed before this can summarize how the brand appears in AI answers.";
12365
12595
  const confidenceNotes = [];
12366
12596
  if (s.totalQueryCount === 0) {
12367
12597
  confidenceNotes.push("Confidence is low until the first tracked query check completes.");
@@ -12496,6 +12726,11 @@ function buildWhatsChanged(input) {
12496
12726
  ...citedQueryCountSmoothed,
12497
12727
  direction: rateDirection(citedQueryCountSmoothed.deltaAbs, COUNT_REAL_MOVEMENT_THRESHOLD)
12498
12728
  } : null;
12729
+ const mentionedQueryCountSmoothed = smoothedRunDelta(citationsTrend, (p) => p.mentionedQueryCount);
12730
+ const mentionedQueryCount = enoughHistory && mentionedQueryCountSmoothed ? {
12731
+ ...mentionedQueryCountSmoothed,
12732
+ direction: rateDirection(mentionedQueryCountSmoothed.deltaAbs, COUNT_REAL_MOVEMENT_THRESHOLD)
12733
+ } : null;
12499
12734
  const providerMovements = [];
12500
12735
  if (enoughHistory) {
12501
12736
  const priorByProvider = new Map(prior.providerRates.map((p) => [p.provider, p.citationRate]));
@@ -12531,6 +12766,7 @@ function buildWhatsChanged(input) {
12531
12766
  citationRate,
12532
12767
  mentionRate,
12533
12768
  citedQueryCount,
12769
+ mentionedQueryCount,
12534
12770
  gscClicksDelta,
12535
12771
  aiReferralsDelta,
12536
12772
  comparisonWindowDays,
@@ -12694,6 +12930,7 @@ function buildProjectReport(db, projectName, periodDays) {
12694
12930
  reportLocation,
12695
12931
  executiveSummary,
12696
12932
  citationsTrend,
12933
+ whatsChanged,
12697
12934
  gsc: gscSection,
12698
12935
  actionPlan
12699
12936
  });
@@ -13100,8 +13337,9 @@ async function visibilityStatsRoutes(app) {
13100
13337
  }
13101
13338
 
13102
13339
  // ../api-routes/src/composites.ts
13103
- import { eq as eq17, and as and12, desc as desc9, sql as sql7, like as like2, or as or4, inArray as inArray9 } from "drizzle-orm";
13340
+ import { eq as eq17, and as and12, asc as asc2, desc as desc9, sql as sql7, like as like2, or as or4, inArray as inArray9 } from "drizzle-orm";
13104
13341
  var TOP_INSIGHT_LIMIT = 5;
13342
+ var PORTFOLIO_RECENT_RUNS_LIMIT = 8;
13105
13343
  var SEARCH_HIT_HARD_LIMIT = 50;
13106
13344
  var SEARCH_SNIPPET_RADIUS = 80;
13107
13345
  var INTEGRATION_SYNC_KINDS = /* @__PURE__ */ new Set([
@@ -13118,6 +13356,14 @@ async function compositeRoutes(app) {
13118
13356
  const project = resolveProject(app.db, request.params.name);
13119
13357
  const filterLocation = (request.query.location ?? "").trim() || null;
13120
13358
  const sinceIso = parseSinceFilter(request.query.since);
13359
+ return reply.send(computeProjectOverview(project, { filterLocation, sinceIso }));
13360
+ });
13361
+ app.get("/portfolio", async (_request, reply) => {
13362
+ return reply.send(buildPortfolioOverview());
13363
+ });
13364
+ function computeProjectOverview(project, opts = {}) {
13365
+ const filterLocation = opts.filterLocation ?? null;
13366
+ const sinceIso = opts.sinceIso ?? null;
13121
13367
  const allRunsRaw = app.db.select().from(runs).where(and12(eq17(runs.projectId, project.id), notProbeRun())).orderBy(desc9(runs.createdAt), desc9(runs.id)).all();
13122
13368
  const allRuns = allRunsRaw.filter((r) => runMatchesFilters(r, filterLocation, sinceIso));
13123
13369
  const totalRuns = allRuns.length;
@@ -13250,8 +13496,111 @@ async function compositeRoutes(app) {
13250
13496
  dateRangeLabel: "All time",
13251
13497
  contextLabel: `${project.country} / ${project.language.toUpperCase()}`
13252
13498
  };
13253
- return reply.send(result);
13254
- });
13499
+ return result;
13500
+ }
13501
+ function buildPortfolioOverview() {
13502
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
13503
+ const projectRows = app.db.select().from(projects).orderBy(asc2(projects.name)).all();
13504
+ const trackedCountRows = app.db.select({ projectId: queries.projectId, total: sql7`COUNT(*)` }).from(queries).groupBy(queries.projectId).all();
13505
+ const trackedCountByProject = new Map(trackedCountRows.map((r) => [r.projectId, Number(r.total)]));
13506
+ const lastSweepRow = app.db.select({ createdAt: runs.createdAt, finishedAt: runs.finishedAt }).from(runs).where(and12(
13507
+ eq17(runs.kind, RunKinds["answer-visibility"]),
13508
+ notProbeRun(),
13509
+ inArray9(runs.status, [RunStatuses.completed, RunStatuses.partial])
13510
+ )).orderBy(desc9(runs.createdAt), desc9(runs.id)).limit(1).get();
13511
+ const lastSweepAt = lastSweepRow ? lastSweepRow.finishedAt ?? lastSweepRow.createdAt : null;
13512
+ const feedInputs = [];
13513
+ const projectRowDtos = [];
13514
+ const runHistoryById = /* @__PURE__ */ new Map();
13515
+ for (const projectRow of projectRows) {
13516
+ const overview = computeProjectOverview(projectRow);
13517
+ for (const point of overview.runHistory) runHistoryById.set(point.runId, point);
13518
+ const latestVisRunRow = app.db.select().from(runs).where(and12(
13519
+ eq17(runs.projectId, projectRow.id),
13520
+ eq17(runs.kind, RunKinds["answer-visibility"]),
13521
+ notProbeRun()
13522
+ )).orderBy(desc9(runs.createdAt), desc9(runs.id)).limit(1).get();
13523
+ const projectName = projectRow.displayName || projectRow.name;
13524
+ feedInputs.push({
13525
+ projectName,
13526
+ projectSlug: projectRow.name,
13527
+ citationMovement: overview.citationMovement,
13528
+ mentionMovement: overview.mentionMovement,
13529
+ movementComparison: overview.movementComparison,
13530
+ attentionItems: overview.attentionItems,
13531
+ latestRun: latestVisRunRow ? {
13532
+ runId: latestVisRunRow.id,
13533
+ status: latestVisRunRow.status,
13534
+ occurredAt: latestVisRunRow.finishedAt ?? latestVisRunRow.createdAt,
13535
+ error: parseRunError(latestVisRunRow.error)
13536
+ } : null,
13537
+ trackedQueryCount: trackedCountByProject.get(projectRow.id) ?? 0,
13538
+ projectCreatedAt: projectRow.createdAt
13539
+ });
13540
+ const mention = overview.scores.mention;
13541
+ projectRowDtos.push({
13542
+ projectSlug: projectRow.name,
13543
+ projectName,
13544
+ canonicalDomain: projectRow.canonicalDomain,
13545
+ mentionScore: mention.progress ?? 0,
13546
+ mentionTone: mention.tone,
13547
+ mentionedOfTotal: { mentioned: overview.queryCounts.mentionedQueries, total: overview.queryCounts.totalQueries },
13548
+ citedOfTotal: { cited: overview.queryCounts.citedQueries, total: overview.queryCounts.totalQueries },
13549
+ mentionDelta: {
13550
+ gained: overview.mentionMovement.gained,
13551
+ lost: overview.mentionMovement.lost,
13552
+ comparable: overview.movementComparison.comparable
13553
+ },
13554
+ citationDelta: {
13555
+ gained: overview.citationMovement.gained,
13556
+ lost: overview.citationMovement.lost,
13557
+ comparable: overview.movementComparison.comparable
13558
+ },
13559
+ competitorPressureLabel: overview.scores.competitorPressure.value,
13560
+ mentionTrend: mention.trend,
13561
+ lastRunAt: latestVisRunRow ? latestVisRunRow.finishedAt ?? latestVisRunRow.createdAt : null,
13562
+ hasEverRun: latestVisRunRow != null
13563
+ });
13564
+ }
13565
+ const feed = buildPortfolioChangeFeed(feedInputs, generatedAt);
13566
+ const projectById = new Map(projectRows.map((p) => [p.id, p]));
13567
+ const recentRunRows = app.db.select().from(runs).where(and12(eq17(runs.kind, RunKinds["answer-visibility"]), notProbeRun())).orderBy(desc9(runs.createdAt), desc9(runs.id)).limit(PORTFOLIO_RECENT_RUNS_LIMIT).all();
13568
+ const recentRuns = [];
13569
+ for (const run of recentRunRows) {
13570
+ const projectRow = projectById.get(run.projectId);
13571
+ if (!projectRow) continue;
13572
+ const point = runHistoryById.get(run.id);
13573
+ const countable = run.status === RunStatuses.completed || run.status === RunStatuses.partial;
13574
+ const error = parseRunError(run.error);
13575
+ recentRuns.push({
13576
+ runId: run.id,
13577
+ projectName: projectRow.displayName || projectRow.name,
13578
+ projectSlug: projectRow.name,
13579
+ kindLabel: "Answer visibility sweep",
13580
+ status: run.status,
13581
+ createdAt: run.createdAt,
13582
+ startedAt: run.startedAt ?? null,
13583
+ finishedAt: run.finishedAt ?? null,
13584
+ durationMs: run.startedAt && run.finishedAt ? new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime() : null,
13585
+ mentionedCount: countable && point ? point.mentionedCount : null,
13586
+ citedCount: countable && point ? point.citedCount : null,
13587
+ totalCount: countable && point ? point.totalCount : null,
13588
+ errorSummary: error ? formatRunErrorOneLine(error) : null
13589
+ });
13590
+ }
13591
+ return {
13592
+ generatedAt,
13593
+ lastSweepAt,
13594
+ projectCount: projectRows.length,
13595
+ comparableProjectCount: feed.comparableProjectCount,
13596
+ firstSweepProjectCount: feed.firstSweepProjectCount,
13597
+ changeFeed: feed.changeFeed,
13598
+ changeFeedTotal: feed.changeFeedTotal,
13599
+ feedEmptyState: feed.feedEmptyState,
13600
+ recentRuns,
13601
+ projects: projectRowDtos
13602
+ };
13603
+ }
13255
13604
  app.get("/projects/:name/search", async (request, reply) => {
13256
13605
  const project = resolveProject(app.db, request.params.name);
13257
13606
  const rawQuery = (request.query.q ?? "").trim();
@@ -13878,6 +14227,7 @@ var SCHEMA_TABLE = {
13878
14227
  LatestProjectRunDto: latestProjectRunDtoSchema,
13879
14228
  LocationContext: locationContextSchema,
13880
14229
  NotificationDto: notificationDtoSchema,
14230
+ PortfolioDto: portfolioDtoSchema,
13881
14231
  ProjectDto: projectDtoSchema,
13882
14232
  ProjectOverviewDto: projectOverviewDtoSchema,
13883
14233
  ProjectReportDto: projectReportDtoSchema,
@@ -17132,6 +17482,17 @@ var routeCatalog = [
17132
17482
  404: errorResponse("Project not found.")
17133
17483
  }
17134
17484
  },
17485
+ {
17486
+ method: "get",
17487
+ path: "/api/v1/portfolio",
17488
+ summary: "Cross-project portfolio: what changed, recent runs, project state",
17489
+ description: 'One call for the dashboard Portfolio page and `canonry portfolio`. Returns a server-ordered "what changed" feed (citation/mention gains and losses over each project\'s comparable basket, failed sweeps, critical/high insight echoes, stale-visibility, query-set changes, and never-run projects \u2014 recency then severity), a timestamped recent-runs log with independent mention and cited result counts, and a per-project state table. Probe runs are excluded. `generatedAt` is the freshness anchor for every relative timestamp.',
17490
+ tags: ["intelligence"],
17491
+ parameters: [],
17492
+ responses: {
17493
+ 200: jsonResponse("Portfolio overview returned.", "PortfolioDto")
17494
+ }
17495
+ },
17135
17496
  {
17136
17497
  method: "get",
17137
17498
  path: "/api/v1/projects/{name}/overview",
@@ -21527,7 +21888,7 @@ async function googleRoutes(app, opts) {
21527
21888
 
21528
21889
  // ../api-routes/src/ads.ts
21529
21890
  import crypto19 from "crypto";
21530
- import { eq as eq22, and as and15, asc as asc2, gte as gte3, lte as lte2, inArray as inArray11 } from "drizzle-orm";
21891
+ import { eq as eq22, and as and15, asc as asc3, gte as gte3, lte as lte2, inArray as inArray11 } from "drizzle-orm";
21531
21892
  function statusDto(row) {
21532
21893
  if (!row) return { connected: false };
21533
21894
  return {
@@ -21728,7 +22089,7 @@ async function adsRoutes(app, opts) {
21728
22089
  if (entityId) conditions.push(eq22(adsInsightsDaily.entityId, entityId));
21729
22090
  if (from) conditions.push(gte3(adsInsightsDaily.date, from));
21730
22091
  if (to) conditions.push(lte2(adsInsightsDaily.date, to));
21731
- const rows = app.db.select().from(adsInsightsDaily).where(and15(...conditions)).orderBy(asc2(adsInsightsDaily.date)).all();
22092
+ const rows = app.db.select().from(adsInsightsDaily).where(and15(...conditions)).orderBy(asc3(adsInsightsDaily.date)).all();
21732
22093
  const dtoRows = rows.map((row) => ({
21733
22094
  level: row.level,
21734
22095
  entityId: row.entityId,
@@ -25137,7 +25498,7 @@ async function wordpressRoutes(app, opts) {
25137
25498
 
25138
25499
  // ../api-routes/src/backlinks.ts
25139
25500
  import crypto23 from "crypto";
25140
- import { and as and20, asc as asc3, desc as desc14, eq as eq26, sql as sql10 } from "drizzle-orm";
25501
+ import { and as and20, asc as asc4, desc as desc14, eq as eq26, sql as sql10 } from "drizzle-orm";
25141
25502
 
25142
25503
  // ../integration-commoncrawl/src/constants.ts
25143
25504
  import os2 from "os";
@@ -25896,7 +26257,7 @@ async function backlinksRoutes(app, opts) {
25896
26257
  async (request, reply) => {
25897
26258
  const project = resolveProject(app.db, request.params.name);
25898
26259
  const source = parseSourceParam(request.query.source);
25899
- const rows = app.db.select().from(backlinkSummaries).where(and20(eq26(backlinkSummaries.projectId, project.id), eq26(backlinkSummaries.source, source))).orderBy(asc3(backlinkSummaries.queriedAt)).all();
26260
+ const rows = app.db.select().from(backlinkSummaries).where(and20(eq26(backlinkSummaries.projectId, project.id), eq26(backlinkSummaries.source, source))).orderBy(asc4(backlinkSummaries.queriedAt)).all();
25900
26261
  const response = rows.map((r) => ({
25901
26262
  release: r.release,
25902
26263
  totalLinkingDomains: r.totalLinkingDomains,
@@ -33861,7 +34222,7 @@ function dedupeStrings(input) {
33861
34222
 
33862
34223
  // ../api-routes/src/technical-aeo.ts
33863
34224
  import crypto29 from "crypto";
33864
- import { and as and26, asc as asc4, count, desc as desc17, eq as eq36, inArray as inArray13 } from "drizzle-orm";
34225
+ import { and as and26, asc as asc5, count, desc as desc17, eq as eq36, inArray as inArray13 } from "drizzle-orm";
33865
34226
  var SURFACEABLE_STATUSES = [RunStatuses.completed, RunStatuses.partial];
33866
34227
  function emptyScore(projectName) {
33867
34228
  return {
@@ -33945,7 +34306,7 @@ async function technicalAeoRoutes(app, opts) {
33945
34306
  const total = totalRow?.value ?? 0;
33946
34307
  const limit = parsePositiveInt(request.query.limit, 100, 500);
33947
34308
  const offset = parsePositiveInt(request.query.offset, 0, Number.MAX_SAFE_INTEGER);
33948
- const orderBy = request.query.sort === "score-desc" ? desc17(siteAuditPages.overallScore) : request.query.sort === "url" ? asc4(siteAuditPages.url) : asc4(siteAuditPages.overallScore);
34309
+ const orderBy = request.query.sort === "score-desc" ? desc17(siteAuditPages.overallScore) : request.query.sort === "url" ? asc5(siteAuditPages.url) : asc5(siteAuditPages.overallScore);
33949
34310
  const rows = app.db.select().from(siteAuditPages).where(where).orderBy(orderBy).limit(limit).offset(offset).all();
33950
34311
  const pages = rows.map((row) => ({
33951
34312
  url: row.url,
@@ -34880,7 +35241,7 @@ var IntelligenceService = class {
34880
35241
  // Backfill must not replay probe runs as if they were real sweeps.
34881
35242
  ne5(runs.trigger, RunTriggers.probe)
34882
35243
  )
34883
- ).orderBy(asc5(runs.finishedAt)).all();
35244
+ ).orderBy(asc6(runs.finishedAt)).all();
34884
35245
  let startIdx = 0;
34885
35246
  let endIdx = allRuns.length;
34886
35247
  if (opts?.fromRunId) {