pagegraph 0.6.1 → 0.7.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
@@ -20616,7 +20616,7 @@ const Boolean$5 = class extends ASTNodeImpl {
20616
20616
  * @category constructors
20617
20617
  * @since 4.0.0
20618
20618
  */
20619
- const boolean = /*#__PURE__*/ new Boolean$5();
20619
+ const boolean$1 = /*#__PURE__*/ new Boolean$5();
20620
20620
  /**
20621
20621
  * Constructs a {@link BigInt}.
20622
20622
  *
@@ -25834,7 +25834,7 @@ const Number$2 = /*#__PURE__*/ make$14(number);
25834
25834
  * @category schemas
25835
25835
  * @since 4.0.0
25836
25836
  */
25837
- const Boolean$4 = /*#__PURE__*/ make$14(boolean);
25837
+ const Boolean$4 = /*#__PURE__*/ make$14(boolean$1);
25838
25838
  /**
25839
25839
  * Schema for `bigint` values. Validates that the input is `typeof` `"bigint"`.
25840
25840
  *
@@ -26424,6 +26424,28 @@ const makeFilter = makeFilter$1;
26424
26424
  */
26425
26425
  const Finite$3 = /*#__PURE__*/ make$14(finite);
26426
26426
  /**
26427
+ * Creates a greater-than-or-equal-to (`>=`) check for any ordered type from an
26428
+ * `Order.Order` instance.
26429
+ *
26430
+ * @category validation
26431
+ * @since 4.0.0
26432
+ */
26433
+ function makeIsGreaterThanOrEqualTo(options) {
26434
+ const gte = isGreaterThanOrEqualTo$1(options.order);
26435
+ const formatter = options.formatter ?? format$2;
26436
+ return (minimum, annotations) => {
26437
+ return makeFilter((input) => gte(input, minimum), {
26438
+ expected: `a value greater than or equal to ${formatter(minimum)}`,
26439
+ arbitraryConstraint: {
26440
+ order: options.order,
26441
+ minimum
26442
+ },
26443
+ ...options.annotate?.(minimum),
26444
+ ...annotations
26445
+ });
26446
+ };
26447
+ }
26448
+ /**
26427
26449
  * Creates an inclusive or exclusive range check for any ordered type from an
26428
26450
  * `Order.Order` instance.
26429
26451
  *
@@ -26458,6 +26480,35 @@ function encodeNumberPayload(number) {
26458
26480
  return number;
26459
26481
  }
26460
26482
  /**
26483
+ * Validates that a number is greater than or equal to the specified value
26484
+ * (inclusive).
26485
+ *
26486
+ * **Details**
26487
+ *
26488
+ * JSON Schema:
26489
+ *
26490
+ * This check corresponds to the `minimum` constraint in JSON Schema.
26491
+ *
26492
+ * Arbitrary:
26493
+ *
26494
+ * During arbitrary generation, this applies a `minimum` constraint
26495
+ * to ensure generated numbers are greater than or equal to the specified value.
26496
+ *
26497
+ * @category validation
26498
+ * @since 4.0.0
26499
+ */
26500
+ const isGreaterThanOrEqualTo = /*#__PURE__*/ makeIsGreaterThanOrEqualTo({
26501
+ order: Number$6,
26502
+ annotate: (minimum) => ({
26503
+ representation: {
26504
+ id: "effect/schema/isGreaterThanOrEqualTo",
26505
+ payload: { minimum: encodeNumberPayload(minimum) }
26506
+ },
26507
+ toJsonSchema: () => ({ minimum }),
26508
+ toCode: () => ({ runtime: `Schema.isGreaterThanOrEqualTo(${format$2(minimum)})` })
26509
+ })
26510
+ });
26511
+ /**
26461
26512
  * Validates that a number is within a specified range. The range boundaries can
26462
26513
  * be inclusive or exclusive based on the provided options.
26463
26514
  *
@@ -26538,6 +26589,19 @@ function isInt(annotations) {
26538
26589
  * @since 3.10.0
26539
26590
  */
26540
26591
  const Int$4 = /*#__PURE__*/ Number$2.check(/*#__PURE__*/ isInt());
26592
+ /**
26593
+ * Schema for non-negative safe integers, including zero.
26594
+ *
26595
+ * **When to use**
26596
+ *
26597
+ * Use when you need a count, index, or size that cannot be negative.
26598
+ *
26599
+ * @see {@link Int} for safe integers that may be negative
26600
+ *
26601
+ * @category schemas
26602
+ * @since 4.0.0
26603
+ */
26604
+ const Natural = /*#__PURE__*/ Int$4.check(/*#__PURE__*/ isGreaterThanOrEqualTo(0));
26541
26605
  const getErrorOptionsKey = (options) => (options?.includeStack === true ? 1 : 0) | (options?.excludeCause === true ? 2 : 0);
26542
26606
  const getErrorOptions = (key) => {
26543
26607
  switch (key) {
@@ -34612,7 +34676,7 @@ const runWith = (command, config) => {
34612
34676
  };
34613
34677
  //#endregion
34614
34678
  //#region package.json
34615
- var version = "0.6.1";
34679
+ var version = "0.7.0";
34616
34680
  //#endregion
34617
34681
  //#region src/core/projections.ts
34618
34682
  const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -35051,7 +35115,7 @@ const CONFIG_FILENAMES = [
35051
35115
  "seo.config.js",
35052
35116
  "seo.config.mjs"
35053
35117
  ];
35054
- const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
35118
+ const messageOf$2 = (cause) => cause instanceof Error ? cause.message : String(cause);
35055
35119
  /** First `seo.config.*` at or above `from`, or undefined at the filesystem root. */
35056
35120
  const findConfigFile = (from) => {
35057
35121
  let directory = resolve(from);
@@ -35079,7 +35143,7 @@ const loadConfigFile = (configPath) => gen(function* () {
35079
35143
  yield* logDebug(`Loading SEO config from ${configPath}`);
35080
35144
  const module = yield* tryPromise({
35081
35145
  try: () => import(pathToFileURL(configPath).href),
35082
- catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$1(cause)}` })
35146
+ catch: (cause) => new SeoCliError({ message: `Could not load ${configPath}: ${messageOf$2(cause)}` })
35083
35147
  });
35084
35148
  if (!isSeoCliConfig(module.default)) return yield* new SeoCliError({ message: `${configPath} must default-export defineSeoConfig({ origin, disallow, loadGraph }).` });
35085
35149
  return module.default;
@@ -35114,8 +35178,8 @@ const acquireGraph = (config) => gen(function* () {
35114
35178
  yield* logDebug("Loading the SEO graph…");
35115
35179
  const loaded = yield* acquireRelease(tryPromise({
35116
35180
  try: () => config.loadGraph(),
35117
- catch: (cause) => new SeoCliError({ message: messageOf$1(cause) })
35118
- }), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$1(defect)}`))));
35181
+ catch: (cause) => new SeoCliError({ message: messageOf$2(cause) })
35182
+ }), (acquired) => promise(() => acquired.dispose()).pipe(catchDefect((defect) => logWarning(`Could not dispose the SEO graph loader: ${messageOf$2(defect)}`))));
35119
35183
  yield* logDebug(`Loaded SEO graph: ${loaded.graph.nodes.size} nodes, ${loaded.graph.edges.length} edges.`);
35120
35184
  return loaded.graph;
35121
35185
  });
@@ -35351,8 +35415,8 @@ const VERDICT_LABELS = {
35351
35415
  review: "Review"
35352
35416
  };
35353
35417
  const decisionLine = (record) => [
35354
- ` ${record.sourceUrl} → ${record.destinationUrl}`,
35355
- ` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)}`,
35418
+ ` ${record.from} ${record.direction === "both" ? "↔" : "→"} ${record.to}`,
35419
+ ` realReason ${record.realReason.toFixed(2)} · anchorPresent ${record.anchorPresent.toFixed(2)} · direction ${record.direction} · relevance ${record.relevance.label}`,
35356
35420
  ` "${record.sourceText}"`
35357
35421
  ];
