pagegraph 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,17 @@ OpenCode SDK and its private Effect runtime only when invoked. `vite` stays a pe
43
43
  load your app through Vite at runtime — and `lighthouse` is only needed by `pagegraph audit`.
44
44
  Importing `pagegraph/audit` programmatically still needs `effect`.
45
45
 
46
+ ### Agent skill
47
+
48
+ The Pagegraph CLI serves its bundled TanStack Start integration skill:
49
+
50
+ ```bash
51
+ pagegraph skills list
52
+ pagegraph skills get core
53
+ ```
54
+
55
+ The skill is maintained at [skill-data/core/SKILL.md](skill-data/core/SKILL.md) and compiled into the CLI, so the instructions match the installed Pagegraph version. `skills get core` prints Markdown to stdout; `--json` returns a structured payload.
56
+
46
57
  ## Quick start
47
58
 
48
59
  **1. Bind your site identity once.** Route files never see an origin or a brand name.
@@ -229,6 +240,8 @@ export default defineSeoConfig({
229
240
  opencode: {
230
241
  configDirectory: ".pagegraph/opencode",
231
242
  defaultModel: "openrouter/example/model",
243
+ // Optional per-completion budget in milliseconds; default 180000.
244
+ timeoutMs: 360_000,
232
245
  models: {
233
246
  "research.keywords": "openrouter/example/research-model",
234
247
  },
@@ -254,9 +267,23 @@ pagegraph research keywords \
254
267
  ```
255
268
 
256
269
  Every workflow starts from the selected graph and context files, asks the SEO agent to search
257
- Executor's live catalog and compose suitable tools, then evaluates its structured observations with
258
- Jev. Tool paths are discovered at runtime; the generated skills contain editable starter recipes,
259
- not a fixed integration list.
270
+ Executor's live catalog and inspect the discovered tools' argument schemas, then evaluates its
271
+ structured observations with Jev. Tool paths are discovered at runtime; the generated skills contain
272
+ editable starter recipes, not a fixed integration list.
273
+
274
+ `workflows.opencode.timeoutMs` sets the positive integer deadline in milliseconds for each OpenCode
275
+ completion (maximum `2147483647`). It covers prompt admission, model execution, and any workflow JSON
276
+ repair prompt. A later action or continuation turn gets a separate budget. The default is `180000`.
277
+
278
+ Selecting pages preserves their incoming and outgoing relationships to non-selected pages in
279
+ `evidence.neighborhood`. Those neighbors supply architecture context without becoming workflow
280
+ targets or consuming `--limit`. The agent receives the limit before research, and PageGraph also caps
281
+ the decoded result before decisions.
282
+
283
+ After research is validated, the run directory contains `research.json`: an incomplete checkpoint
284
+ with the collected evidence, decision inputs, and OpenCode transcript. A downstream failure reports
285
+ its path so the evidence remains available for diagnosis. Only a completed workflow writes
286
+ `run.json` and `summary.md`; a research checkpoint is not a final recommendation.
260
287
 
261
288
  | Command | Result |
262
289
  | --- | --- |
@@ -407,7 +434,12 @@ private-IP-blocked HTTP path as `pagegraph audit` — and reports what a crawler
407
434
  actually receives: the real homepage depth, the pages with no incoming internal
408
435
  edge (rendered orphans), and, when the app has a `seo.config.ts`, the
409
436
  declared-vs-rendered link diff. It is bounded with `--limit` and needs no
410
- framework or config:
437
+ framework or config. Discovery reads same-origin `robots.txt` and bounded
438
+ sitemaps before following page anchors, so a sitemap-only page can appear as an
439
+ orphan. The JSON `crawl` block records skipped URLs, discovery failures, and
440
+ sitemap truncation. Link-followed non-HTML alternates are recorded in `nonHtml`
441
+ without counting as HTML page failures; a sitemap-listed or declared page served
442
+ as non-HTML still blocks a coverage assertion:
411
443
 
412
444
  ```bash
413
445
  pagegraph links verify https://example.com
@@ -440,6 +472,11 @@ and the provenance a gate needs to trust it:
440
472
  "bodyTruncated": false,
441
473
  "truncatedPages": [],
442
474
  "failures": [],
475
+ "nonHtml": [{ "url": "https://example.com/auth.md", "finalUrl": "https://example.com/auth.md", "contentType": "text/markdown" }],
476
+ "discoveryFailures": [],
477
+ "skipped": [],
478
+ "sitemapTruncated": false,
479
+ "sitemapUsed": true,
443
480
  },
444
481
  "nodes": ["/", "/pricing"],
445
482
  "edges": [{ "from": "/", "to": "/pricing", "region": "body" }],
@@ -465,6 +502,7 @@ pagegraph links verify --rendered .seo/rendered.json --assert-coverage --json |
465
502
  The command exits non-zero when any rule is unmet, and **refuses to assert** (also
466
503
  non-zero) rather than reporting a false pass when the crawl outran `--limit`, a
467
504
  page failed to fetch (its anchors are missing), a body hit `--max-body-bytes`, the
505
+ robots or sitemap discovery is incomplete, the
468
506
  config declares no `coverage`, or the artifact's origin differs from
469
507
  `seo.config.ts`. Under `--assert-coverage` the report gains a `coverage` block
470
508
  (`{ rules, ok, violations }`); the default `--json` summary is unchanged.
@@ -475,9 +513,11 @@ config declares no `coverage`, or the artifact's origin differs from
475
513
  `(source, destination)` pairs that are plausible contextual links and not already
476
514
  connected. Pages are grouped into clusters — a shared top-level section, or the
477
515
  same `kind` for root-level pages — and only sitemap-eligible pages are proposed.
478
- Pairs already declared as a `related` edge are excluded, and `--rendered` accepts
479
- a JSON dump of already-served anchors (`[{ from, to }]` or `{ edges: [...] }`) to
480
- exclude those too.
516
+ Each direction is evaluated separately. A declared `related` edge or served
517
+ body-region anchor excludes only its own direction. Nav, header, and footer
518
+ anchors do not exclude contextual suggestions. `--rendered` accepts a JSON dump
519
+ of already-served anchors (`[{ from, to, region }]` or `{ edges: [...] }`).
520
+ Legacy edges without a `region` are treated as body links.
481
521
 
482
522
  The output is a deterministic, reviewable plan: human text by default and
483
523
  versioned JSON with `--json`. Nothing is applied; `--limit` and `--cluster`
@@ -491,6 +531,43 @@ pagegraph links candidates --rendered rendered.json --json | jq
491
531
  pagegraph improve links --page "/docs/**"
492
532
  ```
493
533
 
534
+ For content-backed placements, opt in to a bounded same-origin probe. The configured
535
+ `origin` must match `--site`. Each schema-version-2 result carries a sentence
536
+ from served main content, an exact anchor phrase inside it, a target sentence,
537
+ and score components. Unreadable and over-limit pages are reported as skipped.
538
+ Code samples and tabular comparisons are excluded. An anchor is proposed only
539
+ when its distinct content words occur together in a target sentence, so review
540
+ the placement and its context before editing. Repeated passages and clause
541
+ fragments are excluded; named destinations require a destination-specific anchor.
542
+ The home and about pages are omitted as destinations because site navigation already leads there.
543
+ The pair-only command above remains offline and emits schema version 1.
544
+ The page budget selects low-inbound candidate pairs first. Use repeatable
545
+ `--target /exact/path` to put a known weak destination within the budget. Add
546
+ `--source /exact/path` when you have a likely source and want to guarantee the
547
+ pair is probed even on a large site; raise `--page-limit` for more pairs.
548
+ For a site with many legal, comparison, or other low-inbound pages, use
549
+ `--cluster blog` (or another section) to spend the same page budget on an
550
+ editorial cluster. A zero-result broad probe means no placement passed the
551
+ evidence rules within its selected pages; it does not mean the whole site has
552
+ no possible links.
553
+
554
+ ```bash
555
+ pagegraph links verify https://example.com --json
556
+ pagegraph links candidates --site https://example.com --page-limit 25 --json > /tmp/pagegraph-suggestions.json
557
+ pagegraph links candidates --site https://example.com --cluster blog --page-limit 25 --json
558
+ pagegraph links candidates --site https://example.com --source /blog/guide --target /blog/weak --page-limit 25 --json > /tmp/pagegraph-suggestions.json
559
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json --dry-run
560
+ pagegraph improve links --suggestions /tmp/pagegraph-suggestions.json
561
+ ```
562
+
563
+ `improve links` validates the artifact against the configured origin and graph,
564
+ then asks Jev to judge the suggested placements. Only accepted `add` or `update`
565
+ decisions can open an edit turn; skipped and review-band links remain in the run
566
+ artifact without authorizing an edit. Before an accepted edit, Pagegraph checks
567
+ that the source sentence and target passage still occur in served copy. Regenerate
568
+ the artifact if either page changed. For a local site, pass `--allow-private` to
569
+ both `links candidates --site` and `improve links --suggestions`.
570
+
494
571
  ### Contextual-link coverage
495
572
 
496
573
  Declare a contextual-link coverage policy in `seo.config.ts` (a `coverage` array
package/dist/audit.d.ts CHANGED
@@ -103,6 +103,12 @@ interface ProbeOptions {
103
103
  * probe field that grows with document size; the audit report leaves it unset.
104
104
  */
105
105
  readonly captureAnchors?: boolean | undefined;
106
+ /** Capture bounded text for robots and sitemap discovery. Never enabled for audit reports. */
107
+ readonly captureBody?: boolean | undefined;
108
+ /** Refuse cross-origin redirects during a same-origin crawl. */
109
+ readonly sameOrigin?: string | undefined;
110
+ /** Optional per-redirect crawl policy, checked before each network request. */
111
+ readonly allowUrl?: ((url: URL) => boolean) | undefined;
106
112
  }
107
113
  interface AuditOptions {
108
114
  readonly concurrency?: number;
package/dist/audit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as extractAnchors } from "./links-BFJc-pJ9.js";
1
+ import { a as extractAnchors } from "./links-DAa8KVzg.js";
2
2
  import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import * as Context from "effect/Context";
@@ -865,7 +865,7 @@ const canonicalUrl = (html) => {
865
865
  };
866
866
  const countMatches = (value, pattern) => [...value.matchAll(pattern)].length;
867
867
  const extractDocumentSignals = (body, contentType, requestedUrl) => {
868
- const isHtml = contentType.includes("text/html");
868
+ const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml+xml");
869
869
  const isMarkdown = contentType.includes("text/markdown");
870
870
  const looksJson = contentType.includes("json") || requestedUrl.pathname.endsWith(".json");
871
871
  const titleMatch = isHtml ? /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(body) : null;
@@ -960,6 +960,8 @@ const probeHttp = async (request, options) => {
960
960
  let currentUrl = request.url;
961
961
  let response = null;
962
962
  for (let redirectCount = 0; redirectCount <= 10; redirectCount += 1) {
963
+ if (options.sameOrigin !== void 0 && currentUrl.origin !== options.sameOrigin) throw new Error(`Redirected off-origin to ${currentUrl.href}`);
964
+ if (options.allowUrl !== void 0 && !options.allowUrl(currentUrl)) throw new Error(`Redirected to robots-disallowed URL ${currentUrl.href}`);
963
965
  response = await requestPinned(currentUrl, request, options);
964
966
  headersAt = performance.now();
965
967
  const location = response.headers.location;
@@ -977,7 +979,7 @@ const probeHttp = async (request, options) => {
977
979
  } : await readBoundedBody(response, options.maxBodyBytes);
978
980
  const contentType = typeof response.headers["content-type"] === "string" ? response.headers["content-type"] : "";
979
981
  const body = new TextDecoder().decode(bounded.bytes);
980
- const anchors = options.captureAnchors === true && contentType.includes("text/html") ? extractAnchors(body, currentUrl.href) : void 0;
982
+ const anchors = options.captureAnchors === true && (contentType === "" || /text\/html|application\/xhtml\+xml/i.test(contentType)) ? extractAnchors(body, currentUrl.href) : void 0;
981
983
  return {
982
984
  kind: request.kind,
983
985
  method: request.method,
@@ -994,6 +996,7 @@ const probeHttp = async (request, options) => {
994
996
  capturedBodySha256: bounded.bytes.byteLength === 0 ? null : createHash("sha256").update(bounded.bytes).digest("hex"),
995
997
  bodyTruncated: bounded.truncated,
996
998
  bodyExcerpt: bodyExcerpt(body, contentType),
999
+ ...options.captureBody === true ? { body } : {},
997
1000
  document: body.length === 0 ? null : extractDocumentSignals(body, contentType, currentUrl),
998
1001
  anchors,
999
1002
  error: null
@@ -1015,6 +1018,7 @@ const probeHttp = async (request, options) => {
1015
1018
  capturedBodySha256: null,
1016
1019
  bodyTruncated: false,
1017
1020
  bodyExcerpt: null,
1021
+ ...options.captureBody === true ? { body: void 0 } : {},
1018
1022
  document: null,
1019
1023
  error: errorMessage$2(error)
1020
1024
  };