@ainyc/canonry 2.14.1 → 2.15.1

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-BwFUCV6e.js"></script>
16
- <link rel="stylesheet" crossorigin href="./assets/index-U2SLimrz.css">
15
+ <script type="module" crossorigin src="./assets/index-D0BgyJJT.js"></script>
16
+ <link rel="stylesheet" crossorigin href="./assets/index-DMx3Oy9W.css">
17
17
  </head>
18
18
  <body>
19
19
  <div id="root"></div>
@@ -60,7 +60,7 @@ import {
60
60
  visibilityStateFromAnswerMentioned,
61
61
  windowCutoff,
62
62
  wordpressEnvSchema
63
- } from "./chunk-QTS7VZXN.js";
63
+ } from "./chunk-OX24LLIH.js";
64
64
  import {
65
65
  IntelligenceService,
66
66
  agentMemory,
@@ -98,7 +98,7 @@ import {
98
98
  runs,
99
99
  schedules,
100
100
  usageCounters
101
- } from "./chunk-FV6PY5UE.js";
101
+ } from "./chunk-NEDRCOOL.js";
102
102
 
103
103
  // src/telemetry.ts
104
104
  import crypto from "crypto";
@@ -6662,6 +6662,8 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6662
6662
  sessions: parseInt(row.metricValues[0].value, 10) || 0,
6663
6663
  organicSessions: 0,
6664
6664
  // populated by organic-only pass below
6665
+ directSessions: 0,
6666
+ // populated by direct-only pass below
6665
6667
  users: parseInt(row.metricValues[1].value, 10) || 0
6666
6668
  }));
6667
6669
  rows.push(...pageRows);
@@ -6697,9 +6699,38 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
6697
6699
  organicOffset += (organicResponse.rows ?? []).length;
6698
6700
  if ((organicResponse.rows ?? []).length < 1e4 || organicOffset >= total) break;
6699
6701
  }
6702
+ const directMap = /* @__PURE__ */ new Map();
6703
+ let directOffset = 0;
6704
+ let directPageCount = 0;
6705
+ while (directPageCount < GA4_MAX_PAGES) {
6706
+ directPageCount++;
6707
+ const directRequest = {
6708
+ dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
6709
+ dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
6710
+ metrics: [{ name: "sessions" }],
6711
+ dimensionFilter: {
6712
+ filter: {
6713
+ fieldName: "sessionDefaultChannelGrouping",
6714
+ stringFilter: { matchType: "EXACT", value: "Direct" }
6715
+ }
6716
+ },
6717
+ limit: 1e4,
6718
+ offset: directOffset
6719
+ };
6720
+ const directResponse = await runReport(accessToken, propertyId, directRequest);
6721
+ if (!directResponse) break;
6722
+ for (const row of directResponse.rows ?? []) {
6723
+ const key = `${row.dimensionValues[0].value}::${row.dimensionValues[1].value}`;
6724
+ directMap.set(key, parseInt(row.metricValues[0].value, 10) || 0);
6725
+ }
6726
+ const total = directResponse.rowCount ?? 0;
6727
+ directOffset += (directResponse.rows ?? []).length;
6728
+ if ((directResponse.rows ?? []).length < 1e4 || directOffset >= total) break;
6729
+ }
6700
6730
  for (const row of rows) {
6701
6731
  const key = `${row.date}::${row.landingPage}`;
6702
6732
  row.organicSessions = organicMap.get(key) ?? 0;
6733
+ row.directSessions = directMap.get(key) ?? 0;
6703
6734
  }
