@aaroncql/pim-agent 0.0.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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/bin/pim.ts +109 -0
- package/package.json +49 -0
- package/src/extensions/_init/index.ts +109 -0
- package/src/extensions/bash/capture.test.ts +126 -0
- package/src/extensions/bash/capture.ts +80 -0
- package/src/extensions/bash/format.test.ts +240 -0
- package/src/extensions/bash/format.ts +76 -0
- package/src/extensions/bash/index.ts +86 -0
- package/src/extensions/bash/run.test.ts +262 -0
- package/src/extensions/bash/run.ts +207 -0
- package/src/extensions/bash/schema.ts +54 -0
- package/src/extensions/command-picker/index.ts +52 -0
- package/src/extensions/command-picker/ranker.test.ts +46 -0
- package/src/extensions/command-picker/ranker.ts +17 -0
- package/src/extensions/edit/edit.test.ts +285 -0
- package/src/extensions/edit/edit.ts +382 -0
- package/src/extensions/edit/index.ts +54 -0
- package/src/extensions/edit/schema.ts +37 -0
- package/src/extensions/file-picker/catalog.test.ts +263 -0
- package/src/extensions/file-picker/catalog.ts +219 -0
- package/src/extensions/file-picker/index.test.ts +168 -0
- package/src/extensions/file-picker/index.ts +119 -0
- package/src/extensions/file-picker/ranker.test.ts +94 -0
- package/src/extensions/file-picker/ranker.ts +76 -0
- package/src/extensions/footer/git.test.ts +76 -0
- package/src/extensions/footer/git.ts +87 -0
- package/src/extensions/footer/index.test.ts +161 -0
- package/src/extensions/footer/index.ts +148 -0
- package/src/extensions/footer/powerline.ts +87 -0
- package/src/extensions/footer/segments.test.ts +164 -0
- package/src/extensions/footer/segments.ts +234 -0
- package/src/extensions/glob/glob.test.ts +171 -0
- package/src/extensions/glob/glob.ts +34 -0
- package/src/extensions/glob/index.test.ts +68 -0
- package/src/extensions/glob/index.ts +136 -0
- package/src/extensions/glob/render.test.ts +126 -0
- package/src/extensions/glob/render.ts +74 -0
- package/src/extensions/glob/schema.ts +52 -0
- package/src/extensions/grep/grep.test.ts +387 -0
- package/src/extensions/grep/grep.ts +215 -0
- package/src/extensions/grep/index.test.ts +68 -0
- package/src/extensions/grep/index.ts +158 -0
- package/src/extensions/grep/render.test.ts +269 -0
- package/src/extensions/grep/render.ts +243 -0
- package/src/extensions/grep/schema.ts +92 -0
- package/src/extensions/read/index.ts +84 -0
- package/src/extensions/read/read.test.ts +177 -0
- package/src/extensions/read/read.ts +206 -0
- package/src/extensions/read/render.test.ts +61 -0
- package/src/extensions/read/render.ts +33 -0
- package/src/extensions/read/schema.ts +27 -0
- package/src/extensions/subagent/index.test.ts +44 -0
- package/src/extensions/subagent/index.ts +30 -0
- package/src/extensions/subagent/render.test.ts +292 -0
- package/src/extensions/subagent/render.ts +359 -0
- package/src/extensions/subagent/schema.ts +9 -0
- package/src/extensions/subagent/subagent.test.ts +315 -0
- package/src/extensions/subagent/subagent.ts +418 -0
- package/src/extensions/system-prompt/index.ts +28 -0
- package/src/extensions/system-prompt/prompt.test.ts +64 -0
- package/src/extensions/system-prompt/prompt.ts +213 -0
- package/src/extensions/todo/index.test.ts +244 -0
- package/src/extensions/todo/index.ts +122 -0
- package/src/extensions/todo/render.test.ts +180 -0
- package/src/extensions/todo/render.ts +172 -0
- package/src/extensions/todo/schema.ts +24 -0
- package/src/extensions/todo/todo.test.ts +222 -0
- package/src/extensions/todo/todo.ts +188 -0
- package/src/extensions/tps/index.test.ts +254 -0
- package/src/extensions/tps/index.ts +136 -0
- package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
- package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
- package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
- package/src/extensions/web-fetch/fetch.test.ts +244 -0
- package/src/extensions/web-fetch/fetch.ts +249 -0
- package/src/extensions/web-fetch/index.ts +107 -0
- package/src/extensions/web-fetch/render.test.ts +56 -0
- package/src/extensions/web-fetch/render.ts +39 -0
- package/src/extensions/web-fetch/schema.ts +23 -0
- package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
- package/src/extensions/web-search/ExaMcpClient.ts +258 -0
- package/src/extensions/web-search/index.ts +118 -0
- package/src/extensions/web-search/render.test.ts +21 -0
- package/src/extensions/web-search/render.ts +9 -0
- package/src/extensions/web-search/schema.ts +21 -0
- package/src/extensions/web-search/search.test.ts +53 -0
- package/src/extensions/web-search/search.ts +23 -0
- package/src/extensions/working-indicator/index.test.ts +21 -0
- package/src/extensions/working-indicator/index.ts +77 -0
- package/src/extensions/write/index.ts +76 -0
- package/src/extensions/write/render.test.ts +64 -0
- package/src/extensions/write/schema.ts +14 -0
- package/src/extensions/write/write.test.ts +108 -0
- package/src/extensions/write/write.ts +104 -0
- package/src/shared/DiffLines.test.ts +193 -0
- package/src/shared/DiffLines.ts +307 -0
- package/src/shared/DiffRenderer.test.ts +206 -0
- package/src/shared/DiffRenderer.ts +396 -0
- package/src/shared/DiffView.ts +199 -0
- package/src/shared/EditMatcher.test.ts +123 -0
- package/src/shared/EditMatcher.ts +826 -0
- package/src/shared/FileScanner.test.ts +158 -0
- package/src/shared/FileScanner.ts +41 -0
- package/src/shared/Fs.ts +46 -0
- package/src/shared/FsErrors.ts +72 -0
- package/src/shared/FuzzyMatcher.test.ts +114 -0
- package/src/shared/FuzzyMatcher.ts +73 -0
- package/src/shared/GitignoreFilter.test.ts +64 -0
- package/src/shared/GitignoreFilter.ts +142 -0
- package/src/shared/GlobExclusions.ts +23 -0
- package/src/shared/Levenshtein.ts +33 -0
- package/src/shared/Lines.test.ts +25 -0
- package/src/shared/Lines.ts +77 -0
- package/src/shared/McpClient.test.ts +235 -0
- package/src/shared/McpClient.ts +406 -0
- package/src/shared/OutputBudget.test.ts +99 -0
- package/src/shared/OutputBudget.ts +79 -0
- package/src/shared/Paths.test.ts +51 -0
- package/src/shared/Paths.ts +52 -0
- package/src/shared/PimSettings.test.ts +90 -0
- package/src/shared/PimSettings.ts +124 -0
- package/src/shared/Renderer.test.ts +190 -0
- package/src/shared/Renderer.ts +256 -0
- package/src/shared/SpillCache.test.ts +94 -0
- package/src/shared/SpillCache.ts +89 -0
- package/src/shared/Tools.test.ts +392 -0
- package/src/shared/Tools.ts +636 -0
- package/src/telegram/Bot.ts +198 -0
- package/src/telegram/Commands.ts +721 -0
- package/src/telegram/Config.test.ts +275 -0
- package/src/telegram/Config.ts +162 -0
- package/src/telegram/Markdown.test.ts +143 -0
- package/src/telegram/Markdown.ts +177 -0
- package/src/telegram/Message.ts +211 -0
- package/src/telegram/Renderer.test.ts +216 -0
- package/src/telegram/Renderer.ts +713 -0
- package/src/telegram/SendFileSchema.ts +19 -0
- package/src/telegram/SendFileTool.ts +94 -0
- package/src/telegram/Session.ts +579 -0
- package/src/telegram/SessionRegistry.test.ts +89 -0
- package/src/telegram/SessionRegistry.ts +170 -0
- package/src/telegram/Supervisor.ts +357 -0
- package/src/telegram/TaskScheduler.test.ts +278 -0
- package/src/telegram/TaskScheduler.ts +293 -0
- package/src/telegram/TaskSchema.ts +88 -0
- package/src/telegram/TaskStore.ts +73 -0
- package/src/telegram/TaskTool.test.ts +179 -0
- package/src/telegram/TaskTool.ts +159 -0
- package/src/telegram/TypingIndicator.ts +43 -0
- package/src/telegram/index.ts +32 -0
- package/src/themes/pim-dark.json +84 -0
- package/src/themes/pim-light.json +84 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
export const WEB_FETCH_INLINE_BYTES = 32 * 1024;
|
|
5
|
+
|
|
6
|
+
export const FETCH_FORMATS = ["markdown", "html"] as const;
|
|
7
|
+
export type WebFetchFormat = (typeof FETCH_FORMATS)[number];
|
|
8
|
+
export type WebFetchResolvedFormat = WebFetchFormat;
|
|
9
|
+
|
|
10
|
+
export const webFetchSchema = Type.Object({
|
|
11
|
+
url: Type.String({
|
|
12
|
+
minLength: 1,
|
|
13
|
+
description: "Must be a public http(s) URL.",
|
|
14
|
+
}),
|
|
15
|
+
format: Type.Optional(
|
|
16
|
+
StringEnum(FETCH_FORMATS, {
|
|
17
|
+
description:
|
|
18
|
+
"`markdown`: used by default. `html`: use only when raw source is required.",
|
|
19
|
+
})
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type WebFetchInput = Static<typeof webFetchSchema>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { ExaMcpClient } from "./ExaMcpClient";
|
|
3
|
+
|
|
4
|
+
type MockFetch = (
|
|
5
|
+
input: Parameters<typeof fetch>[0],
|
|
6
|
+
init?: Parameters<typeof fetch>[1]
|
|
7
|
+
) => ReturnType<typeof fetch>;
|
|
8
|
+
|
|
9
|
+
const captureMethod = async (
|
|
10
|
+
input: Parameters<typeof fetch>[0],
|
|
11
|
+
init: Parameters<typeof fetch>[1] | undefined
|
|
12
|
+
): Promise<string> => {
|
|
13
|
+
if (input instanceof Request) {
|
|
14
|
+
const body = (await input.clone().json()) as { method?: string };
|
|
15
|
+
return body.method ?? "";
|
|
16
|
+
}
|
|
17
|
+
const body = JSON.parse(String(init?.body)) as { method?: string };
|
|
18
|
+
return body.method ?? "";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const handshakeOr = (toolCallResponse: () => Response): MockFetch => {
|
|
22
|
+
return async (input, init) => {
|
|
23
|
+
const method = await captureMethod(input, init);
|
|
24
|
+
|
|
25
|
+
if (method === "initialize") {
|
|
26
|
+
return Response.json(
|
|
27
|
+
{ jsonrpc: "2.0", id: 1, result: {} },
|
|
28
|
+
{ headers: { "mcp-session-id": "session" } }
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (method === "notifications/initialized") {
|
|
33
|
+
return new Response(null, { status: 202 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return toolCallResponse();
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
test("parses Exa JSON results", async () => {
|
|
41
|
+
const client = new ExaMcpClient({
|
|
42
|
+
fetch: handshakeOr(() =>
|
|
43
|
+
Response.json({
|
|
44
|
+
jsonrpc: "2.0",
|
|
45
|
+
id: 2,
|
|
46
|
+
result: {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: JSON.stringify({
|
|
51
|
+
results: [
|
|
52
|
+
{
|
|
53
|
+
title: "Pim docs",
|
|
54
|
+
url: "https://example.test/pim",
|
|
55
|
+
snippet: "A concise result.",
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
client.search({ query: "pim agent", numResults: 3 })
|
|
68
|
+
).resolves.toEqual([
|
|
69
|
+
{
|
|
70
|
+
title: "Pim docs",
|
|
71
|
+
url: "https://example.test/pim",
|
|
72
|
+
snippet: "A concise result.",
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("parses Exa plain-text result blocks", async () => {
|
|
78
|
+
const client = new ExaMcpClient({
|
|
79
|
+
fetch: handshakeOr(() =>
|
|
80
|
+
Response.json({
|
|
81
|
+
jsonrpc: "2.0",
|
|
82
|
+
id: 2,
|
|
83
|
+
result: {
|
|
84
|
+
content: [
|
|
85
|
+
{
|
|
86
|
+
type: "text",
|
|
87
|
+
text: [
|
|
88
|
+
"Title: First text result",
|
|
89
|
+
"URL: https://example.test/first",
|
|
90
|
+
"Published: N/A",
|
|
91
|
+
"Author: N/A",
|
|
92
|
+
"Highlights:",
|
|
93
|
+
"First highlighted sentence.",
|
|
94
|
+
"[...]",
|
|
95
|
+
"Second highlighted sentence.",
|
|
96
|
+
"",
|
|
97
|
+
"---",
|
|
98
|
+
"",
|
|
99
|
+
"Title: Second text result",
|
|
100
|
+
"URL: https://example.test/second",
|
|
101
|
+
"Highlights:",
|
|
102
|
+
"Another result.",
|
|
103
|
+
].join("\n"),
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
client.search({ query: "text result", numResults: 2 })
|
|
113
|
+
).resolves.toEqual([
|
|
114
|
+
{
|
|
115
|
+
title: "First text result",
|
|
116
|
+
url: "https://example.test/first",
|
|
117
|
+
snippet: "First highlighted sentence. Second highlighted sentence.",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
title: "Second text result",
|
|
121
|
+
url: "https://example.test/second",
|
|
122
|
+
snippet: "Another result.",
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("throws clean errors for malformed tool envelopes", async () => {
|
|
128
|
+
const client = new ExaMcpClient({
|
|
129
|
+
fetch: handshakeOr(() =>
|
|
130
|
+
Response.json({
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
id: 2,
|
|
133
|
+
result: {
|
|
134
|
+
content: [{ type: "image", url: "https://example.test/image.png" }],
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await expect(client.search({ query: "pim", numResults: 1 })).rejects.toThrow(
|
|
141
|
+
"Exa returned malformed tool content."
|
|
142
|
+
);
|
|
143
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { McpClient, type McpFetch } from "../../shared/McpClient";
|
|
2
|
+
|
|
3
|
+
type ExaMcpClientOptions = {
|
|
4
|
+
readonly endpoint?: string;
|
|
5
|
+
readonly apiKey?: string;
|
|
6
|
+
readonly fetch?: McpFetch;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type ExaSearchInput = {
|
|
10
|
+
readonly query: string;
|
|
11
|
+
readonly numResults: number;
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ExaSearchResult = {
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly url: string;
|
|
18
|
+
readonly snippet: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
class ExaSearchError extends Error {
|
|
22
|
+
public constructor(message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "ExaSearchError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ExaMcpClient {
|
|
29
|
+
private static readonly defaultEndpoint = "https://mcp.exa.ai/mcp";
|
|
30
|
+
private static readonly toolName = "web_search_exa";
|
|
31
|
+
|
|
32
|
+
private readonly client: McpClient;
|
|
33
|
+
|
|
34
|
+
public constructor(options: ExaMcpClientOptions = {}) {
|
|
35
|
+
this.client = new McpClient({
|
|
36
|
+
endpoint: options.endpoint ?? ExaMcpClient.defaultEndpoint,
|
|
37
|
+
...(options.apiKey === undefined || options.apiKey.length === 0
|
|
38
|
+
? {}
|
|
39
|
+
: { headers: { "x-api-key": options.apiKey } }),
|
|
40
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async search(
|
|
45
|
+
input: ExaSearchInput
|
|
46
|
+
): Promise<readonly ExaSearchResult[]> {
|
|
47
|
+
const result = await this.client.callTool({
|
|
48
|
+
name: ExaMcpClient.toolName,
|
|
49
|
+
arguments: {
|
|
50
|
+
query: input.query,
|
|
51
|
+
numResults: input.numResults,
|
|
52
|
+
},
|
|
53
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return extractResults(result);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractResults(result: unknown): readonly ExaSearchResult[] {
|
|
61
|
+
const record = asRecord(result);
|
|
62
|
+
const content = record?.["content"];
|
|
63
|
+
|
|
64
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
65
|
+
throw new ExaSearchError("Exa returned malformed tool content.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const textBlocks = content.map(readTextBlock);
|
|
69
|
+
const plainTextResults = extractPlainTextResults(textBlocks);
|
|
70
|
+
|
|
71
|
+
if (plainTextResults !== undefined) {
|
|
72
|
+
return plainTextResults;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resultObjects = findFirstObjectArray(
|
|
76
|
+
textBlocks
|
|
77
|
+
.map((block) => tryParseJson(block))
|
|
78
|
+
.filter((value) => value !== undefined)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (resultObjects === undefined) {
|
|
82
|
+
throw new ExaSearchError("Exa returned malformed search results.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return resultObjects.map(projectResult);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readTextBlock(block: unknown): string {
|
|
89
|
+
const record = asRecord(block);
|
|
90
|
+
|
|
91
|
+
if (record?.["type"] !== "text" || typeof record["text"] !== "string") {
|
|
92
|
+
throw new ExaSearchError("Exa returned malformed tool content.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return record["text"];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractPlainTextResults(
|
|
99
|
+
textBlocks: readonly string[]
|
|
100
|
+
): readonly ExaSearchResult[] | undefined {
|
|
101
|
+
for (const textBlock of textBlocks) {
|
|
102
|
+
const blocks = textBlock
|
|
103
|
+
.split(/\n---\n/u)
|
|
104
|
+
.map((block) => block.trim())
|
|
105
|
+
.filter((block) => block.length > 0);
|
|
106
|
+
const results = blocks
|
|
107
|
+
.map((block) => parsePlainTextResult(block))
|
|
108
|
+
.filter((result) => result !== undefined);
|
|
109
|
+
|
|
110
|
+
if (results.length > 0) {
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parsePlainTextResult(block: string): ExaSearchResult | undefined {
|
|
119
|
+
const lines = block
|
|
120
|
+
.split(/\r?\n/u)
|
|
121
|
+
.map((line) => line.trim())
|
|
122
|
+
.filter((line) => line.length > 0);
|
|
123
|
+
const title = readLabeledLine(lines, "Title");
|
|
124
|
+
const url = readLabeledLine(lines, "URL");
|
|
125
|
+
|
|
126
|
+
if (title === undefined || url === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
title,
|
|
132
|
+
url,
|
|
133
|
+
snippet: readPlainTextSnippet(lines),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readLabeledLine(
|
|
138
|
+
lines: readonly string[],
|
|
139
|
+
label: string
|
|
140
|
+
): string | undefined {
|
|
141
|
+
const prefix = `${label}:`;
|
|
142
|
+
const line = lines.find((candidate) =>
|
|
143
|
+
candidate.toLowerCase().startsWith(prefix.toLowerCase())
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return line?.slice(prefix.length).trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readPlainTextSnippet(lines: readonly string[]): string {
|
|
150
|
+
const highlightsIndex = lines.findIndex((line) =>
|
|
151
|
+
line.toLowerCase().startsWith("highlights:")
|
|
152
|
+
);
|
|
153
|
+
const snippetLines =
|
|
154
|
+
highlightsIndex === -1 ? lines : lines.slice(highlightsIndex + 1);
|
|
155
|
+
const skipPrefixes = ["title:", "url:", "published:", "author:"];
|
|
156
|
+
const snippet = snippetLines
|
|
157
|
+
.filter((line) => {
|
|
158
|
+
if (line.startsWith("[...]")) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
const lower = line.toLowerCase();
|
|
162
|
+
return !skipPrefixes.some((prefix) => lower.startsWith(prefix));
|
|
163
|
+
})
|
|
164
|
+
.join(" ")
|
|
165
|
+
.replace(/\s+/gu, " ")
|
|
166
|
+
.trim();
|
|
167
|
+
|
|
168
|
+
return snippet.length > 500 ? `${snippet.slice(0, 500)}...` : snippet;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function findFirstObjectArray(
|
|
172
|
+
values: readonly unknown[]
|
|
173
|
+
): readonly Readonly<Record<string, unknown>>[] | undefined {
|
|
174
|
+
for (const value of values) {
|
|
175
|
+
if (
|
|
176
|
+
Array.isArray(value) &&
|
|
177
|
+
value.every((item) => asRecord(item) !== undefined)
|
|
178
|
+
) {
|
|
179
|
+
return value as readonly Readonly<Record<string, unknown>>[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const record = asRecord(value);
|
|
183
|
+
|
|
184
|
+
if (record === undefined) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const nestedValue of Object.values(record)) {
|
|
189
|
+
if (
|
|
190
|
+
Array.isArray(nestedValue) &&
|
|
191
|
+
nestedValue.every((item) => asRecord(item) !== undefined)
|
|
192
|
+
) {
|
|
193
|
+
return nestedValue as readonly Readonly<Record<string, unknown>>[];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function projectResult(
|
|
202
|
+
result: Readonly<Record<string, unknown>>
|
|
203
|
+
): ExaSearchResult {
|
|
204
|
+
return {
|
|
205
|
+
title: readResultString(result, "title"),
|
|
206
|
+
url: readResultString(result, "url"),
|
|
207
|
+
snippet: readSnippet(result),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function readSnippet(result: Readonly<Record<string, unknown>>): string {
|
|
212
|
+
return (
|
|
213
|
+
readOptionalResultString(result, "snippet") ??
|
|
214
|
+
readOptionalResultString(result, "text") ??
|
|
215
|
+
readOptionalResultString(result, "summary") ??
|
|
216
|
+
""
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readResultString(
|
|
221
|
+
result: Readonly<Record<string, unknown>>,
|
|
222
|
+
name: string
|
|
223
|
+
): string {
|
|
224
|
+
const value = readOptionalResultString(result, name);
|
|
225
|
+
|
|
226
|
+
if (value === undefined) {
|
|
227
|
+
throw new ExaSearchError(`Exa returned a result without ${name}.`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readOptionalResultString(
|
|
234
|
+
result: Readonly<Record<string, unknown>>,
|
|
235
|
+
name: string
|
|
236
|
+
): string | undefined {
|
|
237
|
+
const value = result[name];
|
|
238
|
+
|
|
239
|
+
return typeof value === "string" ? value : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function tryParseJson(text: string): unknown | undefined {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(text);
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function asRecord(
|
|
251
|
+
value: unknown
|
|
252
|
+
): Readonly<Record<string, unknown>> | undefined {
|
|
253
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return value as Readonly<Record<string, unknown>>;
|
|
258
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Renderer,
|
|
4
|
+
type StatefulToolCallTitleContext,
|
|
5
|
+
type StatefulToolCallTitleState,
|
|
6
|
+
} from "../../shared/Renderer";
|
|
7
|
+
import { PimSettings } from "../../shared/PimSettings";
|
|
8
|
+
import { Tools } from "../../shared/Tools";
|
|
9
|
+
import { ExaMcpClient } from "./ExaMcpClient";
|
|
10
|
+
import { formatTitle } from "./render";
|
|
11
|
+
import { type WebSearchInput, webSearchSchema } from "./schema";
|
|
12
|
+
import { clampNumResults, formatResults } from "./search";
|
|
13
|
+
|
|
14
|
+
const PREVIEW_LINES = 6;
|
|
15
|
+
|
|
16
|
+
type WebSearchCallState = StatefulToolCallTitleState & {
|
|
17
|
+
resultCount?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type WebSearchRenderContext = StatefulToolCallTitleContext & {
|
|
21
|
+
readonly args?: WebSearchInput;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function createClient(): Promise<ExaMcpClient> {
|
|
25
|
+
const apiKey = await PimSettings.getExaApiKey();
|
|
26
|
+
return new ExaMcpClient(apiKey ? { apiKey } : {});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderTitle(
|
|
30
|
+
input: Partial<WebSearchInput>,
|
|
31
|
+
theme: Theme,
|
|
32
|
+
context: WebSearchRenderContext
|
|
33
|
+
) {
|
|
34
|
+
const state = context.state as WebSearchCallState;
|
|
35
|
+
const count = state.resultCount ?? clampNumResults(input.numResults);
|
|
36
|
+
return Renderer.renderStatefulToolCallTitle({
|
|
37
|
+
label: "Web Search",
|
|
38
|
+
title: formatTitle(input.query, count),
|
|
39
|
+
theme,
|
|
40
|
+
context,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function (pi: ExtensionAPI): void {
|
|
45
|
+
let clientPromise: Promise<ExaMcpClient> | undefined;
|
|
46
|
+
const getClient = () => (clientPromise ??= createClient());
|
|
47
|
+
|
|
48
|
+
Tools.register(pi, {
|
|
49
|
+
name: "web_search",
|
|
50
|
+
label: "web_search",
|
|
51
|
+
description:
|
|
52
|
+
"Search the web. Returns ranked results with title, URL, and a short snippet.",
|
|
53
|
+
parameters: webSearchSchema,
|
|
54
|
+
renderShell: "self",
|
|
55
|
+
executionMode: "parallel",
|
|
56
|
+
async execute(_id, params, signal) {
|
|
57
|
+
const { query, numResults } = params as WebSearchInput;
|
|
58
|
+
|
|
59
|
+
if (signal?.aborted) {
|
|
60
|
+
throw new Error("Web search aborted before execution.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimmed = query.trim();
|
|
64
|
+
if (trimmed.length === 0) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Web search query is empty. Provide a non-empty query."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const clamped = clampNumResults(numResults);
|
|
71
|
+
const client = await getClient();
|
|
72
|
+
const results = await client.search({
|
|
73
|
+
query: trimmed,
|
|
74
|
+
numResults: clamped,
|
|
75
|
+
...(signal === undefined ? {} : { signal }),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (results.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`No web results for "${trimmed}". Try broader keywords or different phrasing.`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: "text", text: formatResults(results) }],
|
|
86
|
+
details: {
|
|
87
|
+
query: trimmed,
|
|
88
|
+
numResults: clamped,
|
|
89
|
+
count: results.length,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
renderCall(args, theme, context) {
|
|
94
|
+
return renderTitle(
|
|
95
|
+
(args ?? {}) as Partial<WebSearchInput>,
|
|
96
|
+
theme,
|
|
97
|
+
context
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
renderResult(result, options, theme, context) {
|
|
101
|
+
const state = context.state as WebSearchCallState;
|
|
102
|
+
const details = result.details as { readonly count?: number } | undefined;
|
|
103
|
+
|
|
104
|
+
if (details?.count !== undefined) {
|
|
105
|
+
state.resultCount = details.count;
|
|
106
|
+
renderTitle(context.args ?? {}, theme, context);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return Renderer.renderBorderedResult({
|
|
110
|
+
result,
|
|
111
|
+
options,
|
|
112
|
+
theme,
|
|
113
|
+
context,
|
|
114
|
+
previewLines: PREVIEW_LINES,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_NUM_RESULTS } from "./schema";
|
|
3
|
+
import { formatTitle } from "./render";
|
|
4
|
+
|
|
5
|
+
describe("formatTitle", () => {
|
|
6
|
+
test("always includes the default count in parentheses", () => {
|
|
7
|
+
expect(formatTitle("bun release notes", undefined)).toBe(
|
|
8
|
+
`bun release notes (${DEFAULT_NUM_RESULTS})`
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("includes explicit counts in parentheses", () => {
|
|
13
|
+
expect(formatTitle("pi agent", 3)).toBe("pi agent (3)");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("uses a placeholder while keeping the count visible", () => {
|
|
17
|
+
expect(formatTitle(undefined, undefined)).toBe(
|
|
18
|
+
`... (${DEFAULT_NUM_RESULTS})`
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
export const MIN_NUM_RESULTS = 1;
|
|
4
|
+
export const MAX_NUM_RESULTS = 10;
|
|
5
|
+
export const DEFAULT_NUM_RESULTS = 5;
|
|
6
|
+
|
|
7
|
+
export const webSearchSchema = Type.Object({
|
|
8
|
+
query: Type.String({
|
|
9
|
+
minLength: 1,
|
|
10
|
+
description: "Search query. Prefer natural language over keywords.",
|
|
11
|
+
}),
|
|
12
|
+
numResults: Type.Optional(
|
|
13
|
+
Type.Integer({
|
|
14
|
+
minimum: MIN_NUM_RESULTS,
|
|
15
|
+
maximum: MAX_NUM_RESULTS,
|
|
16
|
+
description: `Number of results, ${MIN_NUM_RESULTS}-${MAX_NUM_RESULTS}. Defaults to ${DEFAULT_NUM_RESULTS}.`,
|
|
17
|
+
})
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type WebSearchInput = Static<typeof webSearchSchema>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clampNumResults, formatResults } from "./search";
|
|
3
|
+
|
|
4
|
+
describe("clampNumResults", () => {
|
|
5
|
+
test("defaults when undefined", () => {
|
|
6
|
+
expect(clampNumResults(undefined)).toBe(5);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("clamps above maximum", () => {
|
|
10
|
+
expect(clampNumResults(25)).toBe(10);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("clamps below minimum", () => {
|
|
14
|
+
expect(clampNumResults(0)).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("passes through valid values", () => {
|
|
18
|
+
expect(clampNumResults(3)).toBe(3);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("formatResults", () => {
|
|
23
|
+
test("renders results as deterministic plain text", () => {
|
|
24
|
+
expect(
|
|
25
|
+
formatResults([
|
|
26
|
+
{
|
|
27
|
+
title: "First",
|
|
28
|
+
url: "https://example.test/first",
|
|
29
|
+
snippet: "First snippet.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
title: "Second",
|
|
33
|
+
url: "https://example.test/second",
|
|
34
|
+
snippet: "Second snippet.",
|
|
35
|
+
},
|
|
36
|
+
])
|
|
37
|
+
).toBe(
|
|
38
|
+
[
|
|
39
|
+
"title: First",
|
|
40
|
+
"url: https://example.test/first",
|
|
41
|
+
"snippet: First snippet.",
|
|
42
|
+
"",
|
|
43
|
+
"title: Second",
|
|
44
|
+
"url: https://example.test/second",
|
|
45
|
+
"snippet: Second snippet.",
|
|
46
|
+
].join("\n")
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns empty string for empty input", () => {
|
|
51
|
+
expect(formatResults([])).toBe("");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExaSearchResult } from "./ExaMcpClient";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_NUM_RESULTS,
|
|
4
|
+
MAX_NUM_RESULTS,
|
|
5
|
+
MIN_NUM_RESULTS,
|
|
6
|
+
} from "./schema";
|
|
7
|
+
|
|
8
|
+
export function clampNumResults(value: number | undefined): number {
|
|
9
|
+
const requested = value ?? DEFAULT_NUM_RESULTS;
|
|
10
|
+
return Math.min(MAX_NUM_RESULTS, Math.max(MIN_NUM_RESULTS, requested));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatResults(results: readonly ExaSearchResult[]): string {
|
|
14
|
+
return results
|
|
15
|
+
.map((result) =>
|
|
16
|
+
[
|
|
17
|
+
`title: ${result.title}`,
|
|
18
|
+
`url: ${result.url}`,
|
|
19
|
+
`snippet: ${result.snippet}`,
|
|
20
|
+
].join("\n")
|
|
21
|
+
)
|
|
22
|
+
.join("\n\n");
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatElapsed } from "./index";
|
|
3
|
+
|
|
4
|
+
describe("working indicator formatting", () => {
|
|
5
|
+
test("formats sub-minute elapsed time", () => {
|
|
6
|
+
expect(formatElapsed(0)).toBe("0s");
|
|
7
|
+
expect(formatElapsed(999)).toBe("0s");
|
|
8
|
+
expect(formatElapsed(32_000)).toBe("32s");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("formats minute elapsed time", () => {
|
|
12
|
+
expect(formatElapsed(60_000)).toBe("1m 0s");
|
|
13
|
+
expect(formatElapsed(92_000)).toBe("1m 32s");
|
|
14
|
+
expect(formatElapsed(3_599_000)).toBe("59m 59s");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("formats hour elapsed time", () => {
|
|
18
|
+
expect(formatElapsed(3_600_000)).toBe("1h 0m 0s");
|
|
19
|
+
expect(formatElapsed(3_692_000)).toBe("1h 1m 32s");
|
|
20
|
+
});
|
|
21
|
+
});
|