pi-workflow-engine 0.10.3 → 0.12.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.
Files changed (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -0,0 +1,189 @@
1
+ import { WorkflowPauseError } from "./cancellation.ts";
2
+ import {
3
+ WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
4
+ type ResolvedWorkflowRunOptions,
5
+ } from "./options.ts";
6
+ import type { WorkflowProviderUsageLimitPause } from "./workflow-run-record.ts";
7
+
8
+ export const WORKFLOW_PROVIDER_USAGE_LIMIT_CODE = "WORKFLOW_PROVIDER_USAGE_LIMIT";
9
+ export const WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS = 60_000;
10
+ const MAX_RESET_HINT_LENGTH = 512;
11
+
12
+ export interface ProviderUsageLimitDetails {
13
+ readonly stopReason: "error";
14
+ readonly providerMessage: string;
15
+ readonly provider?: string;
16
+ readonly model?: string;
17
+ readonly api?: string;
18
+ readonly resetHint?: string;
19
+ readonly resetAt?: number;
20
+ }
21
+
22
+ export interface ProviderUsageLimitPauseRecord {
23
+ readonly message: string;
24
+ readonly pause: WorkflowProviderUsageLimitPause;
25
+ }
26
+
27
+ /** Fatal-to-the-current-run provider limit that remains resumable from its journal boundary. */
28
+ export class WorkflowProviderUsageLimitError extends WorkflowPauseError {
29
+ override readonly name = "WorkflowProviderUsageLimitError";
30
+ readonly code = WORKFLOW_PROVIDER_USAGE_LIMIT_CODE;
31
+
32
+ constructor(readonly details: ProviderUsageLimitDetails) {
33
+ super(details.providerMessage);
34
+ }
35
+
36
+ toJSON(): {
37
+ readonly name: string;
38
+ readonly message: string;
39
+ readonly code: string;
40
+ readonly details: ProviderUsageLimitDetails;
41
+ } {
42
+ return { name: this.name, message: this.message, code: this.code, details: this.details };
43
+ }
44
+ }
45
+
46
+ export function providerUsageLimitFromMessages(
47
+ messages: readonly unknown[],
48
+ now = Date.now(),
49
+ ): WorkflowProviderUsageLimitError | undefined {
50
+ const message = messages.findLast(isAssistantMessage);
51
+ if (!message || message.stopReason !== "error") return undefined;
52
+ const providerMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
53
+ if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
54
+ const reset = parseProviderResetHint(providerMessage, now);
55
+ const provider = stringDetail(message.provider);
56
+ const model = stringDetail(message.model);
57
+ const api = stringDetail(message.api);
58
+ return new WorkflowProviderUsageLimitError({
59
+ stopReason: "error",
60
+ providerMessage,
61
+ ...(provider ? { provider } : {}),
62
+ ...(model ? { model } : {}),
63
+ ...(api ? { api } : {}),
64
+ ...reset,
65
+ });
66
+ }
67
+
68
+ export function createProviderUsageLimitPauseRecord(
69
+ error: WorkflowProviderUsageLimitError,
70
+ options: ResolvedWorkflowRunOptions,
71
+ replayableInvocation: boolean,
72
+ now = Date.now(),
73
+ ): ProviderUsageLimitPauseRecord {
74
+ const attempt = options.usageLimitAttempt + 1;
75
+ const hintedDelay = error.details.resetAt === undefined
76
+ ? WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS
77
+ : error.details.resetAt - now;
78
+ const delayMs = Math.min(
79
+ options.usageLimitMaxDelayMs,
80
+ Math.max(
81
+ WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
82
+ hintedDelay > 0 ? hintedDelay : WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS,
83
+ ),
84
+ );
85
+ const autoResume = options.autoResumeOnUsageLimit
86
+ && attempt < options.usageLimitMaxAttempts
87
+ && replayableInvocation;
88
+ return {
89
+ message: autoResume
90
+ ? `Provider usage limit paused this run; automatic attempt ${attempt + 1}/${options.usageLimitMaxAttempts} is scheduled.`
91
+ : `Provider usage limit paused this run at attempt ${attempt}/${options.usageLimitMaxAttempts}.`,
92
+ pause: {
93
+ kind: "provider_usage_limit",
94
+ reason: "provider_usage_limit",
95
+ providerMessage: error.details.providerMessage,
96
+ provider: error.details.provider,
97
+ model: error.details.model,
98
+ api: error.details.api,
99
+ resetHint: error.details.resetHint,
100
+ attempt,
101
+ nextEligibleAt: now + delayMs,
102
+ autoResume,
103
+ maxAttempts: options.usageLimitMaxAttempts,
104
+ },
105
+ };
106
+ }
107
+
108
+ export function parseProviderResetHint(
109
+ message: string,
110
+ now = Date.now(),
111
+ ): { readonly resetHint?: string; readonly resetAt?: number } {
112
+ const iso = message.match(
113
+ /(?:[a-z-]*reset[a-z-]*|resets?\s+at|available\s+again\s+at)\s*[:=]?\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))/i,
114
+ )?.[1];
115
+ if (iso) {
116
+ const parsed = Date.parse(iso);
117
+ return Number.isFinite(parsed) && parsed > now
118
+ ? { resetHint: boundedHint(iso), resetAt: parsed }
119
+ : { resetHint: boundedHint(iso) };
120
+ }
121
+
122
+ const labelled = message.match(
123
+ /(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)?\s*(?:milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\s*)+)/i,
124
+ );
125
+ if (labelled?.[1]) {
126
+ const delayMs = parseDurationMs(labelled[1]);
127
+ return delayMs === undefined
128
+ ? { resetHint: boundedHint(labelled[0]) }
129
+ : { resetHint: boundedHint(labelled[0]), resetAt: now + delayMs };
130
+ }
131
+
132
+ const retryAfter = message.match(/retry[- ]after\s*[:=]\s*([^\s,;]+)/i);
133
+ if (retryAfter?.[1]) {
134
+ const seconds = Number(retryAfter[1]);
135
+ return Number.isFinite(seconds) && seconds >= 0
136
+ ? { resetHint: boundedHint(retryAfter[0]), resetAt: now + seconds * 1_000 }
137
+ : { resetHint: boundedHint(retryAfter[0]) };
138
+ }
139
+ return {};
140
+ }
141
+
142
+ function isUsageLimitMessage(message: string): boolean {
143
+ return /(?:GoUsageLimitError|FreeUsageLimitError|insufficient_quota|rate_limit_error|ResourceExhausted)/i.test(message)
144
+ || /\btoo many requests\b/i.test(message)
145
+ || /\b(?:http|status(?: code)?|error)\s*[:=]?\s*429\b/i.test(message)
146
+ || /\b429\b[^\n]*(?:rate|requests?|quota|limit)/i.test(message)
147
+ || /\b(?:usage|quota|monthly|weekly|daily)\s+(?:window\s+)?limit\b/i.test(message)
148
+ || /\bquota\s+(?:has\s+been\s+)?exceeded\b/i.test(message)
149
+ || /\bexceeded\b[^\n]*\bquota\b/i.test(message)
150
+ || /\b(?:usage|rate)\s+limit\s+(?:(?:has\s+been|was)\s+)?(?:reached|exceeded)\b/i.test(message);
151
+ }
152
+
153
+ function parseDurationMs(value: string): number | undefined {
154
+ const unitPattern = /(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)/gi;
155
+ let total = 0;
156
+ let matched = "";
157
+ for (const part of value.matchAll(unitPattern)) {
158
+ const amount = Number(part[1]);
159
+ const unit = part[2]?.toLowerCase();
160
+ if (!Number.isFinite(amount) || !unit) return undefined;
161
+ matched += part[0];
162
+ if (unit === "ms" || unit.startsWith("millisecond")) total += amount;
163
+ else if (unit === "s" || unit.startsWith("sec")) total += amount * 1_000;
164
+ else if (unit === "m" || unit.startsWith("min")) total += amount * 60_000;
165
+ else if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) total += amount * 3_600_000;
166
+ else total += amount * 86_400_000;
167
+ }
168
+ if (!matched || value.replace(/\s+/g, "") !== matched.replace(/\s+/g, "")) return undefined;
169
+ return Number.isFinite(total) && total >= 0 ? total : undefined;
170
+ }
171
+
172
+ function boundedHint(value: string): string {
173
+ return value.length <= MAX_RESET_HINT_LENGTH ? value : `${value.slice(0, MAX_RESET_HINT_LENGTH - 1)}…`;
174
+ }
175
+
176
+ function isAssistantMessage(value: unknown): value is {
177
+ readonly role: "assistant";
178
+ readonly stopReason?: unknown;
179
+ readonly errorMessage?: unknown;
180
+ readonly provider?: unknown;
181
+ readonly model?: unknown;
182
+ readonly api?: unknown;
183
+ } {
184
+ return typeof value === "object" && value !== null && "role" in value && value.role === "assistant";
185
+ }
186
+
187
+ function stringDetail(value: unknown): string | undefined {
188
+ return typeof value === "string" && value.length > 0 ? value : undefined;
189
+ }
@@ -0,0 +1,48 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { isPathWithin } from "./tree-fingerprint.ts";
3
+
4
+ export interface ReplayWorkspaceRoots {
5
+ readonly sessionCwd: string;
6
+ readonly workspaceRoot: string;
7
+ }
8
+
9
+ /** Map a path in either the disposable session or main workspace to one stable namespace. */
10
+ export function logicalWorkspacePath(path: string, roots: ReplayWorkspaceRoots): string | undefined {
11
+ for (const root of resolvedWorkspaceRoots(roots)) {
12
+ if (isPathWithin(root, path)) return `workspace:${portableRelativePath(root, path)}`;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /** Normalize absolute workspace paths embedded in generated system prompts. */
18
+ export function normalizeWorkspaceReferences(value: string, roots: ReplayWorkspaceRoots): string {
19
+ const paths = [...resolvedWorkspaceRoots(roots)].sort((left, right) => right.length - left.length);
20
+ return paths.reduce((normalized, path) => replacePathPrefix(normalized, path, "<workspace>"), value);
21
+ }
22
+
23
+ export function portableRelativePath(root: string, path: string): string {
24
+ const value = relative(root, path).split(sep).join("/");
25
+ return value.length === 0 ? "." : value;
26
+ }
27
+
28
+ function resolvedWorkspaceRoots(roots: ReplayWorkspaceRoots): readonly string[] {
29
+ return [...new Set([resolve(roots.sessionCwd), resolve(roots.workspaceRoot)])];
30
+ }
31
+
32
+ function replacePathPrefix(value: string, prefix: string, replacement: string): string {
33
+ let result = "";
34
+ let cursor = 0;
35
+ while (cursor < value.length) {
36
+ const index = value.indexOf(prefix, cursor);
37
+ if (index === -1) return result + value.slice(cursor);
38
+ const next = value[index + prefix.length];
39
+ if (next !== undefined && next !== "/" && next !== "\\" && /[\p{L}\p{N}._-]/u.test(next)) {
40
+ result += value.slice(cursor, index + prefix.length);
41
+ cursor = index + prefix.length;
42
+ continue;
43
+ }
44
+ result += value.slice(cursor, index) + replacement;
45
+ cursor = index + prefix.length;
46
+ }
47
+ return result;
48
+ }
@@ -0,0 +1,89 @@
1
+ import { Type, type Static } from "typebox";
2
+
3
+ export const MAX_RESEARCH_LANES = 4;
4
+ export const MAX_LANE_EVIDENCE = 6;
5
+ export const MAX_VERIFICATION_CLAIMS = 12;
6
+
7
+ export const ResearchSourceSchema = Type.Object({
8
+ title: Type.String({ minLength: 1, description: "Human-readable title of the supporting page." }),
9
+ url: Type.String({ minLength: 1, description: "Direct HTTP(S) URL of the supporting page, never a search-results URL." }),
10
+ publishedAt: Type.Optional(Type.String({ description: "Publication or update date when the page states one." })),
11
+ });
12
+
13
+ export type ResearchSource = Static<typeof ResearchSourceSchema>;
14
+
15
+ export const ResearchPlanSchema = Type.Object({
16
+ scopeConstraints: Type.Array(Type.String()),
17
+ lanes: Type.Array(
18
+ Type.Object({
19
+ id: Type.String({ minLength: 1 }),
20
+ title: Type.String({ minLength: 1 }),
21
+ objective: Type.String({ minLength: 1 }),
22
+ queries: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 4 }),
23
+ }),
24
+ { minItems: 1, maxItems: MAX_RESEARCH_LANES },
25
+ ),
26
+ });
27
+
28
+ export type ResearchPlan = Static<typeof ResearchPlanSchema>;
29
+ export type ResearchLane = ResearchPlan["lanes"][number];
30
+
31
+ export const ResearchEvidenceSchema = Type.Object({
32
+ claim: Type.String({ minLength: 1 }),
33
+ importance: Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")]),
34
+ stance: Type.Union([Type.Literal("supports"), Type.Literal("conflicts")]),
35
+ evidence: Type.String({ minLength: 1, description: "A short supporting passage or precise paraphrase from the page." }),
36
+ source: ResearchSourceSchema,
37
+ });
38
+
39
+ export type ResearchEvidence = Static<typeof ResearchEvidenceSchema>;
40
+
41
+ export const ResearchLaneResultSchema = Type.Object({
42
+ laneId: Type.String({ minLength: 1 }),
43
+ evidence: Type.Array(ResearchEvidenceSchema, { maxItems: MAX_LANE_EVIDENCE }),
44
+ gaps: Type.Array(Type.String()),
45
+ });
46
+
47
+ export type ResearchLaneResult = Static<typeof ResearchLaneResultSchema>;
48
+
49
+ export interface ResearchClaimCandidate {
50
+ readonly claim: string;
51
+ readonly importance: ResearchEvidence["importance"];
52
+ readonly evidence: readonly ResearchEvidence[];
53
+ }
54
+
55
+ export const ResearchVerificationSchema = Type.Object({
56
+ claim: Type.String({ minLength: 1 }),
57
+ verdict: Type.Union([
58
+ Type.Literal("SUPPORTED"),
59
+ Type.Literal("CONFLICTED"),
60
+ Type.Literal("UNCERTAIN"),
61
+ Type.Literal("INFERENCE"),
62
+ Type.Literal("REJECTED"),
63
+ ]),
64
+ explanation: Type.String({ minLength: 1 }),
65
+ sources: Type.Array(ResearchSourceSchema, { maxItems: 8 }),
66
+ });
67
+
68
+ export type ResearchVerification = Static<typeof ResearchVerificationSchema>;
69
+
70
+ export const ResearchReportEntrySchema = Type.Object({
71
+ claim: Type.String({ minLength: 1 }),
72
+ explanation: Type.String({ minLength: 1 }),
73
+ citations: Type.Array(ResearchSourceSchema, { maxItems: 8 }),
74
+ });
75
+
76
+ export type ResearchReportEntry = Static<typeof ResearchReportEntrySchema>;
77
+
78
+ export const ResearchReportSchema = Type.Object({
79
+ answer: Type.String(),
80
+ supportedClaims: Type.Array(ResearchReportEntrySchema),
81
+ conflictingEvidence: Type.Array(ResearchReportEntrySchema),
82
+ uncertainties: Type.Array(ResearchReportEntrySchema),
83
+ inferences: Type.Array(ResearchReportEntrySchema),
84
+ sources: Type.Array(ResearchSourceSchema),
85
+ limitations: Type.Array(Type.String()),
86
+ nextSteps: Type.Array(Type.String()),
87
+ });
88
+
89
+ export type ResearchReport = Static<typeof ResearchReportSchema>;
@@ -0,0 +1,289 @@
1
+ import {
2
+ MAX_RESEARCH_LANES,
3
+ MAX_VERIFICATION_CLAIMS,
4
+ type ResearchClaimCandidate,
5
+ type ResearchEvidence,
6
+ type ResearchLane,
7
+ type ResearchLaneResult,
8
+ type ResearchPlan,
9
+ type ResearchReport,
10
+ type ResearchReportEntry,
11
+ type ResearchSource,
12
+ type ResearchVerification,
13
+ } from "./research-contract.ts";
14
+
15
+ const TRACKING_PARAMETERS = new Set(["fbclid", "gclid", "mc_cid", "mc_eid", "ref_src"]);
16
+ const IMPORTANCE_RANK: Readonly<Record<ResearchEvidence["importance"], number>> = { high: 0, medium: 1, low: 2 };
17
+
18
+ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
19
+ const seen = new Set<string>();
20
+ const lanes: ResearchLane[] = [];
21
+ for (const lane of plan.lanes) {
22
+ const id = normalizedText(lane.id);
23
+ const title = normalizedText(lane.title);
24
+ const objective = normalizedText(lane.objective);
25
+ const queries = dedupeText(lane.queries).slice(0, 4);
26
+ if (!id || !title || !objective || queries.length === 0 || seen.has(id.toLowerCase())) continue;
27
+ seen.add(id.toLowerCase());
28
+ lanes.push({ id, title, objective, queries });
29
+ if (lanes.length >= MAX_RESEARCH_LANES) break;
30
+ }
31
+ return lanes;
32
+ }
33
+
34
+ export function sanitizeLaneResults(results: readonly ResearchLaneResult[]): ResearchLaneResult[] {
35
+ return results.map((result) => ({
36
+ laneId: normalizedText(result.laneId),
37
+ gaps: dedupeText(result.gaps),
38
+ evidence: dedupeEvidence(result.evidence.map(sanitizeEvidence).filter(isDefined)),
39
+ }));
40
+ }
41
+
42
+ export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
43
+ const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
44
+ for (const item of results.flatMap((result) => result.evidence)) {
45
+ const key = normalizedText(item.claim).toLowerCase();
46
+ if (!key) continue;
47
+ const current = byClaim.get(key);
48
+ if (!current) {
49
+ byClaim.set(key, { claim: normalizedText(item.claim), importance: item.importance, evidence: [item] });
50
+ continue;
51
+ }
52
+ if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
53
+ current.evidence = dedupeEvidence([...current.evidence, item]);
54
+ }
55
+ return [...byClaim.values()]
56
+ .sort((left, right) => IMPORTANCE_RANK[left.importance] - IMPORTANCE_RANK[right.importance])
57
+ .slice(0, MAX_VERIFICATION_CLAIMS);
58
+ }
59
+
60
+ export function sanitizeVerification(
61
+ verification: ResearchVerification,
62
+ candidate: ResearchClaimCandidate,
63
+ ): ResearchVerification {
64
+ const sources = dedupeSources(verification.sources);
65
+ const verdict = (verification.verdict === "SUPPORTED" || verification.verdict === "CONFLICTED") && sources.length === 0
66
+ ? "UNCERTAIN"
67
+ : verification.verdict;
68
+ return {
69
+ claim: candidate.claim,
70
+ verdict,
71
+ explanation: normalizedText(verification.explanation) || "Independent verification returned no explanation.",
72
+ sources,
73
+ };
74
+ }
75
+
76
+ export function unavailableVerification(candidate: ResearchClaimCandidate): ResearchVerification {
77
+ return {
78
+ claim: candidate.claim,
79
+ verdict: "UNCERTAIN",
80
+ explanation: "Independent verification did not complete; gathered evidence remains unverified.",
81
+ sources: dedupeSources(candidate.evidence.map((item) => item.source)),
82
+ };
83
+ }
84
+
85
+ export function sanitizeResearchReport(
86
+ report: ResearchReport,
87
+ verifications: readonly ResearchVerification[],
88
+ ): ResearchReport {
89
+ const sources = dedupeSources(
90
+ verifications
91
+ .filter((verification) => verification.verdict !== "REJECTED")
92
+ .flatMap((verification) => verification.sources),
93
+ );
94
+ const uncertainties = sanitizeEntries(
95
+ report.uncertainties,
96
+ verificationSources(verifications, "UNCERTAIN"),
97
+ false,
98
+ );
99
+ const supportedClaims = sanitizeEntries(
100
+ report.supportedClaims,
101
+ verificationSources(verifications, "SUPPORTED"),
102
+ true,
103
+ uncertainties,
104
+ );
105
+ const conflictingEvidence = sanitizeEntries(
106
+ report.conflictingEvidence,
107
+ verificationSources(verifications, "CONFLICTED"),
108
+ true,
109
+ uncertainties,
110
+ );
111
+ const inferences = sanitizeEntries(
112
+ report.inferences,
113
+ verificationSources(verifications, "INFERENCE"),
114
+ false,
115
+ );
116
+ return {
117
+ answer: normalizedText(report.answer),
118
+ supportedClaims,
119
+ conflictingEvidence,
120
+ uncertainties,
121
+ inferences,
122
+ sources,
123
+ limitations: dedupeText(report.limitations),
124
+ nextSteps: dedupeText(report.nextSteps),
125
+ };
126
+ }
127
+
128
+ function verificationSources(
129
+ verifications: readonly ResearchVerification[],
130
+ verdict: ResearchVerification["verdict"],
131
+ ): ReadonlyMap<string, ReadonlyMap<string, ResearchSource>> {
132
+ const byClaim = new Map<string, Map<string, ResearchSource>>();
133
+ for (const verification of verifications) {
134
+ if (verification.verdict !== verdict) continue;
135
+ const key = normalizedText(verification.claim).toLowerCase();
136
+ const claimSources = byClaim.get(key) ?? new Map<string, ResearchSource>();
137
+ dedupeSources(
138
+ verification.sources,
139
+ ).forEach((source) => claimSources.set(source.url, source));
140
+ byClaim.set(key, claimSources);
141
+ }
142
+ return byClaim;
143
+ }
144
+
145
+ export function fallbackResearchReport(
146
+ verifications: readonly ResearchVerification[],
147
+ limitation: string,
148
+ ): ResearchReport {
149
+ const entry = (verification: ResearchVerification): ResearchReportEntry => ({
150
+ claim: verification.claim,
151
+ explanation: verification.explanation,
152
+ citations: verification.sources,
153
+ });
154
+ return {
155
+ answer: "The synthesis stage did not complete; independently verified claims are grouped below without additional model interpretation.",
156
+ supportedClaims: verifications.filter((item) => item.verdict === "SUPPORTED").map(entry),
157
+ conflictingEvidence: verifications.filter((item) => item.verdict === "CONFLICTED").map(entry),
158
+ uncertainties: verifications.filter((item) => item.verdict === "UNCERTAIN").map(entry),
159
+ inferences: verifications.filter((item) => item.verdict === "INFERENCE").map(entry),
160
+ sources: dedupeSources(verifications.flatMap((verification) => verification.sources)),
161
+ limitations: [limitation],
162
+ nextSteps: ["Review the verified claim groups and rerun synthesis if a narrative answer is required."],
163
+ };
164
+ }
165
+
166
+ export function unavailableResearchReport(reason: "empty-question" | "missing-capability" | "no-evidence"): ResearchReport {
167
+ const message = reason === "empty-question"
168
+ ? "No research question was provided."
169
+ : reason === "missing-capability"
170
+ ? "Research could not start because pi exposed no installed external web-search or URL-extraction tool."
171
+ : "Research completed without enough direct-page evidence to support an answer.";
172
+ const nextStep = reason === "empty-question"
173
+ ? "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments."
174
+ : reason === "missing-capability"
175
+ ? "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow."
176
+ : "Check the installed external-search tool, narrow the question, or provide preferred source domains.";
177
+ return {
178
+ answer: message,
179
+ supportedClaims: [],
180
+ conflictingEvidence: [],
181
+ uncertainties: [],
182
+ inferences: [],
183
+ sources: [],
184
+ limitations: [message],
185
+ nextSteps: [nextStep],
186
+ };
187
+ }
188
+
189
+ export function canonicalSourceUrl(raw: string): string | undefined {
190
+ try {
191
+ const url = new URL(raw.trim());
192
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return undefined;
193
+ if (isSearchResultsUrl(url)) return undefined;
194
+ url.hash = "";
195
+ for (const key of [...url.searchParams.keys()]) {
196
+ if (key.toLowerCase().startsWith("utm_") || TRACKING_PARAMETERS.has(key.toLowerCase())) url.searchParams.delete(key);
197
+ }
198
+ url.searchParams.sort();
199
+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) url.pathname = url.pathname.slice(0, -1);
200
+ return url.toString();
201
+ } catch {
202
+ return undefined;
203
+ }
204
+ }
205
+
206
+ export function dedupeSources(sources: readonly ResearchSource[]): ResearchSource[] {
207
+ const byUrl = new Map<string, ResearchSource>();
208
+ for (const source of sources) {
209
+ const url = canonicalSourceUrl(source.url);
210
+ const title = normalizedText(source.title);
211
+ if (!url || !title || byUrl.has(url)) continue;
212
+ const publishedAt = source.publishedAt === undefined ? undefined : normalizedText(source.publishedAt);
213
+ byUrl.set(url, { title, url, ...(publishedAt ? { publishedAt } : {}) });
214
+ }
215
+ return [...byUrl.values()];
216
+ }
217
+
218
+ function sanitizeEvidence(item: ResearchEvidence): ResearchEvidence | undefined {
219
+ const source = dedupeSources([item.source])[0];
220
+ const claim = normalizedText(item.claim);
221
+ const evidence = normalizedText(item.evidence);
222
+ if (!source || !claim || !evidence) return undefined;
223
+ return { claim, importance: item.importance, stance: item.stance, evidence, source };
224
+ }
225
+
226
+ function dedupeEvidence(evidence: readonly ResearchEvidence[]): ResearchEvidence[] {
227
+ const seen = new Set<string>();
228
+ return evidence.filter((item) => {
229
+ const key = `${item.claim.toLowerCase()}\0${item.stance}\0${item.source.url}`;
230
+ if (seen.has(key)) return false;
231
+ seen.add(key);
232
+ return true;
233
+ });
234
+ }
235
+
236
+ function sanitizeEntries(
237
+ entries: readonly ResearchReportEntry[],
238
+ allowedByClaim: ReadonlyMap<string, ReadonlyMap<string, ResearchSource>>,
239
+ requireCitation: boolean,
240
+ demoted: ResearchReportEntry[] = [],
241
+ ): ResearchReportEntry[] {
242
+ const sanitized: ResearchReportEntry[] = [];
243
+ for (const entry of entries) {
244
+ const claim = normalizedText(entry.claim);
245
+ const explanation = normalizedText(entry.explanation);
246
+ if (!claim || !explanation) continue;
247
+ const allowed = allowedByClaim.get(claim.toLowerCase()) ?? new Map<string, ResearchSource>();
248
+ const citations = dedupeSources(entry.citations)
249
+ .map((source) => allowed.get(source.url))
250
+ .filter(isDefined);
251
+ if (requireCitation && citations.length === 0) {
252
+ demoted.push({ claim, explanation: `${explanation} No verified direct-page citation survived validation.`, citations: [] });
253
+ continue;
254
+ }
255
+ sanitized.push({ claim, explanation, citations });
256
+ }
257
+ return sanitized;
258
+ }
259
+
260
+ function isSearchResultsUrl(url: URL): boolean {
261
+ const host = url.hostname.toLowerCase().replace(/^www\./, "");
262
+ const path = url.pathname.replace(/\/+$/, "") || "/";
263
+ if (/^(?:google\.[a-z.]+|bing\.com|search\.yahoo\.com|search\.brave\.com|yandex\.[a-z.]+)$/.test(host)) {
264
+ return path === "/search" || path === "/results" || path === "/yandsearch";
265
+ }
266
+ if (host === "duckduckgo.com") return (path === "/" || path === "/html" || path === "/lite") && url.searchParams.has("q");
267
+ return false;
268
+ }
269
+
270
+ function dedupeText(values: readonly string[]): string[] {
271
+ const seen = new Set<string>();
272
+ const result: string[] = [];
273
+ for (const value of values) {
274
+ const normalized = normalizedText(value);
275
+ const key = normalized.toLowerCase();
276
+ if (!normalized || seen.has(key)) continue;
277
+ seen.add(key);
278
+ result.push(normalized);
279
+ }
280
+ return result;
281
+ }
282
+
283
+ function normalizedText(value: string): string {
284
+ return value.trim().replace(/\s+/g, " ");
285
+ }
286
+
287
+ function isDefined<T>(value: T | undefined): value is T {
288
+ return value !== undefined;
289
+ }