6704
6735
  for (const row of rows) {
6705
6736
  if (row.date.length === 8 && !row.date.includes("-")) {
@@ -8665,6 +8696,7 @@ async function ga4Routes(app, opts) {
8665
8696
  landingPageNormalized: normalizeUrlPath(row.landingPage),
8666
8697
  sessions: row.sessions,
8667
8698
  organicSessions: row.organicSessions,
8699
+ directSessions: row.directSessions,
8668
8700
  users: row.users,
8669
8701
  syncedAt: now,
8670
8702
  syncRunId: runId
@@ -8782,6 +8814,9 @@ async function ga4Routes(app, opts) {
8782
8814
  totalOrganicSessions: gaTrafficSummaries.totalOrganicSessions,
8783
8815
  totalUsers: gaTrafficSummaries.totalUsers
8784
8816
  }).from(gaTrafficSummaries).where(eq19(gaTrafficSummaries.projectId, project.id)).get();
8817
+ const directTotalRow = app.db.select({
8818
+ totalDirectSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`
8819
+ }).from(gaTrafficSnapshots).where(and8(...snapshotConditions)).get();
8785
8820
  const summaryMeta = app.db.select({
8786
8821
  periodStart: gaTrafficSummaries.periodStart,
8787
8822
  periodEnd: gaTrafficSummaries.periodEnd
@@ -8790,6 +8825,7 @@ async function ga4Routes(app, opts) {
8790
8825
  landingPage: sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`,
8791
8826
  sessions: sql5`SUM(${gaTrafficSnapshots.sessions})`,
8792
8827
  organicSessions: sql5`SUM(${gaTrafficSnapshots.organicSessions})`,
8828
+ directSessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)`,
8793
8829
  users: sql5`SUM(${gaTrafficSnapshots.users})`
8794
8830
  }).from(gaTrafficSnapshots).where(and8(...snapshotConditions)).groupBy(sql5`COALESCE(${gaTrafficSnapshots.landingPageNormalized}, ${gaTrafficSnapshots.landingPage})`).orderBy(sql5`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
8795
8831
  const aiReferrals = app.db.select({
@@ -8812,6 +8848,10 @@ async function ga4Routes(app, opts) {
8812
8848
  GROUP BY date, source, medium
8813
8849
  )`
8814
8850
  ).get();
8851
+ const aiBySession = app.db.select({
8852
+ sessions: sql5`COALESCE(SUM(${gaAiReferrals.sessions}), 0)`,
8853
+ users: sql5`COALESCE(SUM(${gaAiReferrals.users}), 0)`
8854
+ }).from(gaAiReferrals).where(and8(...aiConditions, eq19(gaAiReferrals.sourceDimension, "session"))).get();
8815
8855
  const socialReferrals = app.db.select({
8816
8856
  source: gaSocialReferrals.source,
8817
8857
  medium: gaSocialReferrals.medium,
@@ -8825,14 +8865,17 @@ async function ga4Routes(app, opts) {
8825
8865
  }).from(gaSocialReferrals).where(and8(...socialConditions)).get();
8826
8866
  const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq19(gaTrafficSummaries.projectId, project.id)).orderBy(desc9(gaTrafficSummaries.syncedAt)).limit(1).get();
8827
8867
  const total = summaryRow?.totalSessions ?? 0;
8868
+ const totalDirectSessions = directTotalRow?.totalDirectSessions ?? 0;
8828
8869
  return {
8829
8870
  totalSessions: total,
8830
8871
  totalOrganicSessions: summaryRow?.totalOrganicSessions ?? 0,
8872
+ totalDirectSessions,
8831
8873
  totalUsers: summaryRow?.totalUsers ?? 0,
8832
8874
  topPages: rows.map((r) => ({
8833
8875
  landingPage: r.landingPage,
8834
8876
  sessions: r.sessions ?? 0,
8835
8877
  organicSessions: r.organicSessions ?? 0,
8878
+ directSessions: r.directSessions ?? 0,
8836
8879
  users: r.users ?? 0
8837
8880
  })),
8838
8881
  aiReferrals: aiReferrals.map((r) => ({
@@ -8844,6 +8887,8 @@ async function ga4Routes(app, opts) {
8844
8887
  })),
8845
8888
  aiSessionsDeduped: aiDeduped?.sessions ?? 0,
8846
8889
  aiUsersDeduped: aiDeduped?.users ?? 0,
8890
+ aiSessionsBySession: aiBySession?.sessions ?? 0,
8891
+ aiUsersBySession: aiBySession?.users ?? 0,
8847
8892
  socialReferrals: socialReferrals.map((r) => ({
8848
8893
  source: r.source,
8849
8894
  medium: r.medium,
@@ -8855,6 +8900,8 @@ async function ga4Routes(app, opts) {
8855
8900
  socialUsers: socialTotals?.users ?? 0,
8856
8901
  organicSharePct: total > 0 ? Math.round((summaryRow?.totalOrganicSessions ?? 0) / total * 100) : 0,
8857
8902
  aiSharePct: total > 0 ? Math.round((aiDeduped?.sessions ?? 0) / total * 100) : 0,
8903
+ aiSharePctBySession: total > 0 ? Math.round((aiBySession?.sessions ?? 0) / total * 100) : 0,
8904
+ directSharePct: total > 0 ? Math.round(totalDirectSessions / total * 100) : 0,
8858
8905
  socialSharePct: total > 0 ? Math.round((socialTotals?.sessions ?? 0) / total * 100) : 0,
8859
8906
  lastSyncedAt: latestSync?.syncedAt ?? null,
8860
8907
  periodStart: (() => {
@@ -8973,12 +9020,13 @@ async function ga4Routes(app, opts) {
8973
9020
  const pct = (cur, prev) => prev === 0 ? null : Math.round((cur - prev) / prev * 100);
8974
9021
  const sumTotal = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.sessions}), 0)` }).from(gaTrafficSnapshots).where(and8(eq19(gaTrafficSnapshots.projectId, project.id), sql5`${gaTrafficSnapshots.date} >= ${from}`, sql5`${gaTrafficSnapshots.date} < ${to}`)).get();
8975
9022
  const sumOrganic = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.organicSessions}), 0)` }).from(gaTrafficSnapshots).where(and8(eq19(gaTrafficSnapshots.projectId, project.id), sql5`${gaTrafficSnapshots.date} >= ${from}`, sql5`${gaTrafficSnapshots.date} < ${to}`)).get();
8976
- const sumAi = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(max_sessions), 0)` }).from(sql5`(
8977
- SELECT date, source, medium, MAX(sessions) AS max_sessions
8978
- FROM ga_ai_referrals
8979
- WHERE project_id = ${project.id} AND date >= ${from} AND date < ${to}
8980
- GROUP BY date, source, medium
8981
- )`).get();
9023
+ const sumDirect = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(${gaTrafficSnapshots.directSessions}), 0)` }).from(gaTrafficSnapshots).where(and8(eq19(gaTrafficSnapshots.projectId, project.id), sql5`${gaTrafficSnapshots.date} >= ${from}`, sql5`${gaTrafficSnapshots.date} < ${to}`)).get();
9024
+ const sumAi = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(${gaAiReferrals.sessions}), 0)` }).from(gaAiReferrals).where(and8(
9025
+ eq19(gaAiReferrals.projectId, project.id),
9026
+ sql5`${gaAiReferrals.date} >= ${from}`,
9027
+ sql5`${gaAiReferrals.date} < ${to}`,
9028
+ eq19(gaAiReferrals.sourceDimension, "session")
9029
+ )).get();
8982
9030
  const sumSocial = (from, to) => app.db.select({ sessions: sql5`COALESCE(SUM(${gaSocialReferrals.sessions}), 0)` }).from(gaSocialReferrals).where(and8(eq19(gaSocialReferrals.projectId, project.id), sql5`${gaSocialReferrals.date} >= ${from}`, sql5`${gaSocialReferrals.date} < ${to}`)).get();
