@jameslovespancakes/pi-plus 1.0.11 → 1.0.12

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 (120) hide show
  1. package/README.md +44 -20
  2. package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
  3. package/package.json +6 -3
  4. package/src/core/accounts/oauth-pool.ts +102 -0
  5. package/src/core/accounts/registry.ts +8 -25
  6. package/src/core/accounts/routing.ts +122 -0
  7. package/src/core/anthropic/client-identity.ts +9 -64
  8. package/src/core/anthropic/quota.ts +8 -53
  9. package/src/core/anthropic/routing.ts +40 -115
  10. package/src/core/anthropic/store.ts +7 -9
  11. package/src/core/catalog/quality.ts +33 -15
  12. package/src/core/codex/quota.ts +8 -2
  13. package/src/core/codex/store.ts +12 -9
  14. package/src/core/config.ts +4 -17
  15. package/src/core/store.ts +13 -12
  16. package/src/domains/models/provider-picker.ts +1 -1
  17. package/src/domains/setup/index.ts +23 -23
  18. package/src/domains/subscriptions/accounts-picker.ts +18 -37
  19. package/src/domains/subscriptions/accounts.ts +23 -29
  20. package/src/domains/subscriptions/footer.ts +48 -29
  21. package/src/domains/subscriptions/index.ts +11 -27
  22. package/src/domains/subscriptions/provider.ts +38 -103
  23. package/src/domains/subscriptions/providers/anthropic.ts +5 -17
  24. package/src/domains/subscriptions/providers/codex.ts +171 -4
  25. package/src/domains/subscriptions/providers/hosted.ts +18 -0
  26. package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
  27. package/src/domains/subscriptions/routing.ts +19 -11
  28. package/src/domains/workflows/LICENSE.md +21 -0
  29. package/src/domains/workflows/index.ts +836 -0
  30. package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
  31. package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
  32. package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
  33. package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
  34. package/src/domains/workflows/runtime/agent-limits.ts +43 -0
  35. package/src/domains/workflows/runtime/agent-replay.ts +399 -0
  36. package/src/domains/workflows/runtime/agent-retry.ts +116 -0
  37. package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
  38. package/src/domains/workflows/runtime/agent-runner.ts +203 -0
  39. package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
  40. package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
  41. package/src/domains/workflows/runtime/agent-session.ts +382 -0
  42. package/src/domains/workflows/runtime/agent-skills.ts +270 -0
  43. package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
  44. package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
  45. package/src/domains/workflows/runtime/background-workflows.ts +492 -0
  46. package/src/domains/workflows/runtime/budget.ts +53 -0
  47. package/src/domains/workflows/runtime/cancellation.ts +87 -0
  48. package/src/domains/workflows/runtime/command-completions.ts +36 -0
  49. package/src/domains/workflows/runtime/concurrency.ts +403 -0
  50. package/src/domains/workflows/runtime/debug.ts +3 -0
  51. package/src/domains/workflows/runtime/diff-capture.ts +81 -0
  52. package/src/domains/workflows/runtime/discovery.ts +137 -0
  53. package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
  54. package/src/domains/workflows/runtime/dynamax.ts +330 -0
  55. package/src/domains/workflows/runtime/engine.ts +543 -0
  56. package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
  57. package/src/domains/workflows/runtime/finalizers.ts +66 -0
  58. package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
  59. package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
  60. package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
  61. package/src/domains/workflows/runtime/journal.ts +313 -0
  62. package/src/domains/workflows/runtime/model-profiles.ts +310 -0
  63. package/src/domains/workflows/runtime/options.ts +157 -0
  64. package/src/domains/workflows/runtime/perf.ts +146 -0
  65. package/src/domains/workflows/runtime/pi-compat.ts +32 -0
  66. package/src/domains/workflows/runtime/process-runner.ts +260 -0
  67. package/src/domains/workflows/runtime/progress-types.ts +59 -0
  68. package/src/domains/workflows/runtime/progress.ts +400 -0
  69. package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
  70. package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
  71. package/src/domains/workflows/runtime/research-contract.ts +89 -0
  72. package/src/domains/workflows/runtime/research-evidence.ts +289 -0
  73. package/src/domains/workflows/runtime/resume-context.ts +751 -0
  74. package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
  75. package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
  76. package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
  77. package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
  78. package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
  79. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
  80. package/src/domains/workflows/runtime/review/review-format.ts +133 -0
  81. package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
  82. package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
  83. package/src/domains/workflows/runtime/review/review-report.ts +42 -0
  84. package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
  85. package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
  86. package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
  87. package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
  88. package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
  89. package/src/domains/workflows/runtime/session-identity.ts +6 -0
  90. package/src/domains/workflows/runtime/structured-output.ts +32 -0
  91. package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
  92. package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
  93. package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
  94. package/src/domains/workflows/runtime/types.ts +255 -0
  95. package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
  96. package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
  97. package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
  98. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
  99. package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
  100. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
  101. package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
  102. package/src/domains/workflows/runtime/unknown-error.ts +8 -0
  103. package/src/domains/workflows/runtime/usage.ts +341 -0
  104. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
  105. package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
  106. package/src/domains/workflows/runtime/workflow-module.ts +75 -0
  107. package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
  108. package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
  109. package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
  110. package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
  111. package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
  112. package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
  113. package/src/domains/workflows/runtime/workflows.ts +40 -0
  114. package/src/domains/workflows/runtime/worktree.ts +581 -0
  115. package/src/domains/workflows/workflows/code-review.ts +232 -0
  116. package/src/domains/workflows/workflows/diagnose.ts +154 -0
  117. package/src/domains/workflows/workflows/perf-review.ts +149 -0
  118. package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
  119. package/src/domains/workflows/workflows/research.ts +169 -0
  120. package/src/services/usage-service.ts +6 -30
@@ -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 Array.from(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
+ }