@selesai/code 0.1.19 → 0.2.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.
Files changed (157) hide show
  1. package/dist/agents/architect.md +4 -3
  2. package/dist/agents/builder.md +5 -4
  3. package/dist/agents/commentator.md +3 -2
  4. package/dist/agents/explorer.md +4 -4
  5. package/dist/agents/recapper.md +4 -3
  6. package/dist/config.d.ts +6 -7
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +7 -9
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session.d.ts.map +1 -1
  11. package/dist/core/agent-session.js +3 -1
  12. package/dist/core/agent-session.js.map +1 -1
  13. package/dist/core/compaction/compaction.d.ts.map +1 -1
  14. package/dist/core/compaction/compaction.js +18 -24
  15. package/dist/core/compaction/compaction.js.map +1 -1
  16. package/dist/core/extensions/loader.d.ts.map +1 -1
  17. package/dist/core/extensions/loader.js +2 -0
  18. package/dist/core/extensions/loader.js.map +1 -1
  19. package/dist/core/extensions/types.d.ts +8 -1
  20. package/dist/core/extensions/types.d.ts.map +1 -1
  21. package/dist/core/extensions/types.js.map +1 -1
  22. package/dist/core/skills.d.ts.map +1 -1
  23. package/dist/core/skills.js +25 -11
  24. package/dist/core/skills.js.map +1 -1
  25. package/dist/defaults/models.json +143 -39
  26. package/dist/defaults/settings.json +2 -29
  27. package/dist/extensions/package.json +5 -4
  28. package/dist/extensions/pi-intercom/index.ts +12 -5
  29. package/dist/extensions/pi-powerline-footer/index.ts +4 -1
  30. package/dist/extensions/pi-powerline-footer/tps.ts +286 -0
  31. package/dist/extensions/pi-powerline-footer/welcome.ts +47 -46
  32. package/dist/extensions/pi-subagents/README.md +12 -5
  33. package/dist/extensions/pi-subagents/agents/context-builder.md +2 -2
  34. package/dist/extensions/pi-subagents/agents/researcher.md +4 -5
  35. package/dist/extensions/pi-subagents/package.json +8 -5
  36. package/dist/extensions/pi-subagents/prompts/parallel-context-build.md +1 -1
  37. package/dist/extensions/pi-subagents/prompts/parallel-handoff-plan.md +1 -1
  38. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +1 -1
  39. package/dist/extensions/pi-subagents/src/agents/agents.ts +3 -3
  40. package/dist/extensions/pi-subagents/src/extension/control-notices.ts +1 -1
  41. package/dist/extensions/pi-subagents/src/extension/doctor.ts +3 -3
  42. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +1 -1
  43. package/dist/extensions/pi-subagents/src/extension/index.ts +29 -2
  44. package/dist/extensions/pi-subagents/src/profiles/profiles.ts +2 -2
  45. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +72 -13
  46. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +1 -1
  47. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +1 -1
  48. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +85 -41
  49. package/dist/extensions/pi-subagents/src/runs/foreground/chain-clarify.ts +1 -1
  50. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +1 -1
  51. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +46 -3
  52. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +2 -2
  53. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +1 -3
  54. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +2 -2
  55. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +18 -18
  56. package/dist/extensions/pi-subagents/src/runs/shared/pi-spawn.ts +24 -24
  57. package/dist/extensions/pi-subagents/src/runs/shared/spawn-timing.ts +46 -0
  58. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +2 -2
  59. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +4 -4
  60. package/dist/extensions/pi-subagents/src/shared/fork-context.ts +1 -1
  61. package/dist/extensions/pi-subagents/src/shared/types.ts +6 -6
  62. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -3
  63. package/dist/extensions/pi-subagents/src/slash/slash-bridge.ts +1 -1
  64. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +1 -1
  65. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +1 -1
  66. package/dist/extensions/pi-subagents/src/tui/render.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/spawn-timing.test.ts +45 -0
  68. package/dist/extensions/pi-subagents/tsconfig.runner.json +17 -0
  69. package/dist/extensions/pi-web-agent/package.json +31 -0
  70. package/dist/extensions/pi-web-agent/src/backends/config.ts +205 -0
  71. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  72. package/dist/extensions/pi-web-agent/src/backends/factory.ts +152 -0
  73. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +25 -0
  74. package/dist/extensions/pi-web-agent/src/cache/ttl-cache.ts +28 -0
  75. package/dist/extensions/pi-web-agent/src/changelog-notice.ts +133 -0
  76. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +946 -0
  77. package/dist/extensions/pi-web-agent/src/extension.ts +126 -0
  78. package/dist/extensions/pi-web-agent/src/extract/readability.ts +118 -0
  79. package/dist/extensions/pi-web-agent/src/fetch/browser-resolution.ts +199 -0
  80. package/dist/extensions/pi-web-agent/src/fetch/firecrawl-fetch.ts +100 -0
  81. package/dist/extensions/pi-web-agent/src/fetch/headless-fetch.ts +117 -0
  82. package/dist/extensions/pi-web-agent/src/fetch/http-fetch.ts +67 -0
  83. package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +60 -0
  84. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +50 -0
  85. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +52 -0
  86. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +105 -0
  87. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +45 -0
  88. package/dist/extensions/pi-web-agent/src/orchestration/index.ts +28 -0
  89. package/dist/extensions/pi-web-agent/src/orchestration/query-planner.ts +47 -0
  90. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +376 -0
  91. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +64 -0
  92. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +181 -0
  93. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +101 -0
  94. package/dist/extensions/pi-web-agent/src/orchestration/stop-decider.ts +81 -0
  95. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +208 -0
  96. package/dist/extensions/pi-web-agent/src/presentation/config.ts +75 -0
  97. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +61 -0
  98. package/dist/extensions/pi-web-agent/src/presentation/fetch-presentation.ts +54 -0
  99. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +41 -0
  100. package/dist/extensions/pi-web-agent/src/presentation/select-view.ts +20 -0
  101. package/dist/extensions/pi-web-agent/src/presentation/types.ts +63 -0
  102. package/dist/extensions/pi-web-agent/src/search/brave.ts +114 -0
  103. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +72 -0
  104. package/dist/extensions/pi-web-agent/src/search/searxng.ts +96 -0
  105. package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +71 -0
  106. package/dist/extensions/pi-web-agent/src/tools/web-fetch-headless.ts +31 -0
  107. package/dist/extensions/pi-web-agent/src/tools/web-fetch.ts +31 -0
  108. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +157 -0
  109. package/dist/extensions/pi-web-agent/src/types.ts +88 -0
  110. package/dist/extensions/ponytail/index.js +189 -0
  111. package/dist/extensions/ponytail/package.json +8 -0
  112. package/dist/extensions/ponytail/ponytail-config.cjs +122 -0
  113. package/dist/extensions/ponytail/ponytail-instructions.cjs +94 -0
  114. package/dist/extensions/ponytail/test/extension.test.js +167 -0
  115. package/dist/extensions/ponytail/test/helpers.test.js +92 -0
  116. package/dist/extensions/question/constants.ts +38 -0
  117. package/dist/extensions/question/dialog-adapter.ts +74 -0
  118. package/dist/extensions/question/helpers.ts +102 -0
  119. package/dist/extensions/question/index.ts +671 -0
  120. package/dist/extensions/question/navigation.ts +14 -0
  121. package/dist/extensions/question/package.json +18 -0
  122. package/dist/extensions/question/question-list.ts +498 -0
  123. package/dist/extensions/question/row-layout.ts +162 -0
  124. package/dist/extensions/question/schemas.ts +69 -0
  125. package/dist/extensions/question/selection-mode.ts +49 -0
  126. package/dist/extensions/question/shortcuts.ts +53 -0
  127. package/dist/extensions/question/tests/helpers.test.ts +178 -0
  128. package/dist/extensions/question/tests/question-list.test.ts +230 -0
  129. package/dist/extensions/question/tests/row-layout.test.ts +136 -0
  130. package/dist/extensions/question/tests/shortcuts.test.ts +85 -0
  131. package/dist/extensions/question/tests/ui-protocol.test.ts +143 -0
  132. package/dist/extensions/question/tui-adapter.ts +33 -0
  133. package/dist/extensions/question/types.ts +102 -0
  134. package/dist/extensions/question/ui-protocol.ts +46 -0
  135. package/dist/extensions/tokenin-onboarding.ts +265 -2
  136. package/dist/extensions/web-agent-onboarding.ts +222 -0
  137. package/dist/extensions/workflow/adapter.ts +588 -0
  138. package/dist/extensions/workflow/extension.ts +24 -0
  139. package/dist/extensions/workflow/modes/prototype.ts +156 -0
  140. package/dist/extensions/workflow/modes/quick.ts +138 -0
  141. package/dist/extensions/workflow/package.json +17 -0
  142. package/dist/extensions/workflow/state-machine.ts +386 -0
  143. package/dist/index.d.ts +1 -1
  144. package/dist/index.d.ts.map +1 -1
  145. package/dist/index.js +1 -1
  146. package/dist/index.js.map +1 -1
  147. package/dist/modes/interactive/interactive-mode.d.ts +2 -0
  148. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  149. package/dist/modes/interactive/interactive-mode.js +57 -13
  150. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  151. package/dist/utils/mime.d.ts.map +1 -1
  152. package/dist/utils/mime.js +24 -1
  153. package/dist/utils/mime.js.map +1 -1
  154. package/docs/docs.json +4 -0
  155. package/docs/workflows.md +206 -0
  156. package/package.json +5 -1
  157. package/README.md +0 -198
