@mrclrchtr/supi-web 7.1.0 → 7.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.
Files changed (49) hide show
  1. package/README.md +19 -5
  2. package/node_modules/@mrclrchtr/supi-core/README.md +127 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +72 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +232 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +308 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/debug.ts +14 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +64 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/index.ts +36 -0
  18. package/node_modules/@mrclrchtr/supi-core/src/llm.ts +356 -0
  19. package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
  20. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
  21. package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
  22. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  23. package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
  24. package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
  25. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
  26. package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
  27. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
  28. package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
  29. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +105 -0
  30. package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
  31. package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
  32. package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
  33. package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
  34. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  35. package/package.json +8 -3
  36. package/src/api.ts +1 -0
  37. package/src/config.ts +29 -0
  38. package/src/settings-registration.ts +63 -0
  39. package/src/tool/tool-specs.ts +21 -0
  40. package/src/tool/web_search/bx.ts +178 -0
  41. package/src/tool/web_search/execute.ts +39 -0
  42. package/src/tool/web_search/guidance.ts +6 -0
  43. package/src/tool/web_search/input.ts +91 -0
  44. package/src/tool/web_search/parse.ts +92 -0
  45. package/src/tool/web_search/register.ts +16 -0
  46. package/src/tool/web_search/render.ts +95 -0
  47. package/src/tool/web_search/result.ts +58 -0
  48. package/src/tool/web_search/spec.ts +18 -0
  49. package/src/web.ts +26 -3
@@ -0,0 +1,63 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ defineConfigSettings,
4
+ registerSettings,
5
+ type SettingsModule,
6
+ } from "@mrclrchtr/supi-core/settings";
7
+ import {
8
+ loadWebConfig,
9
+ WEB_CONFIG_SECTION,
10
+ WEB_DEFAULTS,
11
+ WEB_SEARCH_ENABLED_KEY,
12
+ } from "./config.ts";
13
+ import { isBxAvailable } from "./tool/web_search/bx.ts";
14
+
15
+ const RELOAD_NOTICE = "Web Search setting saved. Run /reload to apply this change.";
16
+
17
+ /** Register the Web Search setting without changing active tools. */
18
+ export function registerWebSettings(pi: ExtensionAPI, homeDir?: string): void {
19
+ const fixedSettings = defineConfigSettings({
20
+ id: WEB_CONFIG_SECTION,
21
+ label: "Web Search",
22
+ section: WEB_CONFIG_SECTION,
23
+ defaults: WEB_DEFAULTS,
24
+ fields: [
25
+ {
26
+ kind: "boolean" as const,
27
+ key: WEB_SEARCH_ENABLED_KEY,
28
+ label: "Web Search tool",
29
+ description: "Enable the web_search tool when bx is available on PATH",
30
+ },
31
+ ],
32
+ ...(homeDir ? { homeDir } : {}),
33
+ });
34
+
35
+ const settings: SettingsModule = {
36
+ ...fixedSettings,
37
+ apply: async (request) => {
38
+ const result = await fixedSettings.apply(request);
39
+ if (
40
+ request.fieldKey === WEB_SEARCH_ENABLED_KEY &&
41
+ loadWebConfig(request.cwd, homeDir).webSearchEnabled &&
42
+ !isBxAvailable()
43
+ ) {
44
+ return {
45
+ notice: {
46
+ message: `${RELOAD_NOTICE} bx is not available on PATH, so web_search will stay unavailable until bx is available.`,
47
+ level: "warning",
48
+ },
49
+ };
50
+ }
51
+
52
+ return {
53
+ ...result,
54
+ notice: {
55
+ message: RELOAD_NOTICE,
56
+ level: "info",
57
+ },
58
+ };
59
+ },
60
+ };
61
+
62
+ registerSettings(pi, settings);
63
+ }
@@ -32,6 +32,16 @@ import {
32
32
  WEB_FETCH_MD_TOOL_NAME,
33
33
  webFetchMdParameters,
34
34
  } from "./web_fetch_md/spec.ts";
35
+ import {
36
+ toolDescription as webSearchDescription,
37
+ promptGuidelines as webSearchGuidelines,
38
+ promptSnippet as webSearchSnippet,
39
+ } from "./web_search/guidance.ts";
40
+ import {
41
+ WEB_SEARCH_TOOL_LABEL,
42
+ WEB_SEARCH_TOOL_NAME,
43
+ webSearchParameters,
44
+ } from "./web_search/spec.ts";
35
45
 