8983
9031
  const todayStr = fmt(today);
8984
9032
  const buildTrend = (sum) => {
@@ -8988,18 +9036,18 @@ async function ga4Routes(app, opts) {
8988
9036
  const p30 = sum(daysAgo2(60), daysAgo2(30))?.sessions ?? 0;
8989
9037
  return { sessions7d: c7, sessionsPrev7d: p7, trend7dPct: pct(c7, p7), sessions30d: c30, sessionsPrev30d: p30, trend30dPct: pct(c30, p30) };
8990
9038
  };
8991
- const aiSourceCurrent = app.db.select({ source: sql5`source`, sessions: sql5`COALESCE(SUM(max_sessions), 0)` }).from(sql5`(
8992
- SELECT date, source, medium, MAX(sessions) AS max_sessions
8993
- FROM ga_ai_referrals
8994
- WHERE project_id = ${project.id} AND date >= ${daysAgo2(7)} AND date < ${todayStr}
8995
- GROUP BY date, source, medium
8996
- )`).groupBy(sql5`source`).all();
8997
- const aiSourcePrev = app.db.select({ source: sql5`source`, sessions: sql5`COALESCE(SUM(max_sessions), 0)` }).from(sql5`(
8998
- SELECT date, source, medium, MAX(sessions) AS max_sessions
8999
- FROM ga_ai_referrals
9000
- WHERE project_id = ${project.id} AND date >= ${daysAgo2(14)} AND date < ${daysAgo2(7)}
9001
- GROUP BY date, source, medium
9002
- )`).groupBy(sql5`source`).all();
9039
+ const aiSourceCurrent = app.db.select({ source: gaAiReferrals.source, sessions: sql5`COALESCE(SUM(${gaAiReferrals.sessions}), 0)` }).from(gaAiReferrals).where(and8(
9040
+ eq19(gaAiReferrals.projectId, project.id),
9041
+ sql5`${gaAiReferrals.date} >= ${daysAgo2(7)}`,
9042
+ sql5`${gaAiReferrals.date} < ${todayStr}`,
9043
+ eq19(gaAiReferrals.sourceDimension, "session")
9044
+ )).groupBy(gaAiReferrals.source).all();
9045
+ const aiSourcePrev = app.db.select({ source: gaAiReferrals.source, sessions: sql5`COALESCE(SUM(${gaAiReferrals.sessions}), 0)` }).from(gaAiReferrals).where(and8(
9046
+ eq19(gaAiReferrals.projectId, project.id),
9047
+ sql5`${gaAiReferrals.date} >= ${daysAgo2(14)}`,
9048
+ sql5`${gaAiReferrals.date} < ${daysAgo2(7)}`,
9049
+ eq19(gaAiReferrals.sourceDimension, "session")
9050
+ )).groupBy(gaAiReferrals.source).all();
9003
9051
  const findBiggestMover = (current, prev) => {
9004
9052
  const prevMap = new Map(prev.map((r) => [r.source, r.sessions]));
9005
9053
  let mover = null;
@@ -9021,6 +9069,7 @@ async function ga4Routes(app, opts) {
9021
9069
  organic: buildTrend(sumOrganic),
9022
9070
  ai: buildTrend(sumAi),
9023
9071
  social: buildTrend(sumSocial),
9072
+ direct: buildTrend(sumDirect),
9024
9073
  aiBiggestMover: findBiggestMover(aiSourceCurrent, aiSourcePrev),
9025
9074
  socialBiggestMover: findBiggestMover(socialSourceCurrent, socialSourcePrev)
9026
9075
  };
@@ -320,6 +320,13 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
320
320
  landingPageNormalized: text("landing_page_normalized"),
321
321
  sessions: integer("sessions").notNull().default(0),
322
322
  organicSessions: integer("organic_sessions").notNull().default(0),
323
+ /**
324
+ * Per-page Direct channel sessions. Nullable so existing rows survive
325
+ * the migration; new GA4 sync writes populate it. Distinct from
326
+ * `sessions - organicSessions` because that residual lumps Direct
327
+ * together with social, referral, paid, and email.
328
+ */
329
+ directSessions: integer("direct_sessions"),
323
330
  users: integer("users").notNull().default(0),
324
331
  syncedAt: text("synced_at").notNull(),
325
332
  syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" })
@@ -1068,7 +1075,13 @@ var MIGRATIONS = [
1068
1075
  // See plans/ai-attribution-research.md "Step 1 — data hygiene".
1069
1076
  `ALTER TABLE ga_traffic_snapshots ADD COLUMN landing_page_normalized TEXT`,
1070
1077
  `CREATE INDEX IF NOT EXISTS idx_ga_traffic_page_normalized
1071
- ON ga_traffic_snapshots(project_id, date, landing_page_normalized)`
1078
+ ON ga_traffic_snapshots(project_id, date, landing_page_normalized)`,
1079
+ // v45: Per-page Direct channel sessions on ga_traffic_snapshots. Nullable
1080
+ // so existing rows survive; populated by the GA4 sync writer in a
1081
+ // separate commit. Unblocks an honest channel breakdown for the project
1082
+ // dashboard (organic / social / direct / known-AI) — see
1083
+ // plans/ai-attribution-research.md scope A.
1084
+ `ALTER TABLE ga_traffic_snapshots ADD COLUMN direct_sessions INTEGER`
1072
1085
  ];
1073
1086
  function isDuplicateColumnError(err) {
1074
1087
  if (!(err instanceof Error)) return false;
@@ -1298,18 +1298,26 @@ var ga4SocialReferralDtoSchema = z12.object({
1298
1298
  var ga4TrafficSummaryDtoSchema = z12.object({
1299
1299
  totalSessions: z12.number(),
1300
1300
  totalOrganicSessions: z12.number(),
1301
+ /** Direct-channel sessions (sessions with no source — bookmarks, typed URLs, AI-driven traffic with stripped referrer). 0 for legacy rows from before the column was added. */
1302
+ totalDirectSessions: z12.number(),
1301
1303
  totalUsers: z12.number(),
1302
1304
  topPages: z12.array(z12.object({
1303
1305
  landingPage: z12.string(),
1304
1306
  sessions: z12.number(),
1305
1307
  organicSessions: z12.number(),
1308
+ /** Per-page Direct-channel sessions. 0 for legacy rows. */
1309
+ directSessions: z12.number(),
1306
1310
  users: z12.number()
1307
1311
  })),
1308
1312
  aiReferrals: z12.array(ga4AiReferralDtoSchema),
1309
- /** Deduped AI session total: MAX(sessions) per date+source+medium across attribution dimensions, then summed. */
1313
+ /** Deduped AI session total: MAX(sessions) per date+source+medium across attribution dimensions, then summed. Cross-cutting: can overlap with Direct/Organic/Social via firstUserSource. */
1310
1314
  aiSessionsDeduped: z12.number(),
1311
1315
  /** Deduped AI user total: MAX(users) per date+source+medium across attribution dimensions, then summed. */
1312
1316
  aiUsersDeduped: z12.number(),
1317
+ /** AI sessions whose CURRENT sessionSource matched an AI engine. Disjoint from Direct/Organic/Social — safe for the channel breakdown. */
1318
+ aiSessionsBySession: z12.number(),
1319
+ /** AI users whose CURRENT sessionSource matched an AI engine. Disjoint from Direct/Organic/Social — safe for the channel breakdown. */
1320
+ aiUsersBySession: z12.number(),
1313
1321
  socialReferrals: z12.array(ga4SocialReferralDtoSchema),
1314
1322
  /** Total social sessions (session-scoped, no cross-dimension dedup needed). */
1315
1323
  socialSessions: z12.number(),
@@ -1317,8 +1325,12 @@ var ga4TrafficSummaryDtoSchema = z12.object({
1317
1325
  socialUsers: z12.number(),
1318
1326
  /** Organic sessions as a percentage of total sessions (0–100, rounded). */
1319
1327
  organicSharePct: z12.number(),
1320
- /** Deduped AI sessions as a percentage of total sessions (0–100, rounded). */
1328
+ /** Deduped AI sessions as a percentage of total sessions (0–100, rounded). Cross-cutting: can overlap with Direct/Organic/Social. */
1321
1329
  aiSharePct: z12.number(),
1330
+ /** Session-source-only AI sessions as a percentage of total sessions (0–100, rounded). Disjoint from Direct/Organic/Social. */
1331
+ aiSharePctBySession: z12.number(),
1332
+ /** Direct-channel sessions as a percentage of total sessions (0–100, rounded). */
1333
+ directSharePct: z12.number(),
1322
1334
  /** Social sessions as a percentage of total sessions (0–100, rounded). */
1323
1335
  socialSharePct: z12.number(),
1324
1336
  lastSyncedAt: z12.string().nullable()
@@ -1841,9 +1853,19 @@ function dropTrailingSlash(path2) {
1841
1853
  }
1842
1854
  function normalizeUrlPath(input) {
1843
1855
  if (input == null) return null;
1844
- const trimmed = input.trim();
1856
+ let trimmed = input.trim();
1845
1857
  if (trimmed === "") return null;
1858
+ trimmed = trimmed.replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
1859
+ if (trimmed === "" || trimmed === "/") return "/";
1846
1860
  if (trimmed === "(not set)") return null;
1861
+ trimmed = trimmed.replace(/([a-zA-Z0-9])([).]+)$/, "$1");
1862
+ if (trimmed.startsWith("/)") || trimmed.startsWith("/ ")) {
1863
+ trimmed = "/";
1864
+ }
1865
+ if (trimmed.includes(" ")) {
1866
+ trimmed = trimmed.split(" ")[0];
1867
+ }
1868
+ if (trimmed === "" || trimmed === "/") return "/";
1847
1869
  let pathPart;
1848
1870
  let queryPart;
1849
1871
  if (/^https?:\/\//i.test(trimmed)) {
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  setGoogleAuthConfig,
18
18
  showFirstRunNotice,
19
19
  trackEvent
20
- } from "./chunk-3T64Y7GR.js";
20
+ } from "./chunk-KANIG6ES.js";
21
21
  import {
22
22
  CcReleaseSyncStatuses,
23
23
  CheckScopes,
@@ -45,7 +45,7 @@ import {
45
45
  saveConfig,
46
46
  saveConfigPatch,
47
47
  usageError
48
- } from "./chunk-QTS7VZXN.js";
48
+ } from "./chunk-OX24LLIH.js";
49
49
  import {
50
50
  apiKeys,
51
51
  competitors,
@@ -56,7 +56,7 @@ import {
56
56
  projects,
57
57
  querySnapshots,
58
58
  runs
59
- } from "./chunk-FV6PY5UE.js";
59
+ } from "./chunk-NEDRCOOL.js";
60
60
  import "./chunk-MLKGABMK.js";
61
61
 
62
62
  // src/cli.ts
@@ -162,7 +162,7 @@ Usage: ${spec.usage}`, {
162
162
  }
163
163
 
164
164
  // src/commands/backfill.ts
165
- import { and, eq, inArray, isNull } from "drizzle-orm";
165
+ import { and, eq, inArray } from "drizzle-orm";
166
166
  var SNAPSHOT_BATCH_SIZE = 500;
167
167
  async function backfillAnswerVisibilityCommand(opts) {
168
168
  const config = loadConfig();
@@ -304,14 +304,15 @@ async function backfillAnswerVisibilityCommand(opts) {
304
304
  console.log(` Errors: ${providerErrors}`);
305
305
  }
306
306
  function backfillNormalizedPaths(db, opts) {
307
- const baseConditions = [isNull(gaTrafficSnapshots.landingPageNormalized)];
307
+ const baseConditions = [];
308
308
  if (opts?.projectId) {
309
309
  baseConditions.push(eq(gaTrafficSnapshots.projectId, opts.projectId));
310
310
  }
311
311
  const rows = db.select({
312
312
  id: gaTrafficSnapshots.id,
313
- landingPage: gaTrafficSnapshots.landingPage
314
- }).from(gaTrafficSnapshots).where(and(...baseConditions)).all();
313
+ landingPage: gaTrafficSnapshots.landingPage,
314
+ landingPageNormalized: gaTrafficSnapshots.landingPageNormalized
315
+ }).from(gaTrafficSnapshots).where(baseConditions.length > 0 ? and(...baseConditions) : void 0).all();
315
316
  let updated = 0;
316
317
  let unchanged = 0;
317
318
  if (rows.length > 0) {
@@ -322,6 +323,10 @@ function backfillNormalizedPaths(db, opts) {
322
323
  unchanged++;
323
324
  continue;
324
325
  }
326
+ if (row.landingPageNormalized === next) {
327
+ unchanged++;
328
+ continue;
329
+ }
325
330
  tx.update(gaTrafficSnapshots).set({ landingPageNormalized: next }).where(eq(gaTrafficSnapshots.id, row.id)).run();
326
331
  updated++;
327
332
  }
@@ -371,7 +376,7 @@ async function backfillNormalizedPathsCommand(opts) {
371
376
  console.log(` Unchanged: ${unchanged}`);
372
377
  }
373
378
  async function backfillInsightsCommand(project, opts) {
374
- const { IntelligenceService } = await import("./intelligence-service-AEI46KC5.js");
379
+ const { IntelligenceService } = await import("./intelligence-service-6S5YKANX.js");
375
380
  const config = loadConfig();
376
381
  const db = createClient(config.database);
377
382
  migrate(db);
@@ -2105,11 +2110,16 @@ async function gaAttribution(project, opts) {
2105
2110
  organicSessions: traffic.totalOrganicSessions,
2106
2111
  aiSessions: traffic.aiSessionsDeduped,
2107
2112
  aiUsers: traffic.aiUsersDeduped,
2113
+ aiSessionsBySession: traffic.aiSessionsBySession,
2114
+ aiUsersBySession: traffic.aiUsersBySession,
2108
2115
  socialSessions: traffic.socialSessions,
2109
2116
  socialUsers: traffic.socialUsers,
2117
+ directSessions: traffic.totalDirectSessions,
2110
2118
  aiSharePct: traffic.aiSharePct,
2119
+ aiSharePctBySession: traffic.aiSharePctBySession,
2111
2120
  socialSharePct: traffic.socialSharePct,
2112
2121
  organicSharePct: traffic.organicSharePct,
2122
+ directSharePct: traffic.directSharePct,
2113
2123
  aiReferrals: traffic.aiReferrals,
2114
2124
  socialReferrals: traffic.socialReferrals,
2115
2125
  trend
@@ -2127,9 +2137,10 @@ async function gaAttribution(project, opts) {
2127
2137
  console.log();
2128
2138
  console.log(" CHANNEL BREAKDOWN 7d trend 30d trend");
2129
2139
  console.log(` Organic Search: ${String(traffic.totalOrganicSessions).padEnd(6)} (${String(traffic.organicSharePct).padStart(2)}%) ${fmtTrend(trend.organic.trend7dPct).padEnd(12)} ${fmtTrend(trend.organic.trend30dPct)}`);
2130
- console.log(` AI Referrals: ${String(traffic.aiSessionsDeduped).padEnd(6)} (${String(traffic.aiSharePct).padStart(2)}%) ${fmtTrend(trend.ai.trend7dPct).padEnd(12)} ${fmtTrend(trend.ai.trend30dPct)}`);
2131
2140
  console.log(` Social: ${String(traffic.socialSessions).padEnd(6)} (${String(traffic.socialSharePct).padStart(2)}%) ${fmtTrend(trend.social.trend7dPct).padEnd(12)} ${fmtTrend(trend.social.trend30dPct)}`);
2132
- const otherSessions2 = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsDeduped - traffic.socialSessions;
2141
+ console.log(` Direct: ${String(traffic.totalDirectSessions).padEnd(6)} (${String(traffic.directSharePct).padStart(2)}%) ${fmtTrend(trend.direct.trend7dPct).padEnd(12)} ${fmtTrend(trend.direct.trend30dPct)}`);
2142
+ console.log(` AI Referrals: ${String(traffic.aiSessionsBySession).padEnd(6)} (${String(traffic.aiSharePctBySession).padStart(2)}%) ${fmtTrend(trend.ai.trend7dPct).padEnd(12)} ${fmtTrend(trend.ai.trend30dPct)} (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2143
+ const otherSessions2 = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsBySession - traffic.socialSessions - traffic.totalDirectSessions;
2133
2144
  if (otherSessions2 > 0) {
2134
2145
  const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions2 / traffic.totalSessions * 100) : 0;
2135
2146
  console.log(` Other: ${String(otherSessions2).padEnd(6)} (${String(otherPct).padStart(2)}%)`);
@@ -2161,11 +2172,16 @@ async function gaAttribution(project, opts) {
2161
2172
  organicSessions: traffic.totalOrganicSessions,
2162
2173
  aiSessions: traffic.aiSessionsDeduped,
2163
2174
  aiUsers: traffic.aiUsersDeduped,
2175
+ aiSessionsBySession: traffic.aiSessionsBySession,
2176
+ aiUsersBySession: traffic.aiUsersBySession,
2164
2177
  socialSessions: traffic.socialSessions,
2165
2178
  socialUsers: traffic.socialUsers,
2179
+ directSessions: traffic.totalDirectSessions,
2166
2180
  aiSharePct: traffic.aiSharePct,
2181
+ aiSharePctBySession: traffic.aiSharePctBySession,
2167
2182
  socialSharePct: traffic.socialSharePct,
2168
2183
  organicSharePct: traffic.organicSharePct,
2184
+ directSharePct: traffic.directSharePct,
2169
2185
  aiReferrals: traffic.aiReferrals,
2170
2186
  socialReferrals: traffic.socialReferrals,
2171
2187
  periodStart: traffic.periodStart,
@@ -2184,9 +2200,10 @@ async function gaAttribution(project, opts) {
2184
2200
  console.log();
2185
2201
  console.log(" CHANNEL BREAKDOWN");
2186
2202
  console.log(` Organic Search: ${traffic.totalOrganicSessions} sessions (${traffic.organicSharePct}%)`);
2187
- console.log(` AI Referrals: ${traffic.aiSessionsDeduped} sessions (${traffic.aiSharePct}%)`);
2188
2203
  console.log(` Social: ${traffic.socialSessions} sessions (${traffic.socialSharePct}%)`);
2189
- const otherSessions = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsDeduped - traffic.socialSessions;
2204
+ console.log(` Direct: ${traffic.totalDirectSessions} sessions (${traffic.directSharePct}%)`);
2205
+ console.log(` AI Referrals: ${traffic.aiSessionsBySession} sessions (${traffic.aiSharePctBySession}%) (lower bound \u2014 sessionSource only; referrer-stripped traffic falls under Direct)`);
2206
+ const otherSessions = traffic.totalSessions - traffic.totalOrganicSessions - traffic.aiSessionsBySession - traffic.socialSessions - traffic.totalDirectSessions;
2190
2207
  if (otherSessions > 0) {
2191
2208
  const otherPct = traffic.totalSessions > 0 ? Math.round(otherSessions / traffic.totalSessions * 100) : 0;
2192
2209
  console.log(` Other: ${otherSessions} sessions (${otherPct}%)`);
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  createServer
3
- } from "./chunk-3T64Y7GR.js";
3
+ } from "./chunk-KANIG6ES.js";
4
4
  import {
5
5
  loadConfig
6
- } from "./chunk-QTS7VZXN.js";
7
- import "./chunk-FV6PY5UE.js";
6
+ } from "./chunk-OX24LLIH.js";
7
+ import "./chunk-NEDRCOOL.js";
8
8
  import "./chunk-MLKGABMK.js";
9
9
  export {
10
10
  createServer,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IntelligenceService
3
- } from "./chunk-FV6PY5UE.js";
3
+ } from "./chunk-NEDRCOOL.js";
4
4
  import "./chunk-MLKGABMK.js";
5
5
  export {
6
6
  IntelligenceService
package/dist/mcp.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  projectUpsertRequestSchema,
11
11
  runTriggerRequestSchema,
12
12
  scheduleUpsertRequestSchema
13
- } from "./chunk-QTS7VZXN.js";
13
+ } from "./chunk-OX24LLIH.js";
14
14
  import "./chunk-MLKGABMK.js";
15
15
 
16
16
  // src/mcp/cli.ts
@@ -580,7 +580,7 @@ var canonryMcpTools = [
580
580
  defineTool({
581
581
  name: "canonry_ga_attribution_trend",
582
582
  title: "Get GA attribution trend",
583
- description: "Get per-channel attribution trends for organic, AI, social, and total sessions.",
583
+ description: "Get per-channel attribution trends for organic, AI, social, direct, and total sessions.",
584
584
  access: "read",
585
585
  tier: "ga",
586
586
  inputSchema: projectInputSchema,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ainyc/canonry",
3
- "version": "2.14.1",
3
+ "version": "2.15.1",
4
4
  "type": "module",
5
5
  "description": "Agent-first open-source AEO operating platform - track how answer engines cite your domain",
6
6
  "license": "FSL-1.1-ALv2",
@@ -60,17 +60,17 @@
60
60
  "tsup": "^8.5.1",
61
61
  "tsx": "^4.19.0",
62
62
  "@ainyc/canonry-config": "0.0.0",
63
- "@ainyc/canonry-api-routes": "0.0.0",
64
- "@ainyc/canonry-contracts": "0.0.0",
65
63
  "@ainyc/canonry-db": "0.0.0",
66
64
  "@ainyc/canonry-integration-bing": "0.0.0",
65
+ "@ainyc/canonry-contracts": "0.0.0",
66
+ "@ainyc/canonry-api-routes": "0.0.0",
67
67
  "@ainyc/canonry-integration-commoncrawl": "0.0.0",
68
68
  "@ainyc/canonry-intelligence": "0.0.0",
69
+ "@ainyc/canonry-integration-wordpress": "0.0.0",
69
70
  "@ainyc/canonry-integration-google": "0.0.0",
70
71
  "@ainyc/canonry-provider-cdp": "0.0.0",
71
72
  "@ainyc/canonry-provider-claude": "0.0.0",
72
73
  "@ainyc/canonry-provider-gemini": "0.0.0",
73
- "@ainyc/canonry-integration-wordpress": "0.0.0",
74
74
  "@ainyc/canonry-provider-local": "0.0.0",
75
75
  "@ainyc/canonry-provider-openai": "0.0.0",
76
76
  "@ainyc/canonry-provider-perplexity": "0.0.0"