@@ -0,0 +1,126 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { DEFAULT_BACKEND_CONFIG, type BackendConfig } from './backends/config.js';
3
+ import { Type } from 'typebox';
4
+ import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
5
+ import { DEFAULT_PRESENTATION_CONFIG, resolvePresentationMode } from './presentation/config.js';
6
+ import { createResearchWorkflow } from './orchestration/index.js';
7
+ import { loadPresentationConfigLayers } from './presentation/config-store.js';
8
+ import { selectPresentationView } from './presentation/select-view.js';
9
+ import type {
10
+ PresentationConfig,
11
+ PresentationEnvelope,
12
+ PresentationToolName
13
+ } from './presentation/types.js';
14
+ import { createWebExploreTool } from './tools/web-explore.js';
15
+ import type { WebExploreResponse } from './types.js';
16
+ import { getUpdateChangelogNotice } from './changelog-notice.js';
17
+
18
+ type ToolResultWithPresentation = {
19
+ presentation?: PresentationEnvelope;
20
+ };
21
+
22
+ async function loadWebAgentConfig(pi: ExtensionAPI) {
23
+ const store = (
24
+ pi as ExtensionAPI & {
25
+ __presentationConfigStore?: {
26
+ load?: typeof loadPresentationConfigLayers;
27
+ };
28
+ }
29
+ ).__presentationConfigStore;
30
+
31
+ return store?.load?.() ?? loadPresentationConfigLayers();
32
+ }
33
+
34
+ async function getEffectivePresentationConfig(pi: ExtensionAPI): Promise<PresentationConfig> {
35
+ try {
36
+ const loaded = await loadWebAgentConfig(pi);
37
+ return loaded.effectiveConfig;
38
+ } catch {
39
+ return DEFAULT_PRESENTATION_CONFIG;
40
+ }
41
+ }
42
+
43
+ async function getEffectiveBackendConfig(pi: ExtensionAPI): Promise<BackendConfig> {
44
+ try {
45
+ const loaded = await loadWebAgentConfig(pi);
46
+ return loaded.effectiveBackends ?? DEFAULT_BACKEND_CONFIG;
47
+ } catch {
48
+ return DEFAULT_BACKEND_CONFIG;
49
+ }
50
+ }
51
+
52
+ async function renderToolText(
53
+ pi: ExtensionAPI,
54
+ toolName: PresentationToolName,
55
+ details: ToolResultWithPresentation
56
+ ): Promise<string> {
57
+ const config = await getEffectivePresentationConfig(pi);
58
+ const mode = resolvePresentationMode(toolName, config);
59
+ return selectPresentationView(details.presentation, mode) ?? JSON.stringify(details, null, 2);
60
+ }
61
+
62
+ export default function extension(pi: ExtensionAPI) {
63
+ registerWebAgentConfigCommands(pi);
64
+
65
+ const injectedWebExplore = (pi as ExtensionAPI & { __webExploreTool?: ReturnType<typeof createWebExploreTool> }).__webExploreTool;
66
+ let cachedBackendKey: string | undefined;
67
+ let cachedWebExplore: ReturnType<typeof createWebExploreTool> | undefined;
68
+
69
+ async function getConfiguredWebExplore() {
70
+ if (injectedWebExplore) return injectedWebExplore;
71
+
72
+ const backendConfig = await getEffectiveBackendConfig(pi);
73
+ const backendKey = JSON.stringify(backendConfig);
74
+ if (!cachedWebExplore || cachedBackendKey !== backendKey) {
75
+ cachedBackendKey = backendKey;
76
+ cachedWebExplore = createWebExploreTool({
77
+ explore: createResearchWorkflow({ backendConfig })
78
+ });
79
+ }
80
+
81
+ return cachedWebExplore;
82
+ }
83
+
84
+ pi.on('session_start', async (_event, ctx) => {
85
+ try {
86
+ const notice = await getUpdateChangelogNotice();
87
+ if (notice) {
88
+ ctx.ui.notify(`pi-web-agent updated\n\n${notice}`, 'info');
89
+ }
90
+ } catch {
91
+ // Never block extension startup on changelog display.
92
+ }
93
+ });
94
+
95
+ pi.on('before_agent_start', async (event) => ({
96
+ systemPrompt:
97
+ `${event.systemPrompt}\n\n` +
98
+ 'For web research questions that require finding and comparing sources, use web_explore. ' +
99
+ 'web_explore handles search, fetch, source ranking, and headless escalation internally. ' +
100
+ 'If more web evidence is needed after web_explore, call web_explore again with a narrower query; do not use shell/network commands such as curl, Invoke-WebRequest, npm view/search/pack, or direct HTTP URLs for web research.'
101
+ }));
102
+
103
+ pi.registerTool({
104
+ name: 'web_explore',
105
+ label: 'Web Explore',
106
+ description:
107
+ 'Research a web question using bounded search/fetch passes, source ranking, and targeted headless escalation. Use this for web research, current docs/discussion lookups, and recommendation summaries.',
108
+ parameters: Type.Object({
109
+ query: Type.String({ description: 'Web research question to explore.' })
110
+ }),
111
+ async execute(_toolCallId, params) {
112
+ const webExplore = await getConfiguredWebExplore();
113
+ const result: WebExploreResponse = await webExplore({ query: params.query });
114
+ return {
115
+ content: [
116
+ {
117
+ type: 'text',
118
+ text: await renderToolText(pi, 'web_explore', result)
119
+ }
120
+ ],
121
+ details: result,
122
+ isError: result.status === 'error'
123
+ };
124
+ }
125
+ });
126
+ }
@@ -0,0 +1,118 @@
1
+ import { Readability } from '@mozilla/readability';
2
+ import { JSDOM, VirtualConsole } from 'jsdom';
3
+ import type { ExtractedContent } from '../types.js';
4
+
5
+ export type ReadableExtractionMode = 'readability' | 'fallback';
6
+
7
+ export type SafeReadableExtraction = {
8
+ mode: ReadableExtractionMode;
9
+ content: ExtractedContent;
10
+ };
11
+
12
+ export function extractReadableContent(html: string, maxLength = 4000): ExtractedContent {
13
+ let stylesheetError: Error | undefined;
14
+ const virtualConsole = new VirtualConsole();
15
+
16
+ virtualConsole.on('jsdomError', (error) => {
17
+ if (!stylesheetError && error.message.includes('Could not parse CSS stylesheet')) {
18
+ stylesheetError = error;
19
+ }
20
+ });
21
+
22
+ const dom = new JSDOM(html, {
23
+ url: 'https://example.com',
24
+ virtualConsole
25
+ });
26
+
27
+ if (stylesheetError) {
28
+ throw stylesheetError;
29
+ }
30
+
31
+ const article = new Readability(dom.window.document).parse();
32
+ const rawText = (article?.textContent ?? dom.window.document.body.textContent ?? '').trim();
33
+ const text = rawText.slice(0, maxLength);
34
+
35
+ const fallbackTitle = dom.window.document.title || undefined;
36
+
37
+ return {
38
+ title: article?.title ?? fallbackTitle,
39
+ byline: article?.byline || undefined,
40
+ text
41
+ };
42
+ }
43
+
44
+ function decodeHtmlEntities(text: string): string {
45
+ return text
46
+ .replace(/&nbsp;/gi, ' ')
47
+ .replace(/&amp;/gi, '&')
48
+ .replace(/&lt;/gi, '<')
49
+ .replace(/&gt;/gi, '>')
50
+ .replace(/&quot;/gi, '"')
51
+ .replace(/&#39;/gi, "'")
52
+ .replace(/&#x27;/gi, "'")
53
+ .replace(/&#x2F;/gi, '/')
54
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
55
+ .replace(/&#x([\da-f]+);/gi, (_, code) => String.fromCharCode(parseInt(code, 16)));
56
+ }
57
+
58
+ function extractTitle(html: string): string | undefined {
59
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
60
+ if (!match) return undefined;
61
+ return decodeHtmlEntities(match[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()) || undefined;
62
+ }
63
+
64
+ function stripTagContent(html: string, tagName: string): string {
65
+ return html.replace(new RegExp(`<${tagName}\\b[^>]*>[\\s\\S]*?<\\/${tagName}>`, 'gi'), ' ');
66
+ }
67
+
68
+ function extractPreferredSection(html: string): string {
69
+ const mainMatch = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
70
+ if (mainMatch) return mainMatch[1];
71
+
72
+ const articleMatch = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
73
+ if (articleMatch) return articleMatch[1];
74
+
75
+ const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
76
+ if (bodyMatch) return bodyMatch[1];
77
+
78
+ return html;
79
+ }
80
+
81
+ function extractFallbackText(html: string, maxLength: number): ExtractedContent {
82
+ const title = extractTitle(html);
83
+ let section = extractPreferredSection(html);
84
+
85
+ section = stripTagContent(section, 'script');
86
+ section = stripTagContent(section, 'style');
87
+ section = stripTagContent(section, 'noscript');
88
+ section = stripTagContent(section, 'svg');
89
+ section = stripTagContent(section, 'template');
90
+
91
+ const text = decodeHtmlEntities(section)
92
+ .replace(/<[^>]+>/g, ' ')
93
+ .replace(/\s+/g, ' ')
94
+ .trim()
95
+ .slice(0, maxLength);
96
+
97
+ return {
98
+ title,
99
+ text
100
+ };
101
+ }
102
+
103
+ export function extractReadableContentSafely(
104
+ html: string,
105
+ maxLength = 4000
106
+ ): SafeReadableExtraction {
107
+ try {
108
+ return {
109
+ mode: 'readability',
110
+ content: extractReadableContent(html, maxLength)
111
+ };
112
+ } catch {
113
+ return {
114
+ mode: 'fallback',
115
+ content: extractFallbackText(html, maxLength)
116
+ };
117
+ }
118
+ }
@@ -0,0 +1,199 @@
1
+ type SupportedPlatform = NodeJS.Platform | string;
2
+ type BrowserName = 'configured' | 'chrome' | 'edge' | 'brave' | 'chromium';
3
+
4
+ type BrowserCandidate = {
5
+ browser: Exclude<BrowserName, 'configured'>;
6
+ paths: string[];
7
+ commands: string[];
8
+ };
9
+
10
+ const WINDOWS_CANDIDATES: BrowserCandidate[] = [
11
+ {
12
+ browser: 'chrome',
13
+ paths: [
14
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
15
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
16
+ ],
17
+ commands: ['chrome.exe', 'chrome', 'google-chrome']
18
+ },
19
+ {
20
+ browser: 'edge',
21
+ paths: [
22
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
23
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'
24
+ ],
25
+ commands: ['msedge.exe', 'msedge', 'microsoft-edge']
26
+ },
27
+ {
28
+ browser: 'brave',
29
+ paths: [
30
+ 'C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe',
31
+ 'C:/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe'
32
+ ],
33
+ commands: ['brave.exe', 'brave']
34
+ },
35
+ {
36
+ browser: 'chromium',
37
+ paths: [
38
+ 'C:/Program Files/Chromium/Application/chrome.exe',
39
+ 'C:/Program Files (x86)/Chromium/Application/chrome.exe'
40
+ ],
41
+ commands: ['chromium.exe', 'chromium']
42
+ }
43
+ ];
44
+
45
+ const MACOS_CANDIDATES: BrowserCandidate[] = [
46
+ {
47
+ browser: 'chrome',
48
+ paths: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'],
49
+ commands: ['google-chrome', 'chrome']
50
+ },
51
+ {
52
+ browser: 'edge',
53
+ paths: ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
54
+ commands: ['microsoft-edge', 'msedge']
55
+ },
56
+ {
57
+ browser: 'brave',
58
+ paths: ['/Applications/Brave Browser.app/Contents/MacOS/Brave Browser'],
59
+ commands: ['brave-browser', 'brave']
60
+ },
61
+ {
62
+ browser: 'chromium',
63
+ paths: ['/Applications/Chromium.app/Contents/MacOS/Chromium'],
64
+ commands: ['chromium', 'chromium-browser']
65
+ }
66
+ ];
67
+
68
+ const LINUX_CANDIDATES: BrowserCandidate[] = [
69
+ {
70
+ browser: 'chrome',
71
+ paths: ['/usr/bin/google-chrome', '/usr/local/bin/google-chrome', '/opt/google/chrome/chrome'],
72
+ commands: ['google-chrome', 'google-chrome-stable', 'chrome']
73
+ },
74
+ {
75
+ browser: 'edge',
76
+ paths: ['/usr/bin/microsoft-edge', '/opt/microsoft/msedge/msedge'],
77
+ commands: ['microsoft-edge', 'microsoft-edge-stable', 'msedge']
78
+ },
79
+ {
80
+ browser: 'brave',
81
+ paths: ['/usr/bin/brave-browser', '/usr/local/bin/brave-browser', '/opt/brave.com/brave/brave'],
82
+ commands: ['brave-browser', 'brave']
83
+ },
84
+ {
85
+ browser: 'chromium',
86
+ paths: ['/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
87
+ commands: ['chromium', 'chromium-browser']
88
+ }
89
+ ];
90
+
91
+ export type BrowserResolutionResult =
92
+ | {
93
+ ok: true;
94
+ executablePath: string;
95
+ browser: BrowserName;
96
+ }
97
+ | {
98
+ ok: false;
99
+ error: {
100
+ code: 'BROWSER_NOT_FOUND' | 'CONFIGURED_BROWSER_NOT_FOUND';
101
+ message: string;
102
+ };
103
+ };
104
+
105
+ export async function resolveBrowserExecutable({
106
+ configuredPath,
107
+ platform = process.platform,
108
+ env = process.env,
109
+ fileExists = defaultFileExists
110
+ }: {
111
+ configuredPath?: string;
112
+ platform?: SupportedPlatform;
113
+ env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
114
+ fileExists?: (path: string) => Promise<boolean>;
115
+ }): Promise<BrowserResolutionResult> {
116
+ if (configuredPath) {
117
+ if (await fileExists(configuredPath)) {
118
+ return {
119
+ ok: true,
120
+ executablePath: configuredPath,
121
+ browser: 'configured'
122
+ };
123
+ }
124
+
125
+ return {
126
+ ok: false,
127
+ error: {
128
+ code: 'CONFIGURED_BROWSER_NOT_FOUND',
129
+ message: `Configured browser path was not found: ${configuredPath}`
130
+ }
131
+ };
132
+ }
133
+
134
+ const candidates = getCandidatesForPlatform(platform);
135
+
136
+ for (const candidate of candidates) {
137
+ for (const path of candidate.paths) {
138
+ if (await fileExists(path)) {
139
+ return { ok: true, executablePath: path, browser: candidate.browser };
140
+ }
141
+ }
142
+ }
143
+
144
+ for (const candidate of candidates) {
145
+ for (const path of getPathCommandCandidates(candidate.commands, platform, env)) {
146
+ if (await fileExists(path)) {
147
+ return { ok: true, executablePath: path, browser: candidate.browser };
148
+ }
149
+ }
150
+ }
151
+
152
+ return {
153
+ ok: false,
154
+ error: {
155
+ code: 'BROWSER_NOT_FOUND',
156
+ message: 'No compatible local browser was found for headless fetch.'
157
+ }
158
+ };
159
+ }
160
+
161
+ function getCandidatesForPlatform(platform: SupportedPlatform): BrowserCandidate[] {
162
+ if (platform === 'win32') return WINDOWS_CANDIDATES;
163
+ if (platform === 'darwin') return MACOS_CANDIDATES;
164
+ if (platform === 'linux') return LINUX_CANDIDATES;
165
+
166
+ return [...WINDOWS_CANDIDATES, ...MACOS_CANDIDATES, ...LINUX_CANDIDATES];
167
+ }
168
+
169
+ function getPathCommandCandidates(
170
+ commands: string[],
171
+ platform: SupportedPlatform,
172
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>
173
+ ): string[] {
174
+ const pathValue = env.PATH ?? env.Path ?? env.path;
175
+ if (!pathValue) return [];
176
+
177
+ const delimiter = platform === 'win32' ? ';' : ':';
178
+ const dirs = pathValue.split(delimiter).filter(Boolean);
179
+ const extensions = platform === 'win32' ? ['', '.exe'] : [''];
180
+
181
+ return dirs.flatMap((dir) =>
182
+ commands.flatMap((command) =>
183
+ extensions.map((extension) => {
184
+ const normalizedCommand = command.toLowerCase().endsWith(extension) ? command : `${command}${extension}`;
185
+ return `${dir.replace(/[\\/]$/, '')}/${normalizedCommand}`;
186
+ })
187
+ )
188
+ );
189
+ }
190
+
191
+ async function defaultFileExists(path: string): Promise<boolean> {
192
+ try {
193
+ const { access } = await import('node:fs/promises');
194
+ await access(path);
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
@@ -0,0 +1,100 @@
1
+ import type { FirecrawlOptions } from '../backends/config.js';
2
+ import type { WebFetchResponse } from '../types.js';
3
+
4
+ type FirecrawlResponse = {
5
+ success?: boolean;
6
+ data?: {
7
+ markdown?: unknown;
8
+ html?: unknown;
9
+ metadata?: {
10
+ title?: unknown;
11
+ sourceURL?: unknown;
12
+ };
13
+ };
14
+ error?: unknown;
15
+ };
16
+
17
+ function buildScrapeUrl(baseUrl: string) {
18
+ return new URL('/v1/scrape', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
19
+ }
20
+
21
+ function errorMessage(error: unknown) {
22
+ return error instanceof Error ? error.message : String(error);
23
+ }
24
+
25
+ export function createFirecrawlFetcher({
26
+ baseUrl,
27
+ apiKey,
28
+ options,
29
+ fetchImpl = fetch
30
+ }: {
31
+ baseUrl: string;
32
+ apiKey?: string;
33
+ options?: FirecrawlOptions;
34
+ fetchImpl?: typeof fetch;
35
+ }) {
36
+ return async function firecrawlFetch(url: string): Promise<WebFetchResponse> {
37
+ try {
38
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
39
+ if (apiKey) {
40
+ headers.Authorization = `Bearer ${apiKey}`;
41
+ }
42
+
43
+ const body = {
44
+ url,
45
+ formats: options?.formats ?? ['markdown'],
46
+ ...(options?.onlyMainContent !== undefined ? { onlyMainContent: options.onlyMainContent } : {})
47
+ };
48
+
49
+ const response = await fetchImpl(buildScrapeUrl(baseUrl), {
50
+ method: 'POST',
51
+ headers,
52
+ body: JSON.stringify(body)
53
+ });
54
+
55
+ if (!response.ok) {
56
+ throw new Error(`HTTP ${response.status}`);
57
+ }
58
+
59
+ const parsed = (await response.json()) as FirecrawlResponse;
60
+ if (parsed.success === false) {
61
+ throw new Error(typeof parsed.error === 'string' ? parsed.error : 'Firecrawl scrape failed.');
62
+ }
63
+
64
+ const text = typeof parsed.data?.markdown === 'string'
65
+ ? parsed.data.markdown
66
+ : typeof parsed.data?.html === 'string'
67
+ ? parsed.data.html
68
+ : '';
69
+ const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
70
+ ? parsed.data.metadata.sourceURL
71
+ : url;
72
+ const title = typeof parsed.data?.metadata?.title === 'string'
73
+ ? parsed.data.metadata.title
74
+ : undefined;
75
+
76
+ if (!text.trim()) {
77
+ return {
78
+ status: 'needs_headless',
79
+ url: resolvedUrl,
80
+ metadata: { method: 'firecrawl', cacheHit: false },
81
+ error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
82
+ };
83
+ }
84
+
85
+ return {
86
+ status: 'ok',
87
+ url: resolvedUrl,
88
+ content: { title, text },
89
+ metadata: { method: 'firecrawl', cacheHit: false, truncated: text.length >= 4000 }
90
+ };
91
+ } catch (error) {
92
+ return {
93
+ status: 'error',
94
+ url,
95
+ metadata: { method: 'firecrawl', cacheHit: false },
96
+ error: { code: 'FETCH_FAILED', message: `Firecrawl scrape failed: ${errorMessage(error)}` }
97
+ };
98
+ }
99
+ };
100
+ }
@@ -0,0 +1,117 @@
1
+ import { chromium } from 'playwright';
2
+ import { extractReadableContentSafely } from '../extract/readability.js';
3
+ import { resolveBrowserExecutable, type BrowserResolutionResult } from './browser-resolution.js';
4
+ import type { WebFetchHeadlessResponse } from '../types.js';
5
+
6
+ function cleanupRenderedText(text: string): string {
7
+ let cleaned = text.replace(/(Show more)(\s+\1){1,}/gi, '$1');
8
+ cleaned = cleaned.replace(/(Privacy Terms)(\s+\1){1,}/gi, '$1');
9
+ cleaned = cleaned.replace(/\s+/g, ' ').trim();
10
+ return cleaned;
11
+ }
12
+
13
+ export async function headlessFetch(
14
+ url: string,
15
+ {
16
+ configuredPath,
17
+ resolveBrowser = (options?: { configuredPath?: string }) =>
18
+ resolveBrowserExecutable({ configuredPath: options?.configuredPath }),
19
+ launchBrowser = ({ executablePath, headless }: { executablePath?: string; headless: true }) =>
20
+ chromium.launch(executablePath ? { executablePath, headless } : { headless }),
21
+ now = () => Date.now()
22
+ }: {
23
+ configuredPath?: string;
24
+ resolveBrowser?: (options?: { configuredPath?: string }) => Promise<BrowserResolutionResult>;
25
+ launchBrowser?: (options: { executablePath?: string; headless: true }) => Promise<{
26
+ newContext: () => Promise<{ newPage: () => Promise<any>; close: () => Promise<void> }>;
27
+ close: () => Promise<void>;
28
+ }>;
29
+ now?: () => number;
30
+ } = {}
31
+ ): Promise<WebFetchHeadlessResponse> {
32
+ const resolved = await resolveBrowser({ configuredPath });
33
+ if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
34
+ return {
35
+ status: 'error',
36
+ url,
37
+ metadata: { method: 'headless', cacheHit: false },
38
+ error: resolved.error
39
+ };
40
+ }
41
+
42
+ const browserName = resolved.ok ? resolved.browser : 'chromium';
43
+ const launchOptions = resolved.ok
44
+ ? { executablePath: resolved.executablePath, headless: true as const }
45
+ : { headless: true as const };
46
+
47
+ let browser: Awaited<ReturnType<typeof launchBrowser>> | undefined;
48
+ let context: Awaited<ReturnType<Awaited<ReturnType<typeof launchBrowser>>['newContext']>> | undefined;
49
+ let page: Awaited<ReturnType<Awaited<ReturnType<Awaited<ReturnType<typeof launchBrowser>>['newContext']>>['newPage']>> | undefined;
50
+
51
+ try {
52
+ browser = await launchBrowser(launchOptions);
53
+ context = await browser.newContext();
54
+ page = await context.newPage();
55
+
56
+ const startedAt = now();
57
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
58
+ await page.waitForLoadState('load', { timeout: 10000 });
59
+ await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => undefined);
60
+ const html = await page.content();
61
+ const finishedAt = now();
62
+
63
+ const extraction = extractReadableContentSafely(html);
64
+ const cleanedContent = {
65
+ ...extraction.content,
66
+ text: cleanupRenderedText(extraction.content.text)
67
+ };
68
+
69
+ if (!cleanedContent.text || cleanedContent.text.length < 40) {
70
+ return {
71
+ status: 'blocked',
72
+ url,
73
+ metadata: {
74
+ method: 'headless',
75
+ cacheHit: false,
76
+ browser: browserName,
77
+ navigationMs: finishedAt - startedAt
78
+ },
79
+ error: {
80
+ code: 'HEADLESS_EXTRACTION_WEAK',
81
+ message: 'Rendered page did not produce enough readable content.'
82
+ }
83
+ };
84
+ }
85
+
86
+ return {
87
+ status: 'ok',
88
+ url,
89
+ content: cleanedContent,
90
+ metadata: {
91
+ method: 'headless',
92
+ cacheHit: false,
93
+ browser: browserName,
94
+ navigationMs: finishedAt - startedAt,
95
+ truncated: cleanedContent.text.length >= 4000
96
+ }
97
+ };
98
+ } catch (error) {
99
+ return {
100
+ status: 'error',
101
+ url,
102
+ metadata: {
103
+ method: 'headless',
104
+ cacheHit: false,
105
+ browser: browserName
106
+ },
107
+ error: {
108
+ code: 'HEADLESS_NAVIGATION_FAILED',
109
+ message: error instanceof Error ? error.message : 'Unknown headless navigation failure.'
110
+ }
111
+ };
112
+ } finally {
113
+ await page?.close?.().catch(() => undefined);
114
+ await context?.close?.().catch(() => undefined);
115
+ await browser?.close?.().catch(() => undefined);
116
+ }
117
+ }