@demigodmode/pi-web-agent 0.3.1 → 0.5.1

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.
@@ -1,14 +1,82 @@
1
- const WINDOWS_CANDIDATES = {
2
- chrome: [
3
- 'C:/Program Files/Google/Chrome/Application/chrome.exe',
4
- 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
5
- ],
6
- edge: [
7
- 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
8
- 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
9
- ]
10
- };
11
- export async function resolveBrowserExecutable({ configuredPath, fileExists = defaultFileExists }) {
1
+ const WINDOWS_CANDIDATES = [
2
+ {
3
+ browser: 'chrome',
4
+ paths: [
5
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
6
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
7
+ ],
8
+ commands: ['chrome.exe', 'chrome', 'google-chrome']
9
+ },
10
+ {
11
+ browser: 'edge',
12
+ paths: [
13
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
14
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
15
+ ],
16
+ commands: ['msedge.exe', 'msedge', 'microsoft-edge']
17
+ },
18
+ {
19
+ browser: 'brave',
20
+ paths: [
21
+ 'C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe',
22
+ 'C:/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe'
23
+ ],
24
+ commands: ['brave.exe', 'brave']
25
+ },
26
+ {
27
+ browser: 'chromium',
28
+ paths: [
29
+ 'C:/Program Files/Chromium/Application/chrome.exe',
30
+ 'C:/Program Files (x86)/Chromium/Application/chrome.exe'
31
+ ],
32
+ commands: ['chromium.exe', 'chromium']
33
+ }
34
+ ];
35
+ const MACOS_CANDIDATES = [
36
+ {
37
+ browser: 'chrome',
38
+ paths: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
39
+ commands: ['google-chrome', 'chrome']
40
+ },
41
+ {
42
+ browser: 'edge',
43
+ paths: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
44
+ commands: ['microsoft-edge', 'msedge']
45
+ },
46
+ {
47
+ browser: 'brave',
48
+ paths: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
49
+ commands: ['brave-browser', 'brave']
50
+ },
51
+ {
52
+ browser: 'chromium',
53
+ paths: ['/Applications/Chromium.app/Contents/MacOS/Chromium'],
54
+ commands: ['chromium', 'chromium-browser']
55
+ }
56
+ ];
57
+ const LINUX_CANDIDATES = [
58
+ {
59
+ browser: 'chrome',
60
+ paths: ['/usr/bin/google-chrome', '/usr/local/bin/google-chrome', '/opt/google/chrome/chrome'],
61
+ commands: ['google-chrome', 'google-chrome-stable', 'chrome']
62
+ },
63
+ {
64
+ browser: 'edge',
65
+ paths: ['/usr/bin/microsoft-edge', '/opt/microsoft/msedge/msedge'],
66
+ commands: ['microsoft-edge', 'microsoft-edge-stable', 'msedge']
67
+ },
68
+ {
69
+ browser: 'brave',
70
+ paths: ['/usr/bin/brave-browser', '/usr/local/bin/brave-browser', '/opt/brave.com/brave/brave'],
71
+ commands: ['brave-browser', 'brave']
72
+ },
73
+ {
74
+ browser: 'chromium',
75
+ paths: ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
76
+ commands: ['chromium', 'chromium-browser']
77
+ }
78
+ ];
79
+ export async function resolveBrowserExecutable({ configuredPath, platform = process.platform, env = process.env, fileExists = defaultFileExists }) {
12
80
  if (configuredPath) {
13
81
  if (await fileExists(configuredPath)) {
14
82
  return {
@@ -25,14 +93,19 @@ export async function resolveBrowserExecutable({ configuredPath, fileExists = de
25
93
  }
26
94
  };
27
95
  }
28
- for (const path of WINDOWS_CANDIDATES.chrome) {
29
- if (await fileExists(path)) {
30
- return { ok: true, executablePath: path, browser: 'chrome' };
96
+ const candidates = getCandidatesForPlatform(platform);
97
+ for (const candidate of candidates) {
98
+ for (const path of candidate.paths) {
99
+ if (await fileExists(path)) {
100
+ return { ok: true, executablePath: path, browser: candidate.browser };
101
+ }
31
102
  }
32
103
  }
33
- for (const path of WINDOWS_CANDIDATES.edge) {
34
- if (await fileExists(path)) {
35
- return { ok: true, executablePath: path, browser: 'edge' };
104
+ for (const candidate of candidates) {
105
+ for (const path of getPathCommandCandidates(candidate.commands, platform, env)) {
106
+ if (await fileExists(path)) {
107
+ return { ok: true, executablePath: path, browser: candidate.browser };
108
+ }
36
109
  }
37
110
  }
38
111
  return {
@@ -43,6 +116,27 @@ export async function resolveBrowserExecutable({ configuredPath, fileExists = de
43
116
  }
44
117
  };
45
118
  }
119
+ function getCandidatesForPlatform(platform) {
120
+ if (platform === 'win32')
121
+ return WINDOWS_CANDIDATES;
122
+ if (platform === 'darwin')
123
+ return MACOS_CANDIDATES;
124
+ if (platform === 'linux')
125
+ return LINUX_CANDIDATES;
126
+ return [...WINDOWS_CANDIDATES, ...MACOS_CANDIDATES, ...LINUX_CANDIDATES];
127
+ }
128
+ function getPathCommandCandidates(commands, platform, env) {
129
+ const pathValue = env.PATH ?? env.Path ?? env.path;
130
+ if (!pathValue)
131
+ return [];
132
+ const delimiter = platform === 'win32' ? ';' : ':';
133
+ const dirs = pathValue.split(delimiter).filter(Boolean);
134
+ const extensions = platform === 'win32' ? ['', '.exe'] : [''];
135
+ return dirs.flatMap((dir) => commands.flatMap((command) => extensions.map((extension) => {
136
+ const normalizedCommand = command.toLowerCase().endsWith(extension) ? command : `${command}${extension}`;
137
+ return `${dir.replace(/[\\/]$/, '')}/${normalizedCommand}`;
138
+ })));
139
+ }
46
140
  async function defaultFileExists(path) {
47
141
  try {
48
142
  const { access } = await import('node:fs/promises');
@@ -0,0 +1,8 @@
1
+ import type { ResearchEvidence } from './research-types.js';
2
+ export declare function synthesizeAnswer({ evidence, partial }: {
3
+ evidence: ResearchEvidence[];
4
+ partial: boolean;
5
+ }): {
6
+ findings: string[];
7
+ caveat: string | undefined;
8
+ };
@@ -0,0 +1,17 @@
1
+ function normalizeSummary(summary) {
2
+ return summary.replace(/\s+/g, ' ').trim();
3
+ }
4
+ export function synthesizeAnswer({ evidence, partial }) {
5
+ const findings = evidence.slice(0, 5).map((item) => {
6
+ const summary = normalizeSummary(item.summary);
7
+ return item.sourceKind === 'community' || item.sourceKind === 'issue-thread'
8
+ ? `Community/practical context: ${summary}`
9
+ : summary;
10
+ });
11
+ return {
12
+ findings,
13
+ caveat: partial
14
+ ? 'Evidence is partial, so this answer is based on the strongest source found within the bounded research budget.'
15
+ : undefined
16
+ };
17
+ }
@@ -0,0 +1,6 @@
1
+ import type { SearchResult } from '../types.js';
2
+ export declare function selectCandidates({ results, seenUrls, maxCandidates }: {
3
+ results: SearchResult[];
4
+ seenUrls: Set<string>;
5
+ maxCandidates: number;
6
+ }): SearchResult[];
@@ -0,0 +1,24 @@
1
+ function candidateScore(result) {
2
+ const url = result.url.toLowerCase();
3
+ if (url.includes('playwright.dev/docs') || url.includes('vitest.dev/guide') || url.includes('learn.microsoft.com'))
4
+ return 0;
5
+ if (url.includes('github.com/') && (url.includes('/issues/') || url.includes('/discussions/')))
6
+ return 1;
7
+ if (url.includes('github.com/'))
8
+ return 2;
9
+ if (url.includes('npmjs.com/package/'))
10
+ return 4;
11
+ return 3;
12
+ }
13
+ export function selectCandidates({ results, seenUrls, maxCandidates }) {
14
+ const deduped = new Map();
15
+ for (const result of results) {
16
+ if (seenUrls.has(result.url))
17
+ continue;
18
+ if (!deduped.has(result.url))
19
+ deduped.set(result.url, result);
20
+ }
21
+ return [...deduped.values()]
22
+ .sort((left, right) => candidateScore(left) - candidateScore(right))
23
+ .slice(0, maxCandidates);
24
+ }
@@ -0,0 +1,4 @@
1
+ import type { ResearchEvidence } from './research-types.js';
2
+ export declare function rankEvidence(evidence: ResearchEvidence[]): ResearchEvidence[];
3
+ export declare function hasOfficialEvidence(evidence: ResearchEvidence[]): boolean;
4
+ export declare function strongEvidenceCount(evidence: ResearchEvidence[]): number;
@@ -0,0 +1,36 @@
1
+ function sourceRank(sourceKind) {
2
+ switch (sourceKind) {
3
+ case 'official-docs':
4
+ return 0;
5
+ case 'official-api':
6
+ return 1;
7
+ case 'official-discussion':
8
+ return 2;
9
+ case 'issue-thread':
10
+ return 3;
11
+ case 'community':
12
+ return 4;
13
+ case 'package-page':
14
+ return 5;
15
+ default:
16
+ return 6;
17
+ }
18
+ }
19
+ export function rankEvidence(evidence) {
20
+ const bestByUrl = new Map();
21
+ for (const item of evidence) {
22
+ const current = bestByUrl.get(item.url);
23
+ if (!current || sourceRank(item.sourceKind) < sourceRank(current.sourceKind)) {
24
+ bestByUrl.set(item.url, item);
25
+ }
26
+ }
27
+ return [...bestByUrl.values()].sort((left, right) => sourceRank(left.sourceKind) - sourceRank(right.sourceKind));
28
+ }
29
+ export function hasOfficialEvidence(evidence) {
30
+ return evidence.some((item) => item.sourceKind === 'official-docs' || item.sourceKind === 'official-api');
31
+ }
32
+ export function strongEvidenceCount(evidence) {
33
+ return evidence.filter((item) => item.sourceKind === 'official-docs' ||
34
+ item.sourceKind === 'official-api' ||
35
+ item.sourceKind === 'official-discussion').length;
36
+ }
@@ -13,29 +13,14 @@ export declare function createResearchWorkflow({ search, fetchPage, headlessFetc
13
13
  run({ query }: {
14
14
  query: string;
15
15
  }): Promise<{
16
- decision: {
17
- action: "answer";
18
- rationale: string;
19
- approvedEvidence: import("./research-types.js").ResearchEvidence[];
20
- };
21
- evidence: import("./research-types.js").ResearchEvidence[];
22
- workerPass: import("./research-types.js").ResearchWorkerResult;
23
- } | {
24
- decision: {
25
- action: "escalate-headless";
26
- rationale: string;
27
- url: string;
28
- approvedEvidence: import("./research-types.js").ResearchEvidence[];
29
- };
16
+ decision: import("./research-types.js").ResearchOrchestratorDecision;
30
17
  evidence: import("./research-types.js").ResearchEvidence[];
31
18
  workerPass: import("./research-types.js").ResearchWorkerResult;
32
- } | {
33
- decision: {
34
- action: "research-again";
35
- rationale: string;
36
- followupQuery: string;
19
+ metadata: {
20
+ searchPasses: number;
21
+ fetchedPages: number;
22
+ headlessAttempts: number;
23
+ exhaustedBudget: boolean;
37
24
  };
38
- evidence: import("./research-types.js").ResearchEvidence[];
39
- workerPass: import("./research-types.js").ResearchWorkerResult;
40
25
  }>;
41
26
  };
@@ -0,0 +1,7 @@
1
+ export type QueryPlanInput = {
2
+ originalQuery: string;
3
+ passIndex: number;
4
+ previousQueries: string[];
5
+ gaps: string[];
6
+ };
7
+ export declare function planSearchQueries(input: QueryPlanInput): string[];
@@ -0,0 +1,37 @@
1
+ function normalizeWhitespace(value) {
2
+ return value.replace(/\s+/g, ' ').trim();
3
+ }
4
+ function extractImportantWords(query) {
5
+ return normalizeWhitespace(query)
6
+ .replace(/\b(find|current|tell|me|how|to|the|and|with|for|in|a|an)\b/gi, ' ')
7
+ .replace(/\s+/g, ' ')
8
+ .trim();
9
+ }
10
+ function officialSiteQuery(query) {
11
+ const lower = query.toLowerCase();
12
+ const terms = extractImportantWords(query);
13
+ if (lower.includes('vitest'))
14
+ return `site:vitest.dev ${terms}`;
15
+ if (lower.includes('playwright'))
16
+ return `site:playwright.dev ${terms}`;
17
+ if (lower.includes('microsoft edge') || lower.includes('edge'))
18
+ return `site:learn.microsoft.com ${terms}`;
19
+ return `${terms} official docs`;
20
+ }
21
+ function implementationQuery(query) {
22
+ return `site:github.com ${extractImportantWords(query)}`;
23
+ }
24
+ export function planSearchQueries(input) {
25
+ const planned = input.passIndex === 0
26
+ ? [input.originalQuery]
27
+ : [
28
+ officialSiteQuery(input.originalQuery),
29
+ implementationQuery(input.originalQuery),
30
+ `${extractImportantWords(input.originalQuery)} discussion`
31
+ ];
32
+ const previous = new Set(input.previousQueries.map(normalizeWhitespace));
33
+ return planned
34
+ .map(normalizeWhitespace)
35
+ .filter((query) => query && !previous.has(query))
36
+ .slice(0, 3);
37
+ }
@@ -1,5 +1,5 @@
1
1
  import type { WebFetchHeadlessResponse } from '../types.js';
2
- import type { ResearchEvidence, ResearchWorkerResult } from './research-types.js';
2
+ import type { ResearchEvidence, ResearchOrchestratorDecision, ResearchWorkerResult } from './research-types.js';
3
3
  export declare function createResearchOrchestrator({ worker, headlessFetch }: {
4
4
  worker: {
5
5
  run: (input: {
@@ -15,29 +15,14 @@ export declare function createResearchOrchestrator({ worker, headlessFetch }: {
15
15
  run({ query }: {
16
16
  query: string;
17
17
  }): Promise<{
18
- decision: {
19
- action: "answer";
20
- rationale: string;
21
- approvedEvidence: ResearchEvidence[];
22
- };
23
- evidence: ResearchEvidence[];
24
- workerPass: ResearchWorkerResult;
25
- } | {
26
- decision: {
27
- action: "escalate-headless";
28
- rationale: string;
29
- url: string;
30
- approvedEvidence: ResearchEvidence[];
31
- };
18
+ decision: ResearchOrchestratorDecision;
32
19
  evidence: ResearchEvidence[];
33
20
  workerPass: ResearchWorkerResult;
34
- } | {
35
- decision: {
36
- action: "research-again";
37
- rationale: string;
38
- followupQuery: string;
21
+ metadata: {
22
+ searchPasses: number;
23
+ fetchedPages: number;
24
+ headlessAttempts: number;
25
+ exhaustedBudget: boolean;
39
26
  };
40
- evidence: ResearchEvidence[];
41
- workerPass: ResearchWorkerResult;
42
27
  }>;
43
28
  };
@@ -1,87 +1,199 @@
1
- function sourceRank(sourceKind) {
2
- switch (sourceKind) {
3
- case 'official-docs':
4
- return 0;
5
- case 'official-api':
6
- return 1;
7
- case 'official-discussion':
8
- return 2;
9
- case 'issue-thread':
10
- return 3;
11
- case 'community':
12
- return 4;
13
- case 'package-page':
14
- return 5;
15
- default:
16
- return 6;
17
- }
1
+ import { rankEvidence } from './evidence-ranker.js';
2
+ import { planSearchQueries } from './query-planner.js';
3
+ import { decideNextResearchStep } from './stop-decider.js';
4
+ const DEFAULT_MAX_PASSES = 3;
5
+ const DEFAULT_MAX_FETCHES_PER_PASS = 4;
6
+ const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
7
+ function classifyEvidenceUrl(url) {
8
+ if (url.includes('/docs/api/') || url.includes('/config/'))
9
+ return 'official-api';
10
+ if (url.includes('playwright.dev/docs') || url.includes('vitest.dev/guide/'))
11
+ return 'official-docs';
12
+ if (url.includes('github.com/vitest-dev/vitest') && url.includes('/docs/'))
13
+ return 'official-docs';
14
+ if (url.includes('learn.microsoft.com'))
15
+ return 'official-docs';
16
+ if (url.includes('github.com/') && url.includes('/issues/'))
17
+ return 'issue-thread';
18
+ if (url.includes('npmjs.com/package/'))
19
+ return 'package-page';
20
+ return 'community';
18
21
  }
19
- function sortEvidence(evidence) {
20
- return [...evidence].sort((left, right) => sourceRank(left.sourceKind) - sourceRank(right.sourceKind));
22
+ function summarizeText(text, maxLength = 180) {
23
+ return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
21
24
  }
22
- function strongEvidence(evidence) {
23
- return evidence.filter((item) => item.sourceKind === 'official-docs' ||
24
- item.sourceKind === 'official-api' ||
25
- item.sourceKind === 'official-discussion');
25
+ function isBotCheckContent({ title = '', text }) {
26
+ return /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i.test(`${title}\n${text}`);
26
27
  }
27
- function hasOfficialDocsOrApi(evidence) {
28
- return evidence.some((item) => item.sourceKind === 'official-docs' || item.sourceKind === 'official-api');
28
+ function evidenceFromHeadless(result) {
29
+ if (result.status !== 'ok' || !result.content?.text.trim())
30
+ return null;
31
+ if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
32
+ return null;
33
+ return {
34
+ title: result.content.title ?? result.url,
35
+ url: result.url,
36
+ sourceKind: classifyEvidenceUrl(result.url),
37
+ method: 'headless',
38
+ summary: summarizeText(result.content.text),
39
+ supports: [summarizeText(result.content.text, 120)]
40
+ };
29
41
  }
30
- function hasBotCheck(outcomes) {
31
- return outcomes.some((outcome) => outcome.kind === 'bot-check');
42
+ function fallbackWorkerPass({ previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget }) {
43
+ return {
44
+ searchQueries: previousQueries,
45
+ evidence: [],
46
+ gaps: allGaps,
47
+ lowValueOutcomes: allLowValueOutcomes,
48
+ exhaustedBudget
49
+ };
32
50
  }
33
- function isHeadlessWorthTrying(pass, approvedEvidence) {
34
- if (!pass.suggestedHeadlessUrl)
35
- return false;
36
- if (hasBotCheck(pass.lowValueOutcomes))
37
- return false;
38
- if (approvedEvidence.length >= 2 && hasOfficialDocsOrApi(approvedEvidence))
39
- return false;
40
- const candidate = pass.suggestedHeadlessUrl;
41
- return !candidate.includes('npmjs.com/package/');
51
+ function buildMetadata({ previousQueries, allEvidence, allGaps, allLowValueOutcomes, headlessAttempts, exhaustedBudget }) {
52
+ return {
53
+ searchPasses: previousQueries.length,
54
+ fetchedPages: allEvidence.length + allGaps.length + allLowValueOutcomes.length,
55
+ headlessAttempts,
56
+ exhaustedBudget
57
+ };
58
+ }
59
+ function decisionForAnswer(action, query, ranked) {
60
+ if (action === 'answer') {
61
+ return {
62
+ action: 'answer',
63
+ rationale: 'Adaptive research gathered enough strong evidence.',
64
+ approvedEvidence: ranked
65
+ };
66
+ }
67
+ return {
68
+ action: 'research-again',
69
+ rationale: 'Research budget exhausted; answer with caveat.',
70
+ followupQuery: query
71
+ };
42
72
  }
43
73
  export function createResearchOrchestrator({ worker, headlessFetch }) {
44
74
  return {
45
75
  async run({ query }) {
46
- const pass = await worker.run({ query, maxSearchRounds: 1, maxFetches: 3 });
47
- const approvedEvidence = sortEvidence(pass.evidence.filter((item) => item.sourceKind !== 'package-page'));
48
- const strong = strongEvidence(approvedEvidence);
49
- const enoughEvidence = strong.length >= 2 && hasOfficialDocsOrApi(approvedEvidence);
50
- if (enoughEvidence) {
51
- const decision = {
52
- action: 'answer',
53
- rationale: 'Two strong sources with official support are enough to answer safely.',
54
- approvedEvidence
55
- };
56
- return { decision, evidence: approvedEvidence, workerPass: pass };
57
- }
58
- if (isHeadlessWorthTrying(pass, approvedEvidence)) {
59
- const url = pass.suggestedHeadlessUrl;
60
- await headlessFetch({ url });
61
- const decision = {
62
- action: 'escalate-headless',
63
- rationale: 'One high-value page is worth a single orchestrator-approved headless retry.',
64
- url,
65
- approvedEvidence
66
- };
67
- return { decision, evidence: approvedEvidence, workerPass: pass };
68
- }
69
- const hasConcreteGap = pass.gaps.length > 0;
70
- const onlyLowValueOutcomes = pass.lowValueOutcomes.length > 0 && pass.evidence.length === 0;
71
- if (!hasConcreteGap || onlyLowValueOutcomes) {
72
- const decision = {
73
- action: 'research-again',
74
- rationale: 'Current results did not justify more escalation; continue only with a more targeted pass.',
75
- followupQuery: query
76
- };
77
- return { decision, evidence: approvedEvidence, workerPass: pass };
76
+ const allEvidence = [];
77
+ const allGaps = [];
78
+ const allLowValueOutcomes = [];
79
+ const previousQueries = [];
80
+ const suggestedHeadlessUrls = [];
81
+ let headlessAttempts = 0;
82
+ let lastPass;
83
+ for (let passIndex = 0; passIndex < DEFAULT_MAX_PASSES; passIndex++) {
84
+ const queries = planSearchQueries({
85
+ originalQuery: query,
86
+ passIndex,
87
+ previousQueries,
88
+ gaps: allGaps.map((gap) => gap.message)
89
+ });
90
+ for (const plannedQuery of queries) {
91
+ previousQueries.push(plannedQuery);
92
+ const pass = await worker.run({
93
+ query: plannedQuery,
94
+ maxSearchRounds: 1,
95
+ maxFetches: DEFAULT_MAX_FETCHES_PER_PASS
96
+ });
97
+ lastPass = pass;
98
+ allEvidence.push(...pass.evidence);
99
+ allGaps.push(...pass.gaps);
100
+ allLowValueOutcomes.push(...pass.lowValueOutcomes);
101
+ if (pass.suggestedHeadlessUrl)
102
+ suggestedHeadlessUrls.push(pass.suggestedHeadlessUrl);
103
+ const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
104
+ const decision = decideNextResearchStep({
105
+ evidence: ranked,
106
+ suggestedHeadlessUrls,
107
+ passIndex,
108
+ maxPasses: DEFAULT_MAX_PASSES,
109
+ headlessAttempts,
110
+ maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
111
+ });
112
+ if (decision.action === 'headless') {
113
+ headlessAttempts++;
114
+ const headlessResult = await headlessFetch({ url: decision.url });
115
+ const headlessEvidence = evidenceFromHeadless(headlessResult);
116
+ if (headlessEvidence) {
117
+ allEvidence.push(headlessEvidence);
118
+ const updatedRanked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
119
+ const updatedDecision = decideNextResearchStep({
120
+ evidence: updatedRanked,
121
+ suggestedHeadlessUrls: [],
122
+ passIndex,
123
+ maxPasses: DEFAULT_MAX_PASSES,
124
+ headlessAttempts,
125
+ maxHeadlessAttempts: DEFAULT_MAX_HEADLESS_ATTEMPTS
126
+ });
127
+ return {
128
+ decision: decisionForAnswer(updatedDecision.action === 'answer' ? 'answer' : 'answer-with-caveat', query, updatedRanked),
129
+ evidence: updatedRanked,
130
+ workerPass: lastPass,
131
+ metadata: buildMetadata({
132
+ previousQueries,
133
+ allEvidence,
134
+ allGaps,
135
+ allLowValueOutcomes,
136
+ headlessAttempts,
137
+ exhaustedBudget: updatedDecision.action !== 'answer'
138
+ })
139
+ };
140
+ }
141
+ return {
142
+ decision: {
143
+ action: 'escalate-headless',
144
+ rationale: 'One high-value page is worth a single orchestrator-approved headless retry.',
145
+ url: decision.url,
146
+ approvedEvidence: ranked
147
+ },
148
+ evidence: ranked,
149
+ workerPass: lastPass,
150
+ metadata: buildMetadata({
151
+ previousQueries,
152
+ allEvidence,
153
+ allGaps,
154
+ allLowValueOutcomes,
155
+ headlessAttempts,
156
+ exhaustedBudget: false
157
+ })
158
+ };
159
+ }
160
+ if (decision.action === 'answer' || decision.action === 'answer-with-caveat') {
161
+ return {
162
+ decision: decisionForAnswer(decision.action, query, ranked),
163
+ evidence: ranked,
164
+ workerPass: lastPass,
165
+ metadata: buildMetadata({
166
+ previousQueries,
167
+ allEvidence,
168
+ allGaps,
169
+ allLowValueOutcomes,
170
+ headlessAttempts,
171
+ exhaustedBudget: decision.action === 'answer-with-caveat'
172
+ })
173
+ };
174
+ }
175
+ }
78
176
  }
79
- const decision = {
80
- action: 'research-again',
81
- rationale: 'The first pass did not gather enough strong evidence to answer safely.',
82
- followupQuery: query
177
+ const ranked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
178
+ return {
179
+ decision: decisionForAnswer('answer-with-caveat', query, ranked),
180
+ evidence: ranked,
181
+ workerPass: lastPass ??
182
+ fallbackWorkerPass({
183
+ previousQueries,
184
+ allGaps,
185
+ allLowValueOutcomes,
186
+ exhaustedBudget: true
187
+ }),
188
+ metadata: buildMetadata({
189
+ previousQueries,
190
+ allEvidence,
191
+ allGaps,
192
+ allLowValueOutcomes,
193
+ headlessAttempts,
194
+ exhaustedBudget: true
195
+ })
83
196
  };
84
- return { decision, evidence: approvedEvidence, workerPass: pass };
85
197
  }
86
198
  };
87
199
  }