pagegraph 0.8.3 → 0.8.4

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
@@ -229,6 +229,8 @@ export default defineSeoConfig({
229
229
  opencode: {
230
230
  configDirectory: ".pagegraph/opencode",
231
231
  defaultModel: "openrouter/example/model",
232
+ // Optional per-completion budget in milliseconds; default 180000.
233
+ timeoutMs: 360_000,
232
234
  models: {
233
235
  "research.keywords": "openrouter/example/research-model",
234
236
  },
@@ -254,9 +256,23 @@ pagegraph research keywords \
254
256
  ```
255
257
 
256
258
  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.
259
+ Executor's live catalog and inspect the discovered tools' argument schemas, then evaluates its
260
+ structured observations with Jev. Tool paths are discovered at runtime; the generated skills contain
261
+ editable starter recipes, not a fixed integration list.
262
+
263
+ `workflows.opencode.timeoutMs` sets the positive integer deadline in milliseconds for each OpenCode
264
+ completion (maximum `2147483647`). It covers prompt admission, model execution, and any workflow JSON
265
+ repair prompt. A later action or continuation turn gets a separate budget. The default is `180000`.
266
+
267
+ Selecting pages preserves their incoming and outgoing relationships to non-selected pages in
268
+ `evidence.neighborhood`. Those neighbors supply architecture context without becoming workflow
269
+ targets or consuming `--limit`. The agent receives the limit before research, and PageGraph also caps
270
+ the decoded result before decisions.
271
+
272
+ After research is validated, the run directory contains `research.json`: an incomplete checkpoint
273
+ with the collected evidence, decision inputs, and OpenCode transcript. A downstream failure reports
274
+ its path so the evidence remains available for diagnosis. Only a completed workflow writes
275
+ `run.json` and `summary.md`; a research checkpoint is not a final recommendation.
260
276
 
261
277
  | Command | Result |
262
278
  | --- | --- |
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import * as NFS from "node:fs";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import * as NodeChildProcess from "node:child_process";
5
5
  import { execFileSync, spawn } from "node:child_process";
6
6
  import { PassThrough } from "node:stream";
@@ -9,7 +9,7 @@ import { createHash, randomUUID } from "node:crypto";
9
9
  import * as OS from "node:os";
10
10
  import { tmpdir } from "node:os";
11
11
  import * as Path from "node:path";
12
- import { dirname, join, resolve } from "node:path";
12
+ import { dirname, join, relative, resolve } from "node:path";
13
13
  import * as NodeUrl from "node:url";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import * as readline from "node:readline";
@@ -35710,7 +35710,7 @@ const runWith = (command, config) => {
35710
35710
  };
35711
35711
  //#endregion
35712
35712
  //#region package.json
35713
- var version = "0.8.3";
35713
+ var version = "0.8.4";
35714
35714
  //#endregion
35715
35715
  //#region src/core/projections.ts
35716
35716
  const escapeXml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -36166,12 +36166,13 @@ const findConfigFile = (from) => {
36166
36166
  const isStringArray = (value) => Array.isArray(value) && value.every(isString);
36167
36167
  const isCoverageRules = (value) => Array.isArray(value) && value.every((rule) => isObject$1(rule) && isString(rule["path"]) && Number.isSafeInteger(rule["minInbound"]) && rule["minInbound"] > 0);
36168
36168
  const isStringRecord = (value) => isObject$1(value) && Object.values(value).every(isString);
36169
+ const isWorkflowTimeout = (value) => Number.isSafeInteger(value) && value > 0 && value <= 2147483647;
36169
36170
  const isStringArrayRecord = (value) => isObject$1(value) && Object.values(value).every(isStringArray);
36170
36171
  const isWorkflowConfig = (value) => {
36171
36172
  if (!isObject$1(value) || !isObject$1(value["opencode"])) return false;
36172
36173
  const opencode = value["opencode"];
36173
36174
  const context = value["context"];
36174
- return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
36175
+ return isString(opencode["configDirectory"]) && isString(opencode["defaultModel"]) && (opencode["models"] === void 0 || isStringRecord(opencode["models"])) && (opencode["timeoutMs"] === void 0 || isWorkflowTimeout(opencode["timeoutMs"])) && (context === void 0 || isObject$1(context) && (context["files"] === void 0 || isStringArray(context["files"])) && (context["byWorkflow"] === void 0 || isStringArrayRecord(context["byWorkflow"]))) && (value["runsDirectory"] === void 0 || isString(value["runsDirectory"]));
36175
36176
  };
36176
36177
  /**
36177
36178
  * `seo.config.ts` is the consumer's file and may be plain JS, so its types are
@@ -42084,6 +42085,13 @@ const writeRunBundle = (root, runsDirectory, run) => {
42084
42085
  writeFileSync(resolve(directory, "summary.md"), summary(run), "utf8");
42085
42086
  return directory;
42086
42087
  };
42088
+ const writeResearchCheckpoint = (root, runsDirectory, checkpoint) => {
42089
+ const directory = resolve(root, runsDirectory, checkpoint.id);
42090
+ mkdirSync(directory, { recursive: true });
42091
+ const path = resolve(directory, "research.json");
42092
+ writeFileSync(path, `${JSON.stringify(checkpoint, null, 2)}\n`, "utf8");
42093
+ return path;
42094
+ };
42087
42095
  //#endregion
42088
42096
  //#region node_modules/effect/dist/unstable/ai/Decision.js
42089
42097
  /**
@@ -43281,6 +43289,30 @@ const selectGraph = (graph, options) => {
43281
43289
  collisions: graph.collisions
43282
43290
  };
43283
43291
  };
43292
+ const selectNeighborhood = (graph, targetPaths) => {
43293
+ const inbound = [];
43294
+ const outbound = [];
43295
+ for (const edge of graph.edges) {
43296
+ if (targetPaths.has(edge.to) && !targetPaths.has(edge.from)) {
43297
+ const node = graph.nodes.get(edge.from);
43298
+ if (node !== void 0) inbound.push({
43299
+ edge,
43300
+ node: serializeNode(node)
43301
+ });
43302
+ }
43303
+ if (targetPaths.has(edge.from) && !targetPaths.has(edge.to)) {
43304
+ const node = graph.nodes.get(edge.to);
43305
+ if (node !== void 0) outbound.push({
43306
+ edge,
43307
+ node: serializeNode(node)
43308
+ });
43309
+ }
43310
+ }
43311
+ return {
43312
+ inbound,
43313
+ outbound
43314
+ };
43315
+ };
43284
43316
  const collectContextFiles = (root, workflow, context) => {
43285
43317
  const paths = [...context?.files ?? [], ...context?.byWorkflow?.[workflow] ?? []];
43286
43318
  return [...new Set(paths)].map((path) => ({
@@ -43288,14 +43320,18 @@ const collectContextFiles = (root, workflow, context) => {
43288
43320
  content: readFileSync(resolve(root, path), "utf8")
43289
43321
  }));
43290
43322
  };
43291
- const collectWorkflowEvidence = (graph, options, root, workflow, context) => ({
43292
- graph: serializeGraph(selectGraph(graph, options)),
43293
- sources: collectContextFiles(root, workflow, context)
43294
- });
43323
+ const collectWorkflowEvidence = (graph, options, root, workflow, context) => {
43324
+ const selected = selectGraph(graph, options);
43325
+ return {
43326
+ graph: serializeGraph(selected),
43327
+ neighborhood: selectNeighborhood(graph, new Set(selected.nodes.keys())),
43328
+ sources: collectContextFiles(root, workflow, context)
43329
+ };
43330
+ };
43295
43331
  //#endregion
43296
43332
  //#region src/workflows/git.ts
43297
- const run$1 = (root, args) => execFileSync("git", args, {
43298
- cwd: root,
43333
+ const run$1 = (cwd, args) => execFileSync("git", args, {
43334
+ cwd,
43299
43335
  encoding: "utf8",
43300
43336
  stdio: [
43301
43337
  "ignore",
@@ -43304,38 +43340,36 @@ const run$1 = (root, args) => execFileSync("git", args, {
43304
43340
  ]
43305
43341
  }).trim();
43306
43342
  const nul = (value) => value.split("\0").filter((item) => item.length > 0);
43307
- const fingerprint = (root, path) => {
43343
+ const fingerprint = (root, gitRoot, path) => {
43308
43344
  const absolute = resolve(root, path);
43309
- const content = existsSync(absolute) ? readFileSync(absolute) : Buffer.from("<deleted>");
43310
- const status = run$1(root, [
43345
+ const gitPath = relative(gitRoot, absolute);
43346
+ const status = run$1(gitRoot, [
43311
43347
  "status",
43312
43348
  "--short",
43313
43349
  "--",
43314
- path
43350
+ gitPath
43315
43351
  ]);
43352
+ let content;
43353
+ if (!existsSync(absolute)) content = Buffer.from("<deleted>");
43354
+ else if (statSync(absolute).isDirectory()) {
43355
+ const stage = run$1(gitRoot, [
43356
+ "ls-files",
43357
+ "--stage",
43358
+ "--",
43359
+ gitPath
43360
+ ]);
43361
+ if (stage.split(/\s+/, 1)[0] !== "160000") throw new Error(`Cannot fingerprint directory reported as a Git file: ${path}`);
43362
+ const submoduleRoot = realpathSync(run$1(absolute, ["rev-parse", "--show-toplevel"]));
43363
+ if (submoduleRoot !== realpathSync(absolute)) throw new Error(`Git submodule is unavailable: ${path}`);
43364
+ content = Buffer.from(`gitlink:${stage}\0${JSON.stringify(inspectGit(submoduleRoot))}`);
43365
+ } else content = readFileSync(absolute);
43316
43366
  return createHash("sha256").update(status).update("\0").update(content).digest("hex");
43317
43367
  };
43318
43368
  const inspectGit = (root) => {
43369
+ const absoluteRoot = realpathSync(root);
43370
+ let gitTopLevel;
43319
43371
  try {
43320
- const tracked = nul(run$1(root, [
43321
- "diff",
43322
- "--name-only",
43323
- "-z",
43324
- "HEAD"
43325
- ]));
43326
- const untracked = nul(run$1(root, [
43327
- "ls-files",
43328
- "--others",
43329
- "--exclude-standard",
43330
- "-z"
43331
- ]));
43332
- const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].sort();
43333
- return {
43334
- head: run$1(root, ["rev-parse", "HEAD"]),
43335
- dirty: files.length > 0,
43336
- files,
43337
- fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(root, path)]))
43338
- };
43372
+ gitTopLevel = run$1(absoluteRoot, ["rev-parse", "--show-toplevel"]);
43339
43373
  } catch {
43340
43374
  return {
43341
43375
  dirty: false,
@@ -43343,6 +43377,27 @@ const inspectGit = (root) => {
43343
43377
  fingerprints: {}
43344
43378
  };
43345
43379
  }
43380
+ const gitRoot = realpathSync(gitTopLevel);
43381
+ const tracked = nul(run$1(gitRoot, [
43382
+ "diff",
43383
+ "--name-only",
43384
+ "-z",
43385
+ "HEAD"
43386
+ ]));
43387
+ const untracked = nul(run$1(gitRoot, [
43388
+ "ls-files",
43389
+ "--others",
43390
+ "--exclude-standard",
43391
+ "--full-name",
43392
+ "-z"
43393
+ ]));
43394
+ const files = [.../* @__PURE__ */ new Set([...tracked, ...untracked])].map((path) => relative(absoluteRoot, resolve(gitRoot, path))).sort();
43395
+ return {
43396
+ head: run$1(gitRoot, ["rev-parse", "HEAD"]),
43397
+ dirty: files.length > 0,
43398
+ files,
43399
+ fingerprints: Object.fromEntries(files.map((path) => [path, fingerprint(absoluteRoot, gitRoot, path)]))
43400
+ };
43346
43401
  };
43347
43402
  const changedFiles = (before, after) => [.../* @__PURE__ */ new Set([...before.files, ...after.files])].filter((path) => before.fingerprints[path] !== after.fingerprints[path]).sort();
43348
43403
  //#endregion
@@ -43478,9 +43533,20 @@ const decodeToolPart = decodeUnknownOption(Struct({
43478
43533
  state: Struct({
43479
43534
  status: String$6,
43480
43535
  input: optionalKey(Unknown),
43481
- content: optionalKey(Unknown)
43536
+ content: optionalKey(Unknown),
43537
+ error: optionalKey(Unknown),
43538
+ metadata: optionalKey(Unknown)
43482
43539
  })
43483
43540
  }));
43541
+ const decodeCodeModeMetadata = decodeUnknownOption(Struct({ toolCalls: ArraySchema(Struct({
43542
+ tool: String$6,
43543
+ status: Literals$2([
43544
+ "running",
43545
+ "completed",
43546
+ "error"
43547
+ ]),
43548
+ input: optionalKey(Unknown)
43549
+ })) }));
43484
43550
  const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap((message) => {
43485
43551
  const assistant = decodeAssistantMessage(message);
43486
43552
  if (isNone(assistant)) return [];
@@ -43489,15 +43555,6 @@ const toolParts = (transcript) => decodeTranscript(transcript).messages.flatMap(
43489
43555
  return isSome(decoded) ? [decoded.value] : [];
43490
43556
  });
43491
43557
  });
43492
- const toolText = (value) => {
43493
- if (typeof value === "string") return value;
43494
- if (Array.isArray(value)) return value.map(toolText).join("\n");
43495
- if (value === null || typeof value !== "object") return "";
43496
- const record = value;
43497
- if (record["type"] === "text" && typeof record["text"] === "string") return record["text"];
43498
- return Object.values(record).map(toolText).join("\n");
43499
- };
43500
- const pathsIn = (value) => [...toolText(value).matchAll(/tools\.([A-Za-z0-9_.-]+)/g)].map((match) => match[1] ?? "").filter(Boolean);
43501
43558
  const toolPathPattern = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+$/;
43502
43559
  const pathsInSearchOutput = (value) => {
43503
43560
  const found = /* @__PURE__ */ new Set();
@@ -43526,27 +43583,50 @@ const pathsInSearchOutput = (value) => {
43526
43583
  const evidenceRecord = (part) => ({
43527
43584
  tool: part.name,
43528
43585
  input: part.state.input ?? null,
43529
- output: part.state.content ?? null
43586
+ output: part.state.content ?? part.state.error ?? null
43530
43587
  });
43531
43588
  const collectExecutorEvidence = (transcript) => {
43532
43589
  const discovered = /* @__PURE__ */ new Set();
43533
43590
  const searches = [];
43534
43591
  const calls = [];
43535
43592
  for (const part of toolParts(transcript)) {
43536
- const inputPaths = pathsIn(part.state.input);
43537
- const inputText = toolText(part.state.input);
43538
- if (part.name === "executor_search" || part.name === "executor.search" || inputPaths.some((path) => path === "executor.search")) {
43593
+ const metadata = decodeCodeModeMetadata(part.state.metadata);
43594
+ if (isSome(metadata)) {
43595
+ const invocations = metadata.value.toolCalls;
43596
+ for (const invocation of invocations) {
43597
+ if (invocation.status !== "completed") continue;
43598
+ const record = codeModeEvidenceRecord(invocation, part.state.content);
43599
+ if (invocation.tool === "executor.search") {
43600
+ for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
43601
+ searches.push(record);
43602
+ continue;
43603
+ }
43604
+ if ([...discovered].some((path) => matchesToolPath(invocation.tool, path))) calls.push(record);
43605
+ }
43606
+ continue;
43607
+ }
43608
+ if (part.state.status === "completed" && (part.name === "executor_search" || part.name === "executor.search")) {
43539
43609
  for (const path of pathsInSearchOutput(part.state.content)) if (path !== "executor.search") discovered.add(path);
43540
43610
  searches.push(evidenceRecord(part));
43541
43611
  continue;
43542
43612
  }
43543
- if ([...discovered].some((path) => part.name === path || part.name === path.replaceAll(".", "_") || inputPaths.includes(path) || inputText.includes(path))) calls.push(evidenceRecord(part));
43613
+ if (part.state.status === "completed" && [...discovered].some((path) => matchesToolPath(part.name, path))) calls.push(evidenceRecord(part));
43544
43614
  }
43545
43615
  return {
43546
43616
  searches,
43547
43617
  calls
43548
43618
  };
43549
43619
  };
43620
+ const codeModeEvidenceRecord = (invocation, codeModeOutput) => ({
43621
+ tool: invocation.tool,
43622
+ input: invocation.input ?? null,
43623
+ output: {
43624
+ status: invocation.status,
43625
+ scope: "shared CodeMode execution result",
43626
+ content: codeModeOutput ?? null
43627
+ }
43628
+ });
43629
+ const matchesToolPath = (tool, path) => tool === path || tool === path.replaceAll(".", "_");
43550
43630
  const interactionEventError = (event, sessionId) => {
43551
43631
  if (event === null || typeof event !== "object") return void 0;
43552
43632
  const record = event;
@@ -43559,15 +43639,61 @@ const interactionEventError = (event, sessionId) => {
43559
43639
  if (form !== null && typeof form === "object" && form["sessionID"] === sessionId) return /* @__PURE__ */ new Error("OpenCode requested a form; embedded PageGraph workflows are noninteractive.");
43560
43640
  }
43561
43641
  };
43642
+ const recordWorkflowActivity = (activity, event) => {
43643
+ if (event.type === "log.synced") return;
43644
+ activity.lastActivity = {
43645
+ type: event.type,
43646
+ at: (/* @__PURE__ */ new Date()).toISOString(),
43647
+ seq: event.durable.seq
43648
+ };
43649
+ if (event.type === "session.step.started") {
43650
+ activity.modelSteps += 1;
43651
+ activity.lastModel = `${event.data.model.providerID}/${event.data.model.id}`;
43652
+ return;
43653
+ }
43654
+ if (event.type === "session.tool.input.started") {
43655
+ activity.toolNames.set(event.data.id, event.data.name);
43656
+ activity.lastTool = {
43657
+ id: event.data.id,
43658
+ name: event.data.name,
43659
+ status: "input"
43660
+ };
43661
+ return;
43662
+ }
43663
+ if (event.type === "session.tool.called") {
43664
+ activity.lastTool = {
43665
+ id: event.data.id,
43666
+ name: activity.toolNames.get(event.data.id) ?? event.data.id,
43667
+ status: "called"
43668
+ };
43669
+ return;
43670
+ }
43671
+ if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
43672
+ activity.completedTools += 1;
43673
+ activity.lastTool = {
43674
+ id: event.data.id,
43675
+ name: activity.toolNames.get(event.data.id) ?? event.data.id,
43676
+ status: event.type === "session.tool.success" ? "succeeded" : "failed"
43677
+ };
43678
+ }
43679
+ };
43680
+ const describeWorkflowActivity = (activity, stage) => {
43681
+ const lastActivity = activity.lastActivity ? `${activity.lastActivity.type} at ${activity.lastActivity.at}${activity.lastActivity.seq === void 0 ? "" : ` (seq ${activity.lastActivity.seq})`}` : `none observed during ${stage}`;
43682
+ const lastTool = activity.lastTool ? `; last tool ${activity.lastTool.name} (${activity.lastTool.id}, ${activity.lastTool.status})` : "";
43683
+ const model = activity.lastModel ? `; last model ${activity.lastModel}` : "";
43684
+ return `Model steps: ${activity.modelSteps}; completed tools: ${activity.completedTools}; last activity: ${lastActivity}${model}${lastTool}.`;
43685
+ };
43562
43686
  const waitForIdle = async (host, sessionId, options) => {
43563
- const controller = new AbortController();
43564
- const timer = setTimeout(() => controller.abort(), options.timeoutMs);
43687
+ const controller = options.signal === void 0 ? new AbortController() : void 0;
43688
+ const signal = options.signal ?? controller.signal;
43689
+ const timer = controller === void 0 ? void 0 : setTimeout(() => controller.abort(), options.timeoutMs);
43565
43690
  try {
43566
43691
  for await (const event of host.sessions.log({
43567
43692
  sessionID: sessionId,
43568
43693
  follow: true,
43569
43694
  after: options.after
43570
- }, { signal: controller.signal })) {
43695
+ }, { signal })) {
43696
+ if (options.activity !== void 0) recordWorkflowActivity(options.activity, event);
43571
43697
  const interactionError = interactionEventError(event, sessionId);
43572
43698
  if (interactionError !== void 0) throw interactionError;
43573
43699
  if (event.type === "session.execution.succeeded" && event.data.sessionID === sessionId) return event.durable.seq;
@@ -43575,13 +43701,44 @@ const waitForIdle = async (host, sessionId, options) => {
43575
43701
  if (event.type === "session.execution.interrupted" && event.data.sessionID === sessionId) throw new Error(`OpenCode session was interrupted: ${event.data.reason}.`);
43576
43702
  }
43577
43703
  } catch (cause) {
43578
- if (controller.signal.aborted) throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs ?? options.timeoutMs}ms.`, { cause });
43704
+ if (controller?.signal.aborted) throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs ?? options.timeoutMs}ms.`, { cause });
43579
43705
  throw cause;
43580
43706
  } finally {
43581
- clearTimeout(timer);
43707
+ if (timer !== void 0) clearTimeout(timer);
43582
43708
  }
43583
43709
  throw new Error("OpenCode session log ended before the SEO agent completed.");
43584
43710
  };
43711
+ const runWorkflowTurn = async (host, sessionId, prompt, options) => {
43712
+ const controller = new AbortController();
43713
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
43714
+ let stage = options.stage;
43715
+ try {
43716
+ await host.sessions.prompt({
43717
+ sessionID: sessionId,
43718
+ text: prompt,
43719
+ ...options.skills === void 0 ? {} : { skills: options.skills.map((id) => ({ id })) }
43720
+ }, { signal: controller.signal });
43721
+ if (stage === "prompt admission") stage = "model execution";
43722
+ return await waitForIdle(host, sessionId, {
43723
+ timeoutMs: options.timeoutMs,
43724
+ configuredTimeoutMs: options.configuredTimeoutMs,
43725
+ after: options.after,
43726
+ signal: controller.signal,
43727
+ activity: options.activity
43728
+ });
43729
+ } catch (cause) {
43730
+ if (!controller.signal.aborted) throw cause;
43731
+ let interruption = "unknown";
43732
+ try {
43733
+ interruption = (await host.sessions.interrupt({ sessionID: sessionId }, { signal: AbortSignal.timeout(5e3) })).interrupted ? "active execution interrupted" : "no active execution to interrupt";
43734
+ } catch (interruptCause) {
43735
+ interruption = `interrupt request failed: ${interruptCause instanceof Error ? interruptCause.message : String(interruptCause)}`;
43736
+ }
43737
+ throw new Error(`OpenCode session did not complete within ${options.configuredTimeoutMs}ms during ${stage}; ${describeWorkflowActivity(options.activity, stage)} Timeout handling: ${interruption}.`, { cause });
43738
+ } finally {
43739
+ clearTimeout(timer);
43740
+ }
43741
+ };
43585
43742
  const acquireWorkflowHost = async (options) => {
43586
43743
  const model = parseModel(options.model ?? options.config.defaultModel);
43587
43744
  const configDirectory = resolve(options.root, options.config.configDirectory);
@@ -43603,18 +43760,21 @@ const acquireWorkflowHost = async (options) => {
43603
43760
  sessionID: sessionId,
43604
43761
  permissions: [...workflowOptions.permissions]
43605
43762
  });
43606
- const configuredTimeoutMs = options.timeoutMs ?? 18e4;
43763
+ const configuredTimeoutMs = options.timeoutMs ?? options.config.timeoutMs ?? 18e4;
43607
43764
  const deadline = Date.now() + configuredTimeoutMs;
43608
43765
  const remaining = () => Math.max(0, deadline - Date.now());
43609
- await host.sessions.prompt({
43610
- sessionID: sessionId,
43611
- text: prompt,
43612
- skills: workflowOptions.skills.map((id) => ({ id }))
43613
- });
43614
- let cursor = await waitForIdle(host, sessionId, {
43766
+ const activity = {
43767
+ modelSteps: 0,
43768
+ completedTools: 0,
43769
+ toolNames: /* @__PURE__ */ new Map()
43770
+ };
43771
+ let cursor = await runWorkflowTurn(host, sessionId, prompt, {
43615
43772
  timeoutMs: remaining(),
43616
43773
  configuredTimeoutMs,
43617
- after: cursors.get(sessionId)
43774
+ stage: "prompt admission",
43775
+ after: cursors.get(sessionId),
43776
+ skills: workflowOptions.skills,
43777
+ activity
43618
43778
  });
43619
43779
  cursors.set(sessionId, cursor);
43620
43780
  let transcript = await host.sessions.export({
@@ -43632,14 +43792,12 @@ const acquireWorkflowHost = async (options) => {
43632
43792
  }]
43633
43793
  });
43634
43794
  try {
43635
- await host.sessions.prompt({
43636
- sessionID: sessionId,
43637
- text: STATE_REPAIR_PROMPT
43638
- });
43639
- cursor = await waitForIdle(host, sessionId, {
43795
+ cursor = await runWorkflowTurn(host, sessionId, STATE_REPAIR_PROMPT, {
43640
43796
  timeoutMs: remaining(),
43641
43797
  configuredTimeoutMs,
43642
- after: cursor
43798
+ stage: "workflow state repair",
43799
+ after: cursor,
43800
+ activity
43643
43801
  });
43644
43802
  cursors.set(sessionId, cursor);
43645
43803
  transcript = await host.sessions.export({
@@ -43691,7 +43849,7 @@ const acquireWorkflowHost = async (options) => {
43691
43849
  var action_default = "Continue PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nObserved state:\n{{state}}\n\nJev decisions:\n{{decisions}}\n\nApply only records in the resolved set whose verdict calls for a change. Never apply review records;\nleave them in the artifact for a human. If nothing is safely resolved, return no-change.\n\nRepository mutation mode: {{mutationMode}}. {{mutationInstruction}}\n\nReturn only one JSON object with this shape:\n{\"summary\":\"...\",\"files\":[\"relative/path\"],\"outcome\":\"applied|dry-run|no-change\"}\n";
43692
43850
  //#endregion
43693
43851
  //#region src/workflows/prompts/research.md
43694
- var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nSearch Executor's live catalog in one Code Mode turn, then call at least one exact returned tool\npath in a later turn. Compose whichever current integrations best support this workflow. Do not\nassume fixed provider or tool names. Preserve source references in the structured state.\n\nReturn only one JSON object matching this JSON Schema:\n{{stateSchema}}\n\nMarket: {{market}}\nLanguage: {{language}}\nDevice: {{device}}\nRefresh requested: {{refresh}}\nDo not ask questions, request permissions, or create interactive forms. Fail clearly if required\ninput is missing.\nSeed queries: {{queries}}\nCompetitor seeds: {{competitors}}\nAuthority-domain seeds: {{domains}}\nDeterministic evidence:\n{{evidence}}\n";
43852
+ var research_default = "You are running PageGraph's {{workflow}} workflow.\n\n{{instructions}}\n\nUse these skills: {{skills}}. Treat the supplied project evidence as the deterministic baseline.\nThe graph contains the selected target pages. Its neighborhood preserves incoming and outgoing\nrelationships to pages outside that selection; those neighbors are context, not additional targets.\nReturn at most {{limit}} items or opportunities. Keep exploration scoped to these targets and stop\nwhen the collected evidence supports the requested decision; do not expand into a site-wide audit.\n\nSearch Executor's live catalog with a short intent phrase, then use Code Mode's local `search` for\nthe exact returned path to inspect its current argument signature before calling it in a later turn.\nCatalog discovery returns tool names and descriptions, not complete input schemas. Never guess\narguments from a description. If discovery returns no matches, simplify the query. Compose whichever\ncurrent integrations best support this workflow; tool names and providers are discovered dynamically.\nCall at least one relevant discovered tool and preserve source references and provider errors in the\nstructured state. Do not treat an errored call or empty dataset as proof of indexing or traffic.\nDistinguish project market settings from the country, language, device, and date filters actually\napplied to provider data; report only the segments supported by the call and its response.\n\nReturn only one JSON object matching this JSON Schema:\n{{stateSchema}}\n\nMarket: {{market}}\nLanguage: {{language}}\nDevice: {{device}}\nRefresh requested: {{refresh}}\nDo not ask questions, request permissions, or create interactive forms. Fail clearly if required\ninput is missing.\nSeed queries: {{queries}}\nCompetitor seeds: {{competitors}}\nAuthority-domain seeds: {{domains}}\nDeterministic evidence:\n{{evidence}}\n";
43695
43853
  //#endregion
43696
43854
  //#region src/workflows/run.ts
43697
43855
  const defaultDecide = async (inputs, spec) => {
@@ -43720,6 +43878,7 @@ const capState = (state, limit) => {
43720
43878
  };
43721
43879
  const researchPrompt = (spec, evidence, options) => TextTemplate.from(research_default).values({
43722
43880
  workflow: spec.id,
43881
+ limit: options.limit,
43723
43882
  instructions: spec.researchInstructions,
43724
43883
  skills: spec.skills.join(", "),
43725
43884
  stateSchema: JSON.stringify(spec.stateJsonSchema),
@@ -43782,54 +43941,88 @@ const runWorkflow = async (input, dependencies = {}) => {
43782
43941
  const error = spec.validateDecisionInput(item);
43783
43942
  if (error !== void 0) throw new Error(error);
43784
43943
  }
43785
- const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
43786
- const acted = spec.mutatesFiles ? await host.continue(researched.sessionId, actionPrompt(spec, state, decisionReport, mutation), {
43787
- skills: spec.skills,
43788
- permissions: actionPermissions
43789
- }) : researched;
43790
- const gitAfter = inspectGit(input.root);
43791
- const files = changedFiles(gitAtStart, gitAfter);
43792
- if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
43793
- const run = {
43794
- kind: "pagegraph-workflow-run",
43944
+ const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
43945
+ const checkpoint = {
43946
+ kind: "pagegraph-workflow-research-checkpoint",
43795
43947
  schemaVersion: 1,
43796
43948
  id,
43797
43949
  workflow: spec.id,
43798
43950
  startedAt: started.toISOString(),
43799
- finishedAt: now().toISOString(),
43951
+ checkpointedAt: now().toISOString(),
43800
43952
  project: {
43801
43953
  root: input.root,
43802
43954
  head: gitAtStart.head,
43803
- dirtyAtStart: gitAtStart.dirty
43955
+ dirtyAtStart: gitAtStart.dirty,
43956
+ filesAtStart: gitAtStart.files
43804
43957
  },
43805
43958
  options: input.options,
43806
- model: host.model,
43807
- targets: {
43808
- pages: evidence.graph.nodes.map((node) => node.path),
43809
- queries: input.options.queries,
43810
- kinds: input.options.kinds
43811
- },
43812
43959
  evidence: {
43813
43960
  ...evidence,
43814
- executor: acted.executor
43815
- },
43816
- decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
43817
- changes: {
43818
- files,
43819
- providerCalls: acted.executor.calls.map((call) => call.tool)
43961
+ executor: researched.executor
43820
43962
  },
43821
- result: spec.mutatesFiles ? acted.state : state,
43963
+ state,
43964
+ decisionInputs,
43822
43965
  opencode: {
43823
43966
  agent: "seo",
43824
- sessionId: acted.sessionId,
43825
- transcript: acted.transcript
43967
+ sessionId: researched.sessionId,
43968
+ model: host.model,
43969
+ transcript: researched.transcript
43826
43970
  }
43827
43971
  };
43828
- const runsDirectory = input.out ?? workflows.runsDirectory ?? ".pagegraph/runs";
43829
- return {
43830
- run,
43831
- directory: writeRunBundle(input.root, runsDirectory, run)
43832
- };
43972
+ const checkpointPath = writeResearchCheckpoint(input.root, runsDirectory, checkpoint);
43973
+ const gitAtCheckpoint = inspectGit(input.root);
43974
+ try {
43975
+ const decisionReport = await (dependencies.decide ?? defaultDecide)(decisionInputs, spec);
43976
+ const acted = spec.mutatesFiles ? await host.continue(researched.sessionId, actionPrompt(spec, state, decisionReport, mutation), {
43977
+ skills: spec.skills,
43978
+ permissions: actionPermissions
43979
+ }) : researched;
43980
+ const gitAfter = inspectGit(input.root);
43981
+ const files = changedFiles(gitAtCheckpoint, gitAfter);
43982
+ if ((!spec.mutatesFiles || input.options.dryRun) && files.length > 0) throw new Error(`${spec.id} changed repository files while running in read-only mode: ${files.join(", ")}`);
43983
+ const run = {
43984
+ kind: "pagegraph-workflow-run",
43985
+ schemaVersion: 1,
43986
+ id,
43987
+ workflow: spec.id,
43988
+ startedAt: started.toISOString(),
43989
+ finishedAt: now().toISOString(),
43990
+ project: {
43991
+ root: input.root,
43992
+ head: gitAtStart.head,
43993
+ dirtyAtStart: gitAtStart.dirty
43994
+ },
43995
+ options: input.options,
43996
+ model: host.model,
43997
+ targets: {
43998
+ pages: evidence.graph.nodes.map((node) => node.path),
43999
+ queries: input.options.queries,
44000
+ kinds: input.options.kinds
44001
+ },
44002
+ evidence: {
44003
+ ...evidence,
44004
+ executor: acted.executor
44005
+ },
44006
+ decisions: [decisionArtifact(spec, decisionInputs, decisionReport)],
44007
+ changes: {
44008
+ files,
44009
+ providerCalls: acted.executor.calls.map((call) => call.tool)
44010
+ },
44011
+ result: spec.mutatesFiles ? acted.state : state,
44012
+ opencode: {
44013
+ agent: "seo",
44014
+ sessionId: acted.sessionId,
44015
+ transcript: acted.transcript
44016
+ }
44017
+ };
44018
+ return {
44019
+ run,
44020
+ directory: writeRunBundle(input.root, runsDirectory, run)
44021
+ };
44022
+ } catch (cause) {
44023
+ const message = cause instanceof Error ? cause.message : String(cause);
44024
+ throw new Error(`${message}\nResearch checkpoint: ${checkpointPath}`, { cause });
44025
+ }
43833
44026
  } finally {
43834
44027
  await host.close();
43835
44028
  }
@@ -44361,7 +44554,7 @@ var executor_default$1 = "# Executor Starters for SERP Analysis\n\nThese snippet
44361
44554
  var SKILL_default = "---\nname: site-architecture\ndescription: Plan clear route ownership, hierarchy, collections, and contextual navigation.\n---\n\n# Site Architecture\n\nUse PageGraph's deterministic route nodes and edges as the primary model. Read\n[Executor Starters](references/executor.md) when owned search or crawl evidence can resolve an\nownership or discoverability question.\n\nAssign one clear intent owner, identify useful collections and parent-child relationships, and\nprefer consolidation when multiple pages compete for the same need. Propose new routes only when a\ndistinct supported intent lacks an adequate owner. Treat URL changes as consequential and preserve\nredirect and canonical requirements in the recommendation.\n\nReturn the structured state requested by the workflow. This planning workflow does not move routes,\nedit navigation, or submit sitemaps.\n";
44362
44555
  //#endregion
44363
44556
  //#region src/cli/preset/opencode/skills/site-architecture/references/executor.md
44364
- var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Google Search Console page query canonical indexing\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl sitemap URL information\", limit: 8 }),\n]);\n~~~\n\nIn a later turn:\n\n~~~ts\nconst pages = (await search({ query: \"tools.<exact-owned-pages-path>\", limit: 1 })).items[0];\nreturn pages.path.split(\".\").reduce((node, segment) => node[segment], tools)({\n siteUrl,\n startDate,\n endDate,\n dimensions: [\"PAGE\", \"QUERY\"],\n});\n~~~\n\nKeep submitted, crawled, indexed, ranking, and clicked evidence distinct. Record reporting dates,\nfilters, canonical state, task status, errors, and cost. Do not request indexing, submit sitemaps,\nor update provider projects.\n";
44557
+ var executor_default = "# Executor Starters for Site Architecture\n\nThese snippets are starting points, not an inventory. Discover owned page/query, crawl, sitemap,\nand URL evidence only when the deterministic graph does not answer the question.\n\n~~~ts\nreturn Promise.all([\n tools.executor.search({ query: \"Search Console page performance\", limit: 8 }),\n tools.executor.search({ query: \"Bing Webmaster crawl\", limit: 8 }),\n]);\n~~~\n\nInspect a selected tool's signature before executing it. This is Code Mode's local schema lookup;\nit does not call the provider:\n\n~~~ts\nreturn await search({ query: \"tools.<exact-path-returned-by-executor>\", limit: 1 });\n~~~\n\nIn a later turn, call that exact path with the required fields from its returned signature. Property,\nproject, URL, date, and dimension arguments differ between integrations. Do not copy guessed fields\nfrom a recipe. Prefer page/query performance for demand and ownership questions; use URL inspection\nwhen the question actually concerns crawl, canonical, or indexing state.\n\nKeep submitted, crawled, indexed, ranking, and clicked evidence distinct. Record reporting dates,\nfilters, canonical state, task status, errors, and cost. Do not request indexing, submit sitemaps,\nor update provider projects.\n";
44365
44558
  //#endregion
44366
44559
  //#region src/cli/preset/opencode/preset.ts
44367
44560
  const skill = (name, definition, executor) => ({