import { Type } from "typebox"; import { HttpClient, HttpError, HttpStatusError } from "../shared/http"; import { pyGet } from "../shared/pyget"; import { isFetchEnabled } from "../shared/env"; import { chunkAndSerializeResponse, serializeResponseWithMeta } from "../shared/serialize"; import { chunkingManager } from "../shared/instances"; import { extractSections, prioritizeSections } from "../shared/content"; import { BaseProvider, type HttpClientFactory, type ProviderMetadata, type ProviderResult, type ToolDefinition } from "./base"; const DEFAULT_MAX_BYTES = 20480; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function cleanRepositoryUrl(repoUrl: string | null): string | null { if (!repoUrl) { return null; } let cleaned = repoUrl; if (cleaned.startsWith("git+")) { cleaned = cleaned.slice(4); } if (cleaned.endsWith(".git")) { cleaned = cleaned.slice(0, -4); } return cleaned; } function truncateUtf8ToBytes(text: string, maxBytes: number): string { if (!text) { return ""; } const encoded = Buffer.from(text, "utf8"); if (encoded.length <= maxBytes) { return text; } let sliceLength = maxBytes; const decoder = new TextDecoder("utf-8", { fatal: true }); while (sliceLength > 0) { try { return decoder.decode(encoded.subarray(0, sliceLength)); } catch { sliceLength -= 1; } } return ""; } export class NpmProvider extends BaseProvider { constructor(httpClientFactory: HttpClientFactory) { super(httpClientFactory); } getMetadata(): ProviderMetadata { const toolNames = ["npm_metadata"]; if (isFetchEnabled()) { toolNames.push("fetch_npm_docs"); } return { name: "npm", description: "npm package registry metadata and documentation", exposeAsTool: true, toolNames, supportsLibrarySearch: true, requiredEnvVars: [], optionalEnvVars: [], toolTiers: { npm_metadata: { tier: 2, deferRecommended: true, category: "metadata", }, fetch_npm_docs: { tier: 3, deferRecommended: true, category: "fetch", }, }, }; } async searchLibrary(library: string, _limit = 5): Promise { try { const data = await this._fetchMetadata(library); return { success: true, data, providerName: "npm" }; } catch (error) { if (error instanceof HttpStatusError) { const httpError = error; if (httpError.response.status === 404) { return { success: false, error: null, providerName: "npm" }; } return { success: false, error: `npm registry returned ${httpError.response.status}`, providerName: "npm" }; } if (error instanceof HttpError) { return { success: false, error: `npm registry request failed: ${error}`, providerName: "npm" }; } return { success: false, error: `npm registry request failed: ${error instanceof Error ? error : String(error)}`, providerName: "npm" }; } } async _fetchMetadata(packageName: string): Promise> { const client = (await this.httpClient()) as HttpClient; const response = await client.get(`https://registry.npmjs.org/${packageName}`); response.raiseForStatus(); const payload = JSON.parse(response.text) as Record; const repository = payload.repository; let repoUrl: string | null = null; if (isPlainObject(repository) && typeof repository.url === "string") { repoUrl = repository.url; } else if (typeof repository === "string") { repoUrl = repository; } repoUrl = cleanRepositoryUrl(repoUrl); const maintainers: Array<{ name: unknown; email: unknown }> = []; const rawMaintainers = Array.isArray(payload.maintainers) ? payload.maintainers : []; for (const maintainer of rawMaintainers) { if (isPlainObject(maintainer)) { maintainers.push({ name: maintainer.name, email: maintainer.email, }); } } const homepage = payload.homepage; return { name: payload.name ?? null, summary: payload.description ?? "", version: payload.version ?? null, home_page: homepage ?? null, docs_url: homepage ?? null, repository: repoUrl, license: payload.license ?? null, keywords: pyGet(payload, "keywords", []), maintainers, author: payload.author ?? null, readme: pyGet(payload, "readme", "") as string, }; } async _fetchNpmDocs(packageName: string, maxBytes = DEFAULT_MAX_BYTES): Promise> { try { const client = (await this.httpClient()) as HttpClient; const response = await client.get(`https://registry.npmjs.org/${packageName}`); response.raiseForStatus(); const data = JSON.parse(response.text) as Record; const rawContent = typeof data.readme === "string" ? data.readme : ""; let content = rawContent; let source: "npm" | "npm_minimal" = "npm"; if (!content || content.trim().length < 100) { content = `# ${packageName}\n\n${data.description ?? "No description available."}\n\n`; source = "npm_minimal"; } const sections = extractSections(content); let finalContent = content; if (sections.length > 0) { finalContent = prioritizeSections(sections, maxBytes); } else if (Buffer.byteLength(content, "utf8") > maxBytes) { finalContent = truncateUtf8ToBytes(content, maxBytes); } const originalBytes = Buffer.byteLength(content, "utf8"); return { package: packageName, content: finalContent, size_bytes: Buffer.byteLength(finalContent, "utf8"), source, truncated: originalBytes > maxBytes, version: data.version ?? null, }; } catch (error) { if (error instanceof HttpStatusError) { const httpError = error; return { package: packageName, content: "", error: `npm registry returned ${httpError.response.status}`, size_bytes: 0, source: null, }; } return { package: packageName, content: "", error: `Failed to fetch docs: ${error instanceof Error ? error.message : String(error)}`, size_bytes: 0, source: null, }; } } getTools(): Record { const npmMetadata: ToolDefinition = { name: "npm_metadata", label: "npm_metadata", description: "Get npm package metadata (name, version, URLs). For docs content, use fetch_npm_docs.", promptSnippet: 'npm_metadata("express")', parameters: Type.Object({ package: Type.String(), }), execute: async (_id: string, { package: packageName }: { package: string }) => { const result = await this._fetchMetadata(packageName); return serializeResponseWithMeta(result); }, }; const tools: Record = { npm_metadata: npmMetadata, }; if (isFetchEnabled()) { tools.fetch_npm_docs = { name: "fetch_npm_docs", label: "fetch_npm_docs", description: "Fetch npm package README docs. Already in Markdown format.", promptSnippet: 'fetch_npm_docs("axios")', parameters: Type.Object({ package: Type.String(), max_bytes: Type.Optional(Type.Number()), }), execute: async (_id: string, { package: packageName, max_bytes }: { package: string; max_bytes?: number }) => { const result = await this._fetchNpmDocs(packageName, max_bytes ?? DEFAULT_MAX_BYTES); return chunkAndSerializeResponse(result, "content", chunkingManager); }, }; } return tools; } }