@oh-my-pi/exa 1.3.37 → 1.3.372
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/package.json +1 -1
- package/tools/company.ts +16 -30
- package/tools/index.ts +39 -48
- package/tools/linkedin.ts +15 -29
- package/tools/researcher.ts +17 -31
- package/tools/search.ts +25 -44
- package/tools/shared.ts +514 -228
- package/tools/websets.ts +29 -43
package/README.md
CHANGED
|
@@ -59,10 +59,10 @@ Get your API key from: https://dashboard.exa.ai/api-keys
|
|
|
59
59
|
|
|
60
60
|
| Tool | Description |
|
|
61
61
|
| ------------------------- | ---------------------------------------------------- |
|
|
62
|
-
| `
|
|
62
|
+
| `web_search` | Real-time web searches with content extraction |
|
|
63
63
|
| `web_search_deep` | Natural language web search with synthesized results |
|
|
64
64
|
| `web_search_code_context` | Search code snippets, docs, and examples |
|
|
65
|
-
| `
|
|
65
|
+
| `web_search_crawl` | Extract content from specific URLs |
|
|
66
66
|
|
|
67
67
|
### linkedin
|
|
68
68
|
|
|
@@ -72,16 +72,16 @@ Get your API key from: https://dashboard.exa.ai/api-keys
|
|
|
72
72
|
|
|
73
73
|
### company
|
|
74
74
|
|
|
75
|
-
| Tool
|
|
76
|
-
|
|
|
77
|
-
| `
|
|
75
|
+
| Tool | Description |
|
|
76
|
+
| -------------------- | ------------------------------ |
|
|
77
|
+
| `web_search_company` | Comprehensive company research |
|
|
78
78
|
|
|
79
79
|
### researcher
|
|
80
80
|
|
|
81
81
|
| Tool | Description |
|
|
82
82
|
| ----------------------------- | -------------------------------------------- |
|
|
83
83
|
| `web_search_researcher_start` | Start comprehensive AI-powered research task |
|
|
84
|
-
| `
|
|
84
|
+
| `web_search_researcher_poll` | Check research task status and get results |
|
|
85
85
|
|
|
86
86
|
### websets
|
|
87
87
|
|
package/package.json
CHANGED
package/tools/company.ts
CHANGED
|
@@ -2,45 +2,31 @@
|
|
|
2
2
|
* Exa Company Research Tool
|
|
3
3
|
*
|
|
4
4
|
* Tools:
|
|
5
|
-
* -
|
|
5
|
+
* - web_search_company: Comprehensive company research
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
CustomToolFactory,
|
|
12
|
-
ToolAPI,
|
|
13
|
-
} from "@mariozechner/pi-coding-agent";
|
|
14
|
-
import {
|
|
15
|
-
callExaTool,
|
|
16
|
-
createToolWrapper,
|
|
17
|
-
fetchExaTools,
|
|
18
|
-
findApiKey,
|
|
19
|
-
} from "./shared";
|
|
8
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
9
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
10
|
+
import { callExaTool, createToolWrapper, fetchExaTools, findApiKey } from './shared'
|
|
20
11
|
|
|
21
12
|
// MCP tool names for this feature
|
|
22
|
-
const TOOL_NAMES = [
|
|
13
|
+
const TOOL_NAMES = ['company_research_exa']
|
|
23
14
|
|
|
24
15
|
// Tool name mapping: MCP name -> exposed name
|
|
25
16
|
const NAME_MAP: Record<string, string> = {
|
|
26
|
-
|
|
27
|
-
}
|
|
17
|
+
company_research_exa: 'web_search_company',
|
|
18
|
+
}
|
|
28
19
|
|
|
29
|
-
const factory: CustomToolFactory = async (
|
|
30
|
-
|
|
31
|
-
)
|
|
32
|
-
const apiKey = findApiKey();
|
|
33
|
-
if (!apiKey) return null;
|
|
20
|
+
const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
21
|
+
const apiKey = findApiKey()
|
|
22
|
+
if (!apiKey) return null
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
const mcpTools = await fetchExaTools(apiKey, TOOL_NAMES)
|
|
25
|
+
if (mcpTools.length === 0) return null
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
callExaTool(apiKey, TOOL_NAMES, toolName, args);
|
|
27
|
+
const callFn = (toolName: string, args: Record<string, unknown>) => callExaTool(apiKey, TOOL_NAMES, toolName, args)
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
};
|
|
29
|
+
return mcpTools.map(tool => createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn))
|
|
30
|
+
}
|
|
45
31
|
|
|
46
|
-
export default factory
|
|
32
|
+
export default factory
|
package/tools/index.ts
CHANGED
|
@@ -12,64 +12,55 @@
|
|
|
12
12
|
* - websets: Entity collection management
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import type {
|
|
16
|
-
import type {
|
|
17
|
-
|
|
18
|
-
CustomToolFactory,
|
|
19
|
-
ToolAPI,
|
|
20
|
-
} from "@mariozechner/pi-coding-agent";
|
|
21
|
-
import runtime from "./runtime.json";
|
|
15
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
16
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
17
|
+
import runtime from './runtime.json'
|
|
22
18
|
|
|
23
19
|
// Map feature names to their module imports
|
|
24
|
-
const FEATURE_LOADERS: Record<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
researcher: () => import("./researcher"),
|
|
32
|
-
websets: () => import("./websets"),
|
|
33
|
-
};
|
|
20
|
+
const FEATURE_LOADERS: Record<string, () => Promise<{ default: CustomToolFactory }>> = {
|
|
21
|
+
search: () => import('./search'),
|
|
22
|
+
linkedin: () => import('./linkedin'),
|
|
23
|
+
company: () => import('./company'),
|
|
24
|
+
researcher: () => import('./researcher'),
|
|
25
|
+
websets: () => import('./websets'),
|
|
26
|
+
}
|
|
34
27
|
|
|
35
28
|
/**
|
|
36
29
|
* Factory function that loads enabled features from runtime.json
|
|
37
30
|
*/
|
|
38
|
-
const factory: CustomToolFactory = async (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const allTools: CustomAgentTool<TSchema, unknown>[] = [];
|
|
42
|
-
const enabledFeatures = runtime.features ?? [];
|
|
31
|
+
const factory: CustomToolFactory = async (toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
32
|
+
const allTools: CustomAgentTool<TSchema, unknown>[] = []
|
|
33
|
+
const enabledFeatures = runtime.features ?? []
|
|
43
34
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
for (const feature of enabledFeatures) {
|
|
36
|
+
const loader = FEATURE_LOADERS[feature]
|
|
37
|
+
if (!loader) {
|
|
38
|
+
console.error(`Unknown exa feature: "${feature}"`)
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
50
41
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
42
|
+
try {
|
|
43
|
+
const module = await loader()
|
|
44
|
+
const featureFactory = module.default
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
46
|
+
if (typeof featureFactory === 'function') {
|
|
47
|
+
const result = await featureFactory(toolApi)
|
|
48
|
+
// Handle both single tool and array of tools
|
|
49
|
+
if (result) {
|
|
50
|
+
const tools = Array.isArray(result) ? result : [result]
|
|
51
|
+
for (const tool of tools) {
|
|
52
|
+
if (tool && typeof tool === 'object' && 'name' in tool) {
|
|
53
|
+
allTools.push(tool)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
63
56
|
}
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`Failed to load exa feature "${feature}":`, error)
|
|
66
60
|
}
|
|
67
|
-
|
|
68
|
-
console.error(`Failed to load exa feature "${feature}":`, error);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
61
|
+
}
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
}
|
|
63
|
+
return allTools.length > 0 ? allTools : null
|
|
64
|
+
}
|
|
74
65
|
|
|
75
|
-
export default factory
|
|
66
|
+
export default factory
|
package/tools/linkedin.ts
CHANGED
|
@@ -5,42 +5,28 @@
|
|
|
5
5
|
* - web_search_linkedin: Search LinkedIn profiles and companies
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
9
|
-
import type {
|
|
10
|
-
|
|
11
|
-
CustomToolFactory,
|
|
12
|
-
ToolAPI,
|
|
13
|
-
} from "@mariozechner/pi-coding-agent";
|
|
14
|
-
import {
|
|
15
|
-
callExaTool,
|
|
16
|
-
createToolWrapper,
|
|
17
|
-
fetchExaTools,
|
|
18
|
-
findApiKey,
|
|
19
|
-
} from "./shared";
|
|
8
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
9
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
10
|
+
import { callExaTool, createToolWrapper, fetchExaTools, findApiKey } from './shared'
|
|
20
11
|
|
|
21
12
|
// MCP tool names for this feature
|
|
22
|
-
const TOOL_NAMES = [
|
|
13
|
+
const TOOL_NAMES = ['linkedin_search_exa']
|
|
23
14
|
|
|
24
15
|
// Tool name mapping: MCP name -> exposed name
|
|
25
16
|
const NAME_MAP: Record<string, string> = {
|
|
26
|
-
|
|
27
|
-
}
|
|
17
|
+
linkedin_search_exa: 'web_search_linkedin',
|
|
18
|
+
}
|
|
28
19
|
|
|
29
|
-
const factory: CustomToolFactory = async (
|
|
30
|
-
|
|
31
|
-
)
|
|
32
|
-
const apiKey = findApiKey();
|
|
33
|
-
if (!apiKey) return null;
|
|
20
|
+
const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
21
|
+
const apiKey = findApiKey()
|
|
22
|
+
if (!apiKey) return null
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
const mcpTools = await fetchExaTools(apiKey, TOOL_NAMES)
|
|
25
|
+
if (mcpTools.length === 0) return null
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
callExaTool(apiKey, TOOL_NAMES, toolName, args);
|
|
27
|
+
const callFn = (toolName: string, args: Record<string, unknown>) => callExaTool(apiKey, TOOL_NAMES, toolName, args)
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
44
|
-
};
|
|
29
|
+
return mcpTools.map(tool => createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn))
|
|
30
|
+
}
|
|
45
31
|
|
|
46
|
-
export default factory
|
|
32
|
+
export default factory
|
package/tools/researcher.ts
CHANGED
|
@@ -3,46 +3,32 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tools:
|
|
5
5
|
* - web_search_researcher_start: Start comprehensive AI research tasks
|
|
6
|
-
* -
|
|
6
|
+
* - web_search_researcher_poll: Check research task status
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
10
|
-
import type {
|
|
11
|
-
|
|
12
|
-
CustomToolFactory,
|
|
13
|
-
ToolAPI,
|
|
14
|
-
} from "@mariozechner/pi-coding-agent";
|
|
15
|
-
import {
|
|
16
|
-
callExaTool,
|
|
17
|
-
createToolWrapper,
|
|
18
|
-
fetchExaTools,
|
|
19
|
-
findApiKey,
|
|
20
|
-
} from "./shared";
|
|
9
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
10
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
11
|
+
import { callExaTool, createToolWrapper, fetchExaTools, findApiKey } from './shared'
|
|
21
12
|
|
|
22
13
|
// MCP tool names for this feature
|
|
23
|
-
const TOOL_NAMES = [
|
|
14
|
+
const TOOL_NAMES = ['deep_researcher_start', 'deep_researcher_check']
|
|
24
15
|
|
|
25
16
|
// Tool name mapping: MCP name -> exposed name
|
|
26
17
|
const NAME_MAP: Record<string, string> = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
18
|
+
deep_researcher_start: 'web_search_researcher_start',
|
|
19
|
+
deep_researcher_check: 'web_search_researcher_poll',
|
|
20
|
+
}
|
|
30
21
|
|
|
31
|
-
const factory: CustomToolFactory = async (
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
const apiKey = findApiKey();
|
|
35
|
-
if (!apiKey) return null;
|
|
22
|
+
const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
23
|
+
const apiKey = findApiKey()
|
|
24
|
+
if (!apiKey) return null
|
|
36
25
|
|
|
37
|
-
|
|
38
|
-
|
|
26
|
+
const mcpTools = await fetchExaTools(apiKey, TOOL_NAMES)
|
|
27
|
+
if (mcpTools.length === 0) return null
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
callExaTool(apiKey, TOOL_NAMES, toolName, args);
|
|
29
|
+
const callFn = (toolName: string, args: Record<string, unknown>) => callExaTool(apiKey, TOOL_NAMES, toolName, args)
|
|
42
30
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
};
|
|
31
|
+
return mcpTools.map(tool => createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn))
|
|
32
|
+
}
|
|
47
33
|
|
|
48
|
-
export default factory
|
|
34
|
+
export default factory
|
package/tools/search.ts
CHANGED
|
@@ -2,56 +2,37 @@
|
|
|
2
2
|
* Exa Search Tools - Core web search capabilities
|
|
3
3
|
*
|
|
4
4
|
* Tools:
|
|
5
|
-
* -
|
|
5
|
+
* - web_search: Real-time web searches
|
|
6
6
|
* - web_search_deep: Natural language web search with synthesis
|
|
7
7
|
* - web_search_code_context: Code search for libraries, docs, examples
|
|
8
|
-
* -
|
|
8
|
+
* - web_search_crawl: Extract content from specific URLs
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
13
|
-
|
|
14
|
-
CustomToolFactory,
|
|
15
|
-
ToolAPI,
|
|
16
|
-
} from "@mariozechner/pi-coding-agent";
|
|
17
|
-
import {
|
|
18
|
-
callExaTool,
|
|
19
|
-
createToolWrapper,
|
|
20
|
-
fetchExaTools,
|
|
21
|
-
findApiKey,
|
|
22
|
-
} from "./shared";
|
|
11
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
12
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
13
|
+
import { callExaTool, createToolWrapper, fetchExaTools, findApiKey } from './shared'
|
|
23
14
|
|
|
24
15
|
// MCP tool names for this feature
|
|
25
|
-
const TOOL_NAMES = [
|
|
26
|
-
"web_search_exa",
|
|
27
|
-
"deep_search_exa",
|
|
28
|
-
"get_code_context_exa",
|
|
29
|
-
"crawling_exa",
|
|
30
|
-
];
|
|
16
|
+
const TOOL_NAMES = ['web_search_exa', 'deep_search_exa', 'get_code_context_exa', 'crawling_exa']
|
|
31
17
|
|
|
32
18
|
// Tool name mapping: MCP name -> exposed name
|
|
33
19
|
const NAME_MAP: Record<string, string> = {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const factory: CustomToolFactory = async (
|
|
41
|
-
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn),
|
|
54
|
-
);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export default factory;
|
|
20
|
+
web_search_exa: 'web_search',
|
|
21
|
+
deep_search_exa: 'web_search_deep',
|
|
22
|
+
get_code_context_exa: 'web_search_code_context',
|
|
23
|
+
crawling_exa: 'web_search_crawl',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
27
|
+
const apiKey = findApiKey()
|
|
28
|
+
if (!apiKey) return null
|
|
29
|
+
|
|
30
|
+
const mcpTools = await fetchExaTools(apiKey, TOOL_NAMES)
|
|
31
|
+
if (mcpTools.length === 0) return null
|
|
32
|
+
|
|
33
|
+
const callFn = (toolName: string, args: Record<string, unknown>) => callExaTool(apiKey, TOOL_NAMES, toolName, args)
|
|
34
|
+
|
|
35
|
+
return mcpTools.map(tool => createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default factory
|
package/tools/shared.ts
CHANGED
|
@@ -2,300 +2,586 @@
|
|
|
2
2
|
* Shared utilities for Exa MCP tools
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import * as fs from
|
|
6
|
-
import * as os from
|
|
7
|
-
import * as path from
|
|
8
|
-
import type {
|
|
9
|
-
import
|
|
5
|
+
import * as fs from 'node:fs'
|
|
6
|
+
import * as os from 'node:os'
|
|
7
|
+
import * as path from 'node:path'
|
|
8
|
+
import type { CustomAgentTool } from '@mariozechner/pi-coding-agent'
|
|
9
|
+
import { Text } from '@mariozechner/pi-tui'
|
|
10
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
10
11
|
|
|
11
12
|
// MCP endpoints
|
|
12
|
-
export const EXA_MCP_URL =
|
|
13
|
-
export const WEBSETS_MCP_URL =
|
|
13
|
+
export const EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
|
|
14
|
+
export const WEBSETS_MCP_URL = 'https://websetsmcp.exa.ai/mcp'
|
|
15
|
+
|
|
16
|
+
// Log paths
|
|
17
|
+
const EXA_ERROR_LOG = path.join(os.homedir(), '.pi/exa_errors.log')
|
|
18
|
+
const VIEW_ERROR_LOG = path.join(os.homedir(), '.pi/view_errors.log')
|
|
19
|
+
|
|
20
|
+
function logExaError(msg: string): void {
|
|
21
|
+
fs.appendFileSync(EXA_ERROR_LOG, `[${new Date().toISOString()}] ${msg}\n`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function logViewError(msg: string): void {
|
|
25
|
+
fs.appendFileSync(VIEW_ERROR_LOG, `[${new Date().toISOString()}] ${msg}\n`)
|
|
26
|
+
}
|
|
14
27
|
|
|
15
28
|
export interface MCPTool {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
name: string
|
|
30
|
+
description: string
|
|
31
|
+
inputSchema: TSchema
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
interface MCPToolsResponse {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
result?: {
|
|
36
|
+
tools: MCPTool[]
|
|
37
|
+
}
|
|
38
|
+
error?: {
|
|
39
|
+
code: number
|
|
40
|
+
message: string
|
|
41
|
+
}
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
function normalizeInputSchema(schema: unknown): Record<string, unknown> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
if (!schema || typeof schema !== 'object') {
|
|
46
|
+
return { type: 'object', properties: {}, required: [] }
|
|
47
|
+
}
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
const normalized = { ...(schema as Record<string, unknown>) }
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
if (!('type' in normalized)) {
|
|
52
|
+
normalized.type = 'object'
|
|
53
|
+
}
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
if (!('properties' in normalized)) {
|
|
56
|
+
normalized.properties = {}
|
|
57
|
+
}
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const required = (normalized as { required?: unknown }).required
|
|
60
|
+
if (!Array.isArray(required)) {
|
|
61
|
+
normalized.required = []
|
|
62
|
+
}
|
|
50
63
|
|
|
51
|
-
|
|
64
|
+
return normalized
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
/**
|
|
55
68
|
* Parse a .env file and return key-value pairs
|
|
56
69
|
*/
|
|
57
70
|
function parseEnvFile(filePath: string): Record<string, string> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const key = trimmed.slice(0, eqIndex).trim();
|
|
71
|
-
let value = trimmed.slice(eqIndex + 1).trim();
|
|
72
|
-
|
|
73
|
-
// Remove surrounding quotes
|
|
74
|
-
if (
|
|
75
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
76
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
77
|
-
) {
|
|
78
|
-
value = value.slice(1, -1);
|
|
79
|
-
}
|
|
71
|
+
const result: Record<string, string> = {}
|
|
72
|
+
if (!fs.existsSync(filePath)) return result
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
76
|
+
for (const line of content.split('\n')) {
|
|
77
|
+
const trimmed = line.trim()
|
|
78
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
79
|
+
|
|
80
|
+
const eqIndex = trimmed.indexOf('=')
|
|
81
|
+
if (eqIndex === -1) continue
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} catch {
|
|
84
|
-
// Ignore read errors
|
|
85
|
-
}
|
|
83
|
+
const key = trimmed.slice(0, eqIndex).trim()
|
|
84
|
+
let value = trimmed.slice(eqIndex + 1).trim()
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
// Remove surrounding quotes
|
|
87
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
88
|
+
value = value.slice(1, -1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
result[key] = value
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore read errors
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/**
|
|
91
101
|
* Find EXA_API_KEY from environment or .env files
|
|
92
102
|
*/
|
|
93
103
|
export function findApiKey(): string | null {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
104
|
+
// 1. Check environment variable
|
|
105
|
+
if (process.env.EXA_API_KEY) {
|
|
106
|
+
return process.env.EXA_API_KEY
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. Check .env in current directory
|
|
110
|
+
const localEnv = parseEnvFile(path.join(process.cwd(), '.env'))
|
|
111
|
+
if (localEnv.EXA_API_KEY) {
|
|
112
|
+
return localEnv.EXA_API_KEY
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 3. Check ~/.env
|
|
116
|
+
const homeEnv = parseEnvFile(path.join(os.homedir(), '.env'))
|
|
117
|
+
if (homeEnv.EXA_API_KEY) {
|
|
118
|
+
return homeEnv.EXA_API_KEY
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
/**
|
|
115
125
|
* Call an MCP server endpoint
|
|
116
126
|
*/
|
|
117
|
-
async function callMCP(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return JSON.parse(jsonData);
|
|
127
|
+
async function callMCP(url: string, method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
128
|
+
const body = {
|
|
129
|
+
jsonrpc: '2.0',
|
|
130
|
+
method,
|
|
131
|
+
params: params ?? {},
|
|
132
|
+
id: 1,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const response = await fetch(url, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
Accept: 'application/json, text/event-stream',
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const text = await response.text()
|
|
145
|
+
|
|
146
|
+
// Parse SSE response format
|
|
147
|
+
let jsonData: string | null = null
|
|
148
|
+
for (const line of text.split('\n')) {
|
|
149
|
+
if (line.startsWith('data: ')) {
|
|
150
|
+
jsonData = line.slice(6)
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!jsonData) {
|
|
156
|
+
// Try parsing as plain JSON
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(text)
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`Failed to parse MCP response: ${text.slice(0, 500)}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return JSON.parse(jsonData)
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
/**
|
|
162
168
|
* Fetch available tools from Exa MCP server
|
|
163
169
|
*/
|
|
164
|
-
export async function fetchExaTools(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
170
|
+
export async function fetchExaTools(apiKey: string, toolNames: string[]): Promise<MCPTool[]> {
|
|
171
|
+
const url = `${EXA_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolNames.join(','))}`
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const response = (await callMCP(url, 'tools/list')) as MCPToolsResponse
|
|
175
|
+
if (response.error) {
|
|
176
|
+
throw new Error(response.error.message)
|
|
177
|
+
}
|
|
178
|
+
return response.result?.tools ?? []
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
181
|
+
logExaError(`Failed to fetch Exa tools: ${msg}`)
|
|
182
|
+
return []
|
|
183
|
+
}
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
/**
|
|
183
187
|
* Fetch available tools from Websets MCP server
|
|
184
188
|
*/
|
|
185
189
|
export async function fetchWebsetsTools(apiKey: string): Promise<MCPTool[]> {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
const url = `${WEBSETS_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}`
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const response = (await callMCP(url, 'tools/list')) as MCPToolsResponse
|
|
194
|
+
if (response.error) {
|
|
195
|
+
throw new Error(response.error.message)
|
|
196
|
+
}
|
|
197
|
+
return response.result?.tools ?? []
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
200
|
+
logExaError(`Failed to fetch Websets tools: ${msg}`)
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
198
203
|
}
|
|
199
204
|
|
|
200
205
|
/**
|
|
201
206
|
* Call a tool on Exa MCP server
|
|
202
207
|
*/
|
|
203
|
-
export async function callExaTool(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
toolName: string,
|
|
207
|
-
args: Record<string, unknown>,
|
|
208
|
-
): Promise<unknown> {
|
|
209
|
-
const url = `${EXA_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolNames.join(","))}`;
|
|
210
|
-
return callMCPTool(url, toolName, args);
|
|
208
|
+
export async function callExaTool(apiKey: string, toolNames: string[], toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
|
209
|
+
const url = `${EXA_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolNames.join(','))}`
|
|
210
|
+
return callMCPTool(url, toolName, args)
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
214
|
* Call a tool on Websets MCP server
|
|
215
215
|
*/
|
|
216
|
-
export async function callWebsetsTool(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
args: Record<string, unknown>,
|
|
220
|
-
): Promise<unknown> {
|
|
221
|
-
const url = `${WEBSETS_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}`;
|
|
222
|
-
return callMCPTool(url, toolName, args);
|
|
216
|
+
export async function callWebsetsTool(apiKey: string, toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
|
217
|
+
const url = `${WEBSETS_MCP_URL}?exaApiKey=${encodeURIComponent(apiKey)}`
|
|
218
|
+
return callMCPTool(url, toolName, args)
|
|
223
219
|
}
|
|
224
220
|
|
|
225
221
|
/**
|
|
226
222
|
* Call a tool on an MCP server
|
|
227
223
|
*/
|
|
228
|
-
async function callMCPTool(
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
224
|
+
async function callMCPTool(url: string, toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
|
225
|
+
const response = (await callMCP(url, 'tools/call', {
|
|
226
|
+
name: toolName,
|
|
227
|
+
arguments: args,
|
|
228
|
+
})) as {
|
|
229
|
+
result?: { content?: Array<{ text?: string }> }
|
|
230
|
+
error?: { message: string }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (response.error) {
|
|
234
|
+
throw new Error(response.error.message)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Extract text content from MCP response
|
|
238
|
+
const content = response.result?.content
|
|
239
|
+
if (Array.isArray(content)) {
|
|
240
|
+
const texts = content.filter(c => c.text).map(c => c.text)
|
|
241
|
+
if (texts.length === 1) {
|
|
242
|
+
// Try to parse as JSON
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(texts[0]!)
|
|
245
|
+
} catch {
|
|
246
|
+
return texts[0]
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return texts.join('\n\n')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return response.result
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface SearchResult {
|
|
256
|
+
id?: string
|
|
257
|
+
title?: string
|
|
258
|
+
url?: string
|
|
259
|
+
author?: string
|
|
260
|
+
publishedDate?: string
|
|
261
|
+
text?: string
|
|
262
|
+
image?: string
|
|
263
|
+
favicon?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SearchResponse {
|
|
267
|
+
results?: SearchResult[]
|
|
268
|
+
statuses?: Array<{ id: string; status: string; source?: string }>
|
|
269
|
+
costDollars?: { total: number }
|
|
270
|
+
searchTime?: number
|
|
271
|
+
requestId?: string
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Format search results as readable markdown (for LLM consumption)
|
|
276
|
+
*/
|
|
277
|
+
function formatSearchResults(data: SearchResponse): string {
|
|
278
|
+
const lines: string[] = []
|
|
279
|
+
|
|
280
|
+
if (data.results && data.results.length > 0) {
|
|
281
|
+
for (const result of data.results) {
|
|
282
|
+
// Title with link
|
|
283
|
+
if (result.title && result.url) {
|
|
284
|
+
lines.push(`### [${result.title}](${result.url})`)
|
|
285
|
+
} else if (result.title) {
|
|
286
|
+
lines.push(`### ${result.title}`)
|
|
287
|
+
} else if (result.url) {
|
|
288
|
+
lines.push(`### ${result.url}`)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Author if present
|
|
292
|
+
if (result.author) {
|
|
293
|
+
lines.push(`*by ${result.author}*`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
lines.push('')
|
|
297
|
+
|
|
298
|
+
// Content - truncate if very long
|
|
299
|
+
if (result.text) {
|
|
300
|
+
const text = result.text.trim()
|
|
301
|
+
const maxLen = 2000
|
|
302
|
+
if (text.length > maxLen) {
|
|
303
|
+
lines.push(`${text.slice(0, maxLen)}...`)
|
|
304
|
+
} else {
|
|
305
|
+
lines.push(text)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
lines.push('')
|
|
310
|
+
lines.push('---')
|
|
311
|
+
lines.push('')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Footer with metadata
|
|
316
|
+
const meta: string[] = []
|
|
317
|
+
if (data.results) meta.push(`${data.results.length} result(s)`)
|
|
318
|
+
if (data.searchTime) meta.push(`${(data.searchTime / 1000).toFixed(2)}s`)
|
|
319
|
+
if (data.costDollars?.total) meta.push(`$${data.costDollars.total.toFixed(4)}`)
|
|
320
|
+
|
|
321
|
+
if (meta.length > 0) {
|
|
322
|
+
lines.push(`*${meta.join(' • ')}*`)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return lines.join('\n')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Check if result looks like a search response
|
|
330
|
+
*/
|
|
331
|
+
function isSearchResponse(data: unknown): data is SearchResponse {
|
|
332
|
+
if (!data || typeof data !== 'object') return false
|
|
333
|
+
const obj = data as Record<string, unknown>
|
|
334
|
+
return Array.isArray(obj.results) || 'searchTime' in obj || 'costDollars' in obj
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Parse Exa's markdown text format into a SearchResponse structure
|
|
339
|
+
* Format: Title: ...\nURL: ...\nAuthor: ...\nPublished Date: ...\nText: ...\n\n (repeated)
|
|
340
|
+
*/
|
|
341
|
+
function parseExaMarkdown(text: string): SearchResponse | null {
|
|
342
|
+
const results: SearchResult[] = []
|
|
343
|
+
|
|
344
|
+
// Split by double newlines to separate results, but be careful with Text: blocks
|
|
345
|
+
// Each result starts with "Title:"
|
|
346
|
+
const parts = text.split(/\n(?=Title:)/g)
|
|
347
|
+
|
|
348
|
+
for (const part of parts) {
|
|
349
|
+
if (!part.trim()) continue
|
|
350
|
+
|
|
351
|
+
const result: SearchResult = {}
|
|
352
|
+
const lines = part.split('\n')
|
|
353
|
+
|
|
354
|
+
let currentField: string | null = null
|
|
355
|
+
let textLines: string[] = []
|
|
356
|
+
|
|
357
|
+
for (const line of lines) {
|
|
358
|
+
// Check for field prefixes
|
|
359
|
+
if (line.startsWith('Title: ')) {
|
|
360
|
+
result.title = line.slice(7).trim()
|
|
361
|
+
currentField = null
|
|
362
|
+
} else if (line.startsWith('URL: ')) {
|
|
363
|
+
result.url = line.slice(5).trim()
|
|
364
|
+
currentField = null
|
|
365
|
+
} else if (line.startsWith('Author: ')) {
|
|
366
|
+
result.author = line.slice(8).trim()
|
|
367
|
+
currentField = null
|
|
368
|
+
} else if (line.startsWith('Published Date: ')) {
|
|
369
|
+
result.publishedDate = line.slice(16).trim()
|
|
370
|
+
currentField = null
|
|
371
|
+
} else if (line.startsWith('Text: ')) {
|
|
372
|
+
textLines = [line.slice(6)]
|
|
373
|
+
currentField = 'text'
|
|
374
|
+
} else if (currentField === 'text') {
|
|
375
|
+
textLines.push(line)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (textLines.length > 0) {
|
|
380
|
+
result.text = textLines.join('\n').trim()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (result.title || result.url) {
|
|
384
|
+
results.push(result)
|
|
255
385
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (results.length === 0) return null
|
|
389
|
+
return { results }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Tree formatting helpers
|
|
393
|
+
const TREE_MID = '├─'
|
|
394
|
+
const TREE_END = '└─'
|
|
395
|
+
const TREE_PIPE = '│'
|
|
396
|
+
const TREE_SPACE = ' '
|
|
397
|
+
const TREE_HOOK = '⎿'
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Truncate text to max length with ellipsis
|
|
401
|
+
*/
|
|
402
|
+
function truncate(text: string, maxLen: number): string {
|
|
403
|
+
if (text.length <= maxLen) return text
|
|
404
|
+
return `${text.slice(0, maxLen - 1)}…`
|
|
405
|
+
}
|
|
259
406
|
|
|
260
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Extract domain from URL
|
|
409
|
+
*/
|
|
410
|
+
function getDomain(url: string): string {
|
|
411
|
+
try {
|
|
412
|
+
const u = new URL(url)
|
|
413
|
+
return u.hostname.replace(/^www\./, '')
|
|
414
|
+
} catch {
|
|
415
|
+
return url
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Get first N lines of text as preview
|
|
421
|
+
*/
|
|
422
|
+
function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] {
|
|
423
|
+
const lines = text.split('\n').filter(l => l.trim())
|
|
424
|
+
return lines.slice(0, maxLines).map(l => truncate(l.trim(), maxLineLen))
|
|
261
425
|
}
|
|
262
426
|
|
|
263
427
|
/**
|
|
264
428
|
* Create a tool wrapper for an MCP tool
|
|
265
429
|
*/
|
|
266
430
|
export function createToolWrapper(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
): CustomAgentTool<TSchema, unknown> {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
431
|
+
mcpTool: MCPTool,
|
|
432
|
+
renamedName: string,
|
|
433
|
+
callFn: (toolName: string, args: Record<string, unknown>) => Promise<unknown>
|
|
434
|
+
): CustomAgentTool<TSchema, SearchResponse | { error: string } | unknown> {
|
|
435
|
+
return {
|
|
436
|
+
name: renamedName,
|
|
437
|
+
label: renamedName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
|
438
|
+
description: mcpTool.description,
|
|
439
|
+
parameters: normalizeInputSchema(mcpTool.inputSchema) as TSchema,
|
|
440
|
+
|
|
441
|
+
async execute(_toolCallId, params) {
|
|
442
|
+
try {
|
|
443
|
+
const result = await callFn(mcpTool.name, (params ?? {}) as Record<string, unknown>)
|
|
444
|
+
|
|
445
|
+
let text: string
|
|
446
|
+
if (typeof result === 'string') {
|
|
447
|
+
text = result
|
|
448
|
+
} else if (result == null) {
|
|
449
|
+
text = 'No results'
|
|
450
|
+
} else if (isSearchResponse(result)) {
|
|
451
|
+
text = formatSearchResults(result)
|
|
452
|
+
} else {
|
|
453
|
+
text = JSON.stringify(result, null, 2) ?? String(result)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
content: [{ type: 'text' as const, text }],
|
|
458
|
+
details: result,
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
462
|
+
return {
|
|
463
|
+
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
|
464
|
+
details: { error: message },
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
renderResult(result, { expanded }, theme) {
|
|
470
|
+
let { details } = result
|
|
471
|
+
|
|
472
|
+
// Handle error case
|
|
473
|
+
if (details && typeof details === 'object' && 'error' in details) {
|
|
474
|
+
const errDetails = details as { error: string }
|
|
475
|
+
return new Text(theme.fg('error', `Error: ${errDetails.error}`), 0, 0)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// If details is a string (Exa markdown format), try to parse it
|
|
479
|
+
if (typeof details === 'string') {
|
|
480
|
+
const parsed = parseExaMarkdown(details)
|
|
481
|
+
if (parsed) {
|
|
482
|
+
details = parsed
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Handle non-search responses (plain text/JSON)
|
|
487
|
+
if (!isSearchResponse(details)) {
|
|
488
|
+
const text = result.content[0]
|
|
489
|
+
if (text?.type === 'text') {
|
|
490
|
+
// For non-search content, show truncated in collapsed, full in expanded
|
|
491
|
+
if (expanded) {
|
|
492
|
+
return new Text(text.text, 0, 0)
|
|
493
|
+
}
|
|
494
|
+
const preview = getPreviewLines(text.text, 5, 100)
|
|
495
|
+
const lines = preview.map(l => theme.fg('dim', l)).join('\n')
|
|
496
|
+
return new Text(lines + (text.text.split('\n').length > 5 ? theme.fg('muted', '\n …') : ''), 0, 0)
|
|
497
|
+
}
|
|
498
|
+
return new Text('', 0, 0)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Search response - render as tree
|
|
502
|
+
const data = details as SearchResponse
|
|
503
|
+
const resultCount = data.results?.length ?? 0
|
|
504
|
+
|
|
505
|
+
// Build header with metadata
|
|
506
|
+
const meta: string[] = []
|
|
507
|
+
meta.push(`${resultCount} result${resultCount !== 1 ? 's' : ''}`)
|
|
508
|
+
if (data.searchTime) meta.push(`${(data.searchTime / 1000).toFixed(2)}s`)
|
|
509
|
+
if (data.costDollars?.total) meta.push(`$${data.costDollars.total.toFixed(4)}`)
|
|
510
|
+
|
|
511
|
+
const icon = resultCount > 0 ? theme.fg('success', '●') : theme.fg('warning', '●')
|
|
512
|
+
const expandHint = expanded ? '' : theme.fg('dim', ' (Ctrl+O to expand)')
|
|
513
|
+
let text = `${icon} ${theme.fg('toolTitle', 'Web Search')} ${theme.fg('dim', meta.join(' • '))}${expandHint}`
|
|
514
|
+
|
|
515
|
+
if (!data.results || data.results.length === 0) {
|
|
516
|
+
text += `\n ${theme.fg('dim', TREE_END)} ${theme.fg('muted', 'No results found')}`
|
|
517
|
+
return new Text(text, 0, 0)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Render each result
|
|
521
|
+
try {
|
|
522
|
+
for (let i = 0; i < data.results.length; i++) {
|
|
523
|
+
const r = data.results[i]
|
|
524
|
+
const isLast = i === data.results.length - 1
|
|
525
|
+
const branch = isLast ? TREE_END : TREE_MID
|
|
526
|
+
const cont = isLast ? TREE_SPACE : TREE_PIPE
|
|
527
|
+
|
|
528
|
+
// Title line
|
|
529
|
+
const title = r.title ? truncate(r.title, 80) : 'Untitled'
|
|
530
|
+
const domain = r.url ? getDomain(r.url) : ''
|
|
531
|
+
text += `\n ${theme.fg('dim', branch)} ${theme.fg('accent', title)}`
|
|
532
|
+
if (domain) {
|
|
533
|
+
text += theme.fg('dim', ` (${domain})`)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// URL line (if different from domain)
|
|
537
|
+
if (r.url) {
|
|
538
|
+
text += `\n ${theme.fg('dim', `${cont} ${TREE_HOOK} `)}${theme.fg('link', r.url)}`
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Author/date metadata
|
|
542
|
+
const metaParts: string[] = []
|
|
543
|
+
if (r.author) metaParts.push(`by ${r.author}`)
|
|
544
|
+
if (r.publishedDate) {
|
|
545
|
+
try {
|
|
546
|
+
const date = new Date(r.publishedDate)
|
|
547
|
+
metaParts.push(date.toLocaleDateString())
|
|
548
|
+
} catch {
|
|
549
|
+
// ignore invalid dates
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (metaParts.length > 0) {
|
|
553
|
+
text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('muted', metaParts.join(' • '))}`
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Content preview (collapsed) or full content (expanded)
|
|
557
|
+
if (r.text) {
|
|
558
|
+
if (expanded) {
|
|
559
|
+
// Show full content with proper indentation
|
|
560
|
+
const lines = r.text.split('\n')
|
|
561
|
+
for (const line of lines) {
|
|
562
|
+
if (line.trim()) {
|
|
563
|
+
text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('dim', line)}`
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
// Show preview (first 2 non-empty lines)
|
|
568
|
+
const preview = getPreviewLines(r.text, 2, 100)
|
|
569
|
+
for (const line of preview) {
|
|
570
|
+
text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('dim', line)}`
|
|
571
|
+
}
|
|
572
|
+
const totalLines = r.text.split('\n').filter(l => l.trim()).length
|
|
573
|
+
if (totalLines > 2) {
|
|
574
|
+
text += `\n ${theme.fg('dim', `${cont} `)}${theme.fg('muted', `… ${totalLines - 2} more lines`)}`
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
} catch (err) {
|
|
580
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
581
|
+
logViewError(`exa renderResult error: ${msg}`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return new Text(text, 0, 0)
|
|
585
|
+
},
|
|
586
|
+
}
|
|
301
587
|
}
|
package/tools/websets.ts
CHANGED
|
@@ -20,54 +20,40 @@
|
|
|
20
20
|
* - webset_monitor_create: Auto-update webset on schedule
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import type {
|
|
24
|
-
import type {
|
|
25
|
-
|
|
26
|
-
CustomToolFactory,
|
|
27
|
-
ToolAPI,
|
|
28
|
-
} from "@mariozechner/pi-coding-agent";
|
|
29
|
-
import {
|
|
30
|
-
callWebsetsTool,
|
|
31
|
-
createToolWrapper,
|
|
32
|
-
fetchWebsetsTools,
|
|
33
|
-
findApiKey,
|
|
34
|
-
} from "./shared";
|
|
23
|
+
import type { CustomAgentTool, CustomToolFactory, ToolAPI } from '@mariozechner/pi-coding-agent'
|
|
24
|
+
import type { TSchema } from '@sinclair/typebox'
|
|
25
|
+
import { callWebsetsTool, createToolWrapper, fetchWebsetsTools, findApiKey } from './shared'
|
|
35
26
|
|
|
36
27
|
// Tool name mapping: MCP name -> exposed name
|
|
37
28
|
const NAME_MAP: Record<string, string> = {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
29
|
+
create_webset: 'webset_create',
|
|
30
|
+
list_websets: 'webset_list',
|
|
31
|
+
get_webset: 'webset_get',
|
|
32
|
+
update_webset: 'webset_update',
|
|
33
|
+
delete_webset: 'webset_delete',
|
|
34
|
+
list_webset_items: 'webset_items_list',
|
|
35
|
+
get_item: 'webset_item_get',
|
|
36
|
+
create_search: 'webset_search_create',
|
|
37
|
+
get_search: 'webset_search_get',
|
|
38
|
+
cancel_search: 'webset_search_cancel',
|
|
39
|
+
create_enrichment: 'webset_enrichment_create',
|
|
40
|
+
get_enrichment: 'webset_enrichment_get',
|
|
41
|
+
update_enrichment: 'webset_enrichment_update',
|
|
42
|
+
delete_enrichment: 'webset_enrichment_delete',
|
|
43
|
+
cancel_enrichment: 'webset_enrichment_cancel',
|
|
44
|
+
create_monitor: 'webset_monitor_create',
|
|
45
|
+
}
|
|
55
46
|
|
|
56
|
-
const factory: CustomToolFactory = async (
|
|
57
|
-
|
|
58
|
-
)
|
|
59
|
-
const apiKey = findApiKey();
|
|
60
|
-
if (!apiKey) return null;
|
|
47
|
+
const factory: CustomToolFactory = async (_toolApi: ToolAPI): Promise<CustomAgentTool<TSchema, unknown>[] | null> => {
|
|
48
|
+
const apiKey = findApiKey()
|
|
49
|
+
if (!apiKey) return null
|
|
61
50
|
|
|
62
|
-
|
|
63
|
-
|
|
51
|
+
const mcpTools = await fetchWebsetsTools(apiKey)
|
|
52
|
+
if (mcpTools.length === 0) return null
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
callWebsetsTool(apiKey, toolName, args);
|
|
54
|
+
const callFn = (toolName: string, args: Record<string, unknown>) => callWebsetsTool(apiKey, toolName, args)
|
|
67
55
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
};
|
|
56
|
+
return mcpTools.map(tool => createToolWrapper(tool, NAME_MAP[tool.name] ?? tool.name, callFn))
|
|
57
|
+
}
|
|
72
58
|
|
|
73
|
-
export default factory
|
|
59
|
+
export default factory
|