36
46
  export type { WebDocsFetchInput } from "./web_docs_fetch/spec.ts";
37
47
  export { WEB_DOCS_FETCH_TOOL_NAME } from "./web_docs_fetch/spec.ts";
@@ -39,11 +49,14 @@ export type { WebDocsSearchInput } from "./web_docs_search/spec.ts";
39
49
  export { WEB_DOCS_SEARCH_TOOL_NAME } from "./web_docs_search/spec.ts";
40
50
  export type { WebFetchMdInput, WebFetchOutputMode } from "./web_fetch_md/spec.ts";
41
51
  export { WEB_FETCH_INLINE_MAX_CHARS, WEB_FETCH_MD_TOOL_NAME } from "./web_fetch_md/spec.ts";
52
+ export type { WebSearchInput } from "./web_search/spec.ts";
53
+ export { WEB_SEARCH_TOOL_NAME } from "./web_search/spec.ts";
42
54
 
43
55
  export const WEB_TOOL_NAMES = [
44
56
  WEB_FETCH_MD_TOOL_NAME,
45
57
  WEB_DOCS_SEARCH_TOOL_NAME,
46
58
  WEB_DOCS_FETCH_TOOL_NAME,
59
+ WEB_SEARCH_TOOL_NAME,
47
60
  ] as const;
48
61
  export type WebToolName = (typeof WEB_TOOL_NAMES)[number];
49
62
 
@@ -88,6 +101,14 @@ export const WEB_TOOL_SPECS = [
88
101
  promptGuidelines: docsFetchGuidelines,
89
102
  parameters: webDocsFetchParameters,
90
103
  },
104
+ {
105
+ name: WEB_SEARCH_TOOL_NAME,
106
+ label: WEB_SEARCH_TOOL_LABEL,
107
+ description: webSearchDescription,
108
+ promptSnippet: webSearchSnippet,
109
+ promptGuidelines: webSearchGuidelines,
110
+ parameters: webSearchParameters,
111
+ },
91
112
  ] as const satisfies readonly WebToolSpec[];
92
113
 
