summaryrefslogtreecommitdiff
path: root/src/index.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.ts')
-rw-r--r--src/index.ts198
1 files changed, 198 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..ad0ba6f
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,198 @@
+/**
+ * castle-cdp-search — web search for pi through a Chrome that is already
+ * running somewhere on the wire network, driven over CDP.
+ *
+ * It does not launch a browser. It attaches to one that a person or a launchd
+ * job started, using the same endpoint configuration as pi-browser-harness
+ * (`BU_CDP_HTTP` > `/browser-target` > castle), so a single choice governs both
+ * packages and they can never end up driving different browsers.
+ *
+ * The tool is called `castle_cdp_search`, not `web_search`, on purpose: it is
+ * meant to sit alongside pi-web-access rather than shadow it. The model picks
+ * between them, so both stay available in one session.
+ */
+
+import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { StringEnum } from "@earendil-works/pi-ai";
+import { Type } from "typebox";
+
+import { messageOf } from "./errors.ts";
+import type { SearchResult } from "./extract.ts";
+import { formatResults, truncationNote } from "./format.ts";
+import {
+ MAX_CONCURRENT_SEARCHES,
+ SEARCH_TIMEOUT_MS,
+ type Recency,
+ type SearchPhase,
+ describeConfiguredEndpoint,
+ disconnect,
+ search,
+} from "./search.ts";
+
+const DEFAULT_NUM_RESULTS = 10;
+const MIN_NUM_RESULTS = 1;
+const MAX_NUM_RESULTS = 20;
+
+const parameters = Type.Object({
+ query: Type.String({
+ description: "The search query, phrased as you would type it into a search engine.",
+ minLength: 1,
+ }),
+ numResults: Type.Optional(
+ Type.Integer({
+ description: `How many results to return (${MIN_NUM_RESULTS}-${MAX_NUM_RESULTS}, default ${DEFAULT_NUM_RESULTS}).`,
+ minimum: MIN_NUM_RESULTS,
+ maximum: MAX_NUM_RESULTS,
+ default: DEFAULT_NUM_RESULTS,
+ }),
+ ),
+ // StringEnum, not Type.Union of Type.Literal: Google's API rejects the
+ // anyOf/const shape typebox emits for unions.
+ recency: Type.Optional(
+ StringEnum(["day", "week", "month", "year"] as const, {
+ description: "Restrict results to pages published within this window. Omit for no time limit.",
+ }),
+ ),
+});
+
+/** Exported so `isToolCallEventType<"castle_cdp_search", CastleCdpSearchInput>` can type it. */
+export type CastleCdpSearchInput = {
+ query: string;
+ numResults?: number;
+ recency?: Recency;
+};
+
+export type CastleCdpSearchDetails = {
+ query: string;
+ numResults: number;
+ recency: Recency | null;
+ phase: SearchPhase | "done";
+ note: string;
+ endpoint: string | null;
+ searchUrl: string | null;
+ results: SearchResult[];
+ truncated: boolean;
+};
+
+const initialDetails = (query: string, numResults: number, recency: Recency | null): CastleCdpSearchDetails => ({
+ query,
+ numResults,
+ recency,
+ phase: "queued",
+ note: "starting",
+ endpoint: null,
+ searchUrl: null,
+ results: [],
+ truncated: false,
+});
+
+export default function (pi: ExtensionAPI): void {
+ // Nothing is dialled here. pi runs extension factories in invocations that
+ // never start a session, so opening a socket from a factory would leave one
+ // behind on, say, `pi --list-models`. The connection is made on first use and
+ // torn down below.
+
+ pi.on("session_shutdown", async () => {
+ disconnect();
+ });
+
+ pi.registerTool({
+ name: "castle_cdp_search",
+ label: "Castle Search",
+ description:
+ "Search the web using a real Chrome browser running on the private network, driven over the Chrome " +
+ "DevTools Protocol. Returns ranked results with titles, URLs and snippets. Because it uses a real " +
+ "logged-in browser rather than a search API, it reaches pages that block datacentre traffic. It " +
+ "returns search results only — it does not fetch or read the linked pages, so follow up with a " +
+ "fetch/read tool for full page content. Output is truncated at " +
+ `${formatSize(DEFAULT_MAX_BYTES)} or ${DEFAULT_MAX_LINES} lines.`,
+ promptSnippet:
+ "Search the web through a real Chrome on the private network (castle_cdp_search); returns titles, URLs and snippets",
+ promptGuidelines: [
+ "Use castle_cdp_search to find pages on the open web when you need current information, documentation, " +
+ "or sources you do not already have — it drives a real signed-in Chrome, so it works on sites that " +
+ "reject scripted clients.",
+ "castle_cdp_search returns search results only. To read a result, follow it up with a tool that fetches " +
+ "page content; do not treat the snippet as the full page.",
+ "Pass recency to castle_cdp_search when the answer depends on how recent a page is, and keep numResults " +
+ "small (5-10) unless you genuinely need a wide sweep.",
+ "If castle_cdp_search fails with a SearchChallengeError, the browser is sitting behind a captcha or " +
+ "consent wall that only a person can clear: stop searching, tell the user the URL from the error, and " +
+ "ask them to complete the challenge in that browser before you retry.",
+ ],
+ parameters,
+
+ async execute(_toolCallId, params, signal, onUpdate, _ctx) {
+ const query = params.query.trim();
+ if (query === "") throw new Error("query must not be empty");
+
+ const numResults = Math.min(
+ MAX_NUM_RESULTS,
+ Math.max(MIN_NUM_RESULTS, params.numResults ?? DEFAULT_NUM_RESULTS),
+ );
+ const recency = (params.recency ?? null) as Recency | null;
+
+ const details = initialDetails(query, numResults, recency);
+
+ // One deadline covering connect + navigate + extract, plus the user's own
+ // abort so Esc drops an in-flight navigation rather than waiting it out.
+ const timeout = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
+
+ const report = (phase: SearchPhase, note: string): void => {
+ details.phase = phase;
+ details.note = note;
+ // Keep the TUI moving during the seconds Chrome spends navigating.
+ onUpdate?.({ content: [{ type: "text", text: `${note}…` }], details: { ...details } });
+ };
+
+ try {
+ const outcome = await search({ query, numResults, recency: recency ?? undefined }, combined, report);
+
+ const body = formatResults({
+ query,
+ searchUrl: outcome.searchUrl,
+ finalUrl: outcome.finalUrl,
+ endpoint: outcome.endpoint,
+ results: outcome.results,
+ });
+
+ const truncation = truncateHead(body, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
+ const text = truncation.truncated
+ ? truncation.content +
+ truncationNote(
+ truncation.outputLines,
+ truncation.totalLines,
+ formatSize(truncation.outputBytes),
+ formatSize(truncation.totalBytes),
+ )
+ : truncation.content;
+
+ details.phase = "done";
+ details.note = `${outcome.results.length} results`;
+ details.endpoint = outcome.endpoint;
+ details.searchUrl = outcome.searchUrl;
+ details.results = outcome.results;
+ details.truncated = truncation.truncated;
+
+ return { content: [{ type: "text", text }], details };
+ } catch (e) {
+ // Rethrow, always. pi only marks a tool result as an error when execute()
+ // throws — returning `{ error }` would read to the model as a search that
+ // simply found nothing, which is the one wrong conclusion to invite here.
+ // The endpoint is worth naming: "which browser failed" is most of the
+ // diagnosis when the same config can point at three different machines.
+ const endpoint = await describeConfiguredEndpoint().catch(() => "unknown endpoint");
+ const e2 = e instanceof Error ? e : new Error(messageOf(e));
+ // Naming the phase turns "it timed out" into something actionable:
+ // "queued" means the cap was the bottleneck, "navigating" means the
+ // browser or the network was.
+ e2.message =
+ `${e2.message} [castle_cdp_search: failed during "${details.phase}" — browser ${endpoint}, ` +
+ `up to ${MAX_CONCURRENT_SEARCHES} concurrent searches, ${SEARCH_TIMEOUT_MS / 1000}s deadline covering the whole call]`;
+ throw e2;
+ }
+ },
+ });
+}