@ainyc/canonry 4.27.1 → 4.28.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/index.html CHANGED
@@ -12,8 +12,8 @@
12
12
  <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32.png" />
13
13
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
14
14
  <title>Canonry</title>
15
- <script type="module" crossorigin src="./assets/index-BWjq1HP1.js"></script>
16
- <link rel="stylesheet" crossorigin href="./assets/index-rPok6yk8.css">
15
+ <script type="module" crossorigin src="./assets/index--jYjUA0o.js"></script>
16
+ <link rel="stylesheet" crossorigin href="./assets/index-BnALDZI7.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -3,6 +3,8 @@ import {
3
3
  AGENT_MEMORY_VALUE_MAX_BYTES,
4
4
  DISCOVERY_MAX_PROBES_CAP,
5
5
  competitorBatchRequestSchema,
6
+ discoveryBucketSchema,
7
+ discoveryPromoteRequestSchema,
6
8
  discoveryRunRequestSchema,
7
9
  keywordBatchRequestSchema,
8
10
  keywordGenerateRequestSchema,
@@ -18,7 +20,7 @@ import {
18
20
  trafficConnectCloudRunRequestSchema,
19
21
  trafficConnectWordpressRequestSchema,
20
22
  trafficEventKindSchema
21
- } from "./chunk-HVW665A4.js";
23
+ } from "./chunk-RLLFB3M3.js";
22
24
 
23
25
  // src/config.ts
24
26
  import fs from "fs";
@@ -847,6 +849,13 @@ var ApiClient = class {
847
849
  `/projects/${encodeURIComponent(project)}/discover/sessions/${encodeURIComponent(sessionId)}/promote`
848
850
  );
849
851
  }
852
+ async promoteDiscovery(project, sessionId, body) {
853
+ return this.request(
854
+ "POST",
855
+ `/projects/${encodeURIComponent(project)}/discover/sessions/${encodeURIComponent(sessionId)}/promote`,
856
+ body ?? {}
857
+ );
858
+ }
850
859
  async wordpressConnect(project, body) {
851
860
  return this.request("POST", `/projects/${encodeURIComponent(project)}/wordpress/connect`, body);
852
861
  }
@@ -1272,6 +1281,15 @@ var discoverySessionIdInputSchema = z2.object({
1272
1281
  project: projectNameSchema,
1273
1282
  sessionId: z2.string().min(1).describe("Discovery session ID returned by canonry_discover_run_start.")
1274
1283
  });
