agentlinkops 0.5.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.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,925 @@
1
+ // Read-only GSC context and the manual fallback (DP-0017-T02), implemented against the
2
+ // [first-party context contract](../../docs/initiatives/DP-0017-first-party-search-context/first-party-context-contract.md).
3
+ //
4
+ // The contract's rule runs through every function here: Google OBSERVES search behavior, it
5
+ // does not diagnose it, and it never says a placement earned anything. Structured outputs keep
6
+ // three layers separate — what Google reported, the derived rate, and nothing else. Inference
7
+ // and recommendation live in the human/agent-owned files, never in these rows.
8
+ //
9
+ // Ownership follows the DP-0014 ledger split: `gsc.jsonl` and `context-state.json` are
10
+ // tool-owned facts (appended, never rewritten, newest answer wins at read); `manual.md` is
11
+ // human/agent-owned judgment. Absent data follows the verifier's unknown discipline: nulls
12
+ // stay null, a truncated top-rows answer is not an inventory, and an unreadable state is
13
+ // named, never inferred from emptiness.
14
+ //
15
+ // No Google credentials are authorized for this project. Every state below is proven by
16
+ // fixture clients in test/context-gsc.test.js; the HTTP transport at the bottom exists for the
17
+ // day a customer connects their own property and is never exercised by the tests.
18
+ import { rm, readFile } from 'node:fs/promises';
19
+ import { join, resolve } from 'node:path';
20
+ import { readJsonl, appendJsonl, readJson, writeJson, dayStamp, isValidDay as isDateLike, addDays, daysBetween, parseDay, numberOrNullOr } from './util.js';
21
+
22
+ const isValidDay = value => isDateLike(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
23
+
24
+ export const GSC_SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
25
+
26
+ // The contract's state table, plus `ok` for a clean read. A read resolves to exactly one of
27
+ // these; nothing is left to be inferred from an empty result.
28
+ export const CONTEXT_STATES = Object.freeze([
29
+ 'ok', 'manual_only', 'not_authorized', 'revoked', 'property_not_verified', 'permission_restricted',
30
+ 'empty_site', 'incomplete_window', 'truncated_top_rows', 'page_outside_property', 'quota_exceeded',
31
+ 'window_before_available_data',
32
+ ]);
33
+
34
+ export const RETRIEVED_BY = 'linktrail-gsc';
35
+
36
+ export const CONTEXT_LIMITS = Object.freeze({
37
+ // The product envelope, enforced on ourselves so a refresh is cheap by construction.
38
+ refreshCallsPerProperty: 12, // default budget; the standard window set fits under it
39
+ hardCapCallsPerRun: 50, // absolute ceiling across every property in one run
40
+ rowLimitCap: 25_000, // the API's own documented maximum
41
+ defaultRowLimit: 1_000, // the API's documented default
42
+ availabilityProbes: 24, // bounded backwards monthly probe
43
+ availabilityCacheDays: 30, // the floor is re-observed at most monthly
44
+ freshnessMs: 6 * 60 * 60 * 1000, // an identical window re-query inside this window is served from the repo
45
+ quotaRetryHintMinutes: 15, // Google's documented remediation for load quota
46
+ quotaRetries: 1, // one retry after the hint, then stop
47
+ });
48
+
49
+ export const SEARCH_TYPES = Object.freeze(['web', 'image', 'video', 'news', 'googleNews', 'discover']);
50
+ export const AGGREGATION_TYPES = Object.freeze(['byPage', 'byProperty']);
51
+ export const DATA_STATES = Object.freeze(['final', 'all']);
52
+
53
+ export class ContextError extends Error {
54
+ constructor(reason, details = {}) { super(reason); this.name = 'ContextError'; this.reason = reason; this.details = details; }
55
+ }
56
+
57
+ /** True when an HTTP status means the grant is gone (contract: 401/403). */
58
+ export const isAuthorizationLoss = status => status === 401 || status === 403;
59
+
60
+ /** Recorded stamps are always ISO strings: a `now` may yield a Date, and freshness reads strings. */
61
+ const isoOf = value => (value instanceof Date ? value : new Date(value)).toISOString();
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Paths. Standalone on purpose: cli/config.js is not edited by this card. The same
65
+ // `.agentlinkops/config.json` `paths` overrides are honoured, with the contract's defaults.
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const CONTEXT_DEFAULT_FILES = Object.freeze({
69
+ gsc: 'context/gsc.jsonl',
70
+ contextState: 'context/context-state.json',
71
+ siteFacts: 'context/site-facts.jsonl',
72
+ manual: 'context/manual.md',
73
+ siteProfile: 'context/site-profile.md',
74
+ ga4: 'context/ga4.jsonl', // T04's tool-owned GA4 fact rows; same config `paths` override as the rest
75
+ });
76
+
77
+ /**
78
+ * Resolves the context file paths. `dir` is the `.agentlinkops/` directory (from the CLI config);
79
+ * `file` is the parsed config.json when available, because every path is a default the customer
80
+ * may move. The contract is deliberately not opinionated about where files live.
81
+ */
82
+ export function contextPaths({ dir, file = {} } = {}) {
83
+ const paths = { ...file.paths };
84
+ const at = name => resolve(dir, paths[name] ?? CONTEXT_DEFAULT_FILES[name]);
85
+ return {
86
+ gsc: at('gsc'), contextState: at('contextState'), siteFacts: at('siteFacts'),
87
+ manual: at('manual'), siteProfile: at('siteProfile'), ga4: at('ga4'),
88
+ contextDir: join(dir, 'context'),
89
+ };
90
+ }
91
+
92
+ /** Reads `.agentlinkops/config.json` when present, for callers that only have the directory. */
93
+ export async function loadContextConfig(dir) {
94
+ try { return JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')); } catch { return {}; }
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Manual inputs — the floor of the whole card. Every later stage runs on manual.md alone.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ const MANUAL_SECTIONS = Object.freeze({
102
+ 'target pages': 'target_pages', 'targets': 'target_pages', 'pages': 'target_pages',
103
+ 'site description': 'site_description', 'site or niche': 'site_description', 'niche': 'site_description',
104
+ 'what we sell': 'offering', 'offering': 'offering', 'business': 'offering',
105
+ 'known assets': 'assets', 'assets': 'assets',
106
+ 'competitors': 'competitors', 'competitor domains': 'competitors',
107
+ });
108
+
109
+ /**
110
+ * Parses the human-owned manual.md. Permissive by design: sections are matched case-insensitively
111
+ * by heading, entries are the bullet lines under them, and free prose is kept verbatim. Entries
112
+ * carry `source: 'manual'` and no observation date — which is exactly why they never migrate
113
+ * into fact rows.
114
+ */
115
+ export function parseManual(markdown) {
116
+ if (typeof markdown !== 'string' || !markdown.trim()) return { present: false, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] };
117
+ const parsed = { present: true, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] };
118
+ let field = null, prose = [];
119
+ const flushProse = () => {
120
+ if ((field === 'site_description' || field === 'offering') && prose.length) {
121
+ parsed[field] = prose.join(' ').trim();
122
+ parsed.entries.push({ field, value: parsed[field], source: 'manual' });
123
+ }
124
+ prose = [];
125
+ };
126
+ for (const raw of markdown.replace(/^/u, '').split(/\r?\n/u)) {
127
+ const heading = /^#{1,6}\s+(.+?)\s*$/u.exec(raw.trim());
128
+ if (heading) {
129
+ flushProse();
130
+ field = MANUAL_SECTIONS[heading[1].trim().toLowerCase()] ?? null;
131
+ continue;
132
+ }
133
+ const bullet = /^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/u.exec(raw);
134
+ const text = raw.trim();
135
+ if (!text || !field) continue;
136
+ if (Array.isArray(parsed[field])) {
137
+ const value = (bullet ? bullet[1] : text).trim();
138
+ if (!value) continue;
139
+ parsed[field].push(value);
140
+ parsed.entries.push({ field, value, source: 'manual' });
141
+ } else if (field === 'site_description' || field === 'offering') {
142
+ // Prose sections keep their sentences; a bullet under them is still the author's words.
143
+ prose.push((bullet ? bullet[1] : text).trim());
144
+ }
145
+ }
146
+ flushProse();
147
+ return parsed;
148
+ }
149
+
150
+ export async function readManual(path) {
151
+ let text = null;
152
+ try { text = await readFile(path, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
153
+ return text === null ? { present: false, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] } : parseManual(text);
154
+ }
155
+
156
+ /**
157
+ * The complete no-Google context path. Nothing here needs a connection: the pages a campaign
158
+ * should consider are the manual targets, nominated by judgment, with observation left null
159
+ * rather than invented. The three contract layers are carried separately so nothing collapses
160
+ * into "Google says pursue this".
161
+ */
162
+ export function buildManualContext({ manual, pages = null } = {}) {
163
+ const targets = pages ?? manual.target_pages ?? [];
164
+ return {
165
+ state: 'manual_only',
166
+ source: 'manual.md',
167
+ inputs: manual.entries,
168
+ pages: targets.map(page => ({
169
+ page,
170
+ nominated_by: ['manual'],
171
+ observation: null,
172
+ inference: null, // editable judgment, lives in the profile — never here
173
+ recommendation: null, // a DP-0009 brief's job
174
+ })),
175
+ };
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Windows. Comparable deltas only; incomparable comparisons are refused, not printed.
180
+ // ---------------------------------------------------------------------------
181
+
182
+ export function windowFromRange(start, end) {
183
+ if (!isValidDay(start) || !isValidDay(end)) throw new ContextError('invalid_window', { start, end });
184
+ if (parseDay(end) < parseDay(start)) throw new ContextError('window_end_before_start', { start, end });
185
+ return { start: String(start).slice(0, 10), end: String(end).slice(0, 10), days: daysBetween(start, end) };
186
+ }
187
+
188
+ /**
189
+ * The last `count` contiguous windows of `days` each ending the day before `end` inclusive.
190
+ * `end` defaults to yesterday: Google's property-days are incomplete at the boundary.
191
+ */
192
+ export function recentWindows({ days, count = 1, end = null, now = () => new Date() } = {}) {
193
+ if (!Number.isInteger(days) || days < 1 || days > 366) throw new ContextError('invalid_window_length', { days });
194
+ if (end != null && !isValidDay(end)) throw new ContextError('invalid_window', { end });
195
+ const last = end ?? addDays(dayStamp(now()), -1);
196
+ const windows = [];
197
+ for (let index = 0; index < count; index++) {
198
+ const windowEnd = addDays(last, -index * days);
199
+ windows.push(windowFromRange(addDays(windowEnd, -(days - 1)), windowEnd));
200
+ }
201
+ return windows.reverse();
202
+ }
203
+
204
+ const overlaps = (a, b) => parseDay(a.start) <= parseDay(b.end) && parseDay(b.start) <= parseDay(a.end);
205
+
206
+ /**
207
+ * Two windows may be compared only when they are the same length and do not overlap (contiguous
208
+ * or aligned: last-28 vs prior-28, last-7 vs prior-7, the same dates a year apart). Anything
209
+ * else is refused rather than printed with a percent sign — weekly seasonality makes an
210
+ * incomparable delta decoration.
211
+ */
212
+ export function assertComparable(a, b, { purpose = 'comparison' } = {}) {
213
+ for (const window of [a, b]) {
214
+ if (!window || !isValidDay(window.start) || !isValidDay(window.end)) throw new ContextError('invalid_window', { purpose });
215
+ }
216
+ if (windowFromRange(a.start, a.end).days !== windowFromRange(b.start, b.end).days) {
217
+ throw new ContextError('windows_not_comparable', { purpose, reason: 'unequal_length', a: { days: a.days }, b: { days: b.days } });
218
+ }
219
+ if (a.start === b.start && a.end === b.end) throw new ContextError('windows_not_comparable', { purpose, reason: 'same_window' });
220
+ if (overlaps(a, b)) throw new ContextError('windows_not_comparable', { purpose, reason: 'overlapping', a, b });
221
+ return true;
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Query validation. aggregationType and type are always explicit; `auto` is not allowed.
226
+ // ---------------------------------------------------------------------------
227
+
228
+ export function validateSearchQuery({ type = 'web', aggregationType, dimensions = [], filters = [], rowLimit, dataState = 'final' } = {}) {
229
+ if (!SEARCH_TYPES.includes(type)) throw new ContextError('unsupported_type', { type });
230
+ if (!aggregationType || aggregationType === 'auto') throw new ContextError('aggregation_type_required', { aggregationType });
231
+ if (!AGGREGATION_TYPES.includes(aggregationType)) throw new ContextError('unsupported_aggregation_type', { aggregationType });
232
+ if (!DATA_STATES.includes(dataState)) throw new ContextError('unsupported_data_state', { dataState });
233
+ // Google's own constraints, kept next to the rule they constrain.
234
+ if ((type === 'discover' || type === 'googleNews') && aggregationType === 'byProperty') {
235
+ throw new ContextError('byProperty_not_supported_for_type', { type });
236
+ }
237
+ const usesPage = dimensions.includes('page') || filters.some(filter => String(filter?.dimension ?? filter) === 'page');
238
+ if (usesPage && aggregationType === 'byProperty') {
239
+ throw new ContextError('page_grouping_cannot_aggregate_by_property');
240
+ }
241
+ const limit = rowLimit === undefined ? CONTEXT_LIMITS.defaultRowLimit : Math.round(Number(rowLimit));
242
+ if (!Number.isInteger(limit) || limit < 1 || limit > CONTEXT_LIMITS.rowLimitCap) {
243
+ throw new ContextError('row_limit_out_of_range', { rowLimit, max: CONTEXT_LIMITS.rowLimitCap });
244
+ }
245
+ return { type, aggregationType, dimensions: [...dimensions], rowLimit: limit, dataState };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Property coverage — checked, never assumed.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * `sc-domain:example.com` covers the domain and every subdomain; a URL-prefix property covers
254
+ * exactly that prefix. Anything else is outside.
255
+ */
256
+ export function pageWithinProperty(pageUrl, siteUrl) {
257
+ let page, site;
258
+ try { page = new URL(pageUrl); } catch { return false; }
259
+ if (typeof siteUrl !== 'string' || !siteUrl) return false;
260
+ if (siteUrl.toLowerCase().startsWith('sc-domain:')) {
261
+ const domain = siteUrl.slice('sc-domain:'.length).toLowerCase().replace(/^www\./u, '');
262
+ if (!domain) return false;
263
+ const host = page.hostname.toLowerCase();
264
+ return host === domain || host.endsWith(`.${domain}`);
265
+ }
266
+ try { site = new URL(siteUrl); } catch { return false; }
267
+ if (page.hostname !== site.hostname) return false;
268
+ return (page.pathname + page.search).startsWith(site.pathname);
269
+ }
270
+
271
+ /**
272
+ * The state for a target page against the connected properties: `covered`, or
273
+ * `page_outside_property` — never a row of zeros invented for an uncovered page.
274
+ */
275
+ export function classifyPageCoverage(pageUrl, properties) {
276
+ const list = Array.isArray(properties) ? properties : [];
277
+ return list.some(property => pageWithinProperty(pageUrl, typeof property === 'string' ? property : property.siteUrl))
278
+ ? 'covered' : 'page_outside_property';
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // Fact rows. Appended, never rewritten; a changed answer is a new row; newest wins at read.
283
+ // ---------------------------------------------------------------------------
284
+
285
+ export function factKey(row) {
286
+ const window = row.window ?? {};
287
+ return [row.kind, row.property ?? '', row.page ?? '', row.query ?? '', row.type ?? 'web',
288
+ row.aggregationType ?? '', (row.dimensions ?? []).join('+'), row.dataState ?? 'final',
289
+ window.start ?? '', window.end ?? ''].join('|');
290
+ }
291
+
292
+ /** Newest row per key. `fetched_at`/`captured_at` order, file order breaking ties (last wins). */
293
+ export function newestWinningRows(rows) {
294
+ const byKey = new Map();
295
+ for (const row of rows) {
296
+ const key = factKey(row);
297
+ const stamp = row.fetched_at ?? row.captured_at ?? '';
298
+ const held = byKey.get(key);
299
+ if (!held || stamp >= (held.fetched_at ?? held.captured_at ?? '')) byKey.set(key, row);
300
+ }
301
+ return [...byKey.values()];
302
+ }
303
+
304
+ export async function readGscRows(path) { const { rows, problems, missing } = await readJsonl(path); return { rows, problems, missing }; }
305
+ export const appendGscRows = (path, rows) => appendJsonl(path, rows);
306
+
307
+ /**
308
+ * One API answer becomes fact rows. `keys` in the API response map to `page`/`query`/`date`
309
+ * by the dimensions we asked for; metrics are stored raw, rates are computed at read time.
310
+ * A missing metric stays null — zero is an observation, null is the absence of one.
311
+ */
312
+ export function rowsFromResponse({ property, request, response, window, fetchedAt, retrievedBy = RETRIEVED_BY }) {
313
+ const apiRows = Array.isArray(response?.rows) ? response.rows : [];
314
+ const dimensions = request.dimensions;
315
+ const daysReported = dimensions.includes('date')
316
+ ? new Set(apiRows.map(row => row.keys?.[dimensions.indexOf('date')]).filter(Boolean)).size
317
+ : null; // not derivable from this grouping; the aggregate says so instead of backfilling
318
+ const common = {
319
+ property, window, type: request.type, aggregationType: request.aggregationType,
320
+ dimensions, dataState: request.dataState, retrieved_by: retrievedBy, fetched_at: fetchedAt,
321
+ rows_returned: apiRows.length,
322
+ truncated: apiRows.length >= request.rowLimit,
323
+ days_requested: window.days,
324
+ days_reported: daysReported,
325
+ days_reported_basis: dimensions.includes('date') ? 'distinct_dates_in_rows' : 'not_derivable_from_grouping',
326
+ };
327
+ const firstIncomplete = response?.responseMetadata?.firstIncompleteDate ?? null;
328
+ const marker = {
329
+ ...common, kind: 'gsc.window', page: null, query: null,
330
+ incomplete: Boolean(firstIncomplete) || request.dataState === 'all',
331
+ first_incomplete_date: firstIncomplete,
332
+ state: apiRows.length ? 'rows' : 'empty_site',
333
+ };
334
+ const factRows = apiRows.map(row => ({
335
+ ...common, kind: dimensions.includes('page') && dimensions.includes('query') ? 'gsc.page_query'
336
+ : dimensions.includes('page') ? 'gsc.page' : dimensions.includes('query') ? 'gsc.query'
337
+ : dimensions.includes('date') ? 'gsc.date' : 'gsc.property',
338
+ page: dimensions.includes('page') ? row.keys?.[dimensions.indexOf('page')] ?? null : null,
339
+ query: dimensions.includes('query') ? row.keys?.[dimensions.indexOf('query')] ?? null : null,
340
+ date: dimensions.includes('date') ? row.keys?.[dimensions.indexOf('date')] ?? null : null,
341
+ clicks: numberOrNullOr(row.clicks), impressions: numberOrNullOr(row.impressions),
342
+ position: numberOrNullOr(row.position),
343
+ first_incomplete_date: firstIncomplete,
344
+ }));
345
+ return { marker, factRows };
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // The Google client interface + the (unused-by-default) HTTP transport.
350
+ //
351
+ // Everything above this line is tested against injected clients. Everything below makes real
352
+ // HTTP calls and exists for a customer's own connection; no credentials are authorized for this
353
+ // repository, so no test touches it. Tokens come from the customer's environment, are used in
354
+ // one header, and are never logged, returned or written to any file.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ export class GscApiError extends Error {
358
+ constructor(status, body) {
359
+ super(`gsc_http_${status}`);
360
+ this.name = 'GscApiError';
361
+ this.status = status;
362
+ this.body = body ?? null;
363
+ }
364
+ }
365
+
366
+ export function apiPath(siteUrl, verb) {
367
+ return `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/${verb}`;
368
+ }
369
+
370
+ /** A client over the real Search Console API. Read-only scope only; there is no write verb here. */
371
+ export function createGoogleClient({ fetchImpl = globalThis.fetch, origin = 'https://www.googleapis.com', accessToken = null, getAccessToken = null, now = () => new Date() } = {}) {
372
+ const token = () => accessToken ?? (typeof getAccessToken === 'function' ? getAccessToken() : null);
373
+ async function call(path, body = null) {
374
+ const bearer = token();
375
+ if (!bearer) throw new ContextError('no_access_token');
376
+ let response;
377
+ try {
378
+ response = await fetchImpl(`${origin}${path}`, {
379
+ method: body ? 'POST' : 'GET',
380
+ headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
381
+ body: body ? JSON.stringify(body) : undefined,
382
+ });
383
+ } catch (error) {
384
+ if (error instanceof ContextError || error instanceof GscApiError) throw error;
385
+ throw new GscApiError(0, { error: String(error?.message ?? error) });
386
+ }
387
+ if (!response.ok) throw new GscApiError(response.status, await response.json().catch(() => null));
388
+ return response.json();
389
+ }
390
+ // Frozen on purpose: the shipped GSC surface is exactly sites.list + searchanalytics.query —
391
+ // the two read verbs the published API offers that this card uses. No Links verb exists and
392
+ // none may be added quietly; test/context-ga4.test.js asserts the key set.
393
+ return Object.freeze({
394
+ scope: GSC_SCOPE,
395
+ /** sites.list: the properties the customer's account can see, with permission levels. */
396
+ async listSites() {
397
+ const body = await call('/webmasters/v3/sites');
398
+ return (body?.siteEntry ?? []).map(site => ({ siteUrl: site.siteUrl, permissionLevel: site.permissionLevel }));
399
+ },
400
+ /** searchanalytics.query: read-only performance rows for one window and grouping. */
401
+ async searchAnalytics(siteUrl, requestBody) {
402
+ return call(apiPath(siteUrl, 'searchAnalytics/query'), requestBody);
403
+ },
404
+ });
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // context-state.json. Connection state, last fetch per window, availability probes.
409
+ // Booleans, property names, permission levels and dates only — never tokens.
410
+ // ---------------------------------------------------------------------------
411
+
412
+ export async function readContextState(path) { return (await readJson(path)) ?? {}; }
413
+ export const writeContextState = writeJson;
414
+
415
+ export const fetchKey = ({ property, request }) => [
416
+ property, request.type, request.aggregationType, request.dimensions.join('+'), request.dataState,
417
+ request.window.start, request.window.end,
418
+ ].join('|');
419
+
420
+ const freshEnough = (stamp, nowMs, lifetimeMs) => typeof stamp === 'string' && Number.isFinite(Date.parse(stamp)) && nowMs - Date.parse(stamp) < lifetimeMs;
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // Availability probe. Google's retention figure is unpinned, so the floor is OBSERVED per
424
+ // property: bounded backwards monthly probes, cached, reported as a fact about the property.
425
+ // ---------------------------------------------------------------------------
426
+
427
+ function monthWindow(offset, now) {
428
+ const reference = new Date(now());
429
+ const startOfCurrentMonth = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1));
430
+ const start = new Date(startOfCurrentMonth);
431
+ start.setUTCMonth(start.getUTCMonth() - offset);
432
+ const end = new Date(start);
433
+ end.setUTCMonth(end.getUTCMonth() + 1);
434
+ end.setUTCDate(0);
435
+ return windowFromRange(start.toISOString().slice(0, 10), end.toISOString().slice(0, 10));
436
+ }
437
+
438
+ /**
439
+ * Probes backwards one month at a time until a month returns no rows; that month's first day is
440
+ * the observed availability floor. Bounded at 24 probes; a floor found by exhaustion is reported
441
+ * as `probe_exhausted`, which is a fact about the probe, not a retention claim.
442
+ */
443
+ export async function probeFirstAvailableDate({ client, property, state, now = () => new Date(), onCall = null, maxProbes = CONTEXT_LIMITS.availabilityProbes } = {}) {
444
+ const cached = state.availability?.[property];
445
+ if (cached?.first_available_date !== undefined && freshEnough(cached.probed_at, Date.parse(now()), CONTEXT_LIMITS.availabilityCacheDays * 86_400_000)) {
446
+ return { ...cached, cached: true, calls: 0 };
447
+ }
448
+ let probes = 0;
449
+ for (let offset = 0; offset <= CONTEXT_LIMITS.availabilityProbes; offset++) {
450
+ if (probes >= Math.min(CONTEXT_LIMITS.availabilityProbes, maxProbes)) break;
451
+ const window = monthWindow(offset, now);
452
+ probes++;
453
+ onCall?.();
454
+ const request = validateSearchQuery({ type: 'web', aggregationType: 'byProperty', dimensions: [], rowLimit: 1 });
455
+ const response = await client.searchAnalytics(property, {
456
+ startDate: window.start, endDate: window.end, dimensions: [], type: request.type,
457
+ aggregationType: request.aggregationType, rowLimit: request.rowLimit, dataState: 'final',
458
+ });
459
+ if (!Array.isArray(response?.rows) || response.rows.length === 0) {
460
+ // No rows observed in this month: the floor is its first day. Reported as observed, never
461
+ // as Google's retention policy — a quiet month and a gone month are indistinguishable here,
462
+ // which is exactly why the row carries the probe date and the probe count.
463
+ return { property, first_available_date: window.start, probed_at: isoOf(now()), probes_used: probes, basis: 'observed_first_month_without_rows', calls: probes };
464
+ }
465
+ }
466
+ return { property, first_available_date: null, probed_at: isoOf(now()), probes_used: probes, basis: probes < CONTEXT_LIMITS.availabilityProbes ? 'probe_budget_reached' : 'probe_exhausted_data_in_every_probed_month', calls: probes };
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // The bounded refresh.
471
+ // ---------------------------------------------------------------------------
472
+
473
+ const STANDARD_GROUPINGS = Object.freeze([
474
+ { dimensions: ['page'], aggregationType: 'byPage' },
475
+ { dimensions: ['page', 'query'], aggregationType: 'byPage' },
476
+ ]);
477
+
478
+ /**
479
+ * Runs the read-only refresh. Cheap by construction: the standard set is three comparable
480
+ * windows x (page, page+query) per property, under the 12-call default budget and the 50-call
481
+ * hard cap; identical re-queries inside the freshness lifetime are served from the repo; a
482
+ * quota refusal becomes `quota_exceeded` with the 15-minute hint, one retry, then stop.
483
+ *
484
+ * Every state in the contract's table is produced here and nothing is inferred from emptiness:
485
+ * authorization loss mid-run keeps the rows already written and labels the run `revoked`;
486
+ * restricted or unverified properties are surfaced and skipped; an authorized property with no
487
+ * rows gets a window marker, not an error and not invented zeros.
488
+ */
489
+ export async function refreshContext({
490
+ client = null, paths, properties: wantedProperties = null,
491
+ windows: wantedWindows = null, dataState = 'final', now = () => new Date(), sleep = () => Promise.resolve(),
492
+ freshnessMs = CONTEXT_LIMITS.freshnessMs, limits = {},
493
+ }) {
494
+ const cap = { ...CONTEXT_LIMITS, ...limits };
495
+ for (const key of ['refreshCallsPerProperty', 'hardCapCallsPerRun']) {
496
+ if (!Number.isInteger(cap[key]) || cap[key] < 0) throw new ContextError('invalid_call_budget', { key });
497
+ cap[key] = Math.min(cap[key], CONTEXT_LIMITS[key]);
498
+ }
499
+ for (const window of wantedWindows ?? []) windowFromRange(window.start, window.end);
500
+ const state = await readContextState(paths.contextState);
501
+ const existing = await readGscRows(paths.gsc);
502
+ const nowMs = Date.parse(now());
503
+ const fetchedAt = isoOf(now());
504
+ const output = {
505
+ started_at: fetchedAt, state: 'ok', properties: [], rows_written: 0, markers_written: 0,
506
+ calls: { search_analytics: 0, list_sites: 0, budget_per_property: cap.refreshCallsPerProperty, hard_cap_per_run: cap.hardCapCallsPerRun },
507
+ served_from_repo: 0, states: [], quota: null, notes: [],
508
+ };
509
+
510
+ if (!client) {
511
+ // No connection configured. This is a valid complete configuration, not a failure: manual
512
+ // inputs are the context and every later stage runs on them.
513
+ state.connection = { ...(state.connection ?? {}), grant: 'none', checked_at: fetchedAt };
514
+ await writeContextState(paths.contextState, state);
515
+ output.state = 'manual_only';
516
+ output.notes.push('no Google connection configured; manual.md is the context and handoff rows (if any) are unaffected');
517
+ return output;
518
+ }
519
+
520
+ let sites;
521
+ try {
522
+ output.calls.list_sites++;
523
+ sites = await client.listSites();
524
+ } catch (error) {
525
+ if (error instanceof GscApiError && isAuthorizationLoss(error.status)) {
526
+ state.connection = { ...(state.connection ?? {}), grant: 'refused', last_status: error.status, checked_at: fetchedAt };
527
+ await writeContextState(paths.contextState, state);
528
+ output.state = 'not_authorized';
529
+ return output;
530
+ }
531
+ throw error;
532
+ }
533
+
534
+ const requested = [...new Set((wantedProperties ?? state.connection?.properties ?? []).map(String))];
535
+ const known = new Map(sites.map(site => [site.siteUrl, site]));
536
+ for (const siteUrl of requested) {
537
+ const site = known.get(siteUrl);
538
+ if (!site) { output.properties.push({ siteUrl, state: 'not_authorized' }); output.states.push('not_authorized'); continue; }
539
+ if (site.permissionLevel === 'siteUnverifiedUser' || site.permissionLevel === 'siteRestrictedUser') {
540
+ // Surfaced to the customer, never silently used.
541
+ const label = site.permissionLevel === 'siteUnverifiedUser' ? 'property_not_verified' : 'permission_restricted';
542
+ output.properties.push({ siteUrl, permissionLevel: site.permissionLevel, state: label, rows_written: 0 });
543
+ output.states.push(label);
544
+ continue;
545
+ }
546
+ await refreshProperty({ siteUrl, site, state, existing, output, paths, client, wantedWindows, dataState, now, nowMs, fetchedAt, sleep, cap, freshnessMs });
547
+ }
548
+
549
+ if (!output.properties.length) {
550
+ output.notes.push('no properties requested; pass --property or record properties in context-state.json');
551
+ }
552
+ // A mid-run revocation already wrote `grant: 'revoked'` inside refreshProperty; writing 'ok'
553
+ // over it would relabel a refusal as a success after the fact. Quota stoppage is not a grant
554
+ // state — authorization held, only the envelope ran out.
555
+ const grant = output.state === 'revoked' ? 'revoked' : 'ok';
556
+ state.connection = { ...(state.connection ?? {}), grant, checked_at: fetchedAt, properties: requested, permission_levels: Object.fromEntries(requested.filter(u => known.has(u)).map(u => [u, known.get(u).permissionLevel])) };
557
+ await writeContextState(paths.contextState, state);
558
+ return output;
559
+ }
560
+
561
+ async function refreshProperty({ siteUrl, state, existing, output, paths, client, wantedWindows, dataState, now, nowMs, fetchedAt, sleep, cap, freshnessMs }) {
562
+ const record = { siteUrl, permissionLevel: null, state: 'ok', rows_written: 0, windows: [], calls: 0 };
563
+ output.properties.push(record);
564
+
565
+ const windows = wantedWindows ?? recentWindows({ days: 28, count: 2, now }).concat(recentWindows({ days: 7, count: 2, now }));
566
+ const remaining = () => Math.max(0, Math.min(cap.refreshCallsPerProperty - record.calls, cap.hardCapCallsPerRun - output.calls.search_analytics));
567
+ // Availability is optional context. Reserve report calls so long histories cannot spend
568
+ // the entire refresh on probing, repeatedly starving actual page/query reads.
569
+ const reportReserve = Math.min(remaining(), new Set(windows.map(w => `${w.start}|${w.end}`)).size * STANDARD_GROUPINGS.length);
570
+ const available = await probeFirstAvailableDate({
571
+ client, property: siteUrl, state, now, maxProbes: remaining() - reportReserve,
572
+ onCall: () => { record.calls++; output.calls.search_analytics++; },
573
+ });
574
+ state.availability = { ...(state.availability ?? {}), [siteUrl]: { first_available_date: available.first_available_date, probed_at: available.probed_at, probes_used: available.probes_used, basis: available.basis } };
575
+ record.availability = { ...available };
576
+ delete record.availability.calls;
577
+
578
+ if (available.basis === 'probe_budget_reached') output.notes.push(`budget reached while probing availability for ${siteUrl}; availability floor remains unknown`);
579
+ const seenWindows = new Set();
580
+ for (const window of windows) {
581
+ if (seenWindows.has(window.start + window.end)) continue;
582
+ seenWindows.add(window.start + window.end);
583
+ if (available.first_available_date && window.end < available.first_available_date) {
584
+ record.windows.push({ window, state: 'window_before_available_data' });
585
+ output.states.push('window_before_available_data');
586
+ continue;
587
+ }
588
+ for (const grouping of STANDARD_GROUPINGS) {
589
+ if (record.calls >= cap.refreshCallsPerProperty || output.calls.search_analytics >= cap.hardCapCallsPerRun) {
590
+ record.windows.push({ window, grouping: grouping.dimensions, state: 'budget_reached', budget: { per_property: cap.refreshCallsPerProperty, hard_cap: cap.hardCapCallsPerRun } });
591
+ output.notes.push(`budget reached before ${window.start}..${window.end} ${grouping.dimensions.join('+')}`);
592
+ continue;
593
+ }
594
+ const request = { ...validateSearchQuery({ type: 'web', aggregationType: grouping.aggregationType, dimensions: grouping.dimensions, dataState }), window };
595
+ const key = fetchKey({ property: siteUrl, request });
596
+ const lastFetch = state.last_fetch?.[key];
597
+ if (freshEnough(lastFetch, nowMs, freshnessMs) && dataState === request.dataState) {
598
+ // Identical re-query, served from the repo. Idempotence by window key.
599
+ output.served_from_repo++;
600
+ record.windows.push({ window, grouping: grouping.dimensions, state: 'served_from_repo' });
601
+ continue;
602
+ }
603
+ let response, attempt = 0;
604
+ for (;;) {
605
+ if (remaining() === 0) {
606
+ record.windows.push({ window, grouping: grouping.dimensions, state: 'budget_reached' });
607
+ record.state = 'budget_reached';
608
+ output.notes.push(`budget reached before retry for ${siteUrl}`);
609
+ return;
610
+ }
611
+ try {
612
+ record.calls++; output.calls.search_analytics++;
613
+ response = await client.searchAnalytics(siteUrl, {
614
+ startDate: window.start, endDate: window.end, dimensions: request.dimensions,
615
+ type: request.type, aggregationType: request.aggregationType,
616
+ rowLimit: request.rowLimit, dataState: request.dataState,
617
+ });
618
+ break;
619
+ } catch (error) {
620
+ if (error instanceof GscApiError && error.status === 429) {
621
+ output.quota = { hint_minutes: cap.quotaRetryHintMinutes, retries_used: attempt };
622
+ if (attempt >= cap.quotaRetries || remaining() === 0) {
623
+ // One retry after the hint, then stop. A context refresh is never so urgent it
624
+ // spends the customer's per-site 1,200 queries per minute.
625
+ output.state = output.state === 'ok' ? 'quota_exceeded' : output.state;
626
+ output.states.push('quota_exceeded');
627
+ record.state = record.state === 'ok' ? 'quota_exceeded' : record.state;
628
+ record.windows.push({ window, grouping: grouping.dimensions, state: 'quota_exceeded', hint_minutes: cap.quotaRetryHintMinutes });
629
+ await writeContextState(paths.contextState, state);
630
+ return;
631
+ }
632
+ attempt++;
633
+ await sleep(cap.quotaRetryHintMinutes * 60_000);
634
+ continue;
635
+ }
636
+ if (error instanceof GscApiError && isAuthorizationLoss(error.status)) {
637
+ // Revocation mid-run: the rows already appended are the customer's own data and are
638
+ // retained; the run is labeled, not rolled back.
639
+ state.connection = { ...(state.connection ?? {}), grant: 'revoked', last_status: error.status, checked_at: fetchedAt };
640
+ await writeContextState(paths.contextState, state);
641
+ output.state = 'revoked';
642
+ output.states.push('revoked');
643
+ record.state = 'revoked';
644
+ record.windows.push({ window, grouping: grouping.dimensions, state: 'revoked', partial: true });
645
+ return;
646
+ }
647
+ throw error;
648
+ }
649
+ }
650
+ const { marker, factRows } = rowsFromResponse({ property: siteUrl, request, response, window, fetchedAt });
651
+ await appendGscRows(paths.gsc, [...factRows, marker]);
652
+ record.rows_written += factRows.length; record.markers_written = (record.markers_written ?? 0) + 1;
653
+ output.rows_written += factRows.length; output.markers_written++;
654
+ state.last_fetch = { ...(state.last_fetch ?? {}), [key]: fetchedAt };
655
+ const windowState = marker.state === 'empty_site' ? 'empty_site'
656
+ : marker.truncated ? 'truncated_top_rows'
657
+ : marker.incomplete ? 'incomplete_window' : 'ok';
658
+ record.windows.push({ window, grouping: grouping.dimensions, state: windowState, rows_returned: marker.rows_returned, truncated: marker.truncated, first_incomplete_date: marker.first_incomplete_date });
659
+ output.states.push(windowState);
660
+ }
661
+ }
662
+ record.state = record.state === 'ok' && record.windows.every(w => ['ok', 'served_from_repo', 'empty_site'].includes(w.state)) ? 'ok' : record.state;
663
+ }
664
+
665
+ // ---------------------------------------------------------------------------
666
+ // Read-time outputs. Counts raw, rates derived, position always labeled an average.
667
+ // ---------------------------------------------------------------------------
668
+
669
+ // A rate is derived at read time, never stored. Absent impressions make the rate null: zero is
670
+ // an observation, null is the absence of one.
671
+ const ctrOf = (clicks, impressions) => {
672
+ if (impressions === null || impressions === undefined || clicks === null || clicks === undefined) return null;
673
+ if (impressions === 0) return 0;
674
+ return Math.round((clicks / impressions) * 10_000) / 10_000;
675
+ };
676
+
677
+ /**
678
+ * Page/query context for one page and window, from the repo's own rows. Resolves to exactly one
679
+ * state. `final_through` names the last final day when preliminary data is in view; final and
680
+ * preliminary are never blended into one number.
681
+ */
682
+ export function pageContext({ rows, page, window: wanted = null, properties = null, connection = null, manual = null } = {}) {
683
+ const latest = newestWinningRows(rows ?? []);
684
+ if (properties !== null && properties.length && classifyPageCoverage(page, properties) === 'page_outside_property') {
685
+ return { state: 'page_outside_property', page, properties, rows_returned: 0, queries: [], page_totals: null, notes: ['the page is not under any connected property; no rows are invented for it'] };
686
+ }
687
+ const windowRows = latest.filter(row => row.kind?.startsWith('gsc.') && row.page === page
688
+ && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
689
+ if (!windowRows.length) {
690
+ // No row for this page and window. Why is named, not inferred: an empty_site marker, a
691
+ // pre-floor window, or no connection at all.
692
+ const marker = latest.find(row => row.kind === 'gsc.window' && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
693
+ if (marker?.state === 'empty_site') return { state: 'empty_site', page, window: wanted, rows_returned: 0, queries: [], page_totals: null, notes: ['authorized, property covered, zero rows in this window — a valid state, not an error'] };
694
+ if (wanted && marker && marker.first_incomplete_date && marker.first_incomplete_date <= wanted.end) {
695
+ return { state: 'incomplete_window', page, window: wanted, rows_returned: 0, queries: [], page_totals: null, first_incomplete_date: marker.first_incomplete_date };
696
+ }
697
+ if (connection?.grant === 'revoked' || connection?.grant === 'refused') {
698
+ return { state: connection.grant === 'revoked' ? 'revoked' : 'not_authorized', page, rows_returned: 0, queries: [], page_totals: null, notes: ['existing rows retained; no new rows are written in this state'] };
699
+ }
700
+ return { state: connection?.grant === 'none' || !connection ? 'manual_only' : 'ok', page, rows_returned: 0, queries: [], page_totals: null,
701
+ notes: connection?.grant === 'none' || !connection
702
+ ? ['no Google connection configured; manual inputs are the context' + (manual?.present ? '' : ' and manual.md is absent')]
703
+ : ['the page is not in the returned top rows for this window — which is not the claim "no impressions"'] };
704
+ }
705
+ const pageRow = windowRows.find(row => row.kind === 'gsc.page') ?? null;
706
+ const queryRows = windowRows.filter(row => row.kind === 'gsc.page_query').sort((a, b) => (b.clicks ?? 0) - (a.clicks ?? 0));
707
+ const truncated = Boolean(pageRow?.truncated) || queryRows.some(row => row.truncated);
708
+ const incomplete = windowRows.some(row => row.first_incomplete_date || row.dataState === 'all');
709
+ const firstIncomplete = windowRows.map(row => row.first_incomplete_date).filter(Boolean).sort()[0] ?? null;
710
+ const state = truncated ? 'truncated_top_rows' : incomplete ? 'incomplete_window' : 'ok';
711
+ return {
712
+ state, page,
713
+ window: pageRow?.window ?? queryRows[0]?.window ?? wanted ?? null,
714
+ type: pageRow?.type ?? 'web', aggregationType: pageRow?.aggregationType ?? 'byPage',
715
+ dataState: pageRow?.dataState ?? 'final',
716
+ final_through: firstIncomplete ? addDays(firstIncomplete, -1) : (pageRow?.window?.end ?? null),
717
+ rows_returned: 1 + queryRows.length, truncated,
718
+ first_incomplete_date: firstIncomplete,
719
+ days_requested: pageRow?.days_requested ?? null,
720
+ days_reported: pageRow?.days_reported ?? null,
721
+ days_reported_basis: pageRow?.days_reported_basis ?? null,
722
+ page_totals: pageRow ? {
723
+ clicks: pageRow.clicks, impressions: pageRow.impressions,
724
+ ctr: ctrOf(pageRow.clicks, pageRow.impressions),
725
+ average_position: pageRow.position, // the API defines position as an AVERAGE position
726
+ } : null,
727
+ queries: queryRows.map(row => ({
728
+ query: row.query, clicks: row.clicks, impressions: row.impressions,
729
+ ctr: ctrOf(row.clicks, row.impressions), average_position: row.position,
730
+ })),
731
+ notes: truncated ? ['top rows only: Google does not guarantee all data rows; a page absent here is "not in the returned top rows", not "no impressions"'] : [],
732
+ };
733
+ }
734
+
735
+ /**
736
+ * Site totals for one window, from `byProperty` rows. Returned rows are never summed into a
737
+ * "site total" when truncation is set — that number would be a floor wearing a total's clothes.
738
+ */
739
+ export function siteTotalsContext({ rows, window: wanted, property = null, connection = null } = {}) {
740
+ const latest = newestWinningRows(rows ?? []).filter(row => row.kind === 'gsc.property'
741
+ && (!property || row.property === property)
742
+ && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
743
+ if (!latest.length) {
744
+ const marker = newestWinningRows(rows ?? []).find(row => row.kind === 'gsc.window' && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
745
+ if (marker?.state === 'empty_site') return { state: 'empty_site', window: wanted, totals: null };
746
+ if (connection?.grant === 'none' || !connection) return { state: 'manual_only', window: wanted, totals: null };
747
+ return { state: 'ok', window: wanted, totals: null, notes: ['no byProperty rows for this window'] };
748
+ }
749
+ const row = latest[0];
750
+ if (row.truncated) return { state: 'truncated_top_rows', window: row.window, totals: null, rows_returned: row.rows_returned, notes: ['returned rows hit the cap; a sum of them would be presented as a site total it is not'] };
751
+ return {
752
+ state: row.first_incomplete_date || row.dataState === 'all' ? 'incomplete_window' : 'ok',
753
+ window: row.window, property: row.property, type: row.type, aggregationType: row.aggregationType, dataState: row.dataState,
754
+ totals: { clicks: row.clicks, impressions: row.impressions, ctr: ctrOf(row.clicks, row.impressions), average_position: row.position },
755
+ days_requested: row.days_requested, days_reported: row.days_reported, days_reported_basis: row.days_reported_basis,
756
+ };
757
+ }
758
+
759
+ /** A delta between two comparable windows. Refuses unequal or overlapping windows, and refuses to mix aggregationTypes. */
760
+ export function windowComparison({ rows, windowA, windowB, property = null, page = null } = {}) {
761
+ assertComparable(windowA, windowB);
762
+ const latest = newestWinningRows(rows ?? []);
763
+ const pick = window => {
764
+ const kind = page ? 'gsc.page' : 'gsc.property';
765
+ return latest.find(row => row.kind === kind && (!property || row.property === property)
766
+ && (!page || row.page === page)
767
+ && row.window?.start === window.start && row.window?.end === window.end) ?? null;
768
+ };
769
+ const a = pick(windowA), b = pick(windowB);
770
+ if (!a || !b) return { state: 'ok', windows: { a: windowA, b: windowB }, delta: null, missing: [!a && 'a', !b && 'b'].filter(Boolean), notes: ['a window with no rows cannot be delta-ed; absence is reported, never treated as zero'] };
771
+ if (a.aggregationType !== b.aggregationType) throw new ContextError('aggregation_types_mixed', { a: a.aggregationType, b: b.aggregationType });
772
+ const pct = (now, before) => before === null || before === undefined || before === 0 ? null : Math.round(((now - before) / before) * 10_000) / 10_000;
773
+ return {
774
+ state: 'ok', windows: { a: windowA, b: windowB }, property: a.property, page: a.page,
775
+ aggregationType: a.aggregationType,
776
+ delta: {
777
+ clicks: (a.clicks ?? 0) - (b.clicks ?? 0), clicks_change: pct(a.clicks, b.clicks),
778
+ impressions: (a.impressions ?? 0) - (b.impressions ?? 0), impressions_change: pct(a.impressions, b.impressions),
779
+ ctr: ctrOf(a.clicks, a.impressions) === null || ctrOf(b.clicks, b.impressions) === null ? null : Math.round((ctrOf(a.clicks, a.impressions) - ctrOf(b.clicks, b.impressions)) * 10_000) / 10_000,
780
+ average_position: a.position === null || b.position === null ? null : Math.round((a.position - b.position) * 100) / 100,
781
+ },
782
+ truncated: Boolean(a.truncated || b.truncated),
783
+ notes: a.truncated || b.truncated ? ['a contributing window hit the row cap; deltas over truncated top rows are floor-to-floor'] : [],
784
+ };
785
+ }
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Focus-page selection. Manual works alone; GSC nominates, it never prescribes.
789
+ // ---------------------------------------------------------------------------
790
+
791
+ /**
792
+ * Nominates pages worth a campaign's attention. Manual targets come first (judgment); observed
793
+ * pages follow by clicks then impressions (observation). The three contract layers are carried
794
+ * separately and inference/recommendation are null here on purpose: "high impressions, low
795
+ * clicks" is an observation, "title mismatch" is an editable inference, "pursue resource-page
796
+ * links" is a DP-0009 brief's job.
797
+ */
798
+ export function selectFocusPages({ manual, rows = [], window: wanted = null, limit = 10 } = {}) {
799
+ const chosen = [];
800
+ const seen = new Set();
801
+ const latest = rows.length
802
+ ? newestWinningRows(rows).filter(row => row.kind === 'gsc.page' && row.page
803
+ && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)))
804
+ : [];
805
+ const byPage = new Map(latest.map(row => [row.page, row]));
806
+ const observationOf = row => row ? { window: row.window, clicks: row.clicks, impressions: row.impressions, ctr: ctrOf(row.clicks, row.impressions), average_position: row.position, truncated: Boolean(row.truncated) } : null;
807
+ // Judgment first: a manual target is nominated by judgment, and an observation for it is
808
+ // ATTACHED (a second layer) rather than replacing the nomination — the two never collapse.
809
+ for (const page of manual?.target_pages ?? []) {
810
+ if (seen.has(page) || chosen.length >= limit) continue;
811
+ seen.add(page);
812
+ chosen.push({ page, nominated_by: ['manual'], observation: observationOf(byPage.get(page)), inference: null, recommendation: null });
813
+ }
814
+ const ranked = [...latest].sort((a, b) => (b.clicks ?? 0) - (a.clicks ?? 0) || (b.impressions ?? 0) - (a.impressions ?? 0));
815
+ for (const row of ranked) {
816
+ if (seen.has(row.page) || chosen.length >= limit) continue;
817
+ seen.add(row.page);
818
+ chosen.push({ page: row.page, nominated_by: ['gsc_clicks'], observation: observationOf(row), inference: null, recommendation: null });
819
+ }
820
+ return { pages: chosen, basis: { manual: (manual?.target_pages ?? []).length, gsc: rows.length ? 'rows_present' : 'no_rows' } };
821
+ }
822
+
823
+ // ---------------------------------------------------------------------------
824
+ // Existing-agent context import (the handoff). A connector's rows are never re-labeled.
825
+ // ---------------------------------------------------------------------------
826
+
827
+ function parseHandoffText(text) {
828
+ const trimmed = String(text ?? '').trim();
829
+ if (!trimmed) return { rows: [], error: 'empty file' };
830
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
831
+ try {
832
+ const parsed = JSON.parse(trimmed);
833
+ return { rows: Array.isArray(parsed) ? parsed : [parsed] };
834
+ } catch {
835
+ // Not one JSON document; try JSONL before giving up.
836
+ const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean);
837
+ const rows = [];
838
+ for (const line of lines) {
839
+ try { rows.push(JSON.parse(line)); } catch { return { rows: [], error: 'not_json' }; }
840
+ }
841
+ return { rows };
842
+ }
843
+ }
844
+ // A Markdown snapshot: a pipe table whose header names the row fields.
845
+ const lines = trimmed.split(/\r?\n/u).filter(line => line.trim());
846
+ const divider = /^\s*\|?[\s:|-]+\|?\s*$/u;
847
+ if (!lines.some(line => line.includes('|'))) return { rows: [], error: 'unrecognized_handoff_format' };
848
+ const headerIndex = lines.findIndex(line => line.includes('|') && !divider.test(line));
849
+ if (headerIndex < 0 || headerIndex + 1 >= lines.length || !divider.test(lines[headerIndex + 1])) return { rows: [], error: 'not_a_markdown_table' };
850
+ const cells = line => line.replace(/^\s*\|/u, '').replace(/\|\s*$/u, '').split('|').map(cell => cell.trim());
851
+ const header = cells(lines[headerIndex]);
852
+ const rows = [];
853
+ for (const line of lines.slice(headerIndex + 1).filter(line => line.includes('|'))) {
854
+ if (divider.test(line)) continue;
855
+ const values = cells(line);
856
+ rows.push(Object.fromEntries(header.map((name, index) => [name, values[index] ?? ''])));
857
+ }
858
+ return { rows };
859
+ }
860
+
861
+ /**
862
+ * Imports a connector snapshot into `gsc.jsonl`. Inherited from the DP-0014 import doctrine:
863
+ * the connector is named in `retrieved_by` and never re-labeled as fetched by us; `captured_at`
864
+ * stays the connector's date, separate from any later fetch of ours; rows missing `window` or
865
+ * `aggregationType` are refused per file with what is missing; imports never overwrite rows we
866
+ * fetched. A handoff with no Google grant from us at all is a complete configuration.
867
+ */
868
+ export async function importHandoff({ paths, text = null, file = null, now = () => new Date() } = {}) {
869
+ const raw = text ?? (await readFile(file, 'utf8'));
870
+ const parsed = parseHandoffText(raw);
871
+ if (parsed.error) return { error: parsed.error, accepted: 0, refused: [{ reason: parsed.error }] };
872
+ const existing = (await readGscRows(paths.gsc)).rows;
873
+ const ours = new Set(newestWinningRows(existing).filter(row => row.retrieved_by === RETRIEVED_BY).map(factKey));
874
+ const accepted = [], refused = [];
875
+ parsed.rows.forEach((candidate, index) => {
876
+ // A Markdown table row is flat: `window.start`/`window.end` columns become the nested window.
877
+ if ((!candidate.window || typeof candidate.window !== 'object') && candidate['window.start'] && candidate['window.end']) {
878
+ candidate.window = { start: candidate['window.start'], end: candidate['window.end'] };
879
+ }
880
+ const missing = [];
881
+ if (!candidate.window || !isValidDay(candidate.window.start) || !isValidDay(candidate.window.end)) missing.push('window');
882
+ if (!candidate.aggregationType) missing.push('aggregationType');
883
+ if (!candidate.retrieved_by || candidate.retrieved_by === RETRIEVED_BY) missing.push('retrieved_by (the connector must be named, and never us)');
884
+ if (!candidate.property) missing.push('property');
885
+ if (missing.length) { refused.push({ row: index + 1, reason: `missing ${missing.join(' and ')}` }); return; }
886
+ const row = {
887
+ ...candidate,
888
+ window: { start: candidate.window.start.slice(0, 10), end: candidate.window.end.slice(0, 10), days: candidate.window.days ?? daysBetween(candidate.window.start, candidate.window.end) },
889
+ captured_at: candidate.captured_at ?? null, // the connector's date, never fabricated
890
+ // No fetched_at: nothing here was fetched by us, and the label must not drift.
891
+ };
892
+ if (ours.has(factKey(row))) { refused.push({ row: index + 1, reason: 'a row we fetched for this key exists; ours is kept' }); return; }
893
+ accepted.push(row);
894
+ });
895
+ await appendGscRows(paths.gsc, accepted);
896
+ const state = await readContextState(paths.contextState);
897
+ const connectors = [...new Set([...(state.handoff?.connectors ?? []), ...accepted.map(row => row.retrieved_by)])];
898
+ state.handoff = { ...(state.handoff ?? {}), connectors, last_import_at: now(), rows_imported: (state.handoff?.rows_imported ?? 0) + accepted.length };
899
+ await writeContextState(paths.contextState, state);
900
+ return { accepted: accepted.length, refused, connectors };
901
+ }
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // Forget. Deletion is a decision; the human-owned files are never its collateral.
905
+ // ---------------------------------------------------------------------------
906
+
907
+ /**
908
+ * Removes tool-owned context files and leaves the human-owned `manual.md` and `site-profile.md`
909
+ * untouched. `--source gsc` (the default) removes the search facts and the connection state the
910
+ // tool wrote; `--source site` removes the site facts; `--source all` removes all three.
911
+ */
912
+ export async function forgetContext({ paths, source = 'gsc' } = {}) {
913
+ const toolOwned = { gsc: ['gsc', 'contextState'], site: ['siteFacts'], all: ['gsc', 'contextState', 'siteFacts'] }[source];
914
+ if (!toolOwned) throw new ContextError('unknown_forget_source', { source });
915
+ const removed = [], kept = [];
916
+ for (const name of ['gsc', 'contextState', 'siteFacts', 'manual', 'siteProfile']) {
917
+ const path = paths[name];
918
+ if (name === 'manual' || name === 'siteProfile') { kept.push({ name, path, owner: 'human/agent' }); continue; }
919
+ if (toolOwned.includes(name)) {
920
+ await rm(path, { force: true });
921
+ removed.push({ name, path });
922
+ } else kept.push({ name, path, owner: 'tool' });
923
+ }
924
+ return { removed, kept, note: 'disconnecting Google stops new rows and retains old ones; rows already fetched are the customer’s own data in their own repo' };
925
+ }