@mammothb/pi-websearch 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mammothb/pi-websearch",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A pi extension that adds a websearch tool for searching the web",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,64 @@
1
+ import { parseResponse } from "./parsers";
2
+ import type { SearchArgs } from "./types";
3
+
4
+ const URL = "https://mcp.exa.ai/mcp";
5
+ const TOOL = "web_search_exa";
6
+
7
+ function buildMcpRequest(toolName: string, args: SearchArgs) {
8
+ const value = Object.fromEntries(
9
+ Object.entries(args).filter(([_, v]) => v !== undefined),
10
+ );
11
+ return {
12
+ jsonrpc: "2.0" as const,
13
+ id: 1,
14
+ method: "tools/call" as const,
15
+ params: { name: toolName, arguments: value },
16
+ };
17
+ }
18
+
19
+ export async function call(
20
+ args: SearchArgs,
21
+ signal: AbortSignal | undefined,
22
+ timeoutMs: number,
23
+ ): Promise<string | undefined> {
24
+ const controller = new AbortController();
25
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
26
+ // Forward external signal
27
+ const onAbort = () => controller.abort();
28
+ if (signal) {
29
+ if (signal.aborted) {
30
+ throw new Error("Request aborted");
31
+ }
32
+ signal.addEventListener("abort", onAbort, { once: true });
33
+ }
34
+
35
+ try {
36
+ const response = await fetch(URL, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Accept: "application/json, text/event-stream",
41
+ },
42
+ body: JSON.stringify(buildMcpRequest(TOOL, args)),
43
+ signal: controller.signal,
44
+ });
45
+
46
+ if (!response.ok) {
47
+ throw new Error(
48
+ `Exa MCP returned HTTP ${response.status}: ${response.statusText}`,
49
+ );
50
+ }
51
+
52
+ return parseResponse(await response.text());
53
+ } catch (error) {
54
+ if (controller.signal.aborted && !signal?.aborted) {
55
+ throw new Error("Request timed out");
56
+ }
57
+ throw error;
58
+ } finally {
59
+ clearTimeout(timeoutId);
60
+ if (signal) {
61
+ signal.removeEventListener("abort", onAbort);
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,42 @@
1
+ import { Value } from "typebox/value";
2
+ import { McpResultPayload } from "./types";
3
+
4
+ /**
5
+ * Try to parse a JSON object from a string, returning the first text content.
6
+ */
7
+ function tryParsePayload(payload: string): string | undefined {
8
+ const trimmed = payload.trim();
9
+ if (!trimmed.startsWith("{")) return undefined;
10
+ try {
11
+ const data = Value.Parse(McpResultPayload, JSON.parse(trimmed));
12
+ return data.result.content.find((item) => item.text)?.text;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ /** Parse an MCP response body, handling both plain JSON and SSE streams. */
19
+ export function parseResponse(body: string): string | undefined {
20
+ const trimmed = body.trim();
21
+
22
+ // Try direct JSON parse first
23
+ if (trimmed) {
24
+ const direct = tryParsePayload(trimmed);
25
+ if (direct) {
26
+ return direct;
27
+ }
28
+ }
29
+
30
+ // Try SSE lines: "data: {...}"
31
+ for (const line of body.split("\n")) {
32
+ if (!line.startsWith("data: ")) {
33
+ continue;
34
+ }
35
+ const data = tryParsePayload(line.slice(6));
36
+ if (data) {
37
+ return data;
38
+ }
39
+ }
40
+
41
+ return undefined;
42
+ }
@@ -0,0 +1,43 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { Static } from "typebox";
3
+ import Type from "typebox";
4
+
5
+ export const WebsearchParameters = Type.Object({
6
+ query: Type.String({ description: "Web search query" }),
7
+ numResults: Type.Optional(
8
+ Type.Number({
9
+ description: "Number of search results to return (default: 8)",
10
+ }),
11
+ ),
12
+ livecrawl: Type.Optional(
13
+ StringEnum(["fallback", "preferred"] as const, {
14
+ description:
15
+ "Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
16
+ }),
17
+ ),
18
+ type: Type.Optional(
19
+ StringEnum(["auto", "fast", "deep"] as const, {
20
+ description:
21
+ "Search type - 'auto': balanced search, 'fast': quick results, 'deep': comprehensive search (default: 'auto')",
22
+ }),
23
+ ),
24
+ contextMaxCharacters: Type.Optional(
25
+ Type.Number({
26
+ description:
27
+ "Maximum characters for context string optimized for LLMs (default: 10000)",
28
+ }),
29
+ ),
30
+ });
31
+
32
+ export type SearchArgs = Static<typeof WebsearchParameters>;
33
+
34
+ export const McpResultPayload = Type.Object({
35
+ result: Type.Object({
36
+ content: Type.Array(
37
+ Type.Object({
38
+ type: Type.String(),
39
+ text: Type.String(),
40
+ }),
41
+ ),
42
+ }),
43
+ });
package/src/websearch.ts CHANGED
@@ -1,22 +1,80 @@
1
- import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
- import Type from "typebox";
1
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
2
+ import {
3
+ keyText,
4
+ type Theme,
5
+ type ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Container, Spacer, Text } from "@earendil-works/pi-tui";
8
+ import { call } from "./lib/mcp-websearch";
9
+ import { type SearchArgs, WebsearchParameters } from "./lib/types";
10
+
11
+ const COLLAPSED_PREVIEW_LINES = 7;
12
+ const TIMEOUT_MS = 25_000;
3
13
 
4
14
  interface WebsearchDetails {
5
15
  query: string;
6
- numResults: number;
7
16
  }
8
17
 
9
- const Parameters = Type.Object({
10
- query: Type.String({ description: "Search query" }),
11
- numResults: Type.Optional(
12
- Type.Number({ description: "Number of results (default: 8)" }),
13
- ),
14
- });
18
+ function isTextContent(c: TextContent | ImageContent): c is TextContent {
19
+ return c.type === "text";
20
+ }
21
+
22
+ function renderExpandableResult(
23
+ details: WebsearchDetails,
24
+ textContent: string,
25
+ expanded: boolean,
26
+ theme: Theme,
27
+ ): Container {
28
+ const container = new Container();
29
+
30
+ const title = details.query;
31
+ container.addChild(
32
+ new Text(
33
+ theme.fg("syntaxKeyword", "query: ") + theme.fg("syntaxString", title),
34
+ ),
35
+ );
36
+
37
+ if (!textContent) {
38
+ return container;
39
+ }
40
+
41
+ container.addChild(new Spacer(1));
42
+
43
+ if (expanded) {
44
+ container.addChild(new Text(textContent));
45
+ } else {
46
+ const lines = textContent
47
+ .split("\n")
48
+ .filter(
49
+ (line, index, arr) =>
50
+ line.length > 0 || index === 0 || index < arr.length - 1,
51
+ );
52
+ const previewLines = lines.slice(0, COLLAPSED_PREVIEW_LINES);
53
+ const remaining = Math.max(0, lines.length - previewLines.length);
54
+
55
+ const preview = previewLines.join("\n");
56
+ container.addChild(new Text(preview));
57
+
58
+ if (remaining > 0) {
59
+ container.addChild(new Spacer(1));
60
+ const expandKey = keyText("app.tools.expand") || "Ctrl+O";
61
+ container.addChild(
62
+ new Text(
63
+ theme.fg("muted", `... (${remaining} more lines, `) +
64
+ theme.fg("dim", expandKey) +
65
+ theme.fg("muted", " to expand)"),
66
+ ),
67
+ );
68
+ }
69
+ }
70
+ return container;
71
+ }
15
72
 
16
73
  export function createWebsearchTool(): ToolDefinition<
17
- typeof Parameters,
74
+ typeof WebsearchParameters,
18
75
  WebsearchDetails
19
76
  > {
77
+ const year = new Date().getFullYear();
20
78
  return {
21
79
  name: "websearch",
22
80
  label: "Web Search",
@@ -31,20 +89,61 @@ Usage notes:
31
89
  - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search)
32
90
  - Configurable context length for optimal LLM integration`,
33
91
  promptSnippet: "Search the web",
34
- parameters: Parameters,
35
- async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
36
- const query = params.query;
37
- const numResults = params.numResults ?? 8;
38
-
39
- return {
40
- content: [
41
- {
42
- type: "text",
43
- text: `Search results for: ${query} (${numResults} results requested)`,
44
- },
45
- ],
46
- details: { query, numResults },
92
+ promptGuidelines: [
93
+ "Use websearch to find current information, documentation, or answers that require up-to-date web data. Always cite sources from search results.",
94
+ `The current year is ${year}. You MUST use this year when searching for recent information or current events.\n- Example: If the current year is ${year} and the user asks for "latest AI news", search for "AI news ${year}", NOT "AI news ${year - 1}"`,
95
+ ],
96
+ parameters: WebsearchParameters,
97
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
98
+ const args: SearchArgs = {
99
+ query: params.query,
100
+ type: params.type ?? "auto",
101
+ numResults: params.numResults ?? 8,
102
+ livecrawl: params.livecrawl ?? "fallback",
103
+ contextMaxCharacters: params.contextMaxCharacters,
47
104
  };
105
+
106
+ try {
107
+ const result = await call(args, signal, TIMEOUT_MS);
108
+
109
+ return {
110
+ content: [
111
+ {
112
+ type: "text",
113
+ text:
114
+ result ??
115
+ "No search results found. Please try a different query.",
116
+ },
117
+ ],
118
+ details: { query: params.query },
119
+ };
120
+ } catch (err) {
121
+ const message = err instanceof Error ? err.message : String(err);
122
+ throw new Error(`Web search failed: ${message}`);
123
+ }
124
+ },
125
+ renderCall(args, theme, _context) {
126
+ return new Text(
127
+ theme.fg("toolTitle", theme.bold("websearch ")) +
128
+ theme.fg("muted", `"${args.query}"`),
129
+ );
130
+ },
131
+ renderResult(result, options, theme, _context) {
132
+ if (options.isPartial) {
133
+ return new Text(theme.fg("warning", "Searching..."));
134
+ }
135
+
136
+ const textContent = result.content
137
+ .filter(isTextContent)
138
+ .map((c) => c.text)
139
+ .join("\n");
140
+
141
+ return renderExpandableResult(
142
+ result.details,
143
+ textContent,
144
+ options.expanded,
145
+ theme,
146
+ );
48
147
  },
49
148
  };
50
149
  }