1284
+ var discoveryPromoteInputSchema = z2.object({
1285
+ project: projectNameSchema,
1286
+ sessionId: z2.string().min(1).describe("Discovery session ID returned by canonry_discover_run_start."),
1287
+ request: discoveryPromoteRequestSchema.extend({
1288
+ // Stronger descriptions for the LLM. The base Zod schema enforces the shape.
1289
+ buckets: z2.array(discoveryBucketSchema).min(1).optional().describe("Which probe buckets to adopt into the tracked basket. Omitted promotes cited + aspirational; include wasted-surface explicitly for off-ICP competitor gaps."),
1290
+ includeCompetitors: z2.boolean().optional().describe("Whether to also merge recurring discovered competitor domains into the project. Defaults to true.")
1291
+ }).optional()
1292
+ });
1275
1293
  var AGENT_WEBHOOK_EVENTS = [
1276
1294
  notificationEventSchema.enum["run.completed"],
1277
1295
  notificationEventSchema.enum["insight.critical"],
@@ -2202,7 +2220,7 @@ var canonryMcpTools = [
2202
2220
  defineTool({
2203
2221
  name: "canonry_discover_session_get",
2204
2222
  title: "Get discovery session",
2205
- description: 'Get one discovery session with the full probe list (per-query bucket + cited domains). Use after canonry_discover_run_start to inspect what the discovery pipeline produced; this is the canonical read for "what did discovery find" before PR 2 lands `canonry discover promote`.',
2223
+ description: 'Get one discovery session with the full probe list (per-query bucket + cited domains). Use after canonry_discover_run_start to inspect what the discovery pipeline produced; this is the canonical read for "what did discovery find" before calling canonry_discover_promote.',
2206
2224
  access: "read",
2207
2225
  tier: "discovery",
2208
2226
  inputSchema: discoverySessionIdInputSchema,
@@ -2213,13 +2231,24 @@ var canonryMcpTools = [
2213
2231
  defineTool({
2214
2232
  name: "canonry_discover_promote_preview",
2215
2233
  title: "Preview discovery promotion",
2216
- description: "Read-only preview of what `canonry discover promote` (PR 2) would persist for a session: bucketed query lists and suggested new competitor domains (those not already in the project's tracked competitor list). v1 returns the preview only; use it to confirm a basket before PR 2 ships the merge step.",
2234
+ description: "Read-only preview of available promotion candidates for a session: bucketed query lists and recurring suggested competitor domains not already in the project's tracked competitor list. Use it to confirm a basket before calling canonry_discover_promote.",
2217
2235
  access: "read",
2218
2236
  tier: "discovery",
2219
2237
  inputSchema: discoverySessionIdInputSchema,
2220
2238
  annotations: readAnnotations(),
2221
2239
  openApiOperations: ["GET /api/v1/projects/{name}/discover/sessions/{id}/promote"],
2222
2240
  handler: (client, input) => client.previewDiscoveryPromote(input.project, input.sessionId)
2241
+ }),
2242
+ defineTool({
2243
+ name: "canonry_discover_promote",
2244
+ title: "Promote discovery session",
2245
+ description: 'Adopt a completed discovery session\'s bucketed queries into the project\'s tracked basket, tagged with provenance "discovery:<sessionId>". By default, only cited + aspirational queries are promoted; include wasted-surface explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains are also merged by default. Add-only and idempotent: queries/domains already tracked are returned under `skipped`, never inserted twice. Only sessions with status "completed" can be promoted. Call canonry_discover_promote_preview first to inspect candidates.',
2246
+ access: "write",
2247
+ tier: "discovery",
2248
+ inputSchema: discoveryPromoteInputSchema,
2249
+ annotations: writeAnnotations({ idempotentHint: true }),
2250
+ openApiOperations: ["POST /api/v1/projects/{name}/discover/sessions/{id}/promote"],
2251
+ handler: (client, input) => client.promoteDiscovery(input.project, input.sessionId, input.request)
2223
2252
  })
2224
2253
  ];
2225
2254
  var CANONRY_MCP_TOOL_COUNT = canonryMcpTools.length;
@@ -5,7 +5,7 @@ import {
5
5
  loadConfig,
6
6
  loadConfigRaw,
7
7
  saveConfigPatch
8
- } from "./chunk-2FAEQ56I.js";
8
+ } from "./chunk-GB3QJURO.js";
9
9
  import {
10
10
  DEFAULT_RUN_HISTORY_LIMIT,
11
11
  IntelligenceService,
@@ -70,7 +70,7 @@ import {
70
70
  schedules,
71
71
  trafficSources,
72
72
  usageCounters
73
- } from "./chunk-NXXD6TX7.js";
73
+ } from "./chunk-UEV3HSRL.js";
74
74
  import {
75
75
  AGENT_MEMORY_VALUE_MAX_BYTES,
76
76
  AGENT_PROVIDER_IDS,
@@ -81,6 +81,9 @@ import {
81
81
  CheckScopes,
82
82
  CheckStatuses,
83
83
  CitationStates,
84
+ DEFAULT_DISCOVERY_PROMOTE_BUCKETS,
85
+ DISCOVERY_PROMOTE_COMPETITOR_CAP,
86
+ DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS,
84
87
  DiscoveryBuckets,
85
88
  DiscoverySessionStatuses,
86
89
  MemorySources,
@@ -119,6 +122,7 @@ import {
119
122
  deltaTone,
120
123
  determineAnswerMentioned,
121
124
  discoveryBucketSchema,
125
+ discoveryPromoteRequestSchema,
122
126
  discoveryRunRequestSchema,
123
127
  effectiveDomains,
124
128
  emptyCitationVisibility,
@@ -171,7 +175,7 @@ import {
171
175
  visibilityStateFromAnswerMentioned,
172
176
  windowCutoff,
173
177
  wordpressEnvSchema
174
- } from "./chunk-HVW665A4.js";
178
+ } from "./chunk-RLLFB3M3.js";
175
179
 
176
180
  // src/telemetry.ts
177
181
  import crypto from "crypto";
@@ -2612,7 +2616,7 @@ function buildCategoryCounts(counts) {
2612
2616
  }
2613
2617
 
2614
2618
  // ../api-routes/src/intelligence.ts
2615
- import { eq as eq11, desc as desc4, and as and3 } from "drizzle-orm";
2619
+ import { eq as eq11, desc as desc4, and as and3, inArray as inArray3 } from "drizzle-orm";
2616
2620
  function emptyHealthSnapshot(projectId) {
2617
2621
  return {
2618
2622
  id: `no-data:${projectId}`,
@@ -2656,6 +2660,44 @@ function mapHealthRow(r) {
2656
2660
  status: "ready"
2657
2661
  };
2658
2662
  }
2663
+ function aggregateHealthSnapshots(projectId, rows) {
2664
+ if (rows.length === 1) return mapHealthRow(rows[0]);
2665
+ let totalPairs = 0;
2666
+ let citedPairs = 0;
2667
+ const mergedProviders = {};
2668
+ let newestCreatedAt = "";
2669
+ const runIds = [];
2670
+ for (const row of rows) {
2671
+ totalPairs += row.totalPairs;
2672
+ citedPairs += row.citedPairs;
2673
+ if (row.createdAt > newestCreatedAt) newestCreatedAt = row.createdAt;
2674
+ if (row.runId) runIds.push(row.runId);
2675
+ const providerBreakdown = parseJsonColumn(row.providerBreakdown, {});
2676
+ for (const [provider, entry] of Object.entries(providerBreakdown)) {
2677
+ const existing = mergedProviders[provider] ?? { total: 0, cited: 0, citedRate: 0 };
2678
+ existing.total += entry.total;
2679
+ existing.cited += entry.cited;
2680
+ mergedProviders[provider] = existing;
2681
+ }
2682
+ }
2683
+ for (const entry of Object.values(mergedProviders)) {
2684
+ entry.citedRate = entry.total > 0 ? entry.cited / entry.total : 0;
2685
+ }
2686
+ const overallCitedRate = totalPairs > 0 ? citedPairs / totalPairs : 0;
2687
+ return {
2688
+ // Synthetic id so consumers can tell this is an aggregate; concatenate
2689
+ // source runIds for traceability without inventing a new schema column.
2690
+ id: `group:${runIds.join(",")}`,
2691
+ projectId,
2692
+ runId: runIds[0] ?? null,
2693
+ overallCitedRate,
2694
+ totalPairs,
2695
+ citedPairs,
2696
+ providerBreakdown: mergedProviders,
2697
+ createdAt: newestCreatedAt,
2698
+ status: "ready"
2699
+ };
2700
+ }
2659
2701
  async function intelligenceRoutes(app) {
2660
2702
  app.get("/projects/:name/insights", async (request, reply) => {
2661
2703
  const project = resolveProject(app.db, request.params.name);
@@ -2687,11 +2729,27 @@ async function intelligenceRoutes(app) {
2687
2729
  });
2688
2730
  app.get("/projects/:name/health/latest", async (request, reply) => {
2689
2731
  const project = resolveProject(app.db, request.params.name);
2690
- const row = app.db.select().from(healthSnapshots).where(eq11(healthSnapshots.projectId, project.id)).orderBy(desc4(healthSnapshots.createdAt)).limit(1).get();
2691
- if (!row) {
2732
+ const projectVisRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(and3(
2733
+ eq11(runs.projectId, project.id),
2734
+ eq11(runs.kind, RunKinds["answer-visibility"]),
2735
+ inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
2736
+ )).orderBy(desc4(runs.createdAt), desc4(runs.id)).all();
2737
+ const latestGroup = groupRunsByCreatedAt(projectVisRuns)[0] ?? [];
2738
+ const latestGroupRunIds = latestGroup.map((r) => r.id);
2739
+ if (latestGroupRunIds.length > 0) {
2740
+ const groupRows = app.db.select().from(healthSnapshots).where(and3(
2741
+ eq11(healthSnapshots.projectId, project.id),
2742
+ inArray3(healthSnapshots.runId, latestGroupRunIds)
2743
+ )).all();
2744
+ if (groupRows.length > 0) {
2745
+ return reply.send(aggregateHealthSnapshots(project.id, groupRows));
2746
+ }
2747
+ }
2748
+ const fallback = app.db.select().from(healthSnapshots).where(eq11(healthSnapshots.projectId, project.id)).orderBy(desc4(healthSnapshots.createdAt)).limit(1).get();
2749
+ if (!fallback) {
2692
2750
  return reply.send(emptyHealthSnapshot(project.id));
2693
2751
  }
2694
- return reply.send(mapHealthRow(row));
2752
+ return reply.send(mapHealthRow(fallback));
2695
2753
  });
2696
2754
  app.get("/projects/:name/health/history", async (request, reply) => {
2697
2755
  const project = resolveProject(app.db, request.params.name);
@@ -2703,7 +2761,7 @@ async function intelligenceRoutes(app) {
2703
2761
  }
2704
2762
 
2705
2763
  // ../api-routes/src/report.ts
2706
- import { and as and5, desc as desc6, eq as eq13, gte, inArray as inArray4, lt, lte, ne, or as or2, sql as sql3 } from "drizzle-orm";
2764
+ import { and as and5, desc as desc6, eq as eq13, gte, inArray as inArray5, lt, lte, ne, or as or2, sql as sql3 } from "drizzle-orm";
2707
2765
 
2708
2766
  // ../api-routes/src/report-renderer.ts
2709
2767
  var COLORS = {
@@ -4939,7 +4997,7 @@ function renderReportHtml(report, opts = {}) {
4939
4997
  }
4940
4998
 
4941
4999
  // ../api-routes/src/content-data.ts
4942
- import { and as and4, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
5000
+ import { and as and4, eq as eq12, desc as desc5, inArray as inArray4 } from "drizzle-orm";
4943
5001
  var RECENT_RUNS_WINDOW = 5;
4944
5002
  function loadOrchestratorInput(db, project, locationFilter = void 0) {
4945
5003
  const projectId = project.id;
@@ -5069,7 +5127,7 @@ function listRecentAnswerVisibilityRunIds(db, projectId, limit, locationFilter)
5069
5127
  // Queued/running/failed/cancelled runs may have partial or no
5070
5128
  // snapshots; including them risks pointing latestRunId at a run with
5071
5129
  // no usable evidence.
5072
- inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
5130
+ inArray4(runs.status, [RunStatuses.completed, RunStatuses.partial])
5073
5131
  )
5074
5132
  ).orderBy(desc5(runs.createdAt)).all();
5075
5133
  const filtered = locationFilter === void 0 ? rows : rows.filter((r) => (r.location ?? null) === locationFilter);
@@ -5111,7 +5169,7 @@ function buildCandidateQueries(opts) {
5111
5169
  const queryRows = opts.db.select({ id: queries.id, text: queries.query }).from(queries).where(eq12(queries.projectId, opts.projectId)).all();
5112
5170
  const queryIdByText = new Map(queryRows.map((r) => [r.text, r.id]));
5113
5171
  const candidateQueryIds = opts.candidateQueryStrings.map((q) => queryIdByText.get(q)).filter((id) => Boolean(id));
5114
- const snapshotRows = opts.db.select().from(querySnapshots).where(inArray3(querySnapshots.runId, opts.recentRunIds)).all().filter((r) => candidateQueryIds.includes(r.queryId));
5172
+ const snapshotRows = opts.db.select().from(querySnapshots).where(inArray4(querySnapshots.runId, opts.recentRunIds)).all().filter((r) => candidateQueryIds.includes(r.queryId));
5115
5173
  const snapshotsByQuery = /* @__PURE__ */ new Map();
5116
5174
  for (const row of snapshotRows) {
5117
5175
  const list = snapshotsByQuery.get(row.queryId) ?? [];
@@ -5329,7 +5387,7 @@ function loadSnapshotsForRun(db, runId) {
5329
5387
  }
5330
5388
  function loadSnapshotsForRunIds(db, runIds) {
5331
5389
  if (runIds.length === 0) return [];
5332
- const rows = db.select().from(querySnapshots).where(inArray4(querySnapshots.runId, [...runIds])).all();
5390
+ const rows = db.select().from(querySnapshots).where(inArray5(querySnapshots.runId, [...runIds])).all();
5333
5391
  return rows.map((r) => ({
5334
5392
  id: r.id,
5335
5393
  runId: r.runId,
@@ -5909,7 +5967,7 @@ function buildInsightList(db, projectId, locationFilter) {
5909
5967
  )
5910
5968
  ).orderBy(desc6(runs.createdAt)).all().filter((r) => locationFilter === void 0 || (r.location ?? null) === locationFilter).slice(0, INSIGHT_LOOKBACK_RUNS).map((r) => r.id);
5911
5969
  if (recentRunIds.length === 0) return [];
5912
- const rows = db.select().from(insights).where(and5(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
5970
+ const rows = db.select().from(insights).where(and5(eq13(insights.projectId, projectId), inArray5(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
5913
5971
  const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
5914
5972
  const flat = rows.filter((r) => !r.dismissed).map((r) => {
5915
5973
  const recommendation = parseJsonColumn(r.recommendation, null);
@@ -6671,7 +6729,7 @@ async function reportRoutes(app) {
6671
6729
  }
6672
6730
 
6673
6731
  // ../api-routes/src/citations.ts
6674
- import { eq as eq14, inArray as inArray5 } from "drizzle-orm";
6732
+ import { eq as eq14, inArray as inArray6 } from "drizzle-orm";
6675
6733
  async function citationRoutes(app) {
6676
6734
  app.get("/projects/:name/citations/visibility", async (request, reply) => {
6677
6735
  const project = resolveProject(app.db, request.params.name);
@@ -6695,7 +6753,7 @@ async function citationRoutes(app) {
6695
6753
  competitorOverlap: querySnapshots.competitorOverlap,
6696
6754
  answerMentioned: querySnapshots.answerMentioned,
6697
6755
  createdAt: querySnapshots.createdAt
6698
- }).from(querySnapshots).where(inArray5(querySnapshots.runId, projectRuns.map((r) => r.id))).all();
6756
+ }).from(querySnapshots).where(inArray6(querySnapshots.runId, projectRuns.map((r) => r.id))).all();
6699
6757
  if (rawSnapshots.length === 0) {
6700
6758
  return reply.send(emptyCitationVisibility("no-runs-yet"));
6701
6759
  }
@@ -6835,7 +6893,7 @@ function normalizeDomain2(domain) {
6835
6893
  }
6836
6894
 
6837
6895
  // ../api-routes/src/composites.ts
6838
- import { eq as eq15, and as and6, desc as desc7, sql as sql4, like, or as or3, inArray as inArray6 } from "drizzle-orm";
6896
+ import { eq as eq15, and as and6, desc as desc7, sql as sql4, like, or as or3, inArray as inArray7 } from "drizzle-orm";
6839
6897
  var TOP_INSIGHT_LIMIT = 5;
6840
6898
  var SEARCH_HIT_HARD_LIMIT = 50;
6841
6899
  var SEARCH_SNIPPET_RADIUS = 80;
@@ -7040,7 +7098,7 @@ function loadSnapshotsByRunIds(app, runIds) {
7040
7098
  citationState: querySnapshots.citationState,
7041
7099
  competitorOverlap: querySnapshots.competitorOverlap,
7042
7100
  citedDomains: querySnapshots.citedDomains
7043
- }).from(querySnapshots).where(inArray6(querySnapshots.runId, [...runIds])).all();
7101
+ }).from(querySnapshots).where(inArray7(querySnapshots.runId, [...runIds])).all();
7044
7102
  for (const row of rows) {
7045
7103
  const list = result.get(row.runId) ?? [];
7046
7104
  list.push({
@@ -10352,7 +10410,7 @@ var routeCatalog = [
10352
10410
  method: "get",
10353
10411
  path: "/api/v1/projects/{name}/discover/sessions/{id}/promote",
10354
10412
  summary: "Preview a discovery promotion plan (read-only)",
10355
- description: "Returns the payload `canonry discover promote` (PR 2) will persist: queries grouped by bucket, plus suggested new competitor domains. v1 is preview-only; the actual merge ships in PR 2.",
10413
+ description: "Returns available promotion candidates: queries grouped by bucket, plus recurring suggested competitor domains not already tracked. Read-only \u2014 use the POST to actually adopt the default subset or an explicit bucket subset.",
10356
10414
  tags: ["discovery"],
10357
10415
  parameters: [
10358
10416
  nameParameter,
@@ -10362,6 +10420,43 @@ var routeCatalog = [
10362
10420
  200: { description: "Promote preview returned." },
10363
10421
  404: { description: "Project or session not found." }
10364
10422
  }
10423
+ },
10424
+ {
10425
+ method: "post",
10426
+ path: "/api/v1/projects/{name}/discover/sessions/{id}/promote",
10427
+ summary: "Promote a discovery session into the tracked basket",
10428
+ description: 'Adopts a completed session\'s bucketed queries into the project\'s tracked basket, tagged with `provenance="discovery:<sessionId>"`. By default, only `cited` and `aspirational` queries are promoted; include `wasted-surface` explicitly when off-ICP competitor gaps should also be tracked. Recurring discovered competitor domains are also merged by default. Add-only and idempotent: queries/domains already tracked are returned under `skipped` rather than inserted twice. Only sessions with `status: "completed"` can be promoted.',
10429
+ tags: ["discovery"],
10430
+ parameters: [
10431
+ nameParameter,
10432
+ { name: "id", in: "path", required: true, description: "Discovery session ID.", schema: stringSchema }
10433
+ ],
10434
+ requestBody: {
10435
+ required: false,
10436
+ content: {
10437
+ "application/json": {
10438
+ schema: {
10439
+ type: "object",
10440
+ properties: {
10441
+ buckets: {
10442
+ type: "array",
10443
+ items: { type: "string", enum: ["cited", "aspirational", "wasted-surface"] },
10444
+ description: "Which probe buckets to promote. Omitted means cited + aspirational."
10445
+ },
10446
+ includeCompetitors: {
10447
+ type: "boolean",
10448
+ description: "Whether to also merge recurring discovered competitor domains. Defaults to true."
10449
+ }
10450
+ }
10451
+ }
10452
+ }
10453
+ }
10454
+ },
10455
+ responses: {
10456
+ 200: { description: "Promotion applied; returns promoted + skipped query/competitor lists." },
10457
+ 400: { description: "Session is not completed, or invalid request body." },
10458
+ 404: { description: "Project or session not found." }
10459
+ }
10365
10460
  }
10366
10461
  ];
10367
10462
  var canonryLocalRouteCatalog = [
@@ -19631,7 +19726,7 @@ async function discoveryRoutes(app, opts) {
19631
19726
  else if (bucket === DiscoveryBuckets["wasted-surface"]) wasted.add(probe.query);
19632
19727
  }
19633
19728
  const competitorMap = parseJsonColumn(session.competitorMap, []);
19634
- const newCompetitors = competitorMap.filter((entry) => !seenCompetitors.has(entry.domain.toLowerCase())).slice(0, 20);
19729
+ const newCompetitors = selectEligibleCompetitors(competitorMap).filter((entry) => !seenCompetitors.has(entry.domain.toLowerCase()));
19635
19730
  return reply.send({
19636
19731
  sessionId: session.id,
19637
19732
  projectId: project.id,
@@ -19645,6 +19740,107 @@ async function discoveryRoutes(app, opts) {
19645
19740
  });
19646
19741
  }
19647
19742
  );
19743
+ app.post("/projects/:name/discover/sessions/:id/promote", async (request, reply) => {
19744
+ const project = resolveProject(app.db, request.params.name);
19745
+ const session = app.db.select().from(discoverySessions).where(eq25(discoverySessions.id, request.params.id)).get();
19746
+ if (!session || session.projectId !== project.id) {
19747
+ throw notFound("Discovery session", request.params.id);
19748
+ }
19749
+ const parsed = discoveryPromoteRequestSchema.safeParse(request.body ?? {});
19750
+ if (!parsed.success) {
19751
+ throw validationError("Invalid discovery promote request", {
19752
+ issues: parsed.error.issues.map((issue) => ({
19753
+ path: issue.path.join("."),
19754
+ message: issue.message
19755
+ }))
19756
+ });
19757
+ }
19758
+ if (session.status !== DiscoverySessionStatuses.completed) {
19759
+ throw validationError(
19760
+ `Discovery session is "${session.status}" \u2014 only completed sessions can be promoted.`,
19761
+ { status: session.status }
19762
+ );
19763
+ }
19764
+ const buckets = parsed.data.buckets ?? DEFAULT_DISCOVERY_PROMOTE_BUCKETS;
19765
+ const bucketSet = new Set(buckets);
19766
+ const includeCompetitors = parsed.data.includeCompetitors ?? true;
19767
+ const probeRows = app.db.select().from(discoveryProbes).where(eq25(discoveryProbes.sessionId, session.id)).all();
19768
+ const candidateQueries = /* @__PURE__ */ new Set();
19769
+ for (const probe of probeRows) {
19770
+ if (!probe.bucket) continue;
19771
+ const bucket = discoveryBucketSchema.safeParse(probe.bucket);
19772
+ if (bucket.success && bucketSet.has(bucket.data)) candidateQueries.add(probe.query);
19773
+ }
19774
+ const existingQueries = new Set(
19775
+ app.db.select({ query: queries.query }).from(queries).where(eq25(queries.projectId, project.id)).all().map((r) => r.query.toLowerCase())
19776
+ );
19777
+ const promotedQueries = [];
19778
+ const skippedQueries = [];
19779
+ for (const query of Array.from(candidateQueries).sort()) {
19780
+ if (existingQueries.has(query.toLowerCase())) {
19781
+ skippedQueries.push(query);
19782
+ } else {
19783
+ promotedQueries.push(query);
19784
+ existingQueries.add(query.toLowerCase());
19785
+ }
19786
+ }
19787
+ const promotedCompetitors = [];
19788
+ const skippedCompetitors = [];
19789
+ if (includeCompetitors) {
19790
+ const existingCompetitors = new Set(
19791
+ app.db.select({ domain: competitors.domain }).from(competitors).where(eq25(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
19792
+ );
19793
+ const competitorMap = parseJsonColumn(session.competitorMap, []);
19794
+ for (const entry of selectEligibleCompetitors(competitorMap)) {
19795
+ const key = entry.domain.toLowerCase();
19796
+ if (existingCompetitors.has(key)) {
19797
+ skippedCompetitors.push(entry.domain);
19798
+ } else {
19799
+ promotedCompetitors.push(entry.domain);
19800
+ existingCompetitors.add(key);
19801
+ }
19802
+ }
19803
+ }
19804
+ const provenance = `discovery:${session.id}`;
19805
+ const now = (/* @__PURE__ */ new Date()).toISOString();
19806
+ if (promotedQueries.length > 0 || promotedCompetitors.length > 0) {
19807
+ app.db.transaction((tx) => {
19808
+ for (const query of promotedQueries) {
19809
+ tx.insert(queries).values({
19810
+ id: crypto21.randomUUID(),
19811
+ projectId: project.id,
19812
+ query,
19813
+ provenance,
19814
+ createdAt: now
19815
+ }).run();
19816
+ }
19817
+ for (const domain of promotedCompetitors) {
19818
+ tx.insert(competitors).values({
19819
+ id: crypto21.randomUUID(),
19820
+ projectId: project.id,
19821
+ domain,
19822
+ provenance,
19823
+ createdAt: now
19824
+ }).run();
19825
+ }
19826
+ writeAuditLog(tx, {
19827
+ projectId: project.id,
19828
+ actor: "api",
19829
+ action: "discovery.promoted",
19830
+ entityType: "discovery_session",
19831
+ entityId: session.id,
19832
+ diff: { queries: promotedQueries, competitors: promotedCompetitors }
19833
+ });
19834
+ });
19835
+ }
19836
+ const result = {
19837
+ sessionId: session.id,
19838
+ projectId: project.id,
19839
+ promoted: { queries: promotedQueries, competitors: promotedCompetitors },
19840
+ skipped: { queries: skippedQueries, competitors: skippedCompetitors }
19841
+ };
19842
+ return reply.send(result);
19843
+ });
19648
19844
  }
19649
19845
  function serializeSession(row) {
19650
19846
  return {
@@ -19681,6 +19877,9 @@ function serializeProbe(row) {
19681
19877
  createdAt: row.createdAt
19682
19878
  };
19683
19879
  }
19880
+ function selectEligibleCompetitors(competitorMap) {
19881
+ return competitorMap.filter((entry) => entry.hits >= DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS).sort((a, b) => b.hits - a.hits || a.domain.localeCompare(b.domain)).slice(0, DISCOVERY_PROMOTE_COMPETITOR_CAP);
19882
+ }
19684
19883
 
19685
19884
  // ../api-routes/src/discovery/orchestrate.ts
19686
19885
  import crypto22 from "crypto";
@@ -22614,7 +22813,7 @@ import crypto24 from "crypto";
22614
22813
  import fs7 from "fs";
22615
22814
  import path9 from "path";
22616
22815
  import os5 from "os";
22617
- import { and as and16, eq as eq27, inArray as inArray7, sql as sql10 } from "drizzle-orm";
22816
+ import { and as and16, eq as eq27, inArray as inArray8, sql as sql10 } from "drizzle-orm";
22618
22817
 
22619
22818
  // src/run-telemetry.ts
22620
22819
  import crypto23 from "crypto";
@@ -22955,7 +23154,7 @@ var JobRunner = class {
22955
23154
  this.registry = registry;
22956
23155
  }
22957
23156
  recoverStaleRuns() {
22958
- const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray7(runs.status, ["running", "queued"])).all();
23157
+ const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray8(runs.status, ["running", "queued"])).all();
22959
23158
  if (stale.length === 0) return;
22960
23159
  const now = (/* @__PURE__ */ new Date()).toISOString();
22961
23160
  for (const run of stale) {
@@ -23018,7 +23217,7 @@ var JobRunner = class {
23018
23217
  }
23019
23218
  log.info("run.dispatch", { runId, providerCount: activeProviders.length, providers: activeProviders.map((p) => p.adapter.name) });
23020
23219
  const scopedQueryNames = parseJsonColumn(existingRun.queries, null);
23021
- projectQueries = scopedQueryNames ? this.db.select().from(queries).where(and16(eq27(queries.projectId, projectId), inArray7(queries.query, scopedQueryNames))).all() : this.db.select().from(queries).where(eq27(queries.projectId, projectId)).all();
23220
+ projectQueries = scopedQueryNames ? this.db.select().from(queries).where(and16(eq27(queries.projectId, projectId), inArray8(queries.query, scopedQueryNames))).all() : this.db.select().from(queries).where(eq27(queries.projectId, projectId)).all();
23022
23221
  const projectCompetitors = this.db.select().from(competitors).where(eq27(competitors.projectId, projectId)).all();
23023
23222
  const competitorDomains = projectCompetitors.map((c) => c.domain);
23024
23223
  const allDomains = effectiveDomains({
@@ -24428,7 +24627,7 @@ function writeDiscoveryInsight(db, input) {
24428
24627
  provider: input.seedProvider,
24429
24628
  recommendation: JSON.stringify({
24430
24629
  action: "review-discovered-basket",
24431
- summary: `Run \`canonry discover show ${input.sessionId} --format json\` to inspect the per-query breakdown. PR 2 will add \`canonry discover promote\` to merge the basket into the project.`,
24630
+ summary: `Run \`canonry discover show ${input.sessionId} --format json\` to inspect the per-query breakdown, then \`canonry discover promote <project> ${input.sessionId}\` to merge cited + aspirational findings into the project.`,
24432
24631
  bucketCounts: buckets,
24433
24632
  topCompetitors
24434
24633
  }),
@@ -24672,7 +24871,7 @@ var Scheduler = class {
24672
24871
  };
24673
24872
 
24674
24873
  // src/notifier.ts
24675
- import { eq as eq35, desc as desc16, and as and22, inArray as inArray8, or as or4 } from "drizzle-orm";
24874
+ import { eq as eq35, desc as desc16, and as and22, inArray as inArray9, or as or4 } from "drizzle-orm";
24676
24875
  import crypto31 from "crypto";
24677
24876
  var log10 = createLogger("Notifier");
24678
24877
  var Notifier = class {
@@ -24825,13 +25024,13 @@ var Notifier = class {
24825
25024
  provider: querySnapshots.provider,
24826
25025
  location: querySnapshots.location,
24827
25026
  citationState: querySnapshots.citationState
24828
- }).from(querySnapshots).leftJoin(queries, eq35(querySnapshots.queryId, queries.id)).where(inArray8(querySnapshots.runId, currentRunIds)).all();
25027
+ }).from(querySnapshots).leftJoin(queries, eq35(querySnapshots.queryId, queries.id)).where(inArray9(querySnapshots.runId, currentRunIds)).all();
24829
25028
  const previousSnapshots = this.db.select({
24830
25029
  queryId: querySnapshots.queryId,
24831
25030
  provider: querySnapshots.provider,
24832
25031
  location: querySnapshots.location,
24833
25032
  citationState: querySnapshots.citationState
24834
- }).from(querySnapshots).where(inArray8(querySnapshots.runId, previousRunIds)).all();
25033
+ }).from(querySnapshots).where(inArray9(querySnapshots.runId, previousRunIds)).all();
24835
25034
  const prevMap = /* @__PURE__ */ new Map();
24836
25035
  for (const s of previousSnapshots) {
24837
25036
  prevMap.set(`${s.queryId}:${s.provider}:${s.location ?? ""}`, s.citationState);
@@ -27082,7 +27281,7 @@ async function createServer(opts) {
27082
27281
  content = `[system] Discovery run ${ctx.runId} failed for project ${project.name}: ${ctx.error ?? "unknown error"}. Surface a one-line diagnosis and a suggested next step.`;
27083
27282
  } else {
27084
27283
  const top = ctx.topCompetitors.map((c) => `${c.domain}(${c.hits})`).join(", ") || "none";
27085
- content = `[system] Discovery run ${ctx.runId} completed for project ${project.name} (session ${ctx.sessionId}). Buckets \u2014 cited:${ctx.buckets.cited}, wasted-surface:${ctx.buckets["wasted-surface"]}, aspirational:${ctx.buckets.aspirational} (${ctx.probeCount} probes; seed provider: ${ctx.seedProvider ?? "unknown"}). Top recurring competitor domains: ${top}. Use canonry_discover_session_get to pull per-query buckets and call out anything worth promoting to the tracked basket. Keep it tight.`;
27284
+ content = `[system] Discovery run ${ctx.runId} completed for project ${project.name} (session ${ctx.sessionId}). Buckets \u2014 cited:${ctx.buckets.cited}, wasted-surface:${ctx.buckets["wasted-surface"]}, aspirational:${ctx.buckets.aspirational} (${ctx.probeCount} probes; seed provider: ${ctx.seedProvider ?? "unknown"}). Top recurring competitor domains: ${top}. Use canonry_discover_session_get to pull per-query buckets and call out cited + aspirational findings worth promoting. Keep it tight.`;
27086
27285
  }
27087
27286
  } else {
27088
27287
  content = `[system] Run ${ctx.runId} completed for project ${project.name}. ${ctx.insightCount} insights generated (${ctx.criticalOrHigh} critical/high). Use canonry_run_get to inspect the run and canonry_insights_list to review new findings. Surface anything notable briefly \u2014 skip chit-chat.`;
@@ -2356,6 +2356,12 @@ var trafficEventsResponseSchema = z20.object({
2356
2356
  import { z as z21 } from "zod";
2357
2357
  var discoveryBucketSchema = z21.enum(["cited", "aspirational", "wasted-surface"]);
2358
2358
  var DiscoveryBuckets = discoveryBucketSchema.enum;
2359
+ var DEFAULT_DISCOVERY_PROMOTE_BUCKETS = [
2360
+ DiscoveryBuckets.cited,
2361
+ DiscoveryBuckets.aspirational
2362
+ ];
2363
+ var DISCOVERY_PROMOTE_COMPETITOR_CAP = 20;
2364
+ var DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS = 2;
2359
2365
  var discoverySessionStatusSchema = z21.enum(["queued", "seeding", "probing", "completed", "failed"]);
2360
2366
  var DiscoverySessionStatuses = discoverySessionStatusSchema.enum;
2361
2367
  var discoveryCompetitorMapEntrySchema = z21.object({
@@ -2400,6 +2406,33 @@ var discoveryRunRequestSchema = z21.object({
2400
2406
  dedupThreshold: z21.number().min(0).max(1).optional(),
2401
2407
  maxProbes: z21.number().int().positive().max(DISCOVERY_MAX_PROBES_CAP).optional()
2402
2408
  });
2409
+ var discoveryPromoteRequestSchema = z21.object({
2410
+ buckets: z21.array(discoveryBucketSchema).min(1).optional(),
2411
+ includeCompetitors: z21.boolean().optional()
2412
+ });
2413
+ var discoveryPromotePreviewSchema = z21.object({
2414
+ sessionId: z21.string(),
2415
+ projectId: z21.string(),
2416
+ status: discoverySessionStatusSchema,
2417
+ queriesByBucket: z21.object({
2418
+ cited: z21.array(z21.string()),
2419
+ aspirational: z21.array(z21.string()),
2420
+ "wasted-surface": z21.array(z21.string())
2421
+ }),
2422
+ suggestedCompetitors: z21.array(discoveryCompetitorMapEntrySchema)
2423
+ });
2424
+ var discoveryPromoteResultSchema = z21.object({
2425
+ sessionId: z21.string(),
2426
+ projectId: z21.string(),
2427
+ promoted: z21.object({
2428
+ queries: z21.array(z21.string()),
2429
+ competitors: z21.array(z21.string())
2430
+ }),
2431
+ skipped: z21.object({
2432
+ queries: z21.array(z21.string()),
2433
+ competitors: z21.array(z21.string())
2434
+ })
2435
+ });
2403
2436
  var queryProvenanceSchema = z21.union([
2404
2437
  z21.literal("cli"),
2405
2438
  z21.string().regex(/^discovery:.+$/)
@@ -2637,9 +2670,13 @@ export {
2637
2670
  TrafficEventKinds,
2638
2671
  discoveryBucketSchema,
2639
2672
  DiscoveryBuckets,
2673
+ DEFAULT_DISCOVERY_PROMOTE_BUCKETS,
2674
+ DISCOVERY_PROMOTE_COMPETITOR_CAP,
2675
+ DISCOVERY_PROMOTE_COMPETITOR_MIN_HITS,
2640
2676
  DiscoverySessionStatuses,
2641
2677
  DISCOVERY_MAX_PROBES_CAP,
2642
2678
  discoveryRunRequestSchema,
2679
+ discoveryPromoteRequestSchema,
2643
2680
  clusterByCosine,
2644
2681
  pickClusterRepresentative,
2645
2682
  formatRatio,
@@ -8,7 +8,7 @@ import {
8
8
  categoryLabel,
9
9
  determineAnswerMentioned,
10
10
  normalizeProjectDomain
11
- } from "./chunk-HVW665A4.js";
11
+ } from "./chunk-RLLFB3M3.js";
12
12
 
13
13
  // src/intelligence-service.ts
14
14
  import { eq, desc, asc, and, or, inArray } from "drizzle-orm";