@ainyc/canonry 4.35.0 → 4.39.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.
@@ -8,7 +8,7 @@ import {
8
8
  categoryLabel,
9
9
  determineAnswerMentioned,
10
10
  normalizeProjectDomain
11
- } from "./chunk-EM5GVF3C.js";
11
+ } from "./chunk-XJVYVURK.js";
12
12
 
13
13
  // src/intelligence-service.ts
14
14
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";
@@ -3256,24 +3256,51 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains, topDo
3256
3256
  }
3257
3257
 
3258
3258
  // ../intelligence/src/movement-summary.ts
3259
- function buildMovementSummary(currentSnapshots, previousSnapshots) {
3259
+ function buildMovementSummary(currentSnapshots, previousSnapshots, options = {}) {
3260
3260
  if (previousSnapshots.length === 0) {
3261
- const citedCount = collectCitedQueryIds(currentSnapshots).size;
3261
+ const citedIds = collectCitedQueryIds(currentSnapshots);
3262
+ const citedCount = citedIds.size;
3262
3263
  const tone2 = citedCount > 0 ? "positive" : "neutral";
3263
- return { gained: citedCount, lost: 0, tone: tone2, hasPreviousRun: false };
3264
+ return withQueryLists(
3265
+ { gained: citedCount, lost: 0, tone: tone2, hasPreviousRun: false },
3266
+ citedIds,
3267
+ /* @__PURE__ */ new Set(),
3268
+ options.queryLookup
3269
+ );
3264
3270
  }
3265
3271
  const latestCited = collectCitedQueryIds(currentSnapshots);
3266
3272
  const previousCited = collectCitedQueryIds(previousSnapshots);
3267
- let gained = 0;
3268
- let lost = 0;
3273
+ const gainedIds = /* @__PURE__ */ new Set();
3274
+ const lostIds = /* @__PURE__ */ new Set();
3269
3275
  for (const id of latestCited) {
3270
- if (!previousCited.has(id)) gained++;
3276
+ if (!previousCited.has(id)) gainedIds.add(id);
3271
3277
  }
3272
3278
  for (const id of previousCited) {
3273
- if (!latestCited.has(id)) lost++;
3279
+ if (!latestCited.has(id)) lostIds.add(id);
3280
+ }
3281
+ const tone = lostIds.size > gainedIds.size ? "negative" : gainedIds.size > lostIds.size ? "positive" : "neutral";
3282
+ return withQueryLists(
3283
+ { gained: gainedIds.size, lost: lostIds.size, tone, hasPreviousRun: true },
3284
+ gainedIds,
3285
+ lostIds,
3286
+ options.queryLookup
3287
+ );
3288
+ }
3289
+ function withQueryLists(base, gainedIds, lostIds, lookup) {
3290
+ if (!lookup) return base;
3291
+ return {
3292
+ ...base,
3293
+ gainedQueries: resolveQueryTexts(gainedIds, lookup),
3294
+ lostQueries: resolveQueryTexts(lostIds, lookup)
3295
+ };
3296
+ }
3297
+ function resolveQueryTexts(ids, lookup) {
3298
+ const out = [];
3299
+ for (const id of ids) {
3300
+ const text2 = lookup.get(id);
3301
+ if (text2) out.push(text2);
3274
3302
  }
3275
- const tone = lost > gained ? "negative" : gained > lost ? "positive" : "neutral";
3276
- return { gained, lost, tone, hasPreviousRun: true };
3303
+ return out.sort();
3277
3304
  }
3278
3305
  function collectCitedQueryIds(snapshots) {
3279
3306
  const cited = /* @__PURE__ */ new Set();
@@ -3303,14 +3330,14 @@ function gapTone(gapCount, totalCount) {
3303
3330
 
3304
3331
  // ../intelligence/src/visibility-score.ts
3305
3332
  function buildVisibilityScore(snapshots, options) {
3306
- const tooltip = 'Percentage of tracked queries where your domain is cited by at least one AI answer engine. A query is "visible" if any configured provider includes your site in its response.';
3333
+ const tooltip = "An LLM used a page on your domain as a source for its answer.";
3307
3334
  if (snapshots.length === 0) {
3308
3335
  return {
3309
- label: "Answer Visibility",
3336
+ label: "Citation Coverage",
3310
3337
  value: "No data",
3311
3338
  delta: "Run a sweep first",
3312
3339
  tone: "neutral",
3313
- description: "No visibility data yet. Trigger a run to start tracking.",
3340
+ description: "No citation data yet. Trigger a run to start tracking.",
3314
3341
  tooltip,
3315
3342
  trend: []
3316
3343
  };
@@ -3327,9 +3354,9 @@ function buildVisibilityScore(snapshots, options) {
3327
3354
  const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
3328
3355
  const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
3329
3356
  return {
3330
- label: "Answer Visibility",
3357
+ label: "Citation Coverage",
3331
3358
  value: `${score}`,
3332
- delta: `${citedCount} of ${totalCount} queries visible`,
3359
+ delta: `${citedCount} of ${totalCount} queries cited`,
3333
3360
  tone: isPartialProviderRun ? "caution" : scoreTone(score),
3334
3361
  description: `${citedCount} of ${totalCount} tracked queries found your domain in at least one AI answer engine.`,
3335
3362
  tooltip,
@@ -3339,6 +3366,44 @@ function buildVisibilityScore(snapshots, options) {
3339
3366
  };
3340
3367
  }
3341
3368
 
3369
+ // ../intelligence/src/mention-coverage.ts
3370
+ function buildMentionCoverage(snapshots, options) {
3371
+ const tooltip = "Your domain or company name was in the answer returned by the LLM.";
3372
+ if (snapshots.length === 0) {
3373
+ return {
3374
+ label: "Mention Coverage",
3375
+ value: "No data",
3376
+ delta: "Run a sweep first",
3377
+ tone: "neutral",
3378
+ description: "No mention data yet. Trigger a run to start tracking.",
3379
+ tooltip,
3380
+ trend: []
3381
+ };
3382
+ }
3383
+ const queryMentioned = /* @__PURE__ */ new Map();
3384
+ for (const snap of snapshots) {
3385
+ if (!queryMentioned.has(snap.queryId)) queryMentioned.set(snap.queryId, false);
3386
+ if (snap.answerMentioned === true) queryMentioned.set(snap.queryId, true);
3387
+ }
3388
+ const totalCount = queryMentioned.size;
3389
+ const mentionedCount = [...queryMentioned.values()].filter(Boolean).length;
3390
+ const score = totalCount > 0 ? Math.round(mentionedCount / totalCount * 100) : 0;
3391
+ const runProviders = new Set(snapshots.map((s) => s.provider));
3392
+ const runApiProviderCount = options.configuredApiProviders.filter((p) => runProviders.has(p)).length;
3393
+ const isPartialProviderRun = options.configuredApiProviders.length > 1 && runApiProviderCount < options.configuredApiProviders.length;
3394
+ return {
3395
+ label: "Mention Coverage",
3396
+ value: `${score}`,
3397
+ delta: `${mentionedCount} of ${totalCount} queries mentioned`,
3398
+ tone: isPartialProviderRun ? "caution" : scoreTone(score),
3399
+ description: `${mentionedCount} of ${totalCount} tracked queries had your brand or domain in the AI answer text.`,
3400
+ tooltip,
3401
+ trend: [],
3402
+ progress: score,
3403
+ providerCoverage: isPartialProviderRun ? `${runApiProviderCount} of ${options.configuredApiProviders.length} providers` : void 0
3404
+ };
3405
+ }
3406
+
3342
3407
  // ../intelligence/src/gap-query-score.ts
3343
3408
  function buildGapQueryScore(snapshots) {
3344
3409
  const tooltip = "Tracked queries where a competitor is cited in the latest run but your domain is not.";
@@ -3367,11 +3432,48 @@ function buildGapQueryScore(snapshots) {
3367
3432
  ).length;
3368
3433
  const gapQueryLabel = gapCount === 1 ? "query" : "queries";
3369
3434
  return {
3370
- label: "Gap Queries",
3435
+ label: "Citation Gaps",
3371
3436
  value: `${gapCount}`,
3372
3437
  delta: `${gapCount} of ${totalCount} queries at risk`,
3373
3438
  tone: gapTone(gapCount, totalCount),
3374
- description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive query gaps detected in the latest visibility run.",
3439
+ description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} currently cite competitors without citing your domain.` : "No competitive citation gaps detected in the latest visibility run.",
3440
+ tooltip,
3441
+ trend: [],
3442
+ progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
3443
+ };
3444
+ }
3445
+ function buildMentionGapScore(snapshots) {
3446
+ const tooltip = "Tracked queries where a competitor surfaces in the latest run but your brand / domain is not mentioned in the answer text.";
3447
+ if (snapshots.length === 0) {
3448
+ return {
3449
+ label: "Mention Gaps",
3450
+ value: "No data",
3451
+ delta: "Run a sweep first",
3452
+ tone: "neutral",
3453
+ description: "Run a visibility sweep to identify queries where competitors are mentioned and your brand is not.",
3454
+ tooltip,
3455
+ trend: []
3456
+ };
3457
+ }
3458
+ const byQuery = /* @__PURE__ */ new Map();
3459
+ for (const snap of snapshots) {
3460
+ const key = snap.queryId;
3461
+ const current = byQuery.get(key) ?? { mentioned: false, competitorOverlap: /* @__PURE__ */ new Set() };
3462
+ if (snap.answerMentioned === true) current.mentioned = true;
3463
+ for (const domain of snap.competitorOverlap) current.competitorOverlap.add(domain);
3464
+ byQuery.set(key, current);
3465
+ }
3466
+ const totalCount = byQuery.size;
3467
+ const gapCount = [...byQuery.values()].filter(
3468
+ (entry) => !entry.mentioned && entry.competitorOverlap.size > 0
3469
+ ).length;
3470
+ const gapQueryLabel = gapCount === 1 ? "query" : "queries";
3471
+ return {
3472
+ label: "Mention Gaps",
3473
+ value: `${gapCount}`,
3474
+ delta: `${gapCount} of ${totalCount} queries at risk`,
3475
+ tone: gapTone(gapCount, totalCount),
3476
+ description: gapCount > 0 ? `${gapCount} tracked ${gapQueryLabel} mention competitors but never your brand.` : "No competitive mention gaps detected in the latest visibility run.",
3375
3477
  tooltip,
3376
3478
  trend: [],
3377
3479
  progress: totalCount > 0 ? Math.round(gapCount / totalCount * 100) : 0
@@ -3487,6 +3589,92 @@ function buildRunHistory(runs2, snapshotsByRunId, limit = DEFAULT_RUN_HISTORY_LI
3487
3589
  });
3488
3590
  }
3489
3591
 
3592
+ // ../intelligence/src/share-of-voice.ts
3593
+ function sovTone(score) {
3594
+ if (score >= 30) return "positive";
3595
+ if (score >= 10) return "caution";
3596
+ return "negative";
3597
+ }
3598
+ function buildShareOfVoice(snapshots, options) {
3599
+ const tooltip = "Your domain's share of every distinct cited-source slot across the latest run. Subdomain-aware (cited docs.you.com counts for you.com). Distinct from Citation Coverage \u2014 SoV measures how much of the answer real-estate you own, not just whether you appear.";
3600
+ if (snapshots.length === 0) {
3601
+ return {
3602
+ label: "Share of Voice",
3603
+ value: "No data",
3604
+ delta: "Run a sweep first",
3605
+ tone: "neutral",
3606
+ description: "No SoV data yet. Trigger a run to start tracking.",
3607
+ tooltip,
3608
+ trend: []
3609
+ };
3610
+ }
3611
+ const breakdown = computeShareOfVoiceBreakdown(snapshots, options);
3612
+ if (breakdown.totalSlots === 0) {
3613
+ return {
3614
+ label: "Share of Voice",
3615
+ value: "0",
3616
+ delta: "No citations in this run",
3617
+ tone: "neutral",
3618
+ description: "The latest run produced no source-list citations across any provider, so SoV cannot be measured. (Mention Coverage may still be non-zero \u2014 answers can mention you without grounding to a URL.)",
3619
+ tooltip,
3620
+ trend: [],
3621
+ progress: 0
3622
+ };
3623
+ }
3624
+ const { projectSlots, competitorSlots, otherSlots, totalSlots } = breakdown;
3625
+ const score = Math.round(projectSlots / totalSlots * 100);
3626
+ const competitorShare = Math.round(competitorSlots / totalSlots * 100);
3627
+ const otherShare = Math.max(0, 100 - score - competitorShare);
3628
+ const hasCompetitorsConfigured = options.competitorDomains.length > 0;
3629
+ const description = describeBreakdown({
3630
+ projectSlots,
3631
+ competitorSlots,
3632
+ otherSlots,
3633
+ totalSlots,
3634
+ score,
3635
+ competitorShare,
3636
+ otherShare,
3637
+ hasCompetitorsConfigured
3638
+ });
3639
+ return {
3640
+ label: "Share of Voice",
3641
+ value: `${score}`,
3642
+ delta: `${projectSlots} of ${totalSlots} cited slots`,
3643
+ tone: sovTone(score),
3644
+ description,
3645
+ tooltip,
3646
+ trend: [],
3647
+ progress: score
3648
+ };
3649
+ }
3650
+ function computeShareOfVoiceBreakdown(snapshots, options) {
3651
+ let totalSlots = 0;
3652
+ let projectSlots = 0;
3653
+ let competitorSlots = 0;
3654
+ for (const snap of snapshots) {
3655
+ for (const domain of snap.citedDomains) {
3656
+ totalSlots++;
3657
+ if (citedDomainBelongsToProject(domain, options.projectDomains)) {
3658
+ projectSlots++;
3659
+ } else if (citedDomainBelongsToProject(domain, options.competitorDomains)) {
3660
+ competitorSlots++;
3661
+ }
3662
+ }
3663
+ }
3664
+ const otherSlots = totalSlots - projectSlots - competitorSlots;
3665
+ return { projectSlots, competitorSlots, otherSlots, totalSlots };
3666
+ }
3667
+ function describeBreakdown(parts) {
3668
+ const { projectSlots, competitorSlots, totalSlots, score, competitorShare, otherShare, hasCompetitorsConfigured } = parts;
3669
+ if (!hasCompetitorsConfigured) {
3670
+ return `${projectSlots} of ${totalSlots} cited slots were yours (${score}%). Add tracked competitors to break out the rest.`;
3671
+ }
3672
+ if (competitorSlots === 0) {
3673
+ return `${projectSlots} of ${totalSlots} cited slots were yours (${score}%); no tracked competitors surfaced in the run. The remaining ${otherShare}% goes to unrelated sources.`;
3674
+ }
3675
+ return `You own ${score}% of cited slots; tracked competitors hold ${competitorShare}%; the remaining ${otherShare}% goes to non-competitive sources.`;
3676
+ }
3677
+
3490
3678
  // src/intelligence-service.ts
3491
3679
  import crypto from "crypto";
3492
3680
 
@@ -3611,8 +3799,13 @@ var IntelligenceService = class {
3611
3799
  /**
3612
3800
  * Analyze a single run given an explicit previous run (or null for first run).
3613
3801
  * Used by backfill where we control the run ordering.
3802
+ *
3803
+ * `dryRun: true` skips the DB write — `persistResult` is not called and
3804
+ * dismissed flags / health rows are untouched. Callers receive the same
3805
+ * AnalysisResult they would have, suitable for previewing what a write
3806
+ * would have produced.
3614
3807
  */
3615
- analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords) {
3808
+ analyzeRunWithPrevious(runRecord, previousRunRecord, historyRecords, opts) {
3616
3809
  const currentRun = this.buildRunData(
3617
3810
  runRecord.id,
3618
3811
  runRecord.projectId,
@@ -3632,23 +3825,44 @@ var IntelligenceService = class {
3632
3825
  const history = (historyRecords ?? []).map((r) => r.id === runRecord.id ? currentRun : this.buildRunData(r.id, r.projectId, r.finishedAt ?? r.createdAt, r.location ?? null));
3633
3826
  if (!previousRun) {
3634
3827
  const result2 = analyzeRuns(currentRun, currentRun, { trackedCompetitors, history });
3635
- this.persistResult(this.emptyAnalysisResult(result2), runRecord.id, runRecord.projectId);
3828
+ const emptyResult = this.emptyAnalysisResult(result2);
3829
+ if (!opts?.dryRun) this.persistResult(emptyResult, runRecord.id, runRecord.projectId);
3636
3830
  return result2;
3637
3831
  }
3638
3832
  const result = analyzeRuns(currentRun, previousRun, { trackedCompetitors, history });
3639
3833
  const tieredResult = this.tierResult(result, runRecord.id, runRecord.projectId);
3640
- this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
3834
+ if (!opts?.dryRun) this.persistResult(tieredResult, runRecord.id, runRecord.projectId);
3641
3835
  return tieredResult;
3642
3836
  }
3643
3837
  /**
3644
3838
  * Backfill intelligence for all completed/partial runs of a project.
3645
3839
  * Processes runs in chronological order so each run compares against its predecessor.
3840
+ *
3841
+ * Scoping options:
3842
+ * - `fromRunId` / `toRunId`: bound the target range by exact run ID.
3843
+ * - `since`: bound the target range by `finishedAt >= <date>`. Accepts
3844
+ * any string that `Date.parse` understands (ISO 8601, `YYYY-MM-DD`,
3845
+ * etc.). Runs before the cutoff are *not* re-processed but stay
3846
+ * available for predecessor lookup, so transition detection at the
3847
+ * boundary stays correct. Composes with `fromRunId` / `toRunId` —
3848
+ * all three filters intersect.
3849
+ * - `dryRun`: compute the analysis without writing. The return value
3850
+ * includes a `delta` describing what would change (rows to delete vs
3851
+ * create per run + aggregate). DB is left untouched.
3646
3852
  */
3647
3853
  backfill(projectName, opts, onProgress) {
3648
3854
  const project = this.db.select().from(projects).where(eq(projects.name, projectName)).get();
3649
3855
  if (!project) {
3650
3856
  throw new Error(`Project "${projectName}" not found`);
3651
3857
  }
3858
+ let sinceTimestamp = null;
3859
+ if (opts?.since !== void 0) {
3860
+ const parsed = Date.parse(opts.since);
3861
+ if (Number.isNaN(parsed)) {
3862
+ throw new Error(`Invalid --since value "${opts.since}": expected a parseable date (ISO 8601 or YYYY-MM-DD)`);
3863
+ }
3864
+ sinceTimestamp = parsed;
3865
+ }
3652
3866
  const allRuns = this.db.select().from(runs).where(
3653
3867
  and(
3654
3868
  eq(runs.projectId, project.id),
@@ -3667,10 +3881,28 @@ var IntelligenceService = class {
3667
3881
  if (idx === -1) throw new Error(`Run "${opts.toRunId}" not found in project`);
3668
3882
  endIdx = idx + 1;
3669
3883
  }
3670
- const targetRuns = allRuns.slice(startIdx, endIdx);
3884
+ let targetRuns = allRuns.slice(startIdx, endIdx);
3885
+ if (sinceTimestamp !== null) {
3886
+ targetRuns = targetRuns.filter((r) => {
3887
+ const ts = r.finishedAt ?? r.createdAt;
3888
+ const t = Date.parse(ts);
3889
+ return !Number.isNaN(t) && t >= sinceTimestamp;
3890
+ });
3891
+ }
3671
3892
  let processed = 0;
3672
3893
  let skipped = 0;
3673
3894
  let totalInsights = 0;
3895
+ const isDryRun = opts?.dryRun === true;
3896
+ const perRunDelta = [];
3897
+ let wouldDeleteTotal = 0;
3898
+ const existingByRunId = /* @__PURE__ */ new Map();
3899
+ if (isDryRun && targetRuns.length > 0) {
3900
+ const rows = this.db.select({ runId: insights.runId }).from(insights).where(inArray(insights.runId, targetRuns.map((r) => r.id))).all();
3901
+ for (const r of rows) {
3902
+ if (r.runId == null) continue;
3903
+ existingByRunId.set(r.runId, (existingByRunId.get(r.runId) ?? 0) + 1);
3904
+ }
3905
+ }
3674
3906
  for (let i = 0; i < targetRuns.length; i++) {
3675
3907
  const run = targetRuns[i];
3676
3908
  const runLocation = run.location ?? null;
@@ -3679,16 +3911,35 @@ var IntelligenceService = class {
3679
3911
  const previousRun = sameLocIdx > 0 ? sameLocationRuns[sameLocIdx - 1] : null;
3680
3912
  const historyStart = Math.max(0, sameLocIdx - (HISTORY_WINDOW_RUNS - 1));
3681
3913
  const historyRecords = sameLocationRuns.slice(historyStart, sameLocIdx + 1);
3682
- const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords);
3914
+ const result = this.analyzeRunWithPrevious(run, previousRun, historyRecords, { dryRun: isDryRun });
3683
3915
  if (result) {
3684
3916
  processed++;
3685
3917
  totalInsights += result.insights.length;
3918
+ if (isDryRun) {
3919
+ const existing = existingByRunId.get(run.id) ?? 0;
3920
+ wouldDeleteTotal += existing;
3921
+ perRunDelta.push({ runId: run.id, existingInsights: existing, newInsights: result.insights.length });
3922
+ }
3686
3923
  onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: result.insights.length });
3687
3924
  } else {
3688
3925
  skipped++;
3689
3926
  onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: 0 });
3690
3927
  }
3691
3928
  }
3929
+ if (isDryRun) {
3930
+ return {
3931
+ processed,
3932
+ skipped,
3933
+ totalInsights,
3934
+ dryRun: true,
3935
+ delta: {
3936
+ wouldDelete: wouldDeleteTotal,
3937
+ wouldCreate: totalInsights,
3938
+ netChange: totalInsights - wouldDeleteTotal,
3939
+ perRun: perRunDelta
3940
+ }
3941
+ };
3942
+ }
3692
3943
  return { processed, skipped, totalInsights };
3693
3944
  }
3694
3945
  loadTrackedCompetitors(projectId) {
@@ -3838,17 +4089,28 @@ var IntelligenceService = class {
3838
4089
  buildRunData(runId, projectId, completedAt, location = null) {
3839
4090
  const rows = this.db.select({
3840
4091
  query: queries.query,
4092
+ // Denormalized query text persisted by v58 — the fallback when the
4093
+ // joined queries.query has been hard-deleted (or the query_id was
4094
+ // nulled by the v58 dangling-FK cleanup).
4095
+ queryText: querySnapshots.queryText,
3841
4096
  provider: querySnapshots.provider,
3842
4097
  citationState: querySnapshots.citationState,
3843
4098
  citedDomains: querySnapshots.citedDomains,
3844
4099
  competitorOverlap: querySnapshots.competitorOverlap,
3845
4100
  snapshotLocation: querySnapshots.location
3846
4101
  }).from(querySnapshots).leftJoin(queries, eq(querySnapshots.queryId, queries.id)).where(eq(querySnapshots.runId, runId)).all();
3847
- const snapshots = rows.map((r) => {
4102
+ const snapshots = [];
4103
+ let orphanCount = 0;
4104
+ for (const r of rows) {
4105
+ const resolvedQuery = r.query ?? r.queryText ?? null;
4106
+ if (!resolvedQuery) {
4107
+ orphanCount++;
4108
+ continue;
4109
+ }
3848
4110
  const domains = parseJsonColumn(r.citedDomains, []);
3849
4111
  const competitors2 = parseJsonColumn(r.competitorOverlap, []);
3850
- return {
3851
- query: r.query ?? "",
4112
+ snapshots.push({
4113
+ query: resolvedQuery,
3852
4114
  provider: r.provider,
3853
4115
  cited: r.citationState === CitationStates.cited,
3854
4116
  citationUrl: domains[0] ?? void 0,
@@ -3863,8 +4125,11 @@ var IntelligenceService = class {
3863
4125
  // sources). Cause analysis uses it to name the displacing source
3864
4126
  // when no tracked competitor appears in the response.
3865
4127
  citedDomains: domains
3866
- };
3867
- });
4128
+ });
4129
+ }
4130
+ if (orphanCount > 0) {
4131
+ log.warn("snapshot.orphan-skip", { runId, projectId, orphanCount });
4132
+ }
3868
4133
  return { runId, projectId, completedAt, location, snapshots };
3869
4134
  }
3870
4135
  };
@@ -3928,12 +4193,15 @@ export {
3928
4193
  buildAiSourceOrigin,
3929
4194
  buildMovementSummary,
3930
4195
  buildVisibilityScore,
4196
+ buildMentionCoverage,
3931
4197
  buildGapQueryScore,
4198
+ buildMentionGapScore,
3932
4199
  buildCompetitorPressureScore,
3933
4200
  buildOverviewCompetitors,
3934
4201
  buildProviderScores,
3935
4202
  DEFAULT_RUN_HISTORY_LIMIT,
3936
4203
  buildRunHistory,
4204
+ buildShareOfVoice,
3937
4205
  createLogger,
3938
4206
  IntelligenceService
3939
4207
  };
@@ -22,7 +22,7 @@ import {
22
22
  trafficConnectVercelRequestSchema,
23
23
  trafficConnectWordpressRequestSchema,
24
24
  trafficEventKindSchema
25
- } from "./chunk-EM5GVF3C.js";
25
+ } from "./chunk-XJVYVURK.js";
26
26
 
27
27
  // src/config.ts
28
28
  import fs from "fs";
@@ -466,9 +466,19 @@ var ApiClient = class {
466
466
  async deleteProject(name) {
467
467
  await this.request("DELETE", `/projects/${encodeURIComponent(name)}`);
468
468
  }
469
+ async previewProjectDelete(name) {
470
+ return this.request("GET", `/projects/${encodeURIComponent(name)}/delete-preview`);
471
+ }
469
472
  async putQueries(project, queries) {
470
473
  await this.request("PUT", `/projects/${encodeURIComponent(project)}/queries`, { queries });
471
474
  }
475
+ async previewReplaceQueries(project, queries) {
476
+ return this.request(
477
+ "POST",
478
+ `/projects/${encodeURIComponent(project)}/queries/replace-preview`,
479
+ { queries }
480
+ );
481
+ }
472
482
  async listQueries(project) {
473
483
  return this.request("GET", `/projects/${encodeURIComponent(project)}/queries`);
474
484
  }
@@ -1333,6 +1343,17 @@ var canonryMcpTools = [
1333
1343
  openApiOperations: ["GET /api/v1/projects/{name}"],
1334
1344
  handler: (client, input) => client.getProject(input.project)
1335
1345
  }),
1346
+ defineTool({
1347
+ name: "canonry_project_delete_preview",
1348
+ title: "Preview project delete impact",
1349
+ description: "Returns the cascade impact of deleting a project \u2014 how many queries, competitors, runs, snapshots, and insights would be removed, plus how many audit_log rows would be detached (project_id set NULL). Read-only. Use this BEFORE invoking project delete on any project you didn't create yourself; the underlying delete is irreversible.",
1350
+ access: "read",
1351
+ tier: "setup",
1352
+ inputSchema: projectInputSchema,
1353
+ annotations: readAnnotations(),
1354
+ openApiOperations: ["GET /api/v1/projects/{name}/delete-preview"],
1355
+ handler: (client, input) => client.previewProjectDelete(input.project)
1356
+ }),
1336
1357
  defineTool({
1337
1358
  name: "canonry_project_overview",
1338
1359
  title: "Get project overview (composite)",
@@ -1981,6 +2002,17 @@ var canonryMcpTools = [
1981
2002
  await client.putQueries(input.project, uniqueStrings(input.request.queries));
1982
2003
  }
1983
2004
  }),
2005
+ defineTool({
2006
+ name: "canonry_queries_replace_preview",
2007
+ title: "Preview query replace",
2008
+ description: "Preview the impact of replacing a project's tracked query set: current vs proposed, added/removed/unchanged diff, and the count of snapshots that would detach (queryId \u2192 NULL; queryText preserved). Read-only.",
2009
+ access: "read",
2010
+ tier: "setup",
2011
+ inputSchema: queriesInputSchema,
2012
+ annotations: readAnnotations(),
2013
+ openApiOperations: ["POST /api/v1/projects/{name}/queries/replace-preview"],
2014
+ handler: (client, input) => client.previewReplaceQueries(input.project, uniqueStrings(input.request.queries))
2015
+ }),
1984
2016
  defineTool({
1985
2017
  name: "canonry_keywords_replace",
1986
2018
  title: "Replace keywords (legacy alias)",