@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.
- package/assets/assets/{index-u7ZXZ5mA.js → index-BNTDaIuT.js} +116 -116
- package/assets/assets/index-J4ya3gFy.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-MLS5KJWK.js → chunk-DOUV45KI.js} +295 -27
- package/dist/{chunk-B3FBOECD.js → chunk-JS6KRBBZ.js} +33 -1
- package/dist/{chunk-NLV4MZZF.js → chunk-Q57CMOL6.js} +531 -296
- package/dist/{chunk-EM5GVF3C.js → chunk-XJVYVURK.js} +3 -1
- package/dist/cli.js +126 -36
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-WAJOEOJV.js → intelligence-service-CIGYPPMR.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +7 -7
- package/assets/assets/index-Cfv0_lwq.css +0 -1
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
categoryLabel,
|
|
9
9
|
determineAnswerMentioned,
|
|
10
10
|
normalizeProjectDomain
|
|
11
|
-
} from "./chunk-
|
|
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
|
|
3261
|
+
const citedIds = collectCitedQueryIds(currentSnapshots);
|
|
3262
|
+
const citedCount = citedIds.size;
|
|
3262
3263
|
const tone2 = citedCount > 0 ? "positive" : "neutral";
|
|
3263
|
-
return
|
|
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
|
-
|
|
3268
|
-
|
|
3273
|
+
const gainedIds = /* @__PURE__ */ new Set();
|
|
3274
|
+
const lostIds = /* @__PURE__ */ new Set();
|
|
3269
3275
|
for (const id of latestCited) {
|
|
3270
|
-
if (!previousCited.has(id))
|
|
3276
|
+
if (!previousCited.has(id)) gainedIds.add(id);
|
|
3271
3277
|
}
|
|
3272
3278
|
for (const id of previousCited) {
|
|
3273
|
-
if (!latestCited.has(id))
|
|
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
|
-
|
|
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 =
|
|
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: "
|
|
3336
|
+
label: "Citation Coverage",
|
|
3310
3337
|
value: "No data",
|
|
3311
3338
|
delta: "Run a sweep first",
|
|
3312
3339
|
tone: "neutral",
|
|
3313
|
-
description: "No
|
|
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: "
|
|
3357
|
+
label: "Citation Coverage",
|
|
3331
3358
|
value: `${score}`,
|
|
3332
|
-
delta: `${citedCount} of ${totalCount} queries
|
|
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: "
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3851
|
-
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-
|
|
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)",
|