@ainyc/canonry 1.39.4 → 1.40.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.
@@ -0,0 +1,1218 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/intelligence-service.ts
8
+ import { eq, desc, asc, and, or } from "drizzle-orm";
9
+
10
+ // ../db/src/client.ts
11
+ import { mkdirSync } from "fs";
12
+ import { dirname } from "path";
13
+ import Database from "better-sqlite3";
14
+ import { drizzle } from "drizzle-orm/better-sqlite3";
15
+
16
+ // ../db/src/schema.ts
17
+ var schema_exports = {};
18
+ __export(schema_exports, {
19
+ apiKeys: () => apiKeys,
20
+ auditLog: () => auditLog,
21
+ bingConnections: () => bingConnections,
22
+ bingKeywordStats: () => bingKeywordStats,
23
+ bingUrlInspections: () => bingUrlInspections,
24
+ competitors: () => competitors,
25
+ gaAiReferrals: () => gaAiReferrals,
26
+ gaConnections: () => gaConnections,
27
+ gaTrafficSnapshots: () => gaTrafficSnapshots,
28
+ gaTrafficSummaries: () => gaTrafficSummaries,
29
+ googleConnections: () => googleConnections,
30
+ gscCoverageSnapshots: () => gscCoverageSnapshots,
31
+ gscSearchData: () => gscSearchData,
32
+ gscUrlInspections: () => gscUrlInspections,
33
+ healthSnapshots: () => healthSnapshots,
34
+ insights: () => insights,
35
+ keywords: () => keywords,
36
+ notifications: () => notifications,
37
+ projects: () => projects,
38
+ querySnapshots: () => querySnapshots,
39
+ runs: () => runs,
40
+ schedules: () => schedules,
41
+ usageCounters: () => usageCounters
42
+ });
43
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
44
+ var projects = sqliteTable("projects", {
45
+ id: text("id").primaryKey(),
46
+ name: text("name").notNull().unique(),
47
+ displayName: text("display_name").notNull(),
48
+ canonicalDomain: text("canonical_domain").notNull(),
49
+ ownedDomains: text("owned_domains").notNull().default("[]"),
50
+ country: text("country").notNull(),
51
+ language: text("language").notNull(),
52
+ tags: text("tags").notNull().default("[]"),
53
+ labels: text("labels").notNull().default("{}"),
54
+ providers: text("providers").notNull().default("[]"),
55
+ locations: text("locations").notNull().default("[]"),
56
+ defaultLocation: text("default_location"),
57
+ configSource: text("config_source").notNull().default("cli"),
58
+ configRevision: integer("config_revision").notNull().default(1),
59
+ createdAt: text("created_at").notNull(),
60
+ updatedAt: text("updated_at").notNull()
61
+ });
62
+ var keywords = sqliteTable("keywords", {
63
+ id: text("id").primaryKey(),
64
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
65
+ keyword: text("keyword").notNull(),
66
+ createdAt: text("created_at").notNull()
67
+ }, (table) => [
68
+ index("idx_keywords_project").on(table.projectId),
69
+ uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
70
+ ]);
71
+ var competitors = sqliteTable("competitors", {
72
+ id: text("id").primaryKey(),
73
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
74
+ domain: text("domain").notNull(),
75
+ createdAt: text("created_at").notNull()
76
+ }, (table) => [
77
+ index("idx_competitors_project").on(table.projectId),
78
+ uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
79
+ ]);
80
+ var runs = sqliteTable("runs", {
81
+ id: text("id").primaryKey(),
82
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
83
+ kind: text("kind").notNull().default("answer-visibility"),
84
+ status: text("status").notNull().default("queued"),
85
+ trigger: text("trigger").notNull().default("manual"),
86
+ location: text("location"),
87
+ startedAt: text("started_at"),
88
+ finishedAt: text("finished_at"),
89
+ error: text("error"),
90
+ createdAt: text("created_at").notNull()
91
+ }, (table) => [
92
+ index("idx_runs_project").on(table.projectId),
93
+ index("idx_runs_status").on(table.status)
94
+ ]);
95
+ var querySnapshots = sqliteTable("query_snapshots", {
96
+ id: text("id").primaryKey(),
97
+ runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
98
+ keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
99
+ provider: text("provider").notNull().default("gemini"),
100
+ model: text("model"),
101
+ citationState: text("citation_state").notNull(),
102
+ answerMentioned: integer("answer_mentioned", { mode: "boolean" }),
103
+ answerText: text("answer_text"),
104
+ citedDomains: text("cited_domains").notNull().default("[]"),
105
+ competitorOverlap: text("competitor_overlap").notNull().default("[]"),
106
+ recommendedCompetitors: text("recommended_competitors").notNull().default("[]"),
107
+ location: text("location"),
108
+ screenshotPath: text("screenshot_path"),
109
+ rawResponse: text("raw_response"),
110
+ createdAt: text("created_at").notNull()
111
+ }, (table) => [
112
+ index("idx_snapshots_run").on(table.runId),
113
+ index("idx_snapshots_keyword").on(table.keywordId)
114
+ ]);
115
+ var auditLog = sqliteTable("audit_log", {
116
+ id: text("id").primaryKey(),
117
+ projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
118
+ actor: text("actor").notNull(),
119
+ action: text("action").notNull(),
120
+ entityType: text("entity_type").notNull(),
121
+ entityId: text("entity_id"),
122
+ diff: text("diff"),
123
+ createdAt: text("created_at").notNull()
124
+ }, (table) => [
125
+ index("idx_audit_log_project").on(table.projectId),
126
+ index("idx_audit_log_created").on(table.createdAt)
127
+ ]);
128
+ var apiKeys = sqliteTable("api_keys", {
129
+ id: text("id").primaryKey(),
130
+ name: text("name").notNull(),
131
+ keyHash: text("key_hash").notNull().unique(),
132
+ keyPrefix: text("key_prefix").notNull(),
133
+ scopes: text("scopes").notNull().default('["*"]'),
134
+ createdAt: text("created_at").notNull(),
135
+ lastUsedAt: text("last_used_at"),
136
+ revokedAt: text("revoked_at")
137
+ }, (table) => [
138
+ index("idx_api_keys_prefix").on(table.keyPrefix)
139
+ ]);
140
+ var schedules = sqliteTable("schedules", {
141
+ id: text("id").primaryKey(),
142
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
143
+ cronExpr: text("cron_expr").notNull(),
144
+ preset: text("preset"),
145
+ timezone: text("timezone").notNull().default("UTC"),
146
+ enabled: integer("enabled").notNull().default(1),
147
+ providers: text("providers").notNull().default("[]"),
148
+ lastRunAt: text("last_run_at"),
149
+ nextRunAt: text("next_run_at"),
150
+ createdAt: text("created_at").notNull(),
151
+ updatedAt: text("updated_at").notNull()
152
+ }, (table) => [
153
+ uniqueIndex("idx_schedules_project").on(table.projectId)
154
+ ]);
155
+ var notifications = sqliteTable("notifications", {
156
+ id: text("id").primaryKey(),
157
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
158
+ channel: text("channel").notNull(),
159
+ config: text("config").notNull(),
160
+ webhookSecret: text("webhook_secret"),
161
+ enabled: integer("enabled").notNull().default(1),
162
+ createdAt: text("created_at").notNull(),
163
+ updatedAt: text("updated_at").notNull()
164
+ }, (table) => [
165
+ index("idx_notifications_project").on(table.projectId)
166
+ ]);
167
+ var googleConnections = sqliteTable("google_connections", {
168
+ id: text("id").primaryKey(),
169
+ domain: text("domain").notNull(),
170
+ connectionType: text("connection_type").notNull(),
171
+ propertyId: text("property_id"),
172
+ sitemapUrl: text("sitemap_url"),
173
+ // WARNING: Authentication material should be stored in config.yaml per CLAUDE.md
174
+ accessToken: text("access_token"),
175
+ refreshToken: text("refresh_token"),
176
+ tokenExpiresAt: text("token_expires_at"),
177
+ scopes: text("scopes").notNull().default("[]"),
178
+ createdAt: text("created_at").notNull(),
179
+ updatedAt: text("updated_at").notNull()
180
+ }, (table) => [
181
+ uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
182
+ ]);
183
+ var gscSearchData = sqliteTable("gsc_search_data", {
184
+ id: text("id").primaryKey(),
185
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
186
+ syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
187
+ date: text("date").notNull(),
188
+ query: text("query").notNull(),
189
+ page: text("page").notNull(),
190
+ country: text("country"),
191
+ device: text("device"),
192
+ clicks: integer("clicks").notNull().default(0),
193
+ impressions: integer("impressions").notNull().default(0),
194
+ ctr: text("ctr").notNull().default("0"),
195
+ position: text("position").notNull().default("0"),
196
+ createdAt: text("created_at").notNull()
197
+ }, (table) => [
198
+ index("idx_gsc_search_project_date").on(table.projectId, table.date),
199
+ index("idx_gsc_search_query").on(table.query),
200
+ index("idx_gsc_search_run").on(table.syncRunId)
201
+ ]);
202
+ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
203
+ id: text("id").primaryKey(),
204
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
205
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
206
+ url: text("url").notNull(),
207
+ indexingState: text("indexing_state"),
208
+ verdict: text("verdict"),
209
+ coverageState: text("coverage_state"),
210
+ pageFetchState: text("page_fetch_state"),
211
+ robotsTxtState: text("robots_txt_state"),
212
+ crawlTime: text("crawl_time"),
213
+ lastCrawlResult: text("last_crawl_result"),
214
+ isMobileFriendly: integer("is_mobile_friendly"),
215
+ richResults: text("rich_results").notNull().default("[]"),
216
+ referringUrls: text("referring_urls").notNull().default("[]"),
217
+ inspectedAt: text("inspected_at").notNull(),
218
+ createdAt: text("created_at").notNull()
219
+ }, (table) => [
220
+ index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
221
+ index("idx_gsc_inspect_run").on(table.syncRunId),
222
+ index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
223
+ ]);
224
+ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
225
+ id: text("id").primaryKey(),
226
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
227
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
228
+ date: text("date").notNull(),
229
+ indexed: integer("indexed").notNull().default(0),
230
+ notIndexed: integer("not_indexed").notNull().default(0),
231
+ reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
232
+ createdAt: text("created_at").notNull()
233
+ }, (table) => [
234
+ index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
235
+ index("idx_gsc_coverage_snap_run").on(table.syncRunId)
236
+ ]);
237
+ var bingConnections = sqliteTable("bing_connections", {
238
+ id: text("id").primaryKey(),
239
+ domain: text("domain").notNull(),
240
+ siteUrl: text("site_url"),
241
+ createdAt: text("created_at").notNull(),
242
+ updatedAt: text("updated_at").notNull()
243
+ }, (table) => [
244
+ uniqueIndex("idx_bing_conn_domain").on(table.domain)
245
+ ]);
246
+ var bingUrlInspections = sqliteTable("bing_url_inspections", {
247
+ id: text("id").primaryKey(),
248
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
249
+ url: text("url").notNull(),
250
+ httpCode: integer("http_code"),
251
+ inIndex: integer("in_index"),
252
+ lastCrawledDate: text("last_crawled_date"),
253
+ inIndexDate: text("in_index_date"),
254
+ inspectedAt: text("inspected_at").notNull(),
255
+ createdAt: text("created_at").notNull(),
256
+ documentSize: integer("document_size"),
257
+ anchorCount: integer("anchor_count"),
258
+ discoveryDate: text("discovery_date")
259
+ }, (table) => [
260
+ index("idx_bing_inspect_project_url").on(table.projectId, table.url),
261
+ index("idx_bing_inspect_url_time").on(table.url, table.inspectedAt)
262
+ ]);
263
+ var bingKeywordStats = sqliteTable("bing_keyword_stats", {
264
+ id: text("id").primaryKey(),
265
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
266
+ query: text("query").notNull(),
267
+ impressions: integer("impressions").notNull().default(0),
268
+ clicks: integer("clicks").notNull().default(0),
269
+ ctr: text("ctr").notNull().default("0"),
270
+ averagePosition: text("average_position").notNull().default("0"),
271
+ syncedAt: text("synced_at").notNull(),
272
+ createdAt: text("created_at").notNull()
273
+ }, (table) => [
274
+ index("idx_bing_keyword_project").on(table.projectId),
275
+ index("idx_bing_keyword_query").on(table.query)
276
+ ]);
277
+ var gaConnections = sqliteTable("ga_connections", {
278
+ id: text("id").primaryKey(),
279
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
280
+ propertyId: text("property_id").notNull(),
281
+ clientEmail: text("client_email").notNull(),
282
+ // WARNING: Authentication material should be stored in config.yaml per CLAUDE.md
283
+ privateKey: text("private_key").notNull(),
284
+ createdAt: text("created_at").notNull(),
285
+ updatedAt: text("updated_at").notNull()
286
+ }, (table) => [
287
+ uniqueIndex("idx_ga_conn_project").on(table.projectId)
288
+ ]);
289
+ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
290
+ id: text("id").primaryKey(),
291
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
292
+ date: text("date").notNull(),
293
+ landingPage: text("landing_page").notNull(),
294
+ sessions: integer("sessions").notNull().default(0),
295
+ organicSessions: integer("organic_sessions").notNull().default(0),
296
+ users: integer("users").notNull().default(0),
297
+ syncedAt: text("synced_at").notNull()
298
+ }, (table) => [
299
+ index("idx_ga_traffic_project_date").on(table.projectId, table.date),
300
+ index("idx_ga_traffic_page").on(table.landingPage)
301
+ ]);
302
+ var gaAiReferrals = sqliteTable("ga_ai_referrals", {
303
+ id: text("id").primaryKey(),
304
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
305
+ date: text("date").notNull(),
306
+ source: text("source").notNull(),
307
+ medium: text("medium").notNull(),
308
+ /** Which GA4 dimension produced this row: 'session' | 'first_user' | 'manual_utm' */
309
+ sourceDimension: text("source_dimension").notNull().default("session"),
310
+ sessions: integer("sessions").notNull().default(0),
311
+ users: integer("users").notNull().default(0),
312
+ syncedAt: text("synced_at").notNull()
313
+ }, (table) => [
314
+ index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
315
+ index("idx_ga_ai_ref_source").on(table.source),
316
+ uniqueIndex("idx_ga_ai_ref_unique_v2").on(table.projectId, table.date, table.source, table.medium, table.sourceDimension)
317
+ ]);
318
+ var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
319
+ id: text("id").primaryKey(),
320
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
321
+ periodStart: text("period_start").notNull(),
322
+ periodEnd: text("period_end").notNull(),
323
+ totalSessions: integer("total_sessions").notNull().default(0),
324
+ totalOrganicSessions: integer("total_organic_sessions").notNull().default(0),
325
+ totalUsers: integer("total_users").notNull().default(0),
326
+ syncedAt: text("synced_at").notNull()
327
+ }, (table) => [
328
+ index("idx_ga_summary_project").on(table.projectId)
329
+ ]);
330
+ var usageCounters = sqliteTable("usage_counters", {
331
+ id: text("id").primaryKey(),
332
+ scope: text("scope").notNull(),
333
+ period: text("period").notNull(),
334
+ metric: text("metric").notNull(),
335
+ count: integer("count").notNull().default(0),
336
+ updatedAt: text("updated_at").notNull()
337
+ }, (table) => [
338
+ uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
339
+ index("idx_usage_scope_period").on(table.scope, table.period)
340
+ ]);
341
+ var insights = sqliteTable("insights", {
342
+ id: text("id").primaryKey(),
343
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
344
+ runId: text("run_id").references(() => runs.id, { onDelete: "cascade" }),
345
+ type: text("type").notNull(),
346
+ severity: text("severity").notNull(),
347
+ title: text("title").notNull(),
348
+ keyword: text("keyword").notNull(),
349
+ provider: text("provider").notNull(),
350
+ recommendation: text("recommendation"),
351
+ cause: text("cause"),
352
+ dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false),
353
+ createdAt: text("created_at").notNull()
354
+ }, (table) => [
355
+ index("idx_insights_project").on(table.projectId),
356
+ index("idx_insights_run").on(table.runId),
357
+ index("idx_insights_created").on(table.createdAt),
358
+ index("idx_insights_keyword_provider").on(table.keyword, table.provider)
359
+ ]);
360
+ var healthSnapshots = sqliteTable("health_snapshots", {
361
+ id: text("id").primaryKey(),
362
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
363
+ runId: text("run_id").references(() => runs.id, { onDelete: "cascade" }),
364
+ overallCitedRate: text("overall_cited_rate").notNull(),
365
+ totalPairs: integer("total_pairs").notNull(),
366
+ citedPairs: integer("cited_pairs").notNull(),
367
+ providerBreakdown: text("provider_breakdown").notNull().default("{}"),
368
+ createdAt: text("created_at").notNull()
369
+ }, (table) => [
370
+ index("idx_health_snapshots_project").on(table.projectId),
371
+ index("idx_health_snapshots_run").on(table.runId),
372
+ index("idx_health_snapshots_created").on(table.createdAt)
373
+ ]);
374
+
375
+ // ../db/src/client.ts
376
+ function createClient(databasePath) {
377
+ mkdirSync(dirname(databasePath), { recursive: true });
378
+ const sqlite = new Database(databasePath);
379
+ sqlite.pragma("journal_mode = WAL");
380
+ sqlite.pragma("foreign_keys = ON");
381
+ sqlite.pragma("busy_timeout = 5000");
382
+ return drizzle(sqlite, { schema: schema_exports });
383
+ }
384
+
385
+ // ../db/src/json.ts
386
+ function parseJsonColumn(value, fallback) {
387
+ if (value == null || value === "") return fallback;
388
+ try {
389
+ return JSON.parse(value);
390
+ } catch {
391
+ return fallback;
392
+ }
393
+ }
394
+
395
+ // ../db/src/migrate.ts
396
+ import { sql } from "drizzle-orm";
397
+ var MIGRATION_SQL = `
398
+ CREATE TABLE IF NOT EXISTS projects (
399
+ id TEXT PRIMARY KEY,
400
+ name TEXT NOT NULL UNIQUE,
401
+ display_name TEXT NOT NULL,
402
+ canonical_domain TEXT NOT NULL,
403
+ owned_domains TEXT NOT NULL DEFAULT '[]',
404
+ country TEXT NOT NULL,
405
+ language TEXT NOT NULL,
406
+ tags TEXT NOT NULL DEFAULT '[]',
407
+ labels TEXT NOT NULL DEFAULT '{}',
408
+ providers TEXT NOT NULL DEFAULT '[]',
409
+ config_source TEXT NOT NULL DEFAULT 'cli',
410
+ config_revision INTEGER NOT NULL DEFAULT 1,
411
+ created_at TEXT NOT NULL,
412
+ updated_at TEXT NOT NULL
413
+ );
414
+
415
+ CREATE TABLE IF NOT EXISTS keywords (
416
+ id TEXT PRIMARY KEY,
417
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
418
+ keyword TEXT NOT NULL,
419
+ created_at TEXT NOT NULL,
420
+ UNIQUE(project_id, keyword)
421
+ );
422
+
423
+ CREATE TABLE IF NOT EXISTS competitors (
424
+ id TEXT PRIMARY KEY,
425
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
426
+ domain TEXT NOT NULL,
427
+ created_at TEXT NOT NULL,
428
+ UNIQUE(project_id, domain)
429
+ );
430
+
431
+ CREATE TABLE IF NOT EXISTS runs (
432
+ id TEXT PRIMARY KEY,
433
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
434
+ kind TEXT NOT NULL DEFAULT 'answer-visibility',
435
+ status TEXT NOT NULL DEFAULT 'queued',
436
+ trigger TEXT NOT NULL DEFAULT 'manual',
437
+ started_at TEXT,
438
+ finished_at TEXT,
439
+ error TEXT,
440
+ created_at TEXT NOT NULL
441
+ );
442
+
443
+ CREATE TABLE IF NOT EXISTS query_snapshots (
444
+ id TEXT PRIMARY KEY,
445
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
446
+ keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
447
+ provider TEXT NOT NULL DEFAULT 'gemini',
448
+ citation_state TEXT NOT NULL,
449
+ answer_text TEXT,
450
+ cited_domains TEXT NOT NULL DEFAULT '[]',
451
+ competitor_overlap TEXT NOT NULL DEFAULT '[]',
452
+ raw_response TEXT,
453
+ created_at TEXT NOT NULL
454
+ );
455
+
456
+ CREATE TABLE IF NOT EXISTS audit_log (
457
+ id TEXT PRIMARY KEY,
458
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
459
+ actor TEXT NOT NULL,
460
+ action TEXT NOT NULL,
461
+ entity_type TEXT NOT NULL,
462
+ entity_id TEXT,
463
+ diff TEXT,
464
+ created_at TEXT NOT NULL
465
+ );
466
+
467
+ CREATE TABLE IF NOT EXISTS api_keys (
468
+ id TEXT PRIMARY KEY,
469
+ name TEXT NOT NULL,
470
+ key_hash TEXT NOT NULL UNIQUE,
471
+ key_prefix TEXT NOT NULL,
472
+ scopes TEXT NOT NULL DEFAULT '["*"]',
473
+ created_at TEXT NOT NULL,
474
+ last_used_at TEXT,
475
+ revoked_at TEXT
476
+ );
477
+
478
+ CREATE TABLE IF NOT EXISTS usage_counters (
479
+ id TEXT PRIMARY KEY,
480
+ scope TEXT NOT NULL,
481
+ period TEXT NOT NULL,
482
+ metric TEXT NOT NULL,
483
+ count INTEGER NOT NULL DEFAULT 0,
484
+ updated_at TEXT NOT NULL,
485
+ UNIQUE(scope, period, metric)
486
+ );
487
+
488
+ CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
489
+ CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
490
+ CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
491
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
492
+ CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
493
+ CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
494
+ CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
495
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
496
+ CREATE TABLE IF NOT EXISTS schedules (
497
+ id TEXT PRIMARY KEY,
498
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
499
+ cron_expr TEXT NOT NULL,
500
+ preset TEXT,
501
+ timezone TEXT NOT NULL DEFAULT 'UTC',
502
+ enabled INTEGER NOT NULL DEFAULT 1,
503
+ providers TEXT NOT NULL DEFAULT '[]',
504
+ last_run_at TEXT,
505
+ next_run_at TEXT,
506
+ created_at TEXT NOT NULL,
507
+ updated_at TEXT NOT NULL,
508
+ UNIQUE(project_id)
509
+ );
510
+
511
+ CREATE TABLE IF NOT EXISTS notifications (
512
+ id TEXT PRIMARY KEY,
513
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
514
+ channel TEXT NOT NULL,
515
+ config TEXT NOT NULL,
516
+ enabled INTEGER NOT NULL DEFAULT 1,
517
+ created_at TEXT NOT NULL,
518
+ updated_at TEXT NOT NULL
519
+ );
520
+
521
+ CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
522
+ CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
523
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
524
+ CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
525
+ `;
526
+ var MIGRATIONS = [
527
+ // v2: Add providers column to projects for multi-provider support
528
+ `ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
529
+ // v3: Add webhook_secret column to notifications for HMAC signing
530
+ `ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
531
+ // v4: Add owned_domains column to projects for multi-domain citation matching
532
+ `ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
533
+ // v5: Add model column to query_snapshots for per-model scoring
534
+ `ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
535
+ // v5b: Backfill model from rawResponse JSON for existing snapshots
536
+ `UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
537
+ // v6: Google Search Console integration — google_connections table (domain-scoped)
538
+ // WARNING: access_token, refresh_token are authentication material; consider storing in config.yaml per CLAUDE.md
539
+ `CREATE TABLE IF NOT EXISTS google_connections (
540
+ id TEXT PRIMARY KEY,
541
+ domain TEXT NOT NULL,
542
+ connection_type TEXT NOT NULL,
543
+ property_id TEXT,
544
+ access_token TEXT,
545
+ refresh_token TEXT,
546
+ token_expires_at TEXT,
547
+ scopes TEXT NOT NULL DEFAULT '[]',
548
+ created_at TEXT NOT NULL,
549
+ updated_at TEXT NOT NULL
550
+ )`,
551
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
552
+ // v6: Google Search Console integration — gsc_search_data table
553
+ `CREATE TABLE IF NOT EXISTS gsc_search_data (
554
+ id TEXT PRIMARY KEY,
555
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
556
+ sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
557
+ date TEXT NOT NULL,
558
+ query TEXT NOT NULL,
559
+ page TEXT NOT NULL,
560
+ country TEXT,
561
+ device TEXT,
562
+ clicks INTEGER NOT NULL DEFAULT 0,
563
+ impressions INTEGER NOT NULL DEFAULT 0,
564
+ ctr TEXT NOT NULL DEFAULT '0',
565
+ position TEXT NOT NULL DEFAULT '0',
566
+ created_at TEXT NOT NULL
567
+ )`,
568
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
569
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
570
+ `CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
571
+ // v6: Google Search Console integration — gsc_url_inspections table
572
+ `CREATE TABLE IF NOT EXISTS gsc_url_inspections (
573
+ id TEXT PRIMARY KEY,
574
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
575
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
576
+ url TEXT NOT NULL,
577
+ indexing_state TEXT,
578
+ verdict TEXT,
579
+ coverage_state TEXT,
580
+ page_fetch_state TEXT,
581
+ robots_txt_state TEXT,
582
+ crawl_time TEXT,
583
+ last_crawl_result TEXT,
584
+ is_mobile_friendly INTEGER,
585
+ rich_results TEXT NOT NULL DEFAULT '[]',
586
+ referring_urls TEXT NOT NULL DEFAULT '[]',
587
+ inspected_at TEXT NOT NULL,
588
+ created_at TEXT NOT NULL
589
+ )`,
590
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
591
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
592
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
593
+ // v7: GSC coverage snapshots for historical tracking
594
+ `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
595
+ id TEXT PRIMARY KEY,
596
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
597
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
598
+ date TEXT NOT NULL,
599
+ indexed INTEGER NOT NULL DEFAULT 0,
600
+ not_indexed INTEGER NOT NULL DEFAULT 0,
601
+ reason_breakdown TEXT NOT NULL DEFAULT '{}',
602
+ created_at TEXT NOT NULL
603
+ )`,
604
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
605
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
606
+ // v8: Location-aware sweeps — project locations + snapshot location tag
607
+ `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
608
+ `ALTER TABLE projects ADD COLUMN default_location TEXT`,
609
+ `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
610
+ // v9: Add location column to runs for per-location run tracking
611
+ `ALTER TABLE runs ADD COLUMN location TEXT`,
612
+ // v10: Add sitemapUrl to google_connections for persistent sitemap storage
613
+ `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
614
+ // v11: CDP browser provider — screenshot path for captured evidence
615
+ `ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`,
616
+ // v12: Bing Webmaster Tools — bing_connections table
617
+ `CREATE TABLE IF NOT EXISTS bing_connections (
618
+ id TEXT PRIMARY KEY,
619
+ domain TEXT NOT NULL,
620
+ site_url TEXT,
621
+ created_at TEXT NOT NULL,
622
+ updated_at TEXT NOT NULL
623
+ )`,
624
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_bing_conn_domain ON bing_connections(domain)`,
625
+ // v12: Bing Webmaster Tools — bing_url_inspections table
626
+ `CREATE TABLE IF NOT EXISTS bing_url_inspections (
627
+ id TEXT PRIMARY KEY,
628
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
629
+ url TEXT NOT NULL,
630
+ http_code INTEGER,
631
+ in_index INTEGER,
632
+ last_crawled_date TEXT,
633
+ in_index_date TEXT,
634
+ inspected_at TEXT NOT NULL,
635
+ created_at TEXT NOT NULL
636
+ )`,
637
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_project_url ON bing_url_inspections(project_id, url)`,
638
+ `CREATE INDEX IF NOT EXISTS idx_bing_inspect_url_time ON bing_url_inspections(url, inspected_at)`,
639
+ // v12: Bing Webmaster Tools — bing_keyword_stats table
640
+ `CREATE TABLE IF NOT EXISTS bing_keyword_stats (
641
+ id TEXT PRIMARY KEY,
642
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
643
+ query TEXT NOT NULL,
644
+ impressions INTEGER NOT NULL DEFAULT 0,
645
+ clicks INTEGER NOT NULL DEFAULT 0,
646
+ ctr TEXT NOT NULL DEFAULT '0',
647
+ average_position TEXT NOT NULL DEFAULT '0',
648
+ synced_at TEXT NOT NULL,
649
+ created_at TEXT NOT NULL
650
+ )`,
651
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_project ON bing_keyword_stats(project_id)`,
652
+ `CREATE INDEX IF NOT EXISTS idx_bing_keyword_query ON bing_keyword_stats(query)`,
653
+ // v13: Google Analytics 4 — ga_connections table (service account auth)
654
+ // WARNING: private_key is authentication material; consider storing in config.yaml per CLAUDE.md
655
+ `CREATE TABLE IF NOT EXISTS ga_connections (
656
+ id TEXT PRIMARY KEY,
657
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
658
+ property_id TEXT NOT NULL,
659
+ client_email TEXT NOT NULL,
660
+ private_key TEXT NOT NULL,
661
+ created_at TEXT NOT NULL,
662
+ updated_at TEXT NOT NULL
663
+ )`,
664
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_conn_project ON ga_connections(project_id)`,
665
+ // v13: Google Analytics 4 — ga_traffic_snapshots table
666
+ `CREATE TABLE IF NOT EXISTS ga_traffic_snapshots (
667
+ id TEXT PRIMARY KEY,
668
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
669
+ date TEXT NOT NULL,
670
+ landing_page TEXT NOT NULL,
671
+ sessions INTEGER NOT NULL DEFAULT 0,
672
+ organic_sessions INTEGER NOT NULL DEFAULT 0,
673
+ users INTEGER NOT NULL DEFAULT 0,
674
+ synced_at TEXT NOT NULL
675
+ )`,
676
+ `CREATE INDEX IF NOT EXISTS idx_ga_traffic_project_date ON ga_traffic_snapshots(project_id, date)`,
677
+ `CREATE INDEX IF NOT EXISTS idx_ga_traffic_page ON ga_traffic_snapshots(landing_page)`,
678
+ // v14: GA4 aggregate summaries — stores true unique user count per sync period
679
+ `CREATE TABLE IF NOT EXISTS ga_traffic_summaries (
680
+ id TEXT PRIMARY KEY,
681
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
682
+ period_start TEXT NOT NULL,
683
+ period_end TEXT NOT NULL,
684
+ total_sessions INTEGER NOT NULL DEFAULT 0,
685
+ total_organic_sessions INTEGER NOT NULL DEFAULT 0,
686
+ total_users INTEGER NOT NULL DEFAULT 0,
687
+ synced_at TEXT NOT NULL
688
+ )`,
689
+ `CREATE INDEX IF NOT EXISTS idx_ga_summary_project ON ga_traffic_summaries(project_id)`,
690
+ // v15: Bing URL inspections — document_size, anchor_count, discovery_date columns
691
+ `ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`,
692
+ `ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`,
693
+ `ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`,
694
+ // v16: Recommended competitor names extracted from run answers
695
+ `ALTER TABLE query_snapshots ADD COLUMN recommended_competitors TEXT NOT NULL DEFAULT '[]'`,
696
+ // v17: GA4 AI referral tracking — ga_ai_referrals table
697
+ `CREATE TABLE IF NOT EXISTS ga_ai_referrals (
698
+ id TEXT PRIMARY KEY,
699
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
700
+ date TEXT NOT NULL,
701
+ source TEXT NOT NULL,
702
+ medium TEXT NOT NULL,
703
+ sessions INTEGER NOT NULL DEFAULT 0,
704
+ users INTEGER NOT NULL DEFAULT 0,
705
+ synced_at TEXT NOT NULL
706
+ )`,
707
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_project_date ON ga_ai_referrals(project_id, date)`,
708
+ `CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_source ON ga_ai_referrals(source)`,
709
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`,
710
+ // v18: Answer-level visibility derived from answer text
711
+ `ALTER TABLE query_snapshots ADD COLUMN answer_mentioned INTEGER`,
712
+ // v19: Add named unique indexes and missing columns from early tables
713
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_keywords_project_keyword ON keywords(project_id, keyword)`,
714
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_competitors_project_domain ON competitors(project_id, domain)`,
715
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id)`,
716
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_scope_period_metric ON usage_counters(scope, period, metric)`,
717
+ `ALTER TABLE projects ADD COLUMN config_source TEXT NOT NULL DEFAULT 'cli'`,
718
+ `ALTER TABLE projects ADD COLUMN config_revision INTEGER NOT NULL DEFAULT 1`,
719
+ // v20: Track which GA4 dimension produced each AI referral row
720
+ // Values: 'session' (sessionSource), 'first_user' (firstUserSource), 'manual_utm' (manualSource/utm_source)
721
+ `ALTER TABLE ga_ai_referrals ADD COLUMN source_dimension TEXT NOT NULL DEFAULT 'session'`,
722
+ // Replace old unique index with one that includes source_dimension
723
+ `DROP INDEX IF EXISTS idx_ga_ai_ref_unique`,
724
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique_v2 ON ga_ai_referrals(project_id, date, source, medium, source_dimension)`,
725
+ // v21: Add missing indexes for query_snapshots filtering
726
+ `CREATE INDEX IF NOT EXISTS idx_snapshots_citation_state ON query_snapshots(citation_state)`,
727
+ `CREATE INDEX IF NOT EXISTS idx_snapshots_provider_model ON query_snapshots(provider, model)`,
728
+ `CREATE INDEX IF NOT EXISTS idx_snapshots_location ON query_snapshots(location)`,
729
+ // v22: Intelligence — insights table for regression/gain/opportunity tracking
730
+ `CREATE TABLE IF NOT EXISTS insights (
731
+ id TEXT PRIMARY KEY,
732
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
733
+ type TEXT NOT NULL,
734
+ severity TEXT NOT NULL,
735
+ title TEXT NOT NULL,
736
+ keyword TEXT NOT NULL,
737
+ provider TEXT NOT NULL,
738
+ recommendation TEXT,
739
+ cause TEXT,
740
+ dismissed INTEGER NOT NULL DEFAULT 0,
741
+ created_at TEXT NOT NULL
742
+ )`,
743
+ `CREATE INDEX IF NOT EXISTS idx_insights_project ON insights(project_id)`,
744
+ `CREATE INDEX IF NOT EXISTS idx_insights_created ON insights(created_at)`,
745
+ `CREATE INDEX IF NOT EXISTS idx_insights_keyword_provider ON insights(keyword, provider)`,
746
+ // v23: Intelligence — health_snapshots table for citation health over time
747
+ `CREATE TABLE IF NOT EXISTS health_snapshots (
748
+ id TEXT PRIMARY KEY,
749
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
750
+ overall_cited_rate TEXT NOT NULL,
751
+ total_pairs INTEGER NOT NULL,
752
+ cited_pairs INTEGER NOT NULL,
753
+ provider_breakdown TEXT NOT NULL DEFAULT '{}',
754
+ created_at TEXT NOT NULL
755
+ )`,
756
+ `CREATE INDEX IF NOT EXISTS idx_health_snapshots_project ON health_snapshots(project_id)`,
757
+ `CREATE INDEX IF NOT EXISTS idx_health_snapshots_created ON health_snapshots(created_at)`,
758
+ // v24: Intelligence — add run_id to insights and health_snapshots for per-run correlation and idempotency
759
+ `ALTER TABLE insights ADD COLUMN run_id TEXT REFERENCES runs(id) ON DELETE CASCADE`,
760
+ `CREATE INDEX IF NOT EXISTS idx_insights_run ON insights(run_id)`,
761
+ `ALTER TABLE health_snapshots ADD COLUMN run_id TEXT REFERENCES runs(id) ON DELETE CASCADE`,
762
+ `CREATE INDEX IF NOT EXISTS idx_health_snapshots_run ON health_snapshots(run_id)`
763
+ ];
764
+ function isDuplicateColumnError(err) {
765
+ if (!(err instanceof Error)) return false;
766
+ if (err.message.includes("duplicate column name")) return true;
767
+ if (err.cause instanceof Error && err.cause.message.includes("duplicate column name")) return true;
768
+ return false;
769
+ }
770
+ function migrate(db) {
771
+ const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
772
+ for (const statement of statements) {
773
+ db.run(sql.raw(statement));
774
+ }
775
+ for (const migration of MIGRATIONS) {
776
+ try {
777
+ db.run(sql.raw(migration));
778
+ } catch (err) {
779
+ if (isDuplicateColumnError(err)) continue;
780
+ throw err;
781
+ }
782
+ }
783
+ }
784
+
785
+ // ../intelligence/src/regressions.ts
786
+ function detectRegressions(currentRun, previousRun) {
787
+ const regressions = [];
788
+ const previousCited = /* @__PURE__ */ new Map();
789
+ for (const snap of previousRun.snapshots) {
790
+ if (snap.cited) {
791
+ previousCited.set(`${snap.keyword}:${snap.provider}`, {
792
+ citationUrl: snap.citationUrl,
793
+ position: snap.position
794
+ });
795
+ }
796
+ }
797
+ for (const snap of currentRun.snapshots) {
798
+ const key = `${snap.keyword}:${snap.provider}`;
799
+ if (!snap.cited && previousCited.has(key)) {
800
+ const prev = previousCited.get(key);
801
+ regressions.push({
802
+ keyword: snap.keyword,
803
+ provider: snap.provider,
804
+ previousCitationUrl: prev.citationUrl,
805
+ previousPosition: prev.position,
806
+ currentRunId: currentRun.runId,
807
+ previousRunId: previousRun.runId
808
+ });
809
+ }
810
+ }
811
+ return regressions;
812
+ }
813
+
814
+ // ../intelligence/src/gains.ts
815
+ function detectGains(currentRun, previousRun) {
816
+ const gains = [];
817
+ const previousCited = /* @__PURE__ */ new Set();
818
+ for (const snap of previousRun.snapshots) {
819
+ if (snap.cited) {
820
+ previousCited.add(`${snap.keyword}:${snap.provider}`);
821
+ }
822
+ }
823
+ for (const snap of currentRun.snapshots) {
824
+ const key = `${snap.keyword}:${snap.provider}`;
825
+ if (snap.cited && !previousCited.has(key)) {
826
+ gains.push({
827
+ keyword: snap.keyword,
828
+ provider: snap.provider,
829
+ citationUrl: snap.citationUrl,
830
+ position: snap.position,
831
+ snippet: snap.snippet,
832
+ runId: currentRun.runId
833
+ });
834
+ }
835
+ }
836
+ return gains;
837
+ }
838
+
839
+ // ../intelligence/src/health.ts
840
+ function computeHealth(run) {
841
+ const providerStats = /* @__PURE__ */ new Map();
842
+ let totalPairs = 0;
843
+ let citedPairs = 0;
844
+ for (const snap of run.snapshots) {
845
+ totalPairs++;
846
+ if (snap.cited) citedPairs++;
847
+ const stats = providerStats.get(snap.provider) ?? { cited: 0, total: 0 };
848
+ stats.total++;
849
+ if (snap.cited) stats.cited++;
850
+ providerStats.set(snap.provider, stats);
851
+ }
852
+ const providerBreakdown = {};
853
+ for (const [provider, stats] of providerStats) {
854
+ providerBreakdown[provider] = {
855
+ citedRate: stats.total > 0 ? stats.cited / stats.total : 0,
856
+ cited: stats.cited,
857
+ total: stats.total
858
+ };
859
+ }
860
+ return {
861
+ overallCitedRate: totalPairs > 0 ? citedPairs / totalPairs : 0,
862
+ totalPairs,
863
+ citedPairs,
864
+ providerBreakdown
865
+ };
866
+ }
867
+ function computeHealthTrend(runs2) {
868
+ if (runs2.length === 0) {
869
+ return { current: 0, previous: 0, delta: 0 };
870
+ }
871
+ const current = computeHealth(runs2[runs2.length - 1]).overallCitedRate;
872
+ if (runs2.length === 1) {
873
+ return { current, previous: 0, delta: current };
874
+ }
875
+ const previous = computeHealth(runs2[runs2.length - 2]).overallCitedRate;
876
+ return {
877
+ current,
878
+ previous,
879
+ delta: current - previous
880
+ };
881
+ }
882
+
883
+ // ../intelligence/src/causes.ts
884
+ function analyzeCause(regression, currentSnapshots) {
885
+ const currentSnap = currentSnapshots.find(
886
+ (s) => s.keyword === regression.keyword && s.provider === regression.provider && !s.cited && s.competitorDomain
887
+ );
888
+ if (currentSnap) {
889
+ return {
890
+ cause: "competitor_gain",
891
+ competitorDomain: currentSnap.competitorDomain,
892
+ details: `Competitor ${currentSnap.competitorDomain} now cited for "${regression.keyword}" on ${regression.provider}`
893
+ };
894
+ }
895
+ return {
896
+ cause: "unknown",
897
+ details: `No specific cause identified for loss of "${regression.keyword}" on ${regression.provider}`
898
+ };
899
+ }
900
+
901
+ // ../intelligence/src/insights.ts
902
+ import { randomUUID } from "crypto";
903
+ function generateInsights(regressions, gains, health, causes) {
904
+ const insights2 = [];
905
+ const now = (/* @__PURE__ */ new Date()).toISOString();
906
+ for (const reg of regressions) {
907
+ const key = `${reg.keyword}:${reg.provider}`;
908
+ const cause = causes.get(key);
909
+ insights2.push({
910
+ id: `ins_${randomUUID().slice(0, 8)}`,
911
+ type: "regression",
912
+ severity: "high",
913
+ title: `Lost ${reg.provider} citation for "${reg.keyword}"`,
914
+ keyword: reg.keyword,
915
+ provider: reg.provider,
916
+ recommendation: {
917
+ action: "audit",
918
+ target: reg.previousCitationUrl,
919
+ reason: `Page was previously cited at position ${reg.previousPosition ?? "unknown"}. Run aeo-audit to check for content or schema issues.`
920
+ },
921
+ cause,
922
+ createdAt: now
923
+ });
924
+ }
925
+ for (const gain of gains) {
926
+ insights2.push({
927
+ id: `ins_${randomUUID().slice(0, 8)}`,
928
+ type: "gain",
929
+ severity: "low",
930
+ title: `New ${gain.provider} citation for "${gain.keyword}"`,
931
+ keyword: gain.keyword,
932
+ provider: gain.provider,
933
+ recommendation: {
934
+ action: "monitor",
935
+ target: gain.citationUrl,
936
+ reason: `New citation appeared at position ${gain.position ?? "unknown"}. Monitor to confirm it persists.`
937
+ },
938
+ createdAt: now
939
+ });
940
+ }
941
+ return insights2;
942
+ }
943
+
944
+ // ../intelligence/src/analyzer.ts
945
+ function analyzeRuns(currentRun, previousRun, allRuns) {
946
+ const regressions = detectRegressions(currentRun, previousRun);
947
+ const gains = detectGains(currentRun, previousRun);
948
+ const health = computeHealth(currentRun);
949
+ const trend = allRuns ? computeHealthTrend(allRuns) : void 0;
950
+ const causes = /* @__PURE__ */ new Map();
951
+ for (const reg of regressions) {
952
+ const cause = analyzeCause(reg, currentRun.snapshots);
953
+ causes.set(`${reg.keyword}:${reg.provider}`, cause);
954
+ }
955
+ const insights2 = generateInsights(regressions, gains, health, causes);
956
+ return {
957
+ regressions,
958
+ gains,
959
+ health,
960
+ trend,
961
+ insights: insights2
962
+ };
963
+ }
964
+
965
+ // src/intelligence-service.ts
966
+ import crypto from "crypto";
967
+
968
+ // src/logger.ts
969
+ var IS_TTY = process.stdout.isTTY === true;
970
+ function formatTTY(entry) {
971
+ const { ts, level, module, action, msg, ...ctx } = entry;
972
+ const time = ts.slice(11, 19);
973
+ const levelTag = level === "error" ? "\x1B[31mERR\x1B[0m" : level === "warn" ? "\x1B[33mWRN\x1B[0m" : "\x1B[36mINF\x1B[0m";
974
+ const ctxParts = Object.entries(ctx).filter(([, v]) => v !== void 0 && v !== null).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ");
975
+ const msgPart = msg ? ` ${msg}` : "";
976
+ const ctxPart = ctxParts ? ` ${ctxParts}` : "";
977
+ return `${time} ${levelTag} [${module}] ${action}${msgPart}${ctxPart}`;
978
+ }
979
+ function emit(entry) {
980
+ const stream = entry.level === "error" ? process.stderr : process.stdout;
981
+ if (IS_TTY) {
982
+ stream.write(formatTTY(entry) + "\n");
983
+ } else {
984
+ stream.write(JSON.stringify(entry) + "\n");
985
+ }
986
+ }
987
+ function createLogger(module) {
988
+ function log2(level, action, ctx) {
989
+ const entry = {
990
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
991
+ level,
992
+ module,
993
+ action,
994
+ ...ctx
995
+ };
996
+ emit(entry);
997
+ }
998
+ return {
999
+ info: (action, ctx) => log2("info", action, ctx),
1000
+ warn: (action, ctx) => log2("warn", action, ctx),
1001
+ error: (action, ctx) => log2("error", action, ctx)
1002
+ };
1003
+ }
1004
+
1005
+ // src/intelligence-service.ts
1006
+ var log = createLogger("IntelligenceService");
1007
+ var IntelligenceService = class {
1008
+ constructor(db) {
1009
+ this.db = db;
1010
+ }
1011
+ /**
1012
+ * Analyze a completed run and persist insights + health snapshot.
1013
+ * Idempotent: deletes prior results for the same runId before inserting.
1014
+ * Returns the analysis result for the coordinator to inspect (e.g. for webhook dispatch).
1015
+ */
1016
+ analyzeAndPersist(runId, projectId) {
1017
+ const recentRuns = this.db.select().from(runs).where(
1018
+ and(
1019
+ eq(runs.projectId, projectId),
1020
+ or(eq(runs.status, "completed"), eq(runs.status, "partial"))
1021
+ )
1022
+ ).orderBy(desc(runs.createdAt)).limit(2).all();
1023
+ if (recentRuns.length === 0) {
1024
+ log.info("intelligence.skip", { runId, reason: "no completed runs" });
1025
+ return null;
1026
+ }
1027
+ const currentRunRecord = recentRuns.find((r) => r.id === runId);
1028
+ if (!currentRunRecord) {
1029
+ log.info("intelligence.skip", { runId, reason: "run not in recent completed list" });
1030
+ return null;
1031
+ }
1032
+ const currentRun = this.buildRunData(runId, projectId, currentRunRecord.finishedAt ?? currentRunRecord.createdAt);
1033
+ if (currentRun.snapshots.length === 0) {
1034
+ log.info("intelligence.skip", { runId, reason: "no snapshots" });
1035
+ return null;
1036
+ }
1037
+ const previousRunRecord = recentRuns.find((r) => r.id !== runId);
1038
+ const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
1039
+ if (!previousRun) {
1040
+ const result2 = analyzeRuns(currentRun, currentRun);
1041
+ log.info("intelligence.analyzed", {
1042
+ runId,
1043
+ regressions: 0,
1044
+ gains: 0,
1045
+ citedRate: result2.health.overallCitedRate,
1046
+ insights: 0
1047
+ });
1048
+ this.persistResult({ ...result2, insights: [], regressions: [], gains: [] }, runId, projectId);
1049
+ return result2;
1050
+ }
1051
+ const result = analyzeRuns(currentRun, previousRun);
1052
+ log.info("intelligence.analyzed", {
1053
+ runId,
1054
+ regressions: result.regressions.length,
1055
+ gains: result.gains.length,
1056
+ citedRate: result.health.overallCitedRate,
1057
+ insights: result.insights.length
1058
+ });
1059
+ this.persistResult(result, runId, projectId);
1060
+ return result;
1061
+ }
1062
+ /**
1063
+ * Analyze a single run given an explicit previous run (or null for first run).
1064
+ * Used by backfill where we control the run ordering.
1065
+ */
1066
+ analyzeRunWithPrevious(runRecord, previousRunRecord) {
1067
+ const currentRun = this.buildRunData(runRecord.id, runRecord.projectId, runRecord.finishedAt ?? runRecord.createdAt);
1068
+ if (currentRun.snapshots.length === 0) {
1069
+ return null;
1070
+ }
1071
+ const previousRun = previousRunRecord ? this.buildRunData(previousRunRecord.id, previousRunRecord.projectId, previousRunRecord.finishedAt ?? previousRunRecord.createdAt) : null;
1072
+ if (!previousRun) {
1073
+ const result2 = analyzeRuns(currentRun, currentRun);
1074
+ this.persistResult({ ...result2, insights: [], regressions: [], gains: [] }, runRecord.id, runRecord.projectId);
1075
+ return result2;
1076
+ }
1077
+ const result = analyzeRuns(currentRun, previousRun);
1078
+ this.persistResult(result, runRecord.id, runRecord.projectId);
1079
+ return result;
1080
+ }
1081
+ /**
1082
+ * Backfill intelligence for all completed/partial runs of a project.
1083
+ * Processes runs in chronological order so each run compares against its predecessor.
1084
+ */
1085
+ backfill(projectName, opts, onProgress) {
1086
+ const project = this.db.select().from(projects).where(eq(projects.name, projectName)).get();
1087
+ if (!project) {
1088
+ throw new Error(`Project "${projectName}" not found`);
1089
+ }
1090
+ const allRuns = this.db.select().from(runs).where(
1091
+ and(
1092
+ eq(runs.projectId, project.id),
1093
+ or(eq(runs.status, "completed"), eq(runs.status, "partial"))
1094
+ )
1095
+ ).orderBy(asc(runs.finishedAt)).all();
1096
+ let startIdx = 0;
1097
+ let endIdx = allRuns.length;
1098
+ if (opts?.fromRunId) {
1099
+ const idx = allRuns.findIndex((r) => r.id === opts.fromRunId);
1100
+ if (idx === -1) throw new Error(`Run "${opts.fromRunId}" not found in project`);
1101
+ startIdx = idx;
1102
+ }
1103
+ if (opts?.toRunId) {
1104
+ const idx = allRuns.findIndex((r) => r.id === opts.toRunId);
1105
+ if (idx === -1) throw new Error(`Run "${opts.toRunId}" not found in project`);
1106
+ endIdx = idx + 1;
1107
+ }
1108
+ const targetRuns = allRuns.slice(startIdx, endIdx);
1109
+ let processed = 0;
1110
+ let skipped = 0;
1111
+ let totalInsights = 0;
1112
+ for (let i = 0; i < targetRuns.length; i++) {
1113
+ const run = targetRuns[i];
1114
+ const globalIdx = allRuns.indexOf(run);
1115
+ const previousRun = globalIdx > 0 ? allRuns[globalIdx - 1] : null;
1116
+ const result = this.analyzeRunWithPrevious(run, previousRun);
1117
+ if (result) {
1118
+ processed++;
1119
+ totalInsights += result.insights.length;
1120
+ onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: result.insights.length });
1121
+ } else {
1122
+ skipped++;
1123
+ onProgress?.({ runId: run.id, index: i + 1, total: targetRuns.length, insights: 0 });
1124
+ }
1125
+ }
1126
+ return { processed, skipped, totalInsights };
1127
+ }
1128
+ persistResult(result, runId, projectId) {
1129
+ const previouslyDismissed = /* @__PURE__ */ new Set();
1130
+ const existingInsights = this.db.select({ keyword: insights.keyword, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(eq(insights.runId, runId)).all();
1131
+ for (const row of existingInsights) {
1132
+ if (row.dismissed) {
1133
+ previouslyDismissed.add(`${row.keyword}:${row.provider}:${row.type}`);
1134
+ }
1135
+ }
1136
+ this.db.transaction((tx) => {
1137
+ tx.delete(insights).where(eq(insights.runId, runId)).run();
1138
+ tx.delete(healthSnapshots).where(eq(healthSnapshots.runId, runId)).run();
1139
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1140
+ for (const insight of result.insights) {
1141
+ const wasDismissed = previouslyDismissed.has(`${insight.keyword}:${insight.provider}:${insight.type}`);
1142
+ tx.insert(insights).values({
1143
+ id: insight.id,
1144
+ projectId,
1145
+ runId,
1146
+ type: insight.type,
1147
+ severity: insight.severity,
1148
+ title: insight.title,
1149
+ keyword: insight.keyword,
1150
+ provider: insight.provider,
1151
+ recommendation: insight.recommendation ? JSON.stringify(insight.recommendation) : null,
1152
+ cause: insight.cause ? JSON.stringify(insight.cause) : null,
1153
+ dismissed: wasDismissed,
1154
+ createdAt: insight.createdAt
1155
+ }).run();
1156
+ }
1157
+ tx.insert(healthSnapshots).values({
1158
+ id: crypto.randomUUID(),
1159
+ projectId,
1160
+ runId,
1161
+ overallCitedRate: String(result.health.overallCitedRate),
1162
+ totalPairs: result.health.totalPairs,
1163
+ citedPairs: result.health.citedPairs,
1164
+ providerBreakdown: JSON.stringify(result.health.providerBreakdown),
1165
+ createdAt: now
1166
+ }).run();
1167
+ });
1168
+ log.info("intelligence.persisted", { runId, insights: result.insights.length });
1169
+ }
1170
+ buildRunData(runId, projectId, completedAt) {
1171
+ const rows = this.db.select({
1172
+ keyword: keywords.keyword,
1173
+ provider: querySnapshots.provider,
1174
+ citationState: querySnapshots.citationState,
1175
+ citedDomains: querySnapshots.citedDomains,
1176
+ competitorOverlap: querySnapshots.competitorOverlap
1177
+ }).from(querySnapshots).leftJoin(keywords, eq(querySnapshots.keywordId, keywords.id)).where(eq(querySnapshots.runId, runId)).all();
1178
+ const snapshots = rows.map((r) => {
1179
+ const domains = parseJsonColumn(r.citedDomains, []);
1180
+ const competitors2 = parseJsonColumn(r.competitorOverlap, []);
1181
+ return {
1182
+ keyword: r.keyword ?? "",
1183
+ provider: r.provider,
1184
+ cited: r.citationState === "cited",
1185
+ citationUrl: domains[0] ?? void 0,
1186
+ competitorDomain: competitors2[0] ?? void 0
1187
+ };
1188
+ });
1189
+ return { runId, projectId, completedAt, snapshots };
1190
+ }
1191
+ };
1192
+
1193
+ export {
1194
+ projects,
1195
+ keywords,
1196
+ competitors,
1197
+ runs,
1198
+ querySnapshots,
1199
+ auditLog,
1200
+ apiKeys,
1201
+ schedules,
1202
+ notifications,
1203
+ gscSearchData,
1204
+ gscUrlInspections,
1205
+ gscCoverageSnapshots,
1206
+ bingUrlInspections,
1207
+ gaTrafficSnapshots,
1208
+ gaAiReferrals,
1209
+ gaTrafficSummaries,
1210
+ usageCounters,
1211
+ insights,
1212
+ healthSnapshots,
1213
+ createClient,
1214
+ parseJsonColumn,
1215
+ migrate,
1216
+ createLogger,
1217
+ IntelligenceService
1218
+ };