35358
35422
  const verdictBlock = (verdict, records) => {
@@ -35368,8 +35432,9 @@ const verdictBlock = (verdict, records) => {
35368
35432
  */
35369
35433
  const renderLinksDecideReport = (report) => {
35370
35434
  const { counts } = report;
35435
+ const budget = report.budget === null ? "none" : `${report.budget}/source`;
35371
35436
  const lines = [
35372
- `${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
35437
+ `${counts.candidates} candidate(s) · model ${report.model} · threshold ${report.threshold.toFixed(2)} · budget ${budget} · dropped ${report.dropped}`,
35373
35438
  "",
35374
35439
  ` recommend ${counts.recommend}`,
35375
35440
  ` present ${counts.present}`,
@@ -37748,7 +37813,7 @@ const requestTimeoutMs$1 = Int("request-timeout-ms").pipe(withDescription$2("HTT
37748
37813
  const scannerTimeoutMs = Int("scanner-timeout-ms").pipe(withDescription$2("Lighthouse and hosted scanner timeout in milliseconds"), withDefault(18e4));
37749
37814
  const maxBodyBytes$1 = Int("max-body-bytes").pipe(withDescription$2("Maximum captured response bytes"), withDefault(2e6));
37750
37815
  const outputDir = String$2("output-dir").pipe(withDescription$2("Atomically write timestamped JSON and Markdown artifacts"), optional$1);
37751
- const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
37816
+ const positive$2 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
37752
37817
  const auditCommand = make$9("audit", {
37753
37818
  urls,
37754
37819
  json: jsonFlag,
@@ -37776,11 +37841,11 @@ const auditCommand = make$9("audit", {
37776
37841
  description: "Audit a local app"
37777
37842
  }
37778
37843
  ]), withHandler(fn("SeoCli.audit")(function* (options) {
37779
- const checkedRuns = yield* positive$1("runs", options.runs);
37780
- const checkedConcurrency = yield* positive$1("concurrency", options.concurrency);
37781
- const checkedRequestTimeout = yield* positive$1("request-timeout-ms", options.requestTimeoutMs);
37782
- const checkedScannerTimeout = yield* positive$1("scanner-timeout-ms", options.scannerTimeoutMs);
37783
- const checkedMaxBody = yield* positive$1("max-body-bytes", options.maxBodyBytes);
37844
+ const checkedRuns = yield* positive$2("runs", options.runs);
37845
+ const checkedConcurrency = yield* positive$2("concurrency", options.concurrency);
37846
+ const checkedRequestTimeout = yield* positive$2("request-timeout-ms", options.requestTimeoutMs);
37847
+ const checkedScannerTimeout = yield* positive$2("scanner-timeout-ms", options.scannerTimeoutMs);
37848
+ const checkedMaxBody = yield* positive$2("max-body-bytes", options.maxBodyBytes);
37784
37849
  const scanners = [
37785
37850
  makeHttpScanner({
37786
37851
  allowPrivate: options.allowPrivate,
@@ -37830,342 +37895,48 @@ const auditCommand = make$9("audit", {
37830
37895
  if (structural.length > 0 || http.every((result) => result.status === "error")) return yield* new SeoCliError({ message: `${structural.length} structural finding(s); see the report above.` });
37831
37896
  })));
37832
37897
  //#endregion
37833
- //#region src/cli/commands/diff.ts
37834
- const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
37835
- const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
37836
- const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
37837
- const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
37838
- return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
37839
- });
37840
- const diffCommand = make$9("diff", {
37841
- before,
37842
- after,
37843
- json: jsonFlag
37844
- }).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
37845
- command: "pagegraph diff before.json after.json",
37846
- description: "Render a human-readable semantic comparison"
37847
- }, {
37848
- command: "pagegraph diff before.json after.json --json",
37849
- description: "Emit one versioned JSON diff on stdout"
37850
- }]), withHandler(fn("SeoCli.diff")(function* (options) {
37851
- const beforeReport = yield* readAuditReport("before", options.before);
37852
- const afterReport = yield* readAuditReport("after", options.after);
37853
- const comparison = compareAuditReports(beforeReport, afterReport);
37854
- if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
37855
- if (options.json) yield* printJson(comparison.diff);
37856
- else yield* printText(renderAuditDiff(comparison.diff));
37857
- if (comparison.diff.outcome === "regressed") {
37858
- const summary = comparison.diff.summary;
37859
- return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
37860
- }
37861
- })));
37862
- //#endregion
37863
- //#region src/cli/serialize.ts
37864
- const serializeCrumb = (crumb) => {
37865
- if (crumb === void 0) return void 0;
37866
- return typeof crumb === "function" ? "(dynamic)" : crumb;
37898
+ //#region node_modules/effect/dist/unstable/http/HttpBody.js
37899
+ const TypeId$9 = "~effect/http/HttpBody";
37900
+ const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
37901
+ /**
37902
+ * Error produced while constructing an HTTP body from JSON or schema-encoded input.
37903
+ *
37904
+ * @category errors
37905
+ * @since 4.0.0
37906
+ */
37907
+ var HttpBodyError = class extends TaggedError$1("HttpBodyError") {
37908
+ /**
37909
+ * Marks this value as an HTTP body error for runtime guards.
37910
+ *
37911
+ * @since 4.0.0
37912
+ */
37913
+ [HttpBodyErrorTypeId] = HttpBodyErrorTypeId;
37867
37914
  };
37868
- /** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
37869
- const serializeNode = (node) => ({
37870
- path: node.path,
37871
- kind: node.kind,
37872
- source: node.source,
37873
- policy: {
37874
- kind: node.policy.kind,
37875
- crumb: serializeCrumb(node.policy.crumb),
37876
- sitemap: node.policy.sitemap,
37877
- robots: node.policy.robots,
37878
- related: node.policy.related,
37879
- link: node.policy.link,
37880
- redirectTo: node.policy.redirectTo
37881
- },
37882
- instance: node.instance
37883
- });
37884
- /** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
37885
- const serializeGraph = (graph) => ({
37886
- nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
37887
- edges: graph.edges
37888
- });
37889
- const graphCommand = make$9("graph", {
37890
- format: Literals("format", [
37891
- "tree",
37892
- "mermaid",
37893
- "json"
37894
- ]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
37895
- orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
37896
- }).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
37897
- {
37898
- command: "pagegraph graph",
37899
- description: "The graph as an indented path tree"
37900
- },
37901
- {
37902
- command: "pagegraph graph --format mermaid",
37903
- description: "A Mermaid diagram of nodes and edges"
37904
- },
37905
- {
37906
- command: "pagegraph graph --orphans",
37907
- description: "Only nodes with no incoming edge"
37908
- },
37909
- {
37910
- command: "pagegraph graph --format json",
37911
- description: "The serialized graph on stdout"
37915
+ var Proto$3 = class {
37916
+ [TypeId$9];
37917
+ constructor() {
37918
+ this[TypeId$9] = TypeId$9;
37912
37919
  }
37913
- ]), withHandler(fnUntraced(function* ({ format, orphans }) {
37914
- const config = yield* loadSeoConfig;
37915
- const graph = yield* scoped(acquireGraph(config));
37916
- if (format === "json") {
37917
- const serialized = serializeGraph(graph);
37918
- if (!orphans) return yield* printJson(serialized);
37919
- const orphanSet = orphanPaths(graph);
37920
- return yield* printJson({
37921
- nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
37922
- edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
37923
- });
37920
+ [NodeInspectSymbol]() {
37921
+ return this.toJSON();
37924
37922
  }
37925
- if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
37926
- return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
37927
- })));
37928
- //#endregion
37929
- //#region src/core/inspect-html.ts
37930
- const ENTITIES = {
37931
- "&amp;": "&",
37932
- "&lt;": "<",
37933
- "&gt;": ">",
37934
- "&quot;": "\"",
37935
- "&#39;": "'",
37936
- "&apos;": "'"
37937
- };
37938
- const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
37939
- /** Pull double/single-quoted attributes off a single tag string. */
37940
- const parseAttrs = (tag) => {
37941
- const attrs = {};
37942
- const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
37943
- let match;
37944
- while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
37945
- return attrs;
37946
- };
37947
- /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
37948
- const typeName = (value) => {
37949
- if (typeof value === "string") return value;
37950
- if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
37951
- return "unknown";
37952
- };
37953
- const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
37954
- /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
37955
- const collectItems = (parsed) => {
37956
- if (Array.isArray(parsed)) return parsed.filter(isObject);
37957
- if (isObject(parsed)) {
37958
- if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
37959
- return [parsed];
37923
+ toString() {
37924
+ return format$2(this, { ignoreToString: true });
37960
37925
  }
37961
- return [];
37962
- };
37963
- const countQuestions = (mainEntity) => {
37964
- if (!Array.isArray(mainEntity)) return 0;
37965
- return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
37966
- };
37967
- const validateItemListElements = (value) => {
37968
- if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
37969
- const errors = [];
37970
- value.forEach((entry, index) => {
37971
- if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
37972
- errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
37973
- return;
37974
- }
37975
- if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
37976
- if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
37977
- });
37978
- return errors;
37979
37926
  };
37980
- /** Minimal per-type validation — enough to catch an empty or malformed block. */
37981
- const validateItem = (item) => {
37982
- const type = typeName(item["@type"]);
37983
- const errors = [];
37984
- if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
37985
- if (!item["headline"]) errors.push("Article is missing `headline`.");
37986
- if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
37987
- } else if (type === "FAQPage") {
37988
- if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
37989
- } else if (type === "BreadcrumbList") {
37990
- const items = item["itemListElement"];
37991
- if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
37992
- } else if (type === "ItemList") {
37993
- errors.push(...validateItemListElements(item["itemListElement"]));
37994
- const items = item["itemListElement"];
37995
- if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
37996
- }
37997
- return {
37998
- type,
37999
- valid: errors.length === 0,
38000
- errors
38001
- };
38002
- };
38003
- const validateLdJson = (raw) => {
38004
- let parsed;
38005
- try {
38006
- parsed = JSON.parse(raw);
38007
- } catch (cause) {
38008
- return [{
38009
- type: "unparseable",
38010
- valid: false,
38011
- errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
38012
- }];
38013
- }
38014
- const items = collectItems(parsed);
38015
- if (items.length === 0) return [{
38016
- type: "unknown",
38017
- valid: false,
38018
- errors: ["No JSON-LD object found in block."]
38019
- }];
38020
- return items.map(validateItem);
38021
- };
38022
- /** Parse a rendered HTML document's `<head>` into a report. Pure. */
38023
- const inspectHtml = (url, status, html) => {
38024
- const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
38025
- const head = headMatch ? headMatch[1] : html;
38026
- const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
38027
- const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
38028
- const og = {};
38029
- const twitter = {};
38030
- let description;
38031
- let robots;
38032
- for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
38033
- const attrs = parseAttrs(tag);
38034
- const content = attrs["content"];
38035
- if (content === void 0) continue;
38036
- const property = attrs["property"];
38037
- const name = attrs["name"];
38038
- if (property?.startsWith("og:")) og[property] = content;
38039
- else if (name?.startsWith("twitter:")) twitter[name] = content;
38040
- else if (name === "description") description = content;
38041
- else if (name === "robots") robots = content;
38042
- }
38043
- let canonical;
38044
- for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
38045
- const attrs = parseAttrs(tag);
38046
- if (attrs["rel"] === "canonical") canonical = attrs["href"];
38047
- }
38048
- const jsonLd = [];
38049
- const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
38050
- let scriptMatch;
38051
- while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
38052
- const issues = [];
38053
- if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
38054
- if (!title) issues.push("Missing <title>.");
38055
- if (!description) issues.push("Missing meta description.");
38056
- if (!canonical) issues.push("Missing canonical link.");
38057
- for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
38058
- return {
38059
- url,
38060
- status,
38061
- title,
38062
- description,
38063
- canonical,
38064
- robots,
38065
- og,
38066
- twitter,
38067
- jsonLd,
38068
- issues
38069
- };
38070
- };
38071
- /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
38072
- const hasBlockingIssues = (report) => report.issues.length > 0;
38073
- //#endregion
38074
- //#region src/cli/live-inspect.ts
38075
- /**
38076
- * `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
38077
- * validator in `../inspect-html`. The fetch is all that lives here — the
38078
- * validation is a library capability, not a CLI one, so it stays out of the
38079
- * Effect-bearing half of the package.
38080
- */
38081
- /** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
38082
- const fetchAndInspect = (url) => tryPromise({
38083
- try: async () => {
38084
- const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
38085
- const html = await response.text();
38086
- return inspectHtml(url, response.status, html);
38087
- },
38088
- catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
38089
- });
38090
- const inspectCommand = make$9("inspect", {
38091
- target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
38092
- live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
38093
- json: jsonFlag
38094
- }).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
38095
- {
38096
- command: "pagegraph inspect /pricing",
38097
- description: "The graph node, policy, and edges for a path"
38098
- },
38099
- {
38100
- command: "pagegraph inspect https://example.com/pricing --live",
38101
- description: "Fetch the page and validate its head tags + JSON-LD"
38102
- },
38103
- {
38104
- command: "pagegraph inspect /blog/some-post --json",
38105
- description: "The node report as JSON"
38106
- }
38107
- ]), withHandler(fnUntraced(function* ({ target, live, json }) {
38108
- if (live) {
38109
- const report = yield* fetchAndInspect(target);
38110
- if (json) yield* printJson(report);
38111
- else yield* printText(renderLiveReport(report));
38112
- if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
38113
- return;
38114
- }
38115
- const config = yield* loadSeoConfig;
38116
- const report = inspectNode(yield* scoped(acquireGraph(config)), target);
38117
- if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
38118
- if (json) return yield* printJson({
38119
- node: serializeNode(report.node),
38120
- inSitemap: report.inSitemap,
38121
- incoming: report.incoming,
38122
- outgoing: report.outgoing
38123
- });
38124
- return yield* printText(renderNodeReport(report));
38125
- })));
38126
- //#endregion
38127
- //#region node_modules/effect/dist/unstable/http/HttpBody.js
38128
- const TypeId$9 = "~effect/http/HttpBody";
38129
- const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError";
38130
- /**
38131
- * Error produced while constructing an HTTP body from JSON or schema-encoded input.
38132
- *
38133
- * @category errors
38134
- * @since 4.0.0
38135
- */
38136
- var HttpBodyError = class extends TaggedError$1("HttpBodyError") {
38137
- /**
38138
- * Marks this value as an HTTP body error for runtime guards.
38139
- *
38140
- * @since 4.0.0
38141
- */
38142
- [HttpBodyErrorTypeId] = HttpBodyErrorTypeId;
38143
- };
38144
- var Proto$3 = class {
38145
- [TypeId$9];
38146
- constructor() {
38147
- this[TypeId$9] = TypeId$9;
38148
- }
38149
- [NodeInspectSymbol]() {
38150
- return this.toJSON();
38151
- }
38152
- toString() {
38153
- return format$2(this, { ignoreToString: true });
38154
- }
38155
- };
38156
- /**
38157
- * HTTP body variant representing the absence of request content.
38158
- *
38159
- * @category models
38160
- * @since 4.0.0
38161
- */
38162
- var Empty = class extends Proto$3 {
38163
- _tag = "Empty";
38164
- toJSON() {
38165
- return {
38166
- _id: "effect/HttpBody",
38167
- _tag: "Empty"
38168
- };
37927
+ /**
37928
+ * HTTP body variant representing the absence of request content.
37929
+ *
37930
+ * @category models
37931
+ * @since 4.0.0
37932
+ */
37933
+ var Empty = class extends Proto$3 {
37934
+ _tag = "Empty";
37935
+ toJSON() {
37936
+ return {
37937
+ _id: "effect/HttpBody",
37938
+ _tag: "Empty"
37939
+ };
38169
37940
  }
38170
37941
  };
38171
37942
  /**
@@ -40911,7 +40682,7 @@ const invalidOutput = (description) => make$5({
40911
40682
  reason: new InvalidOutputError({ description })
40912
40683
  });
40913
40684
  const isFiniteNumber = (value) => isNumber(value) && Number.isFinite(value);
40914
- const isUnitInterval = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
40685
+ const isUnitInterval$1 = (value) => isFiniteNumber(value) && value >= 0 && value <= 1;
40915
40686
  const validateDistribution = (key, labels, answer) => {
40916
40687
  const raw = answer.probabilities;
40917
40688
  if (!isObject$1(raw)) return invalidOutput(`Provider returned no probabilities for decision "${key}"`);
@@ -40919,7 +40690,7 @@ const validateDistribution = (key, labels, answer) => {
40919
40690
  let total = 0;
40920
40691
  for (const label of labels) {
40921
40692
  const value = raw[label];
40922
- if (!isUnitInterval(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
40693
+ if (!isUnitInterval$1(value)) return invalidOutput(`Provider returned no probability for label "${label}" of decision "${key}"`);
40923
40694
  probabilities[label] = value;
40924
40695
  total += value;
40925
40696
  }
@@ -40928,7 +40699,7 @@ const validateDistribution = (key, labels, answer) => {
40928
40699
  };
40929
40700
  const validateConfidence = (key, answer) => {
40930
40701
  if (answer.confidence === void 0) return;
40931
- return isUnitInterval(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
40702
+ return isUnitInterval$1(answer.confidence) ? answer.confidence : invalidOutput(`Provider returned confidence outside [0, 1] for decision "${key}"`);
40932
40703
  };
40933
40704
  const validateAnswer = (key, decision, answer) => {
40934
40705
  if (!isObject$1(answer)) return invalidOutput(`Provider returned no answer for decision "${key}"`);
@@ -40964,7 +40735,7 @@ const validateAnswer = (key, decision, answer) => {
40964
40735
  };
40965
40736
  }
40966
40737
  case "Probability":
40967
- if (!isUnitInterval(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
40738
+ if (!isUnitInterval$1(answer.probability)) return invalidOutput(`Provider returned a probability outside [0, 1] for decision "${key}"`);
40968
40739
  return { probability: answer.probability };
40969
40740
  }
40970
40741
  };
@@ -41229,76 +41000,1571 @@ const make$1 = /*#__PURE__*/ fnUntraced(function* (options) {
41229
41000
  */
41230
41001
  const layer = (options) => effect(DecisionModel, make$1(options));
41231
41002
  //#endregion
41232
- //#region src/audit/crawl.ts
41233
- /** Anchors that are never HTML documents; skipped so they do not consume the budget. */
41234
- 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;
41235
- /** The graph keys pages by path, so dedupe crawled URLs the same way. */
41236
- const pageKey = (url) => normalizePath(url);
41237
- /** Run `run` over `items` with at most `limit` in flight, preserving order. */
41238
- const mapLimit = async (items, limit, run) => {
41239
- const results = Array.from({ length: items.length });
41240
- let index = 0;
41241
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
41242
- for (;;) {
41243
- const current = index++;
41244
- if (current >= items.length) return;
41245
- results[current] = await run(items[current]);
41246
- }
41247
- });
41248
- await Promise.all(workers);
41249
- return results.map((result, at) => {
41250
- if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
41251
- return result;
41252
- });
41003
+ //#region node_modules/effect/dist/unstable/ai/Decision.js
41004
+ /**
41005
+ * Brand for decision definitions.
41006
+ *
41007
+ * @category type IDs
41008
+ * @since 4.0.0
41009
+ */
41010
+ const TypeId = "~effect/ai/Decision";
41011
+ /**
41012
+ * Creates a classification decision from labelled criteria.
41013
+ * Throws if fewer than two labels are supplied.
41014
+ *
41015
+ * **Example** (Choosing a department)
41016
+ *
41017
+ * ```ts
41018
+ * import { Decision } from "effect/unstable/ai"
41019
+ *
41020
+ * const department = Decision.classify({
41021
+ * instructions: "Which team should handle this",
41022
+ * criteria: {
41023
+ * billing: "payments",
41024
+ * technical: "bugs",
41025
+ * sales: "pricing"
41026
+ * }
41027
+ * })
41028
+ * ```
41029
+ *
41030
+ * @see {@link rate} for an ordered scale
41031
+ * @see {@link probability} for a single yes or no likelihood
41032
+ *
41033
+ * @category constructors
41034
+ * @since 4.0.0
41035
+ */
41036
+ const classify = (options) => {
41037
+ if (Object.keys(options.criteria).length < 2) throw new Error("Decision.classify: criteria must contain at least two labels");
41038
+ return {
41039
+ _tag: "Classify",
41040
+ instructions: options.instructions,
41041
+ criteria: options.criteria
41042
+ };
41253
41043
  };
41254
41044
  /**
41255
- * Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
41256
- * successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
41257
- * private and reserved destinations are rejected unless `allowPrivate` is set.
41045
+ * Creates a rating decision from an ordered list of criteria.
41046
+ * Throws if fewer than two levels or duplicate levels are supplied.
41047
+ *
41048
+ * **Example** (Rating frustration)
41049
+ *
41050
+ * ```ts
41051
+ * import { Decision } from "effect/unstable/ai"
41052
+ *
41053
+ * const frustration = Decision.rate({
41054
+ * instructions: "How frustrated",
41055
+ * criteria: ["calm", "frustrated", "angry"]
41056
+ * })
41057
+ * ```
41058
+ *
41059
+ * @see {@link classify} for unordered labels
41060
+ *
41061
+ * @category constructors
41062
+ * @since 4.0.0
41258
41063
  */
41259
- const crawlRenderedPages = async (seed, options) => {
41260
- let seedUrl;
41261
- try {
41262
- seedUrl = new URL(seed);
41263
- } catch {
41264
- throw new Error(`Not a valid URL: ${seed}`);
41265
- }
41266
- if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
41267
- if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
41268
- const origin = seedUrl.origin;
41269
- const probeOptions = {
41270
- allowPrivate: options.allowPrivate,
41271
- timeoutMs: options.timeoutMs,
41272
- maxBodyBytes: options.maxBodyBytes,
41273
- captureAnchors: true
41064
+ const rate = (options) => {
41065
+ if (options.criteria.length < 2) throw new Error("Decision.rate: criteria must contain at least two levels");
41066
+ if (new Set(options.criteria).size !== options.criteria.length) throw new Error("Decision.rate: criteria must contain distinct levels");
41067
+ return {
41068
+ _tag: "Rate",
41069
+ instructions: options.instructions,
41070
+ criteria: options.criteria
41274
41071
  };
41275
- const concurrency = Math.max(1, options.concurrency ?? 4);
41276
- const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
41277
- /** Final paths already rendered, so a redirect cannot add the same page twice. */
41278
- const rendered = /* @__PURE__ */ new Set();
41279
- const pages = [];
41280
- const failures = [];
41281
- const truncatedBodies = [];
41282
- let attempts = 0;
41283
- let truncated = false;
41284
- let root = pageKey(seedUrl);
41285
- let frontier = [{
41286
- url: seedUrl,
41287
- depth: 0
41288
- }];
41289
- const probePage = async (item) => {
41290
- const request = {
41291
- kind: "page-html",
41292
- method: "GET",
41293
- accept: "text/html, */*;q=0.1",
41294
- url: item.url
41295
- };
41296
- const probe = await probeHttp(request, probeOptions);
41297
- if (!probe.ok || probe.finalUrl === null) return {
41298
- item,
41299
- page: null,
41300
- failure: {
41301
- url: item.url.href,
41072
+ };
41073
+ /**
41074
+ * Creates a probability decision from descriptions of both outcomes.
41075
+ *
41076
+ * **Example** (Estimating urgency)
41077
+ *
41078
+ * ```ts
41079
+ * import { Decision } from "effect/unstable/ai"
41080
+ *
41081
+ * const urgent = Decision.probability({
41082
+ * instructions: "The message is time-sensitive",
41083
+ * criteria: {
41084
+ * false: "No time pressure",
41085
+ * true: "Needs action now"
41086
+ * }
41087
+ * })
41088
+ * ```
41089
+ *
41090
+ * @see {@link classify} for more than two outcomes
41091
+ *
41092
+ * @category constructors
41093
+ * @since 4.0.0
41094
+ */
41095
+ const probability = (options) => ({
41096
+ _tag: "Probability",
41097
+ instructions: options.instructions,
41098
+ criteria: options.criteria
41099
+ });
41100
+ /**
41101
+ * Creates a decision definition from an input schema and named decisions.
41102
+ * `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
41103
+ * calling the provider. Answer keys and types are inferred from the decisions.
41104
+ * Throws if `decisions` is empty.
41105
+ *
41106
+ * **Example** (Defining ticket triage)
41107
+ *
41108
+ * ```ts
41109
+ * import { Schema } from "effect"
41110
+ * import { Decision } from "effect/unstable/ai"
41111
+ *
41112
+ * const Ticket = Schema.Struct({
41113
+ * subject: Schema.String,
41114
+ * body: Schema.String
41115
+ * })
41116
+ *
41117
+ * const TicketTriage = Decision.make({
41118
+ * input: Ticket,
41119
+ * decisions: {
41120
+ * department: Decision.classify({
41121
+ * instructions: "Which team should handle this",
41122
+ * criteria: { billing: "payments", technical: "bugs" }
41123
+ * }),
41124
+ * urgent: Decision.probability({
41125
+ * instructions: "The message is time-sensitive",
41126
+ * criteria: { false: "No time pressure", true: "Needs action now" }
41127
+ * })
41128
+ * }
41129
+ * })
41130
+ * ```
41131
+ *
41132
+ * @see {@link Definition} for the returned shape
41133
+ *
41134
+ * @category constructors
41135
+ * @since 4.0.0
41136
+ */
41137
+ const make = (options) => {
41138
+ if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
41139
+ return {
41140
+ [TypeId]: TypeId,
41141
+ input: options.input,
41142
+ decisions: options.decisions
41143
+ };
41144
+ };
41145
+ //#endregion
41146
+ //#region src/decide/cache.ts
41147
+ /**
41148
+ * Answer cache keyed by input hash. One JSON file per key under `--cache <dir>`.
41149
+ *
41150
+ * Only model answers are cached — verdicts are recomputed from answers and the
41151
+ * current threshold, so raising or lowering `--threshold` never serves a stale
41152
+ * verdict. Cache files are plain data: a missing or unreadable file is a miss,
41153
+ * never an error that fails the run.
41154
+ */
41155
+ /** Read cached answers for `key`, or `undefined` on any miss (absent or unreadable). */
41156
+ const cacheGet = (cacheDir, key) => {
41157
+ const file = join(cacheDir, `${key}.json`);
41158
+ if (!existsSync(file)) return void 0;
41159
+ try {
41160
+ return JSON.parse(readFileSync(file, "utf8"));
41161
+ } catch {
41162
+ return;
41163
+ }
41164
+ };
41165
+ /** Persist answers for `key`; creates `cacheDir` on first write. */
41166
+ const cachePut = (cacheDir, key, answers) => {
41167
+ mkdirSync(cacheDir, { recursive: true });
41168
+ writeFileSync(join(cacheDir, `${key}.json`), `${JSON.stringify(answers, null, 2)}\n`, "utf8");
41169
+ };
41170
+ //#endregion
41171
+ //#region src/decide/run.ts
41172
+ /**
41173
+ * The decision runner. A family supplies its input schema, a definition built
41174
+ * from the input (families with dynamic labels build one per input), a way to
41175
+ * reference the input in a report, and a pure `evaluate` that maps the model's
41176
+ * answers to a verdict. The runner hashes, caches, dispatches with bounded
41177
+ * concurrency, and buckets confident answers apart from `review`.
41178
+ *
41179
+ * This module reaches Effect and the provider, so it is CLI-internal: the
41180
+ * library entries stay Effect-free and only the bundled `pagegraph` bin ships it.
41181
+ */
41182
+ const isUnitInterval = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
41183
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41184
+ /** A probability distribution over exactly `labels`: unit-range, summing to 1. */
41185
+ const isDistribution = (labels, value) => {
41186
+ if (!isPlainObject(value) || Object.keys(value).length !== labels.length) return false;
41187
+ let total = 0;
41188
+ for (const label of labels) {
41189
+ const probability = value[label];
41190
+ if (!isUnitInterval(probability)) return false;
41191
+ total += probability;
41192
+ }
41193
+ return Math.abs(total - 1) <= 1e-6;
41194
+ };
41195
+ /**
41196
+ * Whether cached answers have the shape `DecisionModel` would have validated —
41197
+ * the same labels, ranges, and distributions. A cache file is only trusted when
41198
+ * it round-trips through this check, so a partially malformed entry cannot
41199
+ * produce a confident verdict.
41200
+ */
41201
+ const validCachedAnswers = (decisions, answers) => {
41202
+ if (!isPlainObject(answers)) return false;
41203
+ for (const key of Object.keys(decisions)) {
41204
+ const answer = answers[key];
41205
+ if (!isPlainObject(answer)) return false;
41206
+ const decision = decisions[key];
41207
+ switch (decision._tag) {
41208
+ case "Probability":
41209
+ if (!isUnitInterval(answer.probability)) return false;
41210
+ break;
41211
+ case "Classify":
41212
+ if (typeof answer.label !== "string" || !Object.hasOwn(decision.criteria, answer.label) || !isDistribution(Object.keys(decision.criteria), answer.probabilities)) return false;
41213
+ break;
41214
+ case "Rate": if (typeof answer.rating !== "number" || !Number.isFinite(answer.rating) || answer.rating < 0 || answer.rating > decision.criteria.length - 1 || typeof answer.label !== "string" || !decision.criteria.includes(answer.label) || !isDistribution(decision.criteria, answer.probabilities)) return false;
41215
+ }
41216
+ }
41217
+ return true;
41218
+ };
41219
+ /** A probability is confident when it is at or beyond `threshold` on either side. */
41220
+ const isConfident$1 = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41221
+ /** True when a probability sits inside the review band `(1 - threshold, threshold)`. */
41222
+ const inReviewBand = (probability, threshold) => !isConfident$1(probability, threshold);
41223
+ /** True when any probability sits inside the review band. */
41224
+ const anyInReviewBand = (probabilities, threshold) => probabilities.some((probability) => inReviewBand(probability, threshold));
41225
+ /** sha256 of the canonical input JSON — deterministic for a schema-decoded input. */
41226
+ const inputHash = (input) => createHash("sha256").update(JSON.stringify(input)).digest("hex");
41227
+ /**
41228
+ * Cache identity. Answers depend on the family and the model as well as the
41229
+ * input, so keying by the input hash alone would let a different family or model
41230
+ * reuse incompatible answers. The answer-schema version guards shape changes.
41231
+ */
41232
+ const cacheKey = (family, model, answerHash) => createHash("sha256").update(`${family}\u0000${model}\u0000v1\u0000${answerHash}`).digest("hex");
41233
+ /** Group records into resolved/review buckets and count each verdict. */
41234
+ const buildDecisionReport = (options) => {
41235
+ const verdicts = {};
41236
+ const resolved = [];
41237
+ const review = [];
41238
+ for (const record of options.records) {
41239
+ verdicts[record.verdict] = (verdicts[record.verdict] ?? 0) + 1;
41240
+ if (record.review) review.push(record);
41241
+ else resolved.push(record);
41242
+ }
41243
+ return {
41244
+ kind: "decide",
41245
+ schemaVersion: 1,
41246
+ family: options.family,
41247
+ model: options.model,
41248
+ threshold: options.threshold,
41249
+ counts: {
41250
+ inputs: options.records.length,
41251
+ resolved: resolved.length,
41252
+ review: review.length
41253
+ },
41254
+ verdicts,
41255
+ resolved,
41256
+ review
41257
+ };
41258
+ };
41259
+ /**
41260
+ * Answer every input through the ambient `DecisionModel` and bucket the results.
41261
+ * One provider call answers every decision for an input; calls run with bounded
41262
+ * concurrency, and a warm cache skips the provider entirely.
41263
+ */
41264
+ const runDecisions = (options) => gen(function* () {
41265
+ const records = yield* forEach$1(options.inputs, (input, index) => gen(function* () {
41266
+ const hash = inputHash(input);
41267
+ const key = cacheKey(options.family.name, options.model, hash);
41268
+ const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
41269
+ let answers;
41270
+ let usage;
41271
+ let verdict;
41272
+ if (cached !== void 0) {
41273
+ const decisions = options.family.definitionFor(input).decisions;
41274
+ if (validCachedAnswers(decisions, cached)) {
41275
+ answers = cached;
41276
+ verdict = options.family.evaluate(input, cached, options.threshold);
41277
+ }
41278
+ }
41279
+ if (verdict === void 0) {
41280
+ const response = yield* decide(options.family.definitionFor(input), { input });
41281
+ answers = response.answers;
41282
+ usage = {
41283
+ inputTokens: response.usage.inputTokens,
41284
+ outputTokens: response.usage.outputTokens
41285
+ };
41286
+ if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
41287
+ verdict = options.family.evaluate(input, answers, options.threshold);
41288
+ }
41289
+ return {
41290
+ decisionId: `${options.family.name}:${index}`,
41291
+ schemaVersion: 1,
41292
+ family: options.family.name,
41293
+ model: options.model,
41294
+ threshold: options.threshold,
41295
+ inputHash: hash,
41296
+ inputRef: options.family.inputRef(input),
41297
+ verdict,
41298
+ review: verdict === "review",
41299
+ answers,
41300
+ ...usage === void 0 ? {} : { usage }
41301
+ };
41302
+ }), { concurrency: options.concurrency ?? 4 });
41303
+ return buildDecisionReport({
41304
+ family: options.family.name,
41305
+ model: options.model,
41306
+ threshold: options.threshold,
41307
+ records
41308
+ });
41309
+ });
41310
+ /** Decode raw batch inputs against a family's schema. Throws on the first invalid input. */
41311
+ /** `decodeUnknownSync` with an unconstrained schema type; the family types the result. */
41312
+ const decodeUnknown = decodeUnknownSync;
41313
+ const decodeFamilyInputs = (family, raw) => raw.map((value) => decodeUnknown(family.input)(value));
41314
+ //#endregion
41315
+ //#region src/decide/families/authority.ts
41316
+ /**
41317
+ * The authority family: is this a real link opportunity, a spam trap, or not
41318
+ * worth outreach?
41319
+ *
41320
+ * Three probabilities judge the domain and one classification places its fit.
41321
+ * A confident spam verdict is `spam`, a confident legitimate-and-worth-while fit
41322
+ * is `accept`, and everything uncertain is `review` — link outreach is never
41323
+ * auto-applied.
41324
+ */
41325
+ const AUTHORITY_FIT_CRITERIA = {
41326
+ directory: "A directory or listing page that accepts submissions.",
41327
+ partner: "A partner or integration page that links relevant products.",
41328
+ editorial: "Editorial coverage: an article or guide that could cite this page.",
41329
+ community: "A community space: forum, Q&A, or discussion group.",
41330
+ none: "No genuine fit; outreach here would be noise."
41331
+ };
41332
+ /** One link target plus the evidence the model reads. */
41333
+ const AuthorityInput = Struct({
41334
+ domain: String$6,
41335
+ url: String$6,
41336
+ anchor: String$6,
41337
+ /** The surrounding copy on our page that would carry the link. */
41338
+ context: String$6,
41339
+ targetPath: String$6,
41340
+ /** Referring domains, when known. */
41341
+ rd: optional$3(Number$2),
41342
+ /** Extra observed signals, e.g. `["no-outbound", "paid-links"]`. */
41343
+ signals: optional$3(ArraySchema(String$6))
41344
+ });
41345
+ /** One provider call answers the three probabilities and the fit classification. */
41346
+ const AuthorityDecision = make({
41347
+ input: AuthorityInput,
41348
+ decisions: {
41349
+ legitimate: probability({
41350
+ instructions: "The target is a legitimate site a reader would trust: real editorial or community content, not a link farm, PBN, or paid-link network.",
41351
+ criteria: {
41352
+ false: "The target looks like spam, a link farm, or a paid-link network.",
41353
+ true: "The target is a legitimate site."
41354
+ }
41355
+ }),
41356
+ spam: probability({
41357
+ instructions: "The target shows spam signals: thin auto-generated content, exact-match anchor farms, or a link-selling footprint.",
41358
+ criteria: {
41359
+ false: "No meaningful spam signals.",
41360
+ true: "Clear spam or link-selling signals."
41361
+ }
41362
+ }),
41363
+ outreachWorth: probability({
41364
+ instructions: "Outreach to this target is worth the effort: a human could plausibly place a reader-useful link.",
41365
+ criteria: {
41366
+ false: "Outreach is not worth the effort.",
41367
+ true: "Outreach could place a useful link."
41368
+ }
41369
+ }),
41370
+ fit: classify({
41371
+ instructions: "Classify the relationship this target could support.",
41372
+ criteria: AUTHORITY_FIT_CRITERIA
41373
+ })
41374
+ }
41375
+ });
41376
+ /**
41377
+ * `spam` when spam is confident; `accept` only when legitimate, outreach-worthy,
41378
+ * and a non-`none` fit are all confident; otherwise `review`.
41379
+ */
41380
+ const classifyAuthorityVerdict = (answers, threshold) => {
41381
+ if (anyInReviewBand([
41382
+ answers.legitimate.probability,
41383
+ answers.spam.probability,
41384
+ answers.outreachWorth.probability
41385
+ ], threshold)) return "review";
41386
+ if (answers.spam.probability >= threshold) return "spam";
41387
+ const fitProbability = answers.fit.probabilities[answers.fit.label] ?? 0;
41388
+ if (answers.fit.label === "none" || fitProbability < threshold || answers.legitimate.probability < threshold || answers.outreachWorth.probability < threshold) return "review";
41389
+ return "accept";
41390
+ };
41391
+ const authorityFamily = {
41392
+ name: "authority",
41393
+ input: AuthorityInput,
41394
+ definitionFor: () => AuthorityDecision,
41395
+ inputRef: (input) => input.domain,
41396
+ evaluate: (_input, answers, threshold) => classifyAuthorityVerdict(answers, threshold)
41397
+ };
41398
+ //#endregion
41399
+ //#region src/decide/families/content.ts
41400
+ /**
41401
+ * The content family: does this page carry enough original substance?
41402
+ *
41403
+ * Seven probabilities score the page's editorial quality and one rating places
41404
+ * its overall value on `thin | adequate | strong`. A confident page with a
41405
+ * `thin` value or any failed probability is `flag`; an uncertain answer is
41406
+ * `review`. Nothing is rewritten here — the verdict is the plan.
41407
+ */
41408
+ /** Ordered value scale from least to most substance. */
41409
+ const CONTENT_VALUES = [
41410
+ "thin",
41411
+ "adequate",
41412
+ "strong"
41413
+ ];
41414
+ /** One page plus the evidence the rubric reads. */
41415
+ const ContentInput = Struct({
41416
+ url: String$6,
41417
+ /** The query the page targets; intent rubrics cannot be judged without it. */
41418
+ query: String$6,
41419
+ title: String$6,
41420
+ h1: String$6,
41421
+ first150Words: String$6,
41422
+ headings: ArraySchema(String$6),
41423
+ wordCount: Natural,
41424
+ /** schema.org types present on the page, e.g. `["Article", "FAQPage"]`. */
41425
+ structuredData: ArraySchema(String$6),
41426
+ /** The topic the page must stay inside; drifting outside it is a failure. */
41427
+ categoryLock: String$6,
41428
+ /** Sibling pages' intents, needed to judge `distinctIntent`. */
41429
+ siblingIntents: optional$3(ArraySchema(String$6)),
41430
+ competitorExcerpts: optional$3(ArraySchema(String$6))
41431
+ });
41432
+ const boolean = (instructions, falseText, trueText) => probability({
41433
+ instructions,
41434
+ criteria: {
41435
+ false: falseText,
41436
+ true: trueText
41437
+ }
41438
+ });
41439
+ /** One provider call answers the seven rubric probabilities and the value rating. */
41440
+ const ContentDecision = make({
41441
+ input: ContentInput,
41442
+ decisions: {
41443
+ answersFirst: boolean("The first 150 words answer the reader's question directly, before background or preamble.", "The opening delays the answer.", "The opening answers the question up front."),
41444
+ originalValue: boolean("The page adds original value — first-hand experience, data, or a distinct point of view — beyond restating common knowledge.", "The page restates what every other result already says.", "The page adds information a reader cannot get elsewhere."),
41445
+ competitiveSubstance: boolean("The page has enough depth and specificity to compete with the current results for this topic.", "The page is shallower or less specific than the competition.", "The page matches or exceeds the competition's substance."),
41446
+ descriptiveTitle: boolean("The title is descriptive and specific to the page's content rather than vague or clickbait.", "The title is vague or misleading.", "The title describes the page's actual content."),
41447
+ structuredDataMatches: boolean("The page's structured data matches its content type and the supplied query's intent.", "Structured data is missing, wrong, or mismatched.", "Structured data correctly describes the page."),
41448
+ categoryConsistency: boolean("The page stays inside its locked category; its headings and body do not drift into an adjacent topic.", "The page drifts outside the locked category.", "The page stays inside the locked category."),
41449
+ distinctIntent: boolean("The page serves a distinct intent rather than duplicating a sibling page's intent. Judge only the sibling intents supplied; if none are supplied, treat this as unresolved.", "The page duplicates a sibling page's intent.", "The page serves an intent no supplied sibling covers."),
41450
+ value: rate({
41451
+ instructions: "Overall reader value of this page: thin restates what exists, adequate is competitive, strong is the best available answer.",
41452
+ criteria: CONTENT_VALUES
41453
+ })
41454
+ }
41455
+ });
41456
+ /** The seven rubric probabilities that decide pass/flag once confidence is settled. */
41457
+ const RUBRIC_KEYS = [
41458
+ "answersFirst",
41459
+ "originalValue",
41460
+ "competitiveSubstance",
41461
+ "descriptiveTitle",
41462
+ "structuredDataMatches",
41463
+ "categoryConsistency",
41464
+ "distinctIntent"
41465
+ ];
41466
+ /** Evidence present by default; a caller with no evidence can still test the band logic. */
41467
+ const ALL_EVIDENCE = {
41468
+ distinctIntent: true,
41469
+ competitiveSubstance: true
41470
+ };
41471
+ /**
41472
+ * `flag` when the value rating is `thin` or any rubric probability is
41473
+ * confidently false; `review` when any probability (rubric or value
41474
+ * distribution) is inside the band, or an evidence-dependent rubric is
41475
+ * confidently positive without its evidence; `pass` otherwise. A confident
41476
+ * negative on an unevidenced rubric stays `flag` — it is the conservative side.
41477
+ */
41478
+ const classifyContentVerdict = (answers, threshold, evidence = ALL_EVIDENCE) => {
41479
+ const rubric = RUBRIC_KEYS.map((key) => answers[key].probability);
41480
+ const distribution = Object.values(answers.value.probabilities);
41481
+ if (anyInReviewBand([...rubric, ...distribution], threshold)) return "review";
41482
+ if (!evidence.distinctIntent && answers.distinctIntent.probability >= threshold) return "review";
41483
+ if (!evidence.competitiveSubstance && answers.competitiveSubstance.probability >= threshold) return "review";
41484
+ if (answers.value.label === "thin") return "flag";
41485
+ if (rubric.some((probability) => probability <= 1 - threshold)) return "flag";
41486
+ return "pass";
41487
+ };
41488
+ const contentFamily = {
41489
+ name: "content",
41490
+ input: ContentInput,
41491
+ definitionFor: () => ContentDecision,
41492
+ inputRef: (input) => input.url,
41493
+ evaluate: (input, answers, threshold) => classifyContentVerdict(answers, threshold, {
41494
+ distinctIntent: input.siblingIntents !== void 0 && input.siblingIntents.length > 0,
41495
+ competitiveSubstance: input.competitorExcerpts !== void 0 && input.competitorExcerpts.length > 0
41496
+ })
41497
+ };
41498
+ /** One query plus the candidate pages already on the site. */
41499
+ const FitInput = Struct({
41500
+ query: String$6,
41501
+ candidates: ArraySchema(Struct({
41502
+ id: String$6,
41503
+ title: String$6,
41504
+ excerpt: String$6
41505
+ }))
41506
+ });
41507
+ const candidateCriteria$1 = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.excerpt}`.slice(0, 255)]));
41508
+ /** One provider call answers `bestPage`, `competition`, and `needsNewPage`. */
41509
+ const makeFitDecision = (input) => make({
41510
+ input: FitInput,
41511
+ decisions: {
41512
+ bestPage: classify({
41513
+ instructions: "Choose the candidate page that best targets this query, or the closest starting point to update. Use the page's title and excerpt.",
41514
+ criteria: candidateCriteria$1(input.candidates)
41515
+ }),
41516
+ competition: probability({
41517
+ instructions: "Two or more of the supplied pages already target this query, so they risk cannibalizing each other.",
41518
+ criteria: {
41519
+ false: "At most one page targets the query.",
41520
+ true: "Multiple pages target the query."
41521
+ }
41522
+ }),
41523
+ needsNewPage: probability({
41524
+ instructions: "None of the supplied pages can serve this query well; a new page is the right answer.",
41525
+ criteria: {
41526
+ false: "An existing page can serve this query.",
41527
+ true: "No existing page can serve this query; a new page is needed."
41528
+ }
41529
+ })
41530
+ }
41531
+ });
41532
+ /** Reject inputs whose candidate ids cannot become provider labels. */
41533
+ const validateFitInput = (input) => {
41534
+ if (input.candidates.length < 2) return "decide fit needs at least two candidates: bestPage compares them.";
41535
+ const long = input.candidates.find((candidate) => candidate.id.length > 255);
41536
+ if (long !== void 0) return `decide fit candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
41537
+ if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide fit candidate ids must be unique: they become provider labels.";
41538
+ };
41539
+ /**
41540
+ * `gap` wins over `cannibalized` when both are confident: a new page is the more
41541
+ * decisive answer. `map` needs a confident `bestPage`; otherwise it is `review`.
41542
+ */
41543
+ const classifyFitVerdict = (answers, threshold) => {
41544
+ if (anyInReviewBand([answers.competition.probability, answers.needsNewPage.probability], threshold)) return "review";
41545
+ if (answers.needsNewPage.probability >= threshold) return "gap";
41546
+ if (answers.competition.probability >= threshold) return "cannibalized";
41547
+ return (answers.bestPage.probabilities[answers.bestPage.label] ?? 0) >= threshold ? "map" : "review";
41548
+ };
41549
+ const fitFamily = {
41550
+ name: "fit",
41551
+ input: FitInput,
41552
+ definitionFor: makeFitDecision,
41553
+ inputRef: (input) => `query:${input.query}`,
41554
+ evaluate: (_input, answers, threshold) => classifyFitVerdict(answers, threshold),
41555
+ validate: validateFitInput
41556
+ };
41557
+ /** One page plus the metadata candidates to rank. */
41558
+ const MetaInput = Struct({
41559
+ url: String$6,
41560
+ intent: String$6,
41561
+ categoryLock: String$6,
41562
+ candidates: ArraySchema(Struct({
41563
+ id: String$6,
41564
+ title: String$6,
41565
+ description: String$6
41566
+ }))
41567
+ });
41568
+ const candidateCriteria = (candidates) => Object.fromEntries(candidates.map((candidate) => [candidate.id, `${candidate.title} — ${candidate.description}`.slice(0, 255)]));
41569
+ /** One provider call answers `best` and the four concern probabilities. */
41570
+ const makeMetaDecision = (input) => make({
41571
+ input: MetaInput,
41572
+ decisions: {
41573
+ best: classify({
41574
+ instructions: "Choose the candidate title/description that best serves the page's intent and stays inside its category. Judge the supplied candidates only.",
41575
+ criteria: candidateCriteria(input.candidates)
41576
+ }),
41577
+ intentFit: probability({
41578
+ instructions: "The chosen candidate's promise matches the page's intent.",
41579
+ criteria: {
41580
+ false: "The candidate promises something the page does not deliver.",
41581
+ true: "The candidate matches the page's intent."
41582
+ }
41583
+ }),
41584
+ lengthOk: probability({
41585
+ instructions: "The chosen candidate fits search-result limits: a title near 60 characters, a description near 160.",
41586
+ criteria: {
41587
+ false: "The candidate is too short or truncated.",
41588
+ true: "The candidate fits the result limits."
41589
+ }
41590
+ }),
41591
+ categoryDrift: probability({
41592
+ instructions: "The chosen candidate drifts outside the page's locked category.",
41593
+ criteria: {
41594
+ false: "The candidate stays inside the category.",
41595
+ true: "The candidate drifts outside the category."
41596
+ }
41597
+ }),
41598
+ clickbait: probability({
41599
+ instructions: "The chosen candidate uses clickbait or overpromises relative to the page's content.",
41600
+ criteria: {
41601
+ false: "The candidate is accurate and measured.",
41602
+ true: "The candidate overpromises or uses clickbait."
41603
+ }
41604
+ })
41605
+ }
41606
+ });
41607
+ /** Reject inputs whose candidate ids cannot become provider labels. */
41608
+ const validateMetaInput = (input) => {
41609
+ if (input.candidates.length < 2) return "decide meta needs at least two candidates: best ranks them.";
41610
+ const long = input.candidates.find((candidate) => candidate.id.length > 255);
41611
+ if (long !== void 0) return `decide meta candidate id exceeds 255 characters: ${long.id.slice(0, 32)}…`;
41612
+ if (new Set(input.candidates.map((candidate) => candidate.id)).size !== input.candidates.length) return "decide meta candidate ids must be unique: they become provider labels.";
41613
+ };
41614
+ /**
41615
+ * A clean candidate is `choose:<id>`. Any review-band probability, a low-probability
41616
+ * pick, confident drift, clickbait, or a mismatch on intent/length is `review`.
41617
+ */
41618
+ const classifyMetaVerdict = (answers, threshold) => {
41619
+ if (anyInReviewBand([
41620
+ answers.intentFit.probability,
41621
+ answers.lengthOk.probability,
41622
+ answers.categoryDrift.probability,
41623
+ answers.clickbait.probability
41624
+ ], threshold)) return "review";
41625
+ if ((answers.best.probabilities[answers.best.label] ?? 0) < threshold) return "review";
41626
+ if (answers.intentFit.probability < threshold || answers.lengthOk.probability < threshold || answers.categoryDrift.probability >= threshold || answers.clickbait.probability >= threshold) return "review";
41627
+ return `choose:${answers.best.label}`;
41628
+ };
41629
+ const metaFamily = {
41630
+ name: "meta",
41631
+ input: MetaInput,
41632
+ definitionFor: makeMetaDecision,
41633
+ inputRef: (input) => input.url,
41634
+ evaluate: (_input, answers, threshold) => classifyMetaVerdict(answers, threshold),
41635
+ validate: validateMetaInput
41636
+ };
41637
+ //#endregion
41638
+ //#region src/decide/families/serp.ts
41639
+ /**
41640
+ * The serp family: is our page the shape this results page rewards?
41641
+ *
41642
+ * The model classifies the dominant SERP format and answers three probabilities
41643
+ * about our page. A confident match is `aligned`, a confident miss is `mismatch`,
41644
+ * and any answer inside the confidence band is `review` — the format question is
41645
+ * the one a human is most often needed to settle.
41646
+ */
41647
+ const SERP_FORMAT_CRITERIA = {
41648
+ definition: "A definition or explainer page: what X is and how it works.",
41649
+ tool: "An interactive tool, calculator, or generator.",
41650
+ comparison: "A comparison of options: X vs Y, or the best X.",
41651
+ listicle: "A ranked or numbered list of items.",
41652
+ docs: "Product, API, or reference documentation.",
41653
+ vendorLanding: "A vendor's marketing or landing page for its own product.",
41654
+ forum: "A community discussion: forum, Reddit, or Stack Overflow.",
41655
+ other: "None of the above."
41656
+ };
41657
+ /** One ranked result in the saved SERP snapshot. */
41658
+ const SerpItem = Struct({
41659
+ type: String$6,
41660
+ rank: Number$2,
41661
+ domain: String$6,
41662
+ title: optional$3(String$6),
41663
+ snippet: optional$3(String$6)
41664
+ });
41665
+ /** Our candidate page, plus the SERP snapshot for the query. */
41666
+ const SerpInput = Struct({
41667
+ query: String$6,
41668
+ ourPage: Struct({
41669
+ url: String$6,
41670
+ title: String$6,
41671
+ kind: String$6,
41672
+ firstWords: String$6
41673
+ }),
41674
+ serpItems: ArraySchema(SerpItem)
41675
+ });
41676
+ /** One provider call answers the SERP format and the three fit probabilities. */
41677
+ const SerpDecision = make({
41678
+ input: SerpInput,
41679
+ decisions: {
41680
+ serpFormat: classify({
41681
+ instructions: "Classify the dominant format of this results page from the ranking results. Weigh the top results most heavily.",
41682
+ criteria: SERP_FORMAT_CRITERIA
41683
+ }),
41684
+ ourFormatFit: probability({
41685
+ instructions: "Our page's format matches how this results page is won: the same shape a top result uses, not merely a related topic.",
41686
+ criteria: {
41687
+ false: "Our format is the wrong shape for this SERP.",
41688
+ true: "Our format is the shape this SERP rewards."
41689
+ }
41690
+ }),
41691
+ intentMatch: probability({
41692
+ instructions: "Our page satisfies the query's dominant intent: the reader's task after landing, not the literal keywords.",
41693
+ criteria: {
41694
+ false: "The query wants something our page does not provide.",
41695
+ true: "Our page provides what the query asks for."
41696
+ }
41697
+ }),
41698
+ titlePatternMatch: probability({
41699
+ instructions: "Our title follows the pattern the ranking titles use, including the qualifiers and framing a searcher expects.",
41700
+ criteria: {
41701
+ false: "Our title does not follow the ranking pattern.",
41702
+ true: "Our title follows the ranking pattern."
41703
+ }
41704
+ })
41705
+ }
41706
+ });
41707
+ /**
41708
+ * `aligned` needs both intent and format confident-positive; anything else that
41709
+ * is confident is `mismatch`. `titlePatternMatch` only widens the review band.
41710
+ */
41711
+ const classifySerpVerdict = (answers, threshold) => {
41712
+ const probabilities = [
41713
+ answers.ourFormatFit.probability,
41714
+ answers.intentMatch.probability,
41715
+ answers.titlePatternMatch.probability
41716
+ ];
41717
+ if (anyInReviewBand(probabilities, threshold)) return "review";
41718
+ return answers.intentMatch.probability >= threshold && answers.ourFormatFit.probability >= threshold ? "aligned" : "mismatch";
41719
+ };
41720
+ const serpFamily = {
41721
+ name: "serp",
41722
+ input: SerpInput,
41723
+ definitionFor: () => SerpDecision,
41724
+ inputRef: (input) => `${input.ourPage.url} · query:${input.query}`,
41725
+ evaluate: (_input, answers, threshold) => classifySerpVerdict(answers, threshold)
41726
+ };
41727
+ //#endregion
41728
+ //#region src/decide/inputs.ts
41729
+ /**
41730
+ * Batch input reading for `pagegraph decide <family>`. A batch is either a JSON
41731
+ * array, a `{ "inputs": [...] }` envelope, or newline-delimited JSON (one input
41732
+ * per non-empty line) — from a file argument or stdin. A TTY with no file is an
41733
+ * error: the command must never appear to hang waiting for input that will not
41734
+ * come from a pipe.
41735
+ */
41736
+ /** Parse a batch of raw inputs from text. Throws a plain `Error` on malformed input. */
41737
+ const decodeInputsText = (text) => {
41738
+ const trimmed = text.trim();
41739
+ if (trimmed.length === 0) throw new Error("no input provided");
41740
+ let parsed;
41741
+ try {
41742
+ parsed = JSON.parse(trimmed);
41743
+ } catch (error) {
41744
+ const lines = trimmed.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
41745
+ if (lines.length > 1) try {
41746
+ return lines.map((line) => JSON.parse(line));
41747
+ } catch {
41748
+ throw error;
41749
+ }
41750
+ throw error;
41751
+ }
41752
+ if (Array.isArray(parsed)) return parsed;
41753
+ if (parsed !== null && typeof parsed === "object") {
41754
+ const inputs = parsed.inputs;
41755
+ if (Array.isArray(inputs)) return inputs;
41756
+ return [parsed];
41757
+ }
41758
+ throw new Error("expected a JSON array, a { \"inputs\": [...] } envelope, or JSONL");
41759
+ };
41760
+ //#endregion
41761
+ //#region src/decide/render.ts
41762
+ /** One review-queue entry: what was asked, how it landed, and the raw answers. */
41763
+ const reviewLine = (record) => [` ${record.inputRef} · ${record.verdict}`, ` ${JSON.stringify(record.answers)}`].join("\n");
41764
+ /**
41765
+ * Human `decide` report: the batch header, a verdict breakdown, and the review
41766
+ * queue — the only part a human has to act on.
41767
+ */
41768
+ const renderDecideReport = (report) => {
41769
+ const lines = [
41770
+ `${report.counts.inputs} input(s) · family ${report.family} · model ${report.model} · threshold ${report.threshold.toFixed(2)}`,
41771
+ "",
41772
+ ` resolved ${report.counts.resolved}`,
41773
+ ` review ${report.counts.review}`,
41774
+ ""
41775
+ ];
41776
+ const verdicts = Object.entries(report.verdicts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
41777
+ if (verdicts.length > 0) {
41778
+ lines.push("Verdicts:", "");
41779
+ for (const [verdict, count] of verdicts) lines.push(` ${verdict.padEnd(20)} ${count}`);
41780
+ lines.push("");
41781
+ }
41782
+ if (report.review.length === 0) lines.push("No inputs need review.");
41783
+ else {
41784
+ lines.push(`Review queue (${report.review.length}):`, "");
41785
+ for (const record of report.review) lines.push(reviewLine(record), "");
41786
+ lines.pop();
41787
+ }
41788
+ return lines.join("\n").trimEnd();
41789
+ };
41790
+ //#endregion
41791
+ //#region src/links/decide.ts
41792
+ /**
41793
+ * Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
41794
+ *
41795
+ * A candidate is a `(source → destination)` pair with the source copy. The model
41796
+ * answers two probabilities — does the source have a genuine reader-facing
41797
+ * reason to link to the destination, and is descriptive anchor text already in
41798
+ * the copy — and this module maps them to a verdict. Anything the model is not
41799
+ * confident about lands in the `review` bucket; nothing is applied automatically.
41800
+ *
41801
+ * This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
41802
+ * the library entries stay Effect-free and only the bundled `pagegraph` bin
41803
+ * ships it.
41804
+ */
41805
+ /** Default probability boundary between a confident and an uncertain answer. */
41806
+ const DEFAULT_LINK_THRESHOLD = .7;
41807
+ /** Ordered relevance scale for budget selection. */
41808
+ const LINK_RELEVANCE = [
41809
+ "irrelevant",
41810
+ "useful",
41811
+ "essential"
41812
+ ];
41813
+ /** One proposed contextual link, plus the source copy the model reads. */
41814
+ const LinkCandidate = Struct({
41815
+ sourceUrl: String$6,
41816
+ destinationUrl: String$6,
41817
+ sourceText: String$6,
41818
+ /** Descriptive anchor text already present for the destination, when known. */
41819
+ existingAnchor: optional$3(String$6)
41820
+ });
41821
+ /**
41822
+ * The four decisions every candidate answers in one provider call: two
41823
+ * probabilities, a direction classification, and a relevance rating. The
41824
+ * probabilities decide the verdict; direction and relevance inform the plan and
41825
+ * the per-source budget.
41826
+ */
41827
+ const LinkDecision = make({
41828
+ input: LinkCandidate,
41829
+ decisions: {
41830
+ realReason: probability({
41831
+ instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
41832
+ criteria: {
41833
+ false: "No genuine reason; the link would be noise or only keyword overlap.",
41834
+ true: "A reader of the source would plausibly want the destination next."
41835
+ }
41836
+ }),
41837
+ anchorPresent: probability({
41838
+ instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
41839
+ criteria: {
41840
+ false: "No existing anchor to the destination in the copy.",
41841
+ true: "Descriptive anchor text to the destination is already in the copy."
41842
+ }
41843
+ }),
41844
+ direction: classify({
41845
+ instructions: "Which direction between the candidate pair deserves a link: source → destination, destination → source, or both.",
41846
+ criteria: {
41847
+ a_to_b: "The source page should link to the destination page.",
41848
+ b_to_a: "The destination page should link to the source page.",
41849
+ both: "Both directions are reader-useful."
41850
+ }
41851
+ }),
41852
+ relevance: rate({
41853
+ instructions: "How relevant this link is to the reader: irrelevant is noise, useful is a sensible next step, essential is a link the page is incomplete without.",
41854
+ criteria: LINK_RELEVANCE
41855
+ })
41856
+ }
41857
+ });
41858
+ /** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
41859
+ const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41860
+ /**
41861
+ * Map the two probabilities to a verdict. A probability inside the review band
41862
+ * makes the whole candidate uncertain, so the confident half of the pair can
41863
+ * never auto-resolve it. Requires `threshold > 0.5`, so the review band
41864
+ * `(1 - threshold, threshold)` is non-empty.
41865
+ *
41866
+ * - `present` — descriptive anchor already in the copy; nothing to add.
41867
+ * - `recommend` — a real reason and no existing anchor; add a contextual link.
41868
+ * - `skip` — no real reason; leave the copy alone.
41869
+ * - `review` — at least one answer is inside the band; a human decides.
41870
+ */
41871
+ const classifyVerdict = (realReason, anchorPresent, threshold) => {
41872
+ if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
41873
+ if (anchorPresent >= threshold) return "present";
41874
+ if (realReason >= threshold) return "recommend";
41875
+ return "skip";
41876
+ };
41877
+ /**
41878
+ * Keep the top `budget` candidates per outbound page by relevance rating,
41879
+ * preserving the original order. The outbound page is `record.from`, which
41880
+ * honors `direction` — grouping by the candidate's `sourceUrl` would misspend the
41881
+ * quota when the model recommends the reverse direction. Ties keep their
41882
+ * original order (stable sort). Pages with fewer candidates pass through.
41883
+ */
41884
+ const applyBudget = (records, budget) => {
41885
+ const bySource = /* @__PURE__ */ new Map();
41886
+ for (const record of records) {
41887
+ const bucket = bySource.get(record.from);
41888
+ if (bucket === void 0) bySource.set(record.from, [record]);
41889
+ else bucket.push(record);
41890
+ }
41891
+ const kept = /* @__PURE__ */ new Set();
41892
+ for (const bucket of bySource.values()) {
41893
+ const top = [...bucket].sort((a, b) => b.relevance.rating - a.relevance.rating).slice(0, budget);
41894
+ for (const record of top) kept.add(record);
41895
+ }
41896
+ return records.filter((record) => kept.has(record));
41897
+ };
41898
+ /** Group records into resolved/review buckets and count each verdict. */
41899
+ const buildLinksDecideReport = (options) => {
41900
+ const counts = {
41901
+ recommend: 0,
41902
+ present: 0,
41903
+ skip: 0,
41904
+ review: 0
41905
+ };
41906
+ const resolved = [];
41907
+ const review = [];
41908
+ for (const record of options.records) {
41909
+ counts[record.verdict] += 1;
41910
+ if (record.verdict === "review") review.push(record);
41911
+ else resolved.push(record);
41912
+ }
41913
+ return {
41914
+ kind: "links-decide",
41915
+ schemaVersion: 1,
41916
+ model: options.model,
41917
+ threshold: options.threshold,
41918
+ budget: options.budget ?? null,
41919
+ dropped: options.dropped ?? 0,
41920
+ counts: {
41921
+ candidates: options.records.length,
41922
+ ...counts
41923
+ },
41924
+ resolved,
41925
+ review
41926
+ };
41927
+ };
41928
+ /**
41929
+ * The endpoints a `recommend` verdict actually directs. `b_to_a` reverses the
41930
+ * candidate pair, so the report names the direction the model chose rather than
41931
+ * always printing source → destination.
41932
+ */
41933
+ const endpoints = (candidate, direction) => direction === "b_to_a" ? {
41934
+ from: candidate.destinationUrl,
41935
+ to: candidate.sourceUrl
41936
+ } : {
41937
+ from: candidate.sourceUrl,
41938
+ to: candidate.destinationUrl
41939
+ };
41940
+ const toRecord = (candidate, answers, threshold) => {
41941
+ const realReason = answers.realReason.probability;
41942
+ const anchorPresent = answers.anchorPresent.probability;
41943
+ const direction = answers.direction.label;
41944
+ return {
41945
+ sourceUrl: candidate.sourceUrl,
41946
+ destinationUrl: candidate.destinationUrl,
41947
+ sourceText: candidate.sourceText,
41948
+ existingAnchor: candidate.existingAnchor ?? null,
41949
+ realReason,
41950
+ anchorPresent,
41951
+ direction,
41952
+ relevance: {
41953
+ rating: answers.relevance.rating,
41954
+ label: answers.relevance.label,
41955
+ probabilities: answers.relevance.probabilities
41956
+ },
41957
+ ...endpoints(candidate, direction),
41958
+ verdict: classifyVerdict(realReason, anchorPresent, threshold)
41959
+ };
41960
+ };
41961
+ /**
41962
+ * Answer every candidate through the ambient `DecisionModel` and bucket the
41963
+ * results. The model answers both decisions in one call per candidate; candidates
41964
+ * are dispatched with bounded concurrency. A warm cache skips the provider.
41965
+ */
41966
+ const decideLinks = (candidates, options) => gen(function* () {
41967
+ const records = yield* forEach$1(candidates, (candidate) => gen(function* () {
41968
+ const hash = inputHash(candidate);
41969
+ const key = cacheKey("links", options.model, hash);
41970
+ const cached = options.cacheDir === void 0 ? void 0 : cacheGet(options.cacheDir, key);
41971
+ if (cached !== void 0 && validCachedAnswers(LinkDecision.decisions, cached)) return toRecord(candidate, cached, options.threshold);
41972
+ const { answers } = yield* decide(LinkDecision, { input: candidate });
41973
+ if (options.cacheDir !== void 0) cachePut(options.cacheDir, key, answers);
41974
+ return toRecord(candidate, answers, options.threshold);
41975
+ }), { concurrency: options.concurrency ?? 4 });
41976
+ const selected = options.budget === void 0 ? records : applyBudget(records, options.budget);
41977
+ return buildLinksDecideReport({
41978
+ model: options.model,
41979
+ threshold: options.threshold,
41980
+ records: selected,
41981
+ budget: options.budget,
41982
+ dropped: records.length - selected.length
41983
+ });
41984
+ });
41985
+ /** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
41986
+ const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
41987
+ //#endregion
41988
+ //#region src/cli/commands/decide.ts
41989
+ /**
41990
+ * `pagegraph decide <family>` — the decision runner over a batch. Each family
41991
+ * subcommand reads a batch of inputs, answers every decision for an input in one
41992
+ * provider call, and separates confident verdicts from `review`.
41993
+ *
41994
+ * `decide links` is the links family alias of the legacy `pagegraph links decide`.
41995
+ * Effect and the TypeSafe provider live only in this CLI-internal command; the
41996
+ * library entries stay Effect-free.
41997
+ */
41998
+ /** Default probability boundary between a confident and an uncertain answer. */
41999
+ const DEFAULT_DECIDE_THRESHOLD = .7;
42000
+ const messageOf$1 = (cause) => cause instanceof Error ? cause.message : String(cause);
42001
+ const positive$1 = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
42002
+ const checkThreshold$1 = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
42003
+ const decideFile$1 = String$1("file").pipe(withDescription("JSON batch: an array, { inputs: [...] }, or JSONL (default: stdin)"), optional);
42004
+ const decideThresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_DECIDE_THRESHOLD));
42005
+ const modelFlag$1 = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
42006
+ const concurrencyFlag$1 = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
42007
+ const cacheFlag = String$2("cache").pipe(withDescription$2("Reuse model answers by input hash; directory for cache files"), optional$1);
42008
+ const reviewOutFlag = String$2("review-out").pipe(withDescription$2("Write only below-threshold records to this JSON file"), optional$1);
42009
+ const budgetFlag$1 = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
42010
+ /** Read the batch text from a file argument or stdin; a TTY with no file is an error. */
42011
+ const readInputsText = (file) => try_({
42012
+ try: () => {
42013
+ if (file !== void 0) return readFileSync(file, "utf8");
42014
+ if (process.stdin.isTTY) throw new Error("no input file given and stdin is a TTY");
42015
+ return readFileSync(0, "utf8");
42016
+ },
42017
+ catch: (cause) => new SeoCliError({ message: `Could not read inputs: ${messageOf$1(cause)}` })
42018
+ });
42019
+ /** Parse the batch text, then schema-decode every input against the family. */
42020
+ const parseInputs = (family, text) => try_({
42021
+ try: () => decodeFamilyInputs(family, decodeInputsText(text)),
42022
+ catch: (cause) => new SeoCliError({ message: `Invalid inputs: ${messageOf$1(cause)}` })
42023
+ });
42024
+ /** Apply a family's optional input guard to every decoded input. */
42025
+ const validateInputs = (family, inputs) => gen(function* () {
42026
+ if (family.validate === void 0) return;
42027
+ for (const input of inputs) {
42028
+ const problem = family.validate(input);
42029
+ if (problem !== void 0) return yield* new SeoCliError({ message: problem });
42030
+ }
42031
+ });
42032
+ const requireKey = (family) => process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "" ? fail$3(new SeoCliError({ message: `TYPESAFE_API_KEY is not set; \`pagegraph decide ${family}\` answers through TypeSafe System One.` })) : void_;
42033
+ /** TypeSafe System One decision model over the shared fetch HTTP client. */
42034
+ const typeSafeDecisionModel$1 = (model$2) => model(model$2).pipe(provide$2(layerConfig()), provide$2(layer$1));
42035
+ const decisionErrorMessage$1 = (error) => error._tag === "AiError" ? new SeoCliError({ message: `Decision request failed (${error.reason._tag}): ${error.message}` }) : new SeoCliError({ message: `TypeSafe configuration failed: ${error.message}` });
42036
+ /** Write the below-threshold records to `file`, creating its parent directory. */
42037
+ const writeReviewRecords = (file, payload) => file === void 0 ? void_ : try_({
42038
+ try: () => {
42039
+ mkdirSync(dirname(resolve(file)), { recursive: true });
42040
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
42041
+ },
42042
+ catch: (cause) => new SeoCliError({ message: `Could not write review records to ${file}: ${messageOf$1(cause)}` })
42043
+ });
42044
+ /** Run one family over a decoded batch and print its report. */
42045
+ const runFamily = (family, options) => gen(function* () {
42046
+ const threshold = yield* checkThreshold$1(options.threshold);
42047
+ const concurrency = yield* positive$1("concurrency", options.concurrency);
42048
+ const text = yield* readInputsText(getOrUndefined$1(options.file));
42049
+ const inputs = yield* parseInputs(family, text);
42050
+ yield* validateInputs(family, inputs);
42051
+ yield* requireKey(family.name);
42052
+ const report = yield* runDecisions({
42053
+ family,
42054
+ inputs,
42055
+ model: options.model,
42056
+ threshold,
42057
+ concurrency,
42058
+ cacheDir: getOrUndefined$1(options.cache)
42059
+ }).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
42060
+ yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
42061
+ family: report.family,
42062
+ model: report.model,
42063
+ threshold: report.threshold,
42064
+ review: report.review
42065
+ });
42066
+ if (options.json) yield* printJson(report);
42067
+ else yield* printText(renderDecideReport(report));
42068
+ });
42069
+ /** Parse the legacy link-candidate batch (array or `{ inputs }`). */
42070
+ const parseLinks = (text) => try_({
42071
+ try: () => decodeLinkCandidates(decodeInputsText(text)),
42072
+ catch: (cause) => new SeoCliError({ message: `Invalid candidates: ${messageOf$1(cause)}` })
42073
+ });
42074
+ /** `decide links` — the links family alias over the legacy `links decide` report. */
42075
+ const runLinks = (options) => gen(function* () {
42076
+ const threshold = yield* checkThreshold$1(options.threshold);
42077
+ const concurrency = yield* positive$1("concurrency", options.concurrency);
42078
+ const budget = yield* positive$1("budget", options.budget);
42079
+ const text = yield* readInputsText(getOrUndefined$1(options.file));
42080
+ const candidates = yield* parseLinks(text);
42081
+ yield* requireKey("links");
42082
+ const report = yield* decideLinks(candidates, {
42083
+ model: options.model,
42084
+ threshold,
42085
+ concurrency,
42086
+ cacheDir: getOrUndefined$1(options.cache),
42087
+ budget
42088
+ }).pipe(provide(typeSafeDecisionModel$1(options.model)), mapError(decisionErrorMessage$1));
42089
+ yield* writeReviewRecords(getOrUndefined$1(options.reviewOut), {
42090
+ family: "links",
42091
+ model: report.model,
42092
+ threshold: report.threshold,
42093
+ review: report.review
42094
+ });
42095
+ if (options.json) yield* printJson(report);
42096
+ else yield* printText(renderLinksDecideReport(report));
42097
+ });
42098
+ const decideCommand = (config) => make$9(config.name, {
42099
+ file: decideFile$1,
42100
+ json: jsonFlag,
42101
+ threshold: decideThresholdFlag,
42102
+ model: modelFlag$1,
42103
+ concurrency: concurrencyFlag$1,
42104
+ cache: cacheFlag,
42105
+ reviewOut: reviewOutFlag
42106
+ }).pipe(withDescription$1(config.description), withExamples(config.examples), withHandler((options) => config.run(options)));
42107
+ /** Build a family subcommand from its family and help text. */
42108
+ const familySubcommand = (family, config) => decideCommand({
42109
+ name: family.name,
42110
+ description: config.description,
42111
+ examples: config.examples,
42112
+ run: (options) => runFamily(family, options)
42113
+ });
42114
+ const serpCommand = familySubcommand(serpFamily, {
42115
+ description: "Classify the SERP format and score our page's fit, intent, and title (aligned | mismatch | review)",
42116
+ examples: [{
42117
+ command: "pagegraph decide serp serp.json",
42118
+ description: "Decide a saved SERP snapshot and print the reviewable plan"
42119
+ }, {
42120
+ command: "cat serp.json | pagegraph decide serp --json | jq",
42121
+ description: "Read the SERP batch from stdin and emit versioned JSON"
42122
+ }]
42123
+ });
42124
+ const contentCommand = familySubcommand(contentFamily, {
42125
+ description: "Score a page's content rubric and place its value on thin | adequate | strong (pass | flag | review)",
42126
+ examples: [{
42127
+ command: "pagegraph decide content page.json",
42128
+ description: "Decide a page's content quality and print the reviewable plan"
42129
+ }, {
42130
+ command: "cat page.json | pagegraph decide content --json | jq",
42131
+ description: "Read the content batch from stdin and emit versioned JSON"
42132
+ }]
42133
+ });
42134
+ const fitCommand = familySubcommand(fitFamily, {
42135
+ description: "Choose the best existing page for a query, or flag cannibalization or a gap (map | cannibalized | gap | review)",
42136
+ examples: [{
42137
+ command: "pagegraph decide fit candidates.json",
42138
+ description: "Rank existing pages for a query and print the reviewable plan"
42139
+ }, {
42140
+ command: "cat candidates.json | pagegraph decide fit --json | jq",
42141
+ description: "Read the fit batch from stdin and emit versioned JSON"
42142
+ }]
42143
+ });
42144
+ const metaCommand = familySubcommand(metaFamily, {
42145
+ description: "Rank supplied title/description candidates for a page (choose:<id> | review)",
42146
+ examples: [{
42147
+ command: "pagegraph decide meta meta.json",
42148
+ description: "Rank metadata candidates and print the chosen one or a review"
42149
+ }, {
42150
+ command: "cat meta.json | pagegraph decide meta --json | jq",
42151
+ description: "Read the meta batch from stdin and emit versioned JSON"
42152
+ }]
42153
+ });
42154
+ const authorityCommand = familySubcommand(authorityFamily, {
42155
+ description: "Judge a link target's legitimacy, spam, and outreach worth, plus its fit (accept | spam | review)",
42156
+ examples: [{
42157
+ command: "pagegraph decide authority targets.json",
42158
+ description: "Judge a batch of link targets and print the reviewable plan"
42159
+ }, {
42160
+ command: "cat targets.json | pagegraph decide authority --json | jq",
42161
+ description: "Read the authority batch from stdin and emit versioned JSON"
42162
+ }]
42163
+ });
42164
+ const linksDecideCommand$1 = make$9("links", {
42165
+ file: decideFile$1,
42166
+ json: jsonFlag,
42167
+ threshold: decideThresholdFlag,
42168
+ model: modelFlag$1,
42169
+ concurrency: concurrencyFlag$1,
42170
+ cache: cacheFlag,
42171
+ reviewOut: reviewOutFlag,
42172
+ budget: budgetFlag$1
42173
+ }).pipe(withDescription$1("Answer the links family: real reason, anchor present, direction, and relevance per candidate"), withExamples([
42174
+ {
42175
+ command: "pagegraph decide links candidates.json",
42176
+ description: "Answer a candidate batch and print the reviewable plan"
42177
+ },
42178
+ {
42179
+ command: "pagegraph decide links candidates.json --budget 2",
42180
+ description: "Keep the top two candidates per source by relevance"
42181
+ },
42182
+ {
42183
+ command: "cat candidates.json | pagegraph decide links --json | jq",
42184
+ description: "Read candidates from stdin and emit versioned JSON"
42185
+ }
42186
+ ]), withHandler((options) => runLinks(options)));
42187
+ /**
42188
+ * `pagegraph decide` groups the decision families. Each family answers one batch
42189
+ * of inputs through TypeSafe System One; confident verdicts resolve, answers in
42190
+ * the confidence band go to review, and nothing is applied automatically.
42191
+ */
42192
+ const decideCommandGroup = make$9("decide").pipe(withDescription$1("Answer calibrated SEO decisions over a batch with Jev"), withExamples([{
42193
+ command: "pagegraph decide links candidates.json --json | jq",
42194
+ description: "Decide a link-candidate batch and emit versioned JSON"
42195
+ }]), withSubcommands([
42196
+ serpCommand,
42197
+ contentCommand,
42198
+ fitCommand,
42199
+ metaCommand,
42200
+ authorityCommand,
42201
+ linksDecideCommand$1
42202
+ ]));
42203
+ //#endregion
42204
+ //#region src/cli/commands/diff.ts
42205
+ const before = String$1("before.json").pipe(withDescription("Earlier pagegraph audit JSON artifact"));
42206
+ const after = String$1("after.json").pipe(withDescription("Later pagegraph audit JSON artifact"));
42207
+ const readAuditReport = fn("SeoCli.readAuditReport")(function* (label, path) {
42208
+ const contents = yield* (yield* FileSystem).readFileString(path).pipe(mapError((error) => new SeoCliError({ message: `Could not read ${label} report ${path}: ${error.message}` })));
42209
+ return yield* decodeUnknownEffect(fromJsonString(AuditReport))(contents).pipe(mapError((error) => new SeoCliError({ message: `Invalid ${label} report ${path}: ${error.message}` })));
42210
+ });
42211
+ const diffCommand = make$9("diff", {
42212
+ before,
42213
+ after,
42214
+ json: jsonFlag
42215
+ }).pipe(withDescription$1("Compare two versioned pagegraph audit JSON artifacts for semantic regressions"), withExamples([{
42216
+ command: "pagegraph diff before.json after.json",
42217
+ description: "Render a human-readable semantic comparison"
42218
+ }, {
42219
+ command: "pagegraph diff before.json after.json --json",
42220
+ description: "Emit one versioned JSON diff on stdout"
42221
+ }]), withHandler(fn("SeoCli.diff")(function* (options) {
42222
+ const beforeReport = yield* readAuditReport("before", options.before);
42223
+ const afterReport = yield* readAuditReport("after", options.after);
42224
+ const comparison = compareAuditReports(beforeReport, afterReport);
42225
+ if (AuditComparisonResult.$is("InvalidReport")(comparison)) return yield* new SeoCliError({ message: `Invalid audit report invariants:\n${comparison.issues.map((issue) => `- ${issue}`).join("\n")}` });
42226
+ if (options.json) yield* printJson(comparison.diff);
42227
+ else yield* printText(renderAuditDiff(comparison.diff));
42228
+ if (comparison.diff.outcome === "regressed") {
42229
+ const summary = comparison.diff.summary;
42230
+ return yield* new SeoCliError({ message: `${summary.structuralRegressions} structural, ${summary.scannerRegressions} scanner, and ${summary.coverageRegressions} coverage regression(s); see the diff above.` });
42231
+ }
42232
+ })));
42233
+ //#endregion
42234
+ //#region src/cli/serialize.ts
42235
+ const serializeCrumb = (crumb) => {
42236
+ if (crumb === void 0) return void 0;
42237
+ return typeof crumb === "function" ? "(dynamic)" : crumb;
42238
+ };
42239
+ /** JSON-safe projection of one node (route policy `crumb` functions → sentinel). */
42240
+ const serializeNode = (node) => ({
42241
+ path: node.path,
42242
+ kind: node.kind,
42243
+ source: node.source,
42244
+ policy: {
42245
+ kind: node.policy.kind,
42246
+ crumb: serializeCrumb(node.policy.crumb),
42247
+ sitemap: node.policy.sitemap,
42248
+ robots: node.policy.robots,
42249
+ related: node.policy.related,
42250
+ link: node.policy.link,
42251
+ redirectTo: node.policy.redirectTo
42252
+ },
42253
+ instance: node.instance
42254
+ });
42255
+ /** Flatten the graph to a sorted, JSON-safe shape. Nodes are ordered by path. */
42256
+ const serializeGraph = (graph) => ({
42257
+ nodes: [...graph.nodes.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0).map(serializeNode),
42258
+ edges: graph.edges
42259
+ });
42260
+ const graphCommand = make$9("graph", {
42261
+ format: Literals("format", [
42262
+ "tree",
42263
+ "mermaid",
42264
+ "json"
42265
+ ]).pipe(withDescription$2("Output format: tree (default), mermaid diagram, or json"), withDefault("tree")),
42266
+ orphans: Boolean$1("orphans").pipe(withDescription$2("Show only orphan nodes (nothing links to them)"), withDefault(false))
42267
+ }).pipe(withDescription$1("Render the SEO graph as a tree, a Mermaid diagram, or JSON"), withExamples([
42268
+ {
42269
+ command: "pagegraph graph",
42270
+ description: "The graph as an indented path tree"
42271
+ },
42272
+ {
42273
+ command: "pagegraph graph --format mermaid",
42274
+ description: "A Mermaid diagram of nodes and edges"
42275
+ },
42276
+ {
42277
+ command: "pagegraph graph --orphans",
42278
+ description: "Only nodes with no incoming edge"
42279
+ },
42280
+ {
42281
+ command: "pagegraph graph --format json",
42282
+ description: "The serialized graph on stdout"
42283
+ }
42284
+ ]), withHandler(fnUntraced(function* ({ format, orphans }) {
42285
+ const config = yield* loadSeoConfig;
42286
+ const graph = yield* scoped(acquireGraph(config));
42287
+ if (format === "json") {
42288
+ const serialized = serializeGraph(graph);
42289
+ if (!orphans) return yield* printJson(serialized);
42290
+ const orphanSet = orphanPaths(graph);
42291
+ return yield* printJson({
42292
+ nodes: serialized.nodes.filter((node) => orphanSet.has(node.path)),
42293
+ edges: serialized.edges.filter((edge) => orphanSet.has(edge.from) && orphanSet.has(edge.to))
42294
+ });
42295
+ }
42296
+ if (format === "mermaid") return yield* printText(renderMermaid(graph, orphans));
42297
+ return yield* printText(orphans ? renderOrphans(graph) : renderTree(graph));
42298
+ })));
42299
+ //#endregion
42300
+ //#region src/core/inspect-html.ts
42301
+ const ENTITIES = {
42302
+ "&amp;": "&",
42303
+ "&lt;": "<",
42304
+ "&gt;": ">",
42305
+ "&quot;": "\"",
42306
+ "&#39;": "'",
42307
+ "&apos;": "'"
42308
+ };
42309
+ const decodeEntities = (value) => value.replace(/&(?:amp|lt|gt|quot|#39|apos);/g, (match) => ENTITIES[match] ?? match);
42310
+ /** Pull double/single-quoted attributes off a single tag string. */
42311
+ const parseAttrs = (tag) => {
42312
+ const attrs = {};
42313
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
42314
+ let match;
42315
+ while ((match = re.exec(tag)) !== null) attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? "");
42316
+ return attrs;
42317
+ };
42318
+ /** Normalize a JSON-LD `@type` (string or array) to a single readable label. */
42319
+ const typeName = (value) => {
42320
+ if (typeof value === "string") return value;
42321
+ if (Array.isArray(value)) return value.filter((v) => typeof v === "string").join(", ") || "unknown";
42322
+ return "unknown";
42323
+ };
42324
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
42325
+ /** Flatten a parsed JSON-LD payload (single object, array, or `@graph`) to items. */
42326
+ const collectItems = (parsed) => {
42327
+ if (Array.isArray(parsed)) return parsed.filter(isObject);
42328
+ if (isObject(parsed)) {
42329
+ if (Array.isArray(parsed["@graph"])) return parsed["@graph"].filter(isObject);
42330
+ return [parsed];
42331
+ }
42332
+ return [];
42333
+ };
42334
+ const countQuestions = (mainEntity) => {
42335
+ if (!Array.isArray(mainEntity)) return 0;
42336
+ return mainEntity.filter((entry) => isObject(entry) && typeName(entry["@type"]) === "Question").length;
42337
+ };
42338
+ const validateItemListElements = (value) => {
42339
+ if (!Array.isArray(value) || value.length < 1) return ["ItemList needs at least one `itemListElement` entry."];
42340
+ const errors = [];
42341
+ value.forEach((entry, index) => {
42342
+ if (!isObject(entry) || typeName(entry["@type"]) !== "ListItem") {
42343
+ errors.push(`ItemList entry ${index + 1} is not a ListItem.`);
42344
+ return;
42345
+ }
42346
+ if (entry["position"] !== index + 1) errors.push(`ItemList entry ${index + 1} has an invalid position.`);
42347
+ if (!entry["name"] || !entry["url"]) errors.push(`ItemList entry ${index + 1} needs a name and URL.`);
42348
+ });
42349
+ return errors;
42350
+ };
42351
+ /** Minimal per-type validation — enough to catch an empty or malformed block. */
42352
+ const validateItem = (item) => {
42353
+ const type = typeName(item["@type"]);
42354
+ const errors = [];
42355
+ if (type === "Article" || type === "NewsArticle" || type === "BlogPosting") {
42356
+ if (!item["headline"]) errors.push("Article is missing `headline`.");
42357
+ if (!item["datePublished"]) errors.push("Article is missing `datePublished`.");
42358
+ } else if (type === "FAQPage") {
42359
+ if (countQuestions(item["mainEntity"]) < 1) errors.push("FAQPage needs at least one Question in `mainEntity`.");
42360
+ } else if (type === "BreadcrumbList") {
42361
+ const items = item["itemListElement"];
42362
+ if (!Array.isArray(items) || items.length < 2) errors.push("BreadcrumbList needs at least two `itemListElement` entries.");
42363
+ } else if (type === "ItemList") {
42364
+ errors.push(...validateItemListElements(item["itemListElement"]));
42365
+ const items = item["itemListElement"];
42366
+ if (Array.isArray(items) && item["numberOfItems"] !== items.length) errors.push("ItemList `numberOfItems` does not match its entries.");
42367
+ }
42368
+ return {
42369
+ type,
42370
+ valid: errors.length === 0,
42371
+ errors
42372
+ };
42373
+ };
42374
+ const validateLdJson = (raw) => {
42375
+ let parsed;
42376
+ try {
42377
+ parsed = JSON.parse(raw);
42378
+ } catch (cause) {
42379
+ return [{
42380
+ type: "unparseable",
42381
+ valid: false,
42382
+ errors: [`JSON parse error: ${cause instanceof Error ? cause.message : String(cause)}`]
42383
+ }];
42384
+ }
42385
+ const items = collectItems(parsed);
42386
+ if (items.length === 0) return [{
42387
+ type: "unknown",
42388
+ valid: false,
42389
+ errors: ["No JSON-LD object found in block."]
42390
+ }];
42391
+ return items.map(validateItem);
42392
+ };
42393
+ /** Parse a rendered HTML document's `<head>` into a report. Pure. */
42394
+ const inspectHtml = (url, status, html) => {
42395
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
42396
+ const head = headMatch ? headMatch[1] : html;
42397
+ const titleMatch = head.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
42398
+ const title = titleMatch ? decodeEntities(titleMatch[1].trim()) : void 0;
42399
+ const og = {};
42400
+ const twitter = {};
42401
+ let description;
42402
+ let robots;
42403
+ for (const tag of head.match(/<meta\b[^>]*>/gi) ?? []) {
42404
+ const attrs = parseAttrs(tag);
42405
+ const content = attrs["content"];
42406
+ if (content === void 0) continue;
42407
+ const property = attrs["property"];
42408
+ const name = attrs["name"];
42409
+ if (property?.startsWith("og:")) og[property] = content;
42410
+ else if (name?.startsWith("twitter:")) twitter[name] = content;
42411
+ else if (name === "description") description = content;
42412
+ else if (name === "robots") robots = content;
42413
+ }
42414
+ let canonical;
42415
+ for (const tag of head.match(/<link\b[^>]*>/gi) ?? []) {
42416
+ const attrs = parseAttrs(tag);
42417
+ if (attrs["rel"] === "canonical") canonical = attrs["href"];
42418
+ }
42419
+ const jsonLd = [];
42420
+ const scriptRe = /<script\b[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
42421
+ let scriptMatch;
42422
+ while ((scriptMatch = scriptRe.exec(head)) !== null) jsonLd.push(...validateLdJson(scriptMatch[1].trim()));
42423
+ const issues = [];
42424
+ if (status >= 400) issues.push(`Fetch returned HTTP ${status}.`);
42425
+ if (!title) issues.push("Missing <title>.");
42426
+ if (!description) issues.push("Missing meta description.");
42427
+ if (!canonical) issues.push("Missing canonical link.");
42428
+ for (const block of jsonLd) if (!block.valid) issues.push(...block.errors.map((error) => `JSON-LD (${block.type}): ${error}`));
42429
+ return {
42430
+ url,
42431
+ status,
42432
+ title,
42433
+ description,
42434
+ canonical,
42435
+ robots,
42436
+ og,
42437
+ twitter,
42438
+ jsonLd,
42439
+ issues
42440
+ };
42441
+ };
42442
+ /** A non-empty `issues` list fails `pagegraph inspect --live` (exit 1). */
42443
+ const hasBlockingIssues = (report) => report.issues.length > 0;
42444
+ //#endregion
42445
+ //#region src/cli/live-inspect.ts
42446
+ /**
42447
+ * `pagegraph inspect <url> --live`: fetch a URL and hand its body to the pure head
42448
+ * validator in `../inspect-html`. The fetch is all that lives here — the
42449
+ * validation is a library capability, not a CLI one, so it stays out of the
42450
+ * Effect-bearing half of the package.
42451
+ */
42452
+ /** Fetch a URL and inspect its `<head>`. Network failures surface as SeoCliError. */
42453
+ const fetchAndInspect = (url) => tryPromise({
42454
+ try: async () => {
42455
+ const response = await fetch(url, { headers: { "user-agent": "pagegraph-cli" } });
42456
+ const html = await response.text();
42457
+ return inspectHtml(url, response.status, html);
42458
+ },
42459
+ catch: (cause) => new SeoCliError({ message: `Could not fetch ${url}: ${cause instanceof Error ? cause.message : String(cause)}` })
42460
+ });
42461
+ const inspectCommand = make$9("inspect", {
42462
+ target: String$1("target").pipe(withDescription("A route path (e.g. /pricing), or a full URL with --live")),
42463
+ live: Boolean$1("live").pipe(withDescription$2("Fetch the URL and inspect its rendered <head> and JSON-LD"), withDefault(false)),
42464
+ json: jsonFlag
42465
+ }).pipe(withDescription$1("Inspect one page: its graph declaration, or its live <head> with --live"), withExamples([
42466
+ {
42467
+ command: "pagegraph inspect /pricing",
42468
+ description: "The graph node, policy, and edges for a path"
42469
+ },
42470
+ {
42471
+ command: "pagegraph inspect https://example.com/pricing --live",
42472
+ description: "Fetch the page and validate its head tags + JSON-LD"
42473
+ },
42474
+ {
42475
+ command: "pagegraph inspect /blog/some-post --json",
42476
+ description: "The node report as JSON"
42477
+ }
42478
+ ]), withHandler(fnUntraced(function* ({ target, live, json }) {
42479
+ if (live) {
42480
+ const report = yield* fetchAndInspect(target);
42481
+ if (json) yield* printJson(report);
42482
+ else yield* printText(renderLiveReport(report));
42483
+ if (hasBlockingIssues(report)) return yield* new SeoCliError({ message: `${report.issues.length} issue(s) found at ${target}.` });
42484
+ return;
42485
+ }
42486
+ const config = yield* loadSeoConfig;
42487
+ const report = inspectNode(yield* scoped(acquireGraph(config)), target);
42488
+ if (report === void 0) return yield* new SeoCliError({ message: `No node at "${target}". Run \`pagegraph graph\` to list paths, or pass a URL with --live.` });
42489
+ if (json) return yield* printJson({
42490
+ node: serializeNode(report.node),
42491
+ inSitemap: report.inSitemap,
42492
+ incoming: report.incoming,
42493
+ outgoing: report.outgoing
42494
+ });
42495
+ return yield* printText(renderNodeReport(report));
42496
+ })));
42497
+ //#endregion
42498
+ //#region src/audit/crawl.ts
42499
+ /** Anchors that are never HTML documents; skipped so they do not consume the budget. */
42500
+ 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;
42501
+ /** The graph keys pages by path, so dedupe crawled URLs the same way. */
42502
+ const pageKey = (url) => normalizePath(url);
42503
+ /** Run `run` over `items` with at most `limit` in flight, preserving order. */
42504
+ const mapLimit = async (items, limit, run) => {
42505
+ const results = Array.from({ length: items.length });
42506
+ let index = 0;
42507
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
42508
+ for (;;) {
42509
+ const current = index++;
42510
+ if (current >= items.length) return;
42511
+ results[current] = await run(items[current]);
42512
+ }
42513
+ });
42514
+ await Promise.all(workers);
42515
+ return results.map((result, at) => {
42516
+ if (result === void 0) throw new Error(`Crawl worker ${at} produced no result`);
42517
+ return result;
42518
+ });
42519
+ };
42520
+ /**
42521
+ * Crawl `seed`'s origin breadth-first and return one {@link RenderedPage} per
42522
+ * successfully fetched HTML page. The seed must be an absolute `http(s)` URL;
42523
+ * private and reserved destinations are rejected unless `allowPrivate` is set.
42524
+ */
42525
+ const crawlRenderedPages = async (seed, options) => {
42526
+ let seedUrl;
42527
+ try {
42528
+ seedUrl = new URL(seed);
42529
+ } catch {
42530
+ throw new Error(`Not a valid URL: ${seed}`);
42531
+ }
42532
+ if (seedUrl.protocol !== "https:" && seedUrl.protocol !== "http:") throw new Error(`Unsupported URL protocol: ${seedUrl.protocol}`);
42533
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new Error(`Crawl limit must be a positive integer: ${options.limit}`);
42534
+ const origin = seedUrl.origin;
42535
+ const probeOptions = {
42536
+ allowPrivate: options.allowPrivate,
42537
+ timeoutMs: options.timeoutMs,
42538
+ maxBodyBytes: options.maxBodyBytes,
42539
+ captureAnchors: true
42540
+ };
42541
+ const concurrency = Math.max(1, options.concurrency ?? 4);
42542
+ const visited = /* @__PURE__ */ new Set([pageKey(seedUrl)]);
42543
+ /** Final paths already rendered, so a redirect cannot add the same page twice. */
42544
+ const rendered = /* @__PURE__ */ new Set();
42545
+ const pages = [];
42546
+ const failures = [];
42547
+ const truncatedBodies = [];
42548
+ let attempts = 0;
42549
+ let truncated = false;
42550
+ let root = pageKey(seedUrl);
42551
+ let frontier = [{
42552
+ url: seedUrl,
42553
+ depth: 0
42554
+ }];
42555
+ const probePage = async (item) => {
42556
+ const request = {
42557
+ kind: "page-html",
42558
+ method: "GET",
42559
+ accept: "text/html, */*;q=0.1",
42560
+ url: item.url
42561
+ };
42562
+ const probe = await probeHttp(request, probeOptions);
42563
+ if (!probe.ok || probe.finalUrl === null) return {
42564
+ item,
42565
+ page: null,
42566
+ failure: {
42567
+ url: item.url.href,
41302
42568
  error: probe.error ?? `HTTP ${probe.status ?? "no response"}`
41303
42569
  },
41304
42570
  links: [],
@@ -41502,209 +42768,6 @@ const decodeRenderedEdges = (input) => {
41502
42768
  });
41503
42769
  };
41504
42770
  //#endregion
41505
- //#region node_modules/effect/dist/unstable/ai/Decision.js
41506
- /**
41507
- * Brand for decision definitions.
41508
- *
41509
- * @category type IDs
41510
- * @since 4.0.0
41511
- */
41512
- const TypeId = "~effect/ai/Decision";
41513
- /**
41514
- * Creates a probability decision from descriptions of both outcomes.
41515
- *
41516
- * **Example** (Estimating urgency)
41517
- *
41518
- * ```ts
41519
- * import { Decision } from "effect/unstable/ai"
41520
- *
41521
- * const urgent = Decision.probability({
41522
- * instructions: "The message is time-sensitive",
41523
- * criteria: {
41524
- * false: "No time pressure",
41525
- * true: "Needs action now"
41526
- * }
41527
- * })
41528
- * ```
41529
- *
41530
- * @see {@link classify} for more than two outcomes
41531
- *
41532
- * @category constructors
41533
- * @since 4.0.0
41534
- */
41535
- const probability = (options) => ({
41536
- _tag: "Probability",
41537
- instructions: options.instructions,
41538
- criteria: options.criteria
41539
- });
41540
- /**
41541
- * Creates a decision definition from an input schema and named decisions.
41542
- * `DecisionModel.decide` encodes the input with `Schema.toCodecJson` before
41543
- * calling the provider. Answer keys and types are inferred from the decisions.
41544
- * Throws if `decisions` is empty.
41545
- *
41546
- * **Example** (Defining ticket triage)
41547
- *
41548
- * ```ts
41549
- * import { Schema } from "effect"
41550
- * import { Decision } from "effect/unstable/ai"
41551
- *
41552
- * const Ticket = Schema.Struct({
41553
- * subject: Schema.String,
41554
- * body: Schema.String
41555
- * })
41556
- *
41557
- * const TicketTriage = Decision.make({
41558
- * input: Ticket,
41559
- * decisions: {
41560
- * department: Decision.classify({
41561
- * instructions: "Which team should handle this",
41562
- * criteria: { billing: "payments", technical: "bugs" }
41563
- * }),
41564
- * urgent: Decision.probability({
41565
- * instructions: "The message is time-sensitive",
41566
- * criteria: { false: "No time pressure", true: "Needs action now" }
41567
- * })
41568
- * }
41569
- * })
41570
- * ```
41571
- *
41572
- * @see {@link Definition} for the returned shape
41573
- *
41574
- * @category constructors
41575
- * @since 4.0.0
41576
- */
41577
- const make = (options) => {
41578
- if (Object.keys(options.decisions).length === 0) throw new Error("Decision.make: decisions must not be empty");
41579
- return {
41580
- [TypeId]: TypeId,
41581
- input: options.input,
41582
- decisions: options.decisions
41583
- };
41584
- };
41585
- //#endregion
41586
- //#region src/links/decide.ts
41587
- /**
41588
- * Link decisions over the rendered graph, answered by an Effect `DecisionModel`.
41589
- *
41590
- * A candidate is a `(source → destination)` pair with the source copy. The model
41591
- * answers two probabilities — does the source have a genuine reader-facing
41592
- * reason to link to the destination, and is descriptive anchor text already in
41593
- * the copy — and this module maps them to a verdict. Anything the model is not
41594
- * confident about lands in the `review` bucket; nothing is applied automatically.
41595
- *
41596
- * This module reaches Effect and the TypeSafe provider, so it is CLI-internal:
41597
- * the library entries stay Effect-free and only the bundled `pagegraph` bin
41598
- * ships it.
41599
- */
41600
- /** Default probability boundary between a confident and an uncertain answer. */
41601
- const DEFAULT_LINK_THRESHOLD = .7;
41602
- /** One proposed contextual link, plus the source copy the model reads. */
41603
- const LinkCandidate = Struct({
41604
- sourceUrl: String$6,
41605
- destinationUrl: String$6,
41606
- sourceText: String$6,
41607
- /** Descriptive anchor text already present for the destination, when known. */
41608
- existingAnchor: optional$3(String$6)
41609
- });
41610
- /**
41611
- * The two decisions every candidate answers. Both are probabilities, so the
41612
- * model returns a calibrated number rather than generated prose.
41613
- */
41614
- const LinkDecision = make({
41615
- input: LinkCandidate,
41616
- decisions: {
41617
- realReason: probability({
41618
- instructions: "The source page has a genuine reader-facing reason to link to the destination page: the destination answers a question the source raises or deepens a topic the source introduces. Navigation, boilerplate, and mere keyword overlap are not reasons.",
41619
- criteria: {
41620
- false: "No genuine reason; the link would be noise or only keyword overlap.",
41621
- true: "A reader of the source would plausibly want the destination next."
41622
- }
41623
- }),
41624
- anchorPresent: probability({
41625
- instructions: "Descriptive anchor text pointing at the destination already appears in the source copy. A bare mention without a link counts as absent.",
41626
- criteria: {
41627
- false: "No existing anchor to the destination in the copy.",
41628
- true: "Descriptive anchor text to the destination is already in the copy."
41629
- }
41630
- })
41631
- }
41632
- });
41633
- /** A decision confidence band around `threshold`: `[0, 1 - threshold] ∪ [threshold, 1]`. */
41634
- const isConfident = (probability, threshold) => probability >= threshold || probability <= 1 - threshold;
41635
- /**
41636
- * Map the two probabilities to a verdict. A probability inside the review band
41637
- * makes the whole candidate uncertain, so the confident half of the pair can
41638
- * never auto-resolve it. Requires `threshold > 0.5`, so the review band
41639
- * `(1 - threshold, threshold)` is non-empty.
41640
- *
41641
- * - `present` — descriptive anchor already in the copy; nothing to add.
41642
- * - `recommend` — a real reason and no existing anchor; add a contextual link.
41643
- * - `skip` — no real reason; leave the copy alone.
41644
- * - `review` — at least one answer is inside the band; a human decides.
41645
- */
41646
- const classifyVerdict = (realReason, anchorPresent, threshold) => {
41647
- if (!isConfident(realReason, threshold) || !isConfident(anchorPresent, threshold)) return "review";
41648
- if (anchorPresent >= threshold) return "present";
41649
- if (realReason >= threshold) return "recommend";
41650
- return "skip";
41651
- };
41652
- /** Group records into resolved/review buckets and count each verdict. */
41653
- const buildLinksDecideReport = (options) => {
41654
- const counts = {
41655
- recommend: 0,
41656
- present: 0,
41657
- skip: 0,
41658
- review: 0
41659
- };
41660
- const resolved = [];
41661
- const review = [];
41662
- for (const record of options.records) {
41663
- counts[record.verdict] += 1;
41664
- if (record.verdict === "review") review.push(record);
41665
- else resolved.push(record);
41666
- }
41667
- return {
41668
- kind: "links-decide",
41669
- schemaVersion: 1,
41670
- model: options.model,
41671
- threshold: options.threshold,
41672
- counts: {
41673
- candidates: options.records.length,
41674
- ...counts
41675
- },
41676
- resolved,
41677
- review
41678
- };
41679
- };
41680
- /**
41681
- * Answer every candidate through the ambient `DecisionModel` and bucket the
41682
- * results. The model answers both decisions in one call per candidate; candidates
41683
- * are dispatched with bounded concurrency.
41684
- */
41685
- const decideLinks = (candidates, options) => gen(function* () {
41686
- const records = yield* forEach$1(candidates, (candidate) => decide(LinkDecision, { input: candidate }).pipe(map$5(({ answers }) => {
41687
- const realReason = answers.realReason.probability;
41688
- const anchorPresent = answers.anchorPresent.probability;
41689
- return {
41690
- sourceUrl: candidate.sourceUrl,
41691
- destinationUrl: candidate.destinationUrl,
41692
- sourceText: candidate.sourceText,
41693
- existingAnchor: candidate.existingAnchor ?? null,
41694
- realReason,
41695
- anchorPresent,
41696
- verdict: classifyVerdict(realReason, anchorPresent, options.threshold)
41697
- };
41698
- })), { concurrency: options.concurrency ?? 4 });
41699
- return buildLinksDecideReport({
41700
- model: options.model,
41701
- threshold: options.threshold,
41702
- records
41703
- });
41704
- });
41705
- /** Decode the candidate array the CLI reads from a file or stdin. Throws on invalid input. */
41706
- const decodeLinkCandidates = (input) => decodeUnknownSync(ArraySchema(LinkCandidate))(input);
41707
- //#endregion
41708
42771
  //#region src/cli/commands/links.ts
41709
42772
  const messageOf = (cause) => cause instanceof Error ? cause.message : String(cause);
41710
42773
  const positive = (name, value) => Number.isSafeInteger(value) && value > 0 ? succeed$2(value) : fail$3(new SeoCliError({ message: `--${name} must be a positive integer` }));
@@ -41958,6 +43021,7 @@ const decideFile = String$1("file").pipe(withDescription("JSON file with an arra
41958
43021
  const thresholdFlag = Finite("threshold").pipe(withDescription$2("Probability above which an answer is confident; below is review (0.5 < t < 1)"), withDefault(DEFAULT_LINK_THRESHOLD));
41959
43022
  const modelFlag = String$2("model").pipe(withDescription$2("TypeSafe System One model identifier"), withDefault("jev-latest"));
41960
43023
  const concurrencyFlag = Int("concurrency").pipe(withDescription$2("Maximum concurrent decision calls"), withDefault(4));
43024
+ const budgetFlag = Int("budget").pipe(withDescription$2("Keep only the top-K candidates per source by relevance"), withDefault(4));
41961
43025
  const checkThreshold = (value) => Number.isFinite(value) && value > .5 && value < 1 ? succeed$2(value) : fail$3(new SeoCliError({ message: "--threshold must be greater than 0.5 and less than 1 (a review band needs 1 - t < t)" }));
41962
43026
  /** Read the candidate JSON from a file argument or stdin; a TTY with no file is an error. */
41963
43027
  const readCandidatesText = (file) => try_({
@@ -41981,8 +43045,9 @@ const linksDecideCommand = make$9("decide", {
41981
43045
  json: jsonFlag,
41982
43046
  threshold: thresholdFlag,
41983
43047
  model: modelFlag,
41984
- concurrency: concurrencyFlag
41985
- }).pipe(withDescription$1("Answer real-reason and anchor-present decisions for candidate links; below-threshold candidates go to review"), withExamples([
43048
+ concurrency: concurrencyFlag,
43049
+ budget: budgetFlag
43050
+ }).pipe(withDescription$1("Answer real-reason, anchor-present, direction, and relevance for candidate links; below-threshold candidates go to review"), withExamples([
41986
43051
  {
41987
43052
  command: "pagegraph links decide candidates.json",
41988
43053
  description: "Decide a candidate set and print the reviewable plan"
@@ -41998,13 +43063,15 @@ const linksDecideCommand = make$9("decide", {
41998
43063
  ]), withHandler(fn("SeoCli.linksDecide")(function* (options) {
41999
43064
  const threshold = yield* checkThreshold(options.threshold);
42000
43065
  const concurrency = yield* positive("concurrency", options.concurrency);
43066
+ const budget = yield* positive("budget", options.budget);
42001
43067
  const text = yield* readCandidatesText(getOrUndefined$1(options.file));
42002
43068
  const candidates = yield* parseCandidates(text);
42003
43069
  if (process.env.TYPESAFE_API_KEY === void 0 || process.env.TYPESAFE_API_KEY === "") return yield* new SeoCliError({ message: "TYPESAFE_API_KEY is not set; `pagegraph links decide` answers through TypeSafe System One." });
42004
43070
  const report = yield* decideLinks(candidates, {
42005
43071
  model: options.model,
42006
43072
  threshold,
42007
- concurrency
43073
+ concurrency,
43074
+ budget
42008
43075
  }).pipe(provide(typeSafeDecisionModel(options.model)), mapError(decisionErrorMessage));
42009
43076
  if (options.json) yield* printJson(report);
42010
43077
  else yield* printText(renderLinksDecideReport(report));
@@ -42167,6 +43234,7 @@ const cli = make$9("pagegraph").pipe(withDescription$1("Inspect and audit a TanS
42167
43234
  }
42168
43235
  ]), withSubcommands([
42169
43236
  auditCommand,
43237
+ decideCommandGroup,
42170
43238
  diffCommand,
42171
43239
  graphCommand,
42172
43240
  inspectCommand,