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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Bounded public site and asset profiles (DP-0017-T03), implemented against the
|
|
2
|
+
// [first-party context contract](../../docs/initiatives/DP-0017-first-party-search-context/first-party-context-contract.md).
|
|
3
|
+
//
|
|
4
|
+
// The profile describes audience, assets and campaign targets from PUBLIC fetches of the
|
|
5
|
+
// customer's own site only — no CMS, no credentials, no rendered browser. It inherits every
|
|
6
|
+
// existing network and robots limit rather than growing new ones:
|
|
7
|
+
//
|
|
8
|
+
// - Sitemap parsing and limits are `src/corpus/seeds.js` UNCHANGED (10 MiB per document,
|
|
9
|
+
// 50,000 URLs per document, 100 directives, index depth 2, cross-submit scope, gzip
|
|
10
|
+
// detection, and the parser's skipped/truncated/failure counters, preserved verbatim).
|
|
11
|
+
// - Page fetching is the verifier's public fetch boundary through `src/corpus/acquire.js`:
|
|
12
|
+
// `publicFetchSafe` transport assertion, robots per hop, 20-second budget, 2 MiB body,
|
|
13
|
+
// 5 redirects, plus the CLI courtesy pacing (2 s floor per host, concurrency 6).
|
|
14
|
+
//
|
|
15
|
+
// One deliberate divergence from the contract's prose, recorded rather than hidden: the
|
|
16
|
+
// contract describes the robots posture as "a 401/403 or oversized robots.txt is a refusal".
|
|
17
|
+
// Commit 0474a43 (2026-09-11, "Treat an unavailable robots.txt as no restrictions, per RFC
|
|
18
|
+
// 9309") changed the shipped verifier to the RFC reading AFTER the DP-0014 measurement the
|
|
19
|
+
// contract cites. Inheriting means inheriting what ships: page fetches follow the verifier's
|
|
20
|
+
// current behavior whatever it is, and the robots fact row records `unavailable_<status>` so
|
|
21
|
+
// the divergence stays visible in the facts. The SITEMAP lane is stricter on our own account:
|
|
22
|
+
// when robots.txt cannot be read, no sitemap directives are guessed and the lane is refused —
|
|
23
|
+
// an unknown, never "no assets".
|
|
24
|
+
//
|
|
25
|
+
// Ownership follows the ledger split: `site-facts.jsonl` rows are tool-owned facts (titles,
|
|
26
|
+
// h1s, asset candidates, sitemap membership, byte hashes, fetchedAt — never raw HTML);
|
|
27
|
+
// `site-profile.md` is the human/agent-owned reading, and every judgment in it cites its fact
|
|
28
|
+
// row or manual entry.
|
|
29
|
+
import { SEED_LIMITS, parseSitemap, sitemapDirectives, withinSitemapScope } from '../corpus/seeds.js';
|
|
30
|
+
import { createAcquisition } from '../corpus/acquire.js';
|
|
31
|
+
import { validatePublicUrl } from '../verifier/url.js';
|
|
32
|
+
import { gunzipSync } from 'node:zlib';
|
|
33
|
+
import { readJsonl, appendJsonl, sha256Hex } from './util.js';
|
|
34
|
+
import { readManual } from './gsc.js';
|
|
35
|
+
|
|
36
|
+
export const PROFILE_LIMITS = Object.freeze({
|
|
37
|
+
sitemapDocuments: 3, // robots + sitemap index + at most 3 sitemap documents
|
|
38
|
+
selectedPages: 10, // at most 10 selected pages per profile build
|
|
39
|
+
robotsBytes: 512 * 1024, // the verifier's own robots bound
|
|
40
|
+
pageConcurrency: 6, // the CLI's courtesy concurrency
|
|
41
|
+
hostDelayMs: 2_000, // the CLI's courtesy floor per host
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export class ProfileError extends Error {
|
|
45
|
+
constructor(reason, details = {}) { super(reason); this.name = 'ProfileError'; this.reason = reason; this.details = details; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// The bounded document transport (robots.txt and sitemap documents). Sitemaps are XML, so the
|
|
50
|
+
// verifier's page fetcher — which requires text/html — cannot serve them; this transport keeps
|
|
51
|
+
// the same shape: public URL validation, manual redirects (max 5), a bounded body, gzip
|
|
52
|
+
// decompression (the acquisition layer's job per seeds.js), and per-host courtesy pacing.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reads a body to raw bytes under the same bound the verifier's reader enforces, WITHOUT the
|
|
57
|
+
* UTF-8 decode: a gzipped sitemap is binary on the wire, and the decode must wait until after
|
|
58
|
+
* the gunzip decision. Content-length is pre-checked, the stream is capped, the reader cancelled.
|
|
59
|
+
*/
|
|
60
|
+
async function readBoundedBytes(response, maxBytes, signal) {
|
|
61
|
+
const length = response.headers.get('content-length');
|
|
62
|
+
const encoded = response.headers.get('content-encoding');
|
|
63
|
+
if (!encoded && length && Number(length) > maxBytes) {
|
|
64
|
+
response.body?.cancel().catch(() => {});
|
|
65
|
+
throw new ProfileError('body_too_large');
|
|
66
|
+
}
|
|
67
|
+
const reader = response.body?.getReader();
|
|
68
|
+
if (!reader) return { data: new Uint8Array(0), bytes: 0 };
|
|
69
|
+
const chunks = [];
|
|
70
|
+
let bytes = 0;
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const { done, value } = await reader.read();
|
|
74
|
+
if (done) break;
|
|
75
|
+
bytes += value.byteLength;
|
|
76
|
+
if (bytes > maxBytes) throw new ProfileError('body_too_large');
|
|
77
|
+
chunks.push(value);
|
|
78
|
+
}
|
|
79
|
+
const combined = new Uint8Array(bytes);
|
|
80
|
+
let offset = 0;
|
|
81
|
+
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
|
82
|
+
return { data: combined, bytes };
|
|
83
|
+
} finally {
|
|
84
|
+
reader.cancel().catch(() => {});
|
|
85
|
+
reader.releaseLock();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createSiteTransport({ fetchImpl = globalThis.fetch, timeoutMs = 20_000, maxBytes = SEED_LIMITS.documentBytes, maxRedirects = 5, hostDelayMs = PROFILE_LIMITS.hostDelayMs, now = () => Date.now(), wait = ms => new Promise(r => setTimeout(r, ms)) } = {}) {
|
|
90
|
+
const nextAllowed = new Map();
|
|
91
|
+
async function pace(host) {
|
|
92
|
+
const allowed = nextAllowed.get(host) ?? 0;
|
|
93
|
+
const delay = allowed - now();
|
|
94
|
+
if (delay > 0) await wait(delay);
|
|
95
|
+
nextAllowed.set(host, now() + hostDelayMs);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function fetchText(rawUrl, { accept = 'text/plain,application/xml,text/xml' } = {}) {
|
|
99
|
+
const checked = validatePublicUrl(rawUrl);
|
|
100
|
+
if (!checked.valid) return { ok: false, url: rawUrl, reason: `unsafe_url:${checked.reason}` };
|
|
101
|
+
let url = checked.url;
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
104
|
+
if (seen.has(url)) return { ok: false, url, reason: 'redirect_loop' };
|
|
105
|
+
seen.add(url);
|
|
106
|
+
await pace(new URL(url).hostname);
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
109
|
+
let response;
|
|
110
|
+
try {
|
|
111
|
+
response = await fetchImpl(url, { method: 'GET', redirect: 'manual', credentials: 'omit', signal: controller.signal, headers: { 'User-Agent': 'LinktrailBot/0.1', Accept: accept } });
|
|
112
|
+
} catch { return { ok: false, url, reason: 'timeout_or_network_error' }; }
|
|
113
|
+
finally { clearTimeout(timer); }
|
|
114
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
115
|
+
response.body?.cancel().catch(() => {});
|
|
116
|
+
const location = response.headers.get('location');
|
|
117
|
+
const next = location ? validatePublicUrl(new URL(location, url).href) : null;
|
|
118
|
+
if (!next?.valid) return { ok: false, url, reason: 'unsafe_or_missing_redirect' };
|
|
119
|
+
url = next.url;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (response.status === 404 || response.status === 410) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: 'not_found' }; }
|
|
123
|
+
if (!response.ok) { response.body?.cancel().catch(() => {}); return { ok: false, url, status: response.status, reason: `http_${response.status}` }; }
|
|
124
|
+
let body;
|
|
125
|
+
try { body = await readBoundedBytes(response, maxBytes, controller.signal); }
|
|
126
|
+
catch (error) { return { ok: false, url, status: response.status, reason: error?.reason ?? 'read_failed' }; }
|
|
127
|
+
let data = body.data, gzipped = false;
|
|
128
|
+
if (data?.length >= 2 && data[0] === 0x1f && data[1] === 0x8b) {
|
|
129
|
+
gzipped = true;
|
|
130
|
+
try { data = gunzipSync(data); } catch { return { ok: false, url, status: response.status, reason: 'sitemap_gunzip_failed' }; }
|
|
131
|
+
if (data.byteLength > maxBytes) return { ok: false, url, status: response.status, reason: 'sitemap_too_large' };
|
|
132
|
+
}
|
|
133
|
+
let text;
|
|
134
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(data); }
|
|
135
|
+
catch { return { ok: false, url, status: response.status, reason: 'unsupported_or_invalid_encoding' }; }
|
|
136
|
+
return { ok: true, url, final_url: url, status: response.status, text, bytes: body.bytes, gzipped };
|
|
137
|
+
}
|
|
138
|
+
return { ok: false, url, reason: 'redirect_limit' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { fetchText };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Derived page fields. No raw HTML is stored: titles, headings, counts, a byte hash and the
|
|
146
|
+
// fetch date, matching the verifier's rule that evidence HTML never lives in a payload.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
const firstMatch = (pattern, html, group = 1) => pattern.exec(html)?.[group]?.replace(/\s+/gu, ' ').trim() ?? null;
|
|
150
|
+
|
|
151
|
+
function boundedMatches(pattern, html, limit) {
|
|
152
|
+
const found = [];
|
|
153
|
+
for (const match of html.matchAll(pattern)) {
|
|
154
|
+
found.push(match[1].replace(/\s+/gu, ' ').trim());
|
|
155
|
+
if (found.length >= limit) break;
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Asset candidates are NOMINATIONS for judgment, never findings: path conventions the publisher chose plus names the manual declared. */
|
|
161
|
+
const ASSET_PATH_PATTERNS = Object.freeze([
|
|
162
|
+
[/\/(tools?|calculators?|templates?|checklists?|guides?)\//iu, 'asset_path_convention'],
|
|
163
|
+
[/\/(studies?|research|data|reports?|whitepapers?|surveys?)\//iu, 'research_asset_path_convention'],
|
|
164
|
+
[/\.(pdf|xlsx?|csv)$/iu, 'downloadable_file'],
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
export function derivePageFields(html) {
|
|
168
|
+
if (typeof html !== 'string' || !html) return { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null, asset_candidate_reasons: null };
|
|
169
|
+
const links = [...html.matchAll(/<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/giu)];
|
|
170
|
+
return {
|
|
171
|
+
title: firstMatch(/<title[^>]*>([\s\S]*?)<\/title>/iu, html),
|
|
172
|
+
h1s: boundedMatches(/<h1\b[^>]*>([\s\S]*?)<\/h1>/giu, html, 5),
|
|
173
|
+
meta_description: (firstMatch(/<meta\b[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']*)["']/iu, html)
|
|
174
|
+
?? firstMatch(/<meta\b[^>]*content\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']description["']/iu, html))?.slice(0, 500) ?? null,
|
|
175
|
+
canonical: firstMatch(/<link\b[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']+)["']/iu, html)
|
|
176
|
+
?? firstMatch(/<link\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*rel\s*=\s*["']canonical["']/iu, html),
|
|
177
|
+
link_counts: { total: links.length },
|
|
178
|
+
asset_candidate_reasons: [], // filled by the caller, who knows the URL and the manual assets
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assetCandidateReasons(url, derived, manualAssets) {
|
|
183
|
+
const reasons = [];
|
|
184
|
+
for (const [pattern, label] of ASSET_PATH_PATTERNS) if (pattern.test(new URL(url).pathname)) reasons.push(label);
|
|
185
|
+
const haystack = [derived.title, ...derived.h1s].filter(Boolean).join(' ').toLowerCase();
|
|
186
|
+
for (const asset of manualAssets ?? []) {
|
|
187
|
+
const name = String(asset).toLowerCase().trim();
|
|
188
|
+
if (name && haystack.includes(name.split(/\s+/u)[0])) reasons.push('matches_manual_asset_name');
|
|
189
|
+
}
|
|
190
|
+
return [...new Set(reasons)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Fact rows and citations.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export async function readSiteFacts(path) { const { rows, problems, missing } = await readJsonl(path); return { rows, problems, missing }; }
|
|
198
|
+
|
|
199
|
+
/** Newest fact row per stable id. The id is derived from kind+url, so citations survive rebuilds. */
|
|
200
|
+
export async function siteFactRow(kind, url, extra = {}) {
|
|
201
|
+
const id = `f-${(await sha256Hex(`${kind}|${url}`)).slice(0, 10)}`;
|
|
202
|
+
return { id, kind, url, fetched_at: extra.fetched_at ?? new Date().toISOString(), ...extra };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function newestSiteFacts(rows) {
|
|
206
|
+
const byId = new Map();
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const held = byId.get(row.id);
|
|
209
|
+
if (!held || String(row.fetched_at ?? '') >= String(held.fetched_at ?? '')) byId.set(row.id, row);
|
|
210
|
+
}
|
|
211
|
+
return [...byId.values()];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The editable, human/agent-owned profile scaffold. Every judgment line cites its source — a
|
|
216
|
+
* fact row id or a manual entry — and editing a judgment never edits a fact: the facts live in
|
|
217
|
+
* a different file with a different owner. Inferences are labeled as such; a recommendation is
|
|
218
|
+
* a DP-0009 brief's job and does not appear here.
|
|
219
|
+
*/
|
|
220
|
+
export function renderSiteProfile({ facts, manual, origin }) {
|
|
221
|
+
const newest = newestSiteFacts(facts);
|
|
222
|
+
const manualIndex = new Map((manual?.entries ?? []).map((entry, index) => [`${entry.field}|${entry.value}`, index]));
|
|
223
|
+
const manualRef = (field, value) => `[manual:${manualIndex.get(`${field}|${value}`) ?? 0}]`;
|
|
224
|
+
const lines = [
|
|
225
|
+
`# Site profile — ${origin}`, '',
|
|
226
|
+
'<!-- Human/agent-owned. Edit the judgments, keep the citations: every fact: reference',
|
|
227
|
+
' resolves to a tool-owned row in site-facts.jsonl, and every manual: reference to a',
|
|
228
|
+
' manual.md entry. The facts never change when you edit this file. -->', '',
|
|
229
|
+
];
|
|
230
|
+
const sitemapFacts = newest.filter(row => row.kind === 'site.sitemap');
|
|
231
|
+
const pageFacts = newest.filter(row => row.kind === 'site.page');
|
|
232
|
+
const robotsFact = newest.find(row => row.kind === 'site.robots');
|
|
233
|
+
|
|
234
|
+
lines.push('## Audience', '');
|
|
235
|
+
if (manual?.site_description) lines.push(`- ${manual.site_description} ${manualRef('site_description', manual.site_description)}`);
|
|
236
|
+
if (robotsFact) lines.push(`- Robots posture observed ${robotsFact.outcome}${robotsFact.reason ? ` (${robotsFact.reason})` : ''}. [fact:${robotsFact.id}]`);
|
|
237
|
+
lines.push("- TODO(judgment): who this site is for, in the operator's own words.", '');
|
|
238
|
+
|
|
239
|
+
lines.push('## Assets', '');
|
|
240
|
+
const assetPages = pageFacts.filter(row => Array.isArray(row.asset_candidate_reasons) && row.asset_candidate_reasons.length);
|
|
241
|
+
for (const row of assetPages) lines.push(`- ${row.url} — candidate via ${row.asset_candidate_reasons.join(', ')}; title "${row.title ?? 'unknown'}". [fact:${row.id}]`);
|
|
242
|
+
for (const asset of manual?.assets ?? []) lines.push(`- ${asset} ${manualRef('assets', asset)}`);
|
|
243
|
+
if (!assetPages.length && !(manual?.assets ?? []).length) lines.push('- TODO(judgment): name the assets worth promoting; unreadable pages stay unknown, never "no assets".');
|
|
244
|
+
lines.push('');
|
|
245
|
+
|
|
246
|
+
lines.push('## Campaign targets', '');
|
|
247
|
+
for (const row of pageFacts) if (row.selection_reasons?.includes('manual')) lines.push(`- ${row.url} — declared target. [fact:${row.id}]`);
|
|
248
|
+
for (const row of pageFacts) if (row.outcome !== 'fetched') lines.push(`- ${row.url} — MISSING/UNKNOWN (${row.outcome}${row.reason ? `: ${row.reason}` : ''}); explicit, not dropped. [fact:${row.id}]`);
|
|
249
|
+
for (const row of sitemapFacts.slice(0, 3)) lines.push(`- Sitemap ${row.url}: ${row.urls_found ?? 'unknown'} URL(s), counters preserved (truncated ${row.truncated}, skipped ${JSON.stringify(row.skipped ?? {})}). [fact:${row.id}]`);
|
|
250
|
+
lines.push('');
|
|
251
|
+
return lines.join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const CITATION = /\[(fact|manual):([^\]]+)\]/gu;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Checks that every citation in an edited profile still resolves. Editing a judgment may never
|
|
258
|
+
* silently orphan the evidence it stands on: an unresolved citation is reported, and facts are
|
|
259
|
+
* only ever read here — nothing in this file writes to site-facts.jsonl except a build.
|
|
260
|
+
*/
|
|
261
|
+
export function validateProfileCitations({ profileText, facts, manual }) {
|
|
262
|
+
const newest = newestSiteFacts(facts ?? []);
|
|
263
|
+
const byId = new Map(newest.map(row => [row.id, row]));
|
|
264
|
+
const citations = [], unresolved = [];
|
|
265
|
+
for (const match of String(profileText ?? '').matchAll(CITATION)) {
|
|
266
|
+
const [, kind, ref] = match;
|
|
267
|
+
const resolves = kind === 'fact' ? byId.has(`f-${ref.replace(/^f-/u, '')}`) : Boolean(manual?.entries?.[Number(ref)]);
|
|
268
|
+
citations.push({ ref: `${kind}:${ref}`, resolves });
|
|
269
|
+
if (!resolves) unresolved.push(`${kind}:${ref}`);
|
|
270
|
+
}
|
|
271
|
+
return { citations: citations.length, unresolved, facts_read_only: true };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// The bounded profile build.
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
function classifySelection(url, manualTargets, homepage, sitemapUrls) {
|
|
279
|
+
const reasons = [];
|
|
280
|
+
if (manualTargets.includes(url)) reasons.push('manual');
|
|
281
|
+
if (homepage && url === homepage) reasons.push('homepage');
|
|
282
|
+
if (sitemapUrls.includes(url)) reasons.push('sitemap');
|
|
283
|
+
return reasons.length ? reasons : ['explicit'];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Builds the bounded public profile. Bounds, all reported as counts when hit: robots.txt (one
|
|
288
|
+
* document), at most 3 sitemap documents, at most 10 selected pages. A page that could not be
|
|
289
|
+
* read is an explicit unknown row — never dropped, never "no assets".
|
|
290
|
+
*/
|
|
291
|
+
export async function buildSiteProfile({
|
|
292
|
+
site = null, paths, manual = null, transport = null, acquisition = null,
|
|
293
|
+
extraPages = [], now = () => new Date().toISOString(), limits = {},
|
|
294
|
+
}) {
|
|
295
|
+
const cap = { ...PROFILE_LIMITS, ...limits };
|
|
296
|
+
const manualInputs = manual ?? (paths?.manual ? await readManual(paths.manual) : { target_pages: [], assets: [] });
|
|
297
|
+
const origin = (() => {
|
|
298
|
+
if (site) { const checked = validatePublicUrl(site.includes('://') ? site : `https://${site}`); return checked.valid ? new URL(checked.url).origin : null; }
|
|
299
|
+
for (const target of manualInputs.target_pages ?? []) {
|
|
300
|
+
const checked = validatePublicUrl(target);
|
|
301
|
+
if (checked.valid) return new URL(checked.url).origin;
|
|
302
|
+
}
|
|
303
|
+
return null;
|
|
304
|
+
})();
|
|
305
|
+
if (!origin) throw new ProfileError('no_site_resolved', { hint: 'pass --site ORIGIN or declare target pages in manual.md' });
|
|
306
|
+
|
|
307
|
+
const fetchedAt = now();
|
|
308
|
+
const facts = [];
|
|
309
|
+
const docFetch = transport ?? createSiteTransport({});
|
|
310
|
+
|
|
311
|
+
// 1. robots.txt — one bounded document. Unreadable is recorded; the sitemap lane is then
|
|
312
|
+
// refused rather than guessing at /sitemap.xml, and the page lane proceeds under the
|
|
313
|
+
// verifier's own current robots posture.
|
|
314
|
+
const robotsUrl = `${origin}/robots.txt`;
|
|
315
|
+
const robotsFetch = await docFetch.fetchText(robotsUrl);
|
|
316
|
+
const robotsText = robotsFetch.ok ? robotsFetch.text : '';
|
|
317
|
+
const directives = robotsFetch.ok ? sitemapDirectives(robotsText, { baseUrl: robotsUrl }) : [];
|
|
318
|
+
const robotsFact = await siteFactRow('site.robots', robotsUrl, {
|
|
319
|
+
outcome: robotsFetch.ok ? 'fetched' : 'unreadable',
|
|
320
|
+
reason: robotsFetch.reason ?? null, status: robotsFetch.status ?? null,
|
|
321
|
+
bytes: robotsFetch.bytes ?? null, gzipped: robotsFetch.gzipped ?? false,
|
|
322
|
+
content_sha256: robotsFetch.ok ? await sha256Hex(robotsText) : null,
|
|
323
|
+
sitemap_directives: directives, fetched_at: fetchedAt,
|
|
324
|
+
});
|
|
325
|
+
facts.push(robotsFact);
|
|
326
|
+
|
|
327
|
+
// 2. Sitemaps declared BY the publisher, bounded at 3 documents total, parsed by seeds.js
|
|
328
|
+
// unchanged. The parser's skipped/truncated/failure counters are preserved verbatim.
|
|
329
|
+
const sitemapUrls = [];
|
|
330
|
+
let sitemapFactsWritten = 0;
|
|
331
|
+
const sitemapFailures = {}, sitemapSkipped = {};
|
|
332
|
+
let sitemapDocuments = 0, sitemapTruncated = false, sitemapCapped = false;
|
|
333
|
+
if (!robotsFetch.ok && robotsFetch.reason !== 'not_found') {
|
|
334
|
+
sitemapFailures[`robots_unreadable_${robotsFetch.reason}`] = 1;
|
|
335
|
+
} else {
|
|
336
|
+
// Directives beyond the cap are DROPPED, so the drop is counted, never silent.
|
|
337
|
+
const roots = directives.slice(0, cap.sitemapDocuments);
|
|
338
|
+
if (directives.length > cap.sitemapDocuments) sitemapCapped = true;
|
|
339
|
+
const queue = roots.map(url => ({ url, depth: 0 }));
|
|
340
|
+
const visited = new Set();
|
|
341
|
+
while (queue.length) {
|
|
342
|
+
const { url, depth } = queue.shift();
|
|
343
|
+
if (visited.has(url)) continue;
|
|
344
|
+
visited.add(url);
|
|
345
|
+
if (sitemapDocuments >= cap.sitemapDocuments) { sitemapCapped = true; break; }
|
|
346
|
+
sitemapDocuments++;
|
|
347
|
+
const fetchAt = now();
|
|
348
|
+
const document = await docFetch.fetchText(url);
|
|
349
|
+
if (!document.ok) { sitemapFailures[document.reason ?? 'unreadable'] = (sitemapFailures[document.reason ?? 'unreadable'] ?? 0) + 1; continue; }
|
|
350
|
+
let parsed;
|
|
351
|
+
try { parsed = parseSitemap(document.text, { baseUrl: url, scope: 'host' }); }
|
|
352
|
+
catch (error) { sitemapFailures[error.reason ?? 'not_a_sitemap'] = (sitemapFailures[error.reason ?? 'not_a_sitemap'] ?? 0) + 1; continue; }
|
|
353
|
+
if (parsed.truncated) sitemapTruncated = true;
|
|
354
|
+
for (const [reason, count] of Object.entries(parsed.skipped ?? {})) sitemapSkipped[reason] = (sitemapSkipped[reason] ?? 0) + count;
|
|
355
|
+
const fact = await siteFactRow('site.sitemap', url, {
|
|
356
|
+
kind_index: parsed.kind, urls: parsed.urls.length, urls_found: parsed.urls.length,
|
|
357
|
+
truncated: parsed.truncated, skipped: parsed.skipped, gzipped: document.gzipped,
|
|
358
|
+
content_sha256: await sha256Hex(document.text), documents: 1, failures: {},
|
|
359
|
+
fetched_at: fetchAt,
|
|
360
|
+
});
|
|
361
|
+
facts.push(fact); sitemapFactsWritten++;
|
|
362
|
+
if (parsed.kind === 'index') {
|
|
363
|
+
if (depth >= SEED_LIMITS.indexDepth) { sitemapFailures.index_too_deep = (sitemapFailures.index_too_deep ?? 0) + 1; continue; }
|
|
364
|
+
for (const child of parsed.urls.slice(0, cap.sitemapDocuments)) {
|
|
365
|
+
if (!visited.has(child) && withinSitemapScope(child, url, 'host')) queue.push({ url: child, depth: depth + 1 });
|
|
366
|
+
}
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
for (const found of parsed.urls) if (!sitemapUrls.includes(found)) sitemapUrls.push(found);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 3. Page selection, hard-capped, every limit reported.
|
|
374
|
+
const homepage = `${origin}/`;
|
|
375
|
+
const wanted = [...new Set([...(manualInputs.target_pages ?? []).filter(u => validatePublicUrl(u).valid), ...extraPages.filter(u => validatePublicUrl(u).valid)])];
|
|
376
|
+
const selected = [...wanted];
|
|
377
|
+
if (!selected.includes(homepage) && selected.length < cap.selectedPages) selected.push(homepage);
|
|
378
|
+
for (const url of sitemapUrls) {
|
|
379
|
+
if (selected.length >= cap.selectedPages) break;
|
|
380
|
+
if (!selected.includes(url)) selected.push(url);
|
|
381
|
+
}
|
|
382
|
+
const pagesCapped = selected.length >= cap.selectedPages;
|
|
383
|
+
const selectedFinal = selected.slice(0, cap.selectedPages);
|
|
384
|
+
|
|
385
|
+
// 4. Page fetches on the verifier's public fetch boundary, with the CLI's courtesy pacing.
|
|
386
|
+
const pageAcquisition = acquisition ?? createAcquisition({ publicFetchSafe: true });
|
|
387
|
+
const pacer = createPacer({ hostDelayMs: cap.hostDelayMs });
|
|
388
|
+
const pageRows = [];
|
|
389
|
+
for (const url of selectedFinal) {
|
|
390
|
+
const fetchAt = now();
|
|
391
|
+
const result = await pageAcquisition.fetchPage(url, { kind: 'direct', beforeFetch: pacer.beforeFetch });
|
|
392
|
+
const fetched = result.outcome === 'fetched';
|
|
393
|
+
const derived = fetched ? derivePageFields(result.html) : { title: null, h1s: [], meta_description: null, canonical: null, link_counts: null };
|
|
394
|
+
derived.asset_candidate_reasons = fetched ? assetCandidateReasons(url, derived, manualInputs.assets) : null; // unknown, never "no assets"
|
|
395
|
+
const fact = await siteFactRow('site.page', url, {
|
|
396
|
+
outcome: result.outcome, reason: result.reason ?? null, http_status: result.http_status ?? null,
|
|
397
|
+
final_url: result.final_url ?? url, robots: result.robots?.reason ?? null,
|
|
398
|
+
title: derived.title, h1s: derived.h1s, meta_description: derived.meta_description,
|
|
399
|
+
canonical: derived.canonical, link_counts: derived.link_counts,
|
|
400
|
+
asset_candidate_reasons: derived.asset_candidate_reasons,
|
|
401
|
+
in_sitemap: sitemapUrls.includes(url),
|
|
402
|
+
selection_reasons: classifySelection(url, manualInputs.target_pages ?? [], homepage, sitemapUrls),
|
|
403
|
+
bytes: result.bytes ?? null, content_sha256: fetched ? await sha256Hex(result.html ?? '') : null,
|
|
404
|
+
fetched_at: fetchAt,
|
|
405
|
+
});
|
|
406
|
+
facts.push(fact); pageRows.push(fact);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (paths?.siteFacts) await appendJsonl(paths.siteFacts, facts);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
state: 'ok',
|
|
413
|
+
origin, built_at: fetchedAt,
|
|
414
|
+
robots: { outcome: robotsFact.outcome, reason: robotsFact.reason, directives: directives.length },
|
|
415
|
+
sitemaps: {
|
|
416
|
+
documents: sitemapDocuments, urls_found: sitemapUrls.length, truncated: sitemapTruncated,
|
|
417
|
+
capped: sitemapCapped, directives_seen: directives.length,
|
|
418
|
+
failures: sitemapFailures, skipped: sitemapSkipped,
|
|
419
|
+
},
|
|
420
|
+
pages: pageRows.map(row => ({ url: row.url, outcome: row.outcome, reason: row.reason, title: row.title, in_sitemap: row.in_sitemap, selection_reasons: row.selection_reasons, asset_candidate_reasons: row.asset_candidate_reasons })),
|
|
421
|
+
limits: { sitemap_documents: cap.sitemapDocuments, selected_pages: cap.selectedPages, pages_capped: pagesCapped, pages_selected: pageRows.length },
|
|
422
|
+
rows_written: facts.length,
|
|
423
|
+
notes: [
|
|
424
|
+
sitemapCapped ? `sitemap document cap (${cap.sitemapDocuments}) reached; counters preserved` : null,
|
|
425
|
+
pagesCapped ? `selected-page cap (${cap.selectedPages}) reached` : null,
|
|
426
|
+
pageRows.some(row => row.outcome !== 'fetched') ? 'one or more pages are unknown/unreadable — explicit outcomes, never "no assets"' : null,
|
|
427
|
+
].filter(Boolean),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The CLI's courtesy floor per host (2 s), honouring a publisher's stated crawl delay when longer. */
|
|
432
|
+
export function createPacer({ hostDelayMs = PROFILE_LIMITS.hostDelayMs, now = () => Date.now(), wait = ms => new Promise(r => setTimeout(r, ms)) } = {}) {
|
|
433
|
+
const nextAllowed = new Map();
|
|
434
|
+
return {
|
|
435
|
+
async beforeFetch(url, context = {}) {
|
|
436
|
+
try { var host = new URL(url).hostname; } catch { return; }
|
|
437
|
+
const stated = Number(context.crawlDelaySeconds ?? 0) * 1000;
|
|
438
|
+
const floor = Math.max(hostDelayMs, Number.isFinite(stated) ? stated : 0);
|
|
439
|
+
const allowed = nextAllowed.get(host) ?? 0;
|
|
440
|
+
const delay = allowed - now();
|
|
441
|
+
if (delay > 0) await wait(delay);
|
|
442
|
+
nextAllowed.set(host, now() + floor);
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Small shared plumbing for the first-party context modules (DP-0017). Nothing here knows
|
|
2
|
+
// about Google or about sites: JSONL discipline, dates and byte hashes, so both halves of the
|
|
3
|
+
// context contract share one implementation of "append-only, newest wins, never rewrite".
|
|
4
|
+
import { readFile, appendFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads a JSONL file into rows, reporting unreadable lines instead of dropping them. A dropped
|
|
9
|
+
* line is a fact that stops being countable; the ledger reader taught this lesson first.
|
|
10
|
+
*/
|
|
11
|
+
export async function readJsonl(path) {
|
|
12
|
+
let text;
|
|
13
|
+
try { text = await readFile(path, 'utf8'); } catch (error) { if (error.code === 'ENOENT') return { rows: [], problems: [], missing: true }; throw error; }
|
|
14
|
+
const rows = [], problems = [];
|
|
15
|
+
const lines = text.split('\n');
|
|
16
|
+
for (let index = 0; index < lines.length; index++) {
|
|
17
|
+
const line = lines[index].trim();
|
|
18
|
+
if (!line) continue;
|
|
19
|
+
try { rows.push(JSON.parse(line)); } catch { problems.push({ line: index + 1, reason: 'not_json' }); }
|
|
20
|
+
}
|
|
21
|
+
return { rows, problems, missing: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function appendJsonl(path, rows) {
|
|
25
|
+
if (!rows.length) return;
|
|
26
|
+
await mkdir(dirname(path), { recursive: true });
|
|
27
|
+
await appendFile(path, rows.map(row => JSON.stringify(row)).join('\n') + '\n', 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readJson(path) {
|
|
31
|
+
try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeJson(path, value) {
|
|
35
|
+
await mkdir(dirname(path), { recursive: true });
|
|
36
|
+
const { writeFile } = await import('node:fs/promises');
|
|
37
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `YYYY-MM-DD` for a Date or ISO string, in UTC. Window arithmetic is done in UTC day steps. */
|
|
41
|
+
export function dayStamp(value) {
|
|
42
|
+
return (typeof value === 'function' ? value() : value ?? new Date()).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseDay(stamp) {
|
|
46
|
+
const [y, m, d] = String(stamp).slice(0, 10).split('-').map(Number);
|
|
47
|
+
return Date.UTC(y, m - 1, d);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isValidDay(stamp) {
|
|
51
|
+
return typeof stamp === 'string' && /^\d{4}-\d{2}-\d{2}$/u.test(stamp) && Number.isFinite(parseDay(stamp));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function addDays(stamp, days) {
|
|
55
|
+
return new Date(parseDay(stamp) + days * 86_400_000).toISOString().slice(0, 10);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function daysBetween(start, end) {
|
|
59
|
+
return Math.round((parseDay(end) - parseDay(start)) / 86_400_000) + 1; // inclusive
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** SHA-256 hex of a string's UTF-8 bytes. Identifies bytes without storing them. */
|
|
63
|
+
export async function sha256Hex(text) {
|
|
64
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(text)));
|
|
65
|
+
return [...new Uint8Array(digest)].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The number Google's API always meant, or null. Never a coerced zero. */
|
|
69
|
+
export function numberOrNullOr(value) {
|
|
70
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure repository-local tool definitions. No cloud or filesystem imports.
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// The DP-0017 first-party context surface (registered here by DP-0026-T03 per T02/T03's
|
|
5
|
+
// deferred-wiring notes: "the service-level surface is the same exported functions"). These
|
|
6
|
+
// tools are LOCAL BY CONTRACT: they read and write the customer's repository
|
|
7
|
+
// (.agentlinkops/context/…), and the context contract makes the cloud a mirror that is never the
|
|
8
|
+
// system of record — so no workerd transport can host them (workerd has no filesystem), and the
|
|
9
|
+
// registry defines them the way registerFreeCheckTool defines the free tool: with the context
|
|
10
|
+
// service INJECTED, keeping this module free of node:fs and safe for the workerd bundle. The
|
|
11
|
+
// injected service is composed from the same functions cli/context.js composes
|
|
12
|
+
// (readContextState, readGscRows, pageContext, refreshContext, importHandoff,
|
|
13
|
+
// buildSiteProfile, validateProfileCitations, …).
|
|
14
|
+
//
|
|
15
|
+
// Repository-context definitions are captured separately from hosted tools. No cloud transport
|
|
16
|
+
// exposes these filesystem operations; a local host must inject its context service.
|
|
17
|
+
export const registerContextTools=({contextTool,context})=>{
|
|
18
|
+
contextTool('context_status','Read this repository\'s first-party context state: connection grant state (booleans, property names, permission levels and dates only — never a token), GSC fact-row count, site-fact count, availability floors and manual-input presence. manual_only is a complete configuration, not an error: with no Google connection the manual inputs are the context.',null,{},a=>context.status(a),false,{strict:true});
|
|
19
|
+
contextTool('context_manual','Read the human/agent-owned manual inputs (target pages, site or niche description, offering, known assets, competitors) that form the no-Google floor of the context. Manual entries are judgment: they carry a manual source label, can be wrong, have no observation date, and never migrate into tool-owned fact rows.',null,{},a=>context.manual(a),false,{strict:true});
|
|
20
|
+
contextTool('context_focus','Choose focus pages from manual targets first, then GSC clicks. Keep manual judgments and search observations in separate fields. Supply both start and end to select one date range, or omit both for the recent 28-day range.',null,{start:z.string().min(10).max(10).optional(),end:z.string().min(10).max(10).optional()},a=>context.focus(a),false,{strict:true});
|
|
21
|
+
contextTool('context_gsc_page','Read page/query context for one supplied page entirely from repository fact rows — no Google connection is consulted and no query is spent; a connector handoff with no grant from us at all is a complete configuration. A page outside every connected property is page_outside_property with zero rows invented; absence from truncated top rows means "not in the returned top rows", never "no impressions"; nulls stay null and CTR is derived at read time.',null,{page:z.string().min(1).max(8192),start:z.string().min(10).max(10).optional(),end:z.string().min(10).max(10).optional()},a=>context.page(a),false,{strict:true});
|
|
22
|
+
contextTool('context_gsc_refresh','Refresh GSC rows with read-only webmasters.readonly access. Limit each run to 12 Search Analytics calls per property and 50 total, including availability probes, reports and retries. Fresh cached requests spend no API calls. A quota refusal returns quota_exceeded with a 15-minute hint and at most one retry within the remaining call budget. Read the token from the host environment at call time. Append fact rows and read the newest answer.',null,{properties:z.array(z.string().min(1).max(200)).min(1).max(10).optional(),dataState:z.enum(['final','all']).optional()},a=>context.refresh(a),true,{strict:true,dispatch:false});
|
|
23
|
+
contextTool('context_gsc_import','Import an existing-agent handoff snapshot (JSON lines or a Markdown table) in the gsc.jsonl row shape, instead of granting anything. The connector stays named in retrieved_by and is never re-labeled as fetched by us; its capture date stays captured_at, separate from any later fetch of ours; rows missing window, aggregationType or retrieved_by are refused per file with what is missing; a connector row whose key matches a row we fetched is refused — ours is kept. A handoff and a grant may coexist, rows kept distinct by retrieved_by.',null,{file:z.string().min(1).max(4096)},a=>context.import(a),true,{strict:true,dispatch:false});
|
|
24
|
+
contextTool('context_profile_build','Build the bounded public site-profile facts: robots.txt, at most 3 sitemap documents and at most 10 selected pages per run, fetched on the verifier\'s public fetch boundary with the CLI\'s courtesy pacing, with every limit the run hit reported as a count. Facts keep derived fields (titles, h1s, description, canonical, link counts, sitemap membership, byte hashes, fetched_at) — never raw HTML. A page that could not be read is unknown/unreadable, never "no assets", and missing pages stay explicit.',null,{site:z.string().min(1).max(2048).optional(),pages:z.array(z.string().min(1).max(8192)).max(50).optional()},a=>context.buildProfile(a),true,{strict:true,dispatch:false});
|
|
25
|
+
contextTool('context_profile_check','Check that every [fact:…] and [manual:…] citation in the human/agent-owned site-profile.md resolves to a fact row or manual entry. Facts are read-only to the profile: editing a judgment never edits a fact row, and a citation nothing backs is reported, never dropped.',null,{},a=>context.checkProfile(a),false,{strict:true});
|
|
26
|
+
};
|
|
27
|
+
|