pagegraph 0.8.3 → 0.9.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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import * as NFS from "node:fs";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFileSync, spawn } from "node:child_process";
6
6
  import { PassThrough } from "node:stream";
@@ -9,7 +9,7 @@ import { createHash, randomUUID } from "node:crypto";
9
9
  import * as OS from "node:os";
10
10
  import { tmpdir } from "node:os";
11
11
  import * as Path from "node:path";
12
- import { dirname, join, resolve } from "node:path";
12
+ import { dirname, join, relative, resolve } from "node:path";
13
13
  import * as NodeUrl from "node:url";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import * as readline from "node:readline";
@@ -35710,7 +35710,7 @@ const runWith = (command, config) => {
35710
35710
  };
35711
35711
  //#endregion
35712
35712
  //#region package.json
35713
- var version = "0.8.3";
35713
+ var version = "0.9.0";
35714
35714
  //#endregion
35715
35715
  //#region src/core/projections.ts
35716
35716
  const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -36166,12 +36166,13 @@ const findConfigFile = (from) => {
36166
36166
  const isStringArray = (value) => Array.isArray(value) && value.every(isString);
36167
36167
  const isCoverageRules = (value) => Array.isArray(value) && value.every((rule) => isObject$1(rule) && isString(rule["path"]) && Number.isSafeInteger(rule["minInbound"]) && rule["minInbound"] > 0);
36168
36168
  const isStringRecord = (value) => isObject$1(value) && Object.values(value).every(isString);
36169
+ const isWorkflowTimeout = (value) => Number.isSafeInteger(value) && value > 0 && value <= 2147483647;
36169
36170
  const isStringArrayRecord = (value) => isObject$1(value) && Object.values(value).every(isStringArray);
36170
36171
  const isWorkflowConfig = (value) => {
36171
36172
  if (!isObject$1(value) || !isObject$1(value["opencode"])) return false;
36172
36173
  const opencode = value["opencode"];
36173
36174
  const context = value["context"];
36174
- return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
36175
+ return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (opencode["timeoutMs"] === void 0 || isWorkflowTimeout(opencode["timeoutMs"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
36175
36176
  };
36176
36177
  /**
36177
36178
  * `seo.config.ts` is the consumer's file and may be plain JS, so its types are
@@ -37524,6 +37525,15 @@ const decodeRenderedEdgeArtifact = (input) => {
37524
37525
  const bodyTruncated = provenance["bodyTruncated"];
37525
37526
  if (bodyTruncated !== void 0 && typeof bodyTruncated !== "boolean") throw new Error("rendered-edge artifact `bodyTruncated` must be a boolean");
37526
37527
  if (typeof provenance["truncated"] !== "boolean") throw new Error("rendered-edge artifact `crawl.truncated` must be a boolean");
37528
+ const skipped = provenance["skipped"];
37529
+ if (skipped !== void 0 && (!Array.isArray(skipped) || !skipped.every((url) => typeof url === "string"))) throw new Error("rendered-edge artifact `crawl.skipped` must be an array of URLs");
37530
+ const nonHtml = provenance["nonHtml"];
37531
+ if (nonHtml !== void 0 && (!Array.isArray(nonHtml) || !nonHtml.every((item) => item !== null && typeof item === "object" && typeof item.url === "string" && typeof item.finalUrl === "string" && typeof item.contentType === "string"))) throw new Error("rendered-edge artifact `crawl.nonHtml` must be an array of { url, finalUrl, contentType }");
37532
+ if (Array.isArray(nonHtml)) for (const item of nonHtml) {
37533
+ requiredAbsoluteUrl(item.url, "crawl.nonHtml.url");
37534
+ requiredAbsoluteUrl(item.finalUrl, "crawl.nonHtml.finalUrl");
37535
+ }
37536
+ for (const field of ["sitemapTruncated", "sitemapUsed"]) if (provenance[field] !== void 0 && typeof provenance[field] !== "boolean") throw new Error(`rendered-edge artifact \`crawl.${field}\` must be a boolean`);
37527
37537
  return {
37528
37538
  kind: ARTIFACT_KIND,
37529
37539
  schemaVersion: 1,
@@ -37536,7 +37546,12 @@ const decodeRenderedEdgeArtifact = (input) => {
37536
37546
  truncated: provenance["truncated"],
37537
37547
  bodyTruncated: bodyTruncated === true,
37538
37548
  truncatedPages: truncatedPages ?? [],
37539
- failures: decodeArtifactFailures(provenance["failures"])
37549
+ failures: decodeArtifactFailures(provenance["failures"]),
37550
+ ...nonHtml === void 0 ? {} : { nonHtml },
37551
+ ...provenance["discoveryFailures"] === void 0 ? {} : { discoveryFailures: decodeArtifactFailures(provenance["discoveryFailures"]) },
37552
+ ...skipped === void 0 ? {} : { skipped },
37553
+ ...provenance["sitemapTruncated"] === void 0 ? {} : { sitemapTruncated: provenance["sitemapTruncated"] },
37554
+ ...provenance["sitemapUsed"] === void 0 ? {} : { sitemapUsed: provenance["sitemapUsed"] }
37540
37555
  },
37541
37556
  nodes: decodeArtifactNodes(value["nodes"]),
37542
37557
  edges: decodeArtifactEdges(value["edges"])
@@ -37751,7 +37766,7 @@ const canonicalUrl = (html) => {
37751
37766
  };
37752
37767
  const countMatches = (value, pattern) => [...value.matchAll(pattern)].length;
37753
37768
  const extractDocumentSignals = (body, contentType, requestedUrl) => {
37754
- const isHtml = contentType.includes("text/html");
37769
+ const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
37755
37770
  const isMarkdown = contentType.includes("text/markdown");
37756
37771
  const looksJson = contentType.includes("json") || requestedUrl.pathname.endsWith(".json");
37757
37772
  const titleMatch = isHtml ? /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body) : null;
@@ -37846,6 +37861,8 @@ const probeHttp = async (request, options) => {
37846
37861
  let currentUrl = request.url;
37847
37862
  let response = null;
37848
37863
  for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
37864
+ if (options.sameOrigin !== void 0 && currentUrl.origin !== options.sameOrigin) throw new Error(`Redirected off-origin to ${currentUrl.href}`);
37865
+ if (options.allowUrl !== void 0 && !options.allowUrl(currentUrl)) throw new Error(`Redirected to robots-disallowed URL ${currentUrl.href}`);
37849
37866
  response = await requestPinned(currentUrl, request, options);
37850
37867
  headersAt = performance.now();
37851
37868
  const location = response.headers.location;
@@ -37863,7 +37880,7 @@ const probeHttp = async (request, options) => {
37863
37880
  } : await readBoundedBody(response, options.maxBodyBytes);
37864
37881
  const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
37865
37882
  const body = new TextDecoder().decode(bounded.bytes);
37866
- const anchors = options.captureAnchors === true && contentType.includes("text/html") ? extractAnchors(body, currentUrl.href) : void 0;
37883
+ const anchors = options.captureAnchors === true && (contentType === "" || /text\/html|application\/xhtml\+xml/i.test(contentType)) ? extractAnchors(body, currentUrl.href) : void 0;
37867
37884
  return {
37868
37885
  kind: request.kind,
37869
37886
  method: request.method,
@@ -37880,6 +37897,7 @@ const probeHttp = async (request, options) => {
37880
37897
  capturedBodySha256: bounded.bytes.byteLength === 0 ? null : createHash("sha256").update(bounded.bytes).digest("hex"),
37881
37898
  bodyTruncated: bounded.truncated,
37882
37899
  bodyExcerpt: bodyExcerpt(body, contentType),
37900
+ ...options.captureBody === true ? { body } : {},
37883
37901
  document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
37884
37902
  anchors,
37885
37903
  error: null
@@ -37901,6 +37919,7 @@ const probeHttp = async (request, options) => {
37901
37919
  capturedBodySha256: null,
37902
37920
  bodyTruncated: false,
37903
37921
  bodyExcerpt: null,
37922
+ ...options.captureBody === true ? { body: void 0 } : {},
37904
37923
  document: null,
37905
37924
  error: errorMessage$2(error)
37906
37925
  };
@@ -42011,6 +42030,845 @@ const layer = /*#__PURE__*/ layerMergedContext(/*#__PURE__*/ succeed$2(/* @__PUR
42011
42030
  return send(void 0);
42012
42031
  })));
42013
42032
  //#endregion
42033
+ //#region src/audit/crawl.ts
42034
+ /** Anchors that are never HTML documents; skipped so they do not consume the budget. */
42035
+ const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
42036
+ /** The graph keys pages by path, so dedupe crawled URLs the same way. */
42037
+ const pageKey = (url) => normalizePath(url);
42038
+ const XML_LOC = /<loc\b[^>]*>([\s\S]*?)<\/loc>/gi;
42039
+ const xmlText = (value) => value.trim().replace(/&(amp|lt|gt|quot|apos|#x[0-9a-f]+|#[0-9]+);/gi, (entity, code) => {
42040
+ const named = {
42041
+ amp: "&",
42042
+ lt: "<",
42043
+ gt: ">",
42044
+ quot: "\"",
42045
+ apos: "'"
42046
+ };
42047
+ if (named[code.toLowerCase()] !== void 0) return named[code.toLowerCase()];
42048
+ const numeric = code.startsWith("#x") || code.startsWith("#X") ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
42049
+ if (!Number.isInteger(numeric) || numeric === 0 || numeric < 32 && numeric !== 9 && numeric !== 10 && numeric !== 13 || numeric >= 55296 && numeric <= 57343 || numeric > 1114111) throw new Error(`Invalid XML character reference: ${entity}`);
42050
+ return String.fromCodePoint(numeric);
42051
+ });
42052
+ const robotsRules = (body) => {
42053
+ const groups = [];
42054
+ const sitemaps = [];
42055
+ let group;
42056
+ let hasDirective = false;
42057
+ for (const raw of body.split(/\r?\n/)) {
42058
+ const line = raw.replace(/\s+#.*$/, "").trim();
42059
+ const match = /^([^:]+):\s*(.*)$/.exec(line);
42060
+ if (!match) continue;
42061
+ const name = match[1].trim().toLowerCase();
42062
+ const value = match[2].trim();
42063
+ if (name === "sitemap") {
42064
+ sitemaps.push(value);
42065
+ continue;
42066
+ }
42067
+ if (name === "user-agent") {
42068
+ if (group === void 0 || hasDirective) {
42069
+ group = {
42070
+ agents: [],
42071
+ rules: []
42072
+ };
42073
+ groups.push(group);
42074
+ hasDirective = false;
42075
+ }
42076
+ group.agents.push(value.toLowerCase());
42077
+ continue;
42078
+ }
42079
+ if (name === "allow" || name === "disallow") {
42080
+ hasDirective = true;
42081
+ if (group !== void 0 && value.startsWith("/")) group.rules.push({
42082
+ path: value,
42083
+ allow: name === "allow"
42084
+ });
42085
+ }
42086
+ }
42087
+ const score = (agent) => agent === "*" ? 0 : "pagegraph/0.4".startsWith(agent) ? agent.length : -1;
42088
+ const best = Math.max(-1, ...groups.flatMap((entry) => entry.agents.map(score)));
42089
+ return {
42090
+ rules: groups.filter((entry) => best >= 0 && entry.agents.some((agent) => score(agent) === best)).flatMap((entry) => entry.rules),
42091
+ sitemaps
42092
+ };
42093
+ };
42094
+ const allowedByRobots = (path, rules) => {
42095
+ let chosen;
42096
+ let bestLength = -1;
42097
+ for (const rule of rules) {
42098
+ const anchored = rule.path.endsWith("$");
42099
+ const parts = (anchored ? rule.path.slice(0, -1) : rule.path).split("*");
42100
+ if (!path.startsWith(parts[0])) continue;
42101
+ let offset = parts[0].length;
42102
+ let matches = true;
42103
+ for (let at = 1; at < parts.length; at++) {
42104
+ const part = parts[at];
42105
+ const found = path.indexOf(part, offset);
42106
+ if (found < 0) {
42107
+ matches = false;
42108
+ break;
42109
+ }
42110
+ offset = found + part.length;
42111
+ }
42112
+ if (!matches || anchored && (parts.length === 1 ? path !== parts[0] : !path.endsWith(parts.at(-1)))) continue;
42113
+ const length = rule.path.replace(/[\*$]/g, "").length;
42114
+ if (length > bestLength || length === bestLength && rule.allow) {
42115
+ chosen = rule;
42116
+ bestLength = length;
42117
+ }
42118
+ }
42119
+ return chosen?.allow ?? true;
42120
+ };
42121
+ /** Run `run` over `items` with at most `limit` in flight, preserving order. */
42122
+ const mapLimit = async (items, limit, run) => {
42123
+ const results = Array.from({ length: items.length });
42124
+ let index = 0;
42125
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
42126
+ for (;;) {
42127
+ const current = index++;
42128
+ if (current >= items.length) return;
42129
+ results[current] = await run(items[current]);
42130
+ }
42131
+ });
42132
+ await Promise.all(workers);
42133
+ return results.map((result, at) => {
42134
+ if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
42135
+ return result;
42136
+ });
42137
+ };
42138
+ /**
42139
+ * Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
42140
+ * successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
42141
+ * private and reserved destinations are rejected unless `allowPrivate` is set.
42142
+ */
42143
+ const crawlRenderedPages = async (seed, options) => {
42144
+ let seedUrl;
42145
+ try {
42146
+ seedUrl = new URL(seed);
42147
+ } catch {
42148
+ throw new Error(`Not a valid URL: ${seed}`);
42149
+ }
42150
+ if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
42151
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
42152
+ const origin = seedUrl.origin;
42153
+ const probeOptions = {
42154
+ allowPrivate: options.allowPrivate,
42155
+ timeoutMs: options.timeoutMs,
42156
+ maxBodyBytes: options.maxBodyBytes,
42157
+ captureAnchors: true,
42158
+ sameOrigin: origin
42159
+ };
42160
+ const concurrency = Math.max(1, options.concurrency ?? 4);
42161
+ const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
42162
+ /** Final paths already rendered, so a redirect cannot add the same page twice. */
42163
+ const rendered = /* @__PURE__ */ new Set();
42164
+ const canonicalByPath = /* @__PURE__ */ new Map();
42165
+ const pages = [];
42166
+ const failures = [];
42167
+ const nonHtml = [];
42168
+ const truncatedBodies = [];
42169
+ const discoveryFailures = [];
42170
+ const skipped = [];
42171
+ let sitemapTruncated = false;
42172
+ let sitemapUsed = false;
42173
+ let attempts = 0;
42174
+ let truncated = false;
42175
+ let root = pageKey(seedUrl);
42176
+ let frontier = [{
42177
+ url: seedUrl,
42178
+ depth: 0
42179
+ }];
42180
+ const discoveryProbe = async (url, kind, allowUrl) => probeHttp({
42181
+ kind,
42182
+ method: "GET",
42183
+ accept: "text/plain, application/xml, text/xml",
42184
+ url
42185
+ }, {
42186
+ ...probeOptions,
42187
+ captureAnchors: false,
42188
+ captureBody: true,
42189
+ allowUrl
42190
+ });
42191
+ const robots = await discoveryProbe(new URL("/robots.txt", origin), "robots");
42192
+ if (!robots.ok && robots.status !== 404) discoveryFailures.push({
42193
+ url: new URL("/robots.txt", origin).href,
42194
+ error: robots.error ?? `HTTP ${robots.status}`
42195
+ });
42196
+ if (robots.bodyTruncated) discoveryFailures.push({
42197
+ url: robots.requestedUrl,
42198
+ error: "robots.txt body truncated"
42199
+ });
42200
+ const policy = robots.ok && !robots.bodyTruncated ? robotsRules(robots.body ?? "") : {
42201
+ rules: [],
42202
+ sitemaps: []
42203
+ };
42204
+ const permitted = (url) => {
42205
+ if (allowedByRobots(`${url.pathname}${url.search}`, policy.rules)) return true;
42206
+ skipped.push(url.href);
42207
+ return false;
42208
+ };
42209
+ const sitemapUrls = [];
42210
+ const sitemapQueue = (policy.sitemaps.length > 0 ? policy.sitemaps : [new URL("/sitemap.xml", origin).href]).flatMap((entry) => {
42211
+ try {
42212
+ return [new URL(entry, origin)];
42213
+ } catch {
42214
+ return [];
42215
+ }
42216
+ });
42217
+ const seenSitemaps = /* @__PURE__ */ new Set();
42218
+ const seenSitemapPages = /* @__PURE__ */ new Set();
42219
+ while (sitemapQueue.length > 0) {
42220
+ const url = sitemapQueue.shift();
42221
+ if (url.origin !== origin) {
42222
+ discoveryFailures.push({
42223
+ url: url.href,
42224
+ error: "Advertised sitemap is off-origin and was not fetched"
42225
+ });
42226
+ continue;
42227
+ }
42228
+ if (seenSitemaps.has(url.href) || !permitted(url)) continue;
42229
+ if (seenSitemaps.size >= Math.max(1, Math.min(options.limit, 16))) {
42230
+ sitemapTruncated = true;
42231
+ break;
42232
+ }
42233
+ seenSitemaps.add(url.href);
42234
+ const probe = await discoveryProbe(url, "sitemap", permitted);
42235
+ if (!probe.ok) {
42236
+ if (probe.status !== 404 || policy.sitemaps.length > 0) discoveryFailures.push({
42237
+ url: url.href,
42238
+ error: probe.error ?? `HTTP ${probe.status}`
42239
+ });
42240
+ continue;
42241
+ }
42242
+ const body = probe.body ?? "";
42243
+ if (probe.bodyTruncated || !/<(?:urlset|sitemapindex)\b/i.test(body)) {
42244
+ discoveryFailures.push({
42245
+ url: url.href,
42246
+ error: probe.bodyTruncated ? "sitemap body truncated" : "malformed sitemap"
42247
+ });
42248
+ continue;
42249
+ }
42250
+ const index = /<sitemapindex\b/i.test(body);
42251
+ const entries = [...body.matchAll(index ? /<sitemap\b[^>]*>([\s\S]*?)<\/sitemap>/gi : /<url\b[^>]*>([\s\S]*?)<\/url>/gi)];
42252
+ for (const entry of entries) {
42253
+ XML_LOC.lastIndex = 0;
42254
+ const loc = XML_LOC.exec(entry[1] ?? "")?.[1];
42255
+ if (loc === void 0) continue;
42256
+ let target;
42257
+ try {
42258
+ if (/&(?!(?:amp|lt|gt|quot|apos|#x[0-9a-f]+|#[0-9]+);)[^;\s]+;/i.test(loc)) throw new Error("unsupported XML entity");
42259
+ const decoded = xmlText(loc);
42260
+ target = new URL(decoded, url);
42261
+ } catch {
42262
+ discoveryFailures.push({
42263
+ url: url.href,
42264
+ error: `Invalid sitemap <loc>: ${loc.slice(0, 120)}`
42265
+ });
42266
+ continue;
42267
+ }
42268
+ if (index) {
42269
+ sitemapQueue.push(target);
42270
+ continue;
42271
+ }
42272
+ if (target.origin !== origin || !permitted(target)) continue;
42273
+ if (NON_PAGE.test(target.pathname)) continue;
42274
+ const key = pageKey(target);
42275
+ if (seenSitemapPages.has(key)) continue;
42276
+ if (seenSitemapPages.size >= options.limit * 4) {
42277
+ sitemapTruncated = true;
42278
+ break;
42279
+ }
42280
+ seenSitemapPages.add(key);
42281
+ sitemapUrls.push(target);
42282
+ }
42283
+ if (sitemapTruncated) break;
42284
+ }
42285
+ sitemapUsed = sitemapUrls.length > 0;
42286
+ const sitemapListed = new Set(sitemapUrls.map(pageKey));
42287
+ if (!permitted(seedUrl)) frontier = [];
42288
+ const probePage = async (item) => {
42289
+ const request = {
42290
+ kind: "page-html",
42291
+ method: "GET",
42292
+ accept: "text/html, */*;q=0.1",
42293
+ url: item.url
42294
+ };
42295
+ const probe = await probeHttp(request, {
42296
+ ...probeOptions,
42297
+ allowUrl: permitted
42298
+ });
42299
+ if (!probe.ok || probe.finalUrl === null) return {
42300
+ item,
42301
+ page: null,
42302
+ failure: {
42303
+ url: item.url.href,
42304
+ error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
42305
+ },
42306
+ links: [],
42307
+ bodyTruncated: false,
42308
+ canonical: null
42309
+ };
42310
+ if (probe.responseHeaders["content-type"] !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(probe.responseHeaders["content-type"])) {
42311
+ const expectedHtml = item.depth === 0 || sitemapListed.has(pageKey(item.url)) || sitemapListed.has(pageKey(new URL(probe.finalUrl)));
42312
+ return {
42313
+ item,
42314
+ page: null,
42315
+ failure: expectedHtml ? {
42316
+ url: item.url.href,
42317
+ error: "Response is not HTML"
42318
+ } : null,
42319
+ ...expectedHtml ? {} : { nonHtml: {
42320
+ url: item.url.href,
42321
+ finalUrl: probe.finalUrl,
42322
+ contentType: probe.responseHeaders["content-type"]
42323
+ } },
42324
+ links: [],
42325
+ bodyTruncated: false,
42326
+ canonical: null
42327
+ };
42328
+ }
42329
+ if (new URL(probe.finalUrl).origin !== origin) return {
42330
+ item,
42331
+ page: null,
42332
+ failure: {
42333
+ url: item.url.href,
42334
+ error: `Redirected off-origin to ${probe.finalUrl}`
42335
+ },
42336
+ links: [],
42337
+ bodyTruncated: false,
42338
+ canonical: null
42339
+ };
42340
+ const anchors = probe.anchors ?? [];
42341
+ const links = anchors.flatMap((anchor) => {
42342
+ if (!anchor.internal) return [];
42343
+ let target;
42344
+ try {
42345
+ target = new URL(anchor.href);
42346
+ } catch {
42347
+ return [];
42348
+ }
42349
+ if (target.origin !== origin) return [];
42350
+ if (NON_PAGE.test(target.pathname)) return [];
42351
+ return [target];
42352
+ });
42353
+ return {
42354
+ item,
42355
+ page: {
42356
+ url: probe.finalUrl,
42357
+ anchors
42358
+ },
42359
+ failure: null,
42360
+ links,
42361
+ bodyTruncated: probe.bodyTruncated,
42362
+ canonical: probe.document?.canonicalUrl ?? null
42363
+ };
42364
+ };
42365
+ while (frontier.length > 0) {
42366
+ const remaining = options.limit - attempts;
42367
+ if (remaining <= 0) {
42368
+ truncated = true;
42369
+ break;
42370
+ }
42371
+ const batch = frontier.length > remaining ? frontier.slice(0, remaining) : frontier;
42372
+ if (frontier.length > batch.length) truncated = true;
42373
+ frontier = [];
42374
+ attempts += batch.length;
42375
+ const probed = await mapLimit(batch, concurrency, probePage);
42376
+ const discovered = [];
42377
+ for (const result of probed) {
42378
+ if (result.failure !== null) failures.push(result.failure);
42379
+ if (result.nonHtml !== void 0) nonHtml.push(result.nonHtml);
42380
+ if (result.page === null) continue;
42381
+ const page = result.page;
42382
+ const finalKey = pageKey(new URL(page.url));
42383
+ if (result.canonical !== null) try {
42384
+ const canonical = new URL(result.canonical, page.url);
42385
+ if (canonical.origin === origin && pageKey(canonical) !== finalKey) canonicalByPath.set(finalKey, pageKey(canonical));
42386
+ } catch {}
42387
+ if (result.item.depth === 0) root = finalKey;
42388
+ if (rendered.has(finalKey)) continue;
42389
+ rendered.add(finalKey);
42390
+ visited.add(finalKey);
42391
+ pages.push(page);
42392
+ if (result.bodyTruncated) truncatedBodies.push(page.url);
42393
+ for (const link of result.links) {
42394
+ if (!permitted(link)) continue;
42395
+ const key = pageKey(link);
42396
+ if (visited.has(key)) continue;
42397
+ visited.add(key);
42398
+ discovered.push({
42399
+ url: link,
42400
+ depth: result.item.depth + 1
42401
+ });
42402
+ }
42403
+ }
42404
+ if (attempts === 1) for (const url of sitemapUrls) {
42405
+ const key = pageKey(url);
42406
+ if (visited.has(key) || !permitted(url)) continue;
42407
+ visited.add(key);
42408
+ discovered.push({
42409
+ url,
42410
+ depth: 1
42411
+ });
42412
+ }
42413
+ frontier = discovered;
42414
+ }
42415
+ const renderedByPath = new Map(pages.map((page) => [pageKey(new URL(page.url)), page]));
42416
+ const retainedPages = pages.filter((page) => {
42417
+ const canonical = canonicalByPath.get(pageKey(new URL(page.url)));
42418
+ const canonicalPage = canonical === void 0 ? void 0 : renderedByPath.get(canonical);
42419
+ return canonicalPage === void 0 || JSON.stringify(page.anchors) !== JSON.stringify(canonicalPage.anchors);
42420
+ });
42421
+ const canonicalRoot = canonicalByPath.get(root);
42422
+ if (canonicalRoot !== void 0 && !retainedPages.some((page) => pageKey(new URL(page.url)) === root)) root = canonicalRoot;
42423
+ return {
42424
+ origin,
42425
+ seed: seedUrl.href,
42426
+ root,
42427
+ pages: retainedPages,
42428
+ failures,
42429
+ nonHtml,
42430
+ truncatedBodies,
42431
+ truncated,
42432
+ discoveryFailures,
42433
+ skipped: [...new Set(skipped)],
42434
+ sitemapTruncated,
42435
+ sitemapUsed,
42436
+ limit: options.limit
42437
+ };
42438
+ };
42439
+ //#endregion
42440
+ //#region src/core/link-candidates.ts
42441
+ /**
42442
+ * Canonical path key: query and hash stripped, trailing slashes removed, "/"
42443
+ * preserved. This is the same path key the graph and the rendered-link core
42444
+ * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
42445
+ * `/blog/a`.
42446
+ */
42447
+ const normalize = (path) => {
42448
+ const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
42449
+ return trimmed === "" ? "/" : trimmed;
42450
+ };
42451
+ const directedEdgeKey = (from, to) => `${normalize(from)}\u0000${normalize(to)}`;
42452
+ /** First path segment, or undefined for the root page. */
42453
+ const topSegment = (path) => {
42454
+ const segment = path.split("/").filter(Boolean)[0];
42455
+ return segment === void 0 || segment === "" ? void 0 : segment;
42456
+ };
42457
+ /** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
42458
+ const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
42459
+ /**
42460
+ * The cluster a pair shares, or undefined when they share none. Section-first:
42461
+ * a shared top-level section wins. The kind fallback is local to root-level
42462
+ * pages, so it never pairs two nested pages from different sections.
42463
+ */
42464
+ const candidateClusterOf = (a, b) => {
42465
+ const aSegment = topSegment(a.path);
42466
+ const bSegment = topSegment(b.path);
42467
+ if (aSegment !== void 0 && aSegment === bSegment) return {
42468
+ key: aSegment,
42469
+ reason: `same top-level section "/${aSegment}"`
42470
+ };
42471
+ if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
42472
+ key: `kind:${a.kind}`,
42473
+ reason: `same kind "${a.kind}"`
42474
+ };
42475
+ };
42476
+ /** A `--cluster` filter matches a section by name, or a kind via its bare label. */
42477
+ const matchesClusterFilter = (cluster, filter) => {
42478
+ const normalized = filter.replace(/^\/+/, "").toLowerCase();
42479
+ const key = cluster.toLowerCase();
42480
+ return key === normalized || key === `kind:${normalized}`;
42481
+ };
42482
+ /**
42483
+ * Enumerate reviewable contextual-link candidates from the declared graph.
42484
+ *
42485
+ * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
42486
+ * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
42487
+ * is supplied — pairs already rendered as an anchor. Queries, hashes, and
42488
+ * trailing slashes are normalized so both sides compare by the graph's path key.
42489
+ */
42490
+ const generateLinkCandidates = (graph, options = {}) => {
42491
+ const limit = options.limit ?? 50;
42492
+ const filters = options.clusters ?? [];
42493
+ const rendered = new Set((options.renderedEdges ?? []).filter((edge) => edge.region === void 0 || edge.region === "body").map((edge) => directedEdgeKey(edge.from, edge.to)));
42494
+ const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => directedEdgeKey(edge.from, edge.to)));
42495
+ const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
42496
+ const all = [];
42497
+ for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
42498
+ const a = eligible[i];
42499
+ const b = eligible[j];
42500
+ const cluster = candidateClusterOf(a, b);
42501
+ if (cluster === void 0) continue;
42502
+ if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
42503
+ for (const [source, destination] of [[a.path, b.path], [b.path, a.path]]) {
42504
+ const key = directedEdgeKey(source, destination);
42505
+ if (declared.has(key) || rendered.has(key)) continue;
42506
+ all.push({
42507
+ source,
42508
+ destination,
42509
+ cluster: cluster.key,
42510
+ reason: cluster.reason
42511
+ });
42512
+ }
42513
+ }
42514
+ const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
42515
+ all.sort(compare);
42516
+ const counts = /* @__PURE__ */ new Map();
42517
+ for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
42518
+ const candidates = all.slice(0, limit);
42519
+ return {
42520
+ candidates,
42521
+ total: all.length,
42522
+ truncated: all.length > candidates.length,
42523
+ clusters: [...counts.entries()].map(([key, count]) => ({
42524
+ key,
42525
+ candidates: count
42526
+ }))
42527
+ };
42528
+ };
42529
+ /**
42530
+ * Decode a rendered-edge dump supplied to the CLI: either a bare array of
42531
+ * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
42532
+ * Throws on any other shape — this is user input, not a provider payload.
42533
+ */
42534
+ const decodeRenderedEdges = (input) => {
42535
+ const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
42536
+ if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
42537
+ return array.map((item) => {
42538
+ if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
42539
+ const { from, to, region } = item;
42540
+ if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
42541
+ if (region !== void 0 && ![
42542
+ "nav",
42543
+ "header",
42544
+ "footer",
42545
+ "body"
42546
+ ].includes(region)) throw new Error("rendered edge region must be nav, header, footer, or body");
42547
+ return region === void 0 ? {
42548
+ from,
42549
+ to
42550
+ } : {
42551
+ from,
42552
+ to,
42553
+ region
42554
+ };
42555
+ });
42556
+ };
42557
+ //#endregion
42558
+ //#region src/core/link-suggestions.ts
42559
+ /** Select pages around low-inbound directions without materializing the full pair universe. */
42560
+ const selectSuggestionPages = (graph, options) => {
42561
+ const eligible = [...graph.nodes.values()].filter(isSitemapEligible).sort((a, b) => a.path.localeCompare(b.path));
42562
+ const byPath = new Map(eligible.map((node) => [node.path, node]));
42563
+ for (const path of [...options.targets, ...options.sources]) if (!byPath.has(path)) throw new Error(`Requested page is not a sitemap-eligible graph path: ${path}`);
42564
+ const selected = /* @__PURE__ */ new Set([...options.targets, ...options.sources]);
42565
+ if (selected.size > options.pageLimit || options.targets.size > 0 && options.sources.size === 0 && options.targets.size >= options.pageLimit) throw new Error("--page-limit must leave room for a candidate source beyond requested targets");
42566
+ const normalized = (path) => path.split(/[?#]/, 1)[0].replace(/\/+$/, "") || "/";
42567
+ const key = (source, destination) => `${normalized(source)}\u0000${normalized(destination)}`;
42568
+ const excluded = /* @__PURE__ */ new Set([...graph.edges.filter((edge) => edge.type === "related").map((edge) => key(edge.from, edge.to)), ...options.renderedEdges.filter((edge) => edge.region === void 0 || edge.region === "body").map((edge) => key(edge.from, edge.to))]);
42569
+ const inbound = /* @__PURE__ */ new Map();
42570
+ for (const edge of graph.edges) if (edge.type === "related") inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
42571
+ const sections = /* @__PURE__ */ new Map();
42572
+ const rootKinds = /* @__PURE__ */ new Map();
42573
+ for (const node of eligible) {
42574
+ const segments = node.path.split("/").filter(Boolean);
42575
+ const section = segments[0];
42576
+ if (section) {
42577
+ if (!sections.has(section)) sections.set(section, []);
42578
+ sections.get(section).push(node);
42579
+ }
42580
+ if (segments.length <= 1) {
42581
+ if (!rootKinds.has(node.kind)) rootKinds.set(node.kind, []);
42582
+ rootKinds.get(node.kind).push(node);
42583
+ }
42584
+ }
42585
+ const requestedSources = eligible.filter((node) => options.sources.has(node.path));
42586
+ const requestedSections = /* @__PURE__ */ new Map();
42587
+ const requestedRootKinds = /* @__PURE__ */ new Map();
42588
+ const relevant = /* @__PURE__ */ new Map();
42589
+ for (const source of requestedSources) {
42590
+ const segments = source.path.split("/").filter(Boolean);
42591
+ const section = segments[0];
42592
+ if (section) {
42593
+ if (!requestedSections.has(section)) requestedSections.set(section, []);
42594
+ requestedSections.get(section).push(source);
42595
+ for (const node of sections.get(section) ?? []) relevant.set(node.path, node);
42596
+ }
42597
+ if (segments.length <= 1) {
42598
+ if (!requestedRootKinds.has(source.kind)) requestedRootKinds.set(source.kind, []);
42599
+ requestedRootKinds.get(source.kind).push(source);
42600
+ for (const node of rootKinds.get(source.kind) ?? []) relevant.set(node.path, node);
42601
+ }
42602
+ }
42603
+ const relevantDestinations = options.sources.size === 0 ? eligible : [...relevant.values()];
42604
+ const destinations = (options.targets.size === 0 ? relevantDestinations : relevantDestinations.filter((node) => options.targets.has(node.path))).sort((a, b) => (inbound.get(a.path) ?? 0) - (inbound.get(b.path) ?? 0) || a.path.localeCompare(b.path));
42605
+ const directionBudget = Math.max(100, options.pageLimit * 100);
42606
+ let examined = 0;
42607
+ for (const destination of destinations) {
42608
+ if (selected.size >= options.pageLimit || examined >= directionBudget) break;
42609
+ const segments = destination.path.split("/").filter(Boolean);
42610
+ const section = segments[0];
42611
+ const sources = [...new Map([...section ? (options.sources.size > 0 ? requestedSections : sections).get(section) ?? [] : [], ...segments.length <= 1 ? (options.sources.size > 0 ? requestedRootKinds : rootKinds).get(destination.kind) ?? [] : []].map((node) => [node.path, node])).values()];
42612
+ for (const source of sources) {
42613
+ if (source.path === destination.path) continue;
42614
+ if (++examined > directionBudget) break;
42615
+ const cluster = candidateClusterOf(source, destination);
42616
+ if (!cluster || options.clusters.length > 0 && !options.clusters.some((filter) => matchesClusterFilter(cluster.key, filter)) || excluded.has(key(source.path, destination.path))) continue;
42617
+ const needed = Number(!selected.has(source.path)) + Number(!selected.has(destination.path));
42618
+ if (selected.size + needed > options.pageLimit) continue;
42619
+ selected.add(source.path);
42620
+ selected.add(destination.path);
42621
+ if (selected.size >= options.pageLimit) break;
42622
+ }
42623
+ }
42624
+ return eligible.filter((node) => selected.has(node.path));
42625
+ };
42626
+ const decode = (text) => text.replace(/&nbsp;|&#160;/gi, " ").replace(/&amp;/gi, "&").replace(/&lt;/gi, "<").replace(/&gt;/gi, ">").replace(/&quot;/gi, "\"").replace(/&#39;|&apos;/gi, "'").replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
42627
+ /** Restrict placements to visible main/article copy, ignoring site chrome and scripts. */
42628
+ const extractPageSentences = (html) => {
42629
+ const visibleHtml = html.replace(/<(script|style|noscript|svg|nav|header|footer|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
42630
+ const containerHtml = visibleHtml.replace(/<(pre|code|table|dl)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
42631
+ const container = /<main\b/i.test(containerHtml) ? "main" : "article";
42632
+ if (!new RegExp(`<${container}\\b`, "i").test(containerHtml)) return [];
42633
+ const suppressedTags = /* @__PURE__ */ new Set([
42634
+ "script",
42635
+ "style",
42636
+ "noscript",
42637
+ "svg",
42638
+ "nav",
42639
+ "header",
42640
+ "footer",
42641
+ "template",
42642
+ "a",
42643
+ "pre",
42644
+ "code",
42645
+ "table",
42646
+ "dl",
42647
+ "kbd",
42648
+ "samp"
42649
+ ]);
42650
+ const blockTags = /* @__PURE__ */ new Set([
42651
+ "p",
42652
+ "li",
42653
+ "blockquote",
42654
+ "section",
42655
+ "div",
42656
+ "h1",
42657
+ "h2",
42658
+ "h3",
42659
+ "h4",
42660
+ "h5",
42661
+ "h6"
42662
+ ]);
42663
+ const voidTags = /* @__PURE__ */ new Set([
42664
+ "area",
42665
+ "base",
42666
+ "br",
42667
+ "col",
42668
+ "embed",
42669
+ "hr",
42670
+ "img",
42671
+ "input",
42672
+ "link",
42673
+ "meta",
42674
+ "param",
42675
+ "source",
42676
+ "track",
42677
+ "wbr"
42678
+ ]);
42679
+ const stack = [];
42680
+ const blocks = [];
42681
+ let current = "";
42682
+ let inside = 0;
42683
+ const flush = () => {
42684
+ if (current.trim()) blocks.push(current);
42685
+ current = "";
42686
+ };
42687
+ for (const token of visibleHtml.match(/<[^>]*>|[^<]+/g) ?? []) {
42688
+ if (!token.startsWith("<")) {
42689
+ if (inside > 0 && !stack.some((entry) => entry.suppressed)) current += token;
42690
+ continue;
42691
+ }
42692
+ const tag = /^<\s*(\/?)\s*([a-z][\w:-]*)\b([^>]*)>/i.exec(token);
42693
+ if (!tag) continue;
42694
+ const name = tag[2].toLowerCase();
42695
+ if (tag[1] === "/") {
42696
+ if (name === container && inside > 0) inside--;
42697
+ const at = stack.findLastIndex((entry) => entry.name === name);
42698
+ if (blockTags.has(name) || suppressedTags.has(name) || name === container || at >= 0 && stack[at].suppressed) flush();
42699
+ if (at >= 0) stack.splice(at);
42700
+ continue;
42701
+ }
42702
+ const attrs = tag[3] ?? "";
42703
+ const suppressed = /(?:^|\s)hidden(?:\s|=|$)/i.test(attrs) || /\baria-hidden\s*=\s*(?:"true"|'true'|true)(?:\s|$)/i.test(attrs) || /\bstyle\s*=\s*(?:"[^"]*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^"]*"|'[^']*(?:display\s*:\s*none|visibility\s*:\s*hidden)[^']*')/i.test(attrs) || suppressedTags.has(name) || /\brole\s*=\s*(?:"(?:table|grid)"|'(?:table|grid)'|(?:table|grid))(?:\s|$)/i.test(attrs);
42704
+ if (name === container) {
42705
+ flush();
42706
+ inside++;
42707
+ }
42708
+ if (suppressed || blockTags.has(name)) flush();
42709
+ if (!voidTags.has(name) && !/\/\s*>$/.test(token)) stack.push({
42710
+ name,
42711
+ suppressed
42712
+ });
42713
+ }
42714
+ flush();
42715
+ return blocks.flatMap((block) => decode(block).replace(/\s+/g, " ").split(/(?<=[.!?])\s+/)).map((part) => part.trim()).filter((part) => part.length >= 30 && part.length <= 500 && !/;\s*(?:or|and)?$/i.test(part));
42716
+ };
42717
+ const stop = new Set("a an as at about after again also and are but by can for from had has have in into is it more most of on or our over that the their them there these this those through to was we were with your you what when where which while will would".split(" "));
42718
+ const words = (value) => (value.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((word) => !stop.has(word));
42719
+ const clauseBreaks = /* @__PURE__ */ new Set([
42720
+ "unless",
42721
+ "because",
42722
+ "although",
42723
+ "whereas",
42724
+ "whether",
42725
+ "until",
42726
+ "however",
42727
+ "therefore",
42728
+ "otherwise"
42729
+ ]);
42730
+ const topicConnectors = /* @__PURE__ */ new Set([
42731
+ "a",
42732
+ "an",
42733
+ "and",
42734
+ "the"
42735
+ ]);
42736
+ const normalizeHyphens = (value) => value.replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, "-");
42737
+ const anchorPhrases = (sentence) => {
42738
+ const tokens = [...sentence.matchAll(/[A-Za-z][A-Za-z0-9\u2010-\u2015\u2212\uFE58\uFE63\uFF0D-]*/g)].map((match) => ({
42739
+ text: match[0],
42740
+ start: match.index,
42741
+ end: match.index + match[0].length
42742
+ }));
42743
+ const phrases = [];
42744
+ for (let start = 0; start < tokens.length; start++) {
42745
+ if (stop.has(tokens[start].text.toLowerCase()) || clauseBreaks.has(tokens[start].text.toLowerCase())) continue;
42746
+ if (new Set(words(tokens[start].text)).size >= 2) phrases.push(tokens[start].text);
42747
+ for (let end = start + 1; end < Math.min(tokens.length, start + 5); end++) {
42748
+ if (!/^\s+$/.test(sentence.slice(tokens[end - 1].end, tokens[end].start))) break;
42749
+ if (clauseBreaks.has(tokens[end].text.toLowerCase())) break;
42750
+ if (stop.has(tokens[end].text.toLowerCase())) continue;
42751
+ const phrase = sentence.slice(tokens[start].start, tokens[end].end);
42752
+ if (new Set(words(phrase)).size >= 2) phrases.push(phrase);
42753
+ }
42754
+ }
42755
+ return phrases;
42756
+ };
42757
+ const indexTargetPage = (page) => {
42758
+ const passages = page.sentences.map((sentence) => ({
42759
+ sentence,
42760
+ normalized: normalizeHyphens(sentence.toLowerCase()).replace(/\s+/g, " ").trim(),
42761
+ terms: new Set(words(sentence))
42762
+ }));
42763
+ const byTerm = /* @__PURE__ */ new Map();
42764
+ passages.forEach((passage, index) => {
42765
+ for (const term of passage.terms) {
42766
+ if (!byTerm.has(term)) byTerm.set(term, []);
42767
+ byTerm.get(term).push(index);
42768
+ }
42769
+ });
42770
+ return {
42771
+ passages,
42772
+ byTerm,
42773
+ terms: new Set(passages.flatMap((passage) => [...passage.terms]))
42774
+ };
42775
+ };
42776
+ /** Deterministic, content-grounded ranking; no placement is emitted without shared terms. */
42777
+ const rankLinkSuggestions = (pairs, pages, inbound, limit) => {
42778
+ const byPath = new Map(pages.map((page) => [page.path, page]));
42779
+ const frequency = /* @__PURE__ */ new Map();
42780
+ for (const page of pages) for (const term of new Set(page.sentences.flatMap(words))) frequency.set(term, (frequency.get(term) ?? 0) + 1);
42781
+ const targetIndexes = /* @__PURE__ */ new Map();
42782
+ const scored = [];
42783
+ for (const pair of pairs) {
42784
+ const source = byPath.get(pair.source);
42785
+ const target = byPath.get(pair.destination);
42786
+ if (!source || !target || pair.destination === "/") continue;
42787
+ const destinationSegments = pair.destination.split("/").filter(Boolean);
42788
+ const destinationSlug = destinationSegments.at(-1) ?? "";
42789
+ const destinationTerms = words(destinationSlug.replace(/[-_]/g, " "));
42790
+ if (destinationSegments.length === 1 && destinationTerms.length === 0) continue;
42791
+ let targetIndex = targetIndexes.get(pair.destination);
42792
+ if (!targetIndex) {
42793
+ targetIndex = indexTargetPage(target);
42794
+ targetIndexes.set(pair.destination, targetIndex);
42795
+ }
42796
+ const namedSection = destinationSegments.length >= 2 && ["compare", "legal"].includes(destinationSegments[0]);
42797
+ const namedDestination = destinationTerms.filter((term) => destinationSegments.length === 1 || namedSection || term.length > 3 && targetIndex.terms.has(term));
42798
+ let best;
42799
+ for (const sentence of source.sentences) {
42800
+ if ([...new Set(words(sentence))].filter((term) => targetIndex.terms.has(term)).length < 2) continue;
42801
+ const normalizedSource = normalizeHyphens(sentence.toLowerCase()).replace(/\s+/g, " ").trim();
42802
+ for (const anchor of anchorPhrases(sentence)) {
42803
+ const anchorTerms = [...new Set(words(anchor))];
42804
+ const passageIndex = (anchorTerms.map((term) => targetIndex.byTerm.get(term) ?? []).sort((a, b) => a.length - b.length)[0] ?? []).find((index) => anchorTerms.every((term) => targetIndex.passages[index].terms.has(term)));
42805
+ if (passageIndex === void 0) continue;
42806
+ const passage = targetIndex.passages[passageIndex];
42807
+ if (namedDestination.length > 0 && !anchorTerms.some((term) => namedDestination.includes(term))) {
42808
+ if (destinationSegments.length === 1 || namedSection) continue;
42809
+ const targetWords = (passage.sentence.toLowerCase().match(/[a-z][a-z0-9]*/g) ?? []).filter((term) => !topicConnectors.has(term));
42810
+ if (!targetWords.some((term, index) => {
42811
+ if (!namedDestination.includes(term)) return false;
42812
+ return [[index - 2, index - 1], [index + 1, index + 2]].some(([first, second]) => anchorTerms.includes(targetWords[first] ?? "") && anchorTerms.includes(targetWords[second] ?? ""));
42813
+ })) continue;
42814
+ }
42815
+ if (!(normalizeHyphens(anchor).match(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g) ?? []).every((compound) => passage.normalized.includes(compound.toLowerCase()))) continue;
42816
+ if (normalizedSource === passage.normalized) continue;
42817
+ const topical = anchorTerms.length;
42818
+ const rarity = Math.round(anchorTerms.reduce((sum, term) => sum + 1 / (frequency.get(term) ?? 1), 0) * 100) / 100;
42819
+ const inboundNeed = Math.round(100 / (1 + (inbound.get(pair.destination) ?? 0))) / 100;
42820
+ const graph = pair.cluster.startsWith("kind:") ? .5 : 1;
42821
+ const scores = {
42822
+ topical,
42823
+ rarity,
42824
+ inboundNeed,
42825
+ graph
42826
+ };
42827
+ const score = Math.round((topical + rarity + inboundNeed + graph) * 100) / 100;
42828
+ const item = {
42829
+ ...pair,
42830
+ sentence,
42831
+ anchor,
42832
+ targetSentence: passage.sentence,
42833
+ score,
42834
+ scores,
42835
+ reason: `${pair.reason}; ${anchorTerms.join(", ")} connect the served passages`
42836
+ };
42837
+ if (!best || item.score > best.score || item.score === best.score && (item.anchor.length < best.anchor.length || item.anchor.length === best.anchor.length && (item.sentence < best.sentence || item.sentence === best.sentence && item.anchor < best.anchor))) best = item;
42838
+ }
42839
+ }
42840
+ if (best) scored.push(best);
42841
+ }
42842
+ scored.sort((a, b) => b.score - a.score || a.source.localeCompare(b.source) || a.destination.localeCompare(b.destination));
42843
+ return {
42844
+ candidates: scored.slice(0, limit),
42845
+ total: scored.length
42846
+ };
42847
+ };
42848
+ const pathValid = (value) => typeof value === "string" && /^\/(?!\/)[^?#]*$/.test(value);
42849
+ const decodeLinksSuggestionReport = (value, origin) => {
42850
+ if (!value || typeof value !== "object") throw new Error("Suggestion report must be an object");
42851
+ const report = value;
42852
+ if (report.kind !== "links-candidates" || report.schemaVersion !== 2 || report.origin !== origin || !Array.isArray(report.candidates)) throw new Error(`Expected schema-version-2 suggestions for ${origin}`);
42853
+ if (!Number.isSafeInteger(report.limit) || report.limit < 1 || !Number.isSafeInteger(report.pageLimit) || report.pageLimit < 1 || report.maxBodyBytes !== void 0 && (!Number.isSafeInteger(report.maxBodyBytes) || report.maxBodyBytes < 1 || report.maxBodyBytes > 1e7) || !Number.isSafeInteger(report.total) || report.total < report.candidates.length || typeof report.truncated !== "boolean" || !Array.isArray(report.skipped)) throw new Error("Invalid suggestion report counts");
42854
+ const seen = /* @__PURE__ */ new Set();
42855
+ for (const candidate of report.candidates) {
42856
+ if (!pathValid(candidate.source) || !pathValid(candidate.destination) || candidate.source === candidate.destination || typeof candidate.cluster !== "string" || typeof candidate.sentence !== "string" || !candidate.sentence.trim() || typeof candidate.anchor !== "string" || !candidate.anchor.trim() || !candidate.sentence.includes(candidate.anchor) || typeof candidate.targetSentence !== "string" || !candidate.targetSentence.trim() || typeof candidate.reason !== "string" || typeof candidate.score !== "number" || !Number.isFinite(candidate.score) || !candidate.scores || [
42857
+ candidate.scores.topical,
42858
+ candidate.scores.rarity,
42859
+ candidate.scores.inboundNeed,
42860
+ candidate.scores.graph
42861
+ ].some((part) => typeof part !== "number" || !Number.isFinite(part))) throw new Error("Malformed or ungrounded suggestion candidate");
42862
+ const key = `${candidate.source}\u0000${candidate.destination}\u0000${candidate.anchor}`;
42863
+ if (seen.has(key)) throw new Error("Duplicate suggestion candidate");
42864
+ seen.add(key);
42865
+ }
42866
+ return {
42867
+ ...report,
42868
+ maxBodyBytes: report.maxBodyBytes ?? 1e7
42869
+ };
42870
+ };
42871
+ //#endregion
42014
42872
  //#region src/workflows/prompts/summary.md
42015
42873
  var summary_default = "# PageGraph {{workflow}}\n\n- Run: `{{runId}}`\n- Model: `{{model}}`\n- Pages: {{pages}}\n- Decisions: {{decisions}} ({{review}} review)\n- Executor searches: {{executorSearches}}\n- Executor calls: {{executorCalls}}\n- Changed files: {{changedFiles}}\n\n{{summary}}\n";
42016
42874
  //#endregion
@@ -42084,6 +42942,20 @@ const writeRunBundle = (root, runsDirectory, run) => {
42084
42942
  writeFileSync(resolve(directory, "summary.md"), summary(run), "utf8");
42085
42943
  return directory;
42086
42944
  };
42945
+ const writeResearchCheckpoint = (root, runsDirectory, checkpoint) => {
42946
+ const directory = resolve(root, runsDirectory, checkpoint.id);
42947
+ mkdirSync(directory, { recursive: true });
42948
+ const path = resolve(directory, "research.json");
42949
+ writeFileSync(path, `${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
42950
+ return path;
42951
+ };
42952
+ const writeResearchFailure = (root, runsDirectory, id, details) => {
42953
+ const directory = resolve(root, runsDirectory, id);
42954
+ mkdirSync(directory, { recursive: true });
42955
+ const path = resolve(directory, "research-failure.json");
42956
+ writeFileSync(path, `${JSON.stringify(details, null, 2)}\n`, "utf8");
42957
+ return path;
42958
+ };
42087
42959
  //#endregion
42088
42960
  //#region node_modules/effect/dist/unstable/ai/Decision.js
42089
42961
  /**
@@ -43281,6 +44153,30 @@ const selectGraph = (graph, options) => {
43281
44153
  collisions: graph.collisions
43282
44154
  };
43283
44155
  };
44156
+ const selectNeighborhood = (graph, targetPaths) => {
44157
+ const inbound = [];
44158
+ const outbound = [];
44159
+ for (const edge of graph.edges) {
44160
+ if (targetPaths.has(edge.to) && !targetPaths.has(edge.from)) {
44161
+ const node = graph.nodes.get(edge.from);
44162
+ if (node !== void 0) inbound.push({
44163
+ edge,
44164
+ node: serializeNode(node)
44165
+ });
44166
+ }
44167
+ if (targetPaths.has(edge.from) && !targetPaths.has(edge.to)) {
44168
+ const node = graph.nodes.get(edge.to);
44169
+ if (node !== void 0) outbound.push({
44170
+ edge,
44171
+ node: serializeNode(node)
44172
+ });
44173
+ }
44174
+ }
44175
+ return {
44176
+ inbound,
44177
+ outbound
44178
+ };
44179
+ };
43284
44180
  const collectContextFiles = (root, workflow, context) => {
43285
44181
  const paths = [...context?.files ?? [], ...context?.byWorkflow?.[workflow] ?? []];
43286
44182
  return [...new Set(paths)].map((path) => ({
@@ -43288,14 +44184,18 @@ const collectContextFiles = (root, workflow, context) => {
43288
44184
  content: readFileSync(resolve(root, path), "utf8")
43289
44185
  }));
43290
44186
  };
43291
- const collectWorkflowEvidence = (graph, options, root, workflow, context) => ({
43292
- graph: serializeGraph(selectGraph(graph, options)),
43293
- sources: collectContextFiles(root, workflow, context)
43294
- });
44187
+ const collectWorkflowEvidence = (graph, options, root, workflow, context) => {
44188
+ const selected = selectGraph(graph, options);
44189
+ return {
44190
+ graph: serializeGraph(selected),
44191
+ neighborhood: selectNeighborhood(graph, new Set(selected.nodes.keys())),
44192
+ sources: collectContextFiles(root, workflow, context)
44193
+ };
44194
+ };
43295
44195
  //#endregion
43296
44196
  //#region src/workflows/git.ts
43297
- const run$1 = (root, args) => execFileSync("git", args, {
43298
- cwd: root,
44197
+ const run$1 = (cwd, args) => execFileSync("git", args, {
44198
+ cwd,
43299
44199
  encoding: "utf8",
43300
44200
  stdio: [
43301
44201
  "ignore",
@@ -43304,38 +44204,36 @@ const run$1 = (root, args) => execFileSync("git", args, {
43304
44204
  ]
43305
44205
  }).trim();
43306
44206
  const nul = (value) => value.split("\0").filter((item) => item.length > 0);
43307
- const fingerprint = (root, path) => {
44207
+ const fingerprint = (root, gitRoot, path) => {
43308
44208
  const absolute = resolve(root, path);
43309
- const content = existsSync(absolute) ? readFileSync(absolute) : Buffer.from("<deleted>");
43310
- const status = run$1(root, [
44209
+ const gitPath = relative(gitRoot, absolute);
44210
+ const status = run$1(gitRoot, [
43311
44211
  "status",
43312
44212
  "--short",
43313
44213
  "--",
43314
- path
44214
+ gitPath
43315
44215
  ]);
44216
+ let content;
44217
+ if (!existsSync(absolute)) content = Buffer.from("<deleted>");
44218
+ else if (statSync(absolute).isDirectory()) {
44219
+ const stage = run$1(gitRoot, [
44220
+ "ls-files",
44221
+ "--stage",
44222
+ "--",
44223
+ gitPath
44224
+ ]);
44225
+ if (stage.split(/\s+/, 1)[0] !== "160000") throw new Error(`Cannot fingerprint directory reported as a Git file: ${path}`);
44226
+ const submoduleRoot = realpathSync(run$1(absolute, ["rev-parse", "--show-toplevel"]));
44227
+ if (submoduleRoot !== realpathSync(absolute)) throw new Error(`Git submodule is unavailable: ${path}`);
44228
+ content = Buffer.from(`gitlink:${stage}\0${JSON.stringify(inspectGit(submoduleRoot))}`);
44229
+ } else content = readFileSync(absolute);
43316
44230
  return createHash("sha256").update(status).update("\0").update(content).digest("hex");
43317
44231
  };
43318
44232
  const inspectGit = (root) => {
44233
+ const absoluteRoot = realpathSync(root);
44234
+ let gitTopLevel;
43319
44235
  try {
43320
- const tracked = nul(run$1(root, [
43321
- "diff",
43322
- "--name-only",
43323
- "-z",
43324
- "HEAD"
43325
- ]));
43326
- const untracked = nul(run$1(root, [
43327
- "ls-files",
43328
- "--others",
43329
- "--exclude-standard",
43330
- "-z"
43331
- ]));
43332
- const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort();
43333
- return {
43334
- head: run$1(root, ["rev-parse", "HEAD"]),
43335
- dirty: files.length > 0,
43336
- files,
43337
- fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(root, path)]))
43338
- };
44236
+ gitTopLevel = run$1(absoluteRoot, ["rev-parse", "--show-toplevel"]);
43339
44237
  } catch {
43340
44238
  return {
43341
44239
  dirty: false,
@@ -43343,6 +44241,27 @@ const inspectGit = (root) => {
43343
44241
  fingerprints: {}
43344
44242
  };
43345
44243
  }
44244
+ const gitRoot = realpathSync(gitTopLevel);
44245
+ const tracked = nul(run$1(gitRoot, [
44246
+ "diff",
44247
+ "--name-only",
44248
+ "-z",
44249
+ "HEAD"
44250
+ ]));
44251
+ const untracked = nul(run$1(gitRoot, [
44252
+ "ls-files",
44253
+ "--others",
44254
+ "--exclude-standard",
44255
+ "--full-name",
44256
+ "-z"
44257
+ ]));
44258
+ const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].map((path) => relative(absoluteRoot, resolve(gitRoot, path))).sort();
44259
+ return {
44260
+ head: run$1(gitRoot, ["rev-parse", "HEAD"]),
44261
+ dirty: files.length > 0,
44262
+ files,
44263
+ fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(absoluteRoot, gitRoot, path)]))
44264
+ };
43346
44265
  };
43347
44266
  const changedFiles = (before, after) => [.../* @__PURE__ */ new Set([...before.files, ...after.files])].filter((path) => before.fingerprints[path] !== after.fingerprints[path]).sort();
43348
44267
  //#endregion
@@ -43478,9 +44397,20 @@ const decodeToolPart = decodeUnknownOption(Struct({
43478
44397
  state: Struct({
43479
44398
  status: String$6,
43480
44399
  input: optionalKey(Unknown),
43481
- content: optionalKey(Unknown)
44400
+ content: optionalKey(Unknown),
44401
+ error: optionalKey(Unknown),
44402
+ metadata: optionalKey(Unknown)
43482
44403
  })
43483
44404
  }));
44405
+ const decodeCodeModeMetadata = decodeUnknownOption(Struct({ toolCalls: ArraySchema(Struct({
44406
+ tool: String$6,
44407
+ status: Literals$2([
44408
+ "running",
44409
+ "completed",
44410
+ "error"
44411
+ ]),
44412
+ input: optionalKey(Unknown)
44413
+ })) }));
43484
44414
  const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap((message) => {
43485
44415
  const assistant = decodeAssistantMessage(message);
43486
44416
  if (isNone(assistant)) return [];
@@ -43489,15 +44419,6 @@ const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap(
43489
44419
  return isSome(decoded) ? [decoded.value] : [];
43490
44420
  });
43491
44421
  });
43492
- const toolText = (value) => {
43493
- if (typeof value === "string") return value;
43494
- if (Array.isArray(value)) return value.map(toolText).join("\n");
43495
- if (value === null || typeof value !== "object") return "";
43496
- const record = value;
43497
- if (record["type"] === "text" && typeof record["text"] === "string") return record["text"];
43498
- return Object.values(record).map(toolText).join("\n");
43499
- };
43500
- const pathsIn = (value) => [...toolText(value).matchAll(/tools\.([A-Za-z0-9_.-]+)/g)].map((match) => match[1] ?? "").filter(Boolean);
43501
44422
  const toolPathPattern = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/;
43502
44423
  const pathsInSearchOutput = (value) => {
43503
44424
  const found = /* @__PURE__ */ new Set();
@@ -43526,27 +44447,50 @@ const pathsInSearchOutput = (value) => {
43526
44447
  const evidenceRecord = (part) => ({
43527
44448
  tool: part.name,
43528
44449
  input: part.state.input ?? null,
43529
- output: part.state.content ?? null
44450
+ output: part.state.content ?? part.state.error ?? null
43530
44451
  });
43531
44452
  const collectExecutorEvidence = (transcript) => {
43532
44453
  const discovered = /* @__PURE__ */ new Set();
43533
44454
  const searches = [];
43534
44455
  const calls = [];
43535
44456
  for (const part of toolParts(transcript)) {
43536
- const inputPaths = pathsIn(part.state.input);
43537
- const inputText = toolText(part.state.input);
43538
- if (part.name === "executor_search" || part.name === "executor.search" || inputPaths.some((path) => path === "executor.search")) {
44457
+ const metadata = decodeCodeModeMetadata(part.state.metadata);
44458
+ if (isSome(metadata)) {
44459
+ const invocations = metadata.value.toolCalls;
44460
+ for (const invocation of invocations) {
44461
+ if (invocation.status !== "completed") continue;
44462
+ const record = codeModeEvidenceRecord(invocation, part.state.content);
44463
+ if (invocation.tool === "executor.search") {
44464
+ for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
44465
+ searches.push(record);
44466
+ continue;
44467
+ }
44468
+ if ([...discovered].some((path) => matchesToolPath(invocation.tool, path))) calls.push(record);
44469
+ }
44470
+ continue;
44471
+ }
44472
+ if (part.state.status === "completed" && (part.name === "executor_search" || part.name === "executor.search")) {
43539
44473
  for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
43540
44474
  searches.push(evidenceRecord(part));
43541
44475
  continue;
43542
44476
  }
43543
- if ([...discovered].some((path) => part.name === path || part.name === path.replaceAll(".", "_") || inputPaths.includes(path) || inputText.includes(path))) calls.push(evidenceRecord(part));
44477
+ if (part.state.status === "completed" && [...discovered].some((path) => matchesToolPath(part.name, path))) calls.push(evidenceRecord(part));
43544
44478
  }
43545
44479
  return {
43546
44480
  searches,
43547
44481
  calls
43548
44482
  };
43549
44483
  };
44484
+ const codeModeEvidenceRecord = (invocation, codeModeOutput) => ({
44485
+ tool: invocation.tool,
44486
+ input: invocation.input ?? null,
44487
+ output: {
44488
+ status: invocation.status,
44489
+ scope: "shared CodeMode execution result",
44490
+ content: codeModeOutput ?? null
44491
+ }
44492
+ });
44493
+ const matchesToolPath = (tool, path) => tool === path || tool === path.replaceAll(".", "_");
43550
44494
  const interactionEventError = (event, sessionId) => {
43551
44495
  if (event === null || typeof event !== "object") return void 0;
43552
44496
  const record = event;
@@ -43559,15 +44503,61 @@ const interactionEventError = (event, sessionId) => {
43559
44503
  if (form !== null && typeof form === "object" && form["sessionID"] === sessionId) return /* @__PURE__ */ new Error("OpenCode requested a form; embedded PageGraph workflows are noninteractive.");
43560
44504
  }
43561
44505
  };
44506
+ const recordWorkflowActivity = (activity, event) => {
44507
+ if (event.type === "log.synced") return;
44508
+ activity.lastActivity = {
44509
+ type: event.type,
44510
+ at: (/* @__PURE__ */ new Date()).toISOString(),
44511
+ seq: event.durable.seq
44512
+ };
44513
+ if (event.type === "session.step.started") {
44514
+ activity.modelSteps += 1;
44515
+ activity.lastModel = `${event.data.model.providerID}/${event.data.model.id}`;
44516
+ return;
44517
+ }
44518
+ if (event.type === "session.tool.input.started") {
44519
+ activity.toolNames.set(event.data.id, event.data.name);
44520
+ activity.lastTool = {
44521
+ id: event.data.id,
44522
+ name: event.data.name,
44523
+ status: "input"
44524
+ };
44525
+ return;
44526
+ }
44527
+ if (event.type === "session.tool.called") {
44528
+ activity.lastTool = {
44529
+ id: event.data.id,
44530
+ name: activity.toolNames.get(event.data.id) ?? event.data.id,
44531
+ status: "called"
44532
+ };
44533
+ return;
44534
+ }
44535
+ if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
44536
+ activity.completedTools += 1;
44537
+ activity.lastTool = {
44538
+ id: event.data.id,
44539
+ name: activity.toolNames.get(event.data.id) ?? event.data.id,
44540
+ status: event.type === "session.tool.success" ? "succeeded" : "failed"
44541
+ };
44542
+ }
44543
+ };
44544
+ const describeWorkflowActivity = (activity, stage) => {
44545
+ const lastActivity = activity.lastActivity ? `${activity.lastActivity.type} at ${activity.lastActivity.at}${activity.lastActivity.seq === void 0 ? "" : ` (seq ${activity.lastActivity.seq})`}` : `none observed during ${stage}`;
44546
+ const lastTool = activity.lastTool ? `; last tool ${activity.lastTool.name} (${activity.lastTool.id}, ${activity.lastTool.status})` : "";
44547
+ const model = activity.lastModel ? `; last model ${activity.lastModel}` : "";
44548
+ return `Model steps: ${activity.modelSteps}; completed tools: ${activity.completedTools}; last activity: ${lastActivity}${model}${lastTool}.`;
44549
+ };
43562
44550
  const waitForIdle = async (host, sessionId, options) => {
43563
- const controller = new AbortController();
43564
- const timer = setTimeout(() => controller.abort(), options.timeoutMs);
44551
+ const controller = options.signal === void 0 ? new AbortController() : void 0;
44552
+ const signal = options.signal ?? controller.signal;
44553
+ const timer = controller === void 0 ? void 0 : setTimeout(() => controller.abort(), options.timeoutMs);
43565
44554
  try {
43566
44555
  for await (const event of host.sessions.log({
43567
44556
  sessionID: sessionId,
43568
44557
  follow: true,
43569
44558
  after: options.after
43570
- }, { signal: controller.signal })) {
44559
+ }, { signal })) {
44560
+ if (options.activity !== void 0) recordWorkflowActivity(options.activity, event);
43571
44561
  const interactionError = interactionEventError(event, sessionId);
43572
44562
  if (interactionError !== void 0) throw interactionError;
43573
44563
  if (event.type === "session.execution.succeeded" && event.data.sessionID === sessionId) return event.durable.seq;
@@ -43575,13 +44565,44 @@ const waitForIdle = async (host, sessionId, options) => {
43575
44565
  if (event.type === "session.execution.interrupted" && event.data.sessionID === sessionId) throw new Error(`OpenCode session was interrupted: ${event.data.reason}.`);
43576
44566
  }
43577
44567
  } catch (cause) {
43578
- if (controller.signal.aborted) throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs ?? options.timeoutMs}ms.`, { cause });
44568
+ if (controller?.signal.aborted) throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs ?? options.timeoutMs}ms.`, { cause });
43579
44569
  throw cause;
43580
44570
  } finally {
43581
- clearTimeout(timer);
44571
+ if (timer !== void 0) clearTimeout(timer);
43582
44572
  }
43583
44573
  throw new Error("OpenCode session log ended before the SEO agent completed.");
43584
44574
  };
44575
+ const runWorkflowTurn = async (host, sessionId, prompt, options) => {
44576
+ const controller = new AbortController();
44577
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
44578
+ let stage = options.stage;
44579
+ try {
44580
+ await host.sessions.prompt({
44581
+ sessionID: sessionId,
44582
+ text: prompt,
44583
+ ...options.skills === void 0 ? {} : { skills: options.skills.map((id) => ({ id })) }
44584
+ }, { signal: controller.signal });
44585
+ if (stage === "prompt admission") stage = "model execution";
44586
+ return await waitForIdle(host, sessionId, {
44587
+ timeoutMs: options.timeoutMs,
44588
+ configuredTimeoutMs: options.configuredTimeoutMs,
44589
+ after: options.after,
44590
+ signal: controller.signal,
44591
+ activity: options.activity
44592
+ });
44593
+ } catch (cause) {
44594
+ if (!controller.signal.aborted) throw cause;
44595
+ let interruption = "unknown";
44596
+ try {
44597
+ interruption = (await host.sessions.interrupt({ sessionID: sessionId }, { signal: AbortSignal.timeout(5e3) })).interrupted ? "active execution interrupted" : "no active execution to interrupt";
44598
+ } catch (interruptCause) {
44599
+ interruption = `interrupt request failed: ${interruptCause instanceof Error ? interruptCause.message : String(interruptCause)}`;
44600
+ }
44601
+ throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs}ms during ${stage}; ${describeWorkflowActivity(options.activity, stage)} Timeout handling: ${interruption}.`, { cause });
44602
+ } finally {
44603
+ clearTimeout(timer);
44604
+ }
44605
+ };
43585
44606
  const acquireWorkflowHost = async (options) => {
43586
44607
  const model = parseModel(options.model ?? options.config.defaultModel);
43587
44608
  const configDirectory = resolve(options.root, options.config.configDirectory);
@@ -43603,18 +44624,21 @@ const acquireWorkflowHost = async (options) => {
43603
44624
  sessionID: sessionId,
43604
44625
  permissions: [...workflowOptions.permissions]
43605
44626
  });
43606
- const configuredTimeoutMs = options.timeoutMs ?? 18e4;
44627
+ const configuredTimeoutMs = options.timeoutMs ?? options.config.timeoutMs ?? 18e4;
43607
44628
  const deadline = Date.now() + configuredTimeoutMs;
43608
44629
  const remaining = () => Math.max(0, deadline - Date.now());
43609
- await host.sessions.prompt({
43610
- sessionID: sessionId,
43611
- text: prompt,
43612
- skills: workflowOptions.skills.map((id) => ({ id }))
43613
- });
43614
- let cursor = await waitForIdle(host, sessionId, {
44630
+ const activity = {
44631
+ modelSteps: 0,
44632
+ completedTools: 0,
44633
+ toolNames: /* @__PURE__ */ new Map()
44634
+ };
44635
+ let cursor = await runWorkflowTurn(host, sessionId, prompt, {
43615
44636
  timeoutMs: remaining(),
43616
44637
  configuredTimeoutMs,
43617
- after: cursors.get(sessionId)
44638
+ stage: "prompt admission",
44639
+ after: cursors.get(sessionId),
44640
+ skills: workflowOptions.skills,
44641
+ activity
43618
44642
  });
43619
44643
  cursors.set(sessionId, cursor);
43620
44644
  let transcript = await host.sessions.export({
@@ -43632,14 +44656,12 @@ const acquireWorkflowHost = async (options) => {
43632
44656
  }]
43633
44657
  });
43634
44658
  try {
43635
- await host.sessions.prompt({
43636
- sessionID: sessionId,
43637
- text: STATE_REPAIR_PROMPT
43638
- });
43639
- cursor = await waitForIdle(host, sessionId, {
44659
+ cursor = await runWorkflowTurn(host, sessionId, STATE_REPAIR_PROMPT, {
43640
44660
  timeoutMs: remaining(),
43641
44661
  configuredTimeoutMs,
43642
- after: cursor
44662
+ stage: "workflow state repair",
44663
+ after: cursor,
44664
+ activity
43643
44665
  });
43644
44666
  cursors.set(sessionId, cursor);
43645
44667
  transcript = await host.sessions.export({
@@ -43691,9 +44713,43 @@ const acquireWorkflowHost = async (options) => {
43691
44713
  var action_default = "Continue PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nObserved state:\n{{state}}\n\nJev decisions:\n{{decisions}}\n\nApply only records in the resolved set whose verdict calls for a change. Never apply review records;\nleave them in the artifact for a human. If nothing is safely resolved, return no-change.\n\nRepository mutation mode: {{mutationMode}}. {{mutationInstruction}}\n\nReturn only one JSON object with this shape:\n{\"summary\":\"...\",\"files\":[\"relative/path\"],\"outcome\":\"applied|dry-run|no-change\"}\n";
43692
44714
  //#endregion
43693
44715
  //#region src/workflows/prompts/research.md
43694
- var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nSearch Executor's live catalog in one Code Mode turn, then call at least one exact returned tool\npath in a later turn. Compose whichever current integrations best support this workflow. Do not\nassume fixed provider or tool names. Preserve source references in the structured state.\n\nReturn only one JSON object matching this JSON Schema:\n{{stateSchema}}\n\nMarket: {{market}}\nLanguage: {{language}}\nDevice: {{device}}\nRefresh requested: {{refresh}}\nDo not ask questions, request permissions, or create interactive forms. Fail clearly if required\ninput is missing.\nSeed queries: {{queries}}\nCompetitor seeds: {{competitors}}\nAuthority-domain seeds: {{domains}}\nDeterministic evidence:\n{{evidence}}\n";
44716
+ var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nThe graph contains the selected target pages. Its neighborhood preserves incoming and outgoing\nrelationships to pages outside that selection; those neighbors are context, not additional targets.\nWhen suggestion evidence is supplied, preserve each candidate's exact source sentence as `context`,\nverbatim anchor, source and destination paths. Do not invent additional link placements.\nReturn at most {{limit}} items or opportunities. Keep exploration scoped to these targets and stop\nwhen the collected evidence supports the requested decision; do not expand into a site-wide audit.\n\nSearch Executor's live catalog with a short intent phrase, then use Code Mode's local `search` for\nthe exact returned path to inspect its current argument signature before calling it in a later turn.\nCatalog discovery returns tool names and descriptions, not complete input schemas. Never guess\narguments from a description. If discovery returns no matches, simplify the query. Compose whichever\ncurrent integrations best support this workflow; tool names and providers are discovered dynamically.\nCall at least one relevant discovered tool and preserve source references and provider errors in the\nstructured state. Do not treat an errored call or empty dataset as proof of indexing or traffic.\nDistinguish project market settings from the country, language, device, and date filters actually\napplied to provider data; report only the segments supported by the call and its response.\n\nReturn only one JSON object matching this JSON Schema:\n{{stateSchema}}\n\nMarket: {{market}}\nLanguage: {{language}}\nDevice: {{device}}\nRefresh requested: {{refresh}}\nDo not ask questions, request permissions, or create interactive forms. Fail clearly if required\ninput is missing.\nSeed queries: {{queries}}\nCompetitor seeds: {{competitors}}\nAuthority-domain seeds: {{domains}}\nDeterministic evidence:\n{{evidence}}\n";
43695
44717
  //#endregion
43696
44718
  //#region src/workflows/run.ts
44719
+ const readCurrentSuggestionSentences = async (origin, path, allowPrivate, maxBodyBytes) => {
44720
+ const options = {
44721
+ sameOrigin: origin,
44722
+ allowPrivate,
44723
+ timeoutMs: 15e3,
44724
+ maxBodyBytes
44725
+ };
44726
+ const robots = await probeHttp({
44727
+ kind: "robots",
44728
+ method: "GET",
44729
+ accept: "text/plain",
44730
+ url: new URL("/robots.txt", origin)
44731
+ }, {
44732
+ ...options,
44733
+ captureBody: true
44734
+ });
44735
+ if (!robots.ok && robots.status !== 404 || robots.bodyTruncated) throw new Error("Could not verify current robots policy for suggestion freshness");
44736
+ const rules = robots.ok ? robotsRules(robots.body ?? "").rules : [];
44737
+ const url = new URL(path, origin);
44738
+ if (!allowedByRobots(`${url.pathname}${url.search}`, rules)) throw new Error(`Suggestion page is robots-disallowed: ${path}`);
44739
+ const probe = await probeHttp({
44740
+ kind: "page-html",
44741
+ method: "GET",
44742
+ accept: "text/html",
44743
+ url
44744
+ }, {
44745
+ ...options,
44746
+ captureBody: true,
44747
+ allowUrl: (next) => allowedByRobots(`${next.pathname}${next.search}`, rules)
44748
+ });
44749
+ const contentType = probe.responseHeaders["content-type"];
44750
+ if (!probe.ok || probe.bodyTruncated || !probe.body || !probe.finalUrl || contentType !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(contentType) || new URL(probe.finalUrl).pathname.replace(/\/+$/, "") !== path.replace(/\/+$/, "")) throw new Error(`Could not verify current served copy for suggestion page ${path}: ${probe.error ?? "unreadable or redirected page"}`);
44751
+ return extractPageSentences(probe.body);
44752
+ };
43697
44753
  const defaultDecide = async (inputs, spec) => {
43698
44754
  const layer$14 = model("jev-latest").pipe(provide$2(layerConfig()), provide$2(layer));
43699
44755
  return runPromise(gen(function* () {
@@ -43720,6 +44776,7 @@ const capState = (state, limit) => {
43720
44776
  };
43721
44777
  const researchPrompt = (spec, evidence, options) => TextTemplate.from(research_default).values({
43722
44778
  workflow: spec.id,
44779
+ limit: options.limit,
43723
44780
  instructions: spec.researchInstructions,
43724
44781
  skills: spec.skills.join(", "),
43725
44782
  stateSchema: JSON.stringify(spec.stateJsonSchema),
@@ -43761,6 +44818,20 @@ const runWorkflow = async (input, dependencies = {}) => {
43761
44818
  });
43762
44819
  if (spec.mutatesFiles) mutation.assertStartAllowed();
43763
44820
  const evidence = collectWorkflowEvidence(input.graph, input.options, input.root, spec.id, workflows.context);
44821
+ const suggestionReport = input.options.suggestions === void 0 ? void 0 : (() => {
44822
+ if (spec.id !== "improve.links") throw new Error("--suggestions is only valid for improve links");
44823
+ const report = decodeLinksSuggestionReport(JSON.parse(readFileSync(resolve(input.root, input.options.suggestions), "utf8")), new URL(input.config.origin).origin);
44824
+ const selectedSources = selectGraph(input.graph, input.options).nodes;
44825
+ for (const candidate of report.candidates) {
44826
+ if (!input.graph.nodes.has(candidate.source) || !input.graph.nodes.has(candidate.destination)) throw new Error(`Suggestion references a path absent from seo.config.ts: ${candidate.source} → ${candidate.destination}`);
44827
+ if (!selectedSources.has(candidate.source)) throw new Error(`Suggestion source is outside workflow page/kind/limit targets: ${candidate.source}`);
44828
+ }
44829
+ return report;
44830
+ })();
44831
+ const suppliedEvidence = suggestionReport === void 0 ? evidence : {
44832
+ ...evidence,
44833
+ suggestions: suggestionReport
44834
+ };
43764
44835
  const host = await (dependencies.acquireHost ?? acquireWorkflowHost)({
43765
44836
  root: input.root,
43766
44837
  config: workflows.opencode,
@@ -43769,67 +44840,184 @@ const runWorkflow = async (input, dependencies = {}) => {
43769
44840
  try {
43770
44841
  const researchPermissions = repositoryMutationPermissionRules(input.root);
43771
44842
  const actionPermissions = spec.mutatesFiles && !input.options.dryRun ? mutation.sessionPermissions : researchPermissions;
43772
- const researched = await host.research(researchPrompt(spec, evidence, input.options), {
44843
+ let researched = await host.research(researchPrompt(spec, suppliedEvidence, input.options), {
43773
44844
  skills: spec.skills,
43774
44845
  permissions: researchPermissions
43775
44846
  });
43776
- const gitAfterResearch = inspectGit(input.root);
43777
- const researchFiles = changedFiles(gitAtStart, gitAfterResearch);
43778
- if (researchFiles.length > 0) throw new Error(`${spec.id} changed repository files during its read-only research turn: ${researchFiles.join(", ")}`);
43779
- const state = spec.decodeState(capState(researched.state, input.options.limit));
44847
+ const assertResearchReadOnly = () => {
44848
+ const researchFiles = changedFiles(gitAtStart, inspectGit(input.root));
44849
+ if (researchFiles.length > 0) throw new Error(`${spec.id} changed repository files during its read-only research turn: ${researchFiles.join(", ")}`);
44850
+ };
44851
+ assertResearchReadOnly();
44852
+ const decodeResearchState = (value) => spec.decodeState(capState(value, input.options.limit));
44853
+ let state;
44854
+ try {
44855
+ state = decodeResearchState(researched.state);
44856
+ } catch (firstError) {
44857
+ const original = researched;
44858
+ let repaired;
44859
+ try {
44860
+ repaired = await host.continue(researched.sessionId, [
44861
+ `The previous ${spec.id} research JSON did not match the required state schema: ${firstError instanceof Error ? firstError.message : String(firstError)}.`,
44862
+ `Return only one corrected JSON object matching this schema: ${JSON.stringify(spec.stateJsonSchema)}.`,
44863
+ "Use only evidence already collected. Do not call tools, edit files, or add commentary."
44864
+ ].join(" "), {
44865
+ skills: spec.skills,
44866
+ permissions: researchPermissions
44867
+ });
44868
+ assertResearchReadOnly();
44869
+ state = decodeResearchState(repaired.state);
44870
+ researched = repaired;
44871
+ } catch (repairError) {
44872
+ const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
44873
+ const path = writeResearchFailure(input.root, runsDirectory, id, {
44874
+ kind: "pagegraph-workflow-research-failure",
44875
+ workflow: spec.id,
44876
+ original,
44877
+ repaired,
44878
+ firstError: String(firstError),
44879
+ repairError: String(repairError)
44880
+ });
44881
+ throw new Error(`${spec.id} research state failed schema validation after one read-only repair. Research failure artifact: ${path}`, { cause: repairError });
44882
+ }
44883
+ }
43780
44884
  const decisionInputs = spec.decisionInputs(state);
44885
+ if (suggestionReport !== void 0) {
44886
+ const valid = new Set(suggestionReport.candidates.map((item) => `${item.source}\u0000${item.destination}\u0000${item.anchor}\u0000${item.sentence}`));
44887
+ for (const item of decisionInputs) {
44888
+ const link = item;
44889
+ if (!valid.has(`${link.from}\u0000${link.to}\u0000${link.anchor}\u0000${link.context}`)) throw new Error("Research proposed a link absent from the supplied suggestions");
44890
+ }
44891
+ }
43781
44892
  for (const item of decisionInputs) {
43782
44893
  const error = spec.validateDecisionInput(item);
43783
44894
  if (error !== void 0) throw new Error(error);
43784
44895
  }
43785
- const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
43786
- const acted = spec.mutatesFiles ? await host.continue(researched.sessionId, actionPrompt(spec, state, decisionReport, mutation), {
43787
- skills: spec.skills,
43788
- permissions: actionPermissions
43789
- }) : researched;
43790
- const gitAfter = inspectGit(input.root);
43791
- const files = changedFiles(gitAtStart, gitAfter);
43792
- if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
43793
- const run = {
43794
- kind: "pagegraph-workflow-run",
44896
+ const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
44897
+ const checkpoint = {
44898
+ kind: "pagegraph-workflow-research-checkpoint",
43795
44899
  schemaVersion: 1,
43796
44900
  id,
43797
44901
  workflow: spec.id,
43798
44902
  startedAt: started.toISOString(),
43799
- finishedAt: now().toISOString(),
44903
+ checkpointedAt: now().toISOString(),
43800
44904
  project: {
43801
44905
  root: input.root,
43802
44906
  head: gitAtStart.head,
43803
- dirtyAtStart: gitAtStart.dirty
44907
+ dirtyAtStart: gitAtStart.dirty,
44908
+ filesAtStart: gitAtStart.files
43804
44909
  },
43805
44910
  options: input.options,
43806
- model: host.model,
43807
- targets: {
43808
- pages: evidence.graph.nodes.map((node) => node.path),
43809
- queries: input.options.queries,
43810
- kinds: input.options.kinds
43811
- },
43812
44911
  evidence: {
43813
- ...evidence,
43814
- executor: acted.executor
44912
+ ...suppliedEvidence,
44913
+ executor: researched.executor
43815
44914
  },
43816
- decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
43817
- changes: {
43818
- files,
43819
- providerCalls: acted.executor.calls.map((call) => call.tool)
43820
- },
43821
- result: spec.mutatesFiles ? acted.state : state,
44915
+ state,
44916
+ decisionInputs,
43822
44917
  opencode: {
43823
44918
  agent: "seo",
43824
- sessionId: acted.sessionId,
43825
- transcript: acted.transcript
44919
+ sessionId: researched.sessionId,
44920
+ model: host.model,
44921
+ transcript: researched.transcript
43826
44922
  }
43827
44923
  };
43828
- const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
43829
- return {
43830
- run,
43831
- directory: writeRunBundle(input.root, runsDirectory, run)
43832
- };
44924
+ const checkpointPath = writeResearchCheckpoint(input.root, runsDirectory, checkpoint);
44925
+ const gitAtCheckpoint = inspectGit(input.root);
44926
+ try {
44927
+ const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
44928
+ const accepted = spec.id === "improve.links" ? decisionReport.resolved.filter((record) => record.verdict === "add" || record.verdict === "update") : void 0;
44929
+ const acceptedIndexes = /* @__PURE__ */ new Set();
44930
+ if (accepted !== void 0) for (const record of accepted) {
44931
+ const match = /^workflow-links:(\d+)$/.exec(record.decisionId);
44932
+ const index = match === null ? -1 : Number(match[1]);
44933
+ const item = decisionInputs[index];
44934
+ if (!item || record.inputRef !== `${item.from} → ${item.to}`) throw new Error(`Decision does not match a researched link: ${record.decisionId}`);
44935
+ acceptedIndexes.add(index);
44936
+ }
44937
+ const actionItems = accepted === void 0 ? void 0 : state.items.filter((_, index) => acceptedIndexes.has(index));
44938
+ if (actionItems !== void 0 && actionItems.length > 0 && suggestionReport !== void 0) {
44939
+ const readSentences = dependencies.readSuggestionSentences ?? readCurrentSuggestionSentences;
44940
+ const current = /* @__PURE__ */ new Map();
44941
+ for (const item of actionItems) {
44942
+ const candidate = suggestionReport.candidates.find((entry) => entry.source === item.from && entry.destination === item.to && entry.anchor === item.anchor && entry.sentence === item.context);
44943
+ if (!candidate) throw new Error("Accepted link is absent from the supplied suggestions");
44944
+ for (const path of [candidate.source, candidate.destination]) if (!current.has(path)) current.set(path, await readSentences(suggestionReport.origin, path, input.options.allowPrivate === true, suggestionReport.maxBodyBytes));
44945
+ if (!current.get(candidate.source).includes(candidate.sentence) || !current.get(candidate.destination).includes(candidate.targetSentence)) throw new Error(`Suggestion is stale; served copy changed for ${candidate.source} → ${candidate.destination}. Regenerate links candidates --site.`);
44946
+ }
44947
+ }
44948
+ const actionState = actionItems === void 0 ? state : {
44949
+ ...state,
44950
+ items: actionItems
44951
+ };
44952
+ const actionDecisions = accepted === void 0 ? decisionReport : {
44953
+ ...decisionReport,
44954
+ resolved: accepted,
44955
+ review: [],
44956
+ counts: {
44957
+ inputs: accepted.length,
44958
+ resolved: accepted.length,
44959
+ review: 0
44960
+ },
44961
+ verdicts: {
44962
+ add: accepted.filter((record) => record.verdict === "add").length,
44963
+ update: accepted.filter((record) => record.verdict === "update").length
44964
+ }
44965
+ };
44966
+ const mayAct = spec.mutatesFiles && (actionItems === void 0 || actionItems.length > 0);
44967
+ const acted = mayAct ? await host.continue(researched.sessionId, actionPrompt(spec, actionState, actionDecisions, mutation), {
44968
+ skills: spec.skills,
44969
+ permissions: actionPermissions
44970
+ }) : researched;
44971
+ const gitAfter = inspectGit(input.root);
44972
+ const files = changedFiles(gitAtCheckpoint, gitAfter);
44973
+ if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
44974
+ const run = {
44975
+ kind: "pagegraph-workflow-run",
44976
+ schemaVersion: 1,
44977
+ id,
44978
+ workflow: spec.id,
44979
+ startedAt: started.toISOString(),
44980
+ finishedAt: now().toISOString(),
44981
+ project: {
44982
+ root: input.root,
44983
+ head: gitAtStart.head,
44984
+ dirtyAtStart: gitAtStart.dirty
44985
+ },
44986
+ options: input.options,
44987
+ model: host.model,
44988
+ targets: {
44989
+ pages: evidence.graph.nodes.map((node) => node.path),
44990
+ queries: input.options.queries,
44991
+ kinds: input.options.kinds
44992
+ },
44993
+ evidence: {
44994
+ ...suppliedEvidence,
44995
+ executor: acted.executor
44996
+ },
44997
+ decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
44998
+ changes: {
44999
+ files,
45000
+ providerCalls: acted.executor.calls.map((call) => call.tool)
45001
+ },
45002
+ result: mayAct ? acted.state : accepted !== void 0 ? {
45003
+ summary: "No link suggestions accepted.",
45004
+ outcome: "no-change",
45005
+ files: []
45006
+ } : state,
45007
+ opencode: {
45008
+ agent: "seo",
45009
+ sessionId: acted.sessionId,
45010
+ transcript: acted.transcript
45011
+ }
45012
+ };
45013
+ return {
45014
+ run,
45015
+ directory: writeRunBundle(input.root, runsDirectory, run)
45016
+ };
45017
+ } catch (cause) {
45018
+ const message = cause instanceof Error ? cause.message : String(cause);
45019
+ throw new Error(`${message}\nResearch checkpoint: ${checkpointPath}`, { cause });
45020
+ }
43833
45021
  } finally {
43834
45022
  await host.close();
43835
45023
  }
@@ -43851,6 +45039,7 @@ const configFlag = String$2("opencode-config").pipe(withDescription$2("OpenCode
43851
45039
  const outFlag = String$2("out").pipe(withDescription$2("Run-artifact directory override"), optional$1);
43852
45040
  const dryRunFlag$1 = Boolean$1("dry-run").pipe(withDescription$2("Suppress file writes; discovered Executor tools remain available"), withDefault(false));
43853
45041
  const allowDirtyFlag$1 = Boolean$1("allow-dirty").pipe(withDescription$2("Permit file changes when the working tree is already dirty"), withDefault(false));
45042
+ const suggestionsFlag = String$2("suggestions").pipe(withDescription$2("Schema-version-2 links candidate JSON to evaluate"), optional$1);
43854
45043
  const baseFlags = {
43855
45044
  page: pageFlag,
43856
45045
  kind: kindFlag,
@@ -43894,7 +45083,9 @@ const optionsFrom = (flags, extra = {}) => {
43894
45083
  domains: strings(flags.domain),
43895
45084
  refresh: flags.refresh === true,
43896
45085
  dryRun: extra.dryRun === true,
43897
- allowDirty: extra.allowDirty === true
45086
+ allowDirty: extra.allowDirty === true,
45087
+ allowPrivate: flags.allowPrivate === true,
45088
+ suggestions: optionalString(flags.suggestions)
43898
45089
  };
43899
45090
  };
43900
45091
  const resultSummary = (result) => {
@@ -44298,70 +45489,70 @@ var seo_default = "---\ndescription: Execute PageGraph SEO workflows from reposi
44298
45489
  var opencode_default = "{\n \"default_agent\": \"seo\",\n \"plugins\": [\n // Add your Executor plugin package or local path here.\n // \"opencode-plugin-executor\"\n ]\n}\n";
44299
45490
  //#endregion
44300
45491
  //#region src/cli/preset/opencode/skills/ai-search/SKILL.md
44301
- var SKILL_default$10 = "---\nname: ai-search\ndescription: Evaluate answer extractability, entity clarity, support, and citation readiness.\n---\n\n# AI Search Analysis\n\nStart from the page's visible content, structured data, route context, and cited sources. Read\n[Executor Starters](references/executor.md) before collecting current AI-search observations.\n\nEvaluate whether an answer engine can identify the subject, extract a concise answer, understand\nentity relationships, and trace material claims to first-party or trustworthy support. Distinguish\nobserved citations from inferred readiness. Do not claim a visibility lift from formatting alone.\n\nReturn the structured state requested by the workflow. This workflow produces evidence and a\ndiagnosis; it does not edit pages or submit them to an answer engine.\n";
45492
+ var SKILL_default$11 = "---\nname: ai-search\ndescription: Evaluate answer extractability, entity clarity, support, and citation readiness.\n---\n\n# AI Search Analysis\n\nStart from the page's visible content, structured data, route context, and cited sources. Read\n[Executor Starters](references/executor.md) before collecting current AI-search observations.\n\nEvaluate whether an answer engine can identify the subject, extract a concise answer, understand\nentity relationships, and trace material claims to first-party or trustworthy support. Distinguish\nobserved citations from inferred readiness. Do not claim a visibility lift from formatting alone.\n\nReturn the structured state requested by the workflow. This workflow produces evidence and a\ndiagnosis; it does not edit pages or submit them to an answer engine.\n";
44302
45493
  //#endregion
44303
45494
  //#region src/cli/preset/opencode/skills/ai-search/references/executor.md
44304
45495
  var executor_default$10 = "# Executor Starters for AI Search Analysis\n\nThese snippets are starting points, not an inventory. Search for current citation, brand-mention,\nanswer-engine, and owned-performance capabilities available through Executor.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"AI search citations answer visibility brand mentions\", limit: 8 }),\n tools.executor.search({ query: \"Google Search Console generative AI performance page\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst visibility = (await search({ query: \"tools.<exact-ai-visibility-path>\", limit: 1 })).items[0];\nreturn visibility.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n domain,\n queries,\n market,\n language,\n});\n~~~\n\nRecord engine or surface, query, market, collection date, cited URL, response excerpt, sampling\nmethod, task status, errors, and cost. Missing citations in a small sample do not prove absence.\nDo not mutate provider projects or publish content.\n";
44305
45496
  //#endregion
44306
45497
  //#region src/cli/preset/opencode/skills/authority-research/SKILL.md
44307
- var SKILL_default$9 = "---\nname: authority-research\ndescription: Qualify legitimate citation, directory, editorial, partner, and community opportunities.\n---\n\n# Authority Research\n\nUse PageGraph's pages to identify the asset that could reasonably earn each citation. Read\n[Executor Starters](references/executor.md) before collecting backlink or live-page evidence.\n\nUse backlink gaps only to nominate sources. Verify the current page and qualify topical relevance,\nlegitimacy, editorial fit, audience overlap, and eligibility. Reject paid-link schemes, bulk\ndirectories, expired pages, scraped lists, and opportunities with no suitable destination asset.\n\nReturn the structured state requested by the workflow. Contact discovery, outreach, submission,\nand provider project updates are separate operations and remain outside this research workflow.\n";
45498
+ var SKILL_default$10 = "---\nname: authority-research\ndescription: Qualify legitimate citation, directory, editorial, partner, and community opportunities.\n---\n\n# Authority Research\n\nUse PageGraph's pages to identify the asset that could reasonably earn each citation. Read\n[Executor Starters](references/executor.md) before collecting backlink or live-page evidence.\n\nUse backlink gaps only to nominate sources. Verify the current page and qualify topical relevance,\nlegitimacy, editorial fit, audience overlap, and eligibility. Reject paid-link schemes, bulk\ndirectories, expired pages, scraped lists, and opportunities with no suitable destination asset.\n\nReturn the structured state requested by the workflow. Contact discovery, outreach, submission,\nand provider project updates are separate operations and remain outside this research workflow.\n";
44308
45499
  //#endregion
44309
45500
  //#region src/cli/preset/opencode/skills/authority-research/references/executor.md
44310
45501
  var executor_default$9 = "# Executor Starters for Authority Research\n\nThese snippets are starting points, not an inventory. Discover backlink and live-page capabilities\nbefore composing exact calls.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"OpenSEO DataForSEO backlink domain intersection\", limit: 8 }),\n tools.executor.search({ query: \"web search fetch directory editorial partner eligibility\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst gap = (await search({ query: \"tools.<exact-backlink-gap-path>\", limit: 1 })).items[0];\nreturn gap.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n targets: [ownedDomain, ...competitors],\n excludeInternal: true,\n limit: 100,\n});\n~~~\n\nRecord source URL, target domain, dates, limits, authority metrics, task status, errors, and cost.\nProvider metrics nominate prospects; current pages determine legitimacy. Do not discover personal\ncontact data, send outreach, submit listings, or mutate OpenSEO project state.\n";
44311
45502
  //#endregion
44312
45503
  //#region src/cli/preset/opencode/skills/competitive-landscape/SKILL.md
44313
- var SKILL_default$8 = "---\nname: competitive-landscape\ndescription: Identify current search competitors and evidence-backed openings across representative queries.\n---\n\n# Competitive Landscape\n\nUse PageGraph's public-page corpus and product context to decide which domains compete for the same\nneed. Read [Executor Starters](references/executor.md) before collecting live market evidence.\n\nSample representative queries, find domains that recur, and compare their page types, intent,\ncoverage, differentiation, and authority signals. Separate direct product competitors, publishers,\ndirectories, communities, and incidental results. Study only the leaders whose evidence could\nchange a decision.\n\nReturn the structured state requested by the workflow. Do not copy competitor claims, treat every\nranking keyword as desirable, or update a provider's saved competitor list.\n";
45504
+ var SKILL_default$9 = "---\nname: competitive-landscape\ndescription: Identify current search competitors and evidence-backed openings across representative queries.\n---\n\n# Competitive Landscape\n\nUse PageGraph's public-page corpus and product context to decide which domains compete for the same\nneed. Read [Executor Starters](references/executor.md) before collecting live market evidence.\n\nSample representative queries, find domains that recur, and compare their page types, intent,\ncoverage, differentiation, and authority signals. Separate direct product competitors, publishers,\ndirectories, communities, and incidental results. Study only the leaders whose evidence could\nchange a decision.\n\nReturn the structured state requested by the workflow. Do not copy competitor claims, treat every\nranking keyword as desirable, or update a provider's saved competitor list.\n";
44314
45505
  //#endregion
44315
45506
  //#region src/cli/preset/opencode/skills/competitive-landscape/references/executor.md
44316
45507
  var executor_default$8 = "# Executor Starters for a Competitive Landscape\n\nThese snippets are starting points, not an inventory. Discover current tools and call only exact\nreturned paths after resolving their signatures.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"OpenSEO SERP competitors recurring domains\", limit: 8 }),\n tools.executor.search({ query: \"domain overview ranked keywords backlink authority\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n queries,\n market,\n language,\n device,\n});\n~~~\n\nPrefer relevant cached OpenSEO context or research when an existing project matches the domain\nunambiguously. Use external traffic and keyword values as estimates. Record query, market, device,\ndate, result depth, task status, errors, and cost. Do not update project context or competitors.\n";
44317
45508
  //#endregion
44318
45509
  //#region src/cli/preset/opencode/skills/content-analysis/SKILL.md
44319
- var SKILL_default$7 = "---\nname: content-analysis\ndescription: Evaluate a page's substance, intent coverage, answer quality, and differentiation.\n---\n\n# Content Analysis\n\nStart from the supplied source, rendered page, route relationships, and project truth. Read\n[Executor Starters](references/executor.md) when live owned-site or competitive evidence is needed.\n\nJudge whether the page answers its intended need, supports material claims, avoids sibling-page\ncannibalization, and offers useful depth for its format. Use word counts and competitor excerpts as\ndescriptive signals, never as universal targets. Keep product accuracy ahead of search demand.\n\nReturn the structured state requested by the workflow. This workflow diagnoses content; it does not\nedit source files.\n";
45510
+ var SKILL_default$8 = "---\nname: content-analysis\ndescription: Evaluate a page's substance, intent coverage, answer quality, and differentiation.\n---\n\n# Content Analysis\n\nStart from the supplied source, rendered page, route relationships, and project truth. Read\n[Executor Starters](references/executor.md) when live owned-site or competitive evidence is needed.\n\nJudge whether the page answers its intended need, supports material claims, avoids sibling-page\ncannibalization, and offers useful depth for its format. Use word counts and competitor excerpts as\ndescriptive signals, never as universal targets. Keep product accuracy ahead of search demand.\n\nReturn the structured state requested by the workflow. This workflow diagnoses content; it does not\nedit source files.\n";
44320
45511
  //#endregion
44321
45512
  //#region src/cli/preset/opencode/skills/content-analysis/references/executor.md
44322
45513
  var executor_default$7 = "# Executor Starters for Content Analysis\n\nThese snippets are starting points, not an inventory. Discover owned query/page performance and\ncurrent result-page evidence when they can change the diagnosis.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page performance\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO current SERP competitor content\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nKeep Search Console or Bing performance separate from provider estimates and live-page\nobservations. Record reporting dates, filters, market, limits, task status, errors, and cost. Do not\nchange content or provider state.\n";
44323
45514
  //#endregion
44324
45515
  //#region src/cli/preset/opencode/skills/content-improvement/SKILL.md
44325
- var SKILL_default$6 = "---\nname: content-improvement\ndescription: Research and apply bounded content changes supported by PageGraph and live evidence.\n---\n\n# Content Improvement\n\nResearch the target page exactly as described by [Executor Starters](references/executor.md). During\nthe research turn, diagnose intent, substance, differentiation, support, and sibling-page ownership\nwithout editing files.\n\nDuring the host-authorized action turn, apply only recommendations accepted by the decision report.\nPreserve the repository's public positioning and voice, edit the owning source rather than generated\noutput, and keep the change local to the selected page and intent. Do not invent metrics, customers,\ncapabilities, or citations.\n\nLeave the resulting repository diff uncommitted for review. Provider state remains read-only.\n";
45516
+ var SKILL_default$7 = "---\nname: content-improvement\ndescription: Research and apply bounded content changes supported by PageGraph and live evidence.\n---\n\n# Content Improvement\n\nResearch the target page exactly as described by [Executor Starters](references/executor.md). During\nthe research turn, diagnose intent, substance, differentiation, support, and sibling-page ownership\nwithout editing files.\n\nDuring the host-authorized action turn, apply only recommendations accepted by the decision report.\nPreserve the repository's public positioning and voice, edit the owning source rather than generated\noutput, and keep the change local to the selected page and intent. Do not invent metrics, customers,\ncapabilities, or citations.\n\nLeave the resulting repository diff uncommitted for review. Provider state remains read-only.\n";
44326
45517
  //#endregion
44327
45518
  //#region src/cli/preset/opencode/skills/content-improvement/references/executor.md
44328
45519
  var executor_default$6 = "# Executor Starters for Content Improvement\n\nThese snippets are starting points, not an inventory. Discover owned performance and current SERP\nevidence during the read-only research turn.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console page query performance\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO current SERP intent competitor pages\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n query,\n market,\n language,\n device,\n});\n~~~\n\nRecord market, language, device, dates, limits, task status, errors, and cost. Search evidence may\nsupport a source edit only after the host evaluates it; never edit during research or write to an\nOpenSEO project.\n";
44329
45520
  //#endregion
44330
45521
  //#region src/cli/preset/opencode/skills/internal-linking/SKILL.md
44331
- var SKILL_default$5 = "---\nname: internal-linking\ndescription: Research and apply useful contextual links supported by route and page evidence.\n---\n\n# Internal Linking\n\nUse PageGraph's nodes and edges to find relevant source-target pairs, then inspect both pages before\nproposing a link. Read [Executor Starters](references/executor.md) only when owned search evidence\ncan clarify page ownership or discoverability.\n\nPrefer links that help a reader continue a task, strengthen a clear hierarchy, or connect an\norphaned but relevant page. Reject forced anchors, repeated links, unsupported wording, and links\nwhose target does not satisfy the promised intent.\n\nDuring the host-authorized action turn, apply only accepted links at the owning source or route\ndeclaration. Preserve generated graph contracts, use natural anchor text, leave the diff uncommitted,\nand keep provider state read-only.\n";
45522
+ var SKILL_default$6 = "---\nname: internal-linking\ndescription: Research and apply useful contextual links supported by route and page evidence.\n---\n\n# Internal Linking\n\nUse PageGraph's nodes and edges to find relevant source-target pairs, then inspect both pages before\nproposing a link. Read [Executor Starters](references/executor.md) only when owned search evidence\ncan clarify page ownership or discoverability.\n\nPrefer links that help a reader continue a task, strengthen a clear hierarchy, or connect an\norphaned but relevant page. Reject forced anchors, repeated links, unsupported wording, and links\nwhose target does not satisfy the promised intent.\n\nDuring the host-authorized action turn, apply only accepted links at the owning source or route\ndeclaration. Preserve generated graph contracts, use natural anchor text, leave the diff uncommitted,\nand keep provider state read-only.\n";
44332
45523
  //#endregion
44333
45524
  //#region src/cli/preset/opencode/skills/internal-linking/references/executor.md
44334
45525
  var executor_default$5 = "# Executor Starters for Internal Linking\n\nThese snippets are starting points, not an inventory. PageGraph's graph is primary; discover owned\nquery/page or crawl evidence only when it resolves ownership or discoverability.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page internal ownership\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl links URL information\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-pages-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nRecord dates, page and query filters, crawl state, task status, errors, and cost. Search performance\ncan support a graph recommendation but does not override the source content. Do not submit URLs,\nrequest indexing, or mutate provider projects.\n";
44335
45526
  //#endregion
44336
45527
  //#region src/cli/preset/opencode/skills/keyword-research/SKILL.md
44337
- var SKILL_default$4 = "---\nname: keyword-research\ndescription: Discover current search demand, validate intent, and map opportunities to public pages.\n---\n\n# Keyword Research\n\nUse PageGraph's route and page evidence as the ownership baseline. Read\n[Executor Starters](references/executor.md) before collecting live evidence.\n\nStart from the supplied seeds and consumer product context. Expand only into queries that match the\nactual product, audience, and market. Use owned-site performance to establish current visibility,\nprovider metrics to estimate demand and competition, and current SERPs to validate intent. A volume\nestimate alone does not establish product fit or justify a new page.\n\nMap each supported query to the strongest existing page. Propose a new page only when no current\npage can satisfy the distinct intent without becoming unfocused. Preserve evidence and caveats for\nevery opportunity.\n\nReturn the structured state requested by the workflow. Do not save keywords, update provider\nprojects, submit URLs, or edit repository files during research.\n";
45528
+ var SKILL_default$5 = "---\nname: keyword-research\ndescription: Discover current search demand, validate intent, and map opportunities to public pages.\n---\n\n# Keyword Research\n\nUse PageGraph's route and page evidence as the ownership baseline. Read\n[Executor Starters](references/executor.md) before collecting live evidence.\n\nStart from the supplied seeds and consumer product context. Expand only into queries that match the\nactual product, audience, and market. Use owned-site performance to establish current visibility,\nprovider metrics to estimate demand and competition, and current SERPs to validate intent. A volume\nestimate alone does not establish product fit or justify a new page.\n\nMap each supported query to the strongest existing page. Propose a new page only when no current\npage can satisfy the distinct intent without becoming unfocused. Preserve evidence and caveats for\nevery opportunity.\n\nReturn the structured state requested by the workflow. Do not save keywords, update provider\nprojects, submit URLs, or edit repository files during research.\n";
44338
45529
  //#endregion
44339
45530
  //#region src/cli/preset/opencode/skills/keyword-research/references/executor.md
44340
45531
  var executor_default$4 = "# Executor Starters for Keyword Research\n\nThese snippets are starting points, not an inventory. Search Executor in one Code Mode turn, retain\nthe exact returned paths, and call those paths in a later turn after resolving their current\nsignatures.\n\nDiscover Google Search Console or Bing query/page performance, OpenSEO cached project context and\nkeyword research, provider keyword metrics, and live SERP results. Prefer a relevant cached OpenSEO\nresult when it already covers the requested market, language, device, and freshness. When a matching\nOpenSEO project can be resolved unambiguously, its project context and research log are supporting\nevidence; the consumer repository remains authoritative for product truth.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console performance query page\", limit: 8 }),\n tools.executor.search({ query: \"OpenSEO project context cached keyword research\", limit: 8 }),\n tools.executor.search({ query: \"keyword volume difficulty current SERP results\", limit: 8 }),\n]);\n~~~\n\nIn a later turn, replace placeholders with exact discovered paths:\n\n~~~ts\nconst call = (path, input) => path.split(\".\").reduce((node, segment) => node[segment], tools)(input);\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nconst demand = (await search({ query: \"tools.<exact-demand-path>\", limit: 1 })).items[0];\nreturn Promise.all([\n call(owned.path, { siteUrl, startDate, endDate, dimensions: [\"PAGE\", \"QUERY\"] }),\n call(demand.path, { keywords, market, language, device }),\n]);\n~~~\n\nTreat Search Console and Bing as owned-site evidence, OpenSEO and DataForSEO as provider evidence,\nand current result pages as observed evidence. Record market, language, device, reporting dates,\nlimits, task status, errors, and cost metadata when returned. Do not create or update an OpenSEO\nproject, save keywords, or perform another provider write.\n";
44341
45532
  //#endregion
44342
45533
  //#region src/cli/preset/opencode/skills/metadata-improvement/SKILL.md
44343
- var SKILL_default$3 = "---\nname: metadata-improvement\ndescription: Research and apply truthful title and description improvements at the owning source.\n---\n\n# Metadata Improvement\n\nUse the route's intent, rendered head, sibling ownership, and live evidence described in\n[Executor Starters](references/executor.md). During research, compare candidate titles and\ndescriptions for accuracy, distinctness, intent alignment, and usefulness rather than fixed length\nor keyword-density targets.\n\nDuring the host-authorized action turn, apply only the accepted candidate at the owning route or\ncontent source. Preserve truthful product claims, existing metadata composition, canonical output,\nand repository conventions. Do not edit generated artifacts.\n\nLeave the resulting repository diff uncommitted. Provider state remains read-only.\n";
45534
+ var SKILL_default$4 = "---\nname: metadata-improvement\ndescription: Research and apply truthful title and description improvements at the owning source.\n---\n\n# Metadata Improvement\n\nUse the route's intent, rendered head, sibling ownership, and live evidence described in\n[Executor Starters](references/executor.md). During research, compare candidate titles and\ndescriptions for accuracy, distinctness, intent alignment, and usefulness rather than fixed length\nor keyword-density targets.\n\nDuring the host-authorized action turn, apply only the accepted candidate at the owning route or\ncontent source. Preserve truthful product claims, existing metadata composition, canonical output,\nand repository conventions. Do not edit generated artifacts.\n\nLeave the resulting repository diff uncommitted. Provider state remains read-only.\n";
44344
45535
  //#endregion
44345
45536
  //#region src/cli/preset/opencode/skills/metadata-improvement/references/executor.md
44346
45537
  var executor_default$3 = "# Executor Starters for Metadata Improvement\n\nThese snippets are starting points, not an inventory. Discover owned query/page evidence and a\ncurrent SERP before selecting a candidate.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console query page CTR position\", limit: 8 }),\n tools.executor.search({ query: \"current SERP titles snippets intent\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst owned = (await search({ query: \"tools.<exact-owned-performance-path>\", limit: 1 })).items[0];\nreturn owned.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nRecord dates, query and page filters, market, device, task status, errors, and cost. CTR and position\ndescribe observed performance; they do not prove a particular title caused it. Do not mutate\nprovider metadata or request recrawls.\n";
44347
45538
  //#endregion
44348
45539
  //#region src/cli/preset/opencode/skills/schema/SKILL.md
44349
- var SKILL_default$2 = "---\nname: schema\ndescription: Research and apply JSON-LD types justified by visible page content.\n---\n\n# Schema\n\nUse the rendered page, existing JSON-LD, route kind, and owning source as the primary evidence. Read\n[Executor Starters](references/executor.md) when owned search appearance or current eligibility\nevidence is material.\n\nPropose only types and properties that describe visible content. Schema does not repair missing\ncontent, guarantee a rich result, or justify unsupported ratings, prices, authorship, FAQs, or\norganization claims.\n\nDuring the host-authorized action turn, apply only accepted changes through the repository's\nexisting JSON-LD composition primitives. Do not edit rendered or generated output. Leave the diff\nuncommitted and provider state unchanged.\n";
45540
+ var SKILL_default$3 = "---\nname: schema\ndescription: Research and apply JSON-LD types justified by visible page content.\n---\n\n# Schema\n\nUse the rendered page, existing JSON-LD, route kind, and owning source as the primary evidence. Read\n[Executor Starters](references/executor.md) when owned search appearance or current eligibility\nevidence is material.\n\nPropose only types and properties that describe visible content. Schema does not repair missing\ncontent, guarantee a rich result, or justify unsupported ratings, prices, authorship, FAQs, or\norganization claims.\n\nDuring the host-authorized action turn, apply only accepted changes through the repository's\nexisting JSON-LD composition primitives. Do not edit rendered or generated output. Leave the diff\nuncommitted and provider state unchanged.\n";
44350
45541
  //#endregion
44351
45542
  //#region src/cli/preset/opencode/skills/schema/references/executor.md
44352
45543
  var executor_default$2 = "# Executor Starters for Schema\n\nThese snippets are starting points, not an inventory. Discover owned search-appearance, URL\ninspection, rich-result, or documentation tools when they can change the eligibility assessment.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console search appearance page\", limit: 8 }),\n tools.executor.search({ query: \"URL inspection rich results structured data eligibility\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst appearance = (await search({ query: \"tools.<exact-search-appearance-path>\", limit: 1 })).items[0];\nreturn appearance.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"SEARCH_APPEARANCE\", \"PAGE\"],\n});\n~~~\n\nTreat visible page content and current primary requirements as eligibility authority. Record dates,\npage filters, result types, task status, errors, and cost. Do not request indexing, submit schemas,\nor update provider projects.\n";
44353
45544
  //#endregion
44354
45545
  //#region src/cli/preset/opencode/skills/serp-analysis/SKILL.md
44355
- var SKILL_default$1 = "---\nname: serp-analysis\ndescription: Compare a target page with current results, intent, formats, features, and title patterns.\n---\n\n# SERP Analysis\n\nTreat each supplied query, market, language, device, and target page as one analysis unit. Read\n[Executor Starters](references/executor.md) before fetching current result evidence.\n\nIdentify dominant intent, page format, result features, recurring domains, title framing, and the\nevidence a useful page must satisfy. Compare the target against those patterns without turning\ncompetitor wording into product truth. Mark mixed or unstable intent rather than forcing a single\nconclusion.\n\nReturn the structured state requested by the workflow. A current SERP is observed evidence for its\nmarket and time, not a universal ranking rule.\n";
45546
+ var SKILL_default$2 = "---\nname: serp-analysis\ndescription: Compare a target page with current results, intent, formats, features, and title patterns.\n---\n\n# SERP Analysis\n\nTreat each supplied query, market, language, device, and target page as one analysis unit. Read\n[Executor Starters](references/executor.md) before fetching current result evidence.\n\nIdentify dominant intent, page format, result features, recurring domains, title framing, and the\nevidence a useful page must satisfy. Compare the target against those patterns without turning\ncompetitor wording into product truth. Mark mixed or unstable intent rather than forcing a single\nconclusion.\n\nReturn the structured state requested by the workflow. A current SERP is observed evidence for its\nmarket and time, not a universal ranking rule.\n";
44356
45547
  //#endregion
44357
45548
  //#region src/cli/preset/opencode/skills/serp-analysis/references/executor.md
44358
45549
  var executor_default$1 = "# Executor Starters for SERP Analysis\n\nThese snippets are starting points, not an inventory. Discover a current organic SERP capability\nand resolve its exact signature before use.\n\n~~~ts\nreturn tools.executor.search({\n query: \"OpenSEO DataForSEO current organic SERP features intent\",\n limit: 8,\n});\n~~~\n\nIn a later turn:\n\n~~~ts\nconst serp = (await search({ query: \"tools.<exact-serp-path>\", limit: 1 })).items[0];\nreturn serp.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n query,\n market,\n language,\n device,\n depth: 20,\n});\n~~~\n\nReuse a matching cached OpenSEO SERP when it satisfies the requested freshness. Record query,\nmarket, language, device, collection time, depth, features, task status, errors, and cost. Fetch\nindividual result pages only when their content is material to the decision.\n";
44359
45550
  //#endregion
44360
45551
  //#region src/cli/preset/opencode/skills/site-architecture/SKILL.md
44361
- var SKILL_default = "---\nname: site-architecture\ndescription: Plan clear route ownership, hierarchy, collections, and contextual navigation.\n---\n\n# Site Architecture\n\nUse PageGraph's deterministic route nodes and edges as the primary model. Read\n[Executor Starters](references/executor.md) when owned search or crawl evidence can resolve an\nownership or discoverability question.\n\nAssign one clear intent owner, identify useful collections and parent-child relationships, and\nprefer consolidation when multiple pages compete for the same need. Propose new routes only when a\ndistinct supported intent lacks an adequate owner. Treat URL changes as consequential and preserve\nredirect and canonical requirements in the recommendation.\n\nReturn the structured state requested by the workflow. This planning workflow does not move routes,\nedit navigation, or submit sitemaps.\n";
45552
+ var SKILL_default$1 = "---\nname: site-architecture\ndescription: Plan clear route ownership, hierarchy, collections, and contextual navigation.\n---\n\n# Site Architecture\n\nUse PageGraph's deterministic route nodes and edges as the primary model. Read\n[Executor Starters](references/executor.md) when owned search or crawl evidence can resolve an\nownership or discoverability question.\n\nAssign one clear intent owner, identify useful collections and parent-child relationships, and\nprefer consolidation when multiple pages compete for the same need. Propose new routes only when a\ndistinct supported intent lacks an adequate owner. Treat URL changes as consequential and preserve\nredirect and canonical requirements in the recommendation.\n\nReturn the structured state requested by the workflow. This planning workflow does not move routes,\nedit navigation, or submit sitemaps.\n";
44362
45553
  //#endregion
44363
45554
  //#region src/cli/preset/opencode/skills/site-architecture/references/executor.md
44364
- var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console page query canonical indexing\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl sitemap URL information\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst pages = (await search({ query: \"tools.<exact-owned-pages-path>\", limit: 1 })).items[0];\nreturn pages.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nKeep submitted, crawled, indexed, ranking, and clicked evidence distinct. Record reporting dates,\nfilters, canonical state, task status, errors, and cost. Do not request indexing, submit sitemaps,\nor update provider projects.\n";
45555
+ var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Search Console page performance\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl\", limit: 8 }),\n]);\n~~~\n\nInspect a selected tool's signature before executing it. This is Code Mode's local schema lookup;\nit does not call the provider:\n\n~~~ts\nreturn await search({ query: \"tools.<exact-path-returned-by-executor>\", limit: 1 });\n~~~\n\nIn a later turn, call that exact path with the required fields from its returned signature. Property,\nproject, URL, date, and dimension arguments differ between integrations. Do not copy guessed fields\nfrom a recipe. Prefer page/query performance for demand and ownership questions; use URL inspection\nwhen the question actually concerns crawl, canonical, or indexing state.\n\nKeep submitted, crawled, indexed, ranking, and clicked evidence distinct. Record reporting dates,\nfilters, canonical state, task status, errors, and cost. Do not request indexing, submit sitemaps,\nor update provider projects.\n";
44365
45556
  //#endregion
44366
45557
  //#region src/cli/preset/opencode/preset.ts
44367
45558
  const skill = (name, definition, executor) => ({
@@ -44372,17 +45563,17 @@ const opencodePreset = {
44372
45563
  ".pagegraph/opencode/opencode.jsonc": opencode_default,
44373
45564
  ".pagegraph/opencode/AGENTS.md": AGENTS_default,
44374
45565
  ".pagegraph/opencode/agents/seo.md": seo_default,
44375
- ...skill("keyword-research", SKILL_default$4, executor_default$4),
44376
- ...skill("competitive-landscape", SKILL_default$8, executor_default$8),
44377
- ...skill("authority-research", SKILL_default$9, executor_default$9),
44378
- ...skill("serp-analysis", SKILL_default$1, executor_default$1),
44379
- ...skill("content-analysis", SKILL_default$7, executor_default$7),
44380
- ...skill("ai-search", SKILL_default$10, executor_default$10),
44381
- ...skill("site-architecture", SKILL_default, executor_default),
44382
- ...skill("content-improvement", SKILL_default$6, executor_default$6),
44383
- ...skill("metadata-improvement", SKILL_default$3, executor_default$3),
44384
- ...skill("schema", SKILL_default$2, executor_default$2),
44385
- ...skill("internal-linking", SKILL_default$5, executor_default$5)
45566
+ ...skill("keyword-research", SKILL_default$5, executor_default$4),
45567
+ ...skill("competitive-landscape", SKILL_default$9, executor_default$8),
45568
+ ...skill("authority-research", SKILL_default$10, executor_default$9),
45569
+ ...skill("serp-analysis", SKILL_default$2, executor_default$1),
45570
+ ...skill("content-analysis", SKILL_default$8, executor_default$7),
45571
+ ...skill("ai-search", SKILL_default$11, executor_default$10),
45572
+ ...skill("site-architecture", SKILL_default$1, executor_default),
45573
+ ...skill("content-improvement", SKILL_default$7, executor_default$6),
45574
+ ...skill("metadata-improvement", SKILL_default$4, executor_default$3),
45575
+ ...skill("schema", SKILL_default$3, executor_default$2),
45576
+ ...skill("internal-linking", SKILL_default$6, executor_default$5)
44386
45577
  };
44387
45578
  //#endregion
44388
45579
  //#region src/cli/commands/init.ts
@@ -44448,277 +45639,6 @@ const initCommand = make$9("init", {
44448
45639
  else yield* printText(`${dryRun ? "Would create" : "Created"} ${created.length} file(s). Kept ${existing.length} existing file(s).`);
44449
45640
  })));
44450
45641
  //#endregion
44451
- //#region src/audit/crawl.ts
44452
- /** Anchors that are never HTML documents; skipped so they do not consume the budget. */
44453
- const NON_PAGE = /\.(?:avif|css|csv|gif|ico|jpe?g|js|json|map|mp4|pdf|png|rss|svg|txt|webm|webp|woff2?|xml|zip)$/i;
44454
- /** The graph keys pages by path, so dedupe crawled URLs the same way. */
44455
- const pageKey = (url) => normalizePath(url);
44456
- /** Run `run` over `items` with at most `limit` in flight, preserving order. */
44457
- const mapLimit = async (items, limit, run) => {
44458
- const results = Array.from({ length: items.length });
44459
- let index = 0;
44460
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
44461
- for (;;) {
44462
- const current = index++;
44463
- if (current >= items.length) return;
44464
- results[current] = await run(items[current]);
44465
- }
44466
- });
44467
- await Promise.all(workers);
44468
- return results.map((result, at) => {
44469
- if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
44470
- return result;
44471
- });
44472
- };
44473
- /**
44474
- * Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
44475
- * successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
44476
- * private and reserved destinations are rejected unless `allowPrivate` is set.
44477
- */
44478
- const crawlRenderedPages = async (seed, options) => {
44479
- let seedUrl;
44480
- try {
44481
- seedUrl = new URL(seed);
44482
- } catch {
44483
- throw new Error(`Not a valid URL: ${seed}`);
44484
- }
44485
- if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
44486
- if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
44487
- const origin = seedUrl.origin;
44488
- const probeOptions = {
44489
- allowPrivate: options.allowPrivate,
44490
- timeoutMs: options.timeoutMs,
44491
- maxBodyBytes: options.maxBodyBytes,
44492
- captureAnchors: true
44493
- };
44494
- const concurrency = Math.max(1, options.concurrency ?? 4);
44495
- const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
44496
- /** Final paths already rendered, so a redirect cannot add the same page twice. */
44497
- const rendered = /* @__PURE__ */ new Set();
44498
- const pages = [];
44499
- const failures = [];
44500
- const truncatedBodies = [];
44501
- let attempts = 0;
44502
- let truncated = false;
44503
- let root = pageKey(seedUrl);
44504
- let frontier = [{
44505
- url: seedUrl,
44506
- depth: 0
44507
- }];
44508
- const probePage = async (item) => {
44509
- const request = {
44510
- kind: "page-html",
44511
- method: "GET",
44512
- accept: "text/html, */*;q=0.1",
44513
- url: item.url
44514
- };
44515
- const probe = await probeHttp(request, probeOptions);
44516
- if (!probe.ok || probe.finalUrl === null) return {
44517
- item,
44518
- page: null,
44519
- failure: {
44520
- url: item.url.href,
44521
- error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
44522
- },
44523
- links: [],
44524
- bodyTruncated: false
44525
- };
44526
- if (new URL(probe.finalUrl).origin !== origin) return {
44527
- item,
44528
- page: null,
44529
- failure: {
44530
- url: item.url.href,
44531
- error: `Redirected off-origin to ${probe.finalUrl}`
44532
- },
44533
- links: [],
44534
- bodyTruncated: false
44535
- };
44536
- const anchors = probe.anchors ?? [];
44537
- const links = anchors.flatMap((anchor) => {
44538
- if (!anchor.internal) return [];
44539
- let target;
44540
- try {
44541
- target = new URL(anchor.href);
44542
- } catch {
44543
- return [];
44544
- }
44545
- if (target.origin !== origin) return [];
44546
- if (NON_PAGE.test(target.pathname)) return [];
44547
- return [target];
44548
- });
44549
- return {
44550
- item,
44551
- page: {
44552
- url: probe.finalUrl,
44553
- anchors
44554
- },
44555
- failure: null,
44556
- links,
44557
- bodyTruncated: probe.bodyTruncated
44558
- };
44559
- };
44560
- while (frontier.length > 0) {
44561
- const remaining = options.limit - attempts;
44562
- if (remaining <= 0) {
44563
- truncated = true;
44564
- break;
44565
- }
44566
- const batch = frontier.length > remaining ? frontier.slice(0, remaining) : frontier;
44567
- if (frontier.length > batch.length) truncated = true;
44568
- frontier = [];
44569
- attempts += batch.length;
44570
- const probed = await mapLimit(batch, concurrency, probePage);
44571
- const discovered = [];
44572
- for (const result of probed) {
44573
- if (result.failure !== null) failures.push(result.failure);
44574
- if (result.page === null) continue;
44575
- const finalKey = pageKey(new URL(result.page.url));
44576
- if (result.item.depth === 0) root = finalKey;
44577
- if (rendered.has(finalKey)) continue;
44578
- rendered.add(finalKey);
44579
- visited.add(finalKey);
44580
- pages.push(result.page);
44581
- if (result.bodyTruncated) truncatedBodies.push(result.page.url);
44582
- for (const link of result.links) {
44583
- const key = pageKey(link);
44584
- if (visited.has(key)) continue;
44585
- visited.add(key);
44586
- discovered.push({
44587
- url: link,
44588
- depth: result.item.depth + 1
44589
- });
44590
- }
44591
- }
44592
- frontier = discovered;
44593
- }
44594
- return {
44595
- origin,
44596
- seed: seedUrl.href,
44597
- root,
44598
- pages,
44599
- failures,
44600
- truncatedBodies,
44601
- truncated,
44602
- limit: options.limit
44603
- };
44604
- };
44605
- //#endregion
44606
- //#region src/core/link-candidates.ts
44607
- /**
44608
- * Canonical path key: query and hash stripped, trailing slashes removed, "/"
44609
- * preserved. This is the same path key the graph and the rendered-link core
44610
- * use, so a served anchor like `/blog/a?ref=nav` still matches the graph pair
44611
- * `/blog/a`.
44612
- */
44613
- const normalize = (path) => {
44614
- const trimmed = (path.split(/[?#]/, 1)[0] ?? "").replace(/\/+$/, "");
44615
- return trimmed === "" ? "/" : trimmed;
44616
- };
44617
- /** Directionless pair key, so an edge in either direction means "connected". */
44618
- const undirectedEdgeKey = (from, to) => {
44619
- const [a, b] = [normalize(from), normalize(to)].sort();
44620
- return `${a}\u0000${b}`;
44621
- };
44622
- /** First path segment, or undefined for the root page. */
44623
- const topSegment = (path) => {
44624
- const segment = path.split("/").filter(Boolean)[0];
44625
- return segment === void 0 || segment === "" ? void 0 : segment;
44626
- };
44627
- /** A page with no nested segment (`/`, `/pricing`, `/blog`) is root-level. */
44628
- const isRootLevel = (path) => path.split("/").filter(Boolean).length <= 1;
44629
- /**
44630
- * The cluster a pair shares, or undefined when they share none. Section-first:
44631
- * a shared top-level section wins. The kind fallback is local to root-level
44632
- * pages, so it never pairs two nested pages from different sections.
44633
- */
44634
- const clusterOf = (a, b) => {
44635
- const aSegment = topSegment(a.path);
44636
- const bSegment = topSegment(b.path);
44637
- if (aSegment !== void 0 && aSegment === bSegment) return {
44638
- key: aSegment,
44639
- reason: `same top-level section "/${aSegment}"`
44640
- };
44641
- if (isRootLevel(a.path) && isRootLevel(b.path) && a.kind === b.kind) return {
44642
- key: `kind:${a.kind}`,
44643
- reason: `same kind "${a.kind}"`
44644
- };
44645
- };
44646
- /** A `--cluster` filter matches a section by name, or a kind via its bare label. */
44647
- const matchesClusterFilter = (cluster, filter) => {
44648
- const normalized = filter.replace(/^\/+/, "").toLowerCase();
44649
- const key = cluster.toLowerCase();
44650
- return key === normalized || key === `kind:${normalized}`;
44651
- };
44652
- /**
44653
- * Enumerate reviewable contextual-link candidates from the declared graph.
44654
- *
44655
- * Excluded: self-pairs, pages that are not sitemap-eligible, pairs already
44656
- * declared as a `related` edge, and — when {@link LinkCandidateOptions.renderedEdges}
44657
- * is supplied — pairs already rendered as an anchor. Queries, hashes, and
44658
- * trailing slashes are normalized so both sides compare by the graph's path key.
44659
- */
44660
- const generateLinkCandidates = (graph, options = {}) => {
44661
- const limit = options.limit ?? 50;
44662
- const filters = options.clusters ?? [];
44663
- const rendered = new Set((options.renderedEdges ?? []).map((edge) => undirectedEdgeKey(edge.from, edge.to)));
44664
- const declared = new Set(graph.edges.filter((edge) => edge.type === "related").map((edge) => undirectedEdgeKey(edge.from, edge.to)));
44665
- const eligible = [...graph.nodes.values()].filter(isSitemapEligible);
44666
- const all = [];
44667
- for (let i = 0; i < eligible.length; i++) for (let j = i + 1; j < eligible.length; j++) {
44668
- const a = eligible[i];
44669
- const b = eligible[j];
44670
- const cluster = clusterOf(a, b);
44671
- if (cluster === void 0) continue;
44672
- if (filters.length > 0 && !filters.some((filter) => matchesClusterFilter(cluster.key, filter))) continue;
44673
- const key = undirectedEdgeKey(a.path, b.path);
44674
- if (declared.has(key) || rendered.has(key)) continue;
44675
- all.push({
44676
- source: a.path,
44677
- destination: b.path,
44678
- cluster: cluster.key,
44679
- reason: cluster.reason
44680
- });
44681
- all.push({
44682
- source: b.path,
44683
- destination: a.path,
44684
- cluster: cluster.key,
44685
- reason: cluster.reason
44686
- });
44687
- }
44688
- const compare = (x, y) => x.cluster < y.cluster ? -1 : x.cluster > y.cluster ? 1 : x.source < y.source ? -1 : x.source > y.source ? 1 : x.destination < y.destination ? -1 : x.destination > y.destination ? 1 : 0;
44689
- all.sort(compare);
44690
- const counts = /* @__PURE__ */ new Map();
44691
- for (const pair of all) counts.set(pair.cluster, (counts.get(pair.cluster) ?? 0) + 1);
44692
- const candidates = all.slice(0, limit);
44693
- return {
44694
- candidates,
44695
- total: all.length,
44696
- truncated: all.length > candidates.length,
44697
- clusters: [...counts.entries()].map(([key, count]) => ({
44698
- key,
44699
- candidates: count
44700
- }))
44701
- };
44702
- };
44703
- /**
44704
- * Decode a rendered-edge dump supplied to the CLI: either a bare array of
44705
- * `{ from, to }` edges, or an object carrying `edges` (or `internalEdges`).
44706
- * Throws on any other shape — this is user input, not a provider payload.
44707
- */
44708
- const decodeRenderedEdges = (input) => {
44709
- const array = Array.isArray(input) ? input : input !== null && typeof input === "object" && Array.isArray(input.edges) ? input.edges : input !== null && typeof input === "object" && Array.isArray(input.internalEdges) ? input.internalEdges : void 0;
44710
- if (array === void 0) throw new Error("expected an array of { from, to } edges, or an object with an `edges` array");
44711
- return array.map((item) => {
44712
- if (item === null || typeof item !== "object") throw new Error("each rendered edge must be an object with string `from` and `to`");
44713
- const { from, to } = item;
44714
- if (typeof from !== "string" || typeof to !== "string") throw new Error("each rendered edge must have string `from` and `to`");
44715
- return {
44716
- from,
44717
- to
44718
- };
44719
- });
44720
- };
44721
- //#endregion
44722
45642
  //#region src/cli/commands/links.ts
44723
45643
  const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
44724
45644
  const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
@@ -44744,6 +45664,7 @@ const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState)
44744
45664
  if (assertCoverage) {
44745
45665
  if (crawlState.truncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage on a truncated crawl; re-run with a higher --limit." });
44746
45666
  if (crawlState.failures > 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: ${crawlState.failures} page(s) failed to fetch, so their anchors are missing.` });
45667
+ if (crawlState.discoveryFailures > 0 || crawlState.sitemapTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage: sitemap or robots discovery was incomplete." });
44747
45668
  if (crawlState.bodyTruncated) return yield* new SeoCliError({ message: "Refusing to assert rendered coverage when a crawled body was truncated; raise --max-body-bytes." });
44748
45669
  }
44749
45670
  const warnings = [];
@@ -44785,6 +45706,11 @@ const analyzeDeclared = (crawlOrigin, renderedEdges, assertCoverage, crawlState)
44785
45706
  }
44786
45707
  return yield* scoped(gen(function* () {
44787
45708
  const graph = yield* acquireGraph(config);
45709
+ if (assertCoverage && crawlState.nonHtml.length > 0) {
45710
+ const declaredPaths = new Set([...graph.nodes.values()].filter(isSitemapEligible).map((node) => normalizePath(new URL(node.path, crawlOrigin))));
45711
+ const missed = crawlState.nonHtml.find((item) => declaredPaths.has(normalizePath(new URL(item.url))) || declaredPaths.has(normalizePath(new URL(item.finalUrl))));
45712
+ if (missed !== void 0) return yield* new SeoCliError({ message: `Refusing to assert rendered coverage: declared page ${missed.url} served non-HTML content.` });
45713
+ }
44788
45714
  return {
44789
45715
  declaredEdges: graph.edges.filter((edge) => edge.type === "related").map((edge) => ({
44790
45716
  from: edge.from,
@@ -44878,8 +45804,13 @@ const linksVerifyCommand = make$9("verify", {
44878
45804
  limit: artifact.crawl.limit,
44879
45805
  truncated: artifact.crawl.truncated,
44880
45806
  failures: artifact.crawl.failures,
45807
+ ...artifact.crawl.nonHtml === void 0 ? {} : { nonHtml: artifact.crawl.nonHtml },
44881
45808
  bodyTruncated: artifact.crawl.bodyTruncated,
44882
- truncatedPages: artifact.crawl.truncatedPages
45809
+ truncatedPages: artifact.crawl.truncatedPages,
45810
+ ...artifact.crawl.discoveryFailures === void 0 ? {} : { discoveryFailures: artifact.crawl.discoveryFailures },
45811
+ ...artifact.crawl.skipped === void 0 ? {} : { skipped: artifact.crawl.skipped },
45812
+ ...artifact.crawl.sitemapTruncated === void 0 ? {} : { sitemapTruncated: artifact.crawl.sitemapTruncated },
45813
+ ...artifact.crawl.sitemapUsed === void 0 ? {} : { sitemapUsed: artifact.crawl.sitemapUsed }
44883
45814
  };
44884
45815
  } else {
44885
45816
  const checkedLimit = yield* positive("limit", options.limit);
@@ -44904,8 +45835,13 @@ const linksVerifyCommand = make$9("verify", {
44904
45835
  limit: crawl.limit,
44905
45836
  truncated: crawl.truncated,
44906
45837
  failures: crawl.failures,
45838
+ nonHtml: crawl.nonHtml,
44907
45839
  bodyTruncated: crawl.truncatedBodies.length > 0,
44908
- truncatedPages: crawl.truncatedBodies
45840
+ truncatedPages: crawl.truncatedBodies,
45841
+ discoveryFailures: crawl.discoveryFailures,
45842
+ skipped: crawl.skipped,
45843
+ sitemapTruncated: crawl.sitemapTruncated,
45844
+ sitemapUsed: crawl.sitemapUsed
44909
45845
  };
44910
45846
  if (emitPath !== void 0) {
44911
45847
  const artifact = {
@@ -44920,7 +45856,12 @@ const linksVerifyCommand = make$9("verify", {
44920
45856
  truncated: crawl.truncated,
44921
45857
  bodyTruncated: crawl.truncatedBodies.length > 0,
44922
45858
  truncatedPages: crawl.truncatedBodies,
44923
- failures: crawl.failures
45859
+ failures: crawl.failures,
45860
+ nonHtml: crawl.nonHtml,
45861
+ discoveryFailures: crawl.discoveryFailures,
45862
+ skipped: crawl.skipped,
45863
+ sitemapTruncated: crawl.sitemapTruncated,
45864
+ sitemapUsed: crawl.sitemapUsed
44924
45865
  },
44925
45866
  nodes: graph.nodes,
44926
45867
  edges: graph.internalEdges
@@ -44930,10 +45871,15 @@ const linksVerifyCommand = make$9("verify", {
44930
45871
  }
44931
45872
  }
44932
45873
  if (crawlReport.truncatedPages.length > 0) warnings.push(`${crawlReport.truncatedPages.length} page body/bodies exceeded --max-body-bytes; anchors past the cutoff are missing.`);
45874
+ if ((crawlReport.discoveryFailures?.length ?? 0) > 0 || crawlReport.sitemapTruncated === true) warnings.push("Sitemap or robots discovery was incomplete; rendered coverage cannot be asserted.");
45875
+ if ((crawlReport.nonHtml?.length ?? 0) > 0) warnings.push(`${crawlReport.nonHtml.length} link-only URL(s) served non-HTML and were skipped.`);
44933
45876
  const analysis = yield* analyzeDeclared(origin, renderedEdges, options.assertCoverage, {
44934
45877
  truncated: crawlReport.truncated,
44935
45878
  bodyTruncated: crawlReport.bodyTruncated,
44936
- failures: crawlReport.failures.length
45879
+ failures: crawlReport.failures.length,
45880
+ discoveryFailures: crawlReport.discoveryFailures?.length ?? 0,
45881
+ sitemapTruncated: crawlReport.sitemapTruncated ?? false,
45882
+ nonHtml: crawlReport.nonHtml ?? []
44937
45883
  });
44938
45884
  warnings.push(...analysis.warnings);
44939
45885
  const diff = analysis.declaredEdges === void 0 ? void 0 : diffLinkGraph(analysis.declaredEdges, graph);
@@ -44971,6 +45917,10 @@ const linksVerifyCommand = make$9("verify", {
44971
45917
  const candidatesLimitFlag = Int("limit").pipe(withDescription$2("Maximum candidates to output after ordering"), withDefault(50));
44972
45918
  const clusterFlag = String$2("cluster").pipe(withDescription$2("Restrict to a cluster: a top-level section or a kind (repeatable)"), between(0, 64));
44973
45919
  const renderedFlag = String$2("rendered").pipe(withDescription$2("JSON file of already-rendered edges to exclude (array or { edges })"), optional$1);
45920
+ const siteFlag = String$2("site").pipe(withDescription$2("Opt in to same-origin served-content suggestions"), optional$1);
45921
+ const pageLimitFlag = Int("page-limit").pipe(withDescription$2("Maximum declared pages to probe for content (default 25)"), withDefault(25));
45922
+ const targetFlag = String$2("target").pipe(withDescription$2("Prioritize an exact target page path (repeatable)"), between(0, 64));
45923
+ const sourceFlag = String$2("source").pipe(withDescription$2("Prioritize an exact source page path (repeatable)"), between(0, 64));
44974
45924
  /** Read already-rendered edges from an optional JSON file; absent input is no exclusions. */
44975
45925
  const readRenderedEdges = (file) => file === void 0 ? succeed$2([]) : try_({
44976
45926
  try: () => decodeRenderedEdges(JSON.parse(readFileSync(file, "utf8"))),
@@ -44980,7 +45930,14 @@ const linksCandidatesCommand = make$9("candidates", {
44980
45930
  json: jsonFlag,
44981
45931
  limit: candidatesLimitFlag,
44982
45932
  cluster: clusterFlag,
44983
- rendered: renderedFlag
45933
+ rendered: renderedFlag,
45934
+ site: siteFlag,
45935
+ pageLimit: pageLimitFlag,
45936
+ target: targetFlag,
45937
+ source: sourceFlag,
45938
+ allowPrivate,
45939
+ requestTimeoutMs,
45940
+ maxBodyBytes
44984
45941
  }).pipe(withDescription$1("Propose reviewable contextual links from the declared graph, clustered and not already connected"), withExamples([
44985
45942
  {
44986
45943
  command: "pagegraph links candidates",
@@ -44993,12 +45950,149 @@ const linksCandidatesCommand = make$9("candidates", {
44993
45950
  {
44994
45951
  command: "pagegraph links candidates --rendered rendered.json --json | jq",
44995
45952
  description: "Exclude anchors already served, and emit versioned JSON"
45953
+ },
45954
+ {
45955
+ command: "pagegraph links candidates --site https://example.com --json",
45956
+ description: "Rank verifiable sentence-and-anchor suggestions from served pages"
45957
+ },
45958
+ {
45959
+ command: "pagegraph links candidates --site https://example.com --cluster blog --page-limit 25 --json",
45960
+ description: "Focus a bounded probe on an editorial section when the site-wide sample is sparse"
45961
+ },
45962
+ {
45963
+ command: "pagegraph links candidates --site https://example.com --target /blog/weak --json",
45964
+ description: "Probe a weak target and its candidate source pages first"
44996
45965
  }
44997
45966
  ]), withHandler(fn("SeoCli.linksCandidates")(function* (options) {
44998
45967
  const limit = yield* positive("limit", options.limit);
45968
+ const pageLimit = yield* positive("page-limit", options.pageLimit);
45969
+ if (isSome(options.site) && (!Number.isSafeInteger(options.maxBodyBytes) || options.maxBodyBytes < 1 || options.maxBodyBytes > 1e7)) return yield* new SeoCliError({ message: "--max-body-bytes must be between 1 and 10000000 in site mode" });
44999
45970
  const config = yield* loadSeoConfig;
45000
45971
  const graph = yield* scoped(acquireGraph(config));
45001
45972
  const renderedEdges = yield* readRenderedEdges(getOrUndefined$1(options.rendered));
45973
+ const site = getOrUndefined$1(options.site);
45974
+ if (site !== void 0) {
45975
+ const report = yield* tryPromise({
45976
+ try: async () => {
45977
+ const target = new URL(site);
45978
+ const configured = new URL(config.origin);
45979
+ if (!["http:", "https:"].includes(target.protocol) || target.origin !== configured.origin) throw new Error(`--site origin must match seo.config.ts (${configured.origin})`);
45980
+ const requestedTargets = new Set(options.target);
45981
+ const requestedSources = new Set(options.source);
45982
+ const nodes = [...graph.nodes.values()].filter(isSitemapEligible);
45983
+ const selected = selectSuggestionPages(graph, {
45984
+ pageLimit,
45985
+ targets: requestedTargets,
45986
+ sources: requestedSources,
45987
+ clusters: options.cluster,
45988
+ renderedEdges
45989
+ });
45990
+ const selectedPaths = new Set(selected.map((node) => node.path));
45991
+ const skipped = nodes.filter((node) => !selectedPaths.has(node.path)).map((node) => ({
45992
+ path: node.path,
45993
+ reason: "page limit or target filter"
45994
+ }));
45995
+ const robots = await probeHttp({
45996
+ kind: "robots",
45997
+ method: "GET",
45998
+ accept: "text/plain",
45999
+ url: new URL("/robots.txt", configured)
46000
+ }, {
46001
+ sameOrigin: configured.origin,
46002
+ allowPrivate: options.allowPrivate,
46003
+ timeoutMs: options.requestTimeoutMs,
46004
+ maxBodyBytes: options.maxBodyBytes,
46005
+ captureBody: true
46006
+ });
46007
+ if (!robots.ok && robots.status !== 404 || robots.bodyTruncated) throw new Error("Could not establish complete robots policy for the site");
46008
+ const rules = robots.ok ? robotsRules(robots.body ?? "").rules : [];
46009
+ const pages = [];
46010
+ const observed = [];
46011
+ for (const node of selected) {
46012
+ if (!allowedByRobots(node.path, rules)) {
46013
+ skipped.push({
46014
+ path: node.path,
46015
+ reason: "robots disallow"
46016
+ });
46017
+ continue;
46018
+ }
46019
+ const url = new URL(node.path, configured);
46020
+ const probe = await probeHttp({
46021
+ kind: "page-html",
46022
+ method: "GET",
46023
+ accept: "text/html",
46024
+ url
46025
+ }, {
46026
+ sameOrigin: configured.origin,
46027
+ allowPrivate: options.allowPrivate,
46028
+ timeoutMs: options.requestTimeoutMs,
46029
+ maxBodyBytes: options.maxBodyBytes,
46030
+ captureAnchors: true,
46031
+ captureBody: true,
46032
+ allowUrl: (next) => allowedByRobots(`${next.pathname}${next.search}`, rules)
46033
+ });
46034
+ const contentType = probe.responseHeaders["content-type"];
46035
+ if (!probe.ok || probe.bodyTruncated || contentType !== void 0 && !/text\/html|application\/xhtml\+xml/i.test(contentType) || !probe.body || !probe.finalUrl || new URL(probe.finalUrl).pathname.replace(/\/+$/, "") !== node.path.replace(/\/+$/, "")) {
46036
+ skipped.push({
46037
+ path: node.path,
46038
+ reason: probe.error ?? (probe.bodyTruncated ? "body truncated" : "unreadable or redirected page")
46039
+ });
46040
+ continue;
46041
+ }
46042
+ const sentences = extractPageSentences(probe.body);
46043
+ if (sentences.length === 0) {
46044
+ skipped.push({
46045
+ path: node.path,
46046
+ reason: "no readable main content"
46047
+ });
46048
+ continue;
46049
+ }
46050
+ pages.push({
46051
+ path: node.path,
46052
+ sentences
46053
+ });
46054
+ for (const anchor of probe.anchors ?? []) {
46055
+ if (!anchor.internal) continue;
46056
+ observed.push({
46057
+ from: node.path,
46058
+ to: new URL(anchor.href).pathname.replace(/\/+$/, "") || "/",
46059
+ region: anchor.region
46060
+ });
46061
+ }
46062
+ }
46063
+ const readablePaths = new Set(pages.map((page) => page.path));
46064
+ const scopedGraph = {
46065
+ ...graph,
46066
+ nodes: new Map([...graph.nodes].filter(([path]) => readablePaths.has(path))),
46067
+ edges: graph.edges.filter((edge) => readablePaths.has(edge.from) && readablePaths.has(edge.to))
46068
+ };
46069
+ const pairs = generateLinkCandidates(scopedGraph, {
46070
+ clusters: options.cluster,
46071
+ limit: Number.MAX_SAFE_INTEGER,
46072
+ renderedEdges: [...renderedEdges, ...observed]
46073
+ }).candidates.filter((pair) => (requestedTargets.size === 0 || requestedTargets.has(pair.destination)) && (requestedSources.size === 0 || requestedSources.has(pair.source)));
46074
+ const inbound = /* @__PURE__ */ new Map();
46075
+ for (const edge of [...graph.edges.filter((edge) => edge.type === "related"), ...observed.filter((edge) => edge.region === "body")]) inbound.set(edge.to, (inbound.get(edge.to) ?? 0) + 1);
46076
+ const ranked = rankLinkSuggestions(pairs, pages, inbound, limit);
46077
+ return {
46078
+ kind: "links-candidates",
46079
+ schemaVersion: 2,
46080
+ origin: configured.origin,
46081
+ limit,
46082
+ pageLimit,
46083
+ maxBodyBytes: options.maxBodyBytes,
46084
+ total: ranked.total,
46085
+ truncated: ranked.total > ranked.candidates.length || skipped.some((item) => item.reason === "page limit or target filter"),
46086
+ skipped,
46087
+ candidates: ranked.candidates
46088
+ };
46089
+ },
46090
+ catch: (cause) => new SeoCliError({ message: `Could not produce site suggestions: ${messageOf(cause)}` })
46091
+ });
46092
+ if (options.json) yield* printJson(report);
46093
+ else yield* printText(`${report.total} content-backed suggestion(s) · ${report.skipped.length} page(s) skipped\n\n${report.candidates.map((item) => `${item.source} → ${item.destination} (score ${item.score})\n ${item.sentence}\n Anchor: ${item.anchor}\n Target: ${item.targetSentence}\n ${item.reason}`).join("\n\n")}`);
46094
+ return;
46095
+ }
45002
46096
  const result = generateLinkCandidates(graph, {
45003
46097
  limit,
45004
46098
  clusters: options.cluster,
@@ -45029,6 +46123,7 @@ const writeFlags = {
45029
46123
  dryRun: dryRunFlag$1,
45030
46124
  allowDirty: allowDirtyFlag$1
45031
46125
  };
46126
+ const allowPrivateFlag = Boolean$1("allow-private").pipe(withDescription$2("Allow localhost/private site probes for suggestion freshness (local development only)"), withDefault(false));
45032
46127
  /** File-writing content, metadata, schema, and link workflows. */
45033
46128
  const improveCommandGroup = make$9("improve").pipe(withDescription$1("Apply page-backed SEO improvements through the configured OpenCode agent"), withSubcommands([
45034
46129
  workflowCommand({
@@ -45075,6 +46170,8 @@ const improveCommandGroup = make$9("improve").pipe(withDescription$1("Apply page
45075
46170
  description: "Add contextual internal links and update PageGraph declarations when appropriate",
45076
46171
  flags: {
45077
46172
  ...baseFlags,
46173
+ suggestions: suggestionsFlag,
46174
+ allowPrivate: allowPrivateFlag,
45078
46175
  dryRun: writeFlags.dryRun,
45079
46176
  allowDirty: writeFlags.allowDirty
45080
46177
  },
@@ -45134,6 +46231,51 @@ const sitemapCommand = make$9("sitemap", {
45134
46231
  }));
45135
46232
  })));
45136
46233
  //#endregion
46234
+ //#region src/cli/commands/skills.ts
46235
+ const skills = { core: "---\nname: core\ndescription: Use when adding Pagegraph to a TanStack Start app or changing its SEO wiring — staticData.seo declarations, seoHead in route head(), typed related links, content collections, the seoRouteConfig coverage gate, seo.config.ts, sitemap and robots routes — or when a `pagegraph check`, coverage, or rendered-head failure needs diagnosing.\n---\n\n# Pagegraph and TanStack Start\n\nEach route declares its SEO policy once in `staticData.seo`; Pagegraph derives the sitemap, robots, breadcrumbs, JSON-LD, related links, and `pagegraph check` from those declarations. TanStack Start still owns routing and the document shell. This skill ships inside the Pagegraph CLI that printed it, so it matches that version. Run the app's installed binary (`bunx pagegraph` from the app root) so the guidance and the types agree.\n\nBefore editing, read the app's `package.json`, `src/routes/`, `src/routeTree.gen.ts`, any existing `seo.config.ts`, and `vite.config.ts`. Adapt the paths below to the app's layout.\n\n## Wire it up\n\n**1. Bind site identity and typed paths once.** Route files never repeat the origin or brand.\n\n```ts\n// src/lib/seo.ts\nimport { createSeo } from \"pagegraph/react\";\nimport type { FileRouteTypes } from \"../routeTree.gen\";\n\ndeclare module \"pagegraph\" {\n interface Register {\n paths: FileRouteTypes[\"fullPaths\"];\n kinds: \"page\" | \"article\" | \"hub\";\n }\n}\n\nexport const { seoHead } = createSeo({\n origin: \"https://example.com\",\n site: {\n name: \"Example\",\n logo: \"/logo.png\",\n publisherLogo: \"/publisher.png\",\n defaultImage: \"/og.png\",\n defaultAuthor: { name: \"Example Team\" },\n },\n organization: {\n description: \"What the company does.\",\n sameAs: [],\n contactPoint: { contactType: \"support\", email: \"support@example.com\" },\n },\n website: { searchPath: \"/search?q={search_term_string}\" },\n});\n```\n\nThe `Register` augmentation is what types `staticData.seo` on TanStack routes and checks `related` and `redirectTo` against real paths, so a renamed route becomes a compile error. `kinds` is the app's own taxonomy; nothing branches on it.\n\n**2. Declare policy on the route, content in `head()`.**\n\n```tsx\n// src/routes/(marketing)/pricing.tsx\nimport { createFileRoute } from \"@tanstack/react-router\";\nimport { seoHead } from \"../../lib/seo\";\n\nexport const Route = createFileRoute(\"/(marketing)/pricing\")({\n staticData: {\n seo: {\n kind: \"page\",\n crumb: \"Pricing\",\n sitemap: { priority: 0.9, changeFrequency: \"monthly\" },\n related: [\"/features\"],\n link: { title: \"Pricing\", description: \"Simple volume pricing.\" },\n },\n },\n head: (ctx) =>\n seoHead(ctx, {\n title: \"Pricing — Example\",\n description: \"Simple volume pricing.\",\n }),\n component: PricingPage,\n});\n```\n\n- `staticData.seo` is static crawl policy and graph edges: `kind`, `crumb`, `sitemap` (or `false`), `robots`, `related`, `link`, `redirectTo`.\n- `seoHead(ctx, instance)` takes the full `title` (no suffix is added) and `description`, and optionally `article`, `faqs`, `service`, `itemList`, `jsonLd`, `canonicalPath`, and `breadcrumbs`. It returns `meta` and a canonical `links` entry for TanStack to render; the document shell must keep `<HeadContent />` and `<Scripts />`.\n- `link` is how other pages render a card for this route. Add it to every route that appears in someone's `related`.\n- Declare `related` only for links the page actually renders in its body.\n- `crumb` accepts `(match) => string` for dynamic segments, such as a title from `loaderData`.\n\n**3. Export one graph loader.** The CLI, sitemap, and robots all read it.\n\n```ts\n// src/lib/seo/graph.ts\nimport { buildSeoGraph } from \"pagegraph\";\nimport { routeTree } from \"../../routeTree.gen\";\nimport { posts } from \"../content/posts\"; // the app's own content source\n\nexport const loadSeoGraph = async () =>\n buildSeoGraph({\n routeTree,\n collections: [\n {\n route: \"/blog/$slug\",\n source: \"blog\",\n instances: posts.map((post) => ({\n path: `/blog/${post.slug}`,\n title: post.title,\n description: post.description,\n publishedAt: post.date,\n })),\n },\n ],\n });\n```\n\nA param route such as `/blog/$slug` never enters the sitemap itself; list its pages as a collection. Instances inherit the param route's `kind` and `sitemap`, so declare those on `/blog/$slug`. Omit `collections` when every public page is a static route.\n\n**4. Point the CLI at the loader** with `seo.config.ts` at the app root:\n\n```ts\n// seo.config.ts\nimport { defineSeoConfig, viteGraphLoader } from \"pagegraph/config\";\n\nexport default defineSeoConfig({\n origin: \"https://example.com\",\n disallow: [\"/dashboard\", \"/api\"],\n loadGraph: viteGraphLoader({\n root: import.meta.dirname,\n entry: \"/src/lib/seo/graph.ts\",\n exportName: \"loadSeoGraph\",\n }),\n});\n```\n\n`entry` is root-relative and evaluated in a headless Vite server, so aliases and content plugins resolve as they do in the app. Optional fields: `contentSignal`, `coverage` (`[{ path, minInbound }]`, contextual-link minimums enforced by `check`), `directives`, and `transform`.\n\n**5. Serve the projections.** In Start server routes, return `renderSitemap(graph, { origin, indexable })` and `renderRobots(graph, { origin, indexable, disallow, contentSignal })` from `pagegraph`. On preview deployments, pass `indexable: false` so robots.txt disallows everything; the sitemap body does not change.\n\n**6. Optionally gate coverage at build time.**\n\n```ts\n// vite.config.ts\nimport { fileURLToPath } from \"node:url\";\nimport { tanstackStart } from \"@tanstack/react-start/plugin/vite\";\nimport { defineConfig } from \"vite\";\nimport { seoRouteConfig } from \"pagegraph/vite\";\n\nexport default defineConfig({\n plugins: [\n tanstackStart(),\n // Runs its own route parse; it does not replace the Start plugin.\n seoRouteConfig({\n outputPath: fileURLToPath(new URL(\"./src/lib/route-config.ts\", import.meta.url)),\n publicGroups: [\"(marketing)\"],\n enforceCoverageIn: [\"(marketing)\"],\n alwaysDisallow: [\"/dashboard\", \"/api\"],\n }),\n ],\n});\n```\n\nThis plugin fails dev startup and `vite build` when a page leaf in an enforced group has neither `staticData` nor `head`. It writes `robotsExclusions` and `reservedSegments` to `outputPath`. Feed `robotsExclusions` into `seo.config.ts` `disallow` and `renderRobots` instead of repeating the list.\n\n## Rules that bite\n\n- `robots` values must be lowercase (`\"noindex, follow\"`). The sitemap projection matches `noindex` literally, so `check` rejects mixed case.\n- A route with `robots` containing `noindex` or with `redirectTo` must not declare a positive `sitemap`. Use `sitemap: false` or omit it.\n- A `related` target route without `link` fails `check`. Collection instances are exempt because their card uses the instance `title` and `description`.\n- Coverage enforcement sees only route files inside the named group folders. `route.tsx` layouts, `.ts` server routes, and ungrouped routes are not checked. List ungrouped public routes in `extraPublicRoutes`.\n- `seoRouteConfig` throws if the directory of `outputPath` does not exist. Point it outside `src/routes` so the route watcher does not loop.\n- The graph knows route declarations and collection instances, not `head()` output. Editorial title and description checks cover instances only. A route's rendered title is verified only by inspecting HTML.\n- Keep indexable pages server-rendered or prerendered. An `ssr: false` route's head is not in the served HTML.\n\n## Verify\n\n| Changed | Run | Proves |\n| --- | --- | --- |\n| Declarations, collections, `seo.config.ts` | `pagegraph check` | No structural violations (exit 1 otherwise); editorial findings are reported but do not fail |\n| Graph shape | `pagegraph graph`, `pagegraph inspect /path` | Nodes, edges, and a page's sitemap status |\n| Sitemap or robots wiring | `pagegraph sitemap`, `pagegraph robots` | The projections from the declared graph |\n| `head()`, `seoHead` input, document shell | `pagegraph inspect <full-url> --live` against a running server | The served `<head>` and JSON-LD |\n| Rendered links vs `related` | `pagegraph links verify <url>` (add `--assert-coverage` for `coverage` rules) | What a crawler receives, not what was declared |\n| Coverage gate | `vite build` | Every enforced page leaf declares SEO |\n\nA passing `check` says nothing about served HTML. Verify head changes with `inspect --live` or a rendered-head test. Add `--json` when a script consumes the output. Stdout carries the data and stderr carries status. Check the exit code as well as the output.\n" };
46236
+ const nameArgument = String$1("name").pipe(withDescription("Bundled skill name from `pagegraph skills list`"));
46237
+ const listCommand = make$9("list", { json: jsonFlag }).pipe(withDescription$1("List skills embedded in this Pagegraph CLI"), withExamples([{
46238
+ command: "pagegraph skills list",
46239
+ description: "List embedded skills"
46240
+ }, {
46241
+ command: "pagegraph skills list --json",
46242
+ description: "List skill names as JSON"
46243
+ }]), withHandler(fnUntraced(function* ({ json }) {
46244
+ const names = Object.keys(skills);
46245
+ if (json) return yield* printJson({
46246
+ schemaVersion: 1,
46247
+ skills: names
46248
+ });
46249
+ return yield* printText(names.join("\n"));
46250
+ })));
46251
+ const getCommand = make$9("get", {
46252
+ name: nameArgument,
46253
+ json: jsonFlag
46254
+ }).pipe(withDescription$1("Print a skill embedded in this Pagegraph CLI"), withExamples([{
46255
+ command: "pagegraph skills get core",
46256
+ description: "Read Pagegraph integration guidance"
46257
+ }, {
46258
+ command: "pagegraph skills get core --json",
46259
+ description: "Read the skill as JSON"
46260
+ }]), withHandler(fnUntraced(function* ({ name, json }) {
46261
+ if (!Object.hasOwn(skills, name)) return yield* new SeoCliError({ message: `Unknown skill "${name}". Run \`pagegraph skills list\` to see available names.` });
46262
+ const content = skills[name];
46263
+ if (json) return yield* printJson({
46264
+ schemaVersion: 1,
46265
+ name,
46266
+ content
46267
+ });
46268
+ return yield* printText(content.trimEnd());
46269
+ })));
46270
+ /** Serve Pagegraph's version-matched agent guidance from the installed CLI. */
46271
+ const skillsCommand = make$9("skills").pipe(withDescription$1("List and read skills embedded in Pagegraph"), withExamples([{
46272
+ command: "pagegraph skills list",
46273
+ description: "Discover embedded skills"
46274
+ }, {
46275
+ command: "pagegraph skills get core",
46276
+ description: "Read the integration skill"
46277
+ }]), withSubcommands([listCommand, getCommand]));
46278
+ //#endregion
45137
46279
  //#region src/cli/cli.ts
45138
46280
  /**
45139
46281
  * Root `pagegraph` command. Every subcommand reads the same SEO graph that render
@@ -45154,6 +46296,10 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect, audit, research
45154
46296
  command: "pagegraph graph",
45155
46297
  description: "Print the SEO graph as a tree"
45156
46298
  },
46299
+ {
46300
+ command: "pagegraph skills get core",
46301
+ description: "Read bundled agent guidance"
46302
+ },
45157
46303
  {
45158
46304
  command: "pagegraph sitemap",
45159
46305
  description: "Render sitemap.xml"
@@ -45175,6 +46321,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect, audit, research
45175
46321
  researchCommandGroup,
45176
46322
  checkCommand,
45177
46323
  sitemapCommand,
46324
+ skillsCommand,
45178
46325
  robotsCommand
45179
46326
  ]));
45180
46327
  //#endregion