93
114
  export function getWebToolSpec(name: WebToolName): WebToolSpec {
@@ -0,0 +1,178 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants, statSync } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ import { StringDecoder } from "node:string_decoder";
5
+
6
+ interface BxRunOptions {
7
+ cwd: string;
8
+ signal?: AbortSignal;
9
+ }
10
+
11
+ interface BxProcessResult {
12
+ stdout: string;
13
+ exitCode: number | null;
14
+ }
15
+
16
+ type BxFailureKind = "cancelled" | "missing" | "start";
17
+
18
+ /** Return a safe message for a documented bx process exit category. */
19
+ export function formatBxExitFailure(exitCode: number | null): string {
20
+ switch (exitCode) {
21
+ case 1:
22
+ return "bx web search failed: client error. Check the query and request.";
23
+ case 2:
24
+ return "bx web search failed: CLI usage or compatibility error. Check the bx version.";
25
+ case 3:
26
+ return "bx web search failed: authentication or permission error. Check bx credentials and service access.";
27
+ case 4:
28
+ return "bx web search failed: rate limit reached. Try again later.";
29
+ case 5:
30
+ return "bx web search failed: server or network error. Check network access and try again later.";
31
+ default:
32
+ return `bx web search failed: unknown bx error (exit code ${exitCode ?? "unknown"}).`;
33
+ }
34
+ }
35
+
36
+ class BxProcessFailure extends Error {
37
+ constructor(readonly kind: BxFailureKind) {
38
+ super(kind);
39
+ }
40
+ }
41
+
42
+ /** Build the direct argv used for one bx context request. */
43
+ export function buildBxContextArgs(query: string, freshness?: string): string[] {
44
+ return [
45
+ "context",
46
+ ...(freshness === undefined ? [] : ["--extra", `freshness=${freshness}`]),
47
+ "--",
48
+ query,
49
+ ];
50
+ }
51
+
52
+ /** Check PATH without starting bx or reading its configuration. */
53
+ export function isBxAvailable(
54
+ pathValue = process.env.PATH,
55
+ platform = process.platform,
56
+ pathExtValue = process.env.PATHEXT,
57
+ ): boolean {
58
+ if (!pathValue) return false;
59
+
60
+ const names = platform === "win32" ? windowsCommandNames(pathExtValue) : ["bx"];
61
+ const pathDelimiter = platform === "win32" ? ";" : delimiter;
62
+ return pathValue
63
+ .split(pathDelimiter)
64
+ .some((directory) =>
65
+ names.some((name) => isExecutable(join(directory || ".", name), platform)),
66
+ );
67
+ }
68
+
69
+ /** Run one bx context request and decode its JSON stdout. */
70
+ export async function runBxContext(
71
+ query: string,
72
+ freshness: string | undefined,
73
+ options: BxRunOptions,
74
+ ): Promise<unknown> {
75
+ const result = await invokeBx(buildBxContextArgs(query, freshness), options);
76
+ if (result.exitCode !== 0) {
77
+ throw new Error(formatBxExitFailure(result.exitCode));
78
+ }
79
+
80
+ try {
81
+ return JSON.parse(result.stdout) as unknown;
82
+ } catch {
83
+ // biome-ignore lint/style/useErrorCause: Keep parser details out of tool errors.
84
+ throw new Error("bx returned invalid JSON for web search.");
85
+ }
86
+ }
87
+
88
+ function windowsCommandNames(pathExtValue: string | undefined): string[] {
89
+ const extensions = (pathExtValue ?? ".COM;.EXE;.BAT;.CMD")
90
+ .split(";")
91
+ .map((extension) => extension.trim())
92
+ .filter(Boolean);
93
+ return ["bx", ...extensions.map((extension) => `bx${extension}`)];
94
+ }
95
+
96
+ function isExecutable(filePath: string, platform: NodeJS.Platform): boolean {
97
+ try {
98
+ if (!statSync(filePath).isFile()) return false;
99
+ accessSync(filePath, platform === "win32" ? constants.F_OK : constants.X_OK);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ async function invokeBx(args: string[], options: BxRunOptions): Promise<BxProcessResult> {
107
+ if (options.signal?.aborted) throw new Error("Web search was cancelled.");
108
+
109
+ return new Promise<BxProcessResult>((resolve, reject) => {
110
+ const decoder = new StringDecoder("utf8");
111
+ let stdout = "";
112
+ let settled = false;
113
+ let cancelled = false;
114
+ let child: ReturnType<typeof spawn>;
115
+
116
+ try {
117
+ child = spawn("bx", args, {
118
+ cwd: options.cwd,
119
+ shell: false,
120
+ stdio: ["ignore", "pipe", "pipe"],
121
+ });
122
+ } catch {
123
+ reject(new BxProcessFailure("start"));
124
+ return;
125
+ }
126
+
127
+ const cleanup = () => {
128
+ options.signal?.removeEventListener("abort", onAbort);
129
+ };
130
+ const finish = (callback: () => void) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ cleanup();
134
+ callback();
135
+ };
136
+ const onAbort = () => {
137
+ cancelled = true;
138
+ try {
139
+ child.kill();
140
+ } catch {
141
+ // The process may have already exited.
142
+ }
143
+ finish(() => reject(new BxProcessFailure("cancelled")));
144
+ };
145
+
146
+ child.stdout?.on("data", (chunk: Buffer | string) => {
147
+ stdout += typeof chunk === "string" ? chunk : decoder.write(chunk);
148
+ });
149
+ child.stderr?.on("data", () => {
150
+ // Drain stderr. Its contents must not enter model-visible errors.
151
+ });
152
+ child.once("error", (error: NodeJS.ErrnoException) => {
153
+ finish(() => {
154
+ if (cancelled) {
155
+ reject(new BxProcessFailure("cancelled"));
156
+ } else if (error.code === "ENOENT") {
157
+ reject(new BxProcessFailure("missing"));
158
+ } else {
159
+ reject(new BxProcessFailure("start"));
160
+ }
161
+ });
162
+ });
163
+ child.once("close", (exitCode: number | null) => {
164
+ finish(() => resolve({ stdout: `${stdout}${decoder.end()}`, exitCode }));
165
+ });
166
+
167
+ if (options.signal) {
168
+ options.signal.addEventListener("abort", onAbort, { once: true });
169
+ }
170
+ }).catch((error: unknown): BxProcessResult => {
171
+ if (error instanceof BxProcessFailure) {
172
+ if (error.kind === "cancelled") throw new Error("Web search was cancelled.");
173
+ if (error.kind === "missing") throw new Error("bx executable was not found on PATH.");
174
+ throw new Error("Could not start bx for web search.");
175
+ }
176
+ throw new Error("Could not start bx for web search.");
177
+ });
178
+ }
@@ -0,0 +1,39 @@
1
+ import type {
2
+ AgentToolResult,
3
+ AgentToolUpdateCallback,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { limitModelVisibleOutput } from "../result.ts";
7
+ import { runBxContext } from "./bx.ts";
8
+ import { validateWebSearchInput } from "./input.ts";
9
+ import { parseBxResponse } from "./parse.ts";
10
+ import { buildSearchResult, formatSearchResults, type WebSearchDetails } from "./result.ts";
11
+
12
+ /** Execute one direct bx context search. */
13
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
14
+ export async function runWebSearch(
15
+ _toolCallId: string,
16
+ params: unknown,
17
+ signal: AbortSignal | undefined,
18
+ onUpdate: AgentToolUpdateCallback<Record<string, unknown>> | undefined,
19
+ ctx: ExtensionContext,
20
+ ): Promise<AgentToolResult<WebSearchDetails>> {
21
+ const input = validateWebSearchInput(params);
22
+ onUpdate?.({
23
+ content: [{ type: "text", text: "Searching the web..." }],
24
+ details: { status: "searching" },
25
+ });
26
+
27
+ const payload = await runBxContext(input.query, input.freshness, {
28
+ cwd: ctx.cwd,
29
+ ...(signal ? { signal } : {}),
30
+ });
31
+ const parsed = parseBxResponse(payload);
32
+ const markdown = formatSearchResults(parsed.sources);
33
+ const output = await limitModelVisibleOutput(markdown, {
34
+ tempPrefix: "web-search",
35
+ suffix: ".md",
36
+ });
37
+
38
+ return buildSearchResult(parsed.sources, output);
39
+ }
@@ -0,0 +1,6 @@
1
+ export const toolDescription =
2
+ "Search the public web and return source titles, URLs, and source excerpts for current or external information. It does not generate an answer.";
3
+
4
+ export const promptSnippet = "search public web sources";
5
+
6
+ export const promptGuidelines = ["Use web_search when current or external web sources are needed."];
@@ -0,0 +1,91 @@
1
+ import type { Static } from "typebox";
2
+ import { Type } from "typebox";
3
+
4
+ /** Short freshness filters accepted by the Brave context service. */
5
+ export const WEB_SEARCH_FRESHNESS_CODES = ["pd", "pw", "pm", "py"] as const;
6
+
7
+ /** Parameter schema for web_search. */
8
+ export const webSearchParameters = Type.Object(
9
+ {
10
+ query: Type.String({
11
+ minLength: 1,
12
+ description: "Search terms or a question for the public web",
13
+ }),
14
+ freshness: Type.Optional(
15
+ Type.String({
16
+ minLength: 1,
17
+ description:
18
+ "Freshness filter: pd (24 hours), pw (7 days), pm (31 days), py (365 days), or an ordered YYYY-MM-DDtoYYYY-MM-DD range. Brave uses the page publication or modification date.",
19
+ }),
20
+ ),
21
+ },
22
+ { additionalProperties: false },
23
+ );
24
+
25
+ /** Input type for web_search. */
26
+ export type WebSearchInput = Static<typeof webSearchParameters>;
27
+
28
+ /** Input after runtime validation. */
29
+ export interface ValidWebSearchInput {
30
+ query: string;
31
+ freshness?: string;
32
+ }
33
+
34
+ /** Validate tool input used by direct callers as well as Pi. */
35
+ export function validateWebSearchInput(params: unknown): ValidWebSearchInput {
36
+ if (!isRecord(params)) throw new Error("web_search input must be an object");
37
+
38
+ const query = typeof params.query === "string" ? params.query.trim() : "";
39
+ if (!query) throw new Error("'query' parameter is required");
40
+
41
+ const freshness = params.freshness;
42
+ if (freshness !== undefined) {
43
+ if (typeof freshness !== "string" || !isValidFreshness(freshness)) {
44
+ throw new Error(
45
+ "'freshness' must be pd, pw, pm, py, or an ordered YYYY-MM-DDtoYYYY-MM-DD range",
46
+ );
47
+ }
48
+ }
49
+
50
+ return { query, ...(freshness === undefined ? {} : { freshness }) };
51
+ }
52
+
53
+ /** Return whether a freshness value is documented and valid. */
54
+ export function isValidFreshness(value: string): boolean {
55
+ if ((WEB_SEARCH_FRESHNESS_CODES as readonly string[]).includes(value)) return true;
56
+
57
+ const match = /^(\d{4})-(\d{2})-(\d{2})to(\d{4})-(\d{2})-(\d{2})$/.exec(value);
58
+ if (!match) return false;
59
+
60
+ const [, startYear, startMonth, startDay, endYear, endMonth, endDay] = match;
61
+ if (
62
+ !isValidIsoDate(`${startYear}-${startMonth}-${startDay}`) ||
63
+ !isValidIsoDate(`${endYear}-${endMonth}-${endDay}`)
64
+ ) {
65
+ return false;
66
+ }
67
+
68
+ return value.slice(0, 10) <= value.slice(12);
69
+ }
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ /** Return whether a value is a real ISO calendar date. */
76
+ export function isValidIsoDate(value: string): boolean {
77
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
78
+ if (!match) return false;
79
+
80
+ const year = Number(match[1]);
81
+ const month = Number(match[2]);
82
+ const day = Number(match[3]);
83
+ if (year < 1 || month < 1 || month > 12 || day < 1) return false;
84
+
85
+ const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
86
+ return day <= (daysInMonth[month - 1] ?? 0);
87
+ }
88
+
89
+ function isLeapYear(year: number): boolean {
90
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
91
+ }
@@ -0,0 +1,92 @@
1
+ import { isValidIsoDate } from "./input.ts";
2
+
3
+ /** One source returned by the Web Search context response. */
4
+ export interface WebSearchSource {
5
+ title: string;
6
+ url: string;
7
+ excerpts: string[];
8
+ sourceDate?: string;
9
+ }
10
+
11
+ /** Parsed source data used by the Web Search result assembler. */
12
+ export interface ParsedWebSearchResponse {
13
+ sources: WebSearchSource[];
14
+ }
15
+
16
+ /** Validate the required bx response envelope and keep only source data. */
17
+ export function parseBxResponse(payload: unknown): ParsedWebSearchResponse {
18
+ const root = asRecord(payload);
19
+ const grounding = asRecord(root?.grounding);
20
+ if (!grounding || !Array.isArray(grounding.generic)) {
21
+ throw new Error("bx returned malformed web search data: grounding.generic is required.");
22
+ }
23
+
24
+ const sources = grounding.generic.map((value, index) =>
25
+ parseSource(value, index, findSourceDate(root, value)),
26
+ );
27
+ return { sources };
28
+ }
29
+
30
+ function parseSource(
31
+ value: unknown,
32
+ index: number,
33
+ sourceDate: string | undefined,
34
+ ): WebSearchSource {
35
+ const source = asRecord(value);
36
+ if (!source) throw new Error(`bx returned malformed web search source at index ${index}.`);
37
+
38
+ const title = requiredText(source.title, "title", index);
39
+ const url = requiredText(source.url, "url", index);
40
+ if (!Array.isArray(source.snippets)) {
41
+ throw new Error(
42
+ `bx returned malformed web search source at index ${index}: snippets is required.`,
43
+ );
44
+ }
45
+
46
+ return {
47
+ title,
48
+ url,
49
+ excerpts: source.snippets.map((snippet, snippetIndex) =>
50
+ parseSnippet(snippet, index, snippetIndex),
51
+ ),
52
+ ...(sourceDate ? { sourceDate } : {}),
53
+ };
54
+ }
55
+
56
+ function requiredText(value: unknown, field: string, index: number): string {
57
+ if (typeof value !== "string" || value.trim().length === 0) {
58
+ throw new Error(
59
+ `bx returned malformed web search source at index ${index}: ${field} is required.`,
60
+ );
61
+ }
62
+ return value.trim();
63
+ }
64
+
65
+ function parseSnippet(value: unknown, sourceIndex: number, snippetIndex: number): string {
66
+ if (typeof value === "string") return value;
67
+
68
+ throw new Error(
69
+ `bx returned malformed web search snippet at source ${sourceIndex}, index ${snippetIndex}.`,
70
+ );
71
+ }
72
+
73
+ function findSourceDate(root: Record<string, unknown> | null, source: unknown): string | undefined {
74
+ const sourceRecord = asRecord(source);
75
+ const url = typeof sourceRecord?.url === "string" ? sourceRecord.url : undefined;
76
+ const sources = asRecord(root?.sources);
77
+ const metadata = url ? asRecord(sources?.[url]) : null;
78
+ return findSafeDate(metadata?.age);
79
+ }
80
+
81
+ function findSafeDate(value: unknown): string | undefined {
82
+ if (typeof value === "string") return isValidIsoDate(value) ? value : undefined;
83
+ if (!Array.isArray(value)) return undefined;
84
+
85
+ return value.find((item): item is string => typeof item === "string" && isValidIsoDate(item));
86
+ }
87
+
88
+ function asRecord(value: unknown): Record<string, unknown> | null {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value)
90
+ ? (value as Record<string, unknown>)
91
+ : null;
92
+ }
@@ -0,0 +1,16 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { promptGuidelines, promptSnippet, toolDescription } from "./guidance.ts";
3
+ import { renderWebSearchCall, renderWebSearchResult } from "./render.ts";
4
+ import { webSearchSpec } from "./spec.ts";
5
+
6
+ /** Register the web_search tool. */
7
+ export function registerWebSearchTool(pi: ExtensionAPI): void {
8
+ pi.registerTool({
9
+ ...webSearchSpec,
10
+ description: toolDescription,
11
+ promptSnippet,
12
+ promptGuidelines: [...promptGuidelines],
13
+ renderCall: renderWebSearchCall,
14
+ renderResult: renderWebSearchResult,
15
+ });
16
+ }
@@ -0,0 +1,95 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { renderCollapsibleTextResult, renderToolCall } from "../render.ts";
3
+ import { WEB_SEARCH_TOOL_NAME, type WebSearchInput } from "./spec.ts";
4
+
5
+ /** Transcript renderer for web_search tool calls. */
6
+ export function renderWebSearchCall(args: unknown, theme: Theme) {
7
+ const input = (args ?? {}) as Partial<WebSearchInput>;
8
+ const query = typeof input.query === "string" ? truncatePreview(input.query) : "";
9
+ const freshness = typeof input.freshness === "string" ? input.freshness : undefined;
10
+ return renderToolCall(WEB_SEARCH_TOOL_NAME, query, theme, freshness);
11
+ }
12
+
13
+ /** Transcript renderer for web_search tool results. */
14
+ export function renderWebSearchResult(
15
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown },
16
+ { expanded, isPartial }: { expanded: boolean; isPartial: boolean },
17
+ theme: Theme,
18
+ context: { isError?: boolean } = {},
19
+ ) {
20
+ if (isPartial) {
21
+ return renderCollapsibleTextResult({
22
+ summary: theme.fg("warning", "Searching the web..."),
23
+ expanded,
24
+ theme,
25
+ });
26
+ }
27
+
28
+ if (context.isError) {
29
+ return renderCollapsibleTextResult({
30
+ summary: theme.fg("error", "Web search failed"),
31
+ expanded,
32
+ theme,
33
+ });
34
+ }
35
+
36
+ const details = readSearchDetails(result.details);
37
+ const summary = buildSearchSummary(details, theme);
38
+ const content = result.content.find((item) => item.type === "text");
39
+ const body =
40
+ details?.sourceCount === 0 ? undefined : content?.type === "text" ? content.text : undefined;
41
+
42
+ return renderCollapsibleTextResult({
43
+ summary,
44
+ body,
45
+ expanded,
46
+ theme,
47
+ fullOutputPath: details?.fullOutputPath,
48
+ });
49
+ }
50
+
51
+ function truncatePreview(text: string, maxChars = 48): string {
52
+ const compact = text.replace(/\s+/g, " ").trim();
53
+ if (compact.length <= maxChars) return compact;
54
+ return `${compact.slice(0, maxChars - 1).trimEnd()}…`;
55
+ }
56
+
57
+ interface RenderSearchDetails {
58
+ sourceCount: number;
59
+ truncated: boolean;
60
+ fullOutputPath?: string;
61
+ }
62
+
63
+ function readSearchDetails(value: unknown): RenderSearchDetails | undefined {
64
+ if (!isRecord(value)) return undefined;
65
+ const sourceCount = value.sourceCount;
66
+ if (typeof sourceCount !== "number" || !Number.isInteger(sourceCount) || sourceCount < 0) {
67
+ return undefined;
68
+ }
69
+
70
+ const truncation = isRecord(value.truncation) ? value.truncation : undefined;
71
+ const fullOutputPath = value.fullOutputPath;
72
+ return {
73
+ sourceCount,
74
+ truncated: truncation?.truncated === true,
75
+ ...(typeof fullOutputPath === "string" && fullOutputPath.length > 0 ? { fullOutputPath } : {}),
76
+ };
77
+ }
78
+
79
+ function buildSearchSummary(
80
+ details: RenderSearchDetails | undefined,
81
+ theme: { fg: (color: "success" | "warning" | "dim", text: string) => string },
82
+ ): string {
83
+ if (!details) return theme.fg("success", "Web search finished");
84
+
85
+ if (details.sourceCount === 0) return theme.fg("warning", "No web search sources found");
86
+
87
+ const noun = details.sourceCount === 1 ? "source" : "sources";
88
+ let summary = theme.fg("success", `Found ${details.sourceCount} ${noun}`);
89
+ if (details.truncated) summary += theme.fg("warning", " [truncated]");
90
+ return summary;
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
@@ -0,0 +1,58 @@
1
+ import type { AgentToolResult, TruncationResult } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelVisibleOutput } from "../result.ts";
3
+ import type { WebSearchSource } from "./parse.ts";
4
+
5
+ /** Human-facing and model-facing facts for one Web Search result. */
6
+ export interface WebSearchDetails extends Record<string, unknown> {
7
+ sourceCount: number;
8
+ excerptCount: number;
9
+ truncation?: TruncationResult;
10
+ fullOutputPath?: string;
11
+ }
12
+
13
+ /** Assemble a successful Web Search result. */
14
+ export function buildSearchResult(
15
+ sources: WebSearchSource[],
16
+ output: ModelVisibleOutput,
17
+ ): AgentToolResult<WebSearchDetails> {
18
+ return {
19
+ content: [{ type: "text", text: output.text }],
20
+ details: {
21
+ sourceCount: sources.length,
22
+ excerptCount: sources.reduce((count, source) => count + source.excerpts.length, 0),
23
+ truncation: output.truncation,
24
+ fullOutputPath: output.fullOutputPath,
25
+ },
26
+ };
27
+ }
28
+
29
+ /** Format all provider sources and excerpts as compact Markdown. */
30
+ export function formatSearchResults(sources: WebSearchSource[]): string {
31
+ if (sources.length === 0) return "No web search sources found.";
32
+
33
+ const lines = ["Source excerpts. Dates are provider-reported publication or modification dates."];
34
+ for (const source of sources) {
35
+ lines.push("", `### [${escapeLinkText(source.title)}](<${escapeUrl(source.url)}>)`);
36
+ if (source.sourceDate) lines.push(`Source date: ${source.sourceDate}`);
37
+ for (const excerpt of source.excerpts) lines.push("", formatExcerpt(excerpt));
38
+ }
39
+
40
+ return lines.join("\n");
41
+ }
42
+
43
+ function formatExcerpt(excerpt: string): string {
44
+ const lines = excerpt.split(/\r?\n/);
45
+ return lines.map((line) => `> ${line}`).join("\n");
46
+ }
47
+
48
+ function escapeLinkText(value: string): string {
49
+ return value
50
+ .replace(/\\/g, "\\\\")
51
+ .replace(/\[/g, "\\[")
52
+ .replace(/\]/g, "\\]")
53
+ .replace(/\r?\n/g, " ");
54
+ }
55
+
56
+ function escapeUrl(value: string): string {
57
+ return value.replace(/\\/g, "\\\\").replace(/>/g, "\\>").replace(/\r?\n/g, "");
58
+ }