pim-agent 0.0.1 → 0.9.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/LICENSE +21 -0
- package/README.md +293 -7
- package/bin/pim.ts +151 -0
- package/package.json +82 -4
- package/packages/core/src/attachments/AttachmentStore.ts +99 -0
- package/packages/core/src/attachments/Attachments.ts +69 -0
- package/packages/core/src/extensions/CoreExtensions.ts +51 -0
- package/packages/core/src/extensions/apply-patch/coordinator.ts +53 -0
- package/packages/core/src/extensions/apply-patch/executor.ts +550 -0
- package/packages/core/src/extensions/apply-patch/index.ts +69 -0
- package/packages/core/src/extensions/apply-patch/matcher.ts +47 -0
- package/packages/core/src/extensions/apply-patch/model.ts +39 -0
- package/packages/core/src/extensions/apply-patch/parser.ts +379 -0
- package/packages/core/src/extensions/apply-patch/render.ts +216 -0
- package/packages/core/src/extensions/apply-patch/schema.ts +37 -0
- package/packages/core/src/extensions/apply-patch/types.ts +30 -0
- package/packages/core/src/extensions/bash/capture.ts +86 -0
- package/packages/core/src/extensions/bash/format.ts +110 -0
- package/packages/core/src/extensions/bash/image.ts +31 -0
- package/packages/core/src/extensions/bash/index.ts +88 -0
- package/packages/core/src/extensions/bash/render.ts +31 -0
- package/packages/core/src/extensions/bash/run.ts +221 -0
- package/packages/core/src/extensions/bash/schema.ts +64 -0
- package/packages/core/src/extensions/edit/edit.ts +366 -0
- package/packages/core/src/extensions/edit/index.ts +36 -0
- package/packages/core/src/extensions/edit/render.ts +16 -0
- package/packages/core/src/extensions/edit/schema.ts +37 -0
- package/packages/core/src/extensions/glob/glob.ts +34 -0
- package/packages/core/src/extensions/glob/index.ts +83 -0
- package/packages/core/src/extensions/glob/render.ts +72 -0
- package/packages/core/src/extensions/glob/schema.ts +66 -0
- package/packages/core/src/extensions/grep/grep.ts +256 -0
- package/packages/core/src/extensions/grep/index.ts +114 -0
- package/packages/core/src/extensions/grep/render.ts +271 -0
- package/packages/core/src/extensions/grep/schema.ts +92 -0
- package/packages/core/src/extensions/read/ImageMemory.ts +42 -0
- package/packages/core/src/extensions/read/image.ts +57 -0
- package/packages/core/src/extensions/read/index.ts +88 -0
- package/packages/core/src/extensions/read/read.ts +191 -0
- package/packages/core/src/extensions/read/render.ts +58 -0
- package/packages/core/src/extensions/read/schema.ts +49 -0
- package/packages/core/src/extensions/subagent/index.ts +33 -0
- package/packages/core/src/extensions/subagent/render.ts +127 -0
- package/packages/core/src/extensions/subagent/schema.ts +9 -0
- package/packages/core/src/extensions/subagent/subagent.ts +419 -0
- package/packages/core/src/extensions/system-prompt/index.ts +28 -0
- package/packages/core/src/extensions/system-prompt/prompt.ts +228 -0
- package/packages/core/src/extensions/todo/index.ts +123 -0
- package/packages/core/src/extensions/todo/render.ts +156 -0
- package/packages/core/src/extensions/todo/schema.ts +24 -0
- package/packages/core/src/extensions/todo/todo.ts +185 -0
- package/packages/core/src/extensions/web-fetch/JinaReaderClient.ts +187 -0
- package/packages/core/src/extensions/web-fetch/WebViewFetchClient.ts +183 -0
- package/packages/core/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +512 -0
- package/packages/core/src/extensions/web-fetch/fetch.ts +265 -0
- package/packages/core/src/extensions/web-fetch/image.ts +165 -0
- package/packages/core/src/extensions/web-fetch/index.ts +83 -0
- package/packages/core/src/extensions/web-fetch/render.ts +100 -0
- package/packages/core/src/extensions/web-fetch/schema.ts +46 -0
- package/packages/core/src/extensions/web-search/ExaMcpClient.ts +251 -0
- package/packages/core/src/extensions/web-search/SearchBreaker.ts +147 -0
- package/packages/core/src/extensions/web-search/SearchChain.ts +77 -0
- package/packages/core/src/extensions/web-search/index.ts +90 -0
- package/packages/core/src/extensions/web-search/providers/DuckDuckGoProvider.ts +195 -0
- package/packages/core/src/extensions/web-search/providers/ExaProvider.ts +54 -0
- package/packages/core/src/extensions/web-search/providers/FirecrawlProvider.ts +144 -0
- package/packages/core/src/extensions/web-search/providers/SearchProvider.ts +118 -0
- package/packages/core/src/extensions/web-search/render.ts +49 -0
- package/packages/core/src/extensions/web-search/schema.ts +29 -0
- package/packages/core/src/extensions/web-search/search.ts +23 -0
- package/packages/core/src/extensions/write/index.ts +58 -0
- package/packages/core/src/extensions/write/render.ts +16 -0
- package/packages/core/src/extensions/write/schema.ts +14 -0
- package/packages/core/src/extensions/write/write.ts +87 -0
- package/packages/core/src/picker/FilePickerSuggestionEngine.ts +14 -0
- package/packages/core/src/picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/packages/core/src/picker/PickerItem.ts +6 -0
- package/packages/core/src/picker/PickerService.ts +110 -0
- package/packages/core/src/picker/RemoteFilePickerSuggestionEngine.ts +56 -0
- package/packages/core/src/picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/packages/core/src/picker/catalog.ts +220 -0
- package/packages/core/src/picker/commandRanker.ts +17 -0
- package/packages/core/src/picker/filePickerWorker.ts +66 -0
- package/packages/core/src/picker/filePickerWorkerMessages.ts +39 -0
- package/packages/core/src/picker/ranker.ts +245 -0
- package/packages/core/src/picker/token.ts +70 -0
- package/packages/core/src/session/AgentRuntime.ts +85 -0
- package/packages/core/src/session/EventLog.ts +213 -0
- package/packages/core/src/session/MessageText.ts +21 -0
- package/packages/core/src/session/ReadCursors.ts +94 -0
- package/packages/core/src/session/SessionCache.ts +68 -0
- package/packages/core/src/session/SessionHost.ts +876 -0
- package/packages/core/src/session/SessionLease.ts +279 -0
- package/packages/core/src/session/SessionRegistry.ts +169 -0
- package/packages/core/src/session/WriteMark.ts +37 -0
- package/packages/core/src/shared/Cli.ts +25 -0
- package/packages/core/src/shared/DaemonUnit.ts +13 -0
- package/packages/core/src/shared/DiffLines.ts +304 -0
- package/packages/core/src/shared/DiffRenderer.ts +283 -0
- package/packages/core/src/shared/DiffView.ts +29 -0
- package/packages/core/src/shared/Directories.ts +61 -0
- package/packages/core/src/shared/EditMatcher.ts +767 -0
- package/packages/core/src/shared/Errors.ts +9 -0
- package/packages/core/src/shared/ExtensionToggles.ts +130 -0
- package/packages/core/src/shared/FileEnumerator.ts +361 -0
- package/packages/core/src/shared/FileScanner.ts +57 -0
- package/packages/core/src/shared/FileWatch.ts +107 -0
- package/packages/core/src/shared/Format.ts +74 -0
- package/packages/core/src/shared/Fs.ts +91 -0
- package/packages/core/src/shared/FsErrors.ts +67 -0
- package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
- package/packages/core/src/shared/Git.ts +89 -0
- package/packages/core/src/shared/GlobExclusions.ts +13 -0
- package/packages/core/src/shared/Http.ts +14 -0
- package/packages/core/src/shared/ImageMime.ts +36 -0
- package/packages/core/src/shared/Images.ts +357 -0
- package/packages/core/src/shared/Json.ts +19 -0
- package/packages/core/src/shared/Languages.ts +95 -0
- package/packages/core/src/shared/Levenshtein.ts +30 -0
- package/packages/core/src/shared/Lines.ts +79 -0
- package/packages/core/src/shared/McpClient.ts +444 -0
- package/packages/core/src/shared/OutputBudget.ts +84 -0
- package/packages/core/src/shared/PatchSummary.ts +40 -0
- package/packages/core/src/shared/Paths.ts +80 -0
- package/packages/core/src/shared/PimSettings.ts +128 -0
- package/packages/core/src/shared/PimVersion.ts +93 -0
- package/packages/core/src/shared/Pool.ts +26 -0
- package/packages/core/src/shared/Proc.ts +30 -0
- package/packages/core/src/shared/RateLimiter.ts +50 -0
- package/packages/core/src/shared/Renderer.ts +371 -0
- package/packages/core/src/shared/SafePath.ts +17 -0
- package/packages/core/src/shared/SearchRender.ts +82 -0
- package/packages/core/src/shared/SendFile.ts +23 -0
- package/packages/core/src/shared/SpillCache.ts +78 -0
- package/packages/core/src/shared/SubagentLogs.ts +102 -0
- package/packages/core/src/shared/Supervisor.ts +460 -0
- package/packages/core/src/shared/Sweeper.ts +32 -0
- package/packages/core/src/shared/Tools.ts +801 -0
- package/packages/core/src/shared/Updater.ts +193 -0
- package/packages/core/src/view/AnsiPainter.ts +227 -0
- package/packages/core/src/view/BodyRenderer.ts +102 -0
- package/packages/core/src/view/DiffBlocks.ts +52 -0
- package/packages/core/src/view/DiffLayout.ts +69 -0
- package/packages/core/src/view/MarkdownPainter.ts +314 -0
- package/packages/core/src/view/Painting.ts +182 -0
- package/packages/core/src/view/ViewBlock.ts +124 -0
- package/packages/daemon/src/Daemon.ts +63 -0
- package/packages/daemon/src/DaemonInstall.ts +115 -0
- package/packages/daemon/src/Surfaces.ts +66 -0
- package/packages/daemon/src/TelegramSurface.ts +30 -0
- package/packages/daemon/src/WebSurface.ts +57 -0
- package/packages/daemon/src/index.ts +37 -0
- package/packages/protocol/src/Command.ts +92 -0
- package/packages/protocol/src/ImageRoute.ts +23 -0
- package/packages/protocol/src/Protocol.ts +6 -0
- package/packages/protocol/src/ServerEvent.ts +251 -0
- package/packages/server/src/AttachmentEndpoint.ts +184 -0
- package/packages/server/src/ClientConnection.ts +265 -0
- package/packages/server/src/ImageEndpoint.ts +36 -0
- package/packages/server/src/ProbeClient.ts +312 -0
- package/packages/server/src/Reloader.ts +93 -0
- package/packages/server/src/SendFileTool.ts +88 -0
- package/packages/server/src/SessionCatalogue.ts +202 -0
- package/packages/server/src/SessionProjection.ts +194 -0
- package/packages/server/src/SessionStream.ts +601 -0
- package/packages/server/src/StaticClient.ts +95 -0
- package/packages/server/src/WebOptions.ts +72 -0
- package/packages/server/src/WsGateway.ts +563 -0
- package/packages/server/src/probe.ts +146 -0
- package/packages/telegram/src/Bot.ts +229 -0
- package/packages/telegram/src/Commands.ts +768 -0
- package/packages/telegram/src/Config.ts +148 -0
- package/packages/telegram/src/Markdown.ts +235 -0
- package/packages/telegram/src/Message.ts +152 -0
- package/packages/telegram/src/Renderer.ts +456 -0
- package/packages/telegram/src/SendFileSchema.ts +18 -0
- package/packages/telegram/src/SendFileTool.ts +65 -0
- package/packages/telegram/src/Session.ts +252 -0
- package/packages/telegram/src/SessionRegistry.ts +126 -0
- package/packages/telegram/src/TaskScheduler.ts +296 -0
- package/packages/telegram/src/TaskSchema.ts +88 -0
- package/packages/telegram/src/TaskStore.ts +67 -0
- package/packages/telegram/src/TaskTool.ts +208 -0
- package/packages/telegram/src/TelegramHtml.ts +288 -0
- package/packages/telegram/src/TypingIndicator.ts +43 -0
- package/packages/telegram/src/UpdateConfirm.ts +61 -0
- package/packages/tui/src/extensions/_init/index.ts +144 -0
- package/packages/tui/src/extensions/command-picker/index.ts +43 -0
- package/packages/tui/src/extensions/file-picker/index.ts +156 -0
- package/packages/tui/src/extensions/footer/index.ts +117 -0
- package/packages/tui/src/extensions/footer/powerline.ts +87 -0
- package/packages/tui/src/extensions/footer/segments.ts +202 -0
- package/packages/tui/src/extensions/pim/index.ts +89 -0
- package/packages/tui/src/extensions/session-lease/index.ts +248 -0
- package/packages/tui/src/extensions/tps/index.ts +118 -0
- package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
- package/packages/tui/src/extensions/wrapProvider.ts +18 -0
- package/packages/tui/src/themes/pim-dark.json +84 -0
- package/packages/tui/src/themes/pim-light.json +84 -0
- package/packages/tui/src/themes/themeCliArgs.ts +20 -0
- package/packages/web/dist/client/apple-touch-icon.png +0 -0
- package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
- package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
- package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
- package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/core-CGdQx9la.js +3 -0
- package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
- package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
- package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
- package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
- package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
- package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
- package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
- package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
- package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
- package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +40 -0
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +1 -0
- package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
- package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
- package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
- package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
- package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
- package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
- package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
- package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
- package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
- package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
- package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
- package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
- package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
- package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
- package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
- package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
- package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
- package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
- package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
- package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
- package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
- package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
- package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
- package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
- package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
- package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
- package/packages/web/dist/client/favicon.svg +1 -0
- package/packages/web/dist/client/icon-192.png +0 -0
- package/packages/web/dist/client/icon-512.png +0 -0
- package/packages/web/dist/client/icon-maskable.png +0 -0
- package/packages/web/dist/client/index.html +24 -0
- package/packages/web/dist/client/manifest.webmanifest +21 -0
- package/packages/web/dist/client/wordmark.svg +1 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Json } from "../../shared/Json";
|
|
2
|
+
import { McpClient, type McpFetch } from "../../shared/McpClient";
|
|
3
|
+
import { RateLimiter } from "../../shared/RateLimiter";
|
|
4
|
+
import {
|
|
5
|
+
normalizeSnippet,
|
|
6
|
+
type SearchResult,
|
|
7
|
+
} from "./providers/SearchProvider";
|
|
8
|
+
|
|
9
|
+
type ExaMcpClientOptions = {
|
|
10
|
+
readonly endpoint?: string;
|
|
11
|
+
readonly apiKey?: string;
|
|
12
|
+
readonly fetch?: McpFetch;
|
|
13
|
+
readonly rateLimiter?: RateLimiter;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ExaSearchInput = {
|
|
17
|
+
readonly query: string;
|
|
18
|
+
readonly numResults: number;
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class ExaSearchError extends Error {
|
|
23
|
+
public constructor(message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ExaSearchError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultEndpoint = "https://mcp.exa.ai/mcp";
|
|
30
|
+
const toolName = "web_search_exa";
|
|
31
|
+
// Stay under the keyless endpoint's 2 req/s window, or a 429 no longer means the daily cap.
|
|
32
|
+
const maxRequestsPerWindow = 2;
|
|
33
|
+
const windowMs = 1100;
|
|
34
|
+
|
|
35
|
+
export class ExaMcpClient {
|
|
36
|
+
private readonly client: McpClient;
|
|
37
|
+
|
|
38
|
+
public constructor(options: ExaMcpClientOptions = {}) {
|
|
39
|
+
const apiKey =
|
|
40
|
+
options.apiKey === undefined || options.apiKey.length === 0
|
|
41
|
+
? undefined
|
|
42
|
+
: options.apiKey;
|
|
43
|
+
// Throttle only on the free tier; an API key lifts the rate limit.
|
|
44
|
+
const rateLimiter =
|
|
45
|
+
apiKey !== undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: (options.rateLimiter ??
|
|
48
|
+
new RateLimiter({
|
|
49
|
+
maxRequests: maxRequestsPerWindow,
|
|
50
|
+
windowMs: windowMs,
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
this.client = new McpClient({
|
|
54
|
+
endpoint: options.endpoint ?? defaultEndpoint,
|
|
55
|
+
...(apiKey === undefined ? {} : { headers: { "x-api-key": apiKey } }),
|
|
56
|
+
...(rateLimiter === undefined ? {} : { rateLimiter }),
|
|
57
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public async search(input: ExaSearchInput): Promise<readonly SearchResult[]> {
|
|
62
|
+
const result = await this.client.callTool({
|
|
63
|
+
name: toolName,
|
|
64
|
+
arguments: {
|
|
65
|
+
query: input.query,
|
|
66
|
+
numResults: input.numResults,
|
|
67
|
+
},
|
|
68
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return extractResults(result);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractResults(result: unknown): readonly SearchResult[] {
|
|
76
|
+
const record = Json.asRecord(result);
|
|
77
|
+
const content = record?.["content"];
|
|
78
|
+
|
|
79
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
80
|
+
throw new ExaSearchError("Exa returned malformed tool content.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const textBlocks = content.map(readTextBlock);
|
|
84
|
+
const plainTextResults = extractPlainTextResults(textBlocks);
|
|
85
|
+
|
|
86
|
+
if (plainTextResults !== undefined) {
|
|
87
|
+
return plainTextResults;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resultObjects = findFirstObjectArray(
|
|
91
|
+
textBlocks
|
|
92
|
+
.map((block) => Json.tryParseJson(block))
|
|
93
|
+
.filter((value) => value !== undefined)
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (resultObjects === undefined) {
|
|
97
|
+
throw new ExaSearchError("Exa returned malformed search results.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return resultObjects.map(projectResult);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readTextBlock(block: unknown): string {
|
|
104
|
+
const record = Json.asRecord(block);
|
|
105
|
+
|
|
106
|
+
if (record?.["type"] !== "text" || typeof record["text"] !== "string") {
|
|
107
|
+
throw new ExaSearchError("Exa returned malformed tool content.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return record["text"];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractPlainTextResults(
|
|
114
|
+
textBlocks: readonly string[]
|
|
115
|
+
): readonly SearchResult[] | undefined {
|
|
116
|
+
for (const textBlock of textBlocks) {
|
|
117
|
+
const blocks = textBlock
|
|
118
|
+
.split(/\n---\n/u)
|
|
119
|
+
.map((block) => block.trim())
|
|
120
|
+
.filter((block) => block.length > 0);
|
|
121
|
+
const results = blocks
|
|
122
|
+
.map((block) => parsePlainTextResult(block))
|
|
123
|
+
.filter((result) => result !== undefined);
|
|
124
|
+
|
|
125
|
+
if (results.length > 0) {
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parsePlainTextResult(block: string): SearchResult | undefined {
|
|
134
|
+
const lines = block
|
|
135
|
+
.split(/\r?\n/u)
|
|
136
|
+
.map((line) => line.trim())
|
|
137
|
+
.filter((line) => line.length > 0);
|
|
138
|
+
const title = readLabeledLine(lines, "Title");
|
|
139
|
+
const url = readLabeledLine(lines, "URL");
|
|
140
|
+
|
|
141
|
+
if (title === undefined || url === undefined) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
title,
|
|
147
|
+
url,
|
|
148
|
+
snippet: normalizeSnippet(readPlainTextSnippet(lines)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readLabeledLine(
|
|
153
|
+
lines: readonly string[],
|
|
154
|
+
label: string
|
|
155
|
+
): string | undefined {
|
|
156
|
+
const prefix = `${label}:`;
|
|
157
|
+
const line = lines.find((candidate) =>
|
|
158
|
+
candidate.toLowerCase().startsWith(prefix.toLowerCase())
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return line?.slice(prefix.length).trim();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readPlainTextSnippet(lines: readonly string[]): string {
|
|
165
|
+
const highlightsIndex = lines.findIndex((line) =>
|
|
166
|
+
line.toLowerCase().startsWith("highlights:")
|
|
167
|
+
);
|
|
168
|
+
const snippetLines =
|
|
169
|
+
highlightsIndex === -1 ? lines : lines.slice(highlightsIndex + 1);
|
|
170
|
+
const skipPrefixes = ["title:", "url:", "published:", "author:"];
|
|
171
|
+
return snippetLines
|
|
172
|
+
.filter((line) => {
|
|
173
|
+
if (line.startsWith("[...]")) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const lower = line.toLowerCase();
|
|
177
|
+
return !skipPrefixes.some((prefix) => lower.startsWith(prefix));
|
|
178
|
+
})
|
|
179
|
+
.join(" ");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function findFirstObjectArray(
|
|
183
|
+
values: readonly unknown[]
|
|
184
|
+
): readonly Readonly<Record<string, unknown>>[] | undefined {
|
|
185
|
+
for (const value of values) {
|
|
186
|
+
if (
|
|
187
|
+
Array.isArray(value) &&
|
|
188
|
+
value.every((item) => Json.asRecord(item) !== undefined)
|
|
189
|
+
) {
|
|
190
|
+
return value as readonly Readonly<Record<string, unknown>>[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const record = Json.asRecord(value);
|
|
194
|
+
|
|
195
|
+
if (record === undefined) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const nestedValue of Object.values(record)) {
|
|
200
|
+
if (
|
|
201
|
+
Array.isArray(nestedValue) &&
|
|
202
|
+
nestedValue.every((item) => Json.asRecord(item) !== undefined)
|
|
203
|
+
) {
|
|
204
|
+
return nestedValue as readonly Readonly<Record<string, unknown>>[];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function projectResult(
|
|
213
|
+
result: Readonly<Record<string, unknown>>
|
|
214
|
+
): SearchResult {
|
|
215
|
+
return {
|
|
216
|
+
title: readResultString(result, "title"),
|
|
217
|
+
url: readResultString(result, "url"),
|
|
218
|
+
snippet: normalizeSnippet(readSnippet(result)),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readSnippet(result: Readonly<Record<string, unknown>>): string {
|
|
223
|
+
return (
|
|
224
|
+
readOptionalResultString(result, "snippet") ??
|
|
225
|
+
readOptionalResultString(result, "text") ??
|
|
226
|
+
readOptionalResultString(result, "summary") ??
|
|
227
|
+
""
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readResultString(
|
|
232
|
+
result: Readonly<Record<string, unknown>>,
|
|
233
|
+
name: string
|
|
234
|
+
): string {
|
|
235
|
+
const value = readOptionalResultString(result, name);
|
|
236
|
+
|
|
237
|
+
if (value === undefined) {
|
|
238
|
+
throw new ExaSearchError(`Exa returned a result without ${name}.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return value;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readOptionalResultString(
|
|
245
|
+
result: Readonly<Record<string, unknown>>,
|
|
246
|
+
name: string
|
|
247
|
+
): string | undefined {
|
|
248
|
+
const value = result[name];
|
|
249
|
+
|
|
250
|
+
return typeof value === "string" ? value : undefined;
|
|
251
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import { Fs } from "../../shared/Fs";
|
|
4
|
+
import { Paths } from "../../shared/Paths";
|
|
5
|
+
|
|
6
|
+
type BreakerEntry = {
|
|
7
|
+
readonly until: number;
|
|
8
|
+
readonly nextProbeAt: number;
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type BreakerState = Record<string, BreakerEntry>;
|
|
13
|
+
|
|
14
|
+
export type SearchBreakerOptions = {
|
|
15
|
+
readonly path?: string;
|
|
16
|
+
readonly now?: () => number;
|
|
17
|
+
readonly probeIntervalMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TripInput = {
|
|
21
|
+
readonly provider: string;
|
|
22
|
+
readonly reason: string;
|
|
23
|
+
readonly retryAfterMs?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
|
+
|
|
28
|
+
const defaultProbeIntervalMs = 1_800_000;
|
|
29
|
+
|
|
30
|
+
/** Which providers are quota-exhausted, on disk; quota is per-IP-per-day, so trips expire at UTC midnight. */
|
|
31
|
+
export class SearchBreaker {
|
|
32
|
+
private readonly filePath: string;
|
|
33
|
+
private readonly now: () => number;
|
|
34
|
+
private readonly probeIntervalMs: number;
|
|
35
|
+
private readonly writes = Fs.serialised();
|
|
36
|
+
|
|
37
|
+
public constructor(options: SearchBreakerOptions = {}) {
|
|
38
|
+
this.filePath =
|
|
39
|
+
options.path ?? join(Paths.pimHomeDir(), "web-search-breaker.json");
|
|
40
|
+
this.now = options.now ?? Date.now;
|
|
41
|
+
this.probeIntervalMs = options.probeIntervalMs ?? defaultProbeIntervalMs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public async isOpen(provider: string): Promise<boolean> {
|
|
45
|
+
const entry = (await this.read())[provider];
|
|
46
|
+
|
|
47
|
+
if (entry === undefined) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const now = this.now();
|
|
52
|
+
|
|
53
|
+
return now < entry.until && now < entry.nextProbeAt;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public async trip(input: TripInput): Promise<void> {
|
|
57
|
+
const now = this.now();
|
|
58
|
+
const ttlMs = input.retryAfterMs ?? msUntilNextUtcMidnight(now);
|
|
59
|
+
const until = now + Math.max(0, ttlMs);
|
|
60
|
+
|
|
61
|
+
await this.mutate((state) => ({
|
|
62
|
+
...state,
|
|
63
|
+
[input.provider]: {
|
|
64
|
+
until,
|
|
65
|
+
nextProbeAt: Math.min(until, now + this.probeIntervalMs),
|
|
66
|
+
reason: input.reason,
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async reset(provider: string): Promise<void> {
|
|
72
|
+
await this.mutate((state) => {
|
|
73
|
+
if (state[provider] === undefined) {
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { [provider]: _removed, ...rest } = state;
|
|
78
|
+
return rest;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async read(): Promise<BreakerState> {
|
|
83
|
+
let raw: unknown;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
raw = await Bun.file(this.filePath).json();
|
|
87
|
+
} catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const now = this.now();
|
|
96
|
+
const state: BreakerState = {};
|
|
97
|
+
|
|
98
|
+
for (const [provider, value] of Object.entries(raw)) {
|
|
99
|
+
const entry = parseEntry(value);
|
|
100
|
+
|
|
101
|
+
if (entry !== undefined && entry.until > now) {
|
|
102
|
+
state[provider] = entry;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async mutate(
|
|
110
|
+
update: (state: BreakerState) => BreakerState
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await this.writes.run(async () => {
|
|
113
|
+
const state = await this.read();
|
|
114
|
+
const next = update(state);
|
|
115
|
+
|
|
116
|
+
if (next === state) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await Paths.ensurePimHome();
|
|
121
|
+
await Fs.writeJson(this.filePath, next);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseEntry(value: unknown): BreakerEntry | undefined {
|
|
127
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const record = value as Record<string, unknown>;
|
|
132
|
+
const { until, nextProbeAt, reason } = record;
|
|
133
|
+
|
|
134
|
+
if (typeof until !== "number" || typeof nextProbeAt !== "number") {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
until,
|
|
140
|
+
nextProbeAt,
|
|
141
|
+
reason: typeof reason === "string" ? reason : "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function msUntilNextUtcMidnight(now: number): number {
|
|
146
|
+
return DAY_MS - (now % DAY_MS);
|
|
147
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Errors } from "../../shared/Errors";
|
|
2
|
+
import type { SearchBreaker } from "./SearchBreaker";
|
|
3
|
+
import {
|
|
4
|
+
ProviderQuotaError,
|
|
5
|
+
type ProviderSearchInput,
|
|
6
|
+
type SearchProvider,
|
|
7
|
+
type SearchResult,
|
|
8
|
+
} from "./providers/SearchProvider";
|
|
9
|
+
|
|
10
|
+
export type ChainSearchOutcome = {
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
readonly results: readonly SearchResult[];
|
|
13
|
+
readonly fellBack: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SearchChainOptions = {
|
|
17
|
+
readonly providers: readonly SearchProvider[];
|
|
18
|
+
readonly breaker: SearchBreaker;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Tries providers in order until one answers; an empty result set is an answer and ends the chain. */
|
|
22
|
+
export class SearchChain {
|
|
23
|
+
private readonly providers: readonly SearchProvider[];
|
|
24
|
+
private readonly breaker: SearchBreaker;
|
|
25
|
+
|
|
26
|
+
public constructor(options: SearchChainOptions) {
|
|
27
|
+
this.providers = options.providers;
|
|
28
|
+
this.breaker = options.breaker;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async search(input: ProviderSearchInput): Promise<ChainSearchOutcome> {
|
|
32
|
+
const failures: string[] = [];
|
|
33
|
+
let attempted = false;
|
|
34
|
+
|
|
35
|
+
for (const provider of this.providers) {
|
|
36
|
+
if (await this.breaker.isOpen(provider.name)) {
|
|
37
|
+
failures.push(`${provider.name}: skipped (quota exhausted)`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (input.signal?.aborted) {
|
|
42
|
+
throw new Error("Web search aborted before execution.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const results = await provider.search(input);
|
|
47
|
+
await this.breaker.reset(provider.name);
|
|
48
|
+
|
|
49
|
+
return { provider: provider.name, results, fellBack: attempted };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (input.signal?.aborted || Errors.isAbort(error)) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
attempted = true;
|
|
56
|
+
|
|
57
|
+
if (error instanceof ProviderQuotaError) {
|
|
58
|
+
await this.breaker.trip({
|
|
59
|
+
provider: provider.name,
|
|
60
|
+
reason: error.message,
|
|
61
|
+
...(error.retryAfterMs === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { retryAfterMs: error.retryAfterMs }),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
failures.push(`${provider.name}: ${Errors.describe(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(
|
|
72
|
+
`All web search providers failed:\n${failures
|
|
73
|
+
.map((failure) => ` - ${failure}`)
|
|
74
|
+
.join("\n")}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PimSettings } from "../../shared/PimSettings";
|
|
3
|
+
import { Tools } from "../../shared/Tools";
|
|
4
|
+
import { DuckDuckGoProvider } from "./providers/DuckDuckGoProvider";
|
|
5
|
+
import { ExaProvider } from "./providers/ExaProvider";
|
|
6
|
+
import { FirecrawlProvider } from "./providers/FirecrawlProvider";
|
|
7
|
+
import { webSearchView } from "./render";
|
|
8
|
+
import { SearchBreaker } from "./SearchBreaker";
|
|
9
|
+
import { SearchChain } from "./SearchChain";
|
|
10
|
+
import { type WebSearchInput, webSearchSchema } from "./schema";
|
|
11
|
+
import { clampNumResults, formatResults } from "./search";
|
|
12
|
+
|
|
13
|
+
async function createChain(): Promise<SearchChain> {
|
|
14
|
+
const [exaApiKey, firecrawlApiKey, jinaApiKey] = await Promise.all([
|
|
15
|
+
PimSettings.getExaApiKey(),
|
|
16
|
+
PimSettings.getFirecrawlApiKey(),
|
|
17
|
+
PimSettings.getJinaApiKey(),
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
return new SearchChain({
|
|
21
|
+
breaker: new SearchBreaker(),
|
|
22
|
+
providers: [
|
|
23
|
+
new ExaProvider(exaApiKey === undefined ? {} : { apiKey: exaApiKey }),
|
|
24
|
+
new FirecrawlProvider(
|
|
25
|
+
firecrawlApiKey === undefined ? {} : { apiKey: firecrawlApiKey }
|
|
26
|
+
),
|
|
27
|
+
new DuckDuckGoProvider(
|
|
28
|
+
jinaApiKey === undefined ? {} : { apiKey: jinaApiKey }
|
|
29
|
+
),
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function (pi: ExtensionAPI): void {
|
|
35
|
+
let chainPromise: Promise<SearchChain> | undefined;
|
|
36
|
+
const getChain = () => (chainPromise ??= createChain());
|
|
37
|
+
|
|
38
|
+
Tools.register(pi, {
|
|
39
|
+
name: "web_search",
|
|
40
|
+
label: "web_search",
|
|
41
|
+
description:
|
|
42
|
+
"Search the web. " +
|
|
43
|
+
"Returns ranked results with title, URL, and a short snippet.",
|
|
44
|
+
parameters: webSearchSchema,
|
|
45
|
+
renderShell: "self",
|
|
46
|
+
effect: { kind: "readOnly" },
|
|
47
|
+
executionMode: "parallel",
|
|
48
|
+
async execute(_id, params, signal) {
|
|
49
|
+
const { query, numResults } = params as WebSearchInput;
|
|
50
|
+
|
|
51
|
+
if (signal?.aborted) {
|
|
52
|
+
throw new Error("Web search aborted before execution.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trimmed = query.trim();
|
|
56
|
+
if (trimmed.length === 0) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"Web search query is empty. Provide a non-empty query."
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const clamped = clampNumResults(numResults);
|
|
63
|
+
const chain = await getChain();
|
|
64
|
+
const outcome = await chain.search({
|
|
65
|
+
query: trimmed,
|
|
66
|
+
numResults: clamped,
|
|
67
|
+
...(signal === undefined ? {} : { signal }),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (outcome.results.length === 0) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`No web results for "${trimmed}" (via ${outcome.provider}). ` +
|
|
73
|
+
"Try broader keywords or different phrasing."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: formatResults(outcome.results) }],
|
|
79
|
+
details: {
|
|
80
|
+
query: trimmed,
|
|
81
|
+
numResults: clamped,
|
|
82
|
+
count: outcome.results.length,
|
|
83
|
+
provider: outcome.provider,
|
|
84
|
+
fellBack: outcome.fellBack,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
toViewModel: webSearchView,
|
|
89
|
+
});
|
|
90
|